POS사가 8개로 늘어났을 때, Handler Registry 패턴으로 if-else를 없앤 이유

이커머스 시스템에서 연동해야 하는 POS사가 하이픈POS, 나이스POS, 이지POS, OKPOS, 메이트POS, 드림하이테크POS, fodiPOS까지 7개였다가 KpnpPOS가 추가되면서 8개로 늘었다. 그 시점에 코드에는 POS 타입마다 if-else 분기가 controller, service, repository 곳곳에 흩어져 있었다. POS사 하나를 추가하면 건드려야 할 파일이 최소 5개였고, 그 중 하나라도 빠지면 운영에서 터졌다. Handler Registry 패턴으로 다시 설계한 뒤로는 새 POS사를 추가할 때 Handler 하나와 Repository 하나만 만들면 됐다

if-else가 여러 계층에 퍼지면 벌어지는 일

처음에는 단순했다. 하이픈POS, 나이스POS, 이지POS 세 개였고 분기도 짧았다

// 초기 코드
if (posType == 1) {
    hyphenPosService.save(request);
} else if (posType == 2) {
    nicePosService.save(request);
} else if (posType == 3) {
    easyPosService.save(request);
}

POS사가 늘수록 이 분기가 controller에도 생기고, service에도 생기고, 조회 쿼리에도 생겼다. OKPOS를 추가할 때 service 파일을 고쳤는데 repository 분기를 빠뜨린 채 배포했다. OKPOS 조회 API가 IllegalArgumentException을 던지면서 알았다

설계 목표를 먼저 정했다

API는 하나로 유지하되, 내부 구현은 POS 타입별로 완전히 분리하는 것이 목표였다. 클라이언트 입장에서는 posType 필드만 바꾸면 동일한 엔드포인트를 쓸 수 있어야 했다

GET /api/admin/orders/{orderId}/pos/{posType}
PUT /api/admin/orders/{orderId}/pos

Handler 인터페이스로 POS 타입별 구현을 격리했다

public interface PosHandler<V extends OrderPosView<?>, R extends OrderPosRequest<?>> {
    int getSupportedPosType();
    V getOrderPosView(Long orderId);
    void saveOrderPos(R request);
}

각 POS사는 이 인터페이스를 구현한다. HyphenPosHandler는 하이픈POS만, OkPosHandler는 OKPOS만 담당한다. 새 POS사가 생기면 새 Handler 클래스를 만들면 되고, 기존 Handler는 건드리지 않는다

@Component
@RequiredArgsConstructor
public class HyphenPosHandler implements PosHandler<OrderPosHyphenView, OrderPosHyphenRequest> {

    @Override
    public int getSupportedPosType() {
        return 1; // HYPHEN
    }

    @Override
    public OrderPosHyphenView getOrderPosView(Long orderId) {
        OrderRecord orderRecord = orderPosHyphenRepository.findByIdHyphen(orderId);
        if (orderRecord == null) {
            throw new CustomException(OrderError.MISMATCH_POS_TYPE);
        }
        HyphenPosRecord hyphenPosRecord = orderPosHyphenRepository.findByOrderIdHyphenPos(orderId);
        return OrderPosHyphenView.of(orderRecord, hyphenPosRecord, getSupportedPosType());
    }

    @Override
    @Transactional
    public void saveOrderPos(OrderPosHyphenRequest request) {
        // Order 공통 정보 + HyphenPos 상세 정보를 하나의 트랜잭션으로 처리
    }
}

Registry가 Handler를 관리한다

애플리케이션 시작 시 Spring이 PosHandler 구현체 목록을 주입한다. Registry는 이를 posTypeCode → Handler 맵으로 구성한다

@Component
public class PosHandlerRegistry {

    private final Map<Integer, PosHandler<?, ?>> handlers;

    public PosHandlerRegistry(List<PosHandler<?, ?>> handlerList) {
        this.handlers = handlerList.stream()
                .collect(Collectors.toMap(PosHandler::getSupportedPosType, Function.identity()));
    }

    public <V extends OrderPosView<?>, R extends OrderPosRequest<?>>
    PosHandler<V, R> getHandler(int posType) {
        PosHandler<?, ?> handler = handlers.get(posType);
        if (handler == null) {
            throw new IllegalArgumentException("Unsupported POS type: " + posType);
        }
        return (PosHandler<V, R>) handler;
    }
}

새 Handler를 @Component로 등록하는 순간 Registry에 자동으로 포함된다. 등록을 잊어버릴 수 없는 구조다

한 가지 한계는 있다. getHandler()@SuppressWarnings("unchecked") 캐스팅이다. PosHandler<V, R>로 강제 캐스팅하기 때문에 컴파일러가 타입 안전성을 보장하지 못한다. 잘못된 타입의 Request가 들어오면 컴파일 타임이 아니라 런타임에 ClassCastException이 난다. Service 계층에서 posType을 검증한 뒤 Handler를 호출하는 흐름을 지키면 실제로 터지는 일은 없었지만, 설계상 열려 있는 구멍이다

Service는 분기 없이 위임만 한다

@Transactional(readOnly = true)
public OrderPosView<?> getOrderPos(Long orderId, PosType posType) {
    int posTypeCode = posType.getCode();
    OrderRecord orderRecord = orderQueryRepository.findByIdAndPosType(orderId, posTypeCode);
    if (orderRecord == null) {
        throw new CustomException(OrderError.MISMATCH_POS_TYPE);
    }
    PosHandler<?, ?> handler = posHandlerRegistry.getHandler(posTypeCode);
    return handler.getOrderPosView(orderId);
}

POS 타입이 늘어도 Service는 바뀌지 않는다. getHandler에서 찾지 못하면 예외가 난다

Jackson 다형성으로 Request 역직렬화를 자동화했다

Request Body에 posType 필드가 들어오면 Jackson이 자동으로 올바른 서브클래스로 역직렬화한다. Controller에 분기가 없다

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.EXISTING_PROPERTY,
    property = "posType",
    visible = true
)
@JsonSubTypes({
    @JsonSubTypes.Type(value = OrderPosHyphenRequest.class, name = "HYPHEN"),
    @JsonSubTypes.Type(value = OrderPosOkRequest.class, name = "OKPOS"),
    // ...
})
public abstract class OrderPosRequest<T> {
    private PosType posType;
    private Long orderId;
    private T posDetail;
}

include = JsonTypeInfo.As.EXISTING_PROPERTY를 쓰면 posType 필드가 타입 식별자로 사용되면서 응답 JSON에도 그대로 노출된다. visible = true를 빠뜨리면 역직렬화 후 posType이 null로 들어온다. 처음에 이걸 놓쳤다가 한 번 삽질했다

Swagger(OpenAPI)도 이 설정 덕분에 POS 타입별 스키마를 자동으로 분리해 보여준다. 프론트엔드 개발자가 “하이픈POS 타입에는 어떤 필드가 오나요?”를 물어볼 필요가 없어졌다

@SuperBuilder는 상속 구조에서 필요하다

부모 클래스 필드(posType, orderId)와 자식 클래스 필드(POS사별 전용 필드)를 하나의 빌더로 설정하려면 @Builder 대신 @SuperBuilder가 필요하다

// @Builder만 쓰면 부모 필드 설정 불가
OrderPosHyphenView view = OrderPosHyphenView.builder()
        .posType(1)          // 컴파일 에러
        .hyphenTerminalId("T001")
        .build();

// @SuperBuilder는 가능
OrderPosHyphenView view = OrderPosHyphenView.builder()
        .posType(1)                  // 부모 필드
        .orderId(1001L)              // 부모 필드
        .hyphenTerminalId("T001")    // 자식 필드
        .posDetail(detail)           // 부모 필드
        .build();

@ToString(callSuper = true)도 세트다. 없으면 로그에 orderId가 안 찍혀서 어떤 주문의 요청인지 알 수 없다

트랜잭션은 Handler 안에서 닫는다

Order 공통 정보와 POS 상세 정보는 하나의 트랜잭션에서 처리된다. Handler 메서드에 @Transactional을 붙이고, 하나라도 실패하면 전체가 롤백된다. insert/update 분기는 posDetail의 식별번호가 null인지로 결정한다. DTO를 따로 나누지 않고 null 여부로 구분하는 방식인데, insert 시 식별번호를 내려보내지 않아야 한다는 규칙을 API 문서에 명시했다

결과

KpnpPOS를 추가할 때 KpnpPosHandlerOrderPosKpnpRepository를 만들었다. 다른 파일은 건드리지 않았다. 배포 후 별다른 이슈가 없었다. 한 줄로 요약하면, 분기를 줄이는 게 아니라 분기가 생길 자리를 없애는 게 목표였다. 새 POS사 추가 시 어떤 파일을 만들어야 하는지 명확해지면, 빠뜨리는 실수도 없어진다