1장 출생
1.1 : -er 로 끝나는 이름을 사용하지 마세요.
- 객체는 그의 역량으로 특징지어져야 합니다. 제가 어떤 사람인지는 키, 몸무게, 피부색과 같은 속성이 아니라, 제가 할 수 있는 일로 설명해야 합니다.
- 새로운 클래스에 이름을 붙일 때는 무엇을 하는지가 아니라 무엇인지를 생각해야 한다.
Manager,Controller,Helper,Converter,Validator같은 이름은 객체가 무엇인지보다 어떤 절차를 수행하는지 설명하는 경우가 많음.
// 동작에 이름을 붙인 경우
final class CashFormatter {
String format(Cash cash) {
return "$" + cash.dollars();
}
}
// 객체가 무엇인지에 이름을 붙인 경우
interface Text {
String content();
}
final class FormattedCash implements Text {
private final Cash cash;
FormattedCash(Cash cash) {
this.cash = cash;
}
@Override
public String content() {
return "$" + this.cash.dollars();
}
}
CashFormatter는 데이터를 받아 처리하는 절차처럼 보임.FormattedCash는 “형식화된 금액”이라는 객체이며 자기 내용을 스스로 표현.
다른 예시.
| 피하고 싶은 이름 | 저자가 선호하는 방향 | 의미 |
|---|---|---|
FileReader |
FileContent |
파일을 읽는 작업 → 파일의 내용 |
UserValidator |
ValidUser 또는 UserValidity |
검증 작업 → 유효한 사용자/유효성 |
PasswordEncoder |
EncodedPassword |
인코딩 작업 → 인코딩된 비밀번호 |
OrderManager |
Order 또는 더 작은 도메인 객체 |
모호한 관리자 → 실제 관리 대상 |
User,Computer처럼 영어 단어 자체가-er로 끝나는 경우까지 금지하는 규칙은 아님.- 이름만 명사로 바꾼다고 객체지향 설계가 되는 것은 아님. 이름을 바꾸기 어려운 것이 책임이 모호하다는 신호인지 보는 규칙에 가까움.
1.2 : 생성자 하나를 주 생성자로 만드세요.
- 생성자가 많아질수록 클라이언트가 클래스를 더 유연하게 사용할 수 있게 됩니다.
- 메서드가 많아지면, 클래스의 초점이 흐려지고, 단일 책임 원칙을 위반하게 된다.
- -> 하나의 주 생성자와 다수의 부 생성자 원칙 : 유효성 검사를 한 곳에서 할 수 있다! (유지 보수성)
- 그렇다면, 주 생성자의 위치를 통일하면 좋은데, 어디에? 가장 앞? 가장 뒤?
나쁜 예시. 각 생성자가 필드를 직접 초기화.
final class Cash {
private final Number amount;
private final Currency currency;
Cash(int amount) {
this.amount = new IntegerNumber(amount);
this.currency = new Currency("USD");
}
Cash(String amount) {
this.amount = new StringAsInteger(amount);
this.currency = new Currency("USD");
}
Cash(Number amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
}
- 필드가 추가되거나 기본 통화 정책이 바뀌면 여러 생성자를 같이 수정해야 함.
하나의 주 생성자로 모은 경우.
final class Cash {
private final Number amount;
private final Currency currency;
Cash(int amount) {
this(new IntegerNumber(amount));
}
Cash(String amount) {
this(new StringAsInteger(amount));
}
Cash(Number amount) {
this(amount, new Currency("USD"));
}
// 주 생성자
Cash(Number amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
}
- 부 생성자는 인자를 다른 형태로 감싼 뒤 주 생성자에 위임.
- 실제 필드 할당은 주 생성자 한 곳에서만 수행.
- 생성자마다 서로 다른 초기화 규칙이 생기는 것을 막음.
- 주 생성자의 위치는 동작에 영향을 주지 않음. 팀 규칙으로 마지막에 두면 “부 생성자들이 하나의 주 생성자로 모인다”는 흐름이 눈에 잘 보임.
1.3 : 생성자에 코드를 넣지 마세요.
- 생성자에서는 인자를 건드리지 말 것.
- 파싱, 검증, I/O 같은 실제 작업은 생성자가 아니라 메서드가 호출될 때 수행.
- 생성자는 값 할당 또는 다른 객체로 감싸는 일만 한다.
- 즉, 객체를 만드는 시점과 객체가 일하는 시점을 분리한다.
나쁜 예시. 객체를 만들자마자 파싱해버림.
final class Cash {
private final int dollars;
Cash(String dollars) {
this.dollars = Integer.parseInt(dollars);
}
int value() {
return this.dollars;
}
}
책에서 원하는 방식. 일단 문자열을 보관하고 실제 값이 필요할 때 파싱.
interface Number {
int intValue();
}
final class StringAsInteger implements Number {
private final String source;
StringAsInteger(String source) {
this.source = source;
}
@Override
public int intValue() {
return Integer.parseInt(this.source);
}
}
final class Cash {
private final Number dollars;
Cash(String dollars) {
this(new StringAsInteger(dollars));
}
Cash(Number dollars) {
this.dollars = dollars;
}
int value() {
return this.dollars.intValue();
}
}
- 이렇게 하면 실제 작업이 필요한 시점을 클라이언트가 결정할 수 있다.
- 한 번만 계산하고 싶으면 생성자에 로직을 넣지 말고 캐싱 데코레이터를 따로 만들면 된다.
- 객체를 생성했을 뿐인데 예외가 발생하거나 DB 연결까지 일어나는 코드는 확실히 예측하기 어렵다. 다만 단순한 값 검증까지 무조건 미뤄야 하는지는 의문.
I/O가 생성자에 들어간 경우.
final class Configuration {
private final Properties values;
Configuration(Path path) throws IOException {
this.values = new Properties();
try (InputStream input = Files.newInputStream(path)) {
this.values.load(input);
}
}
}
- 객체를 조립하는 순간 파일을 열고 읽음.
- 사용하지 않을 객체여도 I/O가 실행되고, 객체 그래프를 만드는 중간에 실패할 수 있음.
작업을 요청받을 때 읽는 경우.
interface Configuration {
String value(String key) throws IOException;
}
final class FileConfiguration implements Configuration {
private final Path path;
FileConfiguration(Path path) {
this.path = path;
}
@Override
public String value(String key) throws IOException {
Properties values = new Properties();
try (InputStream input = Files.newInputStream(this.path)) {
values.load(input);
}
return values.getProperty(key);
}
}
- 생성자는
Path만 보관. 실제 파일 접근은value()가 호출될 때 발생.
애플리케이션 전체도 같은 방식으로 조립.
App app = new App(
new FileConfiguration(Path.of("app.properties")),
new Console()
);
app.run();
new App(...)까지는 객체들을 준비만 하고,run()부터 실제 작업 시작.- 이 방식은 지연 실행 시점을 명확히 하지만
value()를 부를 때마다 파일을 다시 읽을 수 있음. 필요하면 캐시 객체를 별도 데코레이터로 추가.
2장 학습
객체는 작아야 한다.
2.1 가능하면 적게 캡슐화하세요
-
복잡성은 직접적으로 유지보수성에 영향을 미칩니다. 복잡성이 높을수록 유지보수성이 저하….(생략)
-
4개 또는 그 이하의 객체를 캡슐화할것(경험적 근거)
-
다른 객체를 캡슐화하지 않는 객체란 존재하지도 않으며 존재할 수도 없다 (생략) 부품이 없는 객체는 의미가 없기 때문이다.
-
상태 없는 객체는 존재해서는 안되고, 상태는 객체의 식별자여야 합니다.
-
왜 4개인가?
- 4개 이상의 요소로 구성된 좌표(구분값)를 이해하는 것은 너무나도 어렵습니다.
- 더 많은 객체(속성)가 필요하다면, 클래스를 더 작은 클래스들로 분해해야 합니다.
-
Java의 결함을 해결하기 위해==를 사용하지 말고 항상equals메서드를 오버라이드하기 바랍니다.
Object a = new Object();
Object b = new Object();
assert a.equals(b); // 결과는 실패. 구분자 없이 껍데기만 다른 것임에도 다르다는 것은 직관적인 객체의 정의와 다르다.
final class Cash {
private final int dollars;
private final int cents;
private final Currency currency;
Cash(int dollars, int cents, Currency currency) {
this.dollars = dollars;
this.cents = cents;
this.currency = currency;
}
}
- 위 객체는 3개를 캡슐화. 이 셋을 합친 것이
Cash의 상태이자 식별자. - 필드가 계속 늘어나면
Amount,Currency같은 작은 객체로 다시 묶을 수 있는지 확인. - 4개라는 숫자 자체에 과학적인 근거는 없고 저자의 경험 법칙. 핵심은 필드 수가 커질 때 객체의 책임도 섞였는지 의심하라는 뜻으로 보임.
2.2 최소한 뭔가는 캡슐화하세요
- 2.1에서는 너무 많이 캡슐화하지 말라고 했지만, 아무것도 캡슐화하지 않는 것도 문제.
- 상태가 없는 객체는 식별자 없이 행동만 가지고 있음. 사실상 정적 메서드나 유틸리티 클래스와 비슷하다.
final class Year {
int read() {
return (int) (
System.currentTimeMillis()
/ (1000L * 60 * 60 * 24 * 30 * 12)
) + 1970;
}
}
Year객체를 여러 개 만들어도 전부 똑같다. 구분할 상태가 없음.System.currentTimeMillis()라는 전역적인 정적 기능에 직접 의존한다.- 객체처럼 포장했을 뿐, 실제로는 함수에 가까움.
책에서 제안하는 형태.
interface Millis {
long value();
}
final class Year {
private final Millis millis;
Year(Millis millis) {
this.millis = millis;
}
int read() {
return (int) (
this.millis.value()
/ (1000L * 60 * 60 * 24 * 30 * 12)
) + 1970;
}
}
- 이제
Year는 자신이 사용할 시간 객체를 캡슐화한다. - 시스템 시간뿐 아니라 고정된 시간을 전달할 수도 있어서 테스트하기도 쉬워짐.
- 객체가 일을 하기 위해 필요한 대상은 생성자로 받고, 자신의 상태로 캡슐화하라는 이야기.
- 아무것도 캡슐화하지 않아도 되는 것은 저자의 표현으로는
Universe처럼 세상에 단 하나뿐인 객체 정도. 실용적인 경우는 거의 없다고 봄. - 결론: 0개도 안 되고 5개 이상도 안 된다? 객체는 최소 1개, 최대 4개 정도를 캡슐화하라는 저자의 경험 법칙.
2.3 항상 인터페이스를 사용하세요
- 객체들이 서로를 필요로 하기 때문에 결합된다
- 애플리케이션 전체를 유지보수 가능하도록 만들기 위해서는 최선을 다해서 객체를 분리해야 합니다.
- 기술적인 관점에서 객체 분리란 상호작용하는 다른 객체를 수정하지 않고도 해당 객체를 수정할 수 있도록 만든다는 것.
- 이를 위한 가장 훌륭한 도구 : 인터페이스
책의 저자는 모든 퍼블릭 메서드에 대한 인터페이스를 도입하라고 한다. 과연 이것이 적절한가?
interface Cash {
Cash plus(Cash other);
}
final class Dollars implements Cash {
private final int value;
Dollars(int value) {
this.value = value;
}
@Override
public Cash plus(Cash other) {
// 실제 계산은 생략
return this;
}
}
- 클라이언트는
Dollars가 아니라Cash계약에 의존. - 구현을 교체하거나 데코레이터를 끼워 넣어도 클라이언트는 영향을 덜 받음.
- 저자의 규칙은 더 강함. 인터페이스에서 선언되지 않은 public 메서드는 만들지 말 것.
- 구현체 하나뿐인 작은 내부 클래스까지 전부 인터페이스로 분리하면 파일과 타입만 늘 수 있음. 외부 계약 또는 교체 지점이 있는 곳부터 적용하는 편이 현실적일 듯.
2.4 메서드 이름을 신중하게 선택하세요
- 경험 법칙 :
Builder의 이름은 명사로,Manipulator(조정자)의 이름은 동사로 짓자.Builder: 뭔가를 만들고 새로운 객체를 반환하는 메서드- 항상 뭔가를 반환하는 특징이 있음(Void 같은 소리는 ….)
Manipulator: 객체로 추상화한 실세계 엔티티를 수정하는 메서드- 반환 타입은 항상
void, 이름은 항상 동사
- 반환 타입은 항상
- 뭔가를 조작한 후 반환하거나, 뭔가를 만드는 동시에 조작하는 메서드가 있어서는 안된다
2.4.1 빌더는 명사다
- 어떤 것을 반환하는 메서드의 이름을 동사로 짓는 것은 잘못이다.
- 이것은 OOP 라이브러리, SDK, API 등에서 반복되어 온 매우 전형적인 실수입니다.
- 객체는 자신의 의무를 수행하는 방법을 알고 있고 존중 받기를 원하는 살아있는 유기체입니다.
- (내가 이해한 예시)
- 카페에서 진상의 2가지 종류.
- 진상 1 : “아아 하나”
- 진상 2 : ““아이스 아메리카노 한 잔 주문할게요. 아, 그리고 옵션 조율이 좀 필요해요. 원두는 산미가 튀지 않으면서도 바디감이 무겁지 않은 중배전 로스팅으로 부탁드립니다. 너무 강하게 볶아서 쓴맛이 올라오는 건 질색이니까 1차 팝핑 직후와 2차 팝핑 사이의 스위트 스팟을 정확히 맞춰주세요. 품종은 블렌디드 말고 싱글 오리진으로 갈 건데, 에티오피아 예가체프 워시드 계열보다는 과테말라 안티가나 콜롬비아 수프리모 쪽으로 하되, 너무 묵직한 인도네시아 만델린 같은 품종은 배제해 주시고, 클린 컵이 뛰어난 워시드 프로세싱 원두로 준비해 주세요. 추출은 일반 에스프레소 머신으로 뽑지 마시고, 켈리바디 형태의 하리오 V60 드리퍼를 사용해서 핸드드립으로 내려주세요. 물 온도는 92도 정확히 맞춰서 인퓨전(뜸들이기)은 35초간 진행하고, 추출 수율은 19~20% 선에서 끊어주셔야 합니다. 채널링(Channeling) 생기지 않도록 바스켓 태핑 고르게 한 뒤에, 추출된 원액을 얼음이 담긴 서버에 곧바로 급랭시켜서 바디감과 향미 손실을 최소화해 주세요. 텀블러는 대장균 번식 막게 사전에 열탕 소독 해온 거니까 여기로 담아주시면 됩니다.””
- 메서드의 이름을 지을 때, 동사로 사용하는 것은
진상2의 행동과 같다.진상 2의 행동은 카페 근무자를커피 싸개(?) 로 지칭하는 것과 같다.
- 카페에서 진상의 2가지 종류.
2.4.2 조정자는 동사다
- 동사로 시작하는 것은 프로시저처럼 보일 수 있다. 그러나 중요한 차이점은 반환 결과.
- 요청이 무시될 수도 있다.
음악을 틀어 주세요vs음악을 틀고 현재의 볼륨 상태를 말해주세요.
2.4.3 빌더와 조정자 혼합하기
- 이것은 목적이 분명하지 않은 것이다. 개념을 명확히 해라.
2.4.4 Boolean 값을 반환하는 경우
- 이것은 값을 반환하기 때문에 빌더에 속하지만, 가독성 측면에서 이름은
형용사로 지어야 합니다.boolean empty()boolean readable()boolean negative()is는 중복이기 때문에 이름에는 포함시키지 않지만, 메서드를 읽을 때는 일시적으로 앞에 붙여 자연스럽게 들리도록 해야 합니다.(읽을 때는 붙이되, 쓸 때는 붙이지 마라)equals()와exists()는is를 붙이면 올바르지 않은 문장이 만들어지기 때문에equalTo와present가 옳다.
interface File {
// Builder: 값을 만들어 반환하므로 명사
String content();
// Manipulator: 객체가 대표하는 실세계 상태를 바꾸므로 동사 + void
void save(String content);
// Boolean Builder: 형용사
boolean empty();
}
int save()처럼 상태를 바꾸면서 결과까지 반환하면 빌더인지 조정자인지 애매해짐.- 저장 결과가 필요하다면
void save()와Status status()처럼 책임을 나누거나, 저장 작업 자체를 별도 객체로 모델링하는 방식 고려. - Java 관례의
isEmpty(),exists()와 충돌하는 부분이 많음. 이 규칙은 언어 생태계의 관례보다 저자의 문장 읽기 방식을 우선함.
2.5 퍼블릭 상수를 사용하지 마세요.
p.63
이 말은 퍼블릭 상수마다 계약의 의미를 캡슐화하는 새로운 클래스를 만들어야 한다는 것을 의미하는 것일까요? 맞습니다. 수백 개의 단순한 상수 문자열 대신 수백 개의 마이크로 클래스를 만들어야 한다는 것을 의미하는 것일까요? 그렇습니다.
- 하나의 파일을 읽는 입장에서 퍼블릭 상수를 사용하지 않고, 명확한 의미를 가진 마이크로 클래스가 더욱 좋다는 것은 동의함. 그러나 이 경우 전체 코드의 이해가 어려워서 유지 보수성을 낮추지는 않을까?
OOP에서 퍼블릭 상수를 절대로 사용해서는 안됩니다. 변명의 여지가 없습니다.Java,Ruby,Scala로 작성된 라이브러리들에 퍼블릭 상수가 넘쳐난다는 것은 불행한 일입니다.- 여러분이 작성하는 코드에 퍼블릭 상수를 사용해서는 안됩니다.
- 열거형과 퍼블릭 상수 사이에는 아무런 차이도 없기 때문에 열거형 역시 사용해서는 안됩니다.
2.5.1 결합도 증가
- 퍼블릭 상수는 여러 객체가 같은 데이터를 직접 공유하게 만든다.
- 이름만 공유하는 것 같지만, 실제로는 그 값의 형식과 의미까지 공유함.
final class Http {
public static final String POST = "POST";
}
Request request = new Request(Http.POST, "/users");
Request를 사용하는 코드가"POST"라는 문자열 표현까지 알아야 함.- 상수의 값이나 표현 방법이 바뀌면 상수를 참조하는 모든 곳이 영향을 받음.
- 저자는 값 대신 의미를 가진 마이크로 객체를 만들라고 함.
Request request = new PostRequest("/users");
POST가 내부에서 문자열인지 enum인지 클라이언트는 몰라도 됨.
2.5.2 응집도 저하
- 상수를 꺼내 쓰는 쪽으로 관련 로직이 흩어짐.
- 상수는 자신이 어느 문맥에서 사용 가능한지 통제하지 못함.
public static final String CRLF = "\r\n";
- 파일, HTTP, 이메일 등 전혀 다른 문맥에서 같은 상수를 가져다 쓸 수 있음.
- 줄바꿈을 어떻게 쓰는지에 관한 행동이 각 사용자에게 분산됨.
Line,Records,HttpHeaders같은 객체 안으로 의미와 행동을 같이 넣으면 응집도가 높아짐.- 저자는 enum도 퍼블릭 상수의 묶음일 뿐이라며 반대.
- 값 하나마다 클래스를 만들면 타입 수가 폭발할 수 있음. 그래도 여러 곳에서 의미와 로직이 반복되는 상수는 값 객체로 승격할 만함.
2.6 불변 객체로 만드세요.
- 저자는 모든 객체를 불변으로 만들라고 강하게 주장.
- 상태를 바꾸는 대신 기존 상태를 이용해 새 객체를 반환.
- 아래로 갈수록 저자가 더 중요하게 보는 장점.
- 한 클래스당 주석과 공백을 포함해 몇 줄이 최대일까? 책에서는 250줄을 제안.
2.6.1 식별자 가변성
- 객체를 컬렉션의 키로 넣은 뒤 상태가 변하면 식별자가 흔들림.
Map<Cash, String> names = new HashMap<>();
Cash five = new Cash(5);
names.put(five, "five");
five.multiply(2); // 이제 five는 $10
hashCode()계산에 사용한 값이 바뀌면HashMap에서 객체를 다시 찾지 못할 수도 있음.- 불변 객체라면 생성 이후 동일성과 해시값이 유지됨.
2.6.2 실패 원자성
- 가변 객체는 여러 필드를 바꾸는 중간에 예외가 발생하면 반쯤 변경된 상태가 남을 수 있음.
- 불변 객체는 계산이 모두 성공한 뒤 새 객체를 반환. 실패하면 기존 객체는 그대로.
Cash increased = cash.plus(new Cash(5));
plus()가 실패해도cash는 손상되지 않음.
2.6.3 시간적 결합
- setter 기반 객체는 메서드를 특정 순서로 불러야 정상 상태가 됨.
User user = new User();
user.setName("Kim");
user.setEmail("kim@example.com");
setEmail()을 빼먹거나 순서를 바꾸면 아직 완성되지 않은 객체가 돌아다닐 수 있음.- 생성자에서 완전한 상태를 받고 불변으로 만들면 호출 순서에 대한 의존이 사라짐.
2.6.4 부수 효과 제거
- 가변 메서드는 호출한 객체를 몰래 바꿀 수 있어서 결과를 추적하기 어려움.
Cash ten = five.multiply(2); // five는 그대로, ten이 새 객체
- 입력 객체를 바꾸지 않고 새 객체를 반환하면 호출 전후의 상태를 머릿속으로 추적할 일이 줄어듦.
- 여러 곳이 같은 객체를 참조해도 한쪽의 호출이 다른 쪽에 영향을 주지 않음.
2.6.5 NULL 참조 제거
- 가변 객체는 “아직 설정되지 않음”을 표현하려고 필드에
null을 두기 쉬움. - 불변 객체는 생성할 때 필요한 의존성을 전부 받으므로 미완성 상태가 필요하지 않음.
final class User {
private final Name name;
User(Name name) {
this.name = name;
}
}
- 이름이 선택 사항이라면
null대신AnonymousName같은 객체로 표현하라는 방향.
2.6.6 스레드 안전성
- 상태가 바뀌지 않으면 여러 스레드가 동시에 읽어도 변경 경쟁이 없음.
- 락이나
synchronized없이 공유하기 쉬움. - 단, 내부에 가변 객체를 숨겨두거나 외부 자원이 바뀌면 자동으로 안전해지는 것은 아님.
2.6.7 더 작고 단순한 객체
- 상태 변경을 책임지는 setter, 검증, 동기화 코드가 사라짐.
- 각 메서드는 현재 상태를 바꾸는 대신 새 객체를 조립하면 됨.
- 객체가 작아지고 행동을 예측하기 쉬워짐.
- 저자에게 불변이란 메서드 결과가 매번 같음이 아니라 객체가 캡슐화한 대상이 바뀌지 않음에 가까움.
WebPage가 같은 URL 객체를 계속 캡슐화하면서content()호출 때마다 최신 내용을 반환해도 불변 객체로 봄.
- 불변 객체 안에 변화하는 외부 대상을 캡슐화할 수 있다는 설명이 일반적인 값 객체의 불변성과 달라 헷갈릴 수 있음.
2.7 문서를 작성하는 대신 테스트를 만드세요.
- 이상적인 코드는 문서 없이도 읽혀야 한다.
- 코드가 너무 복잡해서 긴 설명이 필요하다면 문서를 쓰기 전에 설계를 단순하게 만들 것.
- 그래도 사용법에 대한 설명은 필요함. 저자는 그 역할을 단위 테스트가 맡아야 한다고 주장.
- 테스트를 보면 입력, 사용 순서, 결과, 예외 상황을 실제로 확인할 수 있다.
- 문서와 달리 코드가 바뀌었을 때 테스트가 깨지므로 잘못된 설명을 방치하기도 어려움.
주석으로 사용법을 설명하는 경우.
/**
* 두 금액을 더한다.
* 예: $5 + $3 = $8
* 음수 금액도 더할 수 있다.
*/
Cash plus(Cash other) {
// ...
}
테스트로 사용법을 보여주는 경우.
final class CashTest {
@Test
void summarizes() {
assertEquals(
new Cash("$8"),
new Cash("$5").plus(new Cash("$3"))
);
}
@Test
void deductsWithNegativeCash() {
assertEquals(
new Cash("-$4"),
new Cash("$7").plus(new Cash("-$11"))
);
}
}
- 테스트 이름도 설명의 일부.
testPlus()보다summarizes(),deductsWithNegativeCash()가 어떤 사례인지 더 잘 보임. - 테스트 코드를 본 코드와 같은 수준으로 관리해야 함. 테스트가 난잡하면 문서 역할도 못 한다.
- 테스트 하나가 문서 한 페이지보다 낫다는 것이 저자의 주장.
- 동작 예시는 테스트가 더 좋다는 데 동의. 하지만 설계 이유, 제약의 배경, 공개 API 계약까지 테스트만으로 전달하기는 어려워 보임. 문서를 완전히 없애기보다는 중복 설명을 줄이라는 의미로 받아들이는 편이 현실적일 듯.
2.8 모의 객체(Mock) 대신 페이크 객체(Fake)를 사용하세요
- Mock은 테스트 안에서 즉석으로 행동을 설정하고 호출 여부를 검증하는 객체.
- Fake는 인터페이스를 실제로 구현한 단순한 대체품.
- 저자는 라이브러리 사용자가 직접 Mock을 만들게 하지 말고, 인터페이스 제공자가 Fake도 같이 제공해야 한다고 주장.
Mock을 사용하는 경우.
Exchange exchange = mock(Exchange.class);
when(exchange.rate("USD", "KRW")).thenReturn(1300.0);
Cash converted = new Cash(10, "USD").in("KRW", exchange);
verify(exchange).rate("USD", "KRW");
- 테스트가
Exchange를 어떻게 호출하는지까지 알아야 함. - 내부 구현이 바뀌면 결과는 같아도
verify()때문에 테스트가 깨질 수 있음.
Fake를 제공하는 경우.
interface Exchange {
double rate(String source, String target);
final class Fake implements Exchange {
private final double value;
Fake(double value) {
this.value = value;
}
@Override
public double rate(String source, String target) {
return this.value;
}
}
}
Exchange exchange = new Exchange.Fake(1300.0);
Cash converted = new Cash(10, "USD").in("KRW", exchange);
- 실제 인터페이스 계약을 따르는 평범한 객체라서 리팩터링에 비교적 강함.
- 여러 테스트에서 재사용 가능하고, API 사용자가 테스트용 구현을 매번 고민하지 않아도 됨.
- 저자는 Fake를 제품 코드와 함께 배포하는 것을 API 품질의 일부로 봄.
- 고정값만 반환하는 Fake는 간단하지만, 실제 구현의 중요한 제약까지 흉내 내지 못하면 거짓 안정감을 줄 수 있음. Mock과 Fake 모두 목적에 따라 선택할 필요가 있어 보임.
2.9 인터페이스를 짧게 유지하고 스마트를 사용하세요.
- 인터페이스는 구현자가 반드시 제공해야 하는 최소 기능만 가질 것.
- 편의 기능까지 인터페이스에 넣으면 모든 구현체가 같은 보조 코드를 반복하게 됨.
- 저자는 최소 인터페이스 위에 공통 편의 기능을 제공하는 객체를
Smart라고 부름.
interface Exchange {
double rate(String source, String target);
abstract class Smart implements Exchange {
double usdTo(String target) {
return this.rate("USD", target);
}
Cash convert(Cash cash, String target) {
return cash.exchange(
target,
this.rate(cash.currency(), target)
);
}
}
}
- 실제 구현체는 핵심인
rate()만 구현. usdTo()나convert()같은 편의 기능은Smart에 한 번만 작성.- 인터페이스를 확장하지 않고도 클라이언트가 더 풍부한 기능을 사용 가능.
- Java 8 이후의
default메서드와 비슷해 보이지만, 저자는 기능을 별도 객체/추상 클래스에 두는 방식을 사용. Smart가 커지면 결국 비대한 추상 기반 클래스가 될 수 있음. 인터페이스를 짧게 유지하려던 목적을 잊지 말아야 함.
3장 취업
3.1 5개 이하의 public 메서드만 노출하세요
- 20개의 퍼블릭 메서드를 가진 클래스(50줄) 보다 하나의 퍼블릭 메서드를 가진 클래스가 더 작다(200줄)
- 5개는 저자의 의견
- 클래스가 작을수록 실수할 가능성이 줄어든다
- 더 작을 수록 유지보수 하기도 좋다 -> 더 작기 때문.
- 메서드를 5개 이하로 유지하세요
- 클래스의 크기는 코드 줄 수보다 외부에 노출된 행동의 수로 판단.
- public 메서드가 많다는 것은 객체가 여러 역할을 맡고 있을 가능성이 큼.
interface Cash {
Cash plus(Cash other);
Cash minus(Cash other);
Cash multiply(int factor);
}
- 이 정도 계약도 커지기 시작하면 계산 종류별 객체나 데코레이터로 분리할 수 있는지 확인.
- 5개는 절대 기준이 아니라 저자가 정한 경고선. 단순 DTO나 프레임워크 구현체에는 그대로 적용하기 어려울 수 있음.
3.2 정적 메서드를 사용하지 마세요
OOP에 정적 메서드를 도입한 결정이Null을 도입한 것 이상으로 커다란 실수임을 깨달았다.OOP에static를 도입한 사람이 누구인지는 알 수 없지만 이들은 순수한 악입니다.(p.99)- 문맥과 상관없이 정적 메서드를 사용하고 있는지 여부는
OOP를 제대로 이해하지 못한 형편없는 프로그래머를 구별하기 위해 사용할 수 있는 최적의 지표입니다. - 성능? 중요한 지표가 아니다.
- 정적 메서드는 객체가 아니라 전역 프로시저에 메시지를 보내는 형태.
- 구현을 교체하거나 장식할 수 없고, 호출하는 쪽에 의존성이 하드코딩됨.
int total = Math.max(left, right);
저자가 선호하는 방향.
Number total = new Max(left, right);
int value = total.intValue();
- 계산을 객체로 만들면 생성자에서 조립하고, 다른 구현으로 교체하거나 데코레이터로 확장 가능.
3.2.1 객체 vs 컴퓨터 사고
- 컴퓨터적 사고(절차지향) : 명령의 흐름을 제어할 책임이 우리에게.
- 객체지향적 : 우리는 그저 누가 누구인지만 정의하고 객체들이 필요할 때 스스로 상호작용하도록 제어를 위임.
// 컴퓨터 사고: 우리가 계산 순서를 직접 지시
int rate = exchange.rate("USD", "KRW");
int converted = cash * rate;
int rounded = Math.round(converted);
// 객체 사고: 어떤 객체들이 협력할지만 조립
Cash converted = new Rounded(
new Converted(cash, exchange, "KRW")
);
- 객체에게 데이터를 꺼내 계산하지 말고, 계산을 수행할 객체를 조합.
3.2.2 선언형 스타일 대 명령형 스타일
-
명령형 : 프로그램의 상태를 변경하는 문장을 사용해서 계산 방식을 서술
-
선언형 : 제어 흐름을 서술하지 않고 계산 로직을 표현
-
실행 관점에서 선언형 방식이 더 최적화되기 때문에 빠르다.
-
객체를 다른 모든 객체로부터 완전히 분리하기 위해서는 어떤 메서드나 주 생성자 안에서도
new연산자를 사용해서는 안된다.- -> 선언형 프로그래밍을 이용하면 객체 사이의 결합도를 낮출 수 있다.
-
표현력
- 선언형 방식은 결과를 이야기 하지만, 명령형 방식은 수행 가능한 한 가지 방법을 이야기 한다.
- 명령형은 머리 속에서 실행을 해봐야 함.
- 선언형 방식은 결과를 이야기 하지만, 명령형 방식은 수행 가능한 한 가지 방법을 이야기 한다.
-
응집도
// 명령형: 어떻게 할지 단계별로 서술
List<String> result = new ArrayList<>();
for (String name : names) {
if (!result.contains(name)) {
result.add(name.toUpperCase());
}
}
Collections.sort(result);
// 선언형: 무엇인지 객체 조합으로 표현
Names result = new Sorted(
new Unique(
new UpperCased(names)
)
);
- 두 번째 코드는 아직 계산하지 않고 작업의 구조만 선언할 수 있음. 실제 계산은 결과가 필요할 때 수행.
- 각 단계가 독립 객체라 테스트와 재조합이 쉬움.
- 항상 더 빠르다는 주장은 실행 환경과 최적화 방식에 따라 다름. 장점의 핵심은 성능보다 표현력과 결합도에 있어 보임.
3.2.3 유틸리티 클래스
- 유틸리티 클래스는 어떤 것의 팩토리가 아니기 때문에 진짜 클래스라고 부를 수 없다.
유틸리티 클래스는 절차적인 프로그래머들이 객체지향이라는 영토에서 거둔 승리의 상징입니다. 유틸리티 클래스는 정적 메서드처럼 단순히 나쁜 요소가 아닙니다. 나쁜 요소들만 모아 놓은 집합체입니다. {생략} 유틸리티 클래스는 끔찍한 안티 패턴입니다.
final class TextUtils {
static String capitalize(String text) {
return text.substring(0, 1).toUpperCase() + text.substring(1);
}
}
TextUtils는 상태도 식별자도 없이 알고리즘만 모아 놓은 장소.- 저자는
new Capitalized(text)처럼 행위를 객체로 모델링하라고 함. - 객체가 되면 동일한 인터페이스를 구현하는 다른 표현과 조합 가능.
3.2.4 싱글톤 패턴
- 싱글톤은 분리 가능한 의존성으로 연결되어 있는데 반해, 유틸리티 클래스는 분리가 불가능한 하드코딩된 결합도를 가진다.
- 싱글톤과 유틸리티 클래스의 차이는 상태 유지 여부가 아님.
- 핵심은 싱글톤은 객체이므로 인터페이스 뒤에 놓고 생성자로 전달할 수 있다는 점.
interface Clock {
long millis();
}
final class SystemClock implements Clock {
static final Clock INSTANCE = new SystemClock();
@Override
public long millis() {
return System.currentTimeMillis();
}
}
Clock을 주입받는 객체는 테스트에서 다른 구현을 받을 수 있음.System.currentTimeMillis()를 직접 호출하면 대체 불가능.- 저자는 싱글톤 자체를 추천한다기보다 유틸리티 클래스보다는 분리 가능하다는 차이를 설명.
3.2.5 함수형 프로그래밍
- 함수형 프로그래밍의 함수와 정적 메서드는 겉모습이 비슷하지만 문맥이 다름.
- 순수 함수는 같은 입력에 같은 결과를 반환하고 부수 효과가 없음.
- 저자는 함수형 프로그래밍의 불변성, 지연 계산, 조합성에는 동의하지만 객체가 함수보다 더 풍부한 추상화라고 봄.
- 객체는 데이터와 행동뿐 아니라 정체성, 생명주기, 캡슐화된 의존성을 가질 수 있음.
Function<Integer, Integer> doubled = value -> value * 2;
int result = doubled.apply(5);
객체로 표현하면:
Number result = new Multiplied(new IntegerNumber(5), 2);
- 함수는 입력을 받아 계산하고, 객체는 자신이 무엇인지 표현한 뒤 요청받을 때 행동.
- 현대 코드는 함수와 객체를 함께 사용하는 경우가 많음. 둘 중 하나를 배척하기보다 부수 효과 없는 계산은 함수로 두는 것도 충분히 실용적.
3.2.6 조합 가능한 데코레이터
- 정적 메서드 대신 작은 객체를 사용하면 데코레이터를 겹쳐 기능을 조합할 수 있음.
- 상속으로 거대한 계층을 만들지 않고 같은 인터페이스의 객체를 감싼다.
interface Text {
String content();
}
final class PlainText implements Text {
private final String value;
PlainText(String value) {
this.value = value;
}
@Override
public String content() {
return this.value;
}
}
final class Trimmed implements Text {
private final Text origin;
Trimmed(Text origin) {
this.origin = origin;
}
@Override
public String content() {
return this.origin.content().trim();
}
}
final class UpperCased implements Text {
private final Text origin;
UpperCased(Text origin) {
this.origin = origin;
}
@Override
public String content() {
return this.origin.content().toUpperCase();
}
}
Text text = new UpperCased(new Trimmed(new PlainText(" hello ")));
- 각 객체는 한 가지 일만 하고 순서를 바꿔 재조합 가능.
- 이 조합성이 정적 유틸리티 메서드보다 객체를 택해야 하는 중요한 이유.
3.3 인자의 값으로 NULL을 절대 허용하지 마세요
null은 객체가 아니므로 메시지를 받을 수 없음.- 인자에
null을 허용하면 메서드 안에 숨은 분기가 생김.
Iterable<File> find(String mask) {
if (mask == null) {
return this.all();
}
return this.matching(mask);
}
null이 “모든 파일”이라는 의미라는 사실을 호출자가 알아야 함.- 타입만 보고는 알 수 없는 별도 프로토콜이 생김.
객체로 의도를 표현.
interface Mask {
boolean matches(File file);
}
final class AnyFile implements Mask {
@Override
public boolean matches(File file) {
return true;
}
}
Iterable<File> find(Mask mask) {
// mask와 일치하는 파일만 반환
}
files.find(new AnyFile());
- “없음”이나 특별한 의미도
null이 아니라 별도 객체로 전달. null이 들어왔는지 방어적으로 검사하는 코드도 넣지 말라는 것이 저자의 입장. 잘못 전달하면 자연스럽게NullPointerException이 발생하게 둠.- 공개 API라면 표준 NPE보다 경계에서 명확한 검증 메시지를 주는 편이 나을 수도 있음. 핵심은 null을 정상 입력으로 해석하지 않는 것.
3.4 충성스러우면서 불변이거나, 아니면 상수이거나
- 세 개념을 구분.
- 불변 객체: 자신이 캡슐화한 객체를 다른 객체로 교체하지 않음.
- 충성스러운 객체: 평생 같은 실세계 엔티티를 대표함.
- 상수 객체: 메서드를 몇 번 호출해도 같은 결과를 반환.
- 모든 상수 객체는 불변이지만, 모든 불변 객체가 상수인 것은 아님.
final class WebPage {
private final URI uri;
WebPage(URI uri) {
this.uri = uri;
}
String content() {
// 같은 URI의 현재 내용을 읽음
}
}
uri는 바뀌지 않으므로 불변.- 항상 같은 웹 페이지를 대표하므로 충성스러움.
- 웹 페이지 내용은 바뀔 수 있으므로
content()결과는 상수가 아님.
final class ConstantText {
private final String value;
ConstantText(String value) {
this.value = value;
}
String content() {
return this.value;
}
}
- 이 객체는 불변이면서 상수.
- 저자의 결론: 객체는 최소한 충성스럽고 불변이어야 하며, 가능하면 상수로 만들 것.
- 일반적으로 말하는 불변성과 저자의 정의가 다소 다름. 외부 세계가 변해 반환값이 달라져도 캡슐화한 식별자가 같으면 불변으로 봄.
3.5 절대 getter와 setter를 사용하지 마세요
- getter/setter는 private 필드를 public 메서드로 우회해서 노출하는 것에 불과함.
- 객체에게 일을 시키는 대신 데이터를 꺼내 외부에서 처리하게 만듦.
- setter는 객체의 불변성도 깨뜨림.
Cash cash = new Cash();
cash.setDollars(cash.getDollars() + 5);
Cash increased = cash.plus(new Cash(5));
- 두 번째는 내부 표현을 모르고
Cash에게 계산을 요청.
3.5.1 객체 대 자료구조
- 자료구조는 데이터를 공개하고 외부 알고리즘이 조작하게 함.
- 객체는 데이터를 숨기고 행동을 공개.
// 자료구조: 투명한 상자
final class CashData {
public int dollars;
}
// 객체: 불투명한 상자
interface Cash {
Cash plus(Cash other);
void print(Output output);
}
- 자료구조는 수동적이고 객체는 능동적이라는 것이 저자의 구분.
- 객체 내부에
dollars라는 필드가 실제로 존재하는지 클라이언트는 몰라야 함.
3.5.2 좋은 의도, 나쁜 결과
- 필드를 public으로 두는 것이 부끄러워 private으로 숨기고 getter/setter를 자동 생성함.
- 겉으로는 캡슐화처럼 보이지만 클라이언트는 여전히 필드 목록과 내부 표현에 의존.
- getter에 검증이나 계산을 넣어도 메서드의 의미가 “내부 데이터 접근”이면 자료구조와 크게 다르지 않음.
- IDE가 자동 생성해준다는 이유로 객체의 모든 필드에 접근자를 만들지 말 것.
3.5.3 접두사에 관한 모든 것
- 저자는 특히
get과set접두사가 문제라고 봄. getDollars()는 객체 안에dollars라는 데이터가 저장돼 있다고 가정.dollars()는 “몇 달러인가?”라고 객체에게 묻는 메시지라 내부 구현을 덜 노출한다는 주장.
int dollars(); // 허용
int getDollars(); // 금지
void setDollars(int value); // 금지
- 값을 반환하는 메서드 자체를 전부 금지하는 것은 아님. 데이터 접근처럼 이름 짓지 말라는 것.
- 접두사만 제거한다고 데이터 중심 설계가 행동 중심 설계로 바뀌는 것은 아님.
dollars()도 외부에서 계산 재료로만 쓰인다면 사실상 getter일 수 있음.
3.6 부 ctor 밖에서는 new를 사용하지 마세요
new는 구체 클래스에 대한 결합을 만든다.- 주 생성자와 메서드 안에서 다른 객체를 직접 만들면 의존성을 교체하기 어려움.
- 객체 조립은 부 생성자에서만 하고, 주 생성자는 완성된 의존성을 전달받음.
final class Report {
private final Storage storage;
// 부 생성자: 기본 조립
Report(String directory) {
this(new FileStorage(directory));
}
// 주 생성자: 의존성을 그대로 받음
Report(Storage storage) {
this.storage = storage;
}
void save(Content content) {
this.storage.save(content);
}
}
- 테스트에서는
new Report(new MemoryStorage())처럼 교체 가능. - 메서드 안에서
new FileStorage(...)를 호출했다면 테스트도 실제 파일 시스템에 묶임. - 애플리케이션 최상위에서도 부 생성자들을 호출해 객체 그래프를 조립하고, 마지막에 한 번 실행.
- 지역적인 값 객체까지 모두 생성자 밖에서 만들지 말라는 규칙은 코드가 지나치게 우회적이 될 수 있음. 핵심 의존성의 생성을 분리하는 원칙으로 보면 이해하기 쉬움.
3.7 인트로스펙션과 캐스팅을 피하세요
instanceof, 리플렉션, 강제 캐스팅은 객체의 실제 타입을 밖에서 조사하는 행위를 피해라- 인터페이스의 계약보다 구현 타입에 의존하게 됨.
- 새 구현을 추가할 때 타입 분기 코드도 계속 수정해야 함.
int size(Iterable<?> items) {
if (items instanceof Collection<?>) {
return ((Collection<?>) items).size();
}
int size = 0;
for (Object ignored : items) {
size += 1;
}
return size;
}
Iterable을 받는다고 해놓고 내부에서는Collection인지 캐묻고 있음.- 타입별 행동이 다르면 오버로딩이나 다형성으로 명시.
int size(Collection<?> items) {
return items.size();
}
int size(Iterable<?> items) {
int size = 0;
for (Object ignored : items) {
size += 1;
}
return size;
}
- 더 나은 방향은 크기를 아는 객체가 스스로 답하도록 공통 계약을 설계하는 것.
- 프레임워크 경계, 직렬화, 레거시 API처럼 런타임 타입 정보가 필요한 영역은 존재함. 도메인 로직의 타입 분기를 다형성으로 옮기라는 경고로 받아들이면 적절.
4장 은퇴
4.1 절대 NULL을 반환하지 마세요
- 객체에게 작업을 요청했으면 결과도 객체여야 함.
null을 반환하면 호출자는 결과를 믿지 못하고 매번 검사해야 함.
String title = document.title();
if (title == null) {
return;
}
System.out.println(title.length());
title()의 계약만으로 정상적인 객체가 오는지 알 수 없음.null검사가 호출자 전체로 퍼지고, 빠뜨린 곳에서는 늦게NullPointerException발생.- 저자의 결론은 단순함. 인자로도 받지 말고, 필드에도 두지 말고, 반환도 하지 말 것.
4.1.1 빠르게 실패하기 vs. 안전하게 실패하기
- 안전하게 실패하기(fail safe): 오류가 생겨도 가능한 한 계속 실행.
null, 기본값, 빈 문자열 등으로 문제를 숨기기 쉬움. - 빠르게 실패하기(fail fast): 문제를 발견한 즉시 예외를 던져 실행을 멈춤.
- 저자는 빠르게 실패하기를 선택.
User user(String name) throws UserNotFoundException {
User found = this.database.find(name);
if (found == null) {
throw new UserNotFoundException(name);
}
return found;
}
- 실패 지점과 원인이 가까워지고 테스트에서 재현하기 쉬움.
null을 멀리 전달한 뒤 엉뚱한 곳에서 실패하는 것보다 디버깅하기 쉬움.- 장애가 나도 계속 동작해야 하는 시스템에서는 fail safe가 필요한 경계도 있음. 다만 내부 오류를 조용히 숨기는 것과 의도적인 복구 정책은 구분해야 함.
4.1.2 NULL의 대안
- 책에서 제시하는 대표적인 대안은 세 가지.
- 찾지 못한 상황이 예외라면 예외를 던짐.
User user(String name) throws UserNotFoundException;
- 0개 또는 1개의 컬렉션을 반환.
Collection<User> users(String name) {
if (this.exists(name)) {
return Collections.singleton(this.user(name));
}
return Collections.emptyList();
}
- 널 객체(null object)를 반환.
interface User {
String name();
void raise(Cash salary);
}
final class AnonymousUser implements User {
private final String label;
AnonymousUser(String label) {
this.label = label;
}
@Override
public String name() {
return this.label;
}
@Override
public void raise(Cash salary) {
throw new IllegalStateException("익명 사용자의 급여를 올릴 수 없음");
}
}
- 호출자는 반환값이 객체라는 사실을 믿고 메시지를 보낼 수 있음.
- 책은 Java의
Optional도 언급하지만 컬렉션과 의미가 겹치고 객체 설계를 미룰 수 있다는 이유로 적극 추천하지 않음. - 현대 Java에서는 0개 또는 1개라는 계약을 명시하는
Optional<T>가 컬렉션보다 의도를 잘 드러내는 경우도 많음.
4.2 체크 예외(checked exception)만 던지세요
- 체크 예외는 메서드 시그니처에 실패 가능성을 드러내고, 호출자가 처리하거나 다시 선언하도록 컴파일러가 강제.
- 언체크 예외는 계약에 보이지 않아서 어떤 실패가 발생할지 호출자가 알기 어려움.
- 저자는 모든 예외를 체크 예외로 만들라고 주장.
byte[] content(File file) throws IOException {
return Files.readAllBytes(file.toPath());
}
throws IOException까지 메서드 계약의 일부.- 실패를 정상 반환값처럼 숨기지 않고 명시적으로 전파.
- 현대 Java에서는 복구 가능한 예외만 체크 예외로 두고, 프로그래밍 오류는 언체크 예외로 두는 관례가 일반적. 모든 예외를 체크로 만들면
throws와 래핑 코드가 과해질 수 있음.
4.2.1 꼭 필요한 경우가 아니라면 예외를 잡지 마세요
- 처리할 방법이 없다면
catch하지 말고 위로 전달. - 로그만 남기고 다시 던지면 상위 계층에서도 같은 오류를 기록해 로그가 중복될 수 있음.
// 나쁜 예시: 복구하지도 못하면서 실패를 숨김
try {
return this.storage.load(key);
} catch (IOException err) {
return new byte[0];
}
// 호출자가 처리하도록 그대로 전파
byte[] load(String key) throws IOException {
return this.storage.load(key);
}
- 예외를 잡는 이유는 의미 있는 문맥을 더해 다시 던지거나, 실제로 복구할 수 있을 때뿐.
4.2.2 항상 예외를 체이닝하세요
- 낮은 수준의 예외를 그대로 노출하면 현재 추상화의 문맥이 없음.
- 새 예외로 감싸되 원래 예외를
cause로 보존.
Content content() throws ContentException {
try {
return this.storage.load(this.key);
} catch (IOException err) {
throw new ContentException(
"문서를 읽을 수 없음: " + this.key,
err
);
}
}
- 체인의 바깥쪽에는 비즈니스 문맥, 안쪽에는 실제 기술 원인이 남음.
- 원인 예외를 버리고 메시지만 복사하면 스택 트레이스와 세부 원인을 잃어버림.
- 모든 계층은 자신이 알고 있는 새로운 정보를 조금씩 추가.
4.2.3 단 한 번만 복구하세요
- 예외를 여러 계층에서 조금씩 처리하면 흐름을 예측하기 어려움.
- 중간 객체들은 문맥을 추가해 전파하고, 최상위 경계에서 한 번만 사용자 응답이나 재시도 여부를 결정.
public static void main(String[] args) {
try {
new App(args).run();
} catch (Exception err) {
System.err.println(err.getMessage());
}
}
- 라이브러리 깊숙한 곳에서 임의로 재시도하거나 기본값으로 바꾸지 않음.
- 복구 정책이 한곳에 모이므로 변경과 테스트가 쉬움.
- 계층별로 처리 가능한 실패가 분명히 다르다면 무조건 최상위 한곳만 고집할 필요는 없어 보임. “처리할 수 있는 책임이 있는 곳에서만 잡는다”에 가까운 원칙.
4.2.4 관점 지향 프로그래밍을 사용하세요
- 재시도, 로깅처럼 여러 메서드에 반복되는 예외 정책을 본문에 섞지 말라는 내용.
- AOP의 어드바이스로 메서드 호출을 감싸면 핵심 객체는 실패를 전파하는 일에만 집중 가능.
@RetryOnFailure(attempts = 3)
Content content() throws IOException {
return this.remote.load();
}
- 실제 재시도 루프는 별도의 aspect가 담당.
- 저자는 AOP를 전반적으로 권장하려는 것이 아니라 “중간에서 성급히 복구하지 않아도 횡단 관심사로 분리할 수 있다”는 예로 사용.
- AOP는 실행 흐름을 코드에서 숨겨 디버깅을 어렵게 만들 수 있음. 명시적인
RetryingStorage데코레이터도 대안.
4.2.5 하나의 예외 타입만으로도 충분합니다
- 예외를 제어 흐름 분기에 사용하지 않고, 최상위에서 한 번만 복구하며, 매번 체이닝한다면 세부 타입으로
catch할 필요가 줄어듦. - 그래서 저자는 애플리케이션에 예외 타입 하나만 있어도 충분하다고 주장.
final class Failure extends Exception {
Failure(String message, Throwable cause) {
super(message, cause);
}
}
- 타입보다 체인에 쌓인 메시지와 원인이 중요하다는 입장.
UserNotFoundException,InvalidOrderException,StorageException같은 타입을 계속 만들지 않음.- 호출자가 실패 종류별로 다른 행동을 해야 한다면 타입 또는 오류 코드가 필요함. 예외를 절대로 분기에 사용하지 않는다는 전제에 동의할 때만 성립하는 급진적인 주장.
4.3 final이거나 abstract이거나
- 구체 클래스는
final로 닫고, 상속을 허용할 클래스는 명시적으로abstract로 설계. - 평범한 구체 클래스를 무심코 상속하게 두지 말 것.
- 부모가 상속을 고려하지 않았다면 자식이 어떤 메서드를 재정의해도 안전한지 계약이 없음.
final class FileStorage implements Storage {
// 완성된 구현: 상속 금지
}
abstract class StorageEnvelope implements Storage {
private final Storage origin;
StorageEnvelope(Storage origin) {
this.origin = origin;
}
@Override
public Content load(Key key) throws IOException {
return this.origin.load(key);
}
}
- 확장은 구현 상속보다 인터페이스와 데코레이터 조합을 우선.
abstract클래스는 미완성된 행동을 자식이 정제하도록 의도적으로 만든 경우에만 사용.- 프레임워크 프록시처럼 구체 클래스 상속을 요구하는 도구와 충돌할 수 있음. 그래도 기본적으로 닫고 필요한 확장점만 여는 원칙은 안전함.
4.4 RAII를 사용하세요
- RAII: Resource Acquisition Is Initialization. 객체의 생명주기에 파일, 소켓, DB 연결 같은 자원의 생명주기를 묶는 방식.
- C++에서는 생성자가 자원을 얻고 소멸자가 자동으로 해제.
- Java는 객체가 언제 가비지 컬렉션될지 알 수 없어서 소멸자에 맡길 수 없음.
- Java에서는
AutoCloseable과try-with-resources가 비슷한 역할.
final class TextFile implements AutoCloseable {
private final BufferedReader reader;
TextFile(Path path) throws IOException {
this.reader = Files.newBufferedReader(path);
}
String content() throws IOException {
return this.reader.readLine();
}
@Override
public void close() throws IOException {
this.reader.close();
}
}
try (TextFile text = new TextFile(Path.of("note.txt"))) {
System.out.println(text.content());
}
- 블록을 벗어나면 정상 종료와 예외 발생 여부에 상관없이
close()호출. - 획득과 해제 규칙이 객체 안에 모이고, 호출자가 해제를 빼먹을 가능성이 줄어듦.
- 파일, 스트림, 락, 트랜잭션처럼 반드시 정리해야 하는 자원에 사용.
이 글에 대해 이야기해요
질문이나 다른 관점을 남겨 주세요.