[디자인패턴] 팩토리 메서드와 추상 팩토리(객체 하나와 제품군)

개요

팩토리 메서드(Factory Method)와 추상 팩토리(Abstract Factory)는 디자인 패턴 중 생성 패턴(Creational Pattern)에 속하는 두 가지다. 둘 다 객체를 직접 new로 만들지 않고 그 책임을 별도 객체에 맡긴다는 점은 같다. 하지만 팩토리 메서드는 객체 하나를 만드는 책임을 맡기고, 추상 팩토리는 서로 짝을 이뤄야 하는 여러 객체를 한 번에 만드는 책임을 맡긴다.

결제 수단처럼 구현체가 하나씩 독립적으로 늘어나는 도메인이라면 팩토리 메서드로 충분하다. 반면 리뷰를 만드는 객체와 지우는 객체가 항상 같은 플랫폼 짝으로 움직여야 한다면, 그 짝을 하나의 팩토리 안에 가두는 추상 팩토리가 필요하다.

이 패턴들은 확장에는 열려 있고 수정에는 닫혀 있어야 한다는 OCP(Open-Closed Principle, 개방 폐쇄 원칙)를 준수한다. 팩토리 메서드는 단일 제품 추가 시 원칙을 지키고, 추상 팩토리는 제품군을 추가할 경우 준수한다.

구분팩토리 메서드추상 팩토리
생성 단위객체 하나서로 관련된 객체 여러 개(제품군)
확장 방향새 제품을 추가한다(TossPayment 옆에 KakaoPayment)새 제품군을 추가한다(배민, 쿠팡 옆에 요기요)
OCP 개방 범위제품 구현체를 추가제품군(플랫폼)을 추가. 제품 종류를 추가하는 것은 오히려 닫혀 있다
언제 쓰는가인터페이스 하나에 구현체가 여러 개로 갈릴 때여러 제품이 항상 짝을 이뤄야 하고, 그 짝이 플랫폼마다 달라질 때

팩토리 메서드 패턴, 구현체 선택을 클라이언트에서 떼어내기

팩토리 메서드는 어떤 구현체를 만들지 결정하는 책임을 클라이언트에서 떼어내는 패턴이다. 클라이언트는 PaymentService같은 인터페이스 하나만 알고, 그 뒤에 TossPayment, DanalPayment, NaverPayment 중 무엇이 실제로 동작할지는 팩토리가 고른다.

결제 수단을 고르는 코드는 보통 타입을 하나 받아 그에 맞는 처리를 실행한다는 흐름이다. 문제는 클라이언트가 TOSS면 TossPayment, DANAL이면 DanalPayment라는 분기를 직접 들고 있게 된다는 점이다. 이 분기는 우리가 새 결제 수단을 추가할 때마다 유지보수를 해야한다. 결제사가 늘 때마다 유지보수가 필요해진다.

아래 그림이 그 구조다. OrderService 안에 when(type) 분기가 그대로 있고, TossPayment, DanalPayment, NaverPayment 세 구체 클래스를 직접 만든다.

패턴을 적용하면 이 구조가 아래처럼 바뀐다. 클라이언트는 PaymentServiceFactory에게 타입만 넘기고, 팩토리는 List로 주입받은 구현체들을 Map으로 묶어 타입에 맞는 것 하나를 돌려준다.

PaymentService 구현체와 PaymentServiceFactory

팩토리 메서드는 하나의 인터페이스, 여러 개의 상속 받은 구현체, 이 구현체들을 한 데 모은 팩토리 클래스가 필요하다.

enum class PaymentType { TOSS, DANAL, NAVER }
 
interface PaymentService {
    val type: PaymentType
    fun pay(orderId: Long)
}

각각 스프링 빈으로 등록해 두면, 나중에 팩토리가 List로 한꺼번에 받을 수 있다.

@Component
class TossPayment : PaymentService {
    override val type = PaymentType.TOSS
    override fun pay(orderId: Long) {
        println("토스로 $orderId 결제")
    }
}
 
@Component
class DanalPayment : PaymentService {
    override val type = PaymentType.DANAL
    override fun pay(orderId: Long) {
        println("다날로 $orderId 결제")
    }
}
 
@Component
class NaverPayment : PaymentService {
    override val type = PaymentType.NAVER
    override fun pay(orderId: Long) {
        println("네이버페이로 $orderId 결제")
    }
}

아래 as-is는 팩토리 메서드를 적용하기 전, OrderService가 타입을 직접 분기해 구체 클래스를 생성하는 코드다.

// as-is: 호출부가 타입을 직접 분기하며 구체 클래스를 생성한다
class OrderService {
    fun pay(orderId: Long, type: PaymentType) {
        val service = when (type) {
            PaymentType.TOSS -> TossPayment()
            PaymentType.DANAL -> DanalPayment()
            PaymentType.NAVER -> NaverPayment()
        }
        service.pay(orderId)
    }
}

이 코드는 요구사항을 만족하나 문제를 세 가지 안고 있다. 아래 표는 그 문제가 무엇이고 언제 터지는지를 정리한 것이다.

문제언제 터지는가
when 분기가 결제사 수만큼 늘어나 OCP를 위반한다결제사가 하나 추가될 때마다 OrderService를 다시 열어 분기를 끼워 넣어야 한다
OrderService가 구체 클래스를 직접 생성해 테스트에서 다른 구현체로 바꿀 수 없다결제 API를 호출하지 않는 가짜 구현체로 바꿔 라우팅 로직만 검증하려 해도 손댈 자리가 없다
같은 when 분기가 필요한 다른 호출부는 그 분기를 그대로 복사해야 한다관리자 화면이나 배치처럼 결제 라우팅이 또 필요한 곳이 생기면, 결제사 하나를 추가할 때 그중 한 곳을 빠뜨리기 쉽다

아래 to-be는 이 문제를 PaymentServiceFactory로 옮겨 해결한다. 팩토리가 구현체 선택에 대한 책임을 갖고, 호출부는 팩토리 클래스만 호출한다.

// to-be: 팩토리가 구현체 선택을 떠안고, 호출부는 인터페이스만 쓴다
@Component
class PaymentServiceFactory(
    private val paymentServices: List<PaymentService>,
) {
    private val paymentMap: Map<PaymentType, PaymentService> =
        paymentServices.associateBy { it.type }
 
    fun route(type: PaymentType): PaymentService =
        paymentMap[type] ?: throw IllegalArgumentException("not supported: $type")
}
 
class OrderService(
    private val paymentServiceFactory: PaymentServiceFactory,
) {
    fun pay(orderId: Long, type: PaymentType) {
        paymentServiceFactory.route(type).pay(orderId)
    }
}
  1. 스프링이 기동 시 PaymentService 빈 세 개(TossPayment, DanalPayment, NaverPayment)를 만든다.
  2. PaymentServiceFactory 생성자가 그 목록을 List로 받아 type을 키로 Map을 만든다.
  3. route(TOSS)를 부르면 Map에서 TossPayment 인스턴스 하나를 꺼내 돌려준다.
  4. 새 결제 수단을 추가하려면 PaymentService 구현체 하나를 빈으로 등록하기만 하면 되고, PaymentServiceFactory와 OrderService는 그대로다.

팩토리 메서드 장단점

장점단점
새 결제 수단을 추가해도 팩토리와 호출부 코드가 바뀌지 않는다구현체마다 클래스가 하나씩 늘어난다
호출부가 구체 타입을 몰라도 되어 결합도가 낮다제품이 하나뿐인 단순한 경우엔 인터페이스와 팩토리가 과한 추상화가 된다
테스트에서 가짜 구현체로 쉽게 교체할 수 있다구현체 개수가 많아지면 어떤 타입이 등록됐는지 Map 선언부를 직접 열어봐야 안다

표의 테스트 이점은 실제로 이렇게 쓰인다. 가짜 구현체 하나만 리스트에 넣으면 실제 PG 호출 없이 라우팅 로직만 검증할 수 있다.

// 테스트에서는 실제 PG 호출 없이 가짜 구현체만 리스트에 넣는다
class FakePaymentService : PaymentService {
    override val type = PaymentType.TOSS
    var paidOrderId: Long? = null
 
    override fun pay(orderId: Long) {
        paidOrderId = orderId
    }
}
 
class PaymentServiceFactoryTest {
    @Test
    fun `route는 타입에 맞는 구현체를 반환한다`() {
        val fake = FakePaymentService()
        val factory = PaymentServiceFactory(listOf(fake))
 
        factory.route(PaymentType.TOSS).pay(1001L)
 
        assertEquals(1001L, fake.paidOrderId)
    }
}

팩토리 메서드가 다루는 변경은 언제나 제품 하나를 추가하는 변경이다. 제품이 아니라 제품군을 추가해야 할 때 팩토리 메서드는 한계가 나타난다.

추상 팩토리 패턴, 제품군을 통째로 묶어 만들기

추상 팩토리는 관련된 객체 여러 개를 한 번에 만드는 패턴이다. 팩토리 메서드가 객체 하나를 고른다면, 추상 팩토리는 함께 쓰여야 하는 객체들을 묶어서 일관되게 내놓는다.

카페에서 커피 한 잔만 고르는 것과 세트 메뉴를 고르는 것은 다르다. 세트 메뉴는 커피와 디저트가 항상 짝으로 나오고, 커피만 바꿔도 디저트는 그대로 따라온다. 리뷰 생성과 삭제도 마찬가지다. 배민 플랫폼에서 만든 리뷰는 배민 방식으로 지워져야 하고, 이 페어가 어긋나면 안 된다.

생성과 삭제를 각각 팩토리 메서드로 따로 고르면 페어가 깨질 수 있다. 리뷰를 만들 때는 배민 팩토리를, 지울 때는 실수로 쿠팡 팩토리를 부르면 컴파일러가 막지 못한다. 우리가 이 실수를 코드 리뷰나 런타임 오류로만 잡을 수 있다면, 그 시점은 이미 늦게 된다.

아래 그림이 그 구조다. ReviewService가 creators와 destroyers 두 리스트를 따로 들고, 각각 인덱스로 짝을 짐작한다.

패턴을 적용하면 이 짝이 하나의 팩토리 안에 갇힌다. AbstractReviewFactory 는 createReview 와 deleteReview 를 한 쌍으로 선언하고, BaeminReviewFactory 와 CoupangReviewFactory 는 각자의 짝만 돌려준다.

AbstractReviewFactory와 배민, 쿠팡 구현

추상 팩토리의 재료는 제품 인터페이스 여러 개와, 그 제품들을 함께 선언하는 팩토리 인터페이스다. 아래 코드는 그 세 인터페이스를 먼저 정의한다.

enum class Platform { BAEMIN, COUPANG }
 
interface ReviewCreator {
    fun create(content: String): Long
}
 
interface ReviewDestroyer {
    fun destroy(reviewId: Long)
}
 
interface AbstractReviewFactory {
    val platform: Platform
    fun createReview(): ReviewCreator
    fun deleteReview(): ReviewDestroyer
}

이제 배민과 쿠팡 두 플랫폼의 구현체를 채운다. 실제 서비스라면 이 자리에 플랫폼별 저장, 삭제 로직이 들어가지만, 여기서는 어떤 플랫폼이 응답했는지만 확인할 수 있게 최소한으로 둔다.

class BaeminReviewCreator : ReviewCreator {
    override fun create(content: String): Long {
        println("배민 리뷰 저장: $content")
        return System.currentTimeMillis()
    }
}
 
class BaeminReviewDestroyer : ReviewDestroyer {
    override fun destroy(reviewId: Long) {
        println("배민 리뷰 삭제: $reviewId")
    }
}
 
@Component
class BaeminReviewFactory : AbstractReviewFactory {
    override val platform = Platform.BAEMIN
    override fun createReview(): ReviewCreator = BaeminReviewCreator()
    override fun deleteReview(): ReviewDestroyer = BaeminReviewDestroyer()
}
 
class CoupangReviewCreator : ReviewCreator {
    override fun create(content: String): Long {
        println("쿠팡 리뷰 저장: $content")
        return System.currentTimeMillis()
    }
}
 
class CoupangReviewDestroyer : ReviewDestroyer {
    override fun destroy(reviewId: Long) {
        println("쿠팡 리뷰 삭제: $reviewId")
    }
}
 
@Component
class CoupangReviewFactory : AbstractReviewFactory {
    override val platform = Platform.COUPANG
    override fun createReview(): ReviewCreator = CoupangReviewCreator()
    override fun deleteReview(): ReviewDestroyer = CoupangReviewDestroyer()
}

문제는 이 구현체들을 어떻게 짝지어 돌려주느냐다. 아래 as-is는 추상 팩토리를 적용하기 전, 생성과 삭제를 각각 다른 리스트에서 인덱스로 골라 쓰는 코드다.

// as-is: 생성과 삭제를 각각 골라 짝이 어긋날 여지가 남는다
class ReviewService(
    private val creators: List<ReviewCreator>,
    private val destroyers: List<ReviewDestroyer>,
) {
    fun create(platform: Platform, content: String): Long {
        val creator = when (platform) {
            Platform.BAEMIN -> creators[0]    // 배민이 리스트의 몇 번째인지 순서로 짐작해야 한다
            Platform.COUPANG -> creators[1]
        }
        return creator.create(content)
    }
 
    fun delete(platform: Platform, reviewId: Long) {
        val destroyer = when (platform) {
            Platform.BAEMIN -> destroyers[1]  // 실수로 순서를 바꿔 쓰면 짝이 깨진다
            Platform.COUPANG -> destroyers[0]
        }
        destroyer.destroy(reviewId)
    }
}

이 코드도 지금 당장은 동작하나 문제를 세 가지 안고 있다. 아래 표는 그 문제가 무엇이고 언제 터지는지를 정리한 것이다.

문제언제 터지는가
creators와 destroyers의 순서를 인덱스로 짐작해야 한다리스트 등록 순서가 바뀌면 짝이 어긋나도 컴파일러가 잡지 못하고 그대로 배포된다
플랫폼이 하나 늘면 create의 when과 delete의 when을 모두 고쳐야 한다요기요를 추가할 때 둘 중 하나만 고치고 배포하면 그 플랫폼만 생성이나 삭제가 실패한다
짝이 어긋나도 예외 없이 다른 플랫폼의 구현체가 그대로 실행된다배민 글로 만든 리뷰를 쿠팡 방식으로 지워도 에러 없이 넘어가 데이터가 어긋난 채로 남는다

아래 to-be는 이 문제를 AbstractReviewFactory로 옮겨 해결한다. 팩토리 하나가 짝을 통째로 반환해 어긋날 방법을 없앤다.

// to-be: 팩토리 하나가 짝을 통째로 반환해 어긋날 방법이 없다
@Component
class ReviewFactoryRouter(
    factories: List<AbstractReviewFactory>,
) {
    private val factoryMap: Map<Platform, AbstractReviewFactory> =
        factories.associateBy { it.platform }
 
    fun of(platform: Platform): AbstractReviewFactory =
        factoryMap[platform] ?: throw IllegalArgumentException("not supported: $platform")
}
 
class ReviewService(
    private val reviewFactoryRouter: ReviewFactoryRouter,
) {
    fun create(platform: Platform, content: String): Long =
        reviewFactoryRouter.of(platform).createReview().create(content)
 
    fun delete(platform: Platform, reviewId: Long) {
        reviewFactoryRouter.of(platform).deleteReview().destroy(reviewId)
    }
}
  1. 스프링이 BaeminReviewFactory와 CoupangReviewFactory 빈 두 개를 만든다.
  2. ReviewFactoryRouter 생성자가 그 목록을 List로 받아 platform을 키로 Map을 만든다.
  3. of(BAEMIN)을 부르면 BaeminReviewFactory 인스턴스 하나를 돌려준다.
  4. 그 팩토리의 createReview()와 deleteReview()는 항상 같은 배민 구현으로만 짝지어진다.

짝이 어긋나는 경로를 세어보면 차이가 드러난다. as-is에서는 creator 선택과 destroyer 선택이 코드 두 곳으로 나뉘어, 그 사이에서 실수가 날 자리가 둘이다. to-be에서는 of(platform) 한 번의 호출로 짝이 함께 나오므로, 실수가 날 자리가 없다.

추상 팩토리 장단점

추상 팩토리가 주는 이득과 치르는 비용도 표로 정리한다. 이득은 제품군이 일관성있게 생성되지만, 제품군 종류가 늘 때 구현해야할 코드량이 많아진다. 또한 제품 종류가 늘면(ReviewUpdater) 다른 제품군에서도 구현이 되어야 한다.

장점단점
생성과 삭제처럼 함께 쓰여야 하는 제품의 짝을 보장한다제품 종류가 하나 늘면 AbstractReviewFactory 에 메서드를 추가해야 하고, 그러면 배민과 쿠팡 팩토리를 전부 고쳐야 한다
플랫폼을 바꾸는 판단이 팩토리 하나를 고르는 문제로 좁혀진다제품군 구성이 자주 바뀌는 도메인에서는 매번 모든 구현 팩토리를 건드리게 된다
팩토리 내부에서만 구현이 결정되어 클라이언트는 조합을 신경 쓰지 않는다팩토리, 제품 인터페이스, 구현 클래스까지 계층이 늘어 코드량이 커진다

팩토리 메서드와 추상 팩토리는 언제 갈리는가

두 패턴 모두 객체를 직접 만들지 않는다는 점에서 출발한다. 그러나 실무에서 어느 쪽을 고를지는 결국 하나의 질문으로 좁혀진다. 지금 늘어나는 것이 제품 하나인가, 아니면 제품군인가? 결제 수단처럼 제품이 하나씩 독립적으로 늘어난다면 팩토리 메서드로 충분하고, 리뷰처럼 제품 여러 개가 항상 짝으로 움직여야 한다면 추상 팩토리가 필요하다.

상황선택근거
결제 수단처럼 구현체가 하나씩 늘어나고 서로 독립적이다팩토리 메서드제품 하나만 새로 등록하면 끝나고, 다른 구현체에 영향이 없다
리뷰 생성과 삭제처럼 여러 제품이 항상 짝을 이뤄야 한다추상 팩토리짝을 하나의 팩토리 안에 가둬 어긋날 방법을 없앤다
제품 종류 자체가 자주 늘어난다(생성 외에 수정, 신고 기능이 계속 추가된다)팩토리 메서드를 여러 개 두거나 재설계추상 팩토리는 메서드가 하나 늘 때마다 모든 구현 팩토리를 고쳐야 해, 변경 비용이 더 크다