Spring Boot DDD 프로젝트에서 개발 컨벤션 정립하기
들어가며
새 프로젝트를 시작할 때 가장 먼저 해야 할 일이 뭘까? 기능 구현? 아니다. 팀이 같은 방향으로 코드를 쓸 수 있는 규칙을 먼저 세우는 것이다.
LS전선 리뉴얼 프로젝트(Spring Boot 4.0, Java 21)에서 BE 개발을 시작하면서, 샘플 도메인 하나를 기준으로 6가지 아키텍처 컨벤션을 정립했다. 이 글은 “왜 이렇게 정했는지”에 초점을 맞춘다.
1. DTO는 domain 레이어에 둔다
기존 구조의 문제
api/sample/
├── SampleController.java
└── dto/
├── SampleRequestDto.java ← DTO가 여기 있으면
└── SampleResponseDto.java
domain/sample/
├── SampleService.java ← Service가 api.dto를 import해야 함
├── entity/
└── repository/
Service가 DTO를 가장 많이 사용하는데, DTO가 api 패키지에 있으면 domain → api 역참조가 발생한다. ArchUnit으로 “domain은 api를 참조하지 않는다” 규칙을 걸어놓고, 정작 ignoreDependency로 예외를 뚫고 있었다.
해결
api/sample/
└── SampleController.java ← Controller만
domain/sample/
├── SampleService.java
├── dto/ ← DTO를 domain으로 이동
│ ├── SampleCreateDto.java
│ └── SampleResponseDto.java
├── entity/
└── repository/
이제 의존 방향이 api → domain으로 자연스럽고, ArchUnit 예외도 제거했다. 규칙에 예외가 있다면, 규칙이 아니라 코드 구조를 바꿔야 한다.
2. 비즈니스 로직은 Entity에 둔다
Service가 뚱뚱해지는 걸 막는 핵심이다.
Before: Service가 모든 걸 한다
// Service
public SampleResponseDto create(SampleRequestDto request) {
SampleEntity entity = SampleEntity.builder()
.title(request.getTitle())
.description(request.getDescription())
.build();
return new SampleResponseDto(sampleRepository.save(entity));
}
After: Entity가 자기 자신을 안다
// Entity
public static SampleEntity fromCreateDto(SampleCreateDto dto) {
return new SampleEntity(dto.getTitle(), dto.getDescription());
}
public void update(SampleUpdateDto dto) {
this.title = dto.getTitle();
this.description = dto.getDescription();
}
public SampleResponseDto toResponseDto() {
return new SampleResponseDto(this);
}
// Service — 흐름 조율만
public SampleResponseDto create(SampleCreateDto request) {
SampleEntity entity = SampleEntity.fromCreateDto(request);
return sampleRepository.save(entity).toResponseDto();
}
Service는 **“무엇을 하라”**만 지시하고, **“어떻게”**는 Entity가 담당한다. 변환 로직이 변경되어도 Entity 한 곳만 수정하면 된다.
3. Lombok은 최소한만 쓴다
| 대상 | 허용 | 금지 |
|---|---|---|
| Entity | @Getter, @NoArgsConstructor(PROTECTED), @Builder(private 생성자만) | @Setter, @AllArgsConstructor, 클래스 레벨 @Builder |
| DTO | @Getter, @NoArgsConstructor | @AllArgsConstructor, @Builder |
핵심 기준: 어노테이션이 생성 의도를 숨기면 금지.
클래스 레벨 @Builder는 “아무 조합으로나 만들 수 있다”는 잘못된 신호를 준다. private 생성자에 @Builder를 붙이면 외부에서 빌더 직접 호출이 차단되고, fromCreateDto() 같은 팩토리 메서드 내부에서만 활용할 수 있다.
DTO의 @AllArgsConstructor도 제거하고 직접 생성자를 작성한다. 파라미터 2~3개일 때는 의미 없어 보이지만, 필드가 늘어날수록 “이 생성자로 뭘 만드는 건지”가 코드에 드러나야 한다.
4. HTTP 메서드는 GET/POST만 사용한다
인프라 제약으로 PUT/DELETE를 쓸 수 없는 환경이었다. URL에 동작을 명시하는 방식으로 해결했다.
GET /api/sample ← 목록 조회
GET /api/sample/{id} ← 단건 조회
POST /api/sample ← 등록
POST /api/sample/{id}/modify ← 수정
POST /api/sample/{id}/modify-status ← 상태 변경
POST /api/sample/{id}/remove ← 삭제
순수 REST 원칙에서는 벗어나지만, Google API Design Guide에서도 Custom Methods 패턴을 공식 인정하고 있다. REST는 규칙이 아니라 아키텍처 스타일이다. 인프라 제약 하에서 팀 내 일관성만 유지하면 된다.
5. 용도별 Update를 분리한다
필드가 30개인 Entity가 있고, 화면 A에서는 5개 필드만, 화면 B에서는 10개 필드만 수정한다면?
// DTO를 용도별로 분리
SampleUpdateDto // 기본 정보 (title, description)
SampleStatusUpdateDto // 상태 변경 (useYn)
// Entity에 메서드도 1:1 대응
entity.update(updateDto);
entity.updateStatus(statusDto);
// URL도 분리
POST /api/sample/{id}/modify // 기본 정보
POST /api/sample/{id}/modify-status // 상태 변경
하나의 거대한 update 메서드에 모든 경우를 때려넣는 건 안티패턴이다. 각 메서드가 하나의 “도메인 행위”를 표현하면, Validation도 DTO별로 다르게 적용할 수 있고, 테스트도 명확해진다.
6. 일급 컬렉션으로 컬렉션 로직을 응집한다
List<SampleEntity>를 그대로 쓰면 필터링, 변환 로직이 Service 곳곳에 흩어진다.
// 일급 컬렉션
public class Samples {
private final List<SampleEntity> items;
public Samples(List<SampleEntity> items) {
this.items = Collections.unmodifiableList(items);
}
public Samples filterActive() {
return new Samples(items.stream()
.filter(SampleEntity::isUseYn)
.toList());
}
public List<SampleResponseDto> toResponseDtos() {
return items.stream()
.map(SampleEntity::toResponseDto)
.toList();
}
}
// Service에서 사용
public List<SampleResponseDto> findAll() {
Samples samples = new Samples(sampleRepository.findAll());
return samples.filterActive().toResponseDtos();
}
네이밍은 {Domain}s 복수형(Samples, Users)으로 통일했다. Collections.unmodifiableList()로 불변성을 보장하면 외부에서 .add()로 내부 상태를 변경하는 실수도 방지한다.
TDD로 검증한다
이 모든 규칙은 테스트로 뒷받침된다.
- Entity 테스트:
fromCreateDto,update,updateStatus,toResponseDto - 일급 컬렉션 테스트:
filterActive,filterInactive, 불변성 검증 - Service 테스트: CRUD 전체 + 예외 케이스
- ArchUnit 테스트: 레이어 의존성 규칙 자동 강제
총 30건의 테스트가 모두 통과한 상태에서 PR을 올렸다. 테스트 없는 비즈니스 로직 PR은 머지하지 않는다는 규칙도 함께.
마무리
컨벤션은 “정답”이 아니라 “합의”다. 중요한 건 팀원들이 동의하고, 일관되게 따를 수 있느냐다. 그래서 규칙을 코드(ArchUnit)와 문서(CLAUDE.md)로 동시에 남겼다.
이 6가지 규칙이 앞으로 모든 도메인 개발의 템플릿이 된다. 새 도메인을 추가할 때 sample 패키지를 복사해서 시작하면, 자연스럽게 이 규칙을 따르게 된다.