[Learner's High] 2주차 - 조회 고도화

2주차 회고

 

1주차에서는 이 프로젝트에서 어떤 문제를 다룰 것인지, 그리고 왜 이 주제를 선택했는지를 정리했습니다.
2주차에 들어서면서는 본격적으로 코드를 작성하기 시작했고, 그 과정에서 아키텍처에 대한 고민이 자연스럽게 따라오기 시작했습니다.

 

처음부터 명확한 아키텍처를 정해두고 시작한 것은 아니었으며, 개발 과정에서 지속적인 확장과 변경을 염두에 두다 보니 구조에 대한 고민이 점점 필요해졌습니다.

 

CQRS 패턴을 적용하게 된 이유

 

처음에는 Decision Log를 저장하고 조회하는 기능을 하나의 흐름으로 구현하고 있었습니다.
그런데 코드를 작성하다 보니, 쓰기와 읽기의 요구사항이 생각보다 많이 다르다는 점이 눈에 들어왔습니다.

로그 수집 쪽은 최대한 단순해야 했습니다.

  • 원본 로그를 빠르게 저장하는 것
  • 실패 없이 적재하는 것
  • 구조가 바뀌어도 크게 흔들리지 않는 것

반면 조회 쪽은 완전히 다른 요구를 가지고 있었습니다.

  • 사람이 이해할 수 있는 Context 형태로 가공
  • 조건 검색과 정렬
  • 이후 확장 가능성

이 두 가지를 하나의 모델과 서비스에서 함께 처리하려다 보니,
조금만 기능이 늘어나도 코드가 빠르게 복잡해질 것 같다는 느낌이 들었습니다.

 

그래서 이 시점에서 CQRS 패턴을 코드 레벨에서 적용하기로 했습니다.

  • Command: Decision Log 수집과 저장
  • Query: 저장된 로그를 조회하고 가공

DB까지는 분리하지 않았지만, 패키지 구조와 모델을 명확히 나누었습니다.

 

이렇게 분리하고 나니, “조회 요구 때문에 저장 구조를 바꿔야 하나?” 같은 고민을 하지 않아도 되었고, 각각의 책임을 독립적으로 생각할 수 있게 되었습니다.

 

 

같은 테이블, 다른 모델

 

CQRS를 적용하면서 다음으로 고민하게 된 것은 모델 분리였습니다.
Command와 Query가 결국 같은 테이블을 보는데, 같은 Entity를 써도 되는가에 대한 고민이었습니다.

 

개발을 진행할수록, 이 질문에 대한 답은 점점 명확해졌습니다.

 

같은 테이블을 보더라도, 관심사는 완전히 다르다는 점이었습니다.

 

그래서 다음과 같이 모델을 분리했습니다.

  • Command 모델
    • DecisionLogEntity
    • JPA Entity
    • 쓰기와 변경에 최적화
  • Query 모델
    • DecisionLogReadModel
    • 조회 전용 DTO (record)
    • QueryDSL Projection으로 직접 생성

 

Query 쪽에서는 @Entity를 사용하지 않고, 필요한 필드만 바로 조회해 DTO로 매핑하는 방식을 선택했습니다.

 

이렇게 분리하고 나니, 저장 구조가 바뀌어도 조회 로직이 크게 영향을 받지 않았고,
반대로 조회 요구가 늘어나도 Command 쪽을 건드리지 않아도 되는 구조가 되었습니다.

 

“아직 작은 프로젝트인데 과한 선택 아닌가?”라는 생각도 들었지만, 2주차 중반쯤에는 이 선택이 오히려 코드를 단순하게 만들어주고 있다는 느낌을 받았습니다.

 

 

레이어간 의존성

 

프로젝트 초반에는 비교적 익숙한 3-레이어 아키텍처로 시작했습니다.

  • Controller
  • Service
  • Repository

기본적인 책임 분리는 충분했고, Decision Log를 수집하고 조회하는 초기 기능을 구현하는 데에도 큰 문제는 없었습니다.
또한 Service 레이어를 중심으로 로직을 모으면서, 가능한 한 외부 의존성을 줄이려고 의식적으로 구조를 잡고 있었습니다.

 

하지만 개발을 진행하면서 느낀 의존성 문제는 DB와 같은 인프라 레이어에만 국한된 것이 아니었습니다.

 

프레젠테이션 레이어에 대한 의존성도 점점 신경 쓰이기 시작했습니다.

 

처음 구조에서는 Service가 요청(Request)과 응답(Response)의 형태를 자연스럽게 알고 있는 상태였습니다.

  • API 요청 DTO가 그대로 Service로 내려오고
  • Service의 반환값이 곧 API 응답이 되는 구조

 

이 방식은 구현 속도는 빠르지만, 조금만 기능이 복잡해지자 다음과 같은 문제가 보이기 시작했습니다.

  • Service 로직이 HTTP 요청/응답 구조를 전제로 동작
  • 같은 로직을 다른 진입점에서 재사용하기 어려운 구조
  • API 스펙 변경이 곧 Service 수정으로 이어지는 흐름

 

“이 로직은 정말 비즈니스 로직인가, 아니면 API에 종속된 로직인가?”라는 질문을 자주 하게 되었습니다.

 

DB 의존성을 줄이려고 Repository를 분리했듯이

프레젠테이션 레이어에 대한 의존성도 Service에서 분리해야겠다는 필요성을 느끼게 되었습니다.

 

이 지점에서 자연스럽게 클린 아키텍처의 의존성 규칙을 다시 떠올리게 되었고,
그 연장선에서 헥사고날 아키텍처(Ports & Adapters)를 함께 고민하게 되었습니다.

 

핵심은 단순했습니다.

  • Service는 HTTP를 몰라도 되고
  • 요청/응답 DTO도 몰라도 되며
  • 외부에서 어떤 방식으로 호출되든 동일하게 동작해야 한다

그래서 구조를 다음과 같이 재정리했습니다.

  • api
    • 외부 요청을 받는 In Adapter
    • Controller, Request/Response DTO, API 전용 Mapper
  • app
    • 애플리케이션 핵심 로직과 Port 정의
    • API나 DB에 대한 직접적인 의존성 없음
  • infra
    • DB, QueryDSL 등 기술 구현을 담당하는 Out Adapter
 
 

Service는 이제 “어떤 요청이 들어왔는지”나 “어떤 형태로 응답해야 하는지”를 알지 못하고,
오직 애플리케이션 관점의 입력과 출력만 다루도록 제한했습니다.

 

현재는 HTTP API와 DB만 존재하지만, 이 구조라면 이후 다음과 같은 확장도 비교적 자연스럽게 대응할 수 있을 것이라 생각했습니다.

  • Kafka를 통한 이벤트 스트리밍
  • OpenSearch 기반 조회 어댑터 추가
  • 배치나 이벤트 기반 호출 방식 추가

프레젠테이션 레이어가 바뀌거나 추가되더라도, 애플리케이션 코어는 그대로 두고
어댑터만 교체하거나 추가하는 방식으로 확장할 수 있는 여지를 남기고 싶었습니다.

 

 

Strategy + Registry 패턴

 

Decision Log를 단순히 조회하는 수준을 넘어서, “요청 하나에 대한 판단 맥락(Context)을 어떻게 구성할 것인가”를 구현하기 시작하면서 또 하나의 문제가 드러나기 시작했습니다.

 

서비스마다 Decision Log의 구조와 의미가 미묘하게 달랐습니다.

  • 어떤 서비스는 policy, rule, input 구조가 단순했고
  • 어떤 서비스는 여러 단계의 판단 결과를 조합해야 했습니다
  • 앞으로 서비스가 더 늘어날 가능성도 충분히 있었습니다

처음에는 if-else나 switch로 서비스별 분기 처리를 하려 했지만, 이 방식은 금방 한계가 보였습니다.

  • Context 추출 로직이 한 클래스에 계속 쌓이는 구조
  • 새로운 서비스 추가 시 기존 코드 수정이 필수
  • “이 서비스 전용 로직”과 “공통 로직”의 경계가 흐려짐

이 시점에서 Context 추출 로직을 전략으로 분리해야겠다는 필요성을 느끼게 되었습니다.

 

Strategy 패턴 적용

각 서비스별 Context 추출 방식을 하나의 인터페이스로 추상화했습니다.

  • DecisionExtractor
    → Context 추출 전략 인터페이스
  • CloudAccessDecisionExtractor
    → cloud_access 서비스 전용 전략
  • DefaultDecisionExtractor
    → 특정 서비스에 해당하지 않는 경우의 기본 전략

이렇게 분리하면서, 서비스별 로직은 각 Extractor 구현체 안으로 자연스럽게 모이게 되었습니다.

 

Registry 패턴으로 확장성 확보

다음으로 Registry 패턴을 함께 적용했습니다.

  • DecisionExtractorRegistry
    • 여러 DecisionExtractor를 보관
    • 서비스 이름을 기준으로 적절한 Extractor를 조회

Spring이 List<DecisionExtractor>를 자동으로 주입해 주기 때문에, 새로운 서비스가 추가되더라도 다음 작업만 하면 되었습니다.

  1. DecisionExtractor 구현체 하나 추가
  2. 서비스 식별 조건만 정의

 

기존 코드는 수정하지 않아도 되었고, Context 조회 흐름도 훨씬 읽기 쉬워졌습니다.

이 구조를 적용하고 나니, “지금은 두세 개뿐이지만, 늘어나도 감당할 수 있겠다”는 감각이 들었습니다.
패턴 자체보다도, 변경이 발생했을 때의 영향 범위를 제한할 수 있다는 점이 가장 큰 장점이었습니다.

 

 

마무리하며

 

2주차에서는 개발을 진행하며 드러난 문제들을 구조적인 선택으로 정리하는 데 집중했습니다.
다음 주에는 Kafka를 활용한 이벤트 스트리밍을 추가해, 이 아키텍처가 서비스 간 비동기 통신에서도 잘 동작하는지 검증해 보려 합니다.