이 프로젝트는 단순한 기능 구현 실습이 아니라, Redis를 활용한 성능 최적화, 분산 락, 요청 제한, 3단 캐시 계층 등 다양한 기술 정책을 설계하고 실험하는 것이 목적이었다. 하지만 기술 정책은 언제나 도메인 로직을 침범할 위험을 동반한다. 예를 들어, 캐시나 락을 직접 서비스 로직에 넣기 시작하면 핵심 유즈케이스가 오염되기 쉽다. 테스트도 어려워지고, 시스템의 책임이 모호해지기 시작한다. 이런 상황을 방지하기 위해, 애초에 구조적으로 기술 정책과 도메인 흐름을 분리할 수 있는 아키텍처가 필요했다. 그리고 그 해답이 바로 헥사고날 아키텍처였다.
헥사고날 아키텍처(Hexagonal Architecture)는 '도메인 로직'을 가장 중심에 두고, 외부 요청(Controller), 외부 시스템(DB, Redis 등)을 어댑터(Adapter)로 분리하여 연결하는 구조다. 핵심 유즈케이스는 포트(Port)를 통해 입출력 어댑터와 통신하며, 포트는 인터페이스로 정의되고 실제 구현은 어댑터가 맡는다. 이 구조의 장점은 다음과 같다:
사용자가 영화 목록을 조회하는 API(/api/v1/movies
)를 호출하면 다음과 같은 구조로 흐름이 전개된다.
1. Controller
가 HTTP 요청을 받고
2. UseCase
를 통해 Service
에 전달하며
3. Service
는 RepositoryPort
를 통해 DB 접근을 요청하고
4. 실제 DB 접근은 Persistence Adapter
에서 수행한다.
각 구성요소는 모두 명확한 책임과 역할을 가지며, 서로 느슨하게 연결되어 있다.
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/
yin.application.port.in.QueryMovieUseCase
interface QueryMovieUseCase {
fun findAllMovies(command: QueryMovieCommand, pageable: Pageable): Page<QueryMovieResponse>
}
application/port/in/
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/
yin.application.command.QueryMovieCommand
data class QueryMovieCommand(
val title: String?,
val genreList: List<Genre>,
val movieStatusList: List<MovieStatus>
)
application/command/
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/
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/
yin.adapter.out.persistence.repository.MovieRepository
interface MovieRepository : JpaRepository<MovieEntity, Long>, MovieCustom
adapter/out/persistence/repository/
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/