오브젝트 실전 강의 교재¶
부록 A — 계약에 의한 설계 (Design by Contract)¶
원서: 조영호 『오브젝트』 대상: Java/Spring 백엔드 입문~중급 수강생 형식: 개념 → 비유 → 예시 → 핵심 교훈 → 함정 → 체크리스트 → 퀴즈(정답 분리)
0. 이 장을 시작하기 전에¶
0.1 학습 목표¶
- 계약 (Contract) 의 의미와 3 요소 (사전·사후·불변).
- 계약 어길 시 누구 책임 의 명시화 — 호출자 vs 메서드.
- 자식의 계약 규칙 (LSP 의 형식적 기반) — 공변·반공변.
- 자바에서 계약 표현 —
requireNonNull·assert·@NonNull·Javadoc·Bean Validation. - Effective Java Item 49·56·70 과 직접 연결.
0.2 큰 그림 — 계약의 3 요소¶
[ 사전조건 (Precondition) ]
메서드 호출 전 만족해야 할 조건
→ 호출자 책임 (어기면 호출자 잘못)
[ 메서드 본문 ]
사전조건이 참이면 동작 보장
[ 사후조건 (Postcondition) ]
메서드 반환 후 만족하는 조건
→ 메서드 책임 (어기면 메서드 잘못)
[ 불변식 (Invariant) ]
객체가 항상 (모든 메서드 호출 전후) 만족하는 조건
→ 객체 책임 (어기면 도메인 일관성 깨짐)
비유 — "계약서"
집을 사고파는 매매 계약을 떠올려 봅시다. 구매자는 계약하기 전에 대금을 치를 자격을 갖춰야 하고, 판매자는 계약이 끝나면 약속한 집을 온전히 넘겨줍니다. 그리고 거래가 진행되는 동안 그 집은 망실되지 않고 그대로 유지되어야 합니다. 어느 한쪽이 이 약속을 어기면, 어긴 쪽이 책임을 집니다.
계약에 의한 설계도 같은 순서를 따릅니다. 계약하기 전에 갖춰야 할 자격이 사전조건이고, 거래가 끝난 뒤 보장되는 약속이 사후조건이며, 거래 내내 깨지면 안 되는 상태가 불변식입니다. 그리고 조건을 어긴 쪽이 책임을 진다는 원칙도 그대로입니다.
0.3 현업에서 왜 중요한가¶
- API 의 계약 명시 = 사용자 (호출자) 의 책임과 우리 (메서드) 의 책임 명확화.
- 13장 LSP 의 형식적 기반 — "자식이 부모 대체 가능" 의 정확한 의미.
- Spring
@Valid·Bean Validation 이 정확히 사전조건의 자동화. - Effective Java Item 49 (매개변수 검증)·56 (Javadoc)·70 (예외) 의 종합.
1. 협력과 계약¶
1.1 부수효과를 명시적으로¶
메서드의 동작을 외부에 약속 — 코드·문서·검증으로 보이게.
명시되지 않은 약속 = 호출자가 추측. 추측이 빗나가면 버그.
1.2 Bertrand Meyer 와 Eiffel¶
계약에 의한 설계 (DbC) = Bertrand Meyer 가 Eiffel (1986) 언어에서 도입.
- 언어 차원에서 require (사전)·ensure (사후)·invariant (불변) 키워드.
- 자바에는 직접 없지만 동일 사고 적용 가능.
2. 계약에 의한 설계 — 3 요소¶
2.1 사전조건 (Precondition)¶
메서드 호출 전 만족해야 할 조건. 호출자 책임.
public Money withdraw(int amount) {
// 사전조건: amount > 0 && amount <= balance
if (amount <= 0)
throw new IllegalArgumentException("amount must be positive: " + amount);
if (amount > balance)
throw new InsufficientBalanceException(balance, amount);
balance -= amount;
return new Money(amount, currency);
}
사전조건 위반 = 호출자 잘못¶
- 호출자가 음수·잔액 초과를 보낸 것.
- 메서드는 잘못된 호출을 거부 (예외) 하지만, 호출자 책임으로 둠.
Spring @Valid = 사전조건 자동화¶
@PostMapping("/orders")
public OrderResponse create(@Valid @RequestBody CreateOrderRequest req) {
// @Valid 가 사전조건 (userId 비어있지 X, amount > 0 등) 검증
return orderService.create(req);
}
public record CreateOrderRequest(
@NotBlank String userId,
@Positive int amount
) {}
→ Bean Validation 이 사전조건을 어노테이션으로. Spring 이 자동 검증.
2.2 사후조건 (Postcondition)¶
메서드 반환 후 만족하는 조건. 메서드 책임.
public Money withdraw(int amount) {
int oldBalance = balance;
// 사전조건 검증 생략 표시
balance -= amount;
Money result = new Money(amount, currency);
// 사후조건:
// 1. balance == oldBalance - amount
// 2. 반환값 == new Money(amount, currency)
assert balance == oldBalance - amount : "balance not decremented correctly";
assert result.amount() == amount : "returned wrong amount";
return result;
}
사후조건 위반 = 메서드 잘못¶
- 메서드가 약속한 결과를 안 줌. 호출자는 책임 없음.
자바에서의 표현¶
- 본문 후
assert(개발 환경에서만). - 단위 테스트로 검증.
- Javadoc
@return으로 명시.
2.3 불변식 (Invariant)¶
객체가 항상 (모든 메서드 호출 전후) 만족하는 조건.
public class Account {
private int balance;
// 불변식: balance >= 0 (모든 시점)
public Account(int initial) {
if (initial < 0) throw new IllegalArgumentException();
this.balance = initial;
// 불변식 만족 확인
assert invariant();
}
public void withdraw(int amount) {
// 사전조건
if (amount <= 0 || amount > balance) throw new IllegalArgumentException();
balance -= amount;
// 불변식 유지 확인
assert invariant();
}
public void deposit(int amount) {
if (amount <= 0) throw new IllegalArgumentException();
balance += amount;
assert invariant();
}
private boolean invariant() {
return balance >= 0;
}
}
→ 모든 public 메서드 종료 시점에 불변식 유지. 객체가 자기 일관성 책임.
3. 계약에 의한 설계와 서브타이핑¶
3.1 계약 규칙 — 자식의 계약 (Liskov)¶
자식이 부모 대체하려면:
| 요소 | 자식의 규칙 | 방향 |
|---|---|---|
| 사전조건 | 부모와 같거나 더 완화 | 반공변 (contravariant) |
| 사후조건 | 부모와 같거나 더 강화 | 공변 (covariant) |
| 불변식 | 부모 것 유지 | invariant |
한 줄로: "자식은 더 받아들이고 더 보장한다".
3.2 위반 예시¶
사전조건 강화 — LSP 위배¶
public class Account {
public void deposit(int amount) {
if (amount <= 0) throw new IllegalArgumentException(); // 사전: amount > 0
balance += amount;
}
}
public class PremiumAccount extends Account {
@Override
public void deposit(int amount) {
if (amount < 10000) throw new IllegalArgumentException(); // 사전: amount >= 10000 (더 엄격)
super.deposit(amount);
}
}
// 클라이언트
void process(Account a) {
a.deposit(5000); // 부모 계약으로는 OK, PremiumAccount 면 예외 — LSP 위배
}
→ 부모 계약 (amount > 0) 으로는 5000 OK 인데 자식이 거부 → 클라이언트 깨짐.
사후조건 약화 — LSP 위배¶
자식이 부모보다 약한 보장 → 클라이언트가 기대한 결과 못 얻음.
3.3 가변성 규칙 (자바)¶
- 반환 타입: 공변 가능 — 자식이 더 구체적 타입 반환 OK.
- 매개변수 타입: invariant — 자바 메서드 오버라이드는 매개변수 동일.
- 예외: 자식이 부모보다 더 좁은 예외 던지기 OK (체크 예외).
3.4 함수 타입과 서브타이핑¶
함수형 언어 (Haskell·Scala) 에서 함수 타입의 공변/반공변 규칙.
// Scala
val f: (Animal => String) = (a: Animal) => a.name // 부모 → String
val g: (Dog => Object) = f // 자식 → Object (반공변·공변)
자바의 Function<? super T, ? extends R> 가 같은 정신.
4. 자바에서 계약 표현¶
4.1 명시적 검증 (사전조건)¶
public void withdraw(int amount) {
Objects.requireNonNull(amount, "amount");
if (amount <= 0) throw new IllegalArgumentException("amount must be positive: " + amount);
if (amount > balance) throw new InsufficientBalanceException(balance, amount);
// ... 본문
}
→ public 메서드는 항상 명시적 검증 (운영에서도 작동).
4.2 어서션 (개발·테스트)¶
private void process(int[] sorted) {
assert sorted != null && isSorted(sorted) : "sorted is required";
// ...
}
-eaJVM 옵션 켜야 작동. 운영은 비활성.- private 메서드 + 사후조건·불변식 검증에 적합.
- public API 검증에는 부적합 (운영에서 안 작동).
4.3 정적 분석 — @NonNull · JSpecify¶
import org.jspecify.annotations.NonNull;
public void send(@NonNull String message, @NonNull User recipient) { ... }
→ IDE·정적 분석기가 컴파일 전 검증. null 전달 가능성을 경고.
4.4 Javadoc — 계약 문서화¶
/**
* 계좌에서 금액을 인출한다.
*
* @param amount 인출 금액 (양수, 잔액 이하 — 사전조건)
* @return 인출된 금액 객체 (amount 와 동일 금액)
* @throws IllegalArgumentException amount <= 0 또는 amount > balance
* @throws IllegalStateException 계좌가 동결 상태
*/
public Money withdraw(int amount) { ... }
→ Effective Java Item 56.
4.5 Bean Validation (사전조건 자동화)¶
public record CreateUserRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 50) String password,
@NotNull @Past LocalDate birthDate
) {}
→ Spring @Valid 가 자동 검증. 사전조건의 선언적 표현.
4.6 종합 — 계약 표현 매트릭스¶
| 위치 | 도구 |
|---|---|
| public API 사전조건 | requireNonNull + if-throw + Bean Validation |
| public API 사후조건 | 단위 테스트 + Javadoc @return |
| public API 예외 | Javadoc @throws |
| private 메서드 | assert |
| 불변식 | private invariant() 메서드 + assert |
| 컴파일 시점 | @NonNull / JSpecify |
핵심 교훈¶
- 계약 = 객체 협력의 약속 — 사전·사후·불변.
- 사전조건 위반 = 호출자 책임, 사후조건 위반 = 메서드 책임, 불변식 위반 = 객체 책임.
- 자식의 계약 = 사전 완화·사후 강화·불변 유지. LSP 의 형식적 기반.
- 자바 표현 =
requireNonNull·assert·@NonNull·Javadoc·Bean Validation 의 조합. - Spring
@Valid= 사전조건의 선언적 자동화.
함정 / 주의¶
- Java
assert는 기본 비활성 — 운영 검증에 부적합. public 은if-throw. - 계약 도그마 X — 모든 메서드에 사전·사후 명시는 과함. 핵심 도메인 만.
- 사전조건 강화 = LSP 위배 — 자식이 부모보다 엄격하면 클라이언트 깨짐.
- 불변식 검증 X — 객체가 일관성 깨진 상태로 동작하면 추적 어려운 버그.
체크리스트¶
- public 메서드에 사전조건 검증 (requireNonNull + if-throw)
- Javadoc 에
@param(사전)·@return(사후)·@throws명시 - Spring DTO 에 Bean Validation 어노테이션
- 자식 클래스가 부모 계약 강화 안 하는가 (LSP)
- 객체의 불변식이 코드에 명시 (private invariant 메서드)
퀴즈¶
- 계약의 3 요소 와 각각의 책임 주체?
- 자식 계약 규칙 — 사전·사후·불변의 가변성?
- 사전조건 강화 가 LSP 위배인 이유?
- 자바에서 public API 계약 표현 의 표준 도구 3 가지?
- Spring
@Valid가 계약에 의한 설계의 어느 부분?
정답·해설¶
- 사전조건 = 호출 전 조건, 호출자 책임. 사후조건 = 반환 후 조건, 메서드 책임. 불변식 = 항상 만족, 객체 책임.
- 사전: 부모와 같거나 더 완화 (반공변). 사후: 부모와 같거나 더 강화 (공변). 불변식: 부모 것 유지. 한 줄로 "자식은 더 받아들이고 더 보장".
- 클라이언트가 부모 계약으로 호출 가능 한데 자식이 거부 → 클라이언트 입장에서 자식 인스턴스가 부모 자리에 들어오면 갑자기 거부당함. 다형성 깨짐 — LSP (자식이 부모 대체 가능) 위배.
- (1) 명시적 검증 (
requireNonNull+if-throw+ Bean Validation), (2) Javadoc (@param·@return·@throws), (3) 정적 분석 (@NonNull/ JSpecify). 세 가지 조합이 표준. - 사전조건의 선언적 자동화.
@Valid+@NotBlank·@Positive같은 Bean Validation 어노테이션이 사전조건을 선언적으로 표현, Spring 이 컨트롤러 진입 시 자동 검증. 위반 시MethodArgumentNotValidException.
다음 — 부록 B: 타입 계층의 구현¶
타입 계층을 표현하는 여러 메커니즘 — 클래스·인터페이스·추상 클래스·결합·덕 타이핑·믹스인. 각 메커니즘의 트레이드오프 비교.