|

코틀린 디자인 패턴 시리즈 (2) – Factory Method 패턴으로 생성 책임 나누기

코틀린 Factory Method 패턴과 생성 책임 분리를 설명하는 대표 이미지
Factory Method는 생성 책임을 사용하는 코드 밖으로 분리해 변경 범위를 줄이는 데 초점을 둔다

Factory Method는 생성자를 감추는 기술이라기보다 생성 책임을 나누는 방법에 가깝습니다. 이 글에서는 코틀린에서 Factory Method를 언제 쓰면 좋고, 언제는 companion object나 top-level factory function으로 충분한지 쉽게 정리해보겠습니다.

처음에는 when 한 줄이면 됩니다. 하지만 타입이 늘고 초기화 규칙이 붙으면, 객체를 만드는 코드가 서비스 로직 안으로 계속 밀려 들어옵니다.

이때 필요한 것은 패턴 이름 암기가 아닙니다. 생성 책임을 따로 빼서 변경 범위를 줄이는 것입니다.


문제 장면

알림을 보내는 기능을 예로 들어보겠습니다. 처음에는 이메일만 있으면 단순합니다. 그런데 SMS, Slack, Push가 붙기 시작하면 어떤 구현체를 만들지, 어떤 설정이 필요한지, 실패 처리를 어디서 할지 판단이 늘어납니다.

문제는 객체 하나를 만드는 일이 아닙니다. 문제는 생성 기준이 비즈니스 로직 안에 섞이는 것입니다.

  • 새 채널을 추가할 때 수정 지점이 많아집니다.
  • 사용하는 쪽이 구체 클래스 이름을 너무 많이 알게 됩니다.
  • 테스트에서 가짜 구현을 끼우기 어려워집니다.
  • 생성 규칙과 실행 흐름이 한곳에 섞입니다.

직접 생성

작은 예제에서는 아래 코드가 자연스럽습니다. 실제로 초반에는 이렇게 시작해도 괜찮습니다.

interface NotificationSender {
    fun send(message: String)
}

class EmailSender : NotificationSender {
    override fun send(message: String) {
        println("EMAIL: $message")
    }
}

class SmsSender : NotificationSender {
    override fun send(message: String) {
        println("SMS: $message")
    }
}

class SlackSender : NotificationSender {
    override fun send(message: String) {
        println("SLACK: $message")
    }
}

class NotificationService {
    fun send(type: String, message: String) {
        val sender = when (type) {
            "email" -> EmailSender()
            "sms" -> SmsSender()
            "slack" -> SlackSender()
            else -> throw IllegalArgumentException("지원하지 않는 타입: $type")
        }

        sender.send(message)
    }
}

이 코드가 틀린 것은 아닙니다. 다만 NotificationService가 메시지를 보내는 흐름뿐 아니라, 어떤 구현체를 만들어야 하는지까지 함께 책임지고 있습니다. 즉, 실행 책임생성 책임이 한 클래스 안에 묶여 있습니다.


Factory Method 핵심

Factory Method의 핵심은 생성자 호출을 다른 메서드로 옮기는 데 있지 않습니다. 핵심은 구체 객체를 어떤 기준으로 만들지 결정하는 책임을 분리하는 것입니다.

  • <code>Product</code>: 공통 인터페이스
  • <code>ConcreteProduct</code>: 실제 구현체
  • <code>Creator</code>: 공통 흐름을 가진 쪽
  • <code>factoryMethod()</code>: 어떤 구현체를 만들지 정하는 지점

여기서 중요한 포인트는 Creator입니다. Creator는 생성만 하는 클래스가 아니라, 보통 생성된 객체를 사용하는 공통 흐름을 이미 가지고 있습니다.


구조

아래 그림처럼 보면 감이 빨리 잡힙니다. 공통 흐름은 NotificationProcessor에 두고, 어떤 NotificationSender를 만들지는 createSender()가 맡습니다.

Factory Method 구조에서 공통 흐름과 생성 지점이 분리되는 모습을 보여주는 세로형 다이어그램
공통 흐름은 Creator에 두고, 실제 Product 선택은 factory method가 맡는다

코틀린 예제

같은 알림 예제를 Factory Method 방식으로 바꾸면 아래처럼 볼 수 있습니다.

interface NotificationSender {
    fun send(message: String)
}

class EmailSender : NotificationSender {
    override fun send(message: String) {
        println("EMAIL: $message")
    }
}

class SmsSender : NotificationSender {
    override fun send(message: String) {
        println("SMS: $message")
    }
}

class SlackSender : NotificationSender {
    override fun send(message: String) {
        println("SLACK: $message")
    }
}

abstract class NotificationProcessor {
    fun process(message: String) {
        val sender = createSender()
        sender.send(message)
        logResult(message)
    }

    protected abstract fun createSender(): NotificationSender

    private fun logResult(message: String) {
        println("sent: $message")
    }
}

class EmailNotificationProcessor : NotificationProcessor() {
    override fun createSender(): NotificationSender = EmailSender()
}

class SmsNotificationProcessor : NotificationProcessor() {
    override fun createSender(): NotificationSender = SmsSender()
}

class SlackNotificationProcessor : NotificationProcessor() {
    override fun createSender(): NotificationSender = SlackSender()
}

이제 process()는 보내는 절차에 집중합니다. 반면 어떤 sender를 만들지는 하위 구현이 맡습니다. 즉, 보내는 절차생성 판단이 분리됩니다.


유지보수

겉으로만 보면 생성자 호출이 다른 곳으로 이동한 것처럼 보일 수 있습니다. 하지만 실무에서는 아래 차이가 더 중요합니다.

의존성

NotificationProcessorNotificationSender 인터페이스만 알면 됩니다. 이메일인지 SMS인지 Slack인지는 createSender() 쪽으로 밀려났습니다.

공통 흐름

process() 안에는 로그, 권한 검사, 재시도, 메트릭 기록 같은 공통 로직을 둘 수 있습니다. 채널마다 달라지는 것은 생성 지점으로 보내고, 공통 절차는 한곳에 남길 수 있습니다.

확장성

Push 알림이 필요해지면 PushSenderPushNotificationProcessor를 추가하면 됩니다. 기존 공통 흐름을 크게 흔들지 않아도 됩니다.

테스트

생성 로직과 실행 흐름이 분리되면 어디를 검증해야 하는지 더 분명해집니다. 테스트 코드도 읽기 쉬워집니다.


코틀린 비교

굳이 Factory Method까지 가지 않아도 되는 경우는 분명히 있습니다. 코틀린에서는 작은 생성 문제를 더 가볍게 풀 수 있는 도구가 많습니다.

companion object

class User private constructor(
    val name: String,
    val role: String,
) {
    companion object {
        fun guest(name: String): User = User(name = name, role = "GUEST")
        fun admin(name: String): User = User(name = name, role = "ADMIN")
    }
}

이 경우는 생성 규칙이 User 안에 가깝고, 종류도 많지 않습니다. 또 공통 실행 흐름을 가진 별도 creator 구조도 필요하지 않습니다. 이럴 때 companion object는 클래스 안에 두는 이름 있는 factory function으로 보면 충분합니다.

top-level function

fun createLocalDateRange(days: Int): ClosedRange<Int> {
    require(days > 0)
    return 1..days
}

생성 로직이 특정 클래스의 정체성과 꼭 묶일 필요가 없다면 top-level function이 더 읽기 좋을 수 있습니다. 파일 단위로 두기 쉽고, privateinternal로 범위를 조절하기도 편합니다.

factory function

fun createSender(type: String): NotificationSender = when (type) {
    "email" -> EmailSender()
    "sms" -> SmsSender()
    "slack" -> SlackSender()
    else -> throw IllegalArgumentException("지원하지 않는 타입: $type")
}

이 정도까지는 충분히 괜찮습니다. 오히려 억지로 추상 클래스와 하위 클래스를 늘리면 구조만 커질 수 있습니다. 중요한 것은 패턴 이름이 아니라, 정말로 생성 책임과 공통 흐름을 나눌 필요가 있는지입니다.


판단 기준

Factory Method가 잘 맞는 상황은 생각보다 단순합니다. 생성 자체보다 생성과 사용하는 흐름을 함께 설계해야 할 때 잘 맞습니다.

  • 공통 처리 흐름은 같고, 생성되는 구현체만 달라집니다.
  • 새 타입이 계속 추가될 가능성이 있습니다.
  • 사용하는 쪽이 구체 클래스에 덜 의존해야 합니다.
  • 테스트나 확장 포인트를 분명히 나누고 싶습니다.
  • 라이브러리나 프레임워크처럼 하위 구현이 확장 지점이 됩니다.

반대로 생성 규칙이 아주 단순하고, 타입 수가 적고, 공통 creator 흐름이 따로 없다면 이름 있는 factory function 하나면 충분합니다.

Factory Method는 패턴을 멋있게 적용하는 기술이 아니라 생성 책임과 유지보수 범위를 정리하는 도구입니다.


남용 주의

코틀린에서는 전통적인 패턴 모양을 그대로 복사할 필요가 없습니다. 오히려 작은 문제에 과하게 적용하면 구조만 커집니다.

함수로 시작

생성 로직이 조금 복잡해지는 단계까지는 factory function 하나로도 충분합니다. 처음부터 추상 클래스 구조를 세울 필요는 없습니다.

흐름이 생기면 분리

단순 생성이 아니라, 생성된 객체를 사용해 공통 단계를 수행하는 흐름이 생기면 Creator 구조가 더 분명해집니다.

상속만 정답은 아님

고전적인 설명은 abstract class Creator와 override 형태가 많습니다. 하지만 실전 코틀린에서는 조합, 함수 주입, 전략 객체와 함께 더 가볍게 풀 수도 있습니다. 중요한 것은 클래스 모양이 아니라 책임 분리입니다.


Abstract Factory 차이

Factory Method는 보통 한 제품을 어떻게 만들지에 더 가깝습니다. 예를 들어 알림 예제에서는 NotificationSender가 핵심 제품입니다. 어떤 sender를 만들지 바꾸는 것이 중심입니다.

반면 Abstract Factory는 서로 관련된 여러 객체를 한 묶음으로 일관되게 생성해야 할 때 더 잘 맞습니다. 예를 들면 다크 테마용 버튼, 입력창, 다이얼로그를 함께 맞춰 생성하는 경우입니다.

  • Factory Method: 어떤 제품 하나를 어떻게 만들까
  • Abstract Factory: 서로 맞물리는 제품군 전체를 어떻게 만들까

실무 질문

실무에서는 이 질문 하나면 됩니다. “이 객체를 만드는 기준이 앞으로 자주 바뀔까?”

자주 바뀌고, 그 기준이 사용하는 흐름과 섞여 있고, 수정할 때마다 여러 곳을 함께 고쳐야 한다면 Factory Method 쪽으로 한 번 정리해볼 가치가 있습니다.

반대로 생성 규칙이 작고 안정적이라면 companion object나 top-level function이 더 낫습니다. 패턴을 쓴다고 항상 좋은 구조가 되는 것은 아닙니다. 패턴의 목적은 구조를 키우는 것이 아니라 변경 비용을 줄이는 것입니다.


마무리

Factory Method는 생성자를 감추는 기술이 아닙니다. 객체를 누가 만들지에 대한 책임을 분리해서, 사용하는 코드가 덜 흔들리게 만드는 설계 도구입니다.

코틀린에서는 작은 문제를 companion object나 top-level factory function으로 충분히 풀 수 있습니다. 하지만 공통 처리 흐름과 확장 포인트가 생기기 시작하면 Factory Method가 훨씬 분명한 기준을 줍니다.

관련해서 먼저 읽어두면 좋은 글은 코틀린 디자인 패턴 시리즈(0) – 왜 아직도 디자인 패턴을 배워야 할까, 코틀린 디자인 패턴 시리즈 (1) – Singleton 패턴은 언제 쓰고 object는 어떻게 다를까, 객체지향에서 책임을 잘 나누는 기준입니다.

공식 참고 자료로는 Kotlin object declarations, Kotlin functions, visibility modifiers 문서를 함께 보면 좋습니다.

함께보면 좋은 글