
📚 개요
오랜만에 제가 만들던 프로젝트의 패키지 구조를 살펴봤는데 난해하고 이걸 어디에 둬야지 적절하지(?)라는 의문이 들어서 디렉토리 구조를 리팩토링 해보자 라는 목표로 패키지를 재구성하게 되었습니다.
이 글은 MVC 패턴이 아닌 포트 어댑터 패턴에서의 효율적인 구조에 대해 탐구합니다.
순도 100%의 주관적 견해이므로 정답이 아닙니다.
📚 준비물
1. 포트 어댑터 패턴에 대한 기본지식
2. Spirng + Jpa..(?) 크겐 필요없음
3. 개선 사항이 보이면 의견 주시기 😘
📚 정의
목표는 <좋은 패키지 구조> 기 때문에 좋은 디렉토리의 기준을 정의 해보고자 합니다.
제가 생각하는 좋은 패키지 구조란
1. 프로젝트의 규모가 커져도 디렉토리의 변화가 없다
2. 외부 기술스택의 변화가 생기더라도 변화가 없다
3. 디렉토리만 바라보고도 어떠한 역할을 하는 클래스들이 존재하겠구나 추측 할 수 있어야 한다
다른거 다 떠나서 적응하기 쉽고 러닝커브가 낮은 쉬운 구조가 좋은 구조 아니야? 라는 의문이 들 수 있지만
제 기준에서는 러닝커브라는 것은 좋고 나쁨을 말할 때 고려할만한 이유가 아니라고 생각했습니다.
🚨 기존의 구조
├── application // 비즈니스 영역
│ ├── input // 이 영역으로 들어오기 위한 포트 인터페이스
│ ├── output // 이 영역에서 외부로 나가기 위한 포트 인터페이스
│ ├── service // 이 영역과 도메인이 상호작용 하는 방식을 정의한 구현체들
│ └── usecase // 이 영역과 도메인이 상호작용하기 위한 인터페이스
├── domain // 도메인 영역을 정의
│ └── model // 엔티티의 위치
│ ├── event // 카프카 이벤트
│ └── vo // 엔티티에서 사용되는 값 객체들
├── framework // 가장 바깥 영역으로 외부와 연결되는 영역
│ ├── adapter // 이 영역과 외부 영역이 상호작용 하는 방식을 정의한 구현체들
│ │ ├── jpa // jpa 를 통해서 바깥으로 나감, jpa repository 인터페이스 위치
│ │ └── output // 이 영역과 외부 영역이 상호작용하기 위한 인터페이스
│ ├── exception // 커스텀 익셉션들이 위치
│ └── web // 이 영역으로 들어오기 위한 방식이 정의된 구현체들
│ └── dto // 프로젝트 내에서 쓰이는 모든 dto
│ ├── domain // 도메인 영역에서 쓰이는 dto
│ ├── input // controller 에서 요청값을 받을 때 쓰이는 dto
│ └── output // controller 에서 응답값을 반환할 때 쓰이는 dto
├── global // 전체 영역에 관한 내용
│ ├── config // 전체 영역 설정
│ └── exception // 익셉션 핸들러, 익셉션 타입 열거형 존재
└── infra // 외부 서비스(client)에 대한 정의
├── external_service1 // 외부서비스 1
└── external_service2 // 외부서비스 2
기존의 구조는 이러했습니다
당시에 나름 고민하고 생각해서 만든거지만 지금보니 많이 어색..
사실 헥사고날 아키텍처를 처음 적용해보기도 했고, 깊게 고민해보지 않고 했기에 의도가 명확히 없었던 구조라고 생각합니다.
‼️ 문제점
- 전체
- port 와 adapter 의 구분이 애매하다. ( 정확한 포트 어댑터 패턴이라 보기에 애매하다 )
- 구조는 적응할 수 있지만, 디렉토리 존재 이유에 대해 명확한 설명이 불가능
- input , output 명칭은 입력값, 출력값에 더 맞는 이름이어서 in , out 으로 변경이 필요
- Application 디렉토리
- 이대로 써도 무방은 하지만, 각 디렉토리 별로 역할이 명확하지 못하다.
- Domain 디렉토리
- Domain(model)과 Entity가 구분이 되어있지 않아 한 클래스가 여러 역할을 갖는다.
- Entity 는 엄연히 외부기술 (Jpa)에 의존하는 코드들이다
- event야 왜 넌 여깄니..?
- Framework 디렉토리
- framework, adapter 용어의 혼용 ( framework 영역 = adapter 영역 ) 사실상 둘의 차이를 제시하기에 어려워져서 의미가 없는 디렉토리가 추가로 만들어졌다
- exception이 해당 디렉토리에만 존재하고 있어서 다른 곳의 exception을 주관하는 문제
- adapter.output 는 있는데 input이 없다면 output 은 솔로가 되어버려 슬프다
- web , adapter 누가 in 이고 누가 out인지 디렉토리만 보고 알 수가 없다
- 모든 dto가 web 아래에 존재해서 다른 곳의 dto마저 주관하는 문제
- Global 디렉토리
- exception 핸들러도 config의 일환이 아닌가 생각이 들고, handler라고 둬야겠다면 handler 라는 역할의 디렉토리를 두면된다
- Infra 디렉토리
- 인프라구성에 필요한 용도인지 외부 클라이언트에 대한 내용인지 구분 할 수 없다
- 외부 클라이언트에 대한 내용일지라도 Framework 영역에 있어야 하지만 그렇지 않고있다
이 만큼 많은 문제가 있다고 생각이 들었습니다.
🩹 개선
본격적으로 패키지 구조를 개선하기 이전에 포트 어댑터 패턴에 대해서 좀 더 생각을 해봤습니다.
패키지 구조를 변경하더라도 무엇이 중심이 되어야하는가?
패키지 구조는 어떤 특성을 가져야 좋을까?
라는 생각을 가지게 되었습니다.
아래는 헥사고날 아키텍처의 기본 구조입니다
포트 어댑터 패턴으로써 가장 유명한 헥사고날 아키텍처인데 이 아키텍처의 중심에는 Domain이 존재합니다.
그 이유로는
가장 변경이 적은 것 (고수준의 모듈)을 변경이 잦은 것(저수준의 모듈)이 의존하는 방향이어야 한다.
라는 헥사고날 아키텍처의 철학이 담겨있습니다.
저도 이 부분에 집중하여 변동성이 가장 작은 것들은 무엇이 있을지에 대해서 생각하게 되었고, 결론적으로는 두가지가 있다고 생각을 하게되었습니다.
1. Domain 의 특성이 아닌 Domain 그 자체
2. Request, Response의 존재
이 두가지는 변함이 없는 부동가치라고 생각을 했고, 이를 중심으로 기존 구조를 개선하게 되었습니다.
1. 영역 분리
첫번째로 영역을 나눴습니다.
가장 기본적인 디렉토리 3가지이고 추가로 전 영역에 적용 되어야 하는 정보를 위해 Global 디렉토리를 구성했습니다
그리고 저는 외부 기술 (External)을 Application 영역에서의 Port와 마찬가지로 외부 영역과 연결짓는 인터페이스와 같다고 해석 했습니다.
// 신규
├── application
├── domain
├── framework
└── global
// 기존
├── application // 도메인을 감싸고 있는 영역
├── domain // 도메인 영역을 정의
├── framework // 가장 바깥 영역으로 외부와 연결되는 영역
├── global // 전체 영역에 관한 내용
└── infra // 외부 서비스(client)에 대한 정의
위와 같이 가장 기본적인 틀을 생성 하고 가장 중심이 되는 영역부터 설정해나갔습니다.
2. 도메인 영역 디렉토리 구조
도메인이 외부와 상관 없이 도메인 스스로가 무언가를 하는데 있어서 필요한 것들은 무엇이 있을까 생각을 해봤습니다.
1. 도메인 그 자체 -> Domain
2. 도메인의 하위 속성을 표현하기 위한 값 객체 -> VO
3. 도메인이 스스로 일을 하다가 발생한 문제 -> Exception
으로 구성하면 좋겠다는 생각을 했습니다.
// 신규
├── domain // 도메인 영역
├── vo // 도메인의 하위 개념들
│ └── Vo.kt (Vo.java) // enum, class 등
├── exception // 도메인 영역에서의 커스텀 익셉션 모음
│ └── DomainCustomException.kt (DomainCustomException.java) // 도메인 범위의 익셉션
└── Domain.kt (Domain.java) // 도메인을 정의한 POJO 클래스 파일들
// 기존
├── domain // 도메인 영역을 정의
└── model // 엔티티의 위치
├── event // 카프카 이벤트
├── vo // 엔티티에서 사용되는 값 객체들
└── Entity.kt (Entity.java) // JPA 엔티티 (@Entity)
event 처럼 필요없던 디렉토리는 제거했습니다.
3. 어플리케이션 영역 디렉토리 구조
애플리케이션 영역이 가지는 요소들 Usecase , InputPort , OutputPort 들 중 같은 성격을 지닌 InputPort, OutputPort를 묶어서 Port 디렉토리에 위치하게 했습니다.
// 신규
├── application
├── usecase // 유스케이스 모음
│ └── Usecase.kt (Usecase.java)
├── command // 애플리케이션 영역으로 들어오는 Dto
│ └── Command.kt (Command.java)
├── port // 바깥 영역과 연결되는 인터페이스
│ ├── in // 들어오는 포트임을 명확히
│ │ └── InPort.kt (Inport.java)
│ └── out // 나가는 포트임을 명확히
│ └── OutPort.kt (OutPort.java)
└── exception // 애플리케이션 영역의 커스텀 익셉션 모음
└── ApplicationException.kt (ApplicationCustomException.java)
// 기존
├── application // 도메인을 감싸고 있는 영역
├── input // 이 영역으로 들어오기 위한 포트 인터페이스
├── output // 이 영역에서 외부로 나가기 위한 포트 인터페이스
├── service // 이 영역과 도메인이 상호작용 하는 방식을 정의한 구현체들
└── usecase // 이 영역과 도메인이 상호작용하기 위한 인터페이스
4. 프레임워크 영역
Framework 영역을 Application 영역처럼 나누고 싶었는데, SpringBoot 의 특성 상 Controller / Repository 의 방향과 역할이 명확하게 다른 상황이었기에 adapter 를 한 디렉토리로 묶을 수 없는 상황이었습니다.
그래서 저는 변하지 않는 사실 ( 요청 / 응답의 존재 ) 를 기반으로 나눠보았습니다
아래와 같이 두가지 케이스 사이에서 고민이 시작되었습니다.
1. In/Out 을 먼저 나누기
In / Out 을 중심으로 먼저 나누게 보았습니다.
이 방식의 장단점을 나눠보자면
장점
- In / Out 이 주된 디렉토리가 되어 외부의 변경에도 주된 디렉토리가 변경되지 않는다.
- In / Out 이 명확하기에 동작의 흐름을 이해하며 작성하기가 쉬워질 듯 하다.
단점
- Adapter, Dto 디렉토리가 중복 생성된다
- framework.in.adapter.web.~~Controller 처럼 depth 자체가 깊어질 수 있다
- Application 을 나누는 방식과 달라 러닝커브가 상대적으로 높다
2. 역할별로 나누기
이번엔 Adapter , Port 등 역할에 따라 분류를 해보았습니다.
이 방식도 마찬가지로 장단점을 나눠보자면
장점
- 역할에 따른 분류이므로 클래스 / 인터페이스의 위치가 명확히 구분된다
- 상대적으로 depth 가 적어질 수 있다.
단점
- 역할별로 나누기에 역할이 많아질 수록 In / Out 디렉토리가 중복 생성된다
- Dto가 추가된다고 하면 Dto도 In/Out 을 나눠야 함
분명 디렉토리의 구조가 더러워질 것이라고는 생각하지만, 역할로 나누는 것이 Application 영역과의 일치성을 가질 것이고 이 것은 결국 통일된 구조로써 명확해지는 구조가 될 것이라고 생각했습니다.
In/Out 으로 구분이 시작되지는 않지만 역할 내부에서 In/Out 을 지정하기에 외부의 변화에도 큰 변화가 없다고 생각하여 구조가 더러워지는 것은 Trade-Off 로 생각하고 후자를 선택했습니다.
// 신규
├── framework
├── adapter // 구현체 위치
│ ├── in
│ │ └── web // 웹 요청으로 들어오는 어댑터
│ │ │ └── Controller.kt
│ │ └── broker // Kafka, RabbitMQ 등으로 들어오는 어댑터
│ │ └── Consumer.kt
│ └── out
│ ├── repository // Application.port.Output 의 구현체 위치
│ │ └── Repository.kt
│ └── broker // Kafka, RabbitMQ 등으로 나가는 어댑터
│ └── Producer.kt
├── dto
│ ├── in
│ │ └── InDto.kt // adapter로 들어올 때 사용되는 dto
│ └── out
│ └── OutDto.kt // adapter 를 통해 반환 할 때 사용되는 dto
├── port
│ ├── in // 요청 http 등등
│ │ └── request.http
│ └── out
│ └── jpa
│ ├── entity
│ │ └── Entity.kt
│ └── JpaRepository.kt
└── exception // 프레임워크 영역의 커스텀 익셉션 모음
└── FrameworkException.kt
// 기존
├── framework // 가장 바깥 영역으로 외부와 연결되는 영역
├── adapter // 이 영역과 외부 영역이 상호작용 하는 방식을 정의한 구현체들
│ ├── jpa // jpa 를 통해서 바깥으로 나감, jpa repository 인터페이스 위치
│ └── output // 이 영역과 외부 영역이 상호작용하기 위한 인터페이스
├── exception // 커스텀 익셉션들이 위치
└── web // 이 영역으로 들어오기 위한 방식이 정의된 구현체들
└── dto // 프로젝트 내에서 쓰이는 모든 dto
├── domain // 도메인 영역에서 쓰이는 dto
├── input // controller 에서 요청값을 받을 때 쓰이는 dto
└── output // controller 에서 응답값을 반환할 때 쓰이는 dto
구조가 많이 커지긴 했지만, 적어도 이 파일이 어디에 위치하면 좋을까? 라는 부분은 많이 해소되었다고 생각합니다.
최종 구조
├── application
│ ├── usecase
│ ├── command
│ ├── port
│ │ ├── in
│ │ └── out
│ └── exception
├── domain
│ ├── vo
│ └── exception
├── framework
│ ├── adapter
│ │ ├── in
│ │ │ └── web
│ │ │ └── broker
│ │ └── out
│ │ ├── repository
│ │ └── broker
│ ├── dto
│ │ ├── in
│ │ └── out
│ ├── port
│ │ ├── in
│ │ └── out
│ └── exception
└── global
├── config
├── extends
└── util
헥사고날 아키텍처, 포트어댑터 패턴에 대해서 공부하고 정답은 없다라는 생각으로 스스로 생각하면서 구조를 작성해봤습니다.
따라서 논리적으로 이상한 부분이나, 기존 구조와 충돌하는 부분이 있을텐데 이 부분은 주관이다 생각하고 봐주셨으면 감사하겠습니다.
'CS > ARCHITECTURE' 카테고리의 다른 글
우당탕탕 헥사고날 아키텍처 적용하기 (0) | 2025.03.20 |
---|
백엔드는 못말려
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!