이커머스 시스템에서 연동해야 하는 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를 추가할 때 KpnpPosHandler와 OrderPosKpnpRepository를 만들었다. 다른 파일은 건드리지 않았다. 배포 후 별다른 이슈가 없었다. 한 줄로 요약하면, 분기를 줄이는 게 아니라 분기가 생길 자리를 없애는 게 목표였다. 새 POS사 추가 시 어떤 파일을 만들어야 하는지 명확해지면, 빠뜨리는 실수도 없어진다