본문 바로가기

IT

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 — 원스톱 창구

한 줄 요약: 복잡한 서브시스템 여러 개를 단순한 창구 하나로 가린다.

어떤 문제를 푸나

주문 하나가 완료될 때 실제로 벌어지는 일을 나열해 보면 이렇습니다.

  1. 재고 차감
  2. 결제 승인
  3. 포인트 적립
  4. 주문 확정 알림 발송

이걸 컨트롤러에 그대로 쓰면 이렇게 됩니다.

// 안 좋은 예 — 컨트롤러가 서브시스템 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 objectinvoke 연산자를 쓰면 호출부가 생성자와 똑같아 보입니다.

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