1. 계층형 패키지의 한계와 Clean Architecture의 피로감
새로운 Spring Boot 프로젝트를 시작할 때 가장 먼저 마주하는 고민은 "패키지(디렉토리) 구조를 어떻게 나눌 것인가?"입니다.
전통적으로 많이 사용하는 방식은 기술적 역할을 기준으로 최상위 패키지를 나누는 계층형 구조(Package by Layer)입니다.
com.example.app
├── controller/
├── service/
├── repository/
├── entity/
└── dto/
처음 도메인이 2~3개일 때는 이 구조도 충분히 직관적입니다. 하지만 서비스가 커지고 도메인이 10개만 넘어가도 문제가 드러납니다. service/ 폴더 안에 수십 개의 클래스가 뒤섞이고, 게시글 기능을 하나 수정하기 위해 controller, service, repository, entity, dto 폴더를 끝없이 오가야 합니다. 관련 있는 코드가 흩어지니 응집도는 낮아지고 도메인 간 경계는 금세 허물어집니다.
이를 해결하기 위해 많은 팀이 엄격한 Clean Architecture나 Hexagonal Architecture(Port & Adapter)를 도입합니다. 하지만 모든 도메인에 이를 기계적으로 적용하면 또 다른 극단인 '보일러플레이트 지옥'에 빠지기 쉽습니다.
단순한 조회나 상태 변경 하나를 구현하기 위해 다음과 같은 파일들을 매번 만들어야 했던 경험이 있으실 겁니다.
Controller→UseCase (In-Port Interface)→Service→Out-Port Interface→PersistenceAdapter→JpaRepositoryRequest DTO→Command DTO→Domain Entity→JpaPersistenceEntity→Response DTO각 계층 사이를 변환하기 위한 2~3개의
Mapper클래스
아키텍처가 비즈니스 문제를 해결하기 위한 수단이 아니라, 아키텍처 규칙을 맞추기 위해 코드를 짜는 주객전도가 발생하는 것입니다.
개발자 블로그 및 포트폴리오 플랫폼 Pebble(pebble-api)의 백엔드를 설계하며 세운 핵심 목표는 명확했습니다.
"도메인의 경계와 책임은 엄격하게 지키되, 형식적인 추상화와 보일러플레이트는 과감히 걷어낸다."
이 글에서는 Pebble 백엔드가 선택한 Package by Feature, DDD-lite, 그리고 Architecture Restraint(아키텍처 절제) 원칙과 실제 디렉토리 구조를 소개합니다.
2. 최상위 디렉토리 구조: Package by Feature
Pebble 백엔드는 기술적 계층이 아니라 비즈니스 기능(Feature) 단위로 최상위 패키지를 구성합니다. 도메인 책임이 가까운 코드끼리 한곳에 모이도록 설계했습니다.
com.pebble.api
├── global/ # 특정 도메인에 속하지 않는 순수 공통 기술 인프라
├── auth/ # 로그인, OAuth 인증, 토큰 발급 및 갱신 흐름
├── member/ # 일반 회원 계정, 상태, 탈퇴 조정, 개인 블로그 설정
├── admin/ # 관리자 계정 및 운영 조정, 감사(Audit)
├── post/ # 기술 블로그 게시글과 구조화 본문 블록(HTML, 코드, 아키텍처, ERD 등)
├── project/ # 개발 프로젝트(포트폴리오)와 6대 섹션 구조화 문서
├── category/ # 관리자가 운영하는 플랫폼 공통 2단계 주제 분류
├── techstack/ # 게시글 태그 및 프로젝트 기술 스택에 쓰이는 표준 기술 어휘
├── board/ # 회원이 자신의 게시글을 정리하는 개인 게시판 (최대 3단계 트리)
├── comment/ # 게시글·프로젝트에 대한 댓글 (회원 비밀댓글 & 비회원 댓글)
├── like/ # 게시글·프로젝트에 대한 좋아요
└── report/ # 콘텐츠 신고 및 운영 처리
이와 함께 리소스 디렉토리(src/main/resources)는 스키마 형상 관리와 원자적 연산 스크립트를 관리합니다.
src/main/resources
├── db/migration/ # Flyway 기반 DB 스키마 버전 관리 (V1 ~ V40)
├── redis/ # Refresh Token 원자적 상태 전이를 위한 Lua 스크립트
└── application*.yaml
Package by Feature의 장점
비즈니스 도메인의 즉각적인 파악: 프로젝트를 처음 여는 사람도 최상위 폴더만 보면 이 서버가 어떤 비즈니스 도메인들로 이루어져 있는지 단번에 이해할 수 있습니다.
높은 응집도와 변경의 지역화: 게시글(
post) 관련 요구사항이 변경되면 오직post/패키지 내부에서만 작업이 이루어집니다.명확한 소유권: 각 Feature가 자신의 도메인 상태와 규칙을 배타적으로 소유하므로, 다른 도메인의 데이터를 무분별하게 수정하는 일을 막을 수 있습니다.
3. Feature 내부 구조: 책임 기반 4-Layer와 DDD-lite
각 Feature 내부는 책임 기반 배치(Responsibility-Based Placement) 원칙에 따라 4개의 계층으로 구성합니다.
post/
├── presentation/ # HTTP 요청/응답 처리 (Controller, Request/Response DTO)
├── application/ # 유스케이스 실행 및 흐름 조정, 트랜잭션 경계 (PostService, PostQueryService)
├── domain/ # 핵심 비즈니스 규칙, 엔티티, 도메인 에러 (Post, PostBlock, PostError)
└── infrastructure/ # 외부 기술 구현 (Spring Data JPA Repository 등)
의존 방향은 항상 핵심 비즈니스 규칙이 담긴 domain을 향하도록 단방향으로 유지합니다.
presentation ──> application ──> domain <── infrastructure
여기서 Pebble은 과도한 추상화를 막기 위해 3가지 DDD-lite 규칙을 적용했습니다.
1) 필요한 계층만 생성한다 (빈 폴더 금지)
모든 Feature가 4개 계층의 모든 구성 요소를 기계적으로 가질 필요는 없습니다. 클래스의 배치 기준은 파일 이름이 아니라 실제 책임이며, 특정 계층에 둘 코드가 없다면 빈 디렉터리를 미리 만들지 않습니다.
2) Domain Entity와 JPA Entity를 무조건 분리하지 않는다
영속성 엔티티(PostJpaEntity)와 도메인 엔티티(Post)를 무조건 쪼개고 매번 Mapper로 변환하는 비용을 치르지 않습니다. JPA Entity가 도메인의 상태와 핵심 비즈니스 규칙(도메인 메서드)을 함께 가지도록 응집시켰습니다.
단, 도메인 모델을 안전하게 보호하기 위해 다음 규칙을 철저히 지킵니다.
Entity에
@Data나 무분별한 공개 Setter를 사용하지 않습니다.상태 변경은 도메인 의미를 가진 메서드를 통해서만 수행합니다.
Entity를
presentation계층(API Response)으로 절대 직접 반환하지 않습니다.
3) 습관적인 Interface, Port/Adapter, 계층별 DTO를 만들지 않는다
불필요한 Interface 지양: 구현체가 하나뿐인
PostServiceImpl이나 형식적인Port/Adapter를 만들지 않습니다. Spring Data JPA의 기본 기능으로 충분하면 그대로 사용하고, 외부 OAuth 연동(OAuthProviderClient)이나 스토리지(R2ObjectStorage)처럼 기술 구현 분리나 다형성이 실질적으로 필요한 곳에만 인터페이스를 도입합니다.DTO 중복 방지:
presentation/dto,application/dto,domain/dto를 계층마다 복사해서 만들지 않습니다. 외부 API와 내부 도메인의 경계를 나누는presentation/dto를 기본으로 사용하고, 내부 계층 간 전달에 정말 별도의 표현이 필요할 때만 레코드/객체를 정의합니다.Command와 Query의 실용적 분리: 무거운 CQRS 인프라 대신, 상태 변경과 도메인 조정을 담당하는
PostService와 복잡한 조회·페이징·집계를 담당하는PostQueryService를 분리하여 서비스 클래스가 비대해지는 것을 방지했습니다.
4. 도메인 경계를 지키는 4가지 핵심 아키텍처 의사결정
보일러플레이트를 걷어냈다고 해서 도메인 경계까지 느슨하게 둔 것은 아닙니다. 오히려 Feature 간 책임과 소유권의 경계는 훨씬 더 선명하게 그었습니다.
의사결정 1. 왜 Post와 Project를 Content라는 상위 도메인으로 묶지 않았을까?
게시글(Post)과 개발 프로젝트(Project)는 둘 다 작성자가 있고, 공개 여부를 설정할 수 있으며, 댓글과 좋아요가 달립니다. 흔히 이러한 표면적 유사성만 보고 Content라는 상위 추상 엔티티나 공통 패키지로 묶으려는 유혹에 빠집니다.
하지만 비즈니스 관점에서 두 도메인의 목적과 규칙은 완전히 다릅니다.
post/ ──> 기술 지식과 개발 경험을 기록하는 블로그 게시글
project/ ──> 개발 프로젝트 / 포트폴리오 아카이빙
Post: 개인 게시판(Board)에 배치되고, 플랫폼 공통Category를 가지며, 마크다운/HTML·코드·다이어그램 등의 순차적 블록(PostBlock)으로 구성됩니다.Project: 프로젝트 진행 상태(IN_PROGRESS/COMPLETED), 배포 및 GitHub 링크, 주요 기능 목록(ProjectFeature), 6대 섹션 구조화 문서를 가지며, 여러 개의 관련Post를 하위 개발 기록으로 연결합니다.
공통 UI나 일부 필드가 겹친다는 이유로 하나의 Content 도메인으로 묶으면, 요구사항이 진화할 때마다 if (type == PROJECT) 같은 분기문이 도메인 전반을 오염시킵니다. 따라서 Pebble은 처음부터 두 도메인을 완전히 독립된 Feature로 분리했습니다.
의사결정 2. 운영 주체와 데이터 소유권의 분리 (admin vs post / project)
관리자가 부적절한 게시글을 차단하거나 강제 삭제하는 기능을 구현할 때 가장 흔히 저지르는 실수는 admin 패키지 안에 PostRepository를 직접 주입받아 데이터를 조작하는 것입니다.
# 지양하는 구조 (데이터 소유권 침범)
admin/
└── AdminContentService ──(직접 조작)──> PostRepository
이렇게 되면 게시글의 공개/차단/삭제 상태 전이 규칙이 post와 admin 두 군데로 파편화됩니다. Pebble은 "운영 주체와 데이터 소유자는 다르다"는 원칙을 세웠습니다.
# Pebble의 구조 (도메인 소유권 존중)
admin/ (운영 주체: 권한 확인 및 감사 로그 기록)
│
▼
post/ (데이터 소유자: 차단 상태 변경 및 도메인 규칙 검증)
관리자가 콘텐츠를 차단할 때, admin Feature는 관리자 운영 흐름과 감사(Audit)만 책임지고 실제 상태 변경은 데이터 소유자인 post나 project Feature가 제공하는 유스케이스를 호출해 처리합니다. 카테고리(category)나 기술 스택(techstack) 관리 역시 마찬가지입니다.
의사결정 3. global이 만능 쓰레기통(Utils)이 되지 않게 막는 기준
많은 프로젝트에서 global이나 common 패키지는 어디에 둘지 애매한 코드를 전부 모아두는 공용 폴더로 전락하곤 합니다. Pebble에서는 다음 세 가지 조건을 모두 만족할 때만 global 배치를 허용합니다.
특정 Feature에 속하지 않는다.
여러 Feature에서 공통으로 사용된다.
어떠한 Feature의 비즈니스 책임도 포함하지 않는다.
대표적인 예가 미디어 파이프라인(global/media)과 예외 처리(global/exception)입니다.
미디어 처리의 경계:
global/media는 이미지를 검증·리사이즈하여 WebP로 재인코딩하고, 비공개 Cloudflare R2 버킷에 저장하며, 15분 만료 HMAC 서명 URL(MediaCdnSigner)을 생성하는 순수 기술 기능만 제공합니다. 반면 "현재 요청자가 이 게시글의 이미지를 업로드하거나 조회할 권한이 있는가?"라는 비즈니스 판단은post와projectFeature가 직접 검증한 뒤에global/media를 호출합니다.예외 처리의 경계:
global/exception에는 공통 규격인ErrorCode인터페이스와ApplicationException, 그리고 이를 HTTP 응답으로 변환하는ApiExceptionHandler만 존재합니다. 구체적인 도메인 에러(PostError,MemberError,AuthError등)는 각 Feature의domain패키지가 직접 소유합니다.
의사결정 4. 공통 엔티티 기반과 식별자 전략 (TSID & BaseTimeEntity)
애플리케이션 레벨 TSID 생성: 모든 엔티티의 기본키(PK)는 DB의
AUTO_INCREMENT나 Sequence에 의존하지 않고,global/id/TsidGenerator를 통해 시간순 정렬이 보장되는 64비트 양수(BIGINT) TSID(Time-Sorted Unique Identifier)를 생성합니다. 이를 통해 B-Tree 인덱스의 삽입 성능을 지키면서도 외부 URL에서 리소스 ID를 순차 추측하는 공격을 방어합니다. 또한 JavaScript의Number정밀도 한계(53비트)를 고려해 모든 API 응답에서는 ID를 10진 문자열(String)로 직렬화합니다.엔티티 시간 필드의 실용적 공통화:
global/persistence의BaseCreatedEntity(createdAt)와BaseTimeEntity(createdAt,updatedAt)를@MappedSuperclass로 정의해 반복되는 생성·수정 시각 기록을 공통화했습니다. 반면 게시글 최초 발행 시각(publishedAt)이나 회원 탈퇴 관련 시각처럼 비즈니스 의미가 담긴 시간 필드는 공통 클래스로 빼지 않고 각 도메인 엔티티가 직접 소유합니다.
5. 아키텍처는 목적이 아니라 수단이다 (Architecture Restraint)
Pebble 백엔드는 새로운 기능이나 구조를 추가할 때 항상 아키텍처 절제(Architecture Restraint) 원칙에 기반한 체크리스트를 먼저 확인합니다.
[Pebble Architecture Decision Checklist]
- [ ] 이 코드는 어느 Feature의 책임인가?
- [ ] 기존 Feature로 처리할 수 없는가? 새로운 Feature가 정말 필요한가?
- [ ] 다른 Feature의 내부 구현이나 데이터 소유권을 침범하지 않는가?
- [ ] 실제로 필요한 Layer만 생성했는가?
- [ ] 새로운 Interface나 Mapper가 현재 코드의 실제 문제를 해결하는가?
- [ ] Entity를 불필요하게 분리하지 않았는가?
- [ ] 단순히 코드가 비슷하다는 이유만으로 성급하게 global로 이동시키지 않았는가?
- [ ] 추가되는 복잡성보다 얻는 이점이 명확히 더 큰가?
좋은 아키텍처란 가장 유행하는 패턴이나 가장 많은 추상화 계층을 가진 구조가 아닙니다. 비즈니스 요구사항이 변할 때 어디를 고쳐야 할지 명확히 알 수 있고, 수정의 여파가 다른 도메인으로 번지지 않으며, 불필요한 보일러플레이트 없이 핵심 도메인 규칙에 집중할 수 있는 구조입니다.
처음부터 미래의 모든 변경을 가정해 거대한 추상화의 성(Over-engineering)을 쌓기보다, 선명한 도메인 경계(Package by Feature)와 책임 중심의 계층(DDD-lite)을 단단히 세워두고 실제 복잡성이 찾아왔을 때 필요한 만큼만 구조를 확장해 나가는 것이 Pebble 백엔드가 지향하는 실용주의 아키텍처입니다.
댓글
로그인 상태를 확인하고 있어요.