훌륭한 설계는 한 번의 큰 결정에서 나오지 않는다. 켄트 벡이 말한 단순한 설계 규칙 네 가지를 꾸준히 따르면, 설계는 만들어 가는 과정에서 저절로 드러난다(창발, emergence). 규칙은 우선순위가 있고, 위에서부터 순서대로 적용한다.

flowchart TD
    R1["1. 모든 테스트를 실행하라<br/>(가장 우선 · 절대 조건)"] --> R2["2. 중복을 없애라"]
    R2 --> R3["3. 프로그래머 의도를 표현하라"]
    R3 --> R4["4. 클래스와 메서드 수를 최소로<br/>(균형추 · 우선순위 최하)"]
    R4 -.->|충돌하면 위 규칙이 이긴다| R1

규칙 1: 모든 테스트를 실행하라

설계가 아무리 우아해도 시스템이 의도대로 돌아간다는 걸 검증할 방법이 없으면 의미가 없다. 이 규칙이 나머지 셋보다 위에 있는 이유가 여기 있다. 그런데 이 규칙은 검증만 하는 게 아니라 설계까지 이끈다. 테스트가 가능한 시스템을 만들려 애쓰다 보면 자연스럽게 작고 목적 하나에 충실한 클래스가 나온다. 결합도가 높으면 테스트를 짤 수 없기 때문에, 테스트를 많이 쓸수록 DIP 같은 원칙을 적용하게 되고 결합도는 낮아진다.

// 테스트하기 어려운 코드는 대개 설계가 나쁘다 — 시계에 직접 묶임
public boolean isExpired(Token token) {
    return token.createdAt().plusHours(1).isBefore(LocalDateTime.now()); // now() 를 통제할 수 없다
}
 
// 테스트를 위해 의존성을 밖으로 빼면 설계도 좋아진다
public boolean isExpired(Token token, Clock clock) {
    return token.createdAt().plusHours(1).isBefore(LocalDateTime.now(clock)); // 시간을 주입
}

판단 기준: “이 코드에 테스트를 붙이기 어렵다”는 느낌은 거의 언제나 설계 결함의 신호다. 테스트를 먼저 확보하는 것이 나머지 세 규칙의 전제 조건이다. 테스트가 있어야 뒤의 리팩터링을 겁 없이 할 수 있다.

규칙 2: 중복을 없애라

테스트를 확보했다면 코드를 점진적으로 정리한다. 그 첫걸음이 중복 제거다. 똑같은 코드는 물론이고, 비슷한 코드도 더 비슷하게 고쳐 중복을 제거할 기회를 만든다. 몇 줄 안 되는 사소한 중복도 쌓이면 변경 지점이 여러 곳으로 흩어진다.

// before — 골격이 같고 한 줄만 다른 두 메서드
public void scaleToOneDimension(float desiredDim, float imageDim) {
    if (Math.abs(desiredDim - imageDim) < errorThreshold) return;
    float scalingFactor = desiredDim / imageDim;
    scalingFactor = (float) (Math.floor(scalingFactor * 100) * 0.01f);
    RenderedOp newImage = ImageUtilities.getScaledImage(image, scalingFactor, scalingFactor);
    image.dispose(); System.gc(); image = newImage;
}
public synchronized void rotate(int degrees) {
    RenderedOp newImage = ImageUtilities.getRotatedImage(image, degrees);
    image.dispose(); System.gc(); image = newImage;
}
 
// after — 반복되는 교체 절차를 메서드로 추출
private void replaceImage(RenderedOp newImage) {
    image.dispose(); System.gc(); image = newImage; // 공통 골격 한 곳으로
}

TEMPLATE METHOD 패턴처럼 공통 골격을 상위 클래스로 올리고 다른 부분만 서브클래스에 남기는 방식도 고차원 중복 제거의 한 형태다. 판단 기준: 같은 절차가 두 번 나타나면 이미 추출 대상이다. 함정: 우연히 비슷할 뿐 서로 다른 이유로 바뀌는 코드를 억지로 합치면(가짜 중복), 나중에 한쪽만 바꿔야 할 때 오히려 방해가 된다.

규칙 3: 프로그래머 의도를 표현하라

코드는 작성자가 아니라 다음에 읽을 사람을 위해 존재한다. 시스템이 복잡해질수록 유지보수자가 코드를 이해하는 데 드는 시간이 늘고, 오해는 곧 버그가 된다. 좋은 이름을 붙이고, 함수와 클래스 크기를 줄이고, 표준 패턴 이름(COMMAND, VISITOR 등)을 쓰면 설계 의도가 이름만으로 전달된다.

// before — 무슨 판단인지 코드를 해독해야 안다
if (e.getHMS() > 60 * 60 && e.getPrio() > 3 && !e.ack()) alert(e);
 
// after — 이름이 의도를 말한다
boolean isCritical = event.duration().toHours() >= 1
        && event.priority().isHigherThan(Priority.NORMAL)
        && !event.isAcknowledged();
if (isCritical) alert(event);

잘 짠 단위 테스트도 “이 클래스를 이렇게 쓴다”를 보여주는 예제 문서 역할을 한다. 판단 기준: 주석으로 설명하고 싶어지는 순간이, 대개 이름이나 구조로 의도를 표현할 기회다. 함정: 의도 표현에는 노력이 든다. “돌아가니까 됐다”며 다음으로 넘어가기 쉽지만, 그 미룸이 다음 사람의 시간을 갉아먹는다.

규칙 4: 클래스와 메서드 수를 최소로 줄여라

중복 제거와 의도 표현을 극단으로 밀어붙이면 자잘한 클래스와 메서드가 끝없이 늘어날 수 있다. 이 규칙은 앞 규칙들에 대한 균형추다. 클래스마다 인터페이스를 강제로 만든다거나, 자료 클래스와 동작 클래스를 무조건 분리하는 식의 독단적 원칙은 오히려 해가 된다.

// over-engineering — 구현이 하나뿐인데 인터페이스·팩토리로 감쌌다
public interface Greeter { String greet(String name); }
public class GreeterFactory { public Greeter create() { return new EnglishGreeter(); } }
public class EnglishGreeter implements Greeter {
    public String greet(String name) { return "Hello, " + name; }
}
 
// 균형추 적용 — 구현이 하나면 클래스 하나로 충분하다
public class Greeter {
    public String greet(String name) { return "Hello, " + name; }
}

판단 기준: 이 규칙은 네 규칙 중 우선순위가 가장 낮다. 인터페이스는 실제로 갈래가 둘 이상 생길 때 도입한다(“나중에 필요할지도”는 근거가 아니다). 함정: 그렇다고 이 규칙을 앞세워 큰 함수/큰 클래스를 정당화하면 안 된다. 규칙 2·3이 먼저고, 그 결과가 과하게 잘게 쪼개졌을 때만 규칙 4로 되돌린다.

정리

  • 테스트를 작성할 수 있는 구조를 만들면 설계는 이미 절반쯤 좋아진 것이다 — 테스트 가능성이 곧 설계 품질의 지표다.
  • 중복 제거와 의도 표현은 리팩터링의 두 축이다. 테스트가 있으니 미루지 않는다.
  • 규칙 사이 충돌이 나면 위 규칙(테스트 → 중복 → 의도 → 개수)이 이긴다. 클래스 수를 줄이는 규칙이 과잉 분리를 막는 균형추다.

창발적 설계라는 말이 거창해 보이지만, 실체는 매일 반복하는 작은 규율이다.

다음장으로 13장