지난 번에 폴더 구조를 잡았다. 이번엔 그 폴더 안을 실제로 채우기 전에, "어떤 개념이 어디에 들어가는지"를 먼저 정리했다. 코드를 짜다가 "이게 어디 가야 하지?"로 헤매는 시간을 줄이기 위해서다.
DDD에는 전략 패턴과 전술 패턴이 있다.
전략 패턴은 큰 그림이다. "시스템을 어떻게 나눌 것인가"를 다루는데 조직 구조나 팀 간 협업까지 영향을 주는 수준이라 개인 프로젝트에서 다 적용하면 설계 문서 쓰는 시간이 코딩보다 많아진다. 이번엔 생략했다.
전술 패턴은 코드 수준이다. 실제로 클래스를 어떻게 구성할지에 관한 이야기다. 이번 단계에서 적용한 것들은 이렇다.
| 패턴 | 내가 만든 것 | 위치 |
|---|---|---|
| Entity | User — 순수 자바, id로 동일성 판단 | domain/model/ |
| Repository (포트) | UserRepository — 인터페이스 | application/port/out/ |
| UseCase | RegisterUseCase, GetUserUseCase | application/port/in/ |
| Command | RegisterCommand — 유스케이스 입력 모델 | application/port/in/ |
| Application Service | RegisterService — 흐름 조율 | application/service/ |
헥사고날에서 모든 결정의 기준은 이거다.
의존성은 항상 안쪽으로. 도메인은 아무것도 모른다.

User 도메인 모델은 JPA도, Spring도, HTTP도 모른다. 순수 자바 객체다. 덕분에 DB를 H2에서 MySQL로 바꿔도, HTTP를 다른 방식으로 바꿔도 도메인은 건드릴 필요가 없다.
순수 자바 객체다. @Entity, @Column 같은 JPA 어노테이션이 없다. Lombok @Getter, @Builder는 써도 된다.
비즈니스 규칙은 여기에 있어야 한다. setFollowerCount(n)이 아니라 incrementFollowerCount(), decrementFollowerCount()처럼 의미 있는 메서드로 상태를 변경한다.
// 이렇게 하지 않는다
user.setFollowerCount(user.getFollowerCount() - 1);
// 이렇게 한다
user.decrementFollowerCount(); // 내부에서 Math.max(0, count - 1) 처리
컨트롤러가 "회원 등록을 해달라"고 요청할 수 있는 계약(인터페이스)이다. 컨트롤러는 이 인터페이스만 알고, 구현체(RegisterService)를 직접 알지 않는다.
유스케이스를 UserUseCase 하나로 합치지 않고 RegisterUseCase, GetUserUseCase로 나눈 이유는 인터페이스 분리 원칙(ISP) 때문이다. UserController가 회원가입만 필요하다면 RegisterUseCase만 의존하면 된다. GetUserUseCase가 바뀌어도 영향을 받지 않는다.
유스케이스의 입력 모델이다. 웹 DTO(RegisterRequest)를 유스케이스에 그대로 넘기지 않는 이유가 여기 있다.
만약 RegisterService가 RegisterRequest를 직접 import하면 application이 adapter를 바라보게 된다. 의존성 방향이 안→바깥이 되어 헥사고날 규칙이 깨진다.
RegisterRequest → 웹 어댑터 소유 (adapter/in/dto)
RegisterCommand → application 소유 (application/port/in)
변환(RegisterRequest → RegisterCommand)은 컨트롤러에서 한다. 방향이 바깥→안으로 유지된다.
Command라는 이름은 DDD 관습에서 온다. 시스템 상태를 바꾸는 요청은 Command, 조회만 하는 요청은 Query라고 부른다.
// record를 쓰면 불변 데이터 캐리어를 간결하게 만들 수 있다
public record RegisterCommand(
String email,
String password,
String username,
String displayName
) {
// compact constructor — 형식 검증
public RegisterCommand {
if (email == null || email.isBlank())
throw new IllegalArgumentException("email is required");
}
}
서비스가 DB에 접근할 때 쓰는 아웃고잉 포트다. 인터페이스만 있고 구현은 없다.
public interface UserRepository {
User save(User user);
Optional<User> findById(String id);
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
boolean existsByEmail(String email);
boolean existsByUsername(String username);
}
@Repository도 없고 JpaRepository 상속도 없다. 완전히 순수한 자바 인터페이스다. 반환 타입이 도메인 User인 것도 포인트다. JPA 엔티티(UserJpaEntity)가 밖으로 나오지 않는다.
유스케이스를 구현한다. RegisterUseCase 인터페이스를 구현하고, UserRepository 인터페이스에 의존한다. 구현체(UserPersistenceAdapter)를 직접 알지 않는다.
흐름 조율이 여기서 일어난다. "이메일 중복 확인 → 도메인 객체 생성 → 저장"처럼. 트랜잭션도 여기에 @Transactional로 건다.
JPA 기술에 종속된 객체다. @Entity, @Table, @Column이 여기에만 있다. 도메인 User와 분리되어 있고, 변환 책임을 어댑터가 담당한다.
UserEntity가 아니라 UserJpaEntity로 이름 붙인 이유는 "이건 JPA에 종속된 객체다"를 이름으로 드러내기 위해서다. 나중에 MongoDB로 일부를 바꾼다면 UserMongoEntity가 추가되는 식으로 확장된다.
UserRepository 인터페이스를 실제로 구현한다. UserJpaRepository를 호출하고, 도메인 ↔ JPA 엔티티 변환을 담당한다.
@Component
@RequiredArgsConstructor
public class UserPersistenceAdapter implements UserRepository {
private final UserJpaRepository userJpaRepository;
@Override
public User save(User user) {
UserJpaEntity entity = toJpaEntity(user);
return toDomain(userJpaRepository.save(entity));
}
@Override
public Optional<User> findByEmail(String email) {
return userJpaRepository.findByEmail(email).map(this::toDomain);
}
private UserJpaEntity toJpaEntity(User user) { ... }
private User toDomain(UserJpaEntity entity) { ... }
}
변환 로직(toJpaEntity, toDomain)은 이 클래스의 private 메서드에만 있다. 도메인 객체와 JPA 엔티티가 서로를 알 필요가 없고, 변환이 한 곳에만 있어서 수정할 때 어댑터만 건드리면 된다.
이 단계에서 확립한 네이밍 규칙이다. 일관성이 있으면 파일 찾기가 쉬워진다.
| 역할 | 네이밍 | 예시 |
|---|---|---|
| 인커밍 포트 | *UseCase | RegisterUseCase |
| 아웃고잉 포트 | *Repository | UserRepository |
| 어댑터 | *Adapter | UserPersistenceAdapter |
| DB 엔티티 | *JpaEntity | UserJpaEntity |
| Spring Data 인터페이스 | *JpaRepository | UserJpaRepository |
| 유스케이스 입력 모델 | *Command | RegisterCommand |
| 웹 입력 DTO | *Request | RegisterRequest |
| 웹 출력 DTO | *Response | UserResponse |
파일이 많아지는 게 이 구조의 대가이긴 한데, 직접 만들다 보니 이유가 보였다. UserRepository 인터페이스가 있으면 RegisterService 테스트를 DB 없이 짤 수 있다. RegisterUseCase 인터페이스가 있으면 컨트롤러 테스트를 서비스 없이 짤 수 있다. 인터페이스가 많아지지만 독립적으로 테스트를 진행할 수 있게 된다.