Architecture & Design/Software Architecture

Principles - 02. 추상화와 깊은 모듈

변속기는 "기어 1~6"만 보여준다 — 깊은 모듈과 얕은 모듈

자동차 변속기를 생각해 보자. 운전자에게 보이는 인터페이스는 기어 레버 — "1단에서 6단으로 옮긴다"가 전부다. 하지만 그 레버 뒤에는 수백 개의 톱니바퀴와 유압 시스템과 센서가 얽혀 있다. 운전자는 내부를 몰라도 운전할 수 있고, 변속기 제조사가 내부를 개선해도 운전자는 기어 레버만 보면 된다.

이것이 깊은 모듈(deep module)이다 — 인터페이스는 단순하지만 구현은 풍부하다. John Ousterhout은 에서 모듈의 깊이를 "인터페이스의 복잡성 대 구현의 복잡성" 비율로 정의했다. 깊은 모듈은 적은 인터페이스(노출)로 많은 구현(숨김)을 감싼다. 얕은 모듈은 그 반대다 — 인터페이스가 복잡한데 구현이 얄팩하다.

깊은 모듈 vs 얕은 모듈

USB 포트가 깊은 모듈의 좋은 예다. "꽂으면 작동한다"는 규격 뒤에 전력 공급, 데이터 프로토콜, 핫스왑, 장치 식별의 복잡성이 숨어 있다. 사용자는 꽂기만 하면 된다 — 인터페이스가 극단적으로 단순하다.

반면, 설정 객체를 15개 매개변수로 받는 생성자는 얕은 모듈이다. 호출자가 15개의 매개변수를 이해하고 조합해야 하는데, 내부 로직은 그 값을 저장하기만 한다. 인터페이스가 구현보다 복잡하다 — 모듈이 감추기는커녕 호출자에게 복잡성을 떠넘긴다.

flowchart TB
    subgraph Deep["깊은 모듈 (변속기)"]
        DI["인터페이스: 기어 1~6<br/>(단순)"]
        DI --- DI2["구현: 톱니바퀴·유압·센서<br/>(풍부, 숨겨짐)"]
    end
    subgraph Shallow["얕은 모듈 (설정 주입)"]
        SI["인터페이스: 매개변수 15개<br/>(복잡)"]
        SI --- SI2["구현: 필드에 저장<br/>(얄팉)"]
    end

Ousterhout의 핵심 통찰: 모듈은 깊을수록 좋다. 깊은 모듈은 호출자가 알아야 할 것을 적게 만들어 시스템 전체의 복잡성을 낮춘다. 얕은 모듈은 오히려 복잡성을 전파한다.

과도한 세분화의 함정

SOLID의 단일 책임(SRP)을 오해하면, 모든 클래스를 하나의 메서드만 갖도록 쪼개는 과잉에 빠진다. Ousterhout은 이를 경계한다 — 클래스가 작아지면 인터페이스(클래스 수)가 늘어나고, 호출자가 알아야 할 접점이 많아진다. 깊이가 얕아지는 것이다.

// 과도한 세분화 — 얕은 모듈 3개
class UrlValidator { boolean isValid(String url) { return url.startsWith("https"); } }
class UrlFormatter { String format(String url) { return url.toLowerCase(); } }
class UrlFetcher { String fetch(String url) { return httpClient.get(url); } }
// 호출자가 3개 클래스를 알아야 함 → 복잡성 전파

// 깊은 모듔 1개 — 단순 인터페이스로 감싼 풍부한 구현
class WebResource {
    private final HttpClient client;
    String fetch(String rawUrl) {           // 인터페이스: URL 하나
        validate(rawUrl);                    // 검증 (내부)
        String normalized = normalize(rawUrl); // 포맷 (내부)
        return client.get(normalized);       // 조회 (내부)
    }   // 호출자는 fetch(url)만 앎 — 검증·포맷·조회가 숨겨짐
}

설계 사례 — 파일 입출력의 깊이

Java의 Files.readString(Path)은 깊은 모듈의 좋은 사례다. 인터페이스는 readString(path) 한 줄이지만, 내부는 파일 열기·인코딩 감지·바이트→문자 변환·리소스 해제를 처리한다. 이전의 FileReader + BufferedReader + InputStreamReader 조합은 같은 일을 하면서 인터페이스가 훨씬 복잡했다 — 얕은 모듈의 조합이었다.

// 얕은 모듌 조합 (Java 7 이전) — 호출자가 3개 클래스 + 인코딩 지정
FileInputStream fis = new FileInputStream("config.txt");
InputStreamReader isr = new InputStreamReader(fis, StandardCharsets.UTF_8);
BufferedReader br = new BufferedReader(isr);
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
br.close();   // 리소스 해제도 호출자 책임
String content = sb.toString();

// 깊은 모듈 (Java 11+) — 인터페이스 한 줄, 구현은 풍부
String content = Files.readString(Path.of("config.txt"));
// 인코딩 감지·버퍼링·리소스 해제가 전부 내부로 숨겨짐

깊은 모듈은 "이미 있는 복잡성을 어디에 둘 것인가"의 문제다. 복잡성을 호출자에게 넘기면(얕은 모듈) 시스템 전체에 퍼지고, 모듈 안으로 감추면(깊은 모듈) 한 곳에 격리된다. 변속기가 수백 개 부품을 껍데기 안에 숨기고 기어 레버만 내미는 것처럼, 좋은 모듈은 적은 인터페이스로 많은 구현을 감싼다.

얕은 모듈이 정당한 경우

모든 모듈이 깊어야 하는 건 아니다. 데이터 전송 객체(DTO)는 얕은 모듈의 대표적 예다 — 필드만 있고 로직이 없다. 하지만 DTO는 "복잡성을 감추려는" 목적이 아니라 "데이터를 옮기려는" 목적이므로, 깊이를 논하는 대상이 아니다. 값 객체(Money, Address)도 인터페이스가 작지만 구현도 작다 — 하지만 이들은 규칙(음수 방지, 통화 검증)을 캡슐화하므로 그 크기에 비해 가치가 있다.

문제는 "추상화하려고 만든 모듈이 얕을 때"다. Wrapper·Adapter·Facade가 원래 모듈보다 더 복잡한 인터페이스를 갖거나, 단순히 호출만 전달(pass-through)하는 경우. 이런 얕은 추상화는 호출자에게 한 단계 더 거쳐야 하는 부담만 주고, 감추는 것은 없다.

// 얕은 추상화 — 가치 없는 pass-through
class OrderRepositoryWrapper {
    private final OrderRepository delegate;
    void save(Order order) { delegate.save(order); }      // 그냥 전달
    Optional<Order> findById(OrderId id) { return delegate.findById(id); }  // 그냥 전달
}
// 인터페이스(2개 메서드)가 구현(2개 메서드 위임)보다 복잡하지 않음 — 깊이 0
// 호출자는 wrapper를 거치는 부담만 얻고, 감춰지는 건 없음

이런 wrapper가 정당해지는 순간은 — 감춰야 할 것이 생겼을 때다. 저장 전 검증, 캐싱, 로깅, 권한 검사를 추가하면, wrapper가 감출 구현이 생기고 깊어진다. 감출 것이 없는 pass-through는 제거하는 편이 낫다.

얕은 모듈 감지 체크리스트

코드 리뷰에서 "이 모듈이 얕은가"를 판별하는 신호들이다. 하나라도 해당하면 깊이를 의심해 본다.

신호 설명
매개변수가 5개 이상 인터페이스가 복잡 — 호출자가 조합을 알아야 함 new Order(config, repo, validator, notifier, scheduler, mapper)
구현이 인터페이스보다 짧음 감출 것이 없음 — 껍데기만 있음 void save(Order o) { delegate.save(o); } (위임만)
호출자가 모듈 내부를 알아야 함 "이 순서로 호출해야 한다" 같은 숨겨진 규칙 init() → configure() → validate() → execute() 순서 강제
모듈이 던지는 예외가 5종 이상 호출자가 처리해야 할 경우의 수가 너무 많음 DB예외·검증예외·권한예외·시간초과·형식오류 전부 throw

API 설계 — 깊은 API vs 얕은 API

같은 기능을 제공하는 두 API를 비교한다. 파일에서 텍스트를 읽는 기능이다.

// 얕은 API — 호출자가 5단계를 알아야 함
FileInputStream fis = new FileInputStream(path);
InputStreamReader isr = new InputStreamReader(fis, charset);
BufferedReader br = new BufferedReader(isr);
String line;
while ((line = br.readLine()) != null) sb.append(line).append("\n");
br.close();
// 인터페이스: 5개 클래스 + charset 지정 + 리소스 해제 → 호출자가 다 앎

// 깊은 API — 호출자가 1줄만 앎
String content = Files.readString(Path.of(path));
// 인터페이스: 1개 메서드 + path → charset 감지, 버퍼링, 해제가 전부 내부로

얕은 API에서 호출자가 알아야 할 것(5개 클래스, charset, 리소스 해제)이 깊은 API에서는 전부 내부로 사라진다. Files.readString의 구현은 그 5단계를 내부에서 수행하지만, 호출자에게는 readString(path) 한 줄만 보인다. 이것이 깊은 모듈의 설계 목표다 — "호출자가 몰라도 되는 것을 최대한 내부로 밀어 넣는다."

추상화의 질이 중요하다

Ousterhout이 강조하는 점 — "추상화가 얕아서" 시스템이 복잡해지는 경우가 "추상화가 없어서"보다 많다. 잘못된 추상(얕은 모듈, 과도한 세분화)은 복잡성을 숨기지 못하고 옮기기만 한다. 깊은 모듈을 만들려면 "호출자가 몰라도 되는 것"을 최대한 내부로 밀어 넣어야 한다 — 인터페이스는 가급적 작게, 구현은 가급적 풍부하게.


참고