-
이코에코(Eco²) Clean Architecture #9: Presentation Layer 정제이코에코(Eco²)/Clean Architecture Migration 2026. 1. 2. 13:23

Presentation Layer의 gRPC 구조를 정제하고, 횡단 관심사를 인터셉터로 분리한 과정을 기록합니다.
1. 판단 기준
Presentation Layer 내부 폴더 구조를 결정할 때 적용한 판단 기준:
1.1 폴더 분리 기준
기준 질문 분리 조건 역할 분리 이 파일의 책임은 무엇인가? 서로 다른 책임 → 다른 폴더 변경 주기 언제 이 파일이 수정되는가? 다른 시점에 변경 → 다른 폴더 생성 방식 수동 작성인가, 자동 생성인가? 자동 생성 파일 → 별도 폴더 재사용성 여러 서비스에서 공유되는가? 공유 가능 → 별도 폴더 1.2 gRPC 폴더 구조 결정
파일 종류 역할 변경 주기 생성 방식 재사용 결론 *_pb2.py메시지 정의 proto 변경 시 자동 생성 X protos/*_pb2_grpc.py서비스 스텁 proto 변경 시 자동 생성 X protos/*_servicer.py요청 처리 비즈니스 변경 시 수동 작성 X servicers/error_handler.py예외 변환 정책 변경 시 수동 작성 O interceptors/logging.py요청 로깅 정책 변경 시 수동 작성 O interceptors/server.py서버 부트스트랩 설정 변경 시 수동 작성 X 루트 1.3 네이밍 결정: schemas vs protos
옵션 장점 단점 schemas/HTTP Pydantic과 일관성 Protobuf와 Pydantic 혼동 protos/Protocol Buffers 명시적 HTTP와 네이밍 불일치 결론:
protos/채택- pb2 파일은 Pydantic이 아닌 Protobuf 컴파일러 생성물
- "자동 생성 파일"이라는 특성을 폴더명으로 표현
- HTTP의
schemas/는 Pydantic, gRPC의protos/는 Protobuf로 명확히 구분
1.4 인터셉터 도입 기준
기준 질문 예시 중복 여러 메서드에 동일 패턴이 반복되는가? try/except 에러 핸들링 횡단 관심사 비즈니스 로직과 분리 가능한가? 로깅, 인증, 메트릭 재사용성 다른 서비스에서도 사용 가능한가? 에러→status 변환 SRP Servicer가 여러 책임을 갖고 있는가? 요청 처리 + 에러 변환 + 로깅
2. AS-IS 구조
presentation/grpc/ ├── server.py ├── servicers/ │ └── users_servicer.py ← try/except 중복, 에러 변환 책임 혼재 ├── users_pb2.py ← 자동 생성 파일이 루트에 혼재 └── users_pb2_grpc.py ← 자동 생성 파일이 루트에 혼재문제점
문제 설명 자동 생성 파일 혼재 pb2 파일이 수동 작성 파일과 같은 레벨에 위치 try/except 중복 모든 메서드에 동일한 에러 핸들링 패턴 반복 책임 혼재 Servicer가 요청 처리 + 에러 변환 + 로깅 모두 담당 중복 패턴 예시:
async def GetOrCreateFromOAuth(self, request, context): try: # 비즈니스 로직 ... except ValueError as e: logger.error("Invalid argument", extra={"error": str(e)}) await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e)) except Exception: logger.exception("Internal error") await context.abort(grpc.StatusCode.INTERNAL, "Internal server error") async def GetUser(self, request, context): try: # 비즈니스 로직 (동일한 패턴 반복) ... except ValueError as e: # 중복...
3. TO-BE 구조
presentation/grpc/ ├── server.py ← 서버 부트스트랩 + 인터셉터 등록 ├── protos/ ← 자동 생성 파일 분리 │ ├── __init__.py │ ├── users_pb2.py │ └── users_pb2_grpc.py ├── servicers/ ← Thin Adapter (요청 처리만) │ └── users_servicer.py └── interceptors/ ← 횡단 관심사 분리 ├── __init__.py ├── error_handler.py ← 예외 → gRPC status 변환 └── logging.py ← 요청/응답 로깅
4. 인터셉터 구현
4.1 ErrorHandlerInterceptor
예외를 gRPC status code로 변환:
class ErrorHandlerInterceptor(grpc.aio.ServerInterceptor): """예외를 gRPC status로 변환하는 인터셉터.""" async def intercept_service(self, continuation, handler_call_details): handler = await continuation(handler_call_details) if handler.unary_unary: return grpc.unary_unary_rpc_method_handler( self._wrap_unary_unary(handler.unary_unary), request_deserializer=handler.request_deserializer, response_serializer=handler.response_serializer, ) return handler def _wrap_unary_unary(self, behavior): async def wrapper(request, context): try: return await behavior(request, context) except ValueError as e: await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e)) except KeyError as e: await context.abort(grpc.StatusCode.NOT_FOUND, str(e)) except PermissionError as e: await context.abort(grpc.StatusCode.PERMISSION_DENIED, str(e)) except Exception: await context.abort(grpc.StatusCode.INTERNAL, "Internal server error") return wrapper4.2 LoggingInterceptor
요청/응답 로깅:
class LoggingInterceptor(grpc.aio.ServerInterceptor): """요청/응답 로깅 인터셉터.""" def _wrap_unary_unary(self, behavior, method): async def wrapper(request, context): start_time = time.perf_counter() logger.debug("gRPC request started", extra={"method": method}) try: response = await behavior(request, context) elapsed_ms = (time.perf_counter() - start_time) * 1000 logger.info( "gRPC request completed", extra={"method": method, "elapsed_ms": round(elapsed_ms, 2)}, ) return response except Exception as e: elapsed_ms = (time.perf_counter() - start_time) * 1000 logger.warning( "gRPC request failed", extra={"method": method, "elapsed_ms": round(elapsed_ms, 2)}, ) raise return wrapper4.3 예외 → gRPC Status 매핑
Python 예외 gRPC Status Code 의미 ValueErrorINVALID_ARGUMENT잘못된 요청 파라미터 KeyErrorNOT_FOUND리소스 없음 PermissionErrorPERMISSION_DENIED권한 부족 NotImplementedErrorUNIMPLEMENTED미구현 기능 ExceptionINTERNAL서버 내부 오류
5. 인터셉터 등록
# server.py interceptors = [ LoggingInterceptor(), # 1. 요청/응답 로깅 (먼저 실행) ErrorHandlerInterceptor(), # 2. 예외 → gRPC status 변환 ] server = grpc.aio.server( futures.ThreadPoolExecutor(max_workers=settings.grpc_max_workers), interceptors=interceptors, )실행 순서:
Request → LoggingInterceptor → ErrorHandlerInterceptor → Servicer ↓ ↓ ↓ 로깅 예외 캐치 비즈니스 로직
6. Servicer 간소화
AS-IS:
async def GetOrCreateFromOAuth(self, request, context): try: dto = OAuthUserRequest(...) async with self._session_factory() as session: command = self._use_case_factory.create_get_or_create_from_oauth_command(session) result = await command.execute(dto) await session.commit() return GetOrCreateFromOAuthResponse(...) except ValueError as e: logger.error("Invalid argument", extra={"error": str(e)}) await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e)) except Exception: logger.exception("Internal error") await context.abort(grpc.StatusCode.INTERNAL, "Internal server error")TO-BE:
async def GetOrCreateFromOAuth(self, request, context): # try/except 없이 비즈니스 로직만 dto = OAuthUserRequest(...) async with self._session_factory() as session: command = self._use_case_factory.create_get_or_create_from_oauth_command(session) result = await command.execute(dto) await session.commit() return GetOrCreateFromOAuthResponse(...)변화:
항목 AS-IS TO-BE 코드 라인 ~15줄 ~8줄 책임 요청 처리 + 에러 변환 + 로깅 요청 처리만 테스트 에러 케이스도 테스트 필요 비즈니스 로직만 테스트
7. 요약
원칙 적용 자동 생성 파일은 별도 폴더 protos/횡단 관심사는 인터셉터로 interceptors/Servicer는 비즈니스 로직만 try/except 제거 Protobuf는 protos/네이밍Pydantic schemas/와 구분에러 변환은 매핑 테이블로 Python 예외 → gRPC Status '이코에코(Eco²) > Clean Architecture Migration' 카테고리의 다른 글
이코에코(Eco²) Clean Architecture #11: Character 도메인 마이그레이션 (1) 2026.01.04 이코에코(Eco²) Clean Architecture #10: Auth/Users 스키마 정규화 (0) 2026.01.02 이코에코(Eco²) Clean Architecture #8: Infrastructure Layer 정제 (0) 2026.01.02 이코에코(Eco²) Clean Architecture #7: Application Layer 정제 (0) 2026.01.02 이코에코(Eco²) Clean Architecture #6: My->Users 리팩토링.MD (0) 2026.01.01