diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml new file mode 100644 index 0000000..b229109 --- /dev/null +++ b/.github/workflows/deploy-test.yml @@ -0,0 +1,47 @@ +name: Deploy to AWS Lambda Test + +on: + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + ECR_CRAWL_REPO_NAME: dev/crawl + LAMBDA_FUNCTION_NAME: withins-playwright-crawler-test + +jobs: + deploy: + name: Deploy to AWS Lambda + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ECR_LAMBDA_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_ECR_LAMBDA_SECRET_ACCESS_KEY_ID }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push Docker image + id: build-image + env: + REGISTRY: ${{ steps.login-ecr.outputs.registry }} + REPOSITORY: ${{ env.ECR_CRAWL_REPO_NAME }} + IMAGE_TAG: ${{ github.sha }} + run: | + IMAGE_URI=$REGISTRY/$REPOSITORY:$IMAGE_TAG + docker build -t $IMAGE_URI . + docker push $IMAGE_URI + echo "image-uri=$IMAGE_URI" >> $GITHUB_OUTPUT + + - name: Deploy to Lambda + uses: int128/deploy-lambda-action@v1 + with: + function-name: ${{ env.LAMBDA_FUNCTION_NAME }} + image-uri: ${{ steps.build-image.outputs.image-uri }} \ No newline at end of file diff --git a/CrawlingService_temp.ts b/CrawlingService_temp.ts deleted file mode 100644 index 3f6f05e..0000000 --- a/CrawlingService_temp.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Browser } from 'playwright-core'; -import { chromium } from 'playwright-core'; -import { JobRegistry } from '../../entity/job/JobRegistry'; -import { Job } from '../../entity/job/Job'; -import { JobExecutor } from '../../entity/job/JobExecutor'; -import { getKoreaTimeISO } from '../../utils/DateUtils'; -import { CrawlingEvent } from './handler'; -import { validateJobName } from './LambdaEventValidator'; -import { TargetDate } from '../../entity/TargetDate'; -import { - Result, - withErrorHandling, - withSyncErrorHandling, - isSuccess, - isFailure, - success, - failure, -} from '../../utils/ErrorHandling'; -import { AppError } from '../../errors/AppError'; -import { ERROR_MESSAGES } from '../../constants/ErrorMessages'; -import { OPERATION_CONTEXT } from '../../constants/OperationContext'; - -const chromiumBinary = require('@sparticuz/chromium'); - -export interface CrawlingResult { - processedJobs: string[]; - results: any[]; // 크롤링된 실제 데이터 - itemCount: number; -} - -export class CrawlingService { - private browser: Browser | null = null; - private jobExecutor: JobExecutor | null = null; - - async executeCrawling(targetDate: TargetDate, jobName: string): Promise> { - const startTime = Date.now(); - console.log(`크롤링 시작 at ${getKoreaTimeISO()}`); - - try { - // jobName 검증 (TargetDate는 이미 검증됨) - validateJobName(jobName); - const parsedDate = targetDate.dateObject; - - // 1단계: 브라우저 초기화 - const browserResult = await this.initializeBrowser(); - if (isFailure(browserResult)) { - return failure( - new AppError( - ERROR_MESSAGES.BROWSER_INIT_FAILED, - OPERATION_CONTEXT.BROWSER_INIT, - browserResult.error instanceof Error ? browserResult.error : undefined, - { targetDate: targetDate.value, jobName } - ), - OPERATION_CONTEXT.BROWSER_INIT - ); - } - - // 2단계: Job 찾기 - const jobResult = this.findJob(jobName); - if (isFailure(jobResult)) { - return failure(jobResult.error, OPERATION_CONTEXT.JOB_LOOKUP); - } - - // 3단계: JobExecutor 실행 - this.jobExecutor = new JobExecutor(this.browser!); - const executionResult = await this.executeJob(jobResult.data, { - targetDate: parsedDate, - }); - - if (isFailure(executionResult)) { - return failure(executionResult.error, OPERATION_CONTEXT.JOB_EXECUTION); - } - - const endTime = Date.now(); - console.log(`Crawling completed in ${endTime - startTime}ms`); - - return success({ - processedJobs: executionResult.data.processedJobs, - results: executionResult.data.results, - itemCount: executionResult.data.itemCount, - }); - } finally { - await this.cleanup(); - } - } - - // HOF로 래핑된 브라우저 초기화 - private initializeBrowser = withErrorHandling(async (): Promise => { - console.log('Initializing browser...'); - this.browser = await chromium.launch({ - headless: true, - executablePath: await chromiumBinary.executablePath(), - args: [ - ...chromiumBinary.args, - '--no-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu', - '--disable-features=VizDisplayCompositor', - '--disable-background-timer-throttling', - '--disable-backgrounding-occluded-windows', - '--disable-renderer-backgrounding', - '--disable-web-security', - '--single-process', - '--disable-setuid-sandbox', - '--no-zygote', - '--disable-accelerated-2d-canvas', - '--no-first-run', - '--no-default-browser-check', - '--disable-extensions', - '--disable-plugins', - ], - }); - console.log('Browser initialized successfully'); - }, OPERATION_CONTEXT.BROWSER_INIT); - - // HOF로 래핑된 Job 찾기 - private findJob = withSyncErrorHandling((jobName: string): Job => { - const job = JobRegistry.getJobByName(jobName); - if (!job) { - throw new AppError( - ERROR_MESSAGES.JOB_NOT_FOUND, - OPERATION_CONTEXT.JOB_LOOKUP, - undefined, - { - requestedJobName: jobName, - availableJobs: JobRegistry.getJobNames() - } - ); - } - console.log(`Found job: ${job.jobName}`); - return job; - }, OPERATION_CONTEXT.JOB_LOOKUP); - - // HOF로 래핑된 Job 실행 - private executeJob = withErrorHandling( - async (job: Job, context: { targetDate: Date }) => { - const result = await this.jobExecutor!.execute(job, context); - if (isFailure(result)) { - throw result.error; - } - return result.data; - }, - OPERATION_CONTEXT.JOB_EXECUTION - ); - - private async cleanup(): Promise { - this.jobExecutor = null; - if (this.browser) { - await this.browser.close(); - this.browser = null; - } - } -} diff --git a/src/aws/lambda/CrawlingService.ts b/src/aws/lambda/CrawlingService.ts index e966f31..5d49b17 100644 --- a/src/aws/lambda/CrawlingService.ts +++ b/src/aws/lambda/CrawlingService.ts @@ -4,18 +4,9 @@ import { JobRegistry } from '../../entity/job/JobRegistry'; import { Job } from '../../entity/job/Job'; import { JobExecutor } from '../../entity/job/JobExecutor'; import { getKoreaTimeISO } from '../../utils/DateUtils'; -import { CrawlingEvent } from './handler'; import { validateJobName } from './LambdaEventValidator'; import { TargetDate } from '../../entity/TargetDate'; -import { - Result, - withErrorHandling, - withSyncErrorHandling, - isSuccess, - isFailure, - success, - failure, -} from '../../utils/ErrorHandling'; +import { HandleErrors } from '../../utils/ErrorHandling'; import { AppError } from '../../errors/AppError'; import { ERROR_MESSAGES } from '../../constants/ErrorMessages'; import { OPERATION_CONTEXT } from '../../constants/OperationContext'; @@ -32,7 +23,7 @@ export class CrawlingService { private browser: Browser | null = null; private jobExecutor: JobExecutor | null = null; - async executeCrawling(targetDate: TargetDate, jobName: string): Promise> { + async executeCrawling(targetDate: TargetDate, jobName: string): Promise { const startTime = Date.now(); console.log(`크롤링 시작 at ${getKoreaTimeISO()}`); @@ -42,42 +33,36 @@ export class CrawlingService { const parsedDate = targetDate.dateObject; // 1단계: 브라우저 초기화 - const browserResult = await this.initializeBrowser(); - if (isFailure(browserResult)) { - return failure(browserResult.error, OPERATION_CONTEXT.BROWSER_INIT); - } + await this.initializeBrowser(); // 2단계: Job 찾기 - const jobResult = this.findJob(jobName); - if (isFailure(jobResult)) { - return failure(jobResult.error, OPERATION_CONTEXT.JOB_LOOKUP); - } + const job = this.findJob(jobName); // 3단계: JobExecutor 실행 this.jobExecutor = new JobExecutor(this.browser!); - const executionResult = await this.executeJob(jobResult.data, { + const executionResult = await this.executeJob(job, { targetDate: parsedDate, }); - if (isFailure(executionResult)) { - return failure(executionResult.error, OPERATION_CONTEXT.JOB_EXECUTION); - } - const endTime = Date.now(); console.log(`Crawling completed in ${endTime - startTime}ms`); - return success({ - processedJobs: executionResult.data.processedJobs, - results: executionResult.data.results, - itemCount: executionResult.data.itemCount, - }); + return this.createCrawlingResult(executionResult); } finally { await this.cleanup(); } } - // HOF로 래핑된 브라우저 초기화 - private initializeBrowser = withErrorHandling(async (): Promise => { + private createCrawlingResult(executionResult: { processedJobs: string[]; results: any[]; itemCount: number }): CrawlingResult { + return { + processedJobs: executionResult.processedJobs, + results: executionResult.results, + itemCount: executionResult.itemCount, + }; + } + + @HandleErrors(OPERATION_CONTEXT.BROWSER_INIT, ERROR_MESSAGES.BROWSER_INIT_FAILED) + private async initializeBrowser(): Promise { console.log('Initializing browser...'); this.browser = await chromium.launch({ headless: true, @@ -103,10 +88,10 @@ export class CrawlingService { ], }); console.log('Browser initialized successfully'); - }, OPERATION_CONTEXT.BROWSER_INIT); + } - // HOF로 래핑된 Job 찾기 - private findJob = withSyncErrorHandling((jobName: string): Job => { + @HandleErrors(OPERATION_CONTEXT.JOB_LOOKUP, ERROR_MESSAGES.JOB_NOT_FOUND) + private findJob(jobName: string): Job { const job = JobRegistry.getJobByName(jobName); if (!job) { throw new AppError( @@ -121,16 +106,13 @@ export class CrawlingService { } console.log(`Found job: ${job.jobName}`); return job; - }, OPERATION_CONTEXT.JOB_LOOKUP); + } - // HOF로 래핑된 Job 실행 - private executeJob = withErrorHandling( - async (job: Job, context: { targetDate: Date }) => { - const result = await this.jobExecutor!.execute(job, context); - return result; - }, - OPERATION_CONTEXT.JOB_EXECUTION - ); + @HandleErrors(OPERATION_CONTEXT.JOB_EXECUTION, ERROR_MESSAGES.JOB_EXECUTION_FAILED) + private async executeJob(job: Job, context: { targetDate: Date }) { + const result = await this.jobExecutor!.execute(job, context); + return result; + } private async cleanup(): Promise { this.jobExecutor = null; diff --git a/src/aws/lambda/handler.ts b/src/aws/lambda/handler.ts index 9983ece..8f17c11 100644 --- a/src/aws/lambda/handler.ts +++ b/src/aws/lambda/handler.ts @@ -2,10 +2,11 @@ import { Context } from 'aws-lambda'; import { CrawlingService } from './CrawlingService'; import { S3Service } from '../s3/S3Service'; import { getKoreaTimeISO } from '../../utils/DateUtils'; -import { isSuccess, isFailure } from '../../utils/ErrorHandling'; import { TargetDate } from '../../entity/TargetDate'; import { validateEvent } from './LambdaEventValidator'; import { ERROR_MESSAGES } from '../../constants/ErrorMessages'; +import { AppError } from '../../errors/AppError'; +import { OPERATION_CONTEXT } from '../../constants/OperationContext'; // Lambda Invocation용 이벤트 인터페이스 export interface CrawlingEvent { @@ -16,7 +17,7 @@ export interface CrawlingEvent { // Lambda Invocation용 응답 인터페이스 export interface CrawlingResponse { success: boolean; - message: string; + message: string; // 사용자 친화적 메시지 targetDate: string; jobName: string; data?: { @@ -25,8 +26,11 @@ export interface CrawlingResponse { itemCount: number; duration: number; }; - // TODO 디버깅하기 쉽게 에러 메시지가 좀 더 구체적으로 명시되어야 할지 고민 - error?: string; + error?: { // 단순화된 Error 구조! + message: string; // 기술적 에러 메시지 (디버깅용) + context: string; // 에러 발생 위치/컨텍스트 + stack?: string; // 스택 트레이스 + }; timestamp: string; } @@ -50,66 +54,116 @@ export const crawl = async (event: CrawlingEvent, context: Context): Promise { + console.log('크롤링 성공 결과', { + targetDate: targetDate.value, + jobName, + data: { + processedJobs: crawlingResult.processedJobs, + s3Location, + itemCount: crawlingResult.itemCount, + duration, + }, + }) + + return { + success: true, + message: ERROR_MESSAGES.SUCCESS, + targetDate: targetDate.value, + jobName, + data: { + processedJobs: crawlingResult.processedJobs, + s3Location, + itemCount: crawlingResult.itemCount, + duration, + }, + timestamp: getKoreaTimeISO(), + }; +}; + +const handleAppError = ( + error: AppError, + targetDate: TargetDate, + jobName: string, + duration: number, + context: Context +): CrawlingResponse => { + console.error('크롤링 실패', { + error: error.message, + context: error.context, + metadata: error.metadata, + cause: error.cause instanceof Error ? error.cause.message : error.cause, + duration: `${duration}ms`, + remainingTime: context.getRemainingTimeInMillis(), + }); + + return { + success: false, + message: ERROR_MESSAGES.CRAWLING_FAILED, + targetDate: targetDate.value, + jobName, + error: { + message: (error.cause instanceof Error ? error.cause.message : undefined) || error.message, + context: error.context, + stack: (error.cause instanceof Error ? error.cause.stack : undefined) || error.stack + }, + timestamp: getKoreaTimeISO(), + }; +}; + +const handleSystemError = ( + error: unknown, + targetDate: TargetDate, + jobName: string, + duration: number, + context: Context +): CrawlingResponse => { + const errorMessage = error instanceof Error ? error.message : String(error); + + console.error('시스템 에러', { + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + duration: `${duration}ms`, + remainingTime: context.getRemainingTimeInMillis(), + }); + + return { + success: false, + message: ERROR_MESSAGES.SYSTEM_ERROR, + targetDate: targetDate.value, + jobName, + error: { + message: errorMessage, + context: OPERATION_CONTEXT.SYSTEM_ERROR, + stack: error instanceof Error ? error.stack : undefined + }, + timestamp: getKoreaTimeISO(), + }; +}; + +const calcDuration = (startTime: number): number => { + return Date.now() - startTime; +}; \ No newline at end of file diff --git a/src/aws/s3/S3Service.ts b/src/aws/s3/S3Service.ts index f95c58b..ed1f9cb 100644 --- a/src/aws/s3/S3Service.ts +++ b/src/aws/s3/S3Service.ts @@ -1,6 +1,5 @@ import { S3Uploader } from './S3Uploader'; -import { Result, withErrorHandling, isFailure, success, failure } from '../../utils/ErrorHandling'; -import { AppError } from '../../errors/AppError'; +import { HandleErrors } from '../../utils/ErrorHandling'; import { ERROR_MESSAGES } from '../../constants/ErrorMessages'; import { TargetDate } from '../../entity/TargetDate'; import { OPERATION_CONTEXT } from '../../constants/OperationContext'; @@ -12,53 +11,13 @@ export class S3Service { this.s3Uploader = new S3Uploader(); } - // HOF로 래핑된 S3 업로드 - private uploadResultSafely = withErrorHandling( - async (results: any[], targetDate: TargetDate, jobName: string): Promise => { - return await this.s3Uploader.uploadCrawlingResults(results, targetDate.value, jobName); - }, - OPERATION_CONTEXT.S3_UPLOAD - ); - - // HOF로 래핑된 빈 결과 업로드 - private uploadEmptyResultSafely = withErrorHandling( - async (targetDate: TargetDate, jobName: string): Promise => { - return await this.s3Uploader.uploadCrawlingResults([], targetDate.value, jobName); - }, - OPERATION_CONTEXT.S3_EMPTY_UPLOAD - ); - - async uploadResults(results: any[], targetDate: TargetDate, jobName: string): Promise> { - const uploadResult = await this.uploadResultSafely(results, targetDate, jobName); - if (isFailure(uploadResult)) { - return failure( - new AppError( - ERROR_MESSAGES.S3_UPLOAD_FAILED, - OPERATION_CONTEXT.S3_UPLOAD, - uploadResult.error instanceof Error ? uploadResult.error : undefined, - { targetDate: targetDate.value, jobName, resultCount: results.length } - ), - OPERATION_CONTEXT.S3_UPLOAD - ); - } - - return success(uploadResult.data); + @HandleErrors(OPERATION_CONTEXT.S3_UPLOAD, ERROR_MESSAGES.S3_UPLOAD_FAILED) + async uploadResults(results: any[], targetDate: TargetDate, jobName: string): Promise { + return await this.s3Uploader.uploadCrawlingResults(results, targetDate.value, jobName); } - async uploadEmptyResult(targetDate: TargetDate, jobName: string): Promise> { - const emptyUploadResult = await this.uploadEmptyResultSafely(targetDate, jobName); - if (isFailure(emptyUploadResult)) { - return failure( - new AppError( - ERROR_MESSAGES.S3_UPLOAD_FAILED, - OPERATION_CONTEXT.S3_EMPTY_UPLOAD, - emptyUploadResult.error instanceof Error ? emptyUploadResult.error : undefined, - { targetDate: targetDate.value, jobName, resultCount: 0 } - ), - OPERATION_CONTEXT.S3_EMPTY_UPLOAD - ); - } - - return success(emptyUploadResult.data); + @HandleErrors(OPERATION_CONTEXT.S3_EMPTY_UPLOAD, ERROR_MESSAGES.S3_UPLOAD_FAILED) + async uploadEmptyResult(targetDate: TargetDate, jobName: string): Promise { + return await this.s3Uploader.uploadCrawlingResults([], targetDate.value, jobName); } } \ No newline at end of file diff --git a/src/constants/ErrorMessages.ts b/src/constants/ErrorMessages.ts index d754292..79d65d3 100644 --- a/src/constants/ErrorMessages.ts +++ b/src/constants/ErrorMessages.ts @@ -12,8 +12,6 @@ export const ERROR_MESSAGES = { // 성공 메시지 SUCCESS: '크롤링 및 S3 업로드 성공', - - // === AppError용 메시지들 === // 검증 관련 INVALID_DATE_FORMAT: 'targetDate는 YYYY-MM-DD 형식이어야 합니다', diff --git a/src/entity/job/JobExecutor.ts b/src/entity/job/JobExecutor.ts index de6267b..26668bd 100644 --- a/src/entity/job/JobExecutor.ts +++ b/src/entity/job/JobExecutor.ts @@ -52,7 +52,7 @@ export class JobExecutor { itemCount: flatResults.length, }; } catch (error) { - console.error(`Job execution failed: ${job.jobName}`, error); + console.warn(`Job execution failed: ${job.jobName}`, error); throw new AppError( ERROR_MESSAGES.JOB_EXECUTION_FAILED, OPERATION_CONTEXT.JOB_EXECUTION, @@ -70,6 +70,11 @@ export class JobExecutor { const viewport = options?.viewport || { width: 800, height: 600 }; await page.setViewportSize(viewport); + // 봇 탐지 방지를 위한 User-Agent 설정 + await page.setExtraHTTPHeaders({ + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36' + }); + if (options?.timeout) { page.setDefaultTimeout(options.timeout); } diff --git a/src/errors/AppError.ts b/src/errors/AppError.ts index dfaedc6..b961890 100644 --- a/src/errors/AppError.ts +++ b/src/errors/AppError.ts @@ -6,10 +6,10 @@ export class AppError extends Error { constructor( message: string, public readonly context: string, - public readonly cause?: Error, + cause?: Error, public readonly metadata?: Record ) { - super(message); + super(message, { cause }); this.name = 'AppError'; // Error의 prototype chain을 올바르게 설정 diff --git a/src/utils/ErrorHandling.ts b/src/utils/ErrorHandling.ts index bbab43d..c817da9 100644 --- a/src/utils/ErrorHandling.ts +++ b/src/utils/ErrorHandling.ts @@ -1,152 +1,103 @@ import { AppError } from '../errors/AppError'; -import { OPERATION_CONTEXT } from '../constants/OperationContext'; +import { OperationContextType } from '../constants/OperationContext'; +import { ErrorMessageType } from '../constants/ErrorMessages'; -/** - * Result 타입 - 성공 또는 실패를 나타내는 타입 - */ -export type Result = - | { success: true; data: T } - | { success: false; error: E; context: string }; - -/** - * HOF(고차함수): 비동기 함수를 래핑하여 예외를 Result 타입으로 변환 - */ -export function withErrorHandling( - fn: (...args: A) => Promise, - context: string -): (...args: A) => Promise> { - return async (...args: A): Promise> => { - try { - console.log(`[${context}] 시작`); - const data = await fn(...args); - console.log(`[${context}] 성공`); - return { success: true, data }; - } catch (error) { - const errorInstance = error instanceof AppError - ? error - : new AppError( - error instanceof Error ? error.message : String(error), - context, - error instanceof Error ? error : undefined - ); - - console.warn(`[${context}] 실패:`, errorInstance.toJSON()); - - return { - success: false, - error: errorInstance, - context - }; - } - }; -} -/** - * HOF(고차함수): 동기 함수를 래핑하여 예외를 Result 타입으로 변환 - */ -export function withSyncErrorHandling( - fn: (...args: A) => T, - context: string -): (...args: A) => Result { - return (...args: A): Result => { - try { - console.log(`[${context}] 시작`); - const data = fn(...args); - console.log(`[${context}] 성공`); - return { success: true, data }; - } catch (error) { - const errorInstance = error instanceof AppError - ? error - : new AppError( - error instanceof Error ? error.message : String(error), - context, - error instanceof Error ? error : undefined - ); - - console.error(`[${context}] 실패:`, errorInstance.toJSON()); - - return { - success: false, - error: errorInstance, - context - }; - } - }; -} /** - * Result 타입 가드 함수들 + * Promise 타입 체크 유틸리티 + * @param value - 체크할 값 + * @returns Promise 여부 */ -export function isSuccess(result: Result): result is { success: true; data: T } { - return result.success; -} - -export function isFailure(result: Result): result is { success: false; error: E; context: string } { - return !result.success; +function isPromiseLike(value: unknown): value is Promise { + return value != null && + typeof value === 'object' && + 'then' in value && + typeof (value as any).then === 'function'; } /** - * Result 헬퍼 함수들 - 중복 코드 제거를 위한 유틸리티 + * 에러를 AppError로 변환하는 유틸리티 + * @param error - 원본 에러 + * @param errorMessage - 에러 메시지 + * @param contextName - 컨텍스트 이름 + * @returns AppError 인스턴스 */ -export function success(data: T): Result { - return { success: true, data }; -} - -export function failure(error: AppError, context?: string): Result { - return { success: false, error, context }; -} - -/** - * Error를 AppError로 변환하는 헬퍼 함수 - */ -export function wrapError( - error: unknown, - message: string, - context: string -): Result { - const errorInstance = error instanceof AppError - ? error - : new AppError(message, context, error instanceof Error ? error : undefined); - - return failure(errorInstance, context); -} - -// /** -// * 여러 Result를 조합하는 유틸리티 -// */ -// export function combineResults(results: Result[]): Result { -// const successResults: T[] = []; -// const errors: Error[] = []; +function convertToAppError(error: unknown, errorMessage: string, contextName: string): AppError { + // 이미 AppError인 경우 그대로 반환 + if (error instanceof AppError) { + return error; + } -// for (const result of results) { -// if (isSuccess(result)) { -// successResults.push(result.data); -// } else { -// errors.push(result.error); -// } -// } - -// if (errors.length > 0) { -// return { -// success: false, -// error: new Error(`${errors.length}개 작업 실패: ${errors.map(e => e.message).join(', ')}`), -// context: 'Combined operations' -// }; -// } + // 다른 에러인 경우 AppError로 변환 + const cause = error instanceof Error ? error : undefined; + const metadata = { + originalError: error instanceof Error ? error.message : String(error), + errorType: error?.constructor?.name || typeof error + }; -// return { success: true, data: successResults }; -// } + return new AppError(errorMessage, contextName, cause, metadata); +} /** - * Result에서 데이터를 안전하게 추출하는 유틸리티 + * 메서드 예외 처리를 위한 Decorator (Stage 3) + * 메서드 실행 중 발생한 예외를 AppError로 변환하여 다시 throw + * + * @param contextName - 로깅과 에러 컨텍스트에 사용될 이름 + * @param errorMessage - 예외 발생 시 사용할 에러 메시지 + * @returns 예외 처리가 적용된 메서드 Decorator + * + * @example + * ```typescript + * class MyService { + * @HandleErrors(OPERATION_CONTEXT.DATA_FETCH, ERROR_MESSAGES.FETCH_FAILED) + * async fetchData(): Promise { + * // 비즈니스 로직 + * } + * } + * ``` */ -// export function unwrapOr(result: Result, defaultValue: T): T { -// return isSuccess(result) ? result.data : defaultValue; -// } - -// export function unwrapOrThrow(result: Result): T { -// if (isSuccess(result)) { -// return result.data; -// } -// throw result.error; -// } \ No newline at end of file +export function HandleErrors(contextName: OperationContextType, errorMessage: ErrorMessageType) { + return function ( + originalMethod: (...args: A) => T, + context: ClassMethodDecoratorContext T> + ) { + // 컴파일 타임 검증 + if (context.kind !== 'method') { + throw new Error(`@HandleErrors can only be applied to methods, but got ${context.kind}`); + } + + if (typeof originalMethod !== 'function') { + throw new Error(`@HandleErrors can only be applied to methods, but ${String(context.name)} is not a function`); + } + + return function (this: unknown, ...args: A): T { + try { + console.log(`[${contextName}] 시작`); + + const result = originalMethod.apply(this, args); + + // Promise인 경우 비동기 처리 + if (isPromiseLike(result)) { + return result.then( + (value: unknown) => { + console.log(`[${contextName}] 성공`); + return value; + }, + (error: unknown) => { + console.warn(`[${contextName}] 실패:`, error); + throw convertToAppError(error, errorMessage, contextName); + } + ) as T; + } + + // 동기 함수인 경우 + console.log(`[${contextName}] 성공`); + return result; + } catch (error) { + console.warn(`[${contextName}] 실패:`, error); + throw convertToAppError(error, errorMessage, contextName); + } + }; + }; +} \ No newline at end of file diff --git a/tests/utils/Decorator.test.ts b/tests/utils/Decorator.test.ts new file mode 100644 index 0000000..ebe4dab --- /dev/null +++ b/tests/utils/Decorator.test.ts @@ -0,0 +1,93 @@ +import { HandleErrors } from '../../src/utils/ErrorHandling'; +import { AppError } from '../../src/errors/AppError'; +import { ERROR_MESSAGES } from '../../src/constants/ErrorMessages'; +import { OPERATION_CONTEXT } from '../../src/constants/OperationContext'; + +describe('HandleErrors Decorator Tests', () => { + // 콘솔 로그 모킹 + let consoleLogSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('HandleErrors Decorator', () => { + class TestService { + @HandleErrors(OPERATION_CONTEXT.SYSTEM_ERROR, ERROR_MESSAGES.SYSTEM_ERROR) + async successMethod(): Promise { + return 'success'; + } + + @HandleErrors(OPERATION_CONTEXT.SYSTEM_ERROR, ERROR_MESSAGES.SYSTEM_ERROR) + async failMethod(): Promise { + throw new Error('Test error'); + } + + @HandleErrors(OPERATION_CONTEXT.SYSTEM_ERROR, ERROR_MESSAGES.SYSTEM_ERROR) + async appErrorMethod(): Promise { + throw new AppError('Test app error', OPERATION_CONTEXT.SYSTEM_ERROR); + } + + @HandleErrors(OPERATION_CONTEXT.BROWSER_INIT, ERROR_MESSAGES.BROWSER_INIT_FAILED) + async browserInitMethod(): Promise { + throw new Error('Browser init failed'); + } + } + + it('should handle successful method execution', async () => { + const service = new TestService(); + const result = await service.successMethod(); + + expect(result).toBe('success'); + expect(consoleLogSpy).toHaveBeenCalledWith('[시스템 에러] 시작'); + expect(consoleLogSpy).toHaveBeenCalledWith('[시스템 에러] 성공'); + }); + + it('should convert regular error to AppError', async () => { + const service = new TestService(); + + await expect(service.failMethod()).rejects.toThrow(AppError); + expect(consoleWarnSpy).toHaveBeenCalledWith('[시스템 에러] 실패:', expect.any(Error)); + }); + + it('should pass through AppError without modification', async () => { + const service = new TestService(); + + await expect(service.appErrorMethod()).rejects.toThrow(AppError); + expect(consoleWarnSpy).toHaveBeenCalledWith('[시스템 에러] 실패:', expect.any(AppError)); + }); + + it('should use correct context and error message', async () => { + const service = new TestService(); + + await expect(service.browserInitMethod()).rejects.toThrow(AppError); + await expect(service.browserInitMethod()).rejects.toThrow(ERROR_MESSAGES.BROWSER_INIT_FAILED); + await expect(service.browserInitMethod()).rejects.toMatchObject({ + message: ERROR_MESSAGES.BROWSER_INIT_FAILED, + context: OPERATION_CONTEXT.BROWSER_INIT + }); + + expect(consoleLogSpy).toHaveBeenCalledWith('[브라우저 초기화] 시작'); + expect(consoleWarnSpy).toHaveBeenCalledWith('[브라우저 초기화] 실패:', expect.any(Error)); + }); + + it('should preserve original error as cause', async () => { + const service = new TestService(); + + await expect(service.failMethod()).rejects.toThrow(AppError); + await expect(service.failMethod()).rejects.toMatchObject({ + message: ERROR_MESSAGES.SYSTEM_ERROR, + context: OPERATION_CONTEXT.SYSTEM_ERROR, + cause: expect.objectContaining({ + message: 'Test error' + }) + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/utils/ErrorHandling.test.ts b/tests/utils/ErrorHandling.test.ts deleted file mode 100644 index 4246986..0000000 --- a/tests/utils/ErrorHandling.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - withErrorHandling, - withSyncErrorHandling, -} from '../../src/utils/ErrorHandling'; -import { AppError } from '../../src/errors/AppError'; - -describe('ErrorHandling', () => { - describe('withErrorHandling', () => { - it('정상적인 비동기 함수의 결과값을 Result 타입으로 변환하여 반환해야 한다', async () => { - //given - const asyncSuccessFn = async (x: number) => x * 3; - const sut = withErrorHandling(asyncSuccessFn, 'async-test'); - //when - const result = await sut(4); - //then - const successResult = result as { success: boolean; data: number }; - expect(successResult.success).toBe(true); - expect(successResult.data).toBe(12); - }); - - it('비동기 함수에서 발생한 에러를 Result 타입으로 변환하여 반환해야 한다', async () => { - //given - const asyncErrorFn = async () => { - throw new Error('async error'); - }; - const sut = withErrorHandling(asyncErrorFn, 'async-error-test'); - //when - const result = await sut(); - //then - const errorResult = result as { success: boolean; error: AppError; context: string }; - expect(errorResult.success).toBe(false); - expect(errorResult.error).toBeInstanceOf(AppError); - expect(errorResult.error.message).toBe('async error'); - expect(errorResult.error.context).toBe('async-error-test'); - expect(errorResult.context).toBe('async-error-test'); - }); - }); - - describe('withSyncErrorHandling', () => { - it('정상적인 동기 함수의 결과값을 Result 타입으로 변환하여 반환해야 한다', () => { - //given - const syncSuccessFn = (x: number) => x * 3; - const sut = withSyncErrorHandling(syncSuccessFn, 'sync-test'); - //when - const result = sut(4); - //then - const successResult = result as { success: boolean; data: number }; - expect(successResult.success).toBe(true); - expect(successResult.data).toBe(12); - }); - - it('동기 함수에서 발생한 에러를 Result 타입으로 변환하여 반환해야 한다', () => { - //given - const syncErrorFn = () => { - throw new Error('sync error'); - }; - const sut = withSyncErrorHandling(syncErrorFn, 'sync-error-test'); - //when - const result = sut(); - //then - const errorResult = result as { success: boolean; error: AppError; context: string }; - expect(errorResult.success).toBe(false); - expect(errorResult.error).toBeInstanceOf(AppError); - expect(errorResult.error.message).toBe('sync error'); - expect(errorResult.error.context).toBe('sync-error-test'); - expect(errorResult.context).toBe('sync-error-test'); - }); - }); -}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1e2acc5..708b97b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,13 +4,13 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "types": ["node", "jest"], - "target": "ES2020", + "target": "ES2022", "module": "commonjs", "outDir": "./dist", // 빌드 출력 디렉토리 지정 "rootDir": "./src", // 소스 디렉토리 지정 "declaration": true, // 타입 선언 파일 생성 "removeComments": true, // 주석 제거로 파일 크기 줄이기 - "skipLibCheck": true // 라이브러리 타입 체크 건너뛰어 빌드 속도 향상 + "skipLibCheck": true // 라이브러리 타입 체크 건너뛰어 빌드 속도 향상 }, "include": [ "src/**/*" // 소스 폴더 포함