Architecture & Design/Software Architecture
Principles - 06. 계약 설계와 API
메뉴판은 계약서다 — 계약 설계와 API
식당에서 메뉴판을 본다. "된장찌개 8,000원"이라고 적혀 있다. 이 한 줄이 계약이다 — 8,000원을 내면(사전 조건) 된장찌개를 받는다(사후 조건). 주방에서 어떤 냄비를 쓰는지, 몇 분 끓이는지는 모른다. 메뉴판이 약속한 것만 지켜지면 된다. 메뉴판이 바뀌면(가격 인상, 메뉴 삭제) 손님이 혼란스럽다 — 계약이 변경된 것이다.
소프트웨어의 API도 같다. 메서드 시그니처가 메뉴판이고, 호출 규칙이 계약이다. Bertrand Meyer는
사전 조건과 사후 조건
사전 조건은 호출자가 지켜야 할 약속이다 — "8,000원을 내야 찌개를 받는다". 사후 조건은 메서드가 보장하는 결과다 — "8,000원을 받으면 찌개를 낸다". 사전 조건을 위반하면 메서드가 무엇을 해도(예외, 쓰레기값) 호출자 책임이다.
// 계약이 명확한 메서드
public Money transfer(AccountId from, AccountId to, Money amount) {
// 사전 조건: from 계좌가 존재하고, 잔액이 충분하고, amount가 양수
// 사후 조건: from에서 amount가 차감되고 to에 amount가 입금된다
}
// 사전 조건 위반 시 (잔액 부족): 예외를 던지는 것이 호출자 책임
계약이 모호하면 디버깅이 어렵다. "잔액 부족일 때 null을 반환하는가, 예외를 던지는가?"가 문서에 없으면, 호출자가 매번 추측해야 한다. 계약이 명확하면 호출자가 사전 조건을 지키고, 메서드가 사후 조건을 보장하면 된다 — 추측할 여지가 없다.
CQS — 명령과 질의를 분리한다
Meyer의 명령-질의 분리(Command-Query Separation, CQS)는 "메서드는 상태를 변경(명령)하거나 정보를 반환(질의)하거나, 둘 중 하나만 해야 한다"는 원칙이다. 둘을 섞으면 부수 효과가 있는 질의가 되어, "조회만 하려 했는데 상태가 바뀌었다"는 혼란이 생긴다.
// CQS 위반 — 명령+질의 혼합
class Stack {
Item pop() {
return items.remove(items.size() - 1); // 상태 변경(제거) + 반환
}
}
// pop()을 "조회" 목적으로 부르면 의도치 않게 요소가 사라짐
// CQS 준수 — 명령과 질의 분리
class Stack {
Item peek() { return items.get(items.size() - 1); } // 질의 (변경 없음)
void pop() { items.remove(items.size() - 1); } // 명령 (반환 없음)
}
설계 사례 — 계약이 깨지는 상황과 호환성
flowchart LR
subgraph Contract["API 계약"]
Pre["사전 조건<br/>amount > 0"]
Post["사후 조건<br/>from에서 차감, to에 입금"]
Inv["불변 조건<br/>총액 보존"]
end
Caller["호출자"] -->|지켜야| Pre
Method["메서드"] -->|보장해야| Post
Method -->|항상 유지| Inv
// 계약 위반 사례 1: 사전 조건 강제 안 함
public void transfer(AccountId from, AccountId to, Money amount) {
// amount가 음수여도 그냥 실행 → from에 돈이 들어감 (버그)
accounts.debit(from, amount);
accounts.credit(to, amount);
}
// 해결: 사전 조건을 명시적 검증으로 강제
public void transfer(AccountId from, AccountId to, Money amount) {
if (amount.isNegative())
throw new IllegalArgumentException("이체 금액은 양수여야 합니다"); // 사전 조건
accounts.debit(from, amount);
accounts.credit(to, amount);
assert totalPreserved(from, to, amount) : "총액 보존 위반"; // 불변 조건
}
// 계약 위반 사례 2: 반환 타입이 약속과 다름 (호환성 깨짐)
// v1: Order placeOrder(Request req) — Order 반환
// v2: OrderResult placeOrder(Request req) — OrderResult 반환
// v1에 의존하는 호출자가 v2에서 컴파일 에러 → 계약(반환 타입) 위반
// 해결: 새 메서드를 추가하고 기존은 유지 (확장에는 열려 있고 수정에는 닫혀 있음 — OCP)
public Order placeOrder(Request req) { return placeOrderV1(req); } // 기존 유지
public OrderResult placeOrderV2(Request req) { /* 새 구현 */ } // 새 메서드
API의 호환성은 계약의 안정성이다. 사전 조건(매개변수 타입), 사후 조건(반환 타입), 부수 효과(예외)가 바뀌면 의존하는 모든 호출자가 깨진다. 메뉴판의 가격·메뉴를 바꾸면 손님이 혼란스럽듯, API 계약을 바꾸면 호출자가 깨진다. 계약을 안정적으로 유지하거나, 바꿀 때는 새 버전을 추가로 제공하는 것이 호환성의 기본이다.
불변 조건 — 항상 참이어야 하는 것
사전 조건과 사후 조건 외에, 불변 조건(invariant)이 있다. 이것은 객체의 생명 주기 내내 — 모든 메서드 호출 전후에 — 항상 참이어야 하는 조건이다. 은행 계좌의 불변 조건은 "잔액 ≥ 0"이다. 입금·출금·이체 어떤 메서드를 부르든, 호출이 끝나면 잔액이 음수가 아니어야 한다.
public class Account {
private Money balance;
// 불변 조건: balance.amount >= 0 (항상)
public void withdraw(Money amount) {
// 사전 조건: amount > 0
if (amount.isNegative()) throw new IllegalArgumentException();
// 불변 조건 유지: 출금 후에도 잔액 >= 0
if (balance.isLessThan(amount)) throw new InsufficientBalanceException();
balance = balance.subtract(amount);
// 사후 조건: balance = 이전 balance - amount (그리고 >= 0)
assert !balance.isNegative() : "불변 조건 위반: 잔액이 음수";
}
}
불변 조건이 깨지면 객체가 "무효 상태"에 빠진다 — 잔액이 음수인 계좌는 도메인 규칙에 존재하지 않는다. 모든 public 메서드는 불변 조건을 유지할 의무가 있다.
계약 위반 — 각 유형별 무슨 일이 일어나는가
| 위반 유형 | 의미 | 결과 | 책임 |
|---|---|---|---|
| 사전 조건 위반 | 호출자가 약속을 안 지킴 (음수 금액) | 메서드가 예외 던짐 | 호출자 |
| 사후 조건 위반 | 메서드가 약속을 안 지킴 (결과가 null) | 버그 — 메서드 구현 수정 | 메서드 |
| 불변 조건 위반 | 객체가 무효 상태에 빙 (잔액 음수) | 심각한 버그 — 도메인 규칙 붕괴 | 메서드 |
사전 조건 위반은 호출자 책임이므로 "예외를 던지는 것이 정상"이다. 하지만 사후·불변 조건 위반은 메서드 자체의 버그다 — 호출자가 아무리 올바로 호출해도 깨지면, 그건 메서드 구현의 결함이다.
API 버저닝 — 계약을 바꿔야 할 때
계약(반환 타입, 매개변수, 예외)을 바꿔야 할 때가 온다. 이때 기존 호출자를 깨뜨리지 않는 세 가지 전략이 있다.
// 전략 1: 새 메서드 추가 (기존 유지)
public Order placeOrder(Request req) { return placeOrderV1(req); } // 기존 계약 유지
public OrderResult placeOrderV2(Request req) { /* 새 계약 */ } // 새 버전
// 전략 2: 선택적 매개변수 (오버로딩)
public Order placeOrder(Request req) { return placeOrder(req, Option.DEFAULT); }
public Order placeOrder(Request req, Option opt) { /* 확장 */ } // 기존 호출자 영향 없음
// 전략 3: 폐기 예고 (deprecation)
@Deprecated("use placeOrderV2 instead") // 경고 → 호출자가 천천히 이전
public Order placeOrder(Request req) { ... }
계약을 바꾸는 것은 "메뉴판을 바꾸는 것"이다 — 단번에 바꾸면 손님이 혼란스럽고, 점진적으로 바꾸면 적응할 수 있다. 새 버전을 추가하고 기존을 유지하거나 폐기 경고를 주어, 호출자가 자기 일정에 맞춰 이전할 수 있게 하는 것이 호환성의 실천이다.
참고
- Meyer, Bertrand —
(2nd ed, Prentice Hall, 1997) (Design by Contract, CQS 원전) — 접근 2026-07-15 - Martin, Robert C. —
(Pearson, 2017), Ch.7 (ISP와 인터페이스 계약) — 접근 2026-07-15
'Architecture & Design > Software Architecture' 카테고리의 다른 글
| Principles - 08. 복잡성의 원천 (0) | 2026.07.16 |
|---|---|
| Principles - 07. 오류 처리 전략 (0) | 2026.07.16 |
| Principles - 05. 캡슐화와 정보 은닉 (0) | 2026.07.16 |
| Principles - 04. 의존성 규칙 (0) | 2026.07.16 |
| Principles - 03. 패러다임의 함의 (0) | 2026.07.16 |