From 0042ae6d9dffaab5f4b12fe08c7168f4a4496610 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 08:29:41 +0900 Subject: [PATCH] refactor: remove dead code and align with effect best practices - Remove unused runSafeSync from effectErrorHandler (dead code) - Unify path aliases to domain-specific form (@/ -> @errors, @models) - Add @errors/* path to tsconfig to match documented alias scheme - Extract retryable error detection helper in defaultFetcher - Adopt ParseResult TreeFormatter/ArrayFormatter in decodeWithBadRequest - Remove redundant WHAT/section comments; keep TSDoc and WHY-only Verified with pnpm lint, pnpm test (277/277), pnpm build, and @effect/language-service diagnostics (0 errors / 0 warnings / 88 files). Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- CLAUDE.md | 2 +- src/lib/defaultFetcher.ts | 38 +++++--- src/lib/effectErrorHandler.ts | 10 --- src/lib/schemaUtils.ts | 10 ++- .../base/kakao/kakaoAlimtalkTemplate.ts | 2 +- src/models/base/kakao/kakaoChannel.ts | 2 +- src/models/base/messages/message.ts | 2 +- src/models/base/naver/naverOption.ts | 2 - src/models/index.ts | 7 -- .../requests/messages/getMessagesRequest.ts | 2 - src/services/messages/groupService.ts | 8 +- src/types/commonTypes.ts | 14 --- test/lib/effectErrorHandler.test.ts | 87 ++++++------------- tsconfig.json | 3 +- 15 files changed, 71 insertions(+), 120 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d2c7c410..25478b78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,7 @@ Schema.String.pipe( | File | Purpose | |------|---------| | `defaultFetcher.ts` | HTTP client — Effect.gen, retry 3x exponential backoff, Match | -| `effectErrorHandler.ts` | `runSafePromise`, `runSafeSync`, `unwrapCause` | +| `effectErrorHandler.ts` | `runSafePromise`, `unwrapCause` | | `authenticator.ts` | HMAC-SHA256 auth header | | `stringifyQuery.ts` | URL query string builder (array handling) | | `fileToBase64.ts` | File/URL → Base64 | diff --git a/CLAUDE.md b/CLAUDE.md index bbc34bbb..7b46100f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ pnpm docs # Generate TypeDoc documentation - 에러: `Data.TaggedError` + environment-aware `toString()` - 비동기: `Effect.gen` + `Effect.tryPromise` - 검증: Effect Schema (`Schema.filter`, `Schema.transform`) -- Promise 변환: `runSafePromise()` / `runSafeSync()` +- Promise 변환: `runSafePromise()` ### Path Aliases ``` diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 088cc253..1160fdc3 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -14,6 +14,14 @@ type DefaultRequest = { method: string; }; +const ERROR_MESSAGE_PREVIEW_LENGTH = 200; +const RETRYABLE_ERROR_KEYWORDS = [ + 'aborted', + 'refused', + 'reset', + 'econn', +] as const; + class RetryableError extends Data.TaggedError('RetryableError')<{ readonly error?: unknown; }> {} @@ -21,6 +29,16 @@ class RetryableError extends Data.TaggedError('RetryableError')<{ const toMessage = (e: unknown): string => e instanceof Error ? e.message : String(e); +const isRetryableNetworkError = (error: Error): boolean => { + const cause = error.cause; + const causeCode = + cause && typeof cause === 'object' && 'code' in cause + ? String(cause.code) + : ''; + const message = `${error.message} ${causeCode}`.toLowerCase(); + return RETRYABLE_ERROR_KEYWORDS.some(keyword => message.includes(keyword)); +}; + const makeParseError = (res: Response, message: string) => new DefaultError({ errorCode: 'ParseError', @@ -62,7 +80,9 @@ const handleClientErrorResponse = (res: Response) => Effect.flatMap(text => { const genericError = new ClientError({ errorCode: `HTTP_${res.status}`, - errorMessage: text.substring(0, 200) || 'Client error occurred', + errorMessage: + text.substring(0, ERROR_MESSAGE_PREVIEW_LENGTH) || + 'Client error occurred', httpStatus: res.status, url: res.url, }); @@ -148,7 +168,8 @@ const handleServerErrorResponse = (res: Response) => const genericError = makeError( `HTTP_${res.status}`, - text.substring(0, 200) || 'Server error occurred', + text.substring(0, ERROR_MESSAGE_PREVIEW_LENGTH) || + 'Server error occurred', ); return parseServerErrorBody(text, genericError, makeError); @@ -191,18 +212,7 @@ export function defaultFetcherEffect( }), catch: (error: unknown) => { if (error instanceof Error) { - const cause = error.cause; - const causeCode = - cause && typeof cause === 'object' && 'code' in cause - ? String(cause.code) - : ''; - const message = (error.message + ' ' + causeCode).toLowerCase(); - const isRetryable = - message.includes('aborted') || - message.includes('refused') || - message.includes('reset') || - message.includes('econn'); - if (isRetryable) { + if (isRetryableNetworkError(error)) { return new RetryableError({error}); } return new NetworkError({ diff --git a/src/lib/effectErrorHandler.ts b/src/lib/effectErrorHandler.ts index f7a83362..f2f97c3f 100644 --- a/src/lib/effectErrorHandler.ts +++ b/src/lib/effectErrorHandler.ts @@ -76,16 +76,6 @@ const unwrapCause = (cause: Cause.Cause): unknown => { return new UnhandledExitError({message}); }; -export const runSafeSync = (effect: Effect.Effect): A => { - const exit = Effect.runSyncExit(effect); - return Exit.match(exit, { - onFailure: cause => { - throw unwrapCause(cause); - }, - onSuccess: value => value, - }); -}; - export const runSafePromise = ( effect: Effect.Effect, ): Promise => { diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 0fccd7f6..27196cc4 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -1,4 +1,4 @@ -import {Schema} from 'effect'; +import {ParseResult, Schema} from 'effect'; import * as Effect from 'effect/Effect'; import {BadRequestError, InvalidDateError} from '../errors/defaultError'; import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer'; @@ -6,6 +6,8 @@ import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer'; /** * Schema 디코딩 + BadRequestError 변환을 결합한 Effect 헬퍼. * 서비스 레이어에서 반복되는 검증 패턴을 통일합니다. + * Effect 공식 ParseResult 포맷터(TreeFormatter/ArrayFormatter)로 + * 에러 경로를 구조화하여 디버깅 가능성을 높입니다. */ export const decodeWithBadRequest = ( schema: Schema.Schema, @@ -15,7 +17,11 @@ export const decodeWithBadRequest = ( Schema.decodeUnknown(schema)(data), error => new BadRequestError({ - message: error.message, + message: ParseResult.TreeFormatter.formatErrorSync(error), + validationErrors: ParseResult.ArrayFormatter.formatErrorSync(error).map( + issue => + `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`, + ), }), ); diff --git a/src/models/base/kakao/kakaoAlimtalkTemplate.ts b/src/models/base/kakao/kakaoAlimtalkTemplate.ts index b279846b..ca09cb96 100644 --- a/src/models/base/kakao/kakaoAlimtalkTemplate.ts +++ b/src/models/base/kakao/kakaoAlimtalkTemplate.ts @@ -1,7 +1,7 @@ +import {type InvalidDateError} from '@errors/defaultError'; import {safeDateTransfer} from '@lib/schemaUtils'; import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; -import {type InvalidDateError} from '@/errors/defaultError'; import {kakaoAlimtalkTemplateQuickReplySchema} from './kakaoAlimtalkTemplateQuickReply'; import {kakaoButtonSchema} from './kakaoButton'; import {type KakaoChannelCategory} from './kakaoChannel'; diff --git a/src/models/base/kakao/kakaoChannel.ts b/src/models/base/kakao/kakaoChannel.ts index b9886654..084d5372 100644 --- a/src/models/base/kakao/kakaoChannel.ts +++ b/src/models/base/kakao/kakaoChannel.ts @@ -1,7 +1,7 @@ +import {type InvalidDateError} from '@errors/defaultError'; import {safeDateTransfer} from '@lib/schemaUtils'; import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; -import {type InvalidDateError} from '@/errors/defaultError'; /** * @description 카카오 채널 카테고리 타입 diff --git a/src/models/base/messages/message.ts b/src/models/base/messages/message.ts index ee00f7f4..d743250c 100644 --- a/src/models/base/messages/message.ts +++ b/src/models/base/messages/message.ts @@ -1,8 +1,8 @@ import {baseKakaoOptionSchema} from '@models/base/kakao/kakaoOption'; import {naverOptionSchema} from '@models/base/naver/naverOption'; import {rcsOptionSchema} from '@models/base/rcs/rcsOption'; +import {voiceOptionSchema} from '@models/requests/voice/voiceOption'; import {Schema} from 'effect'; -import {voiceOptionSchema} from '@/models/requests/voice/voiceOption'; export const messageTypeSchema = Schema.Literal( 'SMS', diff --git a/src/models/base/naver/naverOption.ts b/src/models/base/naver/naverOption.ts index 24b31732..9ab31fc4 100644 --- a/src/models/base/naver/naverOption.ts +++ b/src/models/base/naver/naverOption.ts @@ -1,6 +1,5 @@ import {Schema} from 'effect'; -// 네이버 스마트 알림 naverOptions 버튼 스키마 const naverOptionButtonSchema = Schema.Struct({ buttonName: Schema.String, buttonType: Schema.String, @@ -10,7 +9,6 @@ const naverOptionButtonSchema = Schema.Struct({ linkIos: Schema.optional(Schema.String), }); -// naverOptions 최상위 스키마 export const naverOptionSchema = Schema.Struct({ talkId: Schema.String, templateId: Schema.String, diff --git a/src/models/index.ts b/src/models/index.ts index 282abea5..a2ceee66 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,7 +1,5 @@ -// Base Models - Kakao BMS export * from './base/kakao/bms'; -// Base Models - Kakao export { decodeKakaoAlimtalkTemplate, type KakaoAlimtalkTemplate, @@ -67,12 +65,10 @@ export { messageSchema, messageTypeSchema, } from './base/messages/message'; -// Base Models - Naver export { type NaverOptionSchema, naverOptionSchema, } from './base/naver/naverOption'; -// Base Models - RCS export { type RcsButton, type RcsButtonSchema, @@ -88,8 +84,5 @@ export { rcsOptionSchema, } from './base/rcs/rcsOption'; -// Requests export * from './requests/index'; - -// Responses export * from './responses/index'; diff --git a/src/models/requests/messages/getMessagesRequest.ts b/src/models/requests/messages/getMessagesRequest.ts index ff2dc3f3..47f53a55 100644 --- a/src/models/requests/messages/getMessagesRequest.ts +++ b/src/models/requests/messages/getMessagesRequest.ts @@ -20,7 +20,6 @@ const baseGetMessagesRequestSchema = Schema.Struct({ endDate: Schema.optional(Schema.Union(Schema.String, Schema.DateFromSelf)), }); -// dateType은 startDate 또는 endDate가 함께 제공될 때만 유효 export const getMessagesRequestSchema = baseGetMessagesRequestSchema.pipe( Schema.filter(data => { const hasDate = data.startDate != null || data.endDate != null; @@ -59,7 +58,6 @@ export type GetMessagesRequest = | GetMessagesRequestWithStartDate | GetMessagesRequestWithEndDate; -// 스키마 디코딩 결과 타입 (런타임 검증 후 내부에서 사용) type GetMessagesRequestDecoded = Schema.Schema.Type< typeof getMessagesRequestSchema >; diff --git a/src/services/messages/groupService.ts b/src/services/messages/groupService.ts index 0528d071..0cd62910 100644 --- a/src/services/messages/groupService.ts +++ b/src/services/messages/groupService.ts @@ -15,6 +15,10 @@ import { ScheduledDateSendingRequest, } from '@models/requests/messages/groupMessageRequest'; import {osPlatform, sdkVersion} from '@models/requests/messages/requestConfig'; +import { + type RequestSendMessagesSchema, + requestSendMessageSchema, +} from '@models/requests/messages/sendMessage'; import { AddMessageResponse, GetGroupsResponse, @@ -23,10 +27,6 @@ import { RemoveGroupMessagesResponse, } from '@models/responses/messageResponses'; import * as Effect from 'effect/Effect'; -import { - type RequestSendMessagesSchema, - requestSendMessageSchema, -} from '@/models/requests/messages/sendMessage'; import DefaultService from '../defaultService'; /** diff --git a/src/types/commonTypes.ts b/src/types/commonTypes.ts index 17870756..83ca6d7a 100644 --- a/src/types/commonTypes.ts +++ b/src/types/commonTypes.ts @@ -1,7 +1,5 @@ import {Schema} from 'effect'; -// --- Count & Charge Types --- - export const countSchema = Schema.Struct({ total: Schema.Number, sentTotal: Schema.Number, @@ -62,8 +60,6 @@ export type MessageTypeRecord = Schema.Schema.Type< typeof messageTypeRecordSchema >; -// --- App & Log --- - export const appSchema = Schema.Struct({ profit: messageTypeRecordSchema, appId: Schema.NullishOr(Schema.String), @@ -75,8 +71,6 @@ export const logSchema = Schema.Array( ); export type Log = Schema.Schema.Type; -// --- Group --- - export const groupIdSchema = Schema.String; export type GroupId = Schema.Schema.Type; @@ -101,13 +95,9 @@ export const groupSchema = Schema.Struct({ }); export type Group = Schema.Schema.Type; -// --- Handle Key --- - export const handleKeySchema = Schema.String; export type HandleKey = Schema.Schema.Type; -// --- Black (080 수신거부) --- - export const blackSchema = Schema.Struct({ handleKey: handleKeySchema, type: Schema.Literal('DENIAL'), @@ -118,8 +108,6 @@ export const blackSchema = Schema.Struct({ }); export type Black = Schema.Schema.Type; -// --- Block Group --- - export const blockGroupSchema = Schema.Struct({ blockGroupId: Schema.String, accountId: Schema.String, @@ -132,8 +120,6 @@ export const blockGroupSchema = Schema.Struct({ }); export type BlockGroup = Schema.Schema.Type; -// --- Block Number --- - export const blockNumberSchema = Schema.Struct({ blockNumberId: Schema.String, accountId: Schema.String, diff --git a/test/lib/effectErrorHandler.test.ts b/test/lib/effectErrorHandler.test.ts index bc280e5f..4f0e468a 100644 --- a/test/lib/effectErrorHandler.test.ts +++ b/test/lib/effectErrorHandler.test.ts @@ -6,38 +6,50 @@ import { UnexpectedDefectError, UnhandledExitError, } from '../../src/errors/defaultError'; -import {runSafePromise, runSafeSync} from '../../src/lib/effectErrorHandler'; +import {runSafePromise} from '../../src/lib/effectErrorHandler'; -describe('runSafeSync', () => { - it('should return value on success', () => { - const result = runSafeSync(Effect.succeed(42)); - expect(result).toBe(42); +describe('runSafePromise', () => { + it('should resolve on success', async () => { + const result = await runSafePromise(Effect.succeed('ok')); + expect(result).toBe('ok'); + }); + + it('should reject with original TaggedError on expected failure', async () => { + const effect = Effect.fail(new ApiKeyError({message: 'bad key'})); + await expect(runSafePromise(effect)).rejects.toThrow('bad key'); + try { + await runSafePromise(effect); + } catch (e) { + expect((e as ApiKeyError)._tag).toBe('ApiKeyError'); + expect(e).toBeInstanceOf(Error); + } }); - it('should throw original TaggedError on expected failure', () => { + it('should reject with BadRequestError preserving original fields', async () => { const effect = Effect.fail(new BadRequestError({message: '잘못된 요청'})); - expect(() => runSafeSync(effect)).toThrow('잘못된 요청'); try { - runSafeSync(effect); + await runSafePromise(effect); } catch (e) { - expect((e as BadRequestError)._tag).toBe('BadRequestError'); + const err = e as BadRequestError; + expect(err._tag).toBe('BadRequestError'); + expect(err.message).toBe('잘못된 요청'); } }); - it('should throw UnexpectedDefectError for non-Error defects', () => { - const effect = Effect.die('unexpected string defect'); + it('should reject with UnexpectedDefectError for non-Error defects', async () => { + const effect = Effect.die({weird: 'object'}); try { - runSafeSync(effect); + await runSafePromise(effect); } catch (e) { expect((e as UnexpectedDefectError)._tag).toBe('UnexpectedDefectError'); } }); - it('should handle defect with non-string _tag as generic object', () => { + it('should handle defect with non-string _tag as generic object', async () => { expect.assertions(2); const effect = Effect.die({_tag: 42, message: 'numeric tag'}); try { - runSafeSync(effect); + await runSafePromise(effect); } catch (e) { const err = e as UnexpectedDefectError; expect(err._tag).toBe('UnexpectedDefectError'); @@ -45,11 +57,11 @@ describe('runSafeSync', () => { } }); - it('should handle tagged defect without message property', () => { + it('should handle tagged defect without message property', async () => { expect.assertions(2); const effect = Effect.die({_tag: 'CustomTag'}); try { - runSafeSync(effect); + await runSafePromise(effect); } catch (e) { const err = e as UnexpectedDefectError; expect(err._tag).toBe('UnexpectedDefectError'); @@ -57,49 +69,6 @@ describe('runSafeSync', () => { } }); - it('should throw original Error for Error defects', () => { - const originalError = new TypeError('type mismatch'); - const effect = Effect.die(originalError); - expect(() => runSafeSync(effect)).toThrow(originalError); - }); - - it('should throw UnhandledExitError for interrupted effects', () => { - const effect = Effect.interrupt; - try { - runSafeSync(effect); - } catch (e) { - expect((e as UnhandledExitError)._tag).toBe('UnhandledExitError'); - expect(e).toBeInstanceOf(Error); - } - }); -}); - -describe('runSafePromise', () => { - it('should resolve on success', async () => { - const result = await runSafePromise(Effect.succeed('ok')); - expect(result).toBe('ok'); - }); - - it('should reject with original TaggedError on expected failure', async () => { - const effect = Effect.fail(new ApiKeyError({message: 'bad key'})); - await expect(runSafePromise(effect)).rejects.toThrow('bad key'); - try { - await runSafePromise(effect); - } catch (e) { - expect((e as ApiKeyError)._tag).toBe('ApiKeyError'); - expect(e).toBeInstanceOf(Error); - } - }); - - it('should reject with UnexpectedDefectError for non-Error defects', async () => { - const effect = Effect.die({weird: 'object'}); - try { - await runSafePromise(effect); - } catch (e) { - expect((e as UnexpectedDefectError)._tag).toBe('UnexpectedDefectError'); - } - }); - it('should reject with original Error for Error defects', async () => { const originalError = new RangeError('out of range'); const effect = Effect.die(originalError); diff --git a/tsconfig.json b/tsconfig.json index a35f4aad..5a46f41f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,7 +38,8 @@ "@models/requests/messages/*": ["src/models/requests/messages/*"], "@lib/*": ["src/lib/*"], "@internal-types/*": ["src/types/*"], - "@services/*": ["src/services/*"] + "@services/*": ["src/services/*"], + "@errors/*": ["src/errors/*"] }, /* Additional Type Checking */