IT

61. 코틀린으로 배우는 디자인 패턴 (4·완) — 행동 패턴: Chain of Responsibility, Command, Iterator, Mediator, Memento

Hans L 2026. 8. 9. 13:52

시리즈 안내
1편 — 생성 패턴: Abstract Factory, Builder, Prototype, Singleton
2편 — 구조 패턴 ①: Adapter, Bridge, Composite, Decorator
3편 — 구조 패턴 ②: Facade, Flyweight, Proxy
4편 — 행동 패턴: Chain of Responsibility, Command, Iterator, Mediator, Memento (현재 글)

들어가며

시리즈 마지막 편입니다. 생성 패턴이 "어떻게 만드는가", 구조 패턴이 "어떻게 엮는가"였다면, 행동 패턴은 "객체들이 어떻게 협력하는가"를 다룹니다.

앞의 두 분류가 주로 클래스 관계도의 문제였다면, 행동 패턴은 런타임에 흐르는 요청과 상태의 문제입니다. 그래서 이번 편은 예제를 물류 도메인 — 배송 시스템(TMS)과 물류센터 입고(WMS) — 으로 통일했습니다. 요청이 여러 단계를 거치고, 작업 이력이 쌓이고, 되돌려야 하는 일이 실제로 벌어지는 영역이라 패턴의 동기가 잘 드러납니다.


1. Chain of Responsibility — 요청 릴레이

한 줄 요약: 요청을 처리기들에게 차례로 넘기다가, 처리할 수 있는 쪽에서 멈춘다.

어떤 문제를 푸나

물류센터 입고 검수를 생각해 봅시다. 화물이 들어오면 순서대로 확인합니다.

  1. 발주서에 있는 상품인가
  2. 수량이 발주 수량과 맞는가
  3. 유통기한이 기준 이상 남았는가
  4. 보관 구역에 여유 공간이 있는가

이걸 메서드 하나에 if로 쌓으면 200줄짜리 함수가 되고, 검수 규칙을 추가·제거·재배열할 때마다 그 함수를 건드려야 합니다. 특정 센터에서만 다른 규칙을 적용하는 순간 분기가 두 배가 됩니다.

구현

각 검수를 독립된 객체로 떼어냅니다.

data class InboundRequest(
    val purchaseOrderId: String,
    val sku: String,
    val quantity: Int,
    val expiresAt: LocalDate,
    val centerCode: String,
)

// 통과하면 null, 걸리면 반려 사유를 반환
fun interface InboundRule {
    fun check(request: InboundRequest): Rejection?
}

data class Rejection(val code: String, val message: String)

class PurchaseOrderRule(private val poRepository: PurchaseOrderRepository) : InboundRule {
    override fun check(request: InboundRequest): Rejection? {
        val po = poRepository.find(request.purchaseOrderId)
            ?: return Rejection("PO_NOT_FOUND", "발주서를 찾을 수 없습니다")
        return if (po.contains(request.sku)) null
        else Rejection("SKU_NOT_IN_PO", "발주서에 없는 상품입니다: ${request.sku}")
    }
}

class ExpirationRule(private val minRemainingDays: Long = 30) : InboundRule {
    override fun check(request: InboundRequest): Rejection? {
        val remaining = ChronoUnit.DAYS.between(LocalDate.now(), request.expiresAt)
        return if (remaining >= minRemainingDays) null
        else Rejection("EXPIRES_SOON", "잔여 유통기한이 ${remaining}일로 기준 미달입니다")
    }
}

체인은 리스트 하나면 됩니다.

class InboundInspector(private val rules: List<InboundRule>) {
    fun inspect(request: InboundRequest): Rejection? =
        rules.firstNotNullOfOrNull { it.check(request) }   // 처음 걸리는 규칙에서 멈춤
}

val inspector = InboundInspector(
    listOf(
        PurchaseOrderRule(poRepository),
        QuantityRule(poRepository),
        ExpirationRule(minRemainingDays = 30),
        StorageCapacityRule(locationRepository),
    )
)

GoF 원서처럼 각 핸들러가 next 포인터를 들고 있을 필요가 없습니다. 코틀린에서는 리스트 순회로 충분하고, 순서 변경도 리스트 재배열로 끝납니다. 센터별로 다른 규칙을 쓰고 싶으면 리스트만 다르게 조립하면 됩니다.

한 걸음 더: 미들웨어형 체인

앞의 형태는 "걸리면 중단"만 됩니다. 각 단계가 앞뒤로 개입해야 한다면(로깅, 트랜잭션, 시간 측정) OkHttp Interceptor 스타일이 유용합니다.

typealias Next = (InboundRequest) -> InboundResult

fun interface Interceptor {
    fun intercept(request: InboundRequest, next: Next): InboundResult
}

fun List<Interceptor>.toChain(terminal: Next): Next =
    foldRight(terminal) { interceptor, next ->
        { request -> interceptor.intercept(request, next) }
    }
val chain = listOf(
    Interceptor { req, next ->
        val started = System.nanoTime()
        next(req).also { log.info("입고 처리 {}ms", (System.nanoTime() - started) / 1_000_000) }
    },
    Interceptor { req, next ->
        if (req.quantity <= 0) InboundResult.rejected("수량 오류") else next(req)
    },
).toChain(terminal = { req -> inboundService.receive(req) })

val result = chain(request)

foldRight로 함수를 겹겹이 감싸는 이 관용구는 알아 두면 여러 곳에서 쓰입니다. Spring Security의 FilterChain, Ktor의 파이프라인이 같은 구조입니다.

주의점

  • 끝까지 아무도 처리하지 못했을 때의 동작을 명시하세요. 조용히 null을 반환하면 나중에 원인을 찾기 어렵습니다.
  • 체인 순서에 암묵적 의존이 생기기 쉽습니다(예: 발주 대조가 먼저 돌아야 수량 검증이 의미 있음). 리스트 조립부에 주석으로 남겨 두세요.

2. Command — 작업을 객체로

한 줄 요약: 요청을 객체로 포장해서 저장·전달·취소·재실행할 수 있게 만든다.

어떤 문제를 푸나

WMS에서 작업자가 하는 일은 적치, 피킹, 이동, 재고 조정 같은 것들입니다. 이 작업들에는 공통 요구사항이 붙습니다.

  • 누가 언제 무엇을 했는지 전부 기록해야 한다(감사 추적)
  • 잘못된 작업은 되돌릴 수 있어야 한다
  • 바쁜 시간대에는 큐에 넣고 나중에 처리해야 한다

메서드 호출로는 이 셋 중 어느 것도 안 됩니다. 메서드 호출은 저장할 수도, 큐에 넣을 수도 없으니까요. 호출 자체를 객체로 만들면 셋 다 가능해집니다. 그게 Command입니다.

구현

sealed interface WorkCommand {
    val workerId: String
    val issuedAt: Instant

    fun execute(): WorkLog
    fun undo()
}

data class PutAwayCommand(
    override val workerId: String,
    override val issuedAt: Instant = Instant.now(),
    val sku: String,
    val quantity: Int,
    val toLocation: String,
) : WorkCommand {

    override fun execute(): WorkLog {
        inventoryStore.increase(toLocation, sku, quantity)
        return WorkLog(type = "PUT_AWAY", workerId = workerId, detail = "$sku x$quantity → $toLocation")
    }

    override fun undo() {
        inventoryStore.decrease(toLocation, sku, quantity)
    }
}

data class MoveCommand(
    override val workerId: String,
    override val issuedAt: Instant = Instant.now(),
    val sku: String,
    val quantity: Int,
    val from: String,
    val to: String,
) : WorkCommand {
    override fun execute(): WorkLog { /* ... */ }
    override fun undo() {
        inventoryStore.move(sku, quantity, from = to, to = from)   // 반대로 실행
    }
}

실행기(Invoker)는 커맨드가 무슨 일을 하는지 전혀 모릅니다.

class WorkExecutor(private val workLogRepository: WorkLogRepository) {
    private val history = ArrayDeque<WorkCommand>()

    fun execute(command: WorkCommand) {
        val log = command.execute()
        workLogRepository.save(log)      // 감사 추적이 자동으로 남는다
        history.addLast(command)
    }

    fun undoLast(): Boolean {
        val last = history.removeLastOrNull() ?: return false
        last.undo()
        workLogRepository.save(WorkLog(type = "UNDO", workerId = last.workerId, detail = "$last"))
        return true
    }
}

작업 종류가 늘어나도 WorkExecutor는 그대로입니다. 감사 로그와 실행 취소가 커맨드 하나 추가할 때마다 공짜로 따라옵니다.

코틀린스럽게

sealed interface로 선언하면 커맨드 집합이 닫혀 있어 직렬화와 라우팅이 편합니다. 큐에 넣거나 이벤트 스토어에 적재할 때 유용합니다.

@Serializable
sealed interface WorkCommand   // kotlinx.serialization이 타입 정보를 함께 직렬화

// 큐에서 꺼내 실행
val command = Json.decodeFromString<WorkCommand>(payload)
executor.execute(command)

실행 취소가 필요 없다면 클래스조차 과합니다. 함수 타입이 곧 커맨드입니다.

class TaskQueue {
    private val queue = ArrayDeque<() -> Unit>()
    fun enqueue(task: () -> Unit) = queue.addLast(task)
    fun runAll() { while (queue.isNotEmpty()) queue.removeFirst().invoke() }
}

queue.enqueue { inventoryStore.increase("A-01-03", sku, 10) }

주의점

  • undo()가 항상 가능한 건 아닙니다. 외부 시스템에 이미 전송한 요청, 발송된 알림은 되돌릴 수 없습니다. 되돌릴 수 없는 커맨드는 인터페이스를 분리하거나(UndoableCommand) 명시적으로 예외를 던지게 하세요.
  • 커맨드 객체가 저장소나 서비스를 직접 참조하면 직렬화가 어려워집니다. 큐잉이 목적이라면 커맨드는 데이터만 담고, 실행에 필요한 협력 객체는 실행 시점에 주입받는 구조가 낫습니다.
  • 이 패턴을 끝까지 밀면 이벤트 소싱(Event Sourcing)과 CQRS가 됩니다. 커맨드 로그가 곧 상태의 원본이 되는 구조입니다.

3. Iterator — 컬렉션 탐색기

한 줄 요약: 내부 구조를 노출하지 않고 원소를 하나씩 꺼낸다.

코틀린에서는 이미 다 되어 있다

Iterable, Iterator, for 루프, map/filter — 코틀린 표준 라이브러리 전체가 이 패턴 위에 서 있습니다. 그러니 "구현법"을 외울 이유는 없고, 직접 만들어야 하는 순간이 언제인지만 알면 됩니다.

커스텀 이터레이터는 operator fun iterator()만 제공하면 됩니다.

class DateRange(private val start: LocalDate, private val endInclusive: LocalDate) : Iterable<LocalDate> {
    override fun iterator(): Iterator<LocalDate> = object : Iterator<LocalDate> {
        private var current = start
        override fun hasNext() = !current.isAfter(endInclusive)
        override fun next(): LocalDate = current.also { current = current.plusDays(1) }
    }
}

for (date in DateRange(LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 7))) {
    println("$date 배송 건수: ${deliveryRepository.countByDate(date)}")
}

진짜 쓸모: 대량 데이터를 메모리에 안 올리고 순회하기

이 패턴이 실무에서 빛나는 지점은 여기입니다. 수년간 쌓인 작업 로그 테이블에서 수백만 건을 읽어 집계해야 한다고 해 봅시다. findAll()은 곧바로 OutOfMemoryError입니다.

커서 기반 페이징을 Sequence로 감싸면, 호출하는 쪽은 그냥 컬렉션처럼 쓰면서 실제로는 1,000건씩만 메모리에 올립니다.

fun WorkLogRepository.streamAfter(
    startId: Long = 0,
    pageSize: Int = 1_000,
): Sequence<WorkLog> = sequence {
    var cursor = startId
    while (true) {
        val page = findByIdGreaterThanOrderByIdAsc(cursor, pageSize)
        if (page.isEmpty()) break
        yieldAll(page)
        cursor = page.last().id
    }
}
val countByType = workLogRepository.streamAfter()
    .filter { it.createdAt >= lastMonth }
    .groupingBy { it.type }
    .eachCount()

Sequence지연 평가되므로 filtergroupingBy가 페이지 단위로 흘러가며 처리됩니다. 컬렉션의 List였다면 각 단계마다 중간 리스트가 통째로 만들어졌을 겁니다.

주의점

  • Sequence한 번만 순회 가능한 경우가 많습니다. 두 번 쓰려면 toList()로 확정하거나 시퀀스를 다시 만드세요.
  • 위 예제처럼 시퀀스 안에서 DB를 조회하면, 순회하는 동안 커넥션과 트랜잭션이 살아 있어야 합니다. 시퀀스를 트랜잭션 밖으로 반환하지 마세요. 이건 지연 평가에서 가장 자주 나는 사고입니다.
  • 원소가 수백 개 이하라면 Sequence가 오히려 느립니다. 대량일 때만 쓰세요.
  • 오프셋 페이징(LIMIT ... OFFSET ...)은 뒤로 갈수록 급격히 느려집니다. 위처럼 커서(마지막 id) 기반으로 짜세요.

4. Mediator — 통신 허브

한 줄 요약: 객체들이 서로 직접 부르지 않고 중재자를 통해 소통하게 한다.

어떤 문제를 푸나

배송 상태가 "배송 완료"로 바뀔 때 따라 붙는 일들입니다.

  • 고객에게 알림 발송
  • 정산 대상에 등록
  • 재고 시스템에 확정 통보
  • 배송기사 실적 반영

이걸 DeliveryService가 직접 다 부르면 배송 모듈이 알림·정산·재고·실적 모듈을 전부 알게 됩니다. 여기에 "반품 완료", "배송 실패" 이벤트까지 생기면 관계가 N:N으로 폭발합니다.

구현

스프링을 쓴다면 ApplicationEventPublisher가 이미 잘 만들어진 Mediator입니다.

data class DeliveryCompleted(
    val deliveryId: Long,
    val driverId: String,
    val completedAt: Instant,
)

@Service
class DeliveryService(
    private val deliveryRepository: DeliveryRepository,
    private val events: ApplicationEventPublisher,
) {
    @Transactional
    fun complete(deliveryId: Long, driverId: String) {
        val delivery = deliveryRepository.findById(deliveryId)
        delivery.complete()

        // 누가 듣는지 전혀 모른다
        events.publishEvent(DeliveryCompleted(deliveryId, driverId, Instant.now()))
    }
}

구독자는 각자 독립적으로 붙습니다.

@Component
class SettlementListener(private val settlementService: SettlementService) {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun on(event: DeliveryCompleted) {
        settlementService.register(event.deliveryId, event.driverId)
    }
}

AFTER_COMMIT은 실무에서 중요합니다. 배송 완료 트랜잭션이 롤백됐는데 알림만 나가는 사고를 막아 줍니다.

프레임워크 없이

class EventBus {
    private val handlers = ConcurrentHashMap<KClass<*>, MutableList<(Any) -> Unit>>()

    inline fun <reified T : Any> subscribe(noinline handler: (T) -> Unit) {
        @Suppress("UNCHECKED_CAST")
        register(T::class, handler as (Any) -> Unit)
    }

    fun publish(event: Any) {
        handlers[event::class]?.forEach { it(event) }
    }
}

주의점

  • 중재자가 God Object가 되기 쉽습니다. 중재자에 "A 이벤트면 B를 부르고, 그때 C 상태면 D도 부른다" 같은 분기가 쌓이기 시작하면, N:N 결합을 없앤 대신 모든 복잡도를 한곳에 모은 셈이 됩니다. 중재자는 전달만 하고 판단은 각 구독자가 하게 두세요.
  • 흐름 추적이 어려워집니다. publishEvent 한 줄에서 IDE의 "호출 위치 찾기"가 끊깁니다. 이벤트 이름 규칙을 정하고, 어떤 이벤트에 누가 반응하는지 문서로 남기는 편이 좋습니다.
  • 모든 모듈 간 호출을 이벤트로 바꾸지 마세요. 순서와 결과가 중요한 동기 흐름(재고 차감 후 결제)은 그냥 직접 호출하는 게 옳습니다. 3편의 Facade가 그 자리입니다.

5. Memento — 타임캡슐

한 줄 요약: 객체의 상태를 캡슐화를 깨지 않고 저장했다가 되돌린다.

어떤 문제를 푸나

배차 계획 편집 화면을 떠올려 봅시다. 관리자가 기사에게 배송 건을 이리저리 배정하다가 "아까 상태로 되돌리기"를 누릅니다.

되돌리려면 이전 상태를 어딘가 저장해야 하는데, 그렇다고 DispatchPlan의 내부 필드를 전부 public으로 열어 외부에서 백업하게 만들면 캡슐화가 무너집니다. Memento는 상태 스냅샷을 불투명한 객체로 만들어 이 문제를 피합니다.

구현

class DispatchPlan(initial: List<Assignment> = emptyList()) {

    var assignments: List<Assignment> = initial
        private set

    fun assign(deliveryId: Long, driverId: String) {
        assignments = assignments + Assignment(deliveryId, driverId)
    }

    fun unassign(deliveryId: Long) {
        assignments = assignments.filterNot { it.deliveryId == deliveryId }
    }

    // --- Memento ---
    fun save(): Memento = Memento(assignments)

    fun restore(memento: Memento) {
        assignments = memento.assignments
    }

    // 생성자가 private이라 DispatchPlan만 만들 수 있다
    class Memento internal constructor(internal val assignments: List<Assignment>)
}

보관자(Caretaker)는 스냅샷을 쌓아 두기만 합니다. 내용은 들여다볼 수 없습니다.

class DispatchEditor(private val plan: DispatchPlan) {
    private val undoStack = ArrayDeque<DispatchPlan.Memento>()

    fun edit(block: DispatchPlan.() -> Unit) {
        undoStack.addLast(plan.save())     // 변경 직전 스냅샷
        plan.block()
    }

    fun undo(): Boolean {
        val previous = undoStack.removeLastOrNull() ?: return false
        plan.restore(previous)
        return true
    }
}

editor.edit { assign(deliveryId = 1001, driverId = "D-77") }
editor.edit { unassign(deliveryId = 1002) }
editor.undo()   // 1002 배정이 복구된다

코틀린스럽게

상태가 이미 불변 data class라면 copy()가 곧 메멘토입니다. 별도 클래스가 필요 없습니다.

data class DispatchPlanState(val assignments: List<Assignment>)

class DispatchEditor {
    private var current = DispatchPlanState(emptyList())
    private val history = ArrayDeque<DispatchPlanState>()

    fun update(transform: (DispatchPlanState) -> DispatchPlanState) {
        history.addLast(current)
        current = transform(current)
    }

    fun undo() { history.removeLastOrNull()?.let { current = it } }
}

1편의 Prototype에서 다룬 얕은 복사 함정이 여기서 그대로 재등장합니다. 스냅샷에 MutableList가 들어 있으면 원본이 바뀔 때 과거 스냅샷도 함께 바뀝니다. 불변 컬렉션을 쓰는 것이 사실상 유일한 안전장치입니다.

주의점

  • 스냅샷 비용을 계산하세요. 상태가 크고 편집이 잦으면 메모리를 금방 잡아먹습니다. 이럴 땐 전체 상태 대신 역연산을 저장하는 Command 기반 undo가 훨씬 가볍습니다. 두 패턴의 트레이드오프입니다.
    • Memento: 복원이 확실하고 단순, 메모리 비쌈
    • Command undo: 메모리 저렴, 모든 연산에 정확한 역연산을 구현해야 함
  • 스냅샷을 DB나 Redis에 저장한다면 버전 호환을 생각해야 합니다. 필드가 바뀌면 예전 스냅샷이 복원되지 않습니다.

시리즈 마무리

4편에 걸쳐 16개 패턴을 봤습니다. 전체를 한 표로 정리합니다.

분류 패턴 한 줄 정의 코틀린 기능
생성 Abstract Factory 짝 맞는 객체군을 함께 만든다 인터페이스, 함수 타입
생성 Builder 단계별로 조립한다 기본 인자, 수신 객체 람다 DSL
생성 Prototype 복사해서 만든다 data classcopy()
생성 Singleton 인스턴스를 하나로 object (단, DI 우선)
구조 Adapter 인터페이스를 맞춘다 fun interface, 확장 함수
구조 Bridge 두 축을 분리한다 생성자 주입
구조 Composite 트리를 균일하게 다룬다 sealed interface, 재귀
구조 Decorator 기능을 덧붙인다 by 위임
구조 Facade 창구를 단순화한다 확장 함수
구조 Flyweight 인스턴스를 공유한다 enum, operator invoke
구조 Proxy 접근을 통제한다 by lazy, by 위임
행동 Chain of Responsibility 처리기에 차례로 넘긴다 List + firstNotNullOfOrNull, foldRight
행동 Command 요청을 객체로 만든다 sealed interface, 함수 타입
행동 Iterator 하나씩 꺼낸다 Sequence, operator fun iterator
행동 Mediator 중재자를 통해 소통한다 도메인 이벤트
행동 Memento 상태를 저장하고 되돌린다 data class + 불변 컬렉션

 

시리즈를 관통한 결론은 처음에 말한 그대로입니다. 코틀린에서 디자인 패턴을 공부하는 목적은 클래스를 더 만들기 위해서가 아닙니다. 절반 가까이는 언어 기능으로 이미 흡수됐고, 나머지도 자바보다 훨씬 적은 코드로 표현됩니다.

그럼에도 이름을 알아 둘 가치는 분명합니다. 코드 리뷰에서 "여기 CoR로 빼죠", "이건 Decorator가 아니라 Proxy입니다" 한마디가 문단 하나를 대신하기 때문입니다. 패턴은 코드가 아니라 어휘입니다.

읽어 주셔서 감사합니다. 

 

Thanks
Hans