좋은 도메인 모델을 만드는 일은 어렵다. 도메인 전문가와 대화하고, 유비쿼터스 언어를 다듬고, Bounded Context를 나누고, Aggregate 경계를 고민해야 한다. 그런데 어렵게 만든 모델도 쉽게 망가질 수 있다.
가장 흔한 이유 중 하나는 외부 모델의 침투다.
외부 API, 레거시 시스템, 다른 팀의 서비스, PG사, ERP, CRM 같은 시스템은 모두 자신만의 모델과 언어를 가진다. 문제는 그 언어가 우리 도메인의 언어와 다르다는 점이다. 외부 모델을 그대로 받아들이면 처음에는 편하지만, 시간이 지나면서 우리 모델은 외부 시스템의 용어와 구조에 맞춰 변형된다.
Anti-Corruption Layer는 이 문제를 막기 위한 방어선이다.
외부 모델은 중립적이지 않다
외부 시스템의 DTO는 단순한 데이터 묶음처럼 보인다. 하지만 그 안에는 외부 시스템의 관점이 들어 있다.
예를 들어 결제 PG사의 응답이 다음과 같다고 해보자.
{
"resultCode": "0000",
"transactionId": "tx_12345",
"approvalNo": "A-9999",
"cancelable": true,
"amount": 9900,
"paidAt": "2026-07-05T10:00:00"
}
결제 컨텍스트에서는 이 데이터가 자연스럽다. 승인 번호, 거래 식별자, 응답 코드, 취소 가능 여부는 중요한 개념이다. 하지만 구독 컨텍스트가 알고 싶은 것은 조금 다를 수 있다.
구독 갱신에 필요한 결제가 성공했는가?
결제 실패라면 유예 기간을 시작해야 하는가?
이미 만료된 구독을 다시 활성화할 수 있는가?
이 결제 결과로 이용 권한을 부여해도 되는가?
PG사의 resultCode와 구독 도메인의 SubscriptionRenewed는 같은 수준의 언어가 아니다. 전자는 외부 시스템의 기술적 결과이고, 후자는 우리 도메인에서 의미 있는 변화다.
외부 모델은 중립적이지 않다. 그 모델을 그대로 사용한다는 것은 외부 시스템의 관점을 우리 코드 안으로 받아들이는 것이다.
오염은 아주 작은 편의에서 시작된다
처음에는 단순한 편의처럼 보인다.
PgPaymentResponse response = pgClient.pay(request);
if (response.isSuccess()) {
subscription.setStatus(SubscriptionStatus.ACTIVE);
}
이 정도는 문제가 없어 보인다. 하지만 시간이 지나면서 조건이 늘어난다.
if (response.getResultCode().equals("0000")) {
subscription.renew();
} else if (response.getResultCode().equals("E101")) {
subscription.startGracePeriod();
} else if (response.getResultCode().equals("E302")) {
subscription.expire();
}
이제 구독 도메인의 정책이 PG사의 응답 코드에 직접 묶였다. 결제 시스템을 바꾸면 구독 정책 코드도 흔들린다. 더 큰 문제는 코드의 언어다. 구독 도메인 안에서 “갱신 실패”, “유예 기간 시작”, “만료” 같은 말보다 E101, E302 같은 외부 코드가 더 강한 영향력을 갖게 된다.
이것이 모델 오염이다.
오염은 한 번에 크게 오지 않는다. 작은 편의, 빠른 구현, 간단한 매핑 생략에서 시작된다. 그리고 시간이 지나면 외부 시스템의 언어가 우리 도메인 모델의 언어를 대체한다.
ACL은 단순 DTO 변환이 아니다
Anti-Corruption Layer를 단순히 DTO를 변환하는 계층으로 이해하면 부족하다. 물론 변환은 ACL의 중요한 역할이다. 하지만 핵심은 변환 자체가 아니라 언어의 보호다.
ACL은 외부 모델을 우리 도메인이 이해할 수 있는 모델로 번역한다.
PgPaymentResponse response = pgClient.pay(request);
PaymentResult result = paymentTranslator.translate(response);
subscription.handlePaymentResult(result, now);
여기서 PaymentResult는 PG사의 응답 모델이 아니라 우리 도메인이 이해할 수 있는 개념이어야 한다.
public sealed interface PaymentResult {
record Succeeded(Money amount, Instant paidAt) implements PaymentResult {}
record Failed(PaymentFailureReason reason) implements PaymentResult {}
}
이제 구독 도메인은 resultCode를 알 필요가 없다. approvalNo가 무엇인지도 몰라도 된다. 구독 도메인은 “결제가 성공했는가”, “실패했다면 어떤 도메인적 이유인가”를 알면 된다.
ACL은 외부 시스템의 언어를 내부 도메인의 언어로 번역하는 통역사다.
번역은 손실이 아니라 설계다
외부 응답에 있는 모든 필드를 내부 모델로 가져올 필요는 없다. 오히려 가져오지 않는 것이 중요할 때가 많다.
PG 응답에는 승인 번호, 가맹점 ID, 카드사 코드, 원천 응답 메시지, 취소 가능 여부, 할부 개월 수 등 많은 정보가 있을 수 있다. 하지만 구독 갱신 정책에 필요한 정보는 그중 일부일 수 있다.
결제가 성공했는가?
결제 금액은 기대 금액과 일치하는가?
실패가 일시적 실패인가, 영구적 실패인가?
결제가 실제로 완료된 시각은 언제인가?
번역 과정에서 외부 정보 일부가 사라지는 것은 손실처럼 보일 수 있다. 하지만 이것은 모델링이다. 우리 도메인에 의미 있는 정보만 남기는 과정이기 때문이다.
물론 원본 응답을 감사나 문제 해결을 위해 저장할 수는 있다. 그러나 그것이 도메인 모델의 중심 언어가 되어서는 안 된다.
ACL은 어디에 둘 것인가
ACL은 보통 외부 시스템과 도메인 모델 사이에 둔다. 구조는 프로젝트마다 다를 수 있지만, 일반적으로 다음 역할들이 필요하다.
외부 클라이언트
- 외부 API 호출
- 인증, HTTP, 재시도 같은 기술 세부사항 처리
Translator 또는 Mapper
- 외부 응답 모델을 내부 모델로 변환
- 외부 오류 코드를 도메인 의미로 번역
내부 모델
- 도메인이 이해할 수 있는 결과 또는 명령
예를 들어 다음과 같은 흐름을 만들 수 있다.
PgPaymentResponse response = pgPaymentClient.requestPayment(pgRequest);
PaymentResult paymentResult = paymentAcl.toPaymentResult(response);
subscription.applyPayment(paymentResult, now);
중요한 것은 도메인 계층이 PgPaymentResponse를 직접 알지 않는다는 점이다. 도메인 계층은 외부 시스템이 어떤 JSON을 주는지, 어떤 HTTP 상태 코드를 쓰는지, 어떤 문자열 코드로 실패를 표현하는지 몰라도 된다.
레거시 시스템과 ACL
ACL은 외부 API뿐 아니라 레거시 시스템과 연결할 때도 중요하다.
레거시 시스템에서는 하나의 테이블이나 코드 값이 여러 의미를 동시에 갖는 경우가 많다. 예를 들어 status = 9가 어떤 경우에는 해지를 의미하고, 어떤 경우에는 만료를 의미하고, 특정 플래그와 조합될 때만 환불 완료를 의미할 수 있다.
새로운 구독 도메인 모델을 만들면서 이 레거시 상태 값을 그대로 가져오면 새 모델도 금방 레거시의 모호함에 물든다.
if (legacyStatus == 9 && refundFlag == true) {
// ...
}
ACL은 이 모호한 조합을 새 도메인이 이해할 수 있는 개념으로 바꿔야 한다.
SubscriptionState state = legacySubscriptionTranslator.translate(legacyRecord);
번역은 단순하지 않을 수 있다. 때로는 레거시 모델의 의미를 해석하기 위해 운영자와 대화해야 한다. “이 플래그가 켜진 상태에서 상태 값이 9면 정확히 무슨 뜻인가요?” 같은 질문이 필요하다. 이 과정 역시 DDD의 일부다.
ACL도 과하면 비용이 된다
그렇다고 모든 외부 연동에 거대한 ACL을 만들어야 하는 것은 아니다. 단순한 이메일 발송 API처럼 도메인 모델에 큰 영향을 주지 않는 연동이라면 간단한 어댑터로 충분할 수 있다.
ACL은 특히 다음 상황에서 중요하다.
외부 모델이 Core Domain에 직접 영향을 준다.
외부 시스템의 언어와 내부 도메인의 언어가 다르다.
외부 시스템을 바꾸거나 교체할 가능성이 있다.
레거시 모델이 모호하거나 오염되어 있다.
외부 응답 코드가 내부 정책 판단에 사용되고 있다.
반대로 단순히 데이터를 전달만 하는 곳에 과한 ACL을 만들면 불필요한 복잡성이 생긴다. ACL도 목적이 있어야 한다. 목적은 항상 같다. 내 도메인 모델을 보호하는 것이다.
마치며
Anti-Corruption Layer는 단순한 매퍼가 아니다. 외부 모델과 내부 모델 사이에서 언어를 번역하고, 도메인 모델이 외부 시스템의 관점에 끌려가지 않게 막는 방어선이다.
좋은 ACL은 외부 시스템을 숨기는 데 그치지 않는다. 외부 시스템의 결과를 우리 도메인이 이해할 수 있는 말로 바꾼다. resultCode = E101을 그대로 넘기는 대신 “일시적 결제 실패로 인해 유예 기간을 시작해야 한다”는 도메인 의미로 번역한다.
DDD에서 중요한 것은 모델의 순수함을 신성하게 지키는 것이 아니다. 중요한 것은 Core Domain의 언어와 규칙이 외부 시스템의 우연한 구조에 의해 망가지지 않도록 하는 것이다.
외부 시스템은 계속 바뀐다. 레거시는 쉽게 사라지지 않는다. 다른 팀의 모델도 우리의 뜻대로 움직이지 않는다. 그래서 내 모델을 지키는 경계가 필요하다. ACL은 그 경계 위에 서 있는 통역사다.