헥사고날 아키텍처에서 캐시와 영속성의 의존성 분리 — 데코레이터 패턴 적용
들어가며
사이드 프로젝트에서 헥사고날 아키텍처를 적용하며, 한 가지 설계 난제에 부딪혔다. 캐시 레이어와 영속성(Persistence) 레이어를 각각 독립 모듈로 분리했는데, "캐시 미스 → DB 조회 → 캐시 저장"이라는 흐름을 어떻게 서로 의존 없이 구현할 것인가?
이 글은 Spring 멀티모듈 프로젝트에서 헥사고날 아키텍처를 준수하면서 캐싱을 적용한 과정, 그 과정에서 고려한 여러 대안들과 트레이드오프, 그리고 최종적으로 데코레이터 패턴 + @Primary + @Qualifier 조합이 가장 현실적인 해결책이었던 이유를 정리한다.
1. 문제 상황
프로젝트 모듈 구조
linktrip-application/ ← 도메인 모델 + 포트 인터페이스
linktrip-output-persistence/
└── mysql/ ← JPA 기반 DB 어댑터
linktrip-output-cache/
└── caffeine/ ← Caffeine 로컬 캐시
linktrip-bootstrap/ ← Spring Boot 진입점, 전체 모듈 조립
헥사고날 아키텍처에서 각 모듈의 역할은 명확하다.
- application: 포트(인터페이스)를 정의하고, 비즈니스 로직을 담당
- output-persistence/mysql: 포트를 구현하여 DB에 저장/조회
- output-cache/caffeine: 캐싱 인프라 담당
- bootstrap: 모든 모듈을 조립하는 진입점
전체 헥사고날 아키텍처 구조와 의존 방향
┌─────────────────────────────────────────────────────────────────────┐
│ bootstrap (조립 진입점) │
│ │
│ @SpringBootApplication(scanBasePackages = ["com.linktrip"]) │
│ │
│ 의존: application, input-http, input-batch, │
│ output-persistence/mysql, output-cache/caffeine, │
│ output-storage/aws, output-http │
└──────┬──────────────┬──────────────┬──────────────┬────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ input-http │ │ input-batch│ │output-cache│ │output-persi│
│ (Controller│ │ (Spring │ │ /caffeine │ │stence/mysql│
│ , API) │ │ Batch) │ │ │ │ │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └──────┬─────┘
│ │ │ │
│ │ │ ❌ 서로 │
│ │ │◄─직접 의존──►│
│ │ │ 없음 │
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ application (핵심) │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────────────────┐ │
│ │ domain/ │ │ port/ │ │
│ │ YouTubeVideo │ │ input/ ← UseCase 인터페이스 │ │
│ │ Detail.kt │ │ output/ │ │
│ │ YouTubeChannel │ │ external/ ← 외부 API 포트 │ │
│ │ Detail.kt │ │ persistence/ ← 영속성 포트 │ │
│ │ │ │ YouTubeVideoPersistencePort.kt │ │
│ │ │ │ YouTubeChannelPersistencePort.kt │ │
│ └─────────────────┘ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
의존 방향을 화살표로 정리하면 이렇다:
┌─────────────┐
│ application │ ◄─── 모든 모듈이 이 방향으로 의존
│ (Port 정의) │
└──────┬──────┘
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
output-cache output-persist output-http
/caffeine ence/mysql (외부 API)
│ │
│ ❌ 직접 의존 없음 │
│◄────────────────►│
│ │
│ 둘 다 Port 인터페이스만 알고,
│ 서로의 존재는 모른다.
│ 연결은 Spring DI가 런타임에 수행.
핵심 제약: output-cache와 output-persistence는 서로를 직접 알면 안 된다.
그런데 캐싱이란 본질적으로 "캐시에 있으면 바로 반환, 없으면 DB 조회 후 캐시 저장"이라는 흐름이다. 캐시가 DB를 알아야 하는 것 아닌가? 이 모순을 어떻게 해결할 것인가.
2. 고려한 대안들
대안 A: Application 서비스에서 직접 오케스트레이션
┌───────────────────────────────────────────┐
│ Application (UseCase) │
│ │
│ "캐시 있어? 없으면 DB에서 가져와서 캐시해" │
│ │
│ cachePort.get() │
│ │ │
│ ├── 캐시 히트 → 바로 반환 │
│ │ │
│ └── 캐시 미스 │
│ │ │
│ ▼ │
│ persistencePort.findAll() │
│ │ │
│ ▼ │
│ cachePort.put(결과) ← 캐시 저장 │
│ │
│ ⚠ 캐시 로직이 비즈니스 레이어에 침투! │
└───────────────────────────────────────────┘
// Application 레이어 (UseCase)
class GetVideosUseCase(
private val cachePort: YouTubeVideoCachePort,
private val persistencePort: YouTubeVideoPersistencePort,
) {
fun execute(): List<YouTubeVideoDetail> {
return cachePort.get()
?: persistencePort.findAll().also { cachePort.put(it) }
}
}
문제점:
- 캐싱은 인프라 관심사인데, 비즈니스 로직이 있어야 할 Application 레이어에 캐시 로직이 침투한다
- 모든 UseCase마다 cache-aside 보일러플레이트를 반복 작성해야 한다
- 캐싱의 존재를 도메인이 "알게" 된다 — 헥사고날 위반
대안 B: DB 어댑터에 @Cacheable 직접 적용
┌───────────────────────────────────────────────┐
│ output-persistence/mysql 모듈 │
│ │
│ YouTubeVideoPersistenceAdapter │
│ ┌──────────────────────────────────────────┐ │
│ │ @Cacheable ← 캐시 관심사 │ │
│ │ @Transactional ← 영속성 관심사 │ │
│ │ JpaRepository ← DB 접근 │ │
│ │ │ │
│ │ ⚠ 두 가지 인프라 관심사가 하나에 섞임! │ │
│ └──────────────────────────────────────────┘ │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ output-cache/caffeine 모듈 │
│ │
│ CacheConfig.kt ← 설정만 덩그러니... │
│ │
│ ⚠ 캐시 모듈이 사실상 껍데기 │
└───────────────────────────────────────────────┘
// output-persistence/mysql 모듈
@Component
class YouTubeVideoPersistenceAdapter(...) : YouTubeVideoPersistencePort {
@Cacheable("discoverVideos")
override fun findAll(): List<YouTubeVideoDetail> = ...
@CacheEvict("discoverVideos", allEntries = true)
override fun saveAll(videos: List<YouTubeVideoDetail>) { ... }
}
문제점:
- 영속성 어댑터에 캐시 관심사가 섞인다. 한 클래스가 두 가지 인프라 관심사를 동시에 담당
- 캐시 모듈(
output-cache/caffeine)이 사실상CacheConfig만 갖고 있는 껍데기가 된다 - 나중에 Redis로 교체하거나 캐시를 제거할 때 영속성 어댑터를 수정해야 한다
대안 C: @Configuration에서 수동 new로 조립
┌────────────────────────────────────────────────────────────────┐
│ bootstrap 모듈 │
│ │
│ @Configuration │
│ class YouTubePersistenceWiringConfig { │
│ │
│ @Bean │
│ fun port(repo) = │
│ CachingAdapter( ◄── new로 직접 생성 │
│ PersistenceAdapter(repo) ◄── new로 직접 생성 │
│ ) │
│ } │
│ │
│ ✅ 모듈 간 의존성: 완벽하게 깔끔 │
│ ❌ 문제: new로 만든 객체 → Spring 프록시 없음 │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ new PersistenceAdapter(repo) │ │
│ │ │ │ │
│ │ ├── @Transactional → ❌ 동작 안 함 (프록시 없음) │ │
│ │ └── dirty checking → ❌ 동작 안 함 │ │
│ │ │ │
│ │ new CachingAdapter(...) │ │
│ │ │ │ │
│ │ ├── @Cacheable → ❌ 동작 안 함 (프록시 없음) │ │
│ │ └── @CacheEvict → ❌ 동작 안 함 │ │
│ └────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
// bootstrap 모듈
@Configuration
class YouTubePersistenceWiringConfig {
@Bean
fun youTubeVideoPersistencePort(
repo: YouTubeVideoJpaRepository,
): YouTubeVideoPersistencePort =
YouTubeVideoCachingAdapter(
YouTubeVideoPersistenceAdapter(repo)
)
}
모듈 간 의존성 측면에서는 가장 깔끔하다. bootstrap이 유일한 조립자 역할을 하고, 캐시 모듈과 영속성 모듈은 서로 모른다.
그러나 치명적인 문제가 있다:
new로 직접 생성한 객체는 Spring 빈이 아니다. Spring의 AOP 프록시가 적용되지 않기 때문에:
YouTubeVideoPersistenceAdapter의@Transactional이 동작하지 않는다YouTubeVideoCachingAdapter의@Cacheable,@CacheEvict도 동작하지 않는다
Spring의 @Transactional은 CGLIB 프록시 기반으로 동작한다. BeanPostProcessor가 빈 등록 시점에 프록시를 생성하는데, new로 만든 객체는 이 과정을 거치지 않는다. 결국 트랜잭션 관리를 우리가 직접 TransactionTemplate이나 EntityManager로 수동 처리해야 한다.
Spring Data JPA의
SimpleJpaRepository자체에는@Transactional이 내장되어 있어JpaRepository메서드 호출은 트랜잭션이 보장된다. 하지만 어댑터 레벨에서 "조회 → 비교 → 저장"처럼 여러 Repository 호출을 묶어야 하는 upsert 로직은 어댑터의@Transactional이 반드시 필요하다.
이 트레이드오프 때문에, 아키텍처의 순수성보다 트랜잭션 안정성이 더 중요하다고 판단했다.
3. 최종 선택: 데코레이터 패턴 + @Primary + @Qualifier
데코레이터 패턴이란
데코레이터 패턴은 원래 객체를 감싸서 기능을 추가하되, 원래 객체를 건드리지 않는 구조 패턴이다.
커피 = 아메리카노
커피 + 우유 = 카페라떼 (아메리카노를 감싸서 우유를 추가)
커피 + 우유 + 시럽 = 바닐라라떼 (카페라떼를 감싸서 시럽을 추가)
아메리카노 자체를 바꾼 게 아니다. 겉에 감싸서 기능(우유, 시럽)을 덧붙인 것이다.
적용 구조
┌──────────────────────────────────────┐
│ Application (UseCase) │
│ │
│ class GetVideosUseCase( │
│ val port: PersistencePort ◄─ 주입 │
│ ) │
│ │
│ "나는 Port만 알아. 뒤에 뭐가 있는지 │
│ 캐시가 있는지 DB만 있는지 모른다." │
└──────────────┬───────────────────────┘
│
│ YouTubeVideoPersistencePort 주입
│ (@Primary 때문에 캐시 어댑터가 선택됨)
▼
┌──────────────────────────────────────┐
│ YouTubeVideoCachingAdapter │
│ (@Primary, caffeine 모듈) │
│ │
│ ┌────────────────────────────────┐ │
│ │ findAll() 호출 시: │ │
│ │ │ │
│ │ 캐시 히트? ──YES──► 바로 반환 │ │
│ │ │ │ │
│ │ NO │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ delegate.findAll() 호출 │ │
│ │ (DB 어댑터에 위임) │ │
│ └────────────────────────────────┘ │
└──────────────┬───────────────────────┘
│
│ @Qualifier("youtubeVideoDbAdapter")
│ (delegate로 DB 어댑터를 명시 지정)
▼
┌──────────────────────────────────────┐
│ YouTubeVideoPersistenceAdapter │
│ (mysql 모듈, 순수 DB) │
│ │
│ @Transactional │
│ JpaRepository.findAll() │
└──────────────┬───────────────────────┘
│
▼
┌─────────┐
│ MySQL │
└─────────┘
핵심은, 두 어댑터 모두 같은 YouTubeVideoPersistencePort 인터페이스를 구현한다는 것이다. 캐시 어댑터는 자기가 직접 DB를 건드리지 않고, delegate(실제 DB 어댑터)에게 위임한다. Application 레이어는 포트만 알고, 캐시 존재를 모른다.
실제 코드
포트 인터페이스 (application 모듈)
package com.linktrip.application.port.output.persistence
interface YouTubeVideoPersistencePort {
fun saveAll(videos: List<YouTubeVideoDetail>)
fun findAll(): List<YouTubeVideoDetail>
}
영속성 어댑터 (output-persistence/mysql 모듈)
@Component("youtubeVideoDbAdapter")
class YouTubeVideoPersistenceAdapter(
private val youTubeVideoJpaRepository: YouTubeVideoJpaRepository,
) : YouTubeVideoPersistencePort {
@Transactional
override fun saveAll(videos: List<YouTubeVideoDetail>) {
val videoIds = videos.map { it.videoId }
val existingMap = youTubeVideoJpaRepository
.findAllByVideoIdIn(videoIds)
.associateBy { it.videoId }
val entitiesToSave = videos.map { detail ->
existingMap[detail.videoId]?.apply {
updateFrom(detail)
} ?: YouTubeVideoEntity.from(IdGenerator.generate(), detail)
}
youTubeVideoJpaRepository.saveAll(entitiesToSave)
}
@Transactional(readOnly = true)
override fun findAll(): List<YouTubeVideoDetail> =
youTubeVideoJpaRepository.findAllByOrderByViewCountDesc()
.map { it.toDomain() }
}
@Component("youtubeVideoDbAdapter")로 빈 이름을 명시한다. 이 이름이 뒤에서 @Qualifier의 키가 된다.
캐시 데코레이터 어댑터 (output-cache/caffeine 모듈)
@Primary
@Component
class YouTubeVideoCachingAdapter(
@param:Qualifier("youtubeVideoDbAdapter")
private val delegate: YouTubeVideoPersistencePort,
) : YouTubeVideoPersistencePort {
@CacheEvict(value = [CacheConfig.DISCOVER_VIDEOS], allEntries = true)
override fun saveAll(videos: List<YouTubeVideoDetail>) {
delegate.saveAll(videos)
}
@Cacheable(value = [CacheConfig.DISCOVER_VIDEOS])
override fun findAll(): List<YouTubeVideoDetail> =
delegate.findAll()
}
@Primary와 @Qualifier의 역할
같은 인터페이스(YouTubeVideoPersistencePort)를 구현한 빈이 2개 존재한다:
| 빈 | 모듈 | 역할 |
|---|---|---|
YouTubeVideoPersistenceAdapter |
mysql | 실제 DB 처리 |
YouTubeVideoCachingAdapter |
caffeine | 캐시 래핑 |
Spring은 같은 타입의 빈이 여러 개이면 주입 시 어떤 걸 줄지 모른다.
@Primary: "같은 타입 여러 개 있으면 기본으로 나를 줘" — 서비스에서YouTubeVideoPersistencePort를 주입받으면 캐시 어댑터가 선택된다@Qualifier("youtubeVideoDbAdapter"): "@Primary 말고, 이 이름의 빈을 줘" — 캐시 어댑터가 delegate로 DB 어댑터를 정확히 지정받는다
Spring의 빈 조립 과정:
1. 빈 등록
"youtubeVideoDbAdapter" → YouTubeVideoPersistenceAdapter
"youTubeVideoCachingAdapter" → YouTubeVideoCachingAdapter (@Primary)
2. CachingAdapter 생성 시
delegate에 뭘 넣지?
→ @Qualifier("youtubeVideoDbAdapter") → DB 어댑터를 주입
3. 서비스에서 Port 주입 시
→ 2개 있는데 @Primary가 CachingAdapter → 캐시 어댑터를 주입
4. 실제 호출
서비스.findAll()
→ CachingAdapter.findAll() — @Cacheable
→ 캐시 히트면 바로 반환
→ 캐시 미스면 delegate.findAll() 호출
→ PersistenceAdapter.findAll() — @Transactional(readOnly = true)
→ DB 조회
4. 이 구조가 헥사고날 아키텍처를 위반하지 않는 이유
의존 방향 검증
┌─── 컴파일 타임 의존 방향 (build.gradle.kts) ───┐
│ │
│ ┌─────────────────┐ │
│ │ application │ │
│ │ │ │
│ │ Persistence │ │
│ │ Port.kt │ │
│ └────────┬────────┘ │
│ ▲ │ ▲ │
│ implementation │ implementation │
│ (":linktrip- │ (":linktrip- │
│ application") │ application") │
│ │ │ │ │
│ ┌──────────┘ │ └──────────┐ │
│ │ │ │ │
│ ┌──┴──────────┐ │ ┌─────────────┴──┐ │
│ │output-cache/ │ │ │output-persisten│ │
│ │caffeine │ │ │ce/mysql │ │
│ │ │ │ │ │ │
│ │ Caching │ │ │ Persistence │ │
│ │ Adapter.kt │ │ │ Adapter.kt │ │
│ └──────────────┘ │ └────────────────┘ │
│ │ │ │ │
│ │ ❌ 직접 의존 없음 │ │
│ │◄────────────────────────►│ │
│ │ │ │
└─────────┼──────────────────────────┼──────────┘
│ │
│ ┌─── 런타임 연결 ───┐ │
│ │ Spring DI가 │ │
│ │ @Primary와 │ │
└──►│ @Qualifier로 │◄─┘
│ 자동 조립 │
└───────────────────┘
캐시 어댑터가 의존하는 것은 YouTubeVideoPersistencePort — application 레이어의 인터페이스다. YouTubeVideoPersistenceAdapter(mysql 구현체)를 직접 import하거나 모듈 의존성으로 갖지 않는다.
실제 build.gradle.kts를 봐도:
// caffeine/build.gradle.kts
dependencies {
implementation(project(":linktrip-application")) // application만 의존
// mysql 모듈 의존 없음
}
"같은 인터페이스를 구현한 다른 구현체를 주입받는 것"은 인프라 모듈 간 직접 결합이 아니라, 포트 인터페이스를 통한 느슨한 합성(composition) 이다. 실제 연결은 Spring이 런타임에 DI로 수행한다.
Application 레이어의 순수성
변경 전 (캐시 없음):
Application ──► PersistencePort ──► DB 어댑터 ──► DB
│
이것만 알고 있음
변경 후 (캐시 추가):
Application ──► PersistencePort ──► 캐시 어댑터 ──► DB 어댑터 ──► DB
│
여전히 이것만 알고 있음
뒤에 캐시가 끼었는지
DB만 있는지 전혀 모름
캐시 제거 시:
Application ──► PersistencePort ──► DB 어댑터 ──► DB
│
Application 코드
한 줄도 안 바뀜 ✅
Application 코드는 한 줄도 바뀌지 않았다. 포트 인터페이스만 알고 있으니까. 캐시를 끼우든 빼든 Application은 모른다. 이것이 헥사고날 아키텍처의 핵심 목적 — 인프라 변경이 비즈니스 로직에 영향을 주지 않는 것이다.
5. 왜 @Configuration 수동 조립 대신 @Component를 선택했는가
이 결정의 핵심은 트랜잭션 안전성이다.
new로 생성하면 벌어지는 일
// bootstrap의 @Configuration에서
@Bean
fun youTubeVideoPersistencePort(repo: YouTubeVideoJpaRepository): YouTubeVideoPersistencePort =
YouTubeVideoCachingAdapter(YouTubeVideoPersistenceAdapter(repo))
이렇게 하면 YouTubeVideoPersistenceAdapter는 Spring 빈이 아니다. CGLIB 프록시가 생성되지 않으므로:
@Transactional이 동작하지 않는다saveAll()에서 조회 → 비교 → 저장을 하나의 트랜잭션으로 묶을 수 없다- Dirty checking이 동작하지 않아 기존 엔티티의 변경이 DB에 반영되지 않을 수 있다
Spring Data JPA의 SimpleJpaRepository.saveAll()은 내부적으로 @Transactional이 걸려 있어서 각 save() 호출은 트랜잭션이 보장된다. 하지만 어댑터에서 "한 번에 조회하고 분류해서 저장"하는 upsert 패턴은 어댑터 레벨의 트랜잭션이 필수다.
트레이드오프 비교
| 방식 | 모듈 간 분리 | 트랜잭션 | 캐시 프록시 | 코드 명시성 |
|---|---|---|---|---|
@Configuration + new |
완벽 | ❌ 수동 관리 필요 | ❌ 동작 안 함 | 매우 명확 |
@Component + @Primary/@Qualifier |
빈 이름 문자열 의존 | ✅ 프록시 자동 | ✅ 프록시 자동 | 약간 암묵적 |
빈 이름 문자열에 의존하는 것은 구현 클래스에 직접 의존하는 것보다 훨씬 약한 결합이다. 그리고 트랜잭션이 깨지는 것은 데이터 정합성 문제로 직결된다. 아키텍처의 순수성보다 트랜잭션 안정성이 더 중요하다고 판단했다.
6. 인프라 교체 시나리오
이 구조의 진가는 인프라를 교체할 때 드러난다.
Caffeine → Redis로 캐시 교체
현재: 교체 후:
Application Application
│ │
▼ ▼
PersistencePort PersistencePort
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Caffeine │ ──교체──► │ Redis │
│ CachingAdapter│ │ CachingAdapter│
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ MySQL │ │ MySQL │ ◄── 변경 없음!
│ Adapter │ │ Adapter │
└──────────────┘ └──────────────┘
수정 범위: caffeine 모듈 → redis 모듈 교체
mysql 모듈: 한 줄도 수정 안 함 ✅
Application: 한 줄도 수정 안 함 ✅
캐시 제거
현재: 제거 후:
Application Application
│ │
▼ ▼
PersistencePort PersistencePort
│ │
▼ │
┌──────────────┐ │
│ Caffeine │ ──제거──► │
│ CachingAdapter│ │
└──────┬───────┘ │
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ MySQL │ │ MySQL │
│ Adapter │ │ Adapter │
└──────────────┘ └──────────────┘
DB 어댑터가 유일한 구현체 → @Primary 없이도 자동 주입
Application 코드 변경 없음 ✅
MySQL → PostgreSQL로 DB 교체
현재: 교체 후:
Application Application
│ │
▼ ▼
PersistencePort PersistencePort
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Caffeine │ │ Caffeine │ ◄── 변경 없음!
│ CachingAdapter│ │ CachingAdapter│
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ MySQL │ ──교체──► │ PostgreSQL │
│ Adapter │ │ Adapter │
└──────────────┘ └──────────────┘
수정 범위: mysql 모듈 → postgresql 모듈 교체
caffeine 모듈: 한 줄도 수정 안 함 ✅
Application: 한 줄도 수정 안 함 ✅
각 모듈이 자기 관심사만 담당하고, 서로의 존재를 모른다. 교체 시 Application(핵심 비즈니스 로직)은 절대 수정하지 않는다.
7. Spring AOP 프록시의 동작 원리
이 패턴이 동작하는 근본적인 이유는 Spring의 AOP 프록시 메커니즘이다.
@Component로 등록된 빈에 @Transactional이나 @Cacheable이 있으면, Spring은 해당 빈을 직접 등록하지 않고 CGLIB 프록시 객체를 생성하여 빈으로 등록한다.
@Component로 등록한 경우 (Spring 빈)
─────────────────────────────────────────
서비스가 주입받는 것
│
▼
┌─────────────────────────────────────────┐
│ PersistenceAdapter$$SpringCGLIB (프록시) │ ◄── Spring이 자동 생성
│ │
│ findAll() 호출 시: │
│ 1. 트랜잭션 시작 (@Transactional) │
│ 2. 원본 메서드 호출 ──────────────┐ │
│ 3. 트랜잭션 커밋 │ │
└──────────────────────────────────────┼───┘
│
▼
┌─────────────────────────────────────────┐
│ PersistenceAdapter (원본 객체) │
│ │
│ 실제 JPA Repository 호출 │
└─────────────────────────────────────────┘
new로 직접 생성한 경우 (Spring 빈이 아님)
─────────────────────────────────────────
서비스가 주입받는 것
│
▼
┌─────────────────────────────────────────┐
│ PersistenceAdapter (원본 그대로) │
│ │
│ @Transactional → ❌ 무시됨 │
│ @Cacheable → ❌ 무시됨 │
│ │
│ 프록시가 없으므로 가로챌 수 없음 │
└─────────────────────────────────────────┘
프록시는 원본 클래스를 상속하여 생성된다. 때문에 Kotlin에서는 open 키워드가 필요하다(Kotlin 클래스는 기본이 final이므로 상속 불가). 이것은 헥사고날 아키텍처와는 무관한 순수한 언어 레벨 제약사항이다.
다른 빈이 이 타입을 @Autowired로 받으면, 실제로는 프록시 객체가 주입된다. 메서드 호출 시 프록시가 가로채서(intercept) 트랜잭션을 열거나, 캐시를 확인하는 등의 부가 기능을 수행한 뒤 원본 메서드를 호출한다.
주의: new로 직접 생성한 객체는 이 프록시 생성 과정을 거치지 않으므로, @Transactional과 @Cacheable이 동작하지 않는다. 이것이 @Configuration에서 수동 조립하는 대안 C를 선택하지 않은 핵심 이유다.
8. 정리 및 회고
최종 구조
linktrip-application/
port/output/persistence/
YouTubeVideoPersistencePort.kt ← 인터페이스 (1개)
linktrip-output-persistence/mysql/
adapter/
YouTubeVideoPersistenceAdapter.kt ← @Component + @Transactional (순수 DB)
linktrip-output-cache/caffeine/
adapter/
YouTubeVideoCachingAdapter.kt ← @Primary + @Qualifier (데코레이터)
config/
CacheConfig.kt ← Caffeine 설정 (TTL 4h, maxSize 100)
설계 결정 요약
| 결정 사항 | 선택 | 이유 |
|---|---|---|
| 캐시 위치 | 어댑터 레이어 (데코레이터) | 도메인이 캐시 존재를 몰라야 함 |
| 포트 개수 | 1개 유지 | 별도 CachePort는 도메인에 캐시 관심사 노출 |
| 빈 등록 방식 | @Component (Spring 관리) |
트랜잭션/캐시 프록시 보장 |
| 조립 방식 | @Primary + @Qualifier |
new 조립 시 프록시 미적용 |
| 모듈 간 의존 | 포트 인터페이스만 | 캐시 ↔ 영속성 직접 의존 없음 |
트레이드오프
솔직히 @Qualifier에 빈 이름 문자열을 쓰는 것은 좀 별로다. "youtubeVideoDbAdapter"라는 문자열에 오타가 나면 런타임에야 알 수 있다. 하지만 이 트레이드오프를 감수하면:
- Application 모듈(핵심 비즈니스 로직)은 캐시를 추가하든 제거하든 수정할 필요가 없다
- 각 인프라 모듈은 자기 관심사만 담당하며 서로를 모른다
- Spring AOP 프록시가 정상 동작하여 트랜잭션과 캐시가 보장된다
아키텍처의 완벽한 순수성을 지키려면 수동 조립이 맞겠지만, 그 경우 트랜잭션 관리를 직접 해야 하는 비용이 발생한다. 아키텍처는 결국 제약 조건 안에서의 최선의 선택이다. 빈 이름 문자열 의존이라는 작은 결합을 허용하고, 트랜잭션 안정성과 관심사 분리라는 큰 이점을 취하는 것이 실무적으로 가장 합리적이었다.
참고
이 글에서 다룬 패턴과 관련된 참고 자료들:
- Ardalis - CachedRepository Pattern — 같은 인터페이스를 구현한 캐시 데코레이터 패턴
- Milan Jovanovic - Decorator Pattern in Clean Architecture — 어댑터 레이어에서의 데코레이터 적용
- Baeldung - Hexagonal Architecture in Java — 포트/어댑터 기반 구조
- Spring Framework - Proxying Mechanisms — CGLIB 프록시의 동작 원리
- Spring Transaction Management: @Transactional In-Depth — 트랜잭션 프록시의 내부 동작