기록

멀티 모듈 기반 영화 예매 서비스 설계기 : 멀티 모듈 프로젝트 설계(2) - 헥사고날 아키텍처 적용 본문

Web/Spring

멀티 모듈 기반 영화 예매 서비스 설계기 : 멀티 모듈 프로젝트 설계(2) - 헥사고날 아키텍처 적용

zyin 2025. 3. 31. 10:00

이번 포스팅에서는 영화 정보를 관리하는 service-movie 모듈을 헥사고날 아키텍처(Hexagonal Architecture) 기반으로 설계하고 구현한 과정을 자세히 소개한다. 이번에는 도메인 중심으로 책임을 분리하는 헥사고날 아키텍처의 힘을 실제 프로젝트에 적용해봤다.


🎯 왜 헥사고날 아키텍처를 적용했는가?

일반적인 계층형 구조에서는 Controller, Service, Repository로 기능을 나누지만, 시간이 지나면서 Service에 모든 책임이 몰리고, 비즈니스 규칙과 기술 코드가 뒤섞이기 쉬웠다.

헥사고날 아키텍처는 다음과 같은 명확한 목표를 가지고 있다:

  • 핵심 도메인 로직 보호: 비즈니스 핵심 규칙은 외부 기술(JPA, Redis 등)에 의존하지 않아야 한다.
  • 기술 독립성 확보: 외부 시스템(DB, 메시지 브로커 등) 변경 시에도 도메인 로직은 영향받지 않는다.
  • 유즈케이스 중심 개발: 실제 사용 시나리오 단위로 흐름을 설계하고 구현할 수 있다.
  • 유지보수성과 테스트 용이성 향상: 포트와 어댑터를 통해 의존성을 명확히 나누면 테스트 범위도 명확해진다.

🏗 전체 구조

service-movie
├── adapter
│   ├── in                # 사용자 요청을 받는 컨트롤러 계층
│   └── out               # 외부 시스템(JPA, API 등)과의 연동 구현
├── application           # 유즈케이스 구현 및 흐름 처리
├── domain                # 비즈니스 핵심 규칙
├── port                  # 어댑터와 도메인을 연결하는 인터페이스
└── ServiceMovieApplication.kt  # 애플리케이션 진입점

이 구조는 아키텍처적으로는 복잡해 보일 수 있지만, 실제로는 각 계층의 책임이 명확하게 분리되어 있어 기능이 늘어나도 구조가 무너지지 않는다.


📊 아키텍처 흐름도

 

이 다이어그램은 헥사고날 아키텍처의 흐름을 단순화한 것이다. 외부의 요청이 Controller로 들어오면, Application Layer에서 비즈니스 유즈케이스를 실행하고, 필요 시 Persistence Adapter를 통해 DB에서 데이터를 읽거나 저장한다. 핵심 로직은 Domain Layer에 고립되어 있다.


🧪 영화 목록 조회 – 예시 코드로 보는 계층별 흐름

✅ 1. adapter/in – 사용자 요청을 처리하는 컨트롤러

@RestController
@RequestMapping("/api/v1/movies")
class QueryMovieController(
    private val queryMovieUseCase: QueryMovieUseCase
) {
    @GetMapping
    fun getAllMovies(): List<QueryMovieResponse> {
        return queryMovieUseCase.getAllMovies()
    }
}

이 컨트롤러는 /api/v1/movies 엔드포인트로 들어온 HTTP GET 요청을 처리한다. 실제 비즈니스 로직은 컨트롤러 내부에 존재하지 않으며, QueryMovieUseCase를 통해 유즈케이스 인터페이스를 호출한다. 이를 통해 웹 계층과 도메인 계층의 완전한 분리가 가능하다.


✅ 2. port/in – 유즈케이스 정의

interface QueryMovieUseCase {
    fun getAllMovies(): List<QueryMovieResponse>
}

이 인터페이스는 외부(Controller)에서 호출 가능한 유즈케이스를 명시한다. 유즈케이스 계층의 입구 역할을 하며, 실제 구현은 Application Layer에서 담당하게 된다.


✅ 3. application – 유즈케이스 구현 (서비스 계층)

@Service
class QueryMovieService(
    private val movieRepositoryPort: MovieRepositoryPort
) : QueryMovieUseCase {

    override fun getAllMovies(): List<QueryMovieResponse> {
        val movies = movieRepositoryPort.findAll()
        return movies.map {
            QueryMovieResponse(
                id = it.id,
                title = it.title,
                genre = it.genre.name,
                status = it.status.name
            )
        }
    }
}

여기서는 QueryMovieUseCase를 구현하며, 실제 유즈케이스 로직을 정의한다. 영화 목록을 조회할 때는 MovieRepositoryPort라는 포트를 통해 도메인 모델을 가져오고, 이를 응답 DTO로 매핑한다.

이 계층은 어댑터와 도메인 사이에서 중재자 역할을 수행한다.


✅ 4. port/out – 외부 연동 인터페이스 정의

interface MovieRepositoryPort {
    fun findAll(): List<Movie>
}

도메인이 외부 저장소에 의존하지 않도록 하기 위해, 저장소에 필요한 최소한의 기능만 정의한다. 이 포트를 구현한 클래스는 adapter/out에 존재한다.


✅ 5. adapter/out – JPA 기반의 DB 조회 어댑터

@Repository
class QueryMovieAdapter(
    private val movieRepository: MovieRepository
) : MovieRepositoryPort {

    override fun findAll(): List<Movie> {
        return movieRepository.findAll().map {
            Movie(
                id = it.id,
                title = it.title,
                genre = Genre.valueOf(it.genre),
                status = MovieStatus.valueOf(it.status)
            )
        }
    }
}
interface MovieRepository : JpaRepository<MovieEntity, String>

이 어댑터는 실제 JPA를 사용하여 DB에서 데이터를 읽어오는 역할을 한다. JPA에서 가져온 엔티티를 도메인 모델로 변환해서 반환한다는 점이 중요하다. 도메인 모델은 domain 계층에 정의된 순수 객체이며, JPA에 의존하지 않는다.


✅ 6. domain – 도메인 모델 정의

data class Movie(
    val id: String,
    val title: String,
    val genre: Genre,
    val status: MovieStatus
)

enum class Genre {
    ACTION, COMEDY, DRAMA
}

enum class MovieStatus {
    NOW_SHOWING, COMING_SOON, CLOSED
}

도메인 모델은 외부 시스템, 라이브러리와 철저히 분리된다. 이는 순수한 비즈니스 개념을 표현하는 데 집중하며, 테스트 작성이 매우 쉬운 구조를 제공한다.


✅ 응답 DTO – QueryMovieResponse.kt

data class QueryMovieResponse(
    val id: String,
    val title: String,
    val genre: String,
    val status: String
)

도메인 객체를 외부로 바로 노출하지 않기 위해, 응답 전용 DTO를 별도로 정의한다.


✅ 테스트 전략 예시

@MockBean
lateinit var movieRepositoryPort: MovieRepositoryPort

@Autowired
lateinit var queryMovieUseCase: QueryMovieUseCase

@Test
fun `영화 목록을 조회할 수 있다`() {
    val movie = Movie("1", "Inception", Genre.ACTION, MovieStatus.NOW_SHOWING)
    whenever(movieRepositoryPort.findAll()).thenReturn(listOf(movie))

    val result = queryMovieUseCase.getAllMovies()

    assertThat(result).hasSize(1)
    assertThat(result[0].title).isEqualTo("Inception")
}

유즈케이스 단위 테스트는 어댑터를 모두 모킹(Mock) 처리하면 가능하다. 이 구조 덕분에 기술 요소와 무관하게 유즈케이스 단위 테스트가 쉽고 안정적이다.


🧩 정리하며

도메인을 외부와 철저히 분리하고, 기능 단위가 아닌 비즈니스 흐름 중심으로 구성된 구조는 생각보다 유연하고 강력했다. 다만 데이터 객체간의 변환 코드와 

  • 도메인 로직을 격리하면 시스템 전체가 더 이해하기 쉬워진다.
  • 포트-어댑터 구조를 통해 테스트 코드가 더 간단하고 강력해진다.
  • 새로운 기술(JPA → Redis, Kafka 등)로 바꾸는 것도 훨씬 쉬워진다.
Comments