왜 Interface + Factory? 모든 것을 교체 가능하게 만드는 Java 패턴

발행: (2026년 2월 8일 오전 06:39 GMT+9)
11 분 소요
원문: Dev.to

Source: Dev.to

Sergiy Yevtushenko

Source:

패턴

모든 구성 요소 — 유스케이스, 처리 단계, 어댑터 — 은 정적 팩터리 메서드를 가진 인터페이스로 정의됩니다:

public interface ProcessOrder {
    record Request(String orderId, String paymentToken) {}
    record Response(OrderConfirmation confirmation) {}

    Result execute(Request request);

    interface ValidateInput {
        Result apply(Request raw);
    }
    interface ReserveInventory {
        Result apply(ValidRequest req);
    }
    interface ProcessPayment {
        Result apply(Reservation reservation);
    }
    interface ConfirmOrder {
        Result apply(Payment payment);
    }

    static ProcessOrder processOrder(
            ValidateInput validate,
            ReserveInventory reserve,
            ProcessPayment processPayment,
            ConfirmOrder confirm) {
        return request -> validate.apply(request)
                                  .flatMap(reserve::apply)
                                  .flatMap(processPayment::apply)
                                  .flatMap(confirm::apply);
    }
}

네 단계. 각각은 단일 메서드 인터페이스입니다. 팩터리 메서드는 모든 의존성을 매개변수로 받아 유스케이스를 구현하는 람다를 반환합니다. 본문은 비즈니스 프로세스와 정확히 동일하게 읽힙니다: 검증 → 재고 예약 → 결제 처리 → 주문 확인.

이는 임의의 관례가 아닙니다. 이 구조가 존재하는 데는 세 가지 구체적인 이유가 있습니다.

Reason 1: Substitutability Without Magic

Anyone can implement the interface—no framework, no inheritance hierarchy, no annotations.

Testing becomes trivial

@Test
void order_fails_when_inventory_insufficient() {
    var useCase = ProcessOrder.processOrder(
        request -> Result.success(new ValidRequest(request)), // always valid
        req -> INSUFFICIENT_INVENTORY.result(),             // always fails
        reservation -> { throw new AssertionError("unreachable"); },
        payment -> { throw new AssertionError("unreachable"); }
    );

    useCase.execute(new Request("order-1", "tok_123"))
           .onSuccess(Assertions::fail);
}

모킹 프레임워크도 없고, @Mock 어노테이션도 없으며, when().thenReturn() 체인도 없습니다. 테스트는 순수 람다만으로 필요한 정확한 시나리오를 구성합니다.

Stubbing incomplete implementations during development

// Payment gateway isn’t ready yet? Stub it.
var useCase = ProcessOrder.processOrder(
    realValidator,
    realInventoryService,
    reservation -> Result.success(new Payment("stub-" + reservation.id(), Money.ZERO)),
    realConfirmation
);

재고를 담당하는 팀은 결제 팀을 기다릴 필요가 없습니다. 각 단계는 독립적으로 구현될 수 있습니다.

이유 2: 구현 격리

각 구현은 독립적입니다. 공유되는 기본 클래스가 없고, 오버라이드할 추상 메서드도 없으며, 구현 간에 전혀 결합되지 않습니다.

전형적인 추상‑클래스 접근 방식과의 대비

// The abstract‑class trap
public abstract class AbstractOrderProcessor {
    protected final Logger log = LoggerFactory.getLogger(getClass());

    public final Result execute(Request request) {
        log.info("Processing order: {}", request.orderId());
        var result = doExecute(request);
        log.info("Order result: {}", result);
        return result;
    }

    protected abstract Result doExecute(Request request);
    protected abstract Result validate(Request request);

    // “Shared utility” that every subclass now depends on
    protected Result calculateTotal(List items) {
        // 47 lines of logic that one subclass needed once
    }
}

모든 구현은 기본 클래스에 결합됩니다. calculateTotal을 변경하면 모든 서브클래스를 이해해야 합니다. execute에 로깅을 추가하면 적절한 경우든 아니든 모든 구현에 로깅이 삽입됩니다. 기본 클래스는 중력우물처럼 작동하여, 공유 코드를 축적하고 서로 전혀 공통점이 없어야 할 구현들 사이에 보이지 않는 의존성을 만들게 됩니다.

인터페이스 + 팩토리를 사용할 때

공유되는 구현 코드는 없습니다. 여기까지입니다. 구현들 사이의 모든 교차점은 불필요한 결합이며, 그에 따른 유지보수 비용이 발생합니다—하나의 프로젝트를 이해하는 대신 두 프로젝트를 깊이 이해해야 할 수도 있으며, 이득은 전혀 없습니다.

// Implementation A: uses database
static ProcessPayment databasePayment(PaymentRepository repo) {
    return reservation -> repo.charge(reservation.paymentToken(),
                                      reservation.total())
                        .map(Payment::fromRecord);
}

// Implementation B: uses external API
static ProcessPayment stripePayment(StripeClient client) {
    return reservation -> client.createCharge(reservation.total(),
                                               reservation.paymentToken())
                        .map(Payment::fromStripe);
}

이 구현들은 서로에 대해 알지 못합니다. 코드나 기본 클래스를 공유하지 않으며, 오직 계약—인터페이스—만을 공유하고 그 외에는 아무것도 공유하지 않습니다.

Source:

Reason 3: Disposable Implementation

미묘한 점: 팩토리 메서드는 람다(또는 로컬 레코드)를 반환합니다. 클래스 이름으로 외부에서 참조할 수 없기 때문에 상속보다 합성을 권장하며, 공개 API에 영향을 주지 않고 구현을 쉽게 폐기하거나 교체할 수 있습니다.

// Example: a one‑off implementation used only in a specific test
var testUseCase = ProcessOrder.processOrder(
    validator,
    inventory,
    reservation -> Result.success(new Payment("test-" + reservation.id(),
                                               Money.of(0))),
    confirmer
);

구현이 람다 형태로만 존재하기 때문에 우연한 재사용이나 숨겨진 결합이 발생하지 않습니다. 필요가 바뀌면 단순히 다른 람다나 팩토리 메서드를 제공하면 됩니다.

TL;DR

  • Interface + factory가 제공하는 이점:
    1. 순수한 대체 가능성 – 프레임워크 없이도 어떤 구현이든 교체할 수 있습니다.
    2. 격리성 – 구현들이 숨겨진 베이스 클래스 의존성을 공유하지 않습니다.
    3. 일회성, 합성 가능한 구현 – 스텁, 테스트, 교체가 용이합니다.

이 패턴을 채택하여 Java 코드베이스를 모듈화하고, 테스트하기 쉬우며, 전통적인 상속 중심 설계에서 흔히 발생하는 “중력 우물” 문제로부터 자유롭게 유지하십시오.

ProcessOrder – 함수형 조합 예제

static ProcessOrder processOrder(ValidateInput validate,
                                 ReserveInventory reserve,
                                 ProcessPayment processPayment,
                                 ConfirmOrder confirm) {
    return request -> validate.apply(request)          // this lambda IS the implementation
                              .flatMap(reserve::apply)
                              .flatMap(processPayment::apply)
                              .flatMap(confirm::apply);
}

직접 인스턴스화 금지

There is no code that says new ProcessOrderImpl().
No other code depends on the concrete implementation class.
Because nothing can reference it, the implementation is completely replaceable.

  • The interface is the design artifact.
  • The implementation is incidental.

왜 이것이 중요한가

이것은 학문적인 것이라고 생각할 수 있지만, 다음과 같은 상황이 생기면 필요합니다:

  • 동기 구현을 비동기 구현으로 교체하기.
  • 데이터베이스 어댑터를 API 어댑터로 교체하기.
  • 기존 단계에 캐싱 레이어 추가하기.
  • 단계 내부를 완전히 재작성하기.

각 경우마다:

  1. 인터페이스는 동일하게 유지됩니다.
  2. 팩토리 메서드 시그니처는 동일하게 유지됩니다.
  3. 구현(참조되는 것이 없는)은 교체됩니다.

결과: 하위 변경 없이, 어댑터 레이어 없이, “역호환성” 문제 없이.

복합 효과

각각의 이점은 그 자체만으로도 가치가 있지만, 함께 결합되면 다음과 같은 시스템을 만들 수 있습니다:

테스트는 설정이다

필요한 실제 컴포넌트와 스텁된 컴포넌트를 정확히 조합합니다.

  • 목킹 프레임워크 오버헤드가 없습니다.
  • “모두 목킹”으로 인한 테스트 깨짐이 없습니다.

리팩터링은 안전하다

구현을 교체해도 다른 구현을 깨뜨릴 수 없습니다. 왜냐하면 코드가 공유되지 않기 때문입니다.

  • 컴파일러가 인터페이스를 통해 계약을 강제합니다.

복잡성은 제한된다

하나의 구현을 이해하려면 해당 구현과 그것이 사용하는 인터페이스만 알면 됩니다.

  • 깊은 클래스 계층 구조가 없습니다.
  • 구현을 결합시키는 공유 유틸리티가 없습니다.

점진적 개발이 자연스럽다

  • 준비되지 않은 부분은 스텁으로 대체합니다.
  • 스텁을 실제 구현으로 하나씩 교체합니다.
  • 각 단계는 독립적으로 개발, 테스트, 배포될 수 있습니다.

언제 적용되지 않나요?

구현이 진정으로 하나뿐이며 앞으로도 그렇게 될 경우—예: 순수 유틸리티 함수, 수학 연산, 간단한 데이터 변환. 이런 경우 정적 메서드가 완벽히 적합합니다.

패턴은 다중 구현 가능성이 있는 경우마다 비용을 상쇄합니다. 여기에는 테스트 구현도 포함되며(거의 항상 존재합니다).

전환

대부분의 Java 코드베이스는 구체 클래스를 기본값으로 사용합니다. 인터페이스 추출은 나중에, 마지못해, 테스트가 강제하거나 두 번째 구현이 나타날 때 이루어집니다.

이 접근 방식을 뒤집으세요:

  1. 인터페이스부터 시작하세요. 먼저 계약을 정의합니다.
  2. 나중에 구현하세요. 구현은 자연스럽게 뒤따르며, 변경이 필요할 때 다른 것은 변하지 않습니다.

인터페이스는 당신이 설계하는 것입니다.
구현은 오늘 당신이 우연히 작성하는 것입니다.

Back to Blog

관련 글

더 보기 »

switch 문

개요 - switch case는 변수 또는 표현식의 값에 따라 서로 다른 코드 블록을 실행할 수 있게 해주는 제어문입니다. - 이것은 종종 cleane...

Java의 클래스::

클래스 정의: class는 object를 생성하기 위해 사용되는 blueprint 또는 template이다. 그것은 생성된 object가 가지는 property, variable, 그리고 behavior(method)를 정의한다.