소프트웨어는 혼자 완성되지 않는다. 표준 라이브러리, 오픈소스, 사내 공통 모듈, 아직 만들어지지 않은 옆 팀의 API까지 — 우리 코드는 늘 외부 코드와 맞닿는다. 문제는 외부 코드가 우리의 의도와 같은 방향으로 설계되어 있지 않다는 점이다. 인터페이스를 제공하는 쪽은 많은 사용자를 위해 범용성을 추구하고, 쓰는 쪽은 자기 문제에 필요한 기능만 안정적으로 쓰고 싶어 한다. 이 긴장이 번지지 않게 가두는 것이 경계 설계다.

외부 API를 좁혀서 감싸기

범용 인터페이스는 우리가 원하지 않는 기능까지 열어 둔다. 대표적인 예가 Map이다. 어떤 Map을 여러 곳에 그대로 넘기면, 받은 쪽 누구나 clear()로 비우거나 엉뚱한 타입을 넣을 수 있고, 제네릭 타입 정보도 경계마다 반복된다.

// before — Map 을 그대로 노출한다. 누구나 clear() 할 수 있고 캐스팅이 흩어진다
Map<String, Sensor> sensors = new HashMap<>();
Sensor s = sensors.get(sensorId);
// after — 필요한 동작만 열고 Map 은 안으로 숨긴다
public class Sensors {
    private final Map<String, Sensor> sensors = new HashMap<>();
 
    public Sensor getById(String id) {
        return sensors.get(id);
    }
 
    public void add(String id, Sensor sensor) {
        sensors.put(id, sensor);
    }
    // clear(), 잘못된 put 등 원치 않는 동작은 애초에 노출되지 않는다
}

이제 Map의 인터페이스가 바뀌어도(예: 시그니처 변경) 고칠 곳은 Sensors 하나뿐이다. 판단 기준: Map 같은 경계 인터페이스는 공개 API로 여기저기 넘기지 말고, 클래스 안에 숨겨 우리가 실제로 쓰는 동작만 드러낸다. 함정: 모든 Map을 무조건 감싸라는 규칙은 아니다. 여러 곳으로 전달되며 시스템의 어휘가 되는 경계일수록 감싸는 값어치가 크다.

학습 테스트

서드파티 코드를 곧장 본 코드에 붙이기보다, 작은 테스트로 먼저 다뤄 본다. 이것을 학습 테스트라 부른다. 목적은 라이브러리를 검증하는 것이 아니라, 우리가 이해한 사용법이 실제로 맞는지 확인하는 데 있다.

public class LogTest {
    private Logger logger;
 
    @Before
    public void initialize() {
        logger = Logger.getLogger("logger");
        logger.removeAllAppenders();
        Logger.getRootLogger().removeAllAppenders();
    }
 
    @Test
    public void addAppenderWithStream() {
        logger.addAppender(new ConsoleAppender(
            new PatternLayout("%p %t %m%n"),
            ConsoleAppender.SYSTEM_OUT));
        logger.info("addAppenderWithStream"); // 우리가 이해한 설정이 맞는지 확인
    }
}

이런 테스트의 진짜 가치는 학습이 끝난 뒤에 드러난다. 라이브러리를 새 버전으로 올릴 때, 학습 테스트가 우리가 의존하던 동작이 여전히 유효한지 다시 확인해 준다. 판단 기준: 업그레이드가 두려운 외부 의존성일수록 학습 테스트를 남겨 두면, 그 테스트가 경계의 회귀 안전망이 된다. 게다가 이 테스트는 공짜다 — 어차피 API를 익혀야 하니, 그 학습을 테스트로 적어 두기만 하면 된다.

아직 존재하지 않는 코드와의 경계

필요한 모듈이 아직 없는데 그것을 쓰는 코드를 먼저 짜야 할 때가 있다. 이때는 우리가 원하는 인터페이스를 우리 기준으로 먼저 정의한다. 그러면 실제 구현이 늦어져도 사용하는 쪽의 설계를 진행할 수 있고, 테스트에서는 가짜 구현으로 흐름을 검증할 수 있다.

flowchart LR
    APP["우리 코드"] -->|"우리가 정의한 인터페이스"| PORT["Transmitter (우리 언어)"]
    PORT -.->|"어댑터(ACL)"| ADAPTER["TransmitterAdapter"]
    ADAPTER -->|"실제 외부 API"| EXT["미래의/외부 송신 모듈"]

핵심은 외부 팀이나 미래 구현에 맞춰 지금 코드를 흐릿하게 만들지 않는 것이다. 우리가 필요한 동작을 인터페이스로 표현해 두고, 실제 구현과 연결되는 지점에 어댑터(Anti-Corruption Layer, 부패 방지 계층)를 둔다.

// 우리 언어로 정의한 인터페이스 — 외부 개념이 새어 들지 않는다
public interface Transmitter {
    void transmit(double frequency, String data);
}
 
// 어댑터가 외부 API를 우리 인터페이스로 번역한다
public class TransmitterAdapter implements Transmitter {
    private final ExternalRadio radio; // 외부 타입은 여기 안에만 존재
 
    public TransmitterAdapter(ExternalRadio radio) {
        this.radio = radio;
    }
 
    @Override
    public void transmit(double frequency, String data) {
        radio.tuneTo(frequency);       // 외부 API 호출을 이 경계에 가둔다
        radio.send(data.getBytes());
    }
}

도메인 코드는 Transmitter만 알면 되고, ExternalRadio라는 외부 타입은 어댑터 안에만 존재한다. 외부 API가 우리 언어로 번역되는 지점이 이 어댑터 하나로 좁혀지므로, 외부가 바뀌어도 나머지 코드는 그대로다. 함정: 어댑터를 두지 않고 외부 타입을 도메인 코드가 직접 참조하기 시작하면, 외부의 개념과 이름이 시스템 안으로 스며들어(부패) 나중에 걷어내기 어려워진다.

깨끗한 경계

경계는 변경을 흡수하는 완충지대다. 외부 코드를 직접 신뢰하기보다, 우리가 이해하고 통제할 수 있는 형태로 좁혀서 쓰는 편이 안정적이다. 세 가지 도구가 서로를 보완한다 — 래퍼는 우리가 쓰는 동작만 남기고, 학습 테스트는 경계 바깥의 동작을 확인하며, 어댑터는 외부의 변경이 안으로 번지지 않게 막는다.

결국 좋은 경계는 외부 코드를 멀리하자는 뜻이 아니다. 외부 코드를 적극적으로 쓰되, 그 영향이 경계 밖으로 새어 시스템 전체로 번지지 않게 만드는 설계다.

마무리

  • 외부 API는 범용성을, 우리는 안정성을 원한다. 이 긴장을 경계에 가둔다.
  • Map 같은 경계 인터페이스는 그대로 넘기지 말고 클래스로 감싸 필요한 동작만 연다.
  • 학습 테스트는 사용법을 익히는 동시에, 업그레이드 시 회귀 안전망이 된다.
  • 아직 없는 코드는 우리 인터페이스를 먼저 정의하고, 어댑터(ACL)로 실제 구현과 잇는다.
  • 래퍼·학습 테스트·어댑터가 함께 외부 변경의 충격을 경계 안에 묶어 둔다.

다음장으로 9장