🏗️ 아키텍처

Anti-Corruption Layer

부패 방지 계층 / ACL

레거시 시스템이나 외부 시스템과 통합할 때 자신의 도메인 모델이 "오염(Corruption)"되지 않도록 보호하는 번역 계층입니다. Domain-Driven Design(DDD)의 핵심 전략 패턴으로, 서로 다른 Bounded Context 간의 개념적 경계를 명확히 유지하면서 통합을 가능하게 합니다. Facade, Adapter, Translator 패턴의 조합으로 구현됩니다.

📖 상세 설명

Anti-Corruption Layer(ACL)는 Eric Evans의 "Domain-Driven Design" (2003)에서 소개된 전략 패턴입니다. "Corruption(부패)"이란 외부 시스템의 개념, 용어, 데이터 구조가 자신의 도메인 모델에 침투하여 도메인의 순수성과 의미를 훼손하는 것을 의미합니다. ACL은 이러한 오염을 방지하는 방화벽 역할을 합니다.

레거시 시스템과의 통합은 불가피하지만, 레거시의 구조적 문제나 잘못된 추상화를 그대로 새 시스템에 가져오면 기술 부채가 전파됩니다. ACL은 레거시 시스템의 "나쁜" 모델을 받아들이되, 이를 내부적으로 의미 있는 도메인 객체로 변환합니다. 외부 API가 변경되어도 ACL만 수정하면 되므로 변경의 영향을 최소화합니다.

ACL의 핵심 구성요소는 세 가지입니다. Facade는 외부 시스템에 대한 단순화된 인터페이스를 제공합니다. Adapter는 외부 시스템의 프로토콜을 내부에서 사용하는 형식으로 변환합니다. Translator는 외부 시스템의 데이터를 도메인 객체로 매핑합니다. 이 세 요소가 협력하여 완전한 격리를 제공합니다.

실무에서 ACL은 마이크로서비스 간 통합, 레거시 모놀리스 분해, 외부 SaaS 연동, M&A로 인한 시스템 통합 등에서 필수적입니다. 특히 Strangler Fig 패턴으로 레거시를 점진적으로 대체할 때 ACL은 새 시스템과 레거시 시스템 사이의 공존을 가능하게 하는 핵심 패턴입니다.

ACL의 핵심 구성요소

Facade (파사드)

복잡한 외부 시스템을 단순한 인터페이스로 추상화. 내부 코드는 Facade만 알면 됨.

Adapter (어댑터)

프로토콜 변환 담당. REST↔gRPC, XML↔JSON 등 기술적 변환 처리.

Translator (번역기)

개념적 매핑 담당. 외부 모델의 엔티티/값 객체를 도메인 모델로 변환.

Domain Service

ACL을 사용하는 도메인 로직. 순수한 도메인 언어로만 작성됨.

💻 코드 예제

TypeScript - 레거시 결제 시스템 ACL

// ===== 레거시 시스템의 모델 (외부, 변경 불가) =====
// 레거시는 "트랜잭션"이라는 모호한 개념을 사용
interface LegacyTransaction {
  txn_id: string;
  txn_type: 'P' | 'R' | 'C';  // Payment, Refund, Chargeback
  amt: number;                 // 센트 단위
  ccy: string;                 // 통화 코드
  cust_no: string;
  status_cd: 'A' | 'P' | 'D' | 'F';  // Approved, Pending, Declined, Failed
  created_ts: string;          // 'YYYYMMDDHHMMSS' 형식
}

// 레거시 API 클라이언트
class LegacyPaymentAPI {
  async getTransaction(txnId: string): Promise {
    // 레거시 SOAP/XML API 호출
    const response = await fetch(`${LEGACY_URL}/txn/${txnId}`);
    return this.parseXmlResponse(response);
  }

  async processPayment(data: any): Promise {
    // 복잡한 레거시 API 호출
    return {} as LegacyTransaction;
  }
}

// ===== 우리 도메인 모델 (순수한 비즈니스 개념) =====
// 결제(Payment)라는 명확한 개념
interface Payment {
  id: PaymentId;
  type: PaymentType;
  amount: Money;
  customerId: CustomerId;
  status: PaymentStatus;
  createdAt: Date;
}

type PaymentType = 'PAYMENT' | 'REFUND' | 'CHARGEBACK';
type PaymentStatus = 'APPROVED' | 'PENDING' | 'DECLINED' | 'FAILED';

// 값 객체 (Value Object)
class Money {
  constructor(
    public readonly amount: number,  // 원 단위 (정수)
    public readonly currency: Currency
  ) {}

  static fromCents(cents: number, currencyCode: string): Money {
    const currency = Currency.fromCode(currencyCode);
    return new Money(cents / 100, currency);
  }

  add(other: Money): Money {
    if (!this.currency.equals(other.currency)) {
      throw new Error('Currency mismatch');
    }
    return new Money(this.amount + other.amount, this.currency);
  }
}

class PaymentId {
  constructor(public readonly value: string) {}
}

class CustomerId {
  constructor(public readonly value: string) {}
}

// ===== Anti-Corruption Layer =====

// 1. Translator: 개념적 매핑
class PaymentTranslator {
  // 레거시 → 도메인
  toDomain(legacy: LegacyTransaction): Payment {
    return {
      id: new PaymentId(legacy.txn_id),
      type: this.translateType(legacy.txn_type),
      amount: Money.fromCents(legacy.amt, legacy.ccy),
      customerId: new CustomerId(legacy.cust_no),
      status: this.translateStatus(legacy.status_cd),
      createdAt: this.parseTimestamp(legacy.created_ts),
    };
  }

  // 도메인 → 레거시 (필요시)
  toLegacy(payment: Payment): Partial {
    return {
      txn_type: this.reverseType(payment.type),
      amt: payment.amount.amount * 100,  // 센트로 변환
      ccy: payment.amount.currency.code,
      cust_no: payment.customerId.value,
    };
  }

  private translateType(code: string): PaymentType {
    const mapping: Record = {
      'P': 'PAYMENT',
      'R': 'REFUND',
      'C': 'CHARGEBACK',
    };
    return mapping[code] || 'PAYMENT';
  }

  private translateStatus(code: string): PaymentStatus {
    const mapping: Record = {
      'A': 'APPROVED',
      'P': 'PENDING',
      'D': 'DECLINED',
      'F': 'FAILED',
    };
    return mapping[code] || 'PENDING';
  }

  private reverseType(type: PaymentType): 'P' | 'R' | 'C' {
    const mapping: Record = {
      'PAYMENT': 'P',
      'REFUND': 'R',
      'CHARGEBACK': 'C',
    };
    return mapping[type];
  }

  private parseTimestamp(ts: string): Date {
    // 'YYYYMMDDHHMMSS' → Date
    const year = parseInt(ts.substring(0, 4));
    const month = parseInt(ts.substring(4, 6)) - 1;
    const day = parseInt(ts.substring(6, 8));
    const hour = parseInt(ts.substring(8, 10));
    const min = parseInt(ts.substring(10, 12));
    const sec = parseInt(ts.substring(12, 14));
    return new Date(year, month, day, hour, min, sec);
  }
}

// 2. Adapter: 프로토콜 변환 + 에러 핸들링
class LegacyPaymentAdapter {
  constructor(
    private legacyApi: LegacyPaymentAPI,
    private translator: PaymentTranslator
  ) {}

  async getPayment(id: PaymentId): Promise {
    try {
      const legacyTxn = await this.legacyApi.getTransaction(id.value);
      return this.translator.toDomain(legacyTxn);
    } catch (error) {
      // 레거시 에러를 도메인 예외로 변환
      if (error.code === 'TXN_NOT_FOUND') {
        return null;
      }
      throw new PaymentSystemError('결제 조회 실패', error);
    }
  }

  async processPayment(payment: CreatePaymentCommand): Promise {
    const legacyData = this.translator.toLegacy(payment);
    try {
      const result = await this.legacyApi.processPayment(legacyData);
      return this.translator.toDomain(result);
    } catch (error) {
      throw new PaymentProcessingError('결제 처리 실패', error);
    }
  }
}

// 3. Facade: 단순화된 인터페이스
// 도메인 서비스가 사용하는 인터페이스 (Port)
interface PaymentGateway {
  getPayment(id: PaymentId): Promise;
  processPayment(command: CreatePaymentCommand): Promise;
  refundPayment(paymentId: PaymentId, amount: Money): Promise;
}

// ACL Facade 구현
class LegacyPaymentGateway implements PaymentGateway {
  constructor(private adapter: LegacyPaymentAdapter) {}

  async getPayment(id: PaymentId): Promise {
    return this.adapter.getPayment(id);
  }

  async processPayment(command: CreatePaymentCommand): Promise {
    return this.adapter.processPayment(command);
  }

  async refundPayment(paymentId: PaymentId, amount: Money): Promise {
    // 레거시 환불 로직을 도메인 메서드로 추상화
    const refundCommand = { originalPaymentId: paymentId, amount };
    return this.adapter.processRefund(refundCommand);
  }
}

// ===== 도메인 서비스 (ACL 사용) =====
// 도메인 서비스는 레거시의 존재를 전혀 모름
class PaymentService {
  constructor(private paymentGateway: PaymentGateway) {}

  async getPaymentDetails(paymentId: string): Promise {
    const id = new PaymentId(paymentId);
    const payment = await this.paymentGateway.getPayment(id);

    if (!payment) {
      throw new PaymentNotFoundError(paymentId);
    }

    // 순수한 도메인 모델로 작업
    return this.toDto(payment);
  }

  async processNewPayment(request: PaymentRequest): Promise {
    const command = this.toCommand(request);
    const payment = await this.paymentGateway.processPayment(command);
    return this.toDto(payment);
  }
}

Java - 외부 API 통합 ACL

// ===== 외부 배송 API 모델 (3rd party) =====
@JsonIgnoreProperties(ignoreUnknown = true)
public class ExternalShipmentResponse {
    private String shipment_id;
    private String carrier_code;
    private String tracking_num;
    private String ship_status;  // "IN_TRANSIT", "DELIVERED", "EXCEPTION"
    private List events;
}

// ===== 우리 도메인 모델 =====
public record Shipment(
    ShipmentId id,
    Carrier carrier,
    TrackingNumber trackingNumber,
    ShipmentStatus status,
    List events,
    LocalDateTime estimatedDelivery
) {}

public enum ShipmentStatus {
    PREPARING, SHIPPED, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERED, EXCEPTION
}

public record TrackingEvent(
    LocalDateTime occurredAt,
    String location,
    EventType type,
    String description
) {}

// ===== Anti-Corruption Layer =====

// Translator
@Component
public class ShipmentTranslator {

    public Shipment toDomain(ExternalShipmentResponse external) {
        return new Shipment(
            new ShipmentId(external.getShipmentId()),
            Carrier.fromCode(external.getCarrierCode()),
            new TrackingNumber(external.getTrackingNum()),
            translateStatus(external.getShipStatus()),
            translateEvents(external.getEvents()),
            calculateEstimatedDelivery(external)
        );
    }

    private ShipmentStatus translateStatus(String externalStatus) {
        return switch (externalStatus) {
            case "IN_TRANSIT" -> ShipmentStatus.IN_TRANSIT;
            case "DELIVERED" -> ShipmentStatus.DELIVERED;
            case "EXCEPTION" -> ShipmentStatus.EXCEPTION;
            case "OUT_FOR_DELIVERY" -> ShipmentStatus.OUT_FOR_DELIVERY;
            default -> ShipmentStatus.SHIPPED;
        };
    }

    private List translateEvents(List events) {
        return events.stream()
            .map(this::translateEvent)
            .sorted(Comparator.comparing(TrackingEvent::occurredAt))
            .toList();
    }
}

// Adapter
@Component
public class ExternalShippingAdapter {
    private final ExternalShippingClient client;
    private final ShipmentTranslator translator;
    private final CircuitBreaker circuitBreaker;

    public ExternalShippingAdapter(
        ExternalShippingClient client,
        ShipmentTranslator translator,
        CircuitBreakerRegistry registry
    ) {
        this.client = client;
        this.translator = translator;
        this.circuitBreaker = registry.circuitBreaker("shipping");
    }

    public Optional getShipment(ShipmentId id) {
        return circuitBreaker.executeSupplier(() -> {
            try {
                ExternalShipmentResponse response = client.getShipment(id.value());
                return Optional.of(translator.toDomain(response));
            } catch (ShipmentNotFoundException e) {
                return Optional.empty();
            } catch (ExternalApiException e) {
                throw new ShippingServiceException("배송 조회 실패", e);
            }
        });
    }
}

// Facade (Port 구현)
@Component
public class ExternalShippingGateway implements ShippingGateway {
    private final ExternalShippingAdapter adapter;

    @Override
    public Optional findById(ShipmentId id) {
        return adapter.getShipment(id);
    }

    @Override
    public Shipment createShipment(CreateShipmentCommand command) {
        return adapter.createShipment(command);
    }
}

// 도메인 서비스 - 외부 시스템의 존재를 모름
@Service
public class OrderFulfillmentService {
    private final ShippingGateway shippingGateway;  // Port
    private final OrderRepository orderRepository;

    public ShipmentStatusDto getOrderShipmentStatus(OrderId orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        Shipment shipment = shippingGateway.findById(order.getShipmentId())
            .orElseThrow(() -> new ShipmentNotFoundException(order.getShipmentId()));

        // 순수한 도메인 로직
        return ShipmentStatusDto.from(shipment);
    }
}

아키텍처 다이어그램

┌─────────────────────────────────────────────────────────────┐
│                    Our Bounded Context                       │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │                   Domain Layer                           │ │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────┐  │ │
│  │  │ Domain      │  │ Domain      │  │ Domain          │  │ │
│  │  │ Model       │  │ Service     │  │ Events          │  │ │
│  │  │ (Payment,   │  │ (uses Port) │  │ (PaymentMade)   │  │ │
│  │  │  Money...)  │  │             │  │                 │  │ │
│  │  └─────────────┘  └──────┬──────┘  └─────────────────┘  │ │
│  │                          │ uses                          │ │
│  │                          ▼                               │ │
│  │  ┌─────────────────────────────────────────────────────┐│ │
│  │  │                   Port (Interface)                   ││ │
│  │  │              PaymentGateway                          ││ │
│  │  │  - getPayment(id): Payment                          ││ │
│  │  │  - processPayment(cmd): Payment                     ││ │
│  │  └─────────────────────────────────────────────────────┘│ │
│  └─────────────────────────────────────────────────────────┘ │
│                              │                               │
│  ════════════════════════════╪═══════════════════════════════│
│                              │ implements                    │
│  ┌───────────────────────────▼─────────────────────────────┐ │
│  │           Anti-Corruption Layer (ACL)                    │ │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────────────────┐   │ │
│  │  │ Facade   │→ │ Adapter  │→ │    Translator        │   │ │
│  │  │ (Gateway │  │ (Protocol│  │ (LegacyTxn→Payment)  │   │ │
│  │  │  impl)   │  │  convert)│  │                      │   │ │
│  │  └──────────┘  └──────────┘  └──────────────────────┘   │ │
│  └─────────────────────────────────────────────────────────┘ │
└───────────────────────────────│──────────────────────────────┘
                                │ calls
                                ▼
┌─────────────────────────────────────────────────────────────┐
│               External / Legacy System                       │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │  Different model, different language                     │ │
│  │  (LegacyTransaction, txn_type: 'P'|'R'|'C', ...)       │ │
│  └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

🗣️ 실무 대화 예시

💬 설계 리뷰에서

"레거시 ERP와 통합해야 하는데, ERP의 '거래(Transaction)' 개념이 우리 도메인의 '주문'과 완전히 다릅니다. ACL을 도입해서 ERP의 모델이 우리 주문 도메인을 오염시키지 않도록 번역 계층을 만들겠습니다."

💬 기술 면접에서

"Anti-Corruption Layer는 DDD의 Context Mapping 전략 중 하나입니다. 외부 시스템의 개념이 내 도메인을 침투하는 것을 막는 방화벽 역할을 해요. Facade로 단순화하고, Adapter로 프로토콜을 변환하고, Translator로 개념을 매핑합니다."

💬 레거시 마이그레이션 회의에서

"Strangler Fig 패턴으로 점진적 마이그레이션을 하려면 ACL이 필수입니다. 새 마이크로서비스는 ACL을 통해 레거시와 통신하면서, 레거시의 나쁜 모델에 영향받지 않고 깨끗한 도메인 모델을 유지할 수 있어요."

⚠️ 주의사항

과도한 추상화: 간단한 통합에 ACL을 적용하면 불필요한 복잡성이 추가됩니다. 외부 시스템의 모델이 자신의 도메인과 유사하다면 ACL 없이 직접 통합해도 됩니다.

동기화 문제 무시: ACL은 모델 변환만 담당하고, 데이터 일관성은 별도로 고려해야 합니다. 양방향 동기화가 필요하면 이벤트 기반 아키텍처를 함께 적용하세요.

성능 간과: 모든 외부 호출이 Translator를 거치면 객체 생성 오버헤드가 발생합니다. 대량 데이터 처리 시 배치 변환이나 지연 로딩을 고려하세요.

권장 사항: ACL 경계에서 모든 외부 호출을 로깅하고, 변환 실패를 명시적으로 처리하세요. 외부 시스템 변경 시 ACL 테스트만으로 영향 범위를 확인할 수 있어야 합니다.

🔗 관련 용어

📚 더 배우기