[MSA] DDD패턴을 Hexagonal 폴더에 매핑하기

Raha·2026년 6월 20일

Social Network Project

목록 보기
3/3

할 일

지난 번에 폴더 구조를 잡았다. 이번엔 그 폴더 안을 실제로 채우기 전에, "어떤 개념이 어디에 들어가는지"를 먼저 정리했다. 코드를 짜다가 "이게 어디 가야 하지?"로 헤매는 시간을 줄이기 위해서다.


DDD 전술 패턴이란

DDD에는 전략 패턴과 전술 패턴이 있다.

전략 패턴은 큰 그림이다. "시스템을 어떻게 나눌 것인가"를 다루는데 조직 구조나 팀 간 협업까지 영향을 주는 수준이라 개인 프로젝트에서 다 적용하면 설계 문서 쓰는 시간이 코딩보다 많아진다. 이번엔 생략했다.

전술 패턴은 코드 수준이다. 실제로 클래스를 어떻게 구성할지에 관한 이야기다. 이번 단계에서 적용한 것들은 이렇다.

패턴내가 만든 것위치
EntityUser — 순수 자바, id로 동일성 판단domain/model/
Repository (포트)UserRepository — 인터페이스application/port/out/
UseCaseRegisterUseCase, GetUserUseCaseapplication/port/in/
CommandRegisterCommand — 유스케이스 입력 모델application/port/in/
Application ServiceRegisterService — 흐름 조율application/service/

핵심 규칙: 의존성 방향

헥사고날에서 모든 결정의 기준은 이거다.

의존성은 항상 안쪽으로. 도메인은 아무것도 모른다.

User 도메인 모델은 JPA도, Spring도, HTTP도 모른다. 순수 자바 객체다. 덕분에 DB를 H2에서 MySQL로 바꿔도, HTTP를 다른 방식으로 바꿔도 도메인은 건드릴 필요가 없다.


각 파일의 역할

domain/model/User.java

순수 자바 객체다. @Entity, @Column 같은 JPA 어노테이션이 없다. Lombok @Getter, @Builder는 써도 된다.

비즈니스 규칙은 여기에 있어야 한다. setFollowerCount(n)이 아니라 incrementFollowerCount(), decrementFollowerCount()처럼 의미 있는 메서드로 상태를 변경한다.

// 이렇게 하지 않는다
user.setFollowerCount(user.getFollowerCount() - 1);

// 이렇게 한다
user.decrementFollowerCount();  // 내부에서 Math.max(0, count - 1) 처리

application/port/in/RegisterUseCase.java

컨트롤러가 "회원 등록을 해달라"고 요청할 수 있는 계약(인터페이스)이다. 컨트롤러는 이 인터페이스만 알고, 구현체(RegisterService)를 직접 알지 않는다.

유스케이스를 UserUseCase 하나로 합치지 않고 RegisterUseCase, GetUserUseCase로 나눈 이유는 인터페이스 분리 원칙(ISP) 때문이다. UserController가 회원가입만 필요하다면 RegisterUseCase만 의존하면 된다. GetUserUseCase가 바뀌어도 영향을 받지 않는다.

application/port/in/RegisterCommand.java

유스케이스의 입력 모델이다. 웹 DTO(RegisterRequest)를 유스케이스에 그대로 넘기지 않는 이유가 여기 있다.

만약 RegisterServiceRegisterRequest를 직접 import하면 applicationadapter를 바라보게 된다. 의존성 방향이 안→바깥이 되어 헥사고날 규칙이 깨진다.

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");
    }
}

application/port/out/UserRepository.java

서비스가 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)가 밖으로 나오지 않는다.

application/service/RegisterService.java

유스케이스를 구현한다. RegisterUseCase 인터페이스를 구현하고, UserRepository 인터페이스에 의존한다. 구현체(UserPersistenceAdapter)를 직접 알지 않는다.

흐름 조율이 여기서 일어난다. "이메일 중복 확인 → 도메인 객체 생성 → 저장"처럼. 트랜잭션도 여기에 @Transactional로 건다.

adapter/out/persistence/UserJpaEntity.java

JPA 기술에 종속된 객체다. @Entity, @Table, @Column이 여기에만 있다. 도메인 User와 분리되어 있고, 변환 책임을 어댑터가 담당한다.

UserEntity가 아니라 UserJpaEntity로 이름 붙인 이유는 "이건 JPA에 종속된 객체다"를 이름으로 드러내기 위해서다. 나중에 MongoDB로 일부를 바꾼다면 UserMongoEntity가 추가되는 식으로 확장된다.

adapter/out/persistence/UserPersistenceAdapter.java

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 엔티티가 서로를 알 필요가 없고, 변환이 한 곳에만 있어서 수정할 때 어댑터만 건드리면 된다.


정리- Convention

이 단계에서 확립한 네이밍 규칙이다. 일관성이 있으면 파일 찾기가 쉬워진다.

역할네이밍예시
인커밍 포트*UseCaseRegisterUseCase
아웃고잉 포트*RepositoryUserRepository
어댑터*AdapterUserPersistenceAdapter
DB 엔티티*JpaEntityUserJpaEntity
Spring Data 인터페이스*JpaRepositoryUserJpaRepository
유스케이스 입력 모델*CommandRegisterCommand
웹 입력 DTO*RequestRegisterRequest
웹 출력 DTO*ResponseUserResponse

이 단계에서 배운 것

파일이 많아지는 게 이 구조의 대가이긴 한데, 직접 만들다 보니 이유가 보였다. UserRepository 인터페이스가 있으면 RegisterService 테스트를 DB 없이 짤 수 있다. RegisterUseCase 인터페이스가 있으면 컨트롤러 테스트를 서비스 없이 짤 수 있다. 인터페이스가 많아지지만 독립적으로 테스트를 진행할 수 있게 된다.

profile
Backend Developer | AI Integration & Product Minded

0개의 댓글