[설계] 헥사고날 아키텍처 구조 설계

y001·2025년 4월 13일
0
post-thumbnail

도입: 구조가 흐름을 망치지 않도록, 도메인을 중심에 두기로 했다

이 프로젝트는 단순한 기능 구현 실습이 아니라, Redis를 활용한 성능 최적화, 분산 락, 요청 제한, 3단 캐시 계층 등 다양한 기술 정책을 설계하고 실험하는 것이 목적이었다. 하지만 기술 정책은 언제나 도메인 로직을 침범할 위험을 동반한다. 예를 들어, 캐시나 락을 직접 서비스 로직에 넣기 시작하면 핵심 유즈케이스가 오염되기 쉽다. 테스트도 어려워지고, 시스템의 책임이 모호해지기 시작한다. 이런 상황을 방지하기 위해, 애초에 구조적으로 기술 정책과 도메인 흐름을 분리할 수 있는 아키텍처가 필요했다. 그리고 그 해답이 바로 헥사고날 아키텍처였다.


헥사고날 아키텍처란 무엇이고, 왜 필요한가?

헥사고날 아키텍처(Hexagonal Architecture)는 '도메인 로직'을 가장 중심에 두고, 외부 요청(Controller), 외부 시스템(DB, Redis 등)을 어댑터(Adapter)로 분리하여 연결하는 구조다. 핵심 유즈케이스는 포트(Port)를 통해 입출력 어댑터와 통신하며, 포트는 인터페이스로 정의되고 실제 구현은 어댑터가 맡는다. 이 구조의 장점은 다음과 같다:

  • 외부 기술(JPA, Redis, Kafka 등)에 도메인 로직이 오염되지 않는다.
  • 기술 정책은 Adapter 레이어에서 구현되므로 교체가 용이하다.
  • 도메인 로직은 순수 Kotlin(또는 Java)로 유지되며 테스트가 쉽다.
  • 유스케이스 흐름이 명확하게 분리되어 유지보수가 수월하다.

전체 흐름: GET /movies 요청이 지나가는 경로

사용자가 영화 목록을 조회하는 API(/api/v1/movies)를 호출하면 다음과 같은 구조로 흐름이 전개된다.
1. Controller가 HTTP 요청을 받고
2. UseCase를 통해 Service에 전달하며
3. ServiceRepositoryPort를 통해 DB 접근을 요청하고
4. 실제 DB 접근은 Persistence Adapter에서 수행한다.
각 구성요소는 모두 명확한 책임과 역할을 가지며, 서로 느슨하게 연결되어 있다.


각 클래스 및 폴더 구성

1. Controller – yin.adapter.in.controller.MovieController

@RestController
@RequestMapping("/api")
class MovieController(
    private val queryMovieUseCase: QueryMovieUseCase
) {
    @GetMapping("/v1/movies")
    fun getMovies(
        @Validated request: QueryMovieRequest,
        pageable: Pageable
    ): ResponseEntity<Page<QueryMovieResponse>> {
        val command = QueryMovieCommand(
            title = request.title,
            genreList = request.genreList,
            movieStatusList = request.movieStatusList,
        )
        val result = queryMovieUseCase.findAllMovies(command, pageable)
        return ResponseEntity.ok(result)
    }
}
  • 위치: adapter/in/controller/
  • 역할: 외부 요청을 받아 유즈케이스 인터페이스로 전달하는 진입점
  • 특징: 복잡한 로직은 포함하지 않으며 단순 위임에 집중한다

2. UseCase (Input Port) – yin.application.port.in.QueryMovieUseCase

interface QueryMovieUseCase {
    fun findAllMovies(command: QueryMovieCommand, pageable: Pageable): Page<QueryMovieResponse>
}
  • 위치: application/port/in/
  • 역할: 컨트롤러와 서비스 간의 인터페이스 역할을 하며 유스케이스의 계약을 정의한다
  • 장점: 테스트 용이성, 유연한 구현체 교체, 흐름 제어

3. Service – yin.application.service.QueryMovieService

@Service
@Transactional(readOnly = true)
class QueryMovieService(
    private val movieRepositoryPort: MovieRepositoryPort
) : QueryMovieUseCase {
    override fun findAllMovies(command: QueryMovieCommand, pageable: Pageable): Page<QueryMovieResponse> {
        val moviesPage = movieRepositoryPort.findAllMovies(command, pageable)
        val movieIds = moviesPage.content.map { it.id.toLong() }
        val schedules = movieRepositoryPort.findSchedulesByMovieIdIn(movieIds)
        val scheduleMap = schedules.groupBy { it.movieId }

        return moviesPage.map { movie ->
            val movieSchedules = scheduleMap[movie.id] ?: emptyList()
            movie.copy(schedules = movieSchedules)
        }
    }
}
  • 위치: application/service/
  • 역할: 유즈케이스의 핵심 구현체이며, 도메인 조립과 흐름을 관리한다
  • 특징: 외부 시스템 접근은 직접 하지 않고 Port만 호출하며, 트랜잭션 경계도 이곳에서 관리한다

4. Command 객체 – yin.application.command.QueryMovieCommand

data class QueryMovieCommand(
    val title: String?,
    val genreList: List<Genre>,
    val movieStatusList: List<MovieStatus>
)
  • 위치: application/command/
  • 역할: 컨트롤러에서 받은 값을 유스케이스에 전달하기 위한 요청 데이터 구조
  • 특징: DTO와 분리되며, 유스케이스의 목적에 맞춘 구조를 가진다

5. Repository Port (Output Port) – yin.application.port.out.MovieRepositoryPort

interface MovieRepositoryPort {
    fun findAllMovies(command: QueryMovieCommand, pageable: Pageable): Page<QueryMovieResponse>
    fun findSchedulesByMovieIdIn(movieIds: List<Long>): List<QueryScheduleResponse>
}
  • 위치: application/port/out/
  • 역할: DB 또는 외부 시스템 접근을 위한 추상화 인터페이스
  • 특징: Service는 이 인터페이스만 의존하며, 구현체 교체가 자유롭다

6. Persistence Adapter – yin.adapter.out.persistence.QueryMovieAdapter

@Repository
class QueryMovieAdapter(
    private val movieRepository: MovieRepository,
    private val scheduleRepository: ScheduleRepository
) : MovieRepositoryPort {
    override fun findAllMovies(...) = ...
    override fun findSchedulesByMovieIdIn(...) = ...
}
  • 위치: adapter/out/persistence/
  • 역할: Port 인터페이스를 실제로 구현하는 계층
  • 특징: JPA, Redis, Kafka 등 외부 시스템과의 구체적 연동을 처리하며, 기술 정책이 포함된다

7. Repository Interface – yin.adapter.out.persistence.repository.MovieRepository

interface MovieRepository : JpaRepository<MovieEntity, Long>, MovieCustom
  • 위치: adapter/out/persistence/repository/
  • 역할: JPA를 이용해 DB 접근을 정의하고, QueryDSL 커스텀 쿼리와 조합하여 사용한다

8. Domain Entity – yin.domain.Movie

data class Movie(
    val id: Long,
    val title: String,
    val genre: Genre,
    val releaseDate: LocalDate,
    val runningTime: Int,
    val status: MovieStatus
)
  • 위치: domain/
  • 역할: 도메인의 핵심 비즈니스 개념을 표현하는 모델이며, 외부 기술에 의존하지 않는다
  • 특징: 테스트가 쉽고, 도메인 이벤트 또는 밸류 객체 등과 조합이 자유롭다

0개의 댓글