백엔드 실용주의 디자인
목차
백엔드 프로젝트를 만들다 보면 구조에 대한 고민을 피해 갈 수 없습니다. 많은 경우 기능 기반 구조(feature-based structure) 를 채택해, 비슷한 관심사의 코드를 한곳에 모으죠. 기능 기반 구조는 관련된 코드의 응집도가 높아, 처음에는 타입(레이어) 기반 구조보다 훨씬 깔끔해 보입니다. 하지만 얼마 가지 않아 기능 사이의 결합을 고민하게 됩니다.
이런 문제의 해법은 클린 아키텍처나 헥사고날 아키텍처처럼 잘 알려진 방법론에서 찾을 수 있습니다. 다만 제 견해로 이 방식들은 SOLID 원칙 대부분을 충실히 지키려다 보니 적지 않은 복잡도를 떠안습니다. 게다가 ‘정석’이 곧 ‘나에게 맞는 것’은 아니죠. 이 글은 기능 사이의 참조가 일어나는 근본적인 원인을 먼저 짚고, 비교적 가벼운 방법부터 점진적으로 구조를 개선해 가는 여러 선택지를 다룹니다.
먼저 이 글이 전제하는 환경을 못박고 가겠습니다. 모놀리식 프로젝트이면서 단일 DB를 쓰는 환경입니다. MSA나 다중 DB처럼 분산을 전제로 한 이야기는 다루지 않아요. 다만 모놀리식 안에서 동기식 이벤트를 활용하는 방법은 뒤에서 함께 살펴봅니다.
Note
이 글은 실용적인 구조를 세우기 위해도메인 주도 설계의 몇 가지 개념을 빌려옵니다. 도메인 주도 설계 용어가 낯설다면 경계를 긋는 언어, 도메인 주도 설계를 먼저 읽고 오시길 권합니다. 모놀리식 단일 DB 환경에서 이 개념들을 어떻게 실용적으로 받아들였는지 정리해두었어요. 이미 익숙하다면 그대로 읽어 내려가셔도 좋습니다.
Feature based structure
기능 기반 폴더 구조는 로버트 C. 마틴이 말한 스크리밍 아키텍처(Screaming Architecture) 의 철학과 맞닿아 있습니다. 프로젝트의 구조가 어떤 기술을 썼는지가 아니라 무엇을 하는 서비스인지, 즉 도메인을 드러내야 한다는 생각이죠. 대략 이런 모습입니다.
.
└── src/
├── auth
├── user
├── category
├── comment
├── notification
├── post/
│ ├── post.controller
│ ├── post.service
│ ├── post.entity
│ ├── post.repository
│ └── dto/
└── tag
위 구조는 블로그 도메인을 다루는 프로젝트의 대략적인 모습이에요. 앞으로 이렇게 기능 단위로 나뉜 폴더 하나하나를 ‘모듈’이라고 부르겠습니다. 이 정도 작은 규모에서도 모듈 사이의 결합은 어렵지 않게 나타납니다. 두 가지만 예로 들어볼게요.
- 회원가입. 가입 요청을 받는 진입점은 보통
auth모듈에 둡니다. 그런데 계정을 만들면서 사용자 프로필도 함께 생성해야 한다면?auth모듈의 서비스가user모듈의 엔티티와 리포지토리를 가져다 쓰게 됩니다. - 게시물 발행과 구독자 알림.
post모듈에서 글이 발행되면 구독자에게 보낼 알림 이력을 남겨야 한다고 해보죠. 이때post모듈의 서비스가notification모듈을 호출하게 됩니다.
그림처럼 한 모듈의 서비스가 다른 모듈의 리포지토리나 엔티티에 손을 뻗는 일이 생깁니다. 왜 이런 일이 자연스럽게 벌어지는지, 그 원인부터 짚어보죠.
요구사항의 흐름과 도메인 모델은 서로 다른 축이다
모듈 안에는 아키텍처의 여러 레이어가 함께 담깁니다. 컨트롤러는 프레젠테이션 레이어, 서비스는 애플리케이션 레이어, 엔티티와 리포지토리 인터페이스는 도메인 레이어, 리포지토리 구현체는 인프라스트럭처 레이어에 속하죠.
문제는 모듈을 가르는 단위가 대체로 애그리거트 수준이라는 데 있습니다. 애그리거트는 함께 일관성을 지켜야 하는 변경의 단위입니다. 쉽게 말해 하나의 엔티티, 또는 생명주기를 같이하는 엔티티 묶음을 다루는 단위죠.
그런데 하나의 요구사항이 딱 하나의 애그리거트로 끊어지는 경우는 드뭅니다. 일반·지원 하위 도메인은 자기 영역 안에서 CRUD만으로 끝나는 일이 많지만, 핵심 하위 도메인은 다른 하위 도메인들과의 상호작용을 필요로 하거든요. 결국 애그리거트를 조립하고 사용하는 오케스트레이션 서비스는, 다른 모듈의 애그리거트를 끌어다 쓸 가능성이 늘 열려 있는 셈입니다.
미리 말해두자면, 이 구조가 나쁘다는 뜻은 아니에요. 이렇게 두어도 모듈 사이의 참조가 대체로 단방향이고 관리 못 할 수준이 아니라면, 충분히 타당한 결정입니다. 다만 ‘모듈이 정말 잘 분리된 건가’는 별개의 질문이죠. 의도를 갖고 모듈을 나눴는데, A 모듈의 서비스가 B 모듈의 리포지토리나 엔티티를 참조해도 정말 괜찮은 걸까요? 참조해도 된다면 그 기준은 무엇일까요? 이런 의문이 한 번이라도 들었다면, 이제 자신이 타협할 수 있는 기준을 정의할 때가 온 겁니다.
관련된 모듈을 묶기
앞서 말했듯 모듈은 저마다 자기 기능에 관련된 서비스를 가집니다. 이 서비스는 보통 애플리케이션(유즈케이스) 레이어에서 여러 객체를 조율하는 오케스트레이션 성격을 띠죠. 그 안에는 하나 또는 여러개의 애그리거트들이 상호작용하며 기능을 만들어갑니다.
그래서 서비스는 자기 모듈의 엔티티·리포지토리를 다루는 건 기본이고, 요구사항에 따라 다른 모듈의 엔티티나 리포지토리까지 가져다 씁니다.
‘다른 모듈의 내부를 직접 가져다 쓰는 건 모듈을 나눈 의도에 어긋난다. 그러니 외부가 정말 필요로 하는 기능만 따로 만들어 제공하고, 내 엔티티·리포지토리는 감추겠다’. 이렇게 생각할 수도 있어요. 이것도 하나의 정답이고, 뒤에서 다룰 방법 중 하나입니다. 다만 장점이 있으면 단점도 있죠. 결국 그만큼 비용이 듭니다.
그렇다면 그 비용을 치르기 전에, 이 참조가 정말 합리적인지부터 따져볼 수 있습니다. 한 기능에 관련된 기술의 집합을 모듈이라고 했는데, 사실 모듈끼리도 더 큰 묶음을 이룰 수 있거든요. 도메인 주도 설계에서는 이 묶음을 바운디드 컨텍스트(Bounded Context) 라고 부릅니다. 앞으로는 줄여서 컨텍스트라고 하겠습니다.
Note
엄밀한 정의는 아니지만, 이 글의 맥락에서는 이렇게 정리해두면 편합니다. 기능 기반 구조의 모듈은 대체로 애그리거트 단위로 관리되고, 하나의 컨텍스트는 여러 애그리거트(=모듈)를 품을 수 있습니다. 그래야 비로소 한 컨텍스트에 필요한 도메인 모델이 모두 모였다고 볼 수 있어요.
예를 들어볼게요. 앞에서 나눈 post, category, comment, tag 모듈은 사실 사용자에게 콘텐츠를 보여주기 위한 한 묶음으로 볼 수 있습니다. 즉 ‘콘텐츠’가 블로그 도메인의 핵심 하위 도메인이자 하나의 컨텍스트인 거죠. 블로그의 존재 이유가 바로 콘텐츠니까요. auth와 user도 마찬가지입니다. 가입 진입점을 auth에 뒀지만 계정 엔티티에 사용자 프로필이 얽혀 user 모듈을 끌어다 쓰던 그 상황이 그렇죠. 이 둘은 보통 IAM(Identity & Access Management) 이라는 일반 하위 도메인, 즉 하나의 컨텍스트로 묶입니다.
src/
├── content/ # 콘텐츠 컨텍스트 (핵심)
│ ├── post/
│ ├── category/
│ ├── comment/
│ └── tag/
├── iam/ # IAM 컨텍스트 (일반)
│ ├── auth/
│ └── user/
└── notification/ # 알림 컨텍스트 (지원)
제 경험상 한 컨텍스트 안의 모듈끼리는 협력이 잦습니다. 그래서 같은 컨텍스트 안에서는 한 모듈의 서비스가 다른 모듈의 리포지토리나 엔티티를 참조하는 게 자연스럽다고 봐요. 오히려 이 안에서까지 굳이 공유용 서비스를 만들어 기능을 우회 제공하는 건 마이너스라고 생각합니다.
미리 말씀드리면, 이렇게 하위 도메인을 식별하는 방식은 도메인마다 다르고 해석도 갈립니다. 중요한 건 우리가 무심코 나눈 모듈들이 관련된 것끼리 다시 묶일 수 있다는 점이에요. 도메인 모델을 먼저 설계하고 들어갔다면 처음부터 보였을 테지만, 이렇게 점진적으로 발견해 가는 것도 충분히 가능합니다.
컨텍스트 간의 결합
이렇게 컨텍스트를 잘 나눠도, 컨텍스트 사이의 참조는 여전히 발생합니다. 그리고 바로 여기가, 우리가 진짜로 풀어야 할 결합의 영역이에요.
다시 강조하지만 우리는 모놀리식 단일 DB 환경을 전제로 합니다. 마음만 먹으면 다른 컨텍스트의 도메인 모델이 관리하는 영속성 계층을 침범하는 게 어렵지 않죠. 그래서 의도적으로 나눈 컨텍스트가 다른 컨텍스트를 참조한다면, 어떤 이유일 때 어디까지 허용할지 선을 분명히 그어야 합니다.
다른 컨텍스트를 참조하는 진짜 이유
컨텍스트 밖을 참조하는 이유는 크게 세 가지입니다.
- 다른 컨텍스트의 데이터를 조회해야 할 때
- 다른 컨텍스트의 기능이 한 트랜잭션으로 함께 보장되어야 할 때
- 비교적 단순하게, 기술적인 외부 모듈을 가져다 쓸 때
그리고 이를 풀어내는 방법으로 이 글에서는 세 가지를 살펴봅니다. 폴더 구조로 참조 방향을 제어하기, 인터페이스를 이용한 DIP, 그리고 모놀리식에서도 쓸 수 있는 동기식 이벤트입니다.
폴더 구조로 참조 방향 제어하기
이 방법의 핵심은, 결합을 일으키는 오케스트레이션 서비스를 별도 레이어로 분리하는 것입니다. 그 안에서도 변형이 많지만, 레이어가 늘어날수록 기능 기반 구조의 장점이 옅어지니 저는 두 개의 레이어로 푸는 방식을 보여드릴게요.
그 전에 짚어둘 게 있어요. 컨텍스트가 2~3개뿐이고 참조가 대체로 단방향이라면, 이 방법은 과할 수 있습니다. 그럴 땐 단방향 참조를 그냥 인정하고 두는 편이 나아요. 하지만 중규모 이상으로 넘어가면 그런 행복한 상황은 잘 오지 않고, 그때 가장 간단하게 손댈 수 있는 방법이 이것입니다.
핵심은 간단합니다. 컨텍스트를 넘어 참조당하는 쪽을 더 하위 레이어로 내리는 것이죠.
- 상위 레이어: 도메인 모델을 가져다 쓰는 컨트롤러·오케스트레이션 서비스 모듈
- 하위 레이어: 도메인 모델과 인프라스트럭처 모듈
여기에 세 가지 규칙을 둡니다. ① 상위 레이어는 하위 레이어를 참조할 수 있다. ② 같은 레이어의 모듈끼리는 서로 참조하지 않는다. ③ 그래도 남는 예외는 팀의 합의로 정한다. 이 규칙들이 익숙하다면 맞습니다. 폴더 구조 글에서 다룬 Layer Import Rule과 같은 사고예요.
이렇게 하면 하위 레이어의 도메인 엔티티·리포지토리를 상위 레이어의 여러 모듈이 공평하게 가져다 쓸 수 있습니다. 그러다 같은 사용 패턴이 반복되면, 그때 좀 더 추상적인 서비스로 묶어 재사용 기능으로 제공하면 되고요.
필요할 때마다 하나씩 내릴 수도, 일관성을 위해 한 번에 분리할 수도 있습니다. 엔티티나 리포지토리 인터페이스를 참조하는 상위 모듈이 많아지면서 불필요한 기능이 끼어드는 오염의 우려는 있지만, 저는 감당할 만한 트레이드오프라고 봅니다. 우려된다면 하위 모듈에서 한 번 더 캡슐화하는 식으로 조절하면 되고요. 구체적인 방식은 정하기 나름입니다.
인터페이스를 통한 DIP
앞의 방식과 달리, 모듈을 한 레이어 폴더에서 관리하면서 내부 리포지토리·엔티티를 직접 노출하지 않는 방법입니다. 외부가 필요한 만큼만 드러내는 인터페이스를 제공하거나, 순환 참조의 조짐이 보이면 DIP(의존성 역전) 로 모듈 사이를 단방향으로 되돌리는 데 집중하죠.
이 방향은 헥사고날 아키텍처와 닮아 있습니다. 복잡도를 낮추기 위해, 자주 참조당하는 모듈은 그 모듈이 직접 기능을 공유하거나 공유 커널(Shared Kernel) 을 활용할 수도 있어요.
동기 이벤트 버스를 통한 의존성 줄이기
앞의 두 방법이 참조를 허용하되 방향을 다스리는 쪽이었다면, 이번엔 참조 자체를 끊어내는 접근입니다.
post 모듈이 발행 후 notification 모듈을 직접 호출하는 대신, ‘글이 발행되었다’는 사실(이벤트)만 알리고 손을 떼게 하는 거죠. 알림을 남기는 일은 그 이벤트를 구독하는 핸들러가 맡습니다. 그러면 post는 더 이상 notification을 알 필요가 없어집니다. 의존 방향이 끊어지는 거예요.
Important
여기서 말하는 이벤트는모놀리식 안에서 동기적으로 처리되는 인-프로세스 이벤트입니다. 메시지 브로커를 두는 분산 비동기 이벤트가 아니에요. 같은 트랜잭션 안에서 핸들러가 함께 실행되므로, 게시물 생성과 알림 이력 생성을 하나로 묶어야 한다는 일관성 요구도 그대로 지킬 수 있습니다.
덕분에 모듈 간 결합은 느슨해지지만, 공짜는 아닙니다. 흐름이 코드에서 한눈에 안 보이고 디버깅이 간접적이 되죠. 하지만, 규모가 커질수록 복잡도 대비 이점이 많은 방법이라고 생각합니다.
얕은 CQRS와 읽기 전용 모델
폴더 레이어로 푸는 방식과 달리, 지금까지의 두 방법(인터페이스·이벤트)은 한 레이어 안에서 협력하는 모듈들의 참조를 느슨하게 하거나 끊는 데 초점이 있었습니다. 그런데 실제로 복잡도를 키우는 진짜 골칫거리는 따로 있어요. 바로 조회입니다.
앞서 말했듯 우리는 모놀리식 단일 DB를 전제로 합니다. 데이터에는 언제든 접근할 수 있죠. 그렇다면 굳이 컨텍스트를 나누는 핵심 이유가 무엇인지 다시 떠올려야 합니다. 관련된 도메인 모델의 생명주기를 제대로 관리하는 것, 즉 데이터의 생성·수정·삭제 같은 쓰기(write) 가 우리가 세운 규칙과 정책의 틀 안에서만 일어나게 하는 것이죠. 쓰기와 달리 조회(read) 는 그 틀을 똑같이 따를 필요가 없습니다. 명령(쓰기)과 조회를 분리하는 CQRS의 발상을 얕게 빌려오는 셈인데, 그 조회는 다시 두 갈래로 나뉩니다.
첫째, 화면에 보여줄 데이터입니다. 목록 화면 하나에 글 제목, 작성자 이름, 댓글 수, 카테고리가 한꺼번에 필요할 수 있죠. 여러 컨텍스트에 흩어진 데이터예요. 이럴 때는 쓰기 모델을 거칠 것 없이, 화면이 필요로 하는 모양 그대로 가져오는 전용 쿼리 서비스를 두는 게 낫습니다. 컨텍스트 경계를 가로질러 조인하든 DB 뷰를 쓰든 상관없어요. 어차피 쓰기를 건드리지 않는 순수한 읽기니까, 화면용 데이터에까지 경계의 규칙을 강요할 이유는 없습니다.
둘째, 비즈니스 흐름 안에서 필요한 조회입니다. 이건 결이 다릅니다. 다른 컨텍스트의 데이터를 검증하거나 흐름의 입력으로 삼아야 할 때죠.
예를 들어 알림 컨텍스트에서 구독자들에게 알림을 보내려고, 사용자(IAM 컨텍스트)의 데이터를 미리 조회해야 한다고 해보죠. (동기 이벤트를 쓰더라도 이벤트는 행위를 호출하는 것이지 데이터를 받아오는 게 아니라서, 조회 문제는 그대로 남습니다.) 이때 IAM 쪽에 조회 인터페이스를 만들어 주거나, 공유 커널에 ‘사용자 목록 조회’를 그냥 열어두기로 정할 수도 있어요.
하지만 이게 정말 문제를 푸는 길일까요? 알림 컨텍스트가 원하는 건 사실 사용자가 아니라 수신자(recipient) 아닐까요? 경계를 긋는 언어, 도메인 주도 설계에서도 말했듯, 애플리케이션 아키텍처와 데이터 아키텍처는 문제 해결의 층위가 다릅니다. 테이블이 하나라고 해서, 모델을 한곳에서만 정의하고 그 모델로만 접근해야 하는 법은 없어요. 데이터를 생성·변경하는 주체는 한 컨텍스트에서 관리하되, 비즈니스 흐름에서 그 데이터가 다른 의미로 쓰인다면, Recipient처럼 내 컨텍스트의 언어로 된 읽기 전용 모델을 만들어 자기 리포지토리로 같은 테이블을 조회해 쓰면 됩니다.
정리하면 이렇습니다. 쓰기 모델은 컨텍스트 경계와 규칙을 엄격히 지키되, 조회는 두 길로 풉니다. 화면을 위한 데이터는 전용 쿼리 서비스가 필요한 모양대로 직접 가져오고, 비즈니스 흐름에서 쓰는 데이터는 내 경계의 언어로 된 읽기 전용 모델로 가져오는 거죠. 무거운 CQRS 인프라(별도 저장소, 이벤트 소싱) 없이도, 단일 DB 위에서 경계를 지키면서 조회 비용을 낮출 수 있습니다.
마치며
여기까지, 기능 사이의 결합이 왜 생기는지(요구사항의 흐름과 도메인 모델은 서로 다른 축이기 때문이죠), 그리고 그것을 점진적으로 풀어가는 방법들을 훑었습니다. 관련된 모듈을 컨텍스트로 묶고, 폴더 레이어로 참조 방향을 다스리고, 인터페이스로 DIP를 걸고, 동기 이벤트로 의존을 끊고, 얕은 CQRS로 조회를 분리하는 식으로요.
눈치채셨겠지만 이 글에는 구체적인 코드가 거의 없습니다. 각 방법의 실제 구현은 그 자체로 글 한 편씩을 들여야 할 만큼 방대하고, 저도 아직 이런저런 방식을 두고 연구하는 상황입니다. 참고할 만한 예제가 어느 정도 정리되면, 구현 이야기는 별도의 글로 다시 가져오려 합니다.
끝까지 읽어주셔서 감사합니다
다른 글도 둘러보세요