Dev/Architecture

아키텍처 구조에 관한 고민 (1) - 아키텍처 비교

kimyeonjae 2026. 1. 30. 21:45

기존 아키텍처

프로젝트를 시작하며 스프링 부트 기반의 전통적인 계층형 아키텍처를 선택했습니다.

기존 아키텍처 구조


Controller (Presentation Layer) → REST API 엔드포인트 

Service (Business Layer) → 비즈니스 로직 

Repository (Data Access Layer) → JPA / QueryDSL 

Domain (Entity / Domain Model) → JPA 엔티티 

Infra Structure → 유틸리티

이유

이유로는 아래와 같습니다.

  • 계층 별 관심사를 분리하여 코드 가독성 및 확장, 유지보수를 원활히 하기 위해

  • 특정 DB나 기술 스택 변경 가능성을 열어두기 위해 (JPA에 의존)

  • 학습한 내용을 적용

초기 개발 단계에서의 서비스의 유지보수나 확장성에 대해 큰 불편함을 느끼지 못하였고, 이 구조만으로도 충분하였습니다.

문제 상황

문제 1 : 의존관계가 복잡해짐

서비스가 점점 커지며 계층별 책임이 점점 섞이게 되어 각 계층의 의존도가 커지고 의존 관계가 복잡해졌습니다.
또한 처음 설계와 다르게 계속해서 비즈니스 로직이 추가 / 수정 되었습니다.

특히 SRP를 지키려고 클래스를 잘게 쪼갰습니다.

  • 클래스를 나누어 책임이 분리된것 같았지만, 나눈 클래스들이 서로 강하게 의존하게 되었습니다.

  • 하나를 수정하면 다른 클래스에 영향이 쉽게 전파되었습니다.

  • 수정하기 위해 사이드 이팩트를 고려해야 하는 양이 점점 늘어나며 유지보수성이 떨어졌습니다.

문제 2 : 명확한 규칙 없이 진행된 패키지 구조

계층별 패키지 구조를 나누어 구성하였습니다. 그러나 어느 계층에 가야할지 모호한 서비스 로직들이 이곳 저곳 흩어지게 되었습니다.
이로 인해 아래와 같은 문제가 생겼습니다.

  • 과도한 의존성을 가짐

  • 테스트가 어려워짐

  • 레이어의 경계가 점점 흐려짐

  • 해당 로직을 담당하는 서비스를 찾기 어려워짐

문제 3 : Domain Entity = JPA Entity 방식과 단위 테스트의 어려움

사실 이를 사용하면 코드 복잡도도 낮아지고, 생산성도 높아진다는 장점이 있습니다. 간단한 서비스를 만든다면 오히려 다른 구조를 사용하는게 오버 엔지니어링이 되기에 의도적인 트레이드 오프입니다.
그러나 JPA에 의존하게 됩니다.

  • Lazy Loading같은 기능을 Service 레이어에서 사용하게 되면, 도메인이 JPA를 의존하게 됨

  • 또한 순수한 단위 테스트가 불가능 해집니다.

보통 초반 빠른 기능 구현을 위해 이 방식을 사용합니다. 추후 서비스가 커지게 될 때 편하게 변경하기 위해

  • Service 안의 규칙을 엔티티 밖으로 꺼내기

  • 연관관계는 필요 최소한으로

  • 조회는 점점 쿼리 중심으로 바꿔가며 Lazy Loading 의존 낮추기

계층형 아키텍처의 한계와 대안

데이터베이스 주도 설계를 유발한다

  • ORM 프레임워크를 계층형 아키텍처와 결합하면 비즈니스 규칙을 영속성 관점과 섞고 싶어짐

  • 이는 영속성 코드가 도메인 코드에 섞이며 강한 결합을 유발하게 됨

계층형 아키텍처는 JPA를 중심으로 개발할 경우 DB테이블을 먼저 설계하고, Entity를 생성 한 후 도메인 로직 순으로 개발 순서가 흘러가기 쉽습니다.

도메인 로직을 먼저 만들고, 이를 기반으로 영속성 계층과 웹 계층을 만들자

지름길을 택하기 쉬워진다

접근해야 할 클래스가 있으면 바로 하위 계층으로 내려버리는 문제가 있습니다.
이렇게 되면 영속성 계층 변경시 도메인 계층까지 변경해야 할 일이 생길 수 있습니다.

엔티티와 리포지토리의 인터페이스를 도메인 계층으로 올리고, 기존 엔티티와 리포지토리 구현체를 영속성 계층에 두기
또한 아키텍처 룰을 통해 강제하는 것이 좋다.

(사실 계층형 아키텍처에서도 인터페이스 분리 잘 하고, 패키지 잘 두면 어느정도 괜찮습니다)

도메인 모델 풍부하게 만들기

Entity는
- 자신의 상태
- 자신의 규칙
- 의미 있는 행위

Service는
- 유스케이스 흐름
- 엔티티 조합
- 트랜잭션
의 역할을 하게 만들기

헥사고날 아키텍처 적용

해당 아키텍처를 적용하게 되면 계층간 경계가 강해집니다.

  1. controller에서 서비스의 Interface를 호출 하면 (in port)

  2. 이것을 구현한 Service 코드가 동작을 하게되고

  3. 여기서 Service 코드가 adepter에 구현되어 있는 인터페이스인 (out port)를 호출한다.

Domain Entity와 JPA Entity 분리

기존에 사용하던 계층형 아키텍처 구조는 Domain Entity = JPA Entity 였습니다.
하지만 여기서는 두 엔티티를 분리한 후 사이에 양방향 매퍼를 두고 사용합니다.

  • 도메인은 JPA 구조를 전혀 모름
  • JPA 엔티티는 도메인 규칙 없음

즉 Lazy Loading은 영속성 어뎁터 내부에서만 허용하고, 도메인 계층으로 넘어가는 순간 ,Lazy Loading은 끝이 나 있어야 합니다.

사용 패턴 1

  • Repositorey에서 @Query로 join fetch 명시해서 필요한 만큼 로딩
  • Adapter 내부에서 Lazy 로딩 해결하고, 도메인은 완성된 객체만 간다.

사용 패턴 2

  • 전용 Query Port 분리 (읽기 모델)
  • CQRS 스타일 적용 (읽기 쓰기 작업 분리)

또한 OSIV (Open Session In View) 피해야 합니다.

  • Web 계층 까지 영속성 컨텍스트 유지
  • Controller / View에서 Lazy 트리거 가능
  • 아키텍처 경계 붕괴

두 엔티티 사이에서 단방향 매핑을 사용하는 것이 좋습니다.
양방향은 객체 그래프 탐색이 필요할 때 사용하지만, 양쪽 엔티티가 서로를 참조하므로 연관관계 편의 메소드 작성을 해야합니다.
이 아키텍처는 Lazy Loading자체를 포기한 것이 아니고, Lazy Loading을 도메인 설계 도구로 쓰지 않는 것입니다. (수동으로 하는거)

fetch join을 써서 미리 필요한 연관 관계 데이터들을 완성하기
그래도 아래와 같은 상황에서는 안쓰는게 좋다

  • 리스트나 조회 전용
  • 컬렉션 크기가 크거나 잘 모를 때 (메모리 부하)
  • 여러 컬렉션들을 동시에 fetch join (Cartesian Product)
    위 상황일 때는 DB에서 필요한 형태로 바로 조회해서 DTO나 Projection으로 가져오기

다음 글은 이 외에 만들면서 지키면 좋겠다 싶은 짜투리들 모아서 정리해보겠습니다.
틀린점이나 미묘한 점이 있으시면 알려주시면 수정하겠습니다!