60. 코틀린으로 배우는 디자인 패턴 (3/4) — 구조 패턴 ②: Facade, Flyweight, Proxy
시리즈 안내
1편 — 생성 패턴: Abstract Factory, Builder, Prototype, Singleton
2편 — 구조 패턴 ①: Adapter, Bridge, Composite, Decorator
3편 — 구조 패턴 ②: Facade, Flyweight, Proxy (현재 글)
4편 — 행동 패턴: Chain of Responsibility, Command, Iterator, Mediator, Memento
들어가며
구조 패턴의 나머지 세 가지입니다. 2편에서 "구조 패턴은 코드 모양이 다 비슷하고 의도만 다르다"고 했는데, 이번 편의 Proxy가 그 말의 결정판입니다. Decorator와 코드가 글자 단위로 같을 수도 있습니다.
그래서 이번 편은 마지막에 구조 패턴 7개를 한 표로 정리하고 갑니다. 그전에 각 패턴을 하나씩 보겠습니다.
1. Facade — 원스톱 창구
한 줄 요약: 복잡한 서브시스템 여러 개를 단순한 창구 하나로 가린다.
어떤 문제를 푸나
주문 하나가 완료될 때 실제로 벌어지는 일을 나열해 보면 이렇습니다.
- 재고 차감
- 결제 승인
- 포인트 적립
- 주문 확정 알림 발송
이걸 컨트롤러에 그대로 쓰면 이렇게 됩니다.
// 안 좋은 예 — 컨트롤러가 서브시스템 4개를 전부 안다
@RestController
class OrderController(
private val inventoryService: InventoryService,
private val paymentService: PaymentService,
private val pointService: PointService,
private val notificationService: NotificationService,
) {
@PostMapping("/orders")
fun placeOrder(@RequestBody request: OrderRequest): OrderResponse {
inventoryService.decrease(request.productId, request.quantity)
val paymentKey = paymentService.pay(request.userId, request.amount)
pointService.accumulate(request.userId, request.amount / 100)
notificationService.notifyOrderPlaced(request.userId, paymentKey)
return OrderResponse(paymentKey)
}
}
문제는 순서와 조합에 관한 지식이 컨트롤러에 새어 나왔다는 점입니다. 배치 작업에서도, 어드민에서도 같은 주문 처리가 필요하면 이 네 줄이 그대로 복제됩니다. 그리고 어느 한 곳에서 포인트 적립을 빠뜨리는 날이 옵니다.
구현
호출 순서와 조합을 한 클래스에 가둡니다.
class OrderFacade(
private val inventoryService: InventoryService,
private val paymentService: PaymentService,
private val pointService: PointService,
private val notificationService: NotificationService,
) {
fun placeOrder(command: PlaceOrderCommand): OrderResult {
inventoryService.decrease(command.productId, command.quantity)
val paymentKey = runCatching {
paymentService.pay(command.userId, command.amount)
}.getOrElse { e ->
inventoryService.restore(command.productId, command.quantity) // 보상 처리
throw OrderFailedException("결제에 실패했습니다", e)
}
pointService.accumulate(command.userId, command.amount / 100)
notificationService.notifyOrderPlaced(command.userId, paymentKey)
return OrderResult(paymentKey = paymentKey, status = OrderStatus.PAID)
}
}
이제 컨트롤러는 이렇게 됩니다.
@RestController
class OrderController(private val orderFacade: OrderFacade) {
@PostMapping("/orders")
fun placeOrder(@RequestBody request: OrderRequest): OrderResponse =
orderFacade.placeOrder(request.toCommand()).toResponse()
}

의존성이 4개에서 1개로 줄었고, 실패 시 보상 처리 같은 조율 로직이 한 군데에 모였습니다.
코틀린스럽게
외부 라이브러리를 감싸는 가벼운 퍼사드는 확장 함수로 만들면 클래스조차 필요 없습니다.
// 3줄짜리 준비 코드를 매번 쓰지 않도록
fun RedisTemplate<String, String>.cacheJson(key: String, value: Any, ttl: Duration) {
opsForValue().set(key, objectMapper.writeValueAsString(value), ttl)
}
주의점
- Facade는 기능을 새로 만들지 않습니다. 조율만 합니다. 여기에 도메인 규칙(할인 계산, 등급 판정 등)이 들어가기 시작하면 그건 도메인 서비스로 내려가야 할 코드입니다. 이 선을 안 지키면 Facade는 몇 달 안에 God Object가 됩니다.
- 트랜잭션 경계를 조심하세요. 위 예제처럼 외부 API 호출(결제)이 섞인 메서드에
@Transactional을 걸면, 외부 응답을 기다리는 내내 DB 커넥션을 잡고 있게 됩니다. 외부 호출은 트랜잭션 밖으로 빼고, 실패 시 보상 처리를 명시적으로 작성하는 편이 안전합니다. - Facade가 있다고 서브시스템 직접 접근을 막을 필요는 없습니다. 편의를 위한 창구일 뿐입니다.
Adapter와 뭐가 다른가요
- Adapter: 상대가 요구하는 인터페이스가 이미 정해져 있고, 거기에 맞춥니다. 보통 1:1.
- Facade: 인터페이스를 내가 새로 만듭니다. 보통 1:N(여러 서브시스템을 하나로).
2. Flyweight — 공간 절약가
한 줄 요약: 똑같은 객체를 수없이 만드는 대신 하나를 공유한다.
어떤 문제를 푸나
주문 데이터 100만 건을 메모리에 올려 통계를 낸다고 해 봅시다. 각 주문에는 배송 지역 정보가 붙습니다.
data class Region(val code: String, val name: String, val deliveryFee: Long)
data class Order(
val id: Long,
val amount: Long,
val region: Region, // 100만 개의 Region 인스턴스가 생긴다
)
그런데 실제 지역은 250개뿐입니다. 같은 값을 가진 객체 100만 개가 힙을 차지하는 셈입니다.
Flyweight는 객체의 상태를 두 가지로 나눕니다.
- 내재적 상태(intrinsic): 어떤 문맥에서든 동일한 값. 지역 코드, 이름, 배송비. → 공유 가능
- 외재적 상태(extrinsic): 사용하는 쪽마다 다른 값. 주문 금액, 수령인. → 매번 전달
내재적 상태만 뽑아 공유하면 인스턴스가 250개로 줄어듭니다.

구현
class Region private constructor(
val code: String,
val name: String,
val deliveryFee: Long,
) {
// 외재적 상태는 필드로 갖지 않고 파라미터로 받는다
fun totalFee(orderAmount: Long): Long =
if (orderAmount >= FREE_THRESHOLD) 0 else deliveryFee
companion object {
private const val FREE_THRESHOLD = 50_000L
private val pool = ConcurrentHashMap<String, Region>()
fun of(code: String): Region = pool.computeIfAbsent(code) { c ->
val row = RegionTable.find(c) // 최초 1회만 조회
Region(c, row.name, row.deliveryFee)
}
}
}
생성자를 private으로 막고 of()로만 만들게 하는 것이 핵심입니다. 그래야 공유가 보장됩니다.
val a = Region.of("11")
val b = Region.of("11")
println(a === b) // true — 같은 인스턴스
코틀린스럽게
고정된 집합이라면 enum이 이미 완벽한 Flyweight입니다. 풀을 손으로 만들 이유가 없습니다.
enum class Region(val label: String, val deliveryFee: Long) {
SEOUL("서울", 3_000),
JEJU("제주", 6_000),
;
}
data class를 유지하면서 캐시를 붙이고 싶다면 companion object의 invoke 연산자를 쓰면 호출부가 생성자와 똑같아 보입니다.
data class Currency private constructor(val code: String) {
companion object {
private val pool = ConcurrentHashMap<String, Currency>()
operator fun invoke(code: String): Currency =
pool.computeIfAbsent(code) { Currency(it) }
}
}
val krw = Currency("KRW") // 생성자처럼 보이지만 실제로는 풀에서 꺼낸다
알아두면 좋은 것: JVM도 Flyweight를 쓴다
Integer.valueOf는 -128~127 범위를 캐싱합니다. 코틀린에서 Int를 박싱하면 이 동작이 그대로 드러납니다.
val a: Int? = 127
val b: Int? = 127
println(a == b) // true
println(a === b) // true — 캐시된 같은 객체
val c: Int? = 128
val d: Int? = 128
println(c == d) // true
println(c === d) // false — 각각 새 객체
박싱된 숫자를 ===로 비교하면 안 되는 이유가 바로 이겁니다. 값 비교는 항상 ==를 쓰세요.
주의점
- 풀이 메모리 누수가 됩니다. 키 종류가 무한정 늘어나는 값(주문번호, 세션 ID 등)에 풀을 쓰면
ConcurrentHashMap이 계속 커집니다. 키 집합이 유한하고 작을 때만 쓰세요. 불안하면 Caffeine 같은 캐시로 크기 상한을 두세요. - 공유 객체는 반드시 불변이어야 합니다. 한 곳에서 상태를 바꾸면 전부 바뀝니다.
val과 불변 컬렉션으로 강제하세요. - 먼저 측정하세요. 요즘 JVM과 하드웨어에서 이 패턴이 실제로 필요한 경우는 생각보다 드뭅니다. 프로파일링으로 병목을 확인하기 전에 적용하면 코드만 복잡해지는 조기 최적화가 됩니다.
String.intern()남용은 특히 위험합니다. 이건 그냥 쓰지 마세요.
3. Proxy — 대역 배우
한 줄 요약: 진짜 객체 앞에 서서 접근을 통제한다.
어떤 문제를 푸나
Proxy는 실제 객체와 같은 인터페이스를 구현하면서 그 앞을 지킵니다. 목적에 따라 네 가지로 나뉩니다.
| 종류 | 목적 | 실무 예 |
|---|---|---|
| 가상 프록시 | 비싼 객체 생성을 미룬다 | 지연 로딩 |
| 보호 프록시 | 권한 없는 호출을 막는다 | 인가 체크 |
| 원격 프록시 | 원격 호출을 로컬처럼 보이게 한다 | Feign Client |
| 캐싱 프록시 | 반복 호출을 줄인다 | 응답 캐시 |
가상 프록시 — 코틀린에서는 by lazy
가장 자주 쓰는 형태인데, 코틀린에서는 패턴이라는 자각조차 없이 씁니다.
class ProductDetail(
private val productId: Long,
private val reviewRepository: ReviewRepository,
) {
// 리뷰 목록은 실제로 접근할 때 한 번만 조회된다
val reviews: List<Review> by lazy { reviewRepository.findByProductId(productId) }
}
by lazy가 만들어 내는 대리 객체가 곧 가상 프록시입니다. 기본값이 스레드 안전(LazyThreadSafetyMode.SYNCHRONIZED)이라는 점도 알아 두면 좋습니다.
JPA의 LAZY 연관관계도 같은 원리입니다. 엔티티 자리에 프록시를 꽂아 두고, 필드에 접근하는 순간 쿼리를 날립니다. LazyInitializationException은 그 프록시가 세션이 닫힌 뒤에 초기화를 시도하다 나는 오류입니다.
보호 프록시
2편의 by 위임을 그대로 씁니다.
interface ReportService {
fun salesReport(month: YearMonth): Report
fun salaryReport(month: YearMonth): Report
}
class SecuredReportService(
private val delegate: ReportService,
private val currentUser: () -> User,
) : ReportService by delegate {
override fun salaryReport(month: YearMonth): Report {
val user = currentUser()
require(user.hasRole(Role.HR_MANAGER)) {
"급여 리포트 조회 권한이 없습니다. (userId=${user.id})"
}
return delegate.salaryReport(month)
}
// salesReport는 통제 없이 그대로 위임
}
원본 ReportService 구현체에는 권한 코드가 한 줄도 없습니다. 인가 정책이 바뀌면 프록시만 고칩니다.
Spring AOP는 거대한 Proxy 구현체다
@Transactional, @Cacheable, @Async가 동작하는 원리가 전부 Proxy입니다. 스프링이 빈을 등록할 때 원본 대신 프록시 객체를 컨테이너에 넣고, 메서드 호출을 가로채 부가 기능을 실행한 뒤 원본에 넘깁니다.
이 사실을 알면 유명한 함정 두 가지가 이해됩니다.
① 자기 호출(self-invocation) 문제
@Service
class OrderService {
fun processAll(orders: List<Order>) {
orders.forEach { process(it) } // ← 프록시를 안 거친다. 트랜잭션이 안 걸린다
}
@Transactional
fun process(order: Order) { /* ... */ }
}

외부에서 processAll()을 부르면 프록시를 통과하지만, 그 내부에서 process()를 부르는 건 프록시가 아니라 원본 자신입니다. 2편에서 다룬 by 위임의 self-call 함정과 정확히 같은 원인입니다. 위임은 상속이 아니다 — 이 한 문장이 두 함정을 모두 설명합니다.
② 코틀린 클래스는 기본이 final이다
스프링은 인터페이스가 없으면 CGLIB으로 상속 기반 프록시를 만듭니다. 그런데 코틀린은 클래스와 메서드가 기본 final이라 상속이 불가능합니다. 그래서 @Transactional이 조용히 동작하지 않는 일이 생깁니다.
해결은 kotlin-spring 플러그인(allopen)입니다. @Component, @Transactional 등이 붙은 클래스를 자동으로 open으로 만들어 줍니다.
// build.gradle.kts
plugins {
kotlin("plugin.spring") version "2.0.0"
}
스프링 부트 이니셜라이저로 코틀린 프로젝트를 만들면 기본 포함되지만, 수동 설정 시 빠뜨리면 원인 찾기가 아주 어렵습니다.
주의점
- 프록시는 호출자에게 투명해야 합니다. 반환 타입이나 예외 계약을 바꾸면 그건 Adapter입니다.
- 체인이 깊어지면 스택 트레이스가 읽기 어려워지고 디버깅이 힘들어집니다. 세 겹을 넘어가면 설계를 다시 보세요.
Decorator와 뭐가 다른가요
코드만 봐서는 구분이 안 됩니다. 둘 다 같은 인터페이스를 구현하고 원본을 감쌉니다. 차이는 의도뿐입니다.
- Decorator: 기능을 더한다. 겹겹이 쌓는 것이 전제이고, 원본은 항상 호출된다.
- Proxy: 접근을 통제한다. 보통 한 겹이고, 원본을 아예 호출하지 않을 수도 있다(권한 없음, 캐시 히트).
"원본을 안 부를 수도 있는가"가 실용적인 판별 기준입니다.
구조 패턴 7종 정리
3편에 걸쳐 다룬 구조 패턴을 한 표로 모았습니다.
| 패턴 | 한 줄 정의 | 판별 질문 | 코틀린 기능 |
|---|---|---|---|
| Adapter | 인터페이스를 맞춘다 | 상대 인터페이스가 이미 정해져 있나? | fun interface, 확장 함수 |
| Bridge | 두 축을 분리한다 | 클래스가 곱셈으로 늘어나나? | 생성자 주입, 함수 타입 |
| Composite | 트리를 균일하게 다룬다 | 개별과 묶음을 똑같이 쓰고 싶나? | sealed interface, sequence |
| Decorator | 기능을 덧붙인다 | 원본은 항상 불리나? | by 위임 |
| Facade | 창구를 단순화한다 | 서브시스템이 여러 개인가? | 확장 함수 |
| Flyweight | 인스턴스를 공유한다 | 같은 값 객체가 대량인가? | enum, operator invoke |
| Proxy | 접근을 통제한다 | 원본을 안 부를 수도 있나? | by lazy, by 위임 |
구조 패턴 대부분이 코틀린에서는 한두 개 키워드로 축약된다는 점이 이 시리즈에서 반복해 확인한 사실입니다. 패턴 이름을 아는 이유는 클래스를 많이 만들기 위해서가 아니라, 팀원과 설계 의도를 한 단어로 주고받기 위해서입니다.
다음 편은 시리즈 마지막으로, 객체들이 어떻게 협력하는가를 다루는 행동 패턴 — Chain of Responsibility, Command, Iterator, Mediator, Memento입니다.
Thanks
Hans