티스토리 뷰

 

지난 1장 포스트에 이어 2장에 대한 정리를 하려고 한다.

이번 장의 원제는 'Education'이고, 번역본 상으로는 '학교생활'이다.

 

학교에 들어가 가장 중요하게 배우는 게 무엇인가?

바로 사회생활이다.

이번 장에서는 사회생활 잘하는 객체에 관한 내용을 다룬다.

소통에 용이한 객체를 만들기 위해 지켜야 할 원칙에는 무엇이 있는지 알아보자.

 

목차는 다음과 같다.

1. 가능하면 적게 캡슐화하세요
2. 최소한 뭔가는 캡슐화하세요
3. 항상 인터페이스를 사용하세요
4. 메서드 이름을 신중하게 선택하세요
5. 퍼블릭 상수를 사용하지 마세요
6. 불변 객체로 만드세요
7. 문서화 대신 테스트를 작성하세요
8. 모의 객체(Mock) 대신 페이크 객체(Fake)를 사용하세요
9. 인터페이스를 짧게 유지하고 스마트(smart)를 사용하세요

(작성 편의를 위해 개인적 동의 여부와 상관없이 단정적 어조를 사용했다)

 

1. 가능하면 적게 캡슐화하세요

제목만 봐도 직관적으로 이해할 수 있듯, 객체가 캡슐화하는 객체의 수를 적게 유지해야 한다.

그리고 구체적으로, 하나의 객체는 4개 이하의 객체만을 캡슐화해야 한다.

(아, 4개라는 숫자에 과학적 근거가 있는 건 아니고 저자의 개인적 경험에 기반한 숫자이다.)

 

OOP에서의 '객체'는 고수준의 행동을 창조하기 위해 함께 동작하는 객체들의 집합체(aggregation)이다.

예를 들어, 책은 페이지, 표지, ISBN의 집합체이고, 책장은 책과 제목의 집합체이고, 차고는 자동차와 주소의 집합체이다.

 

그리고 객체가 캡슐화하는 객체 전체는 객체의 식별자이다.

이를 다르게 풀어 설명하면, 어떤 두 객체가 캡슐화하고 있는 객체 전체가 같다면 둘은 같은 객체라는 것이다.

// Java
Cash x = new Cash(29, 95, "USD");
Cash y = new Cash(29, 95, "USD");

assert x.euquals(y); // false이지만 true여야 한다고 주장
assert x == y; // false이지만 true여야 한다고 주장

따라서 이 예시에서 x와 y의 비교 결과가 모두 true로 나와야 한다.

같은 객체들을 캡슐화하고 있음에도 서로 다르다는 결과가 나타나는 건 언어의 설계적 결함일 뿐이다.

 

객체의 식별자는 세계 안에서 객체가 위치하는 좌표이다.

그 객체가 캡슐화하는 객체들은 해당 객체를 특정할 수 있게 해준다.

 

그런데, 그 좌표를 구성하는 요소의 수가 4개를 넘어가는 것은 우리의 직관에 위배된다.

만약 하나의 차가 차체, 왼쪽 문1, 왼쪽 문2, 오른쪽 문1, 오른쪽 문2, 엔진, 바퀴1, 바퀴2, 바퀴 3 등등 수많은 요소로 이루어졌다고 생각해보자.

이를 직관적으로 쉽게 파악할 수 있을까?

그렇지 않다.

이것이 4개 이하의 객체만을 캡슐화해야 하는 이유이다.

 

[Q&A]

Q. 캡슐화하고 있는 객체가 같다고 정말 같은 객체라고 할 수 있는 건가요? 만약 이웃이 저와 같은 제조사, 같은 모델의 자동차를 가지고 있더라도 제 자동차와는 여전히 다른 자동차인 것 같은데요?

A. 캡슐화하고 있는 객체가 같다면 같은 객체여야 한다.

당신의 예시와 관련해 두 가지 설명을 덧붙이겠다.

첫째, 현실 세계와 클래스의 설계가 반드시 일대일 대응할 필요는 없다.

당신은 현실 세계에 기반해서 생각하고 있기 때문에 같은 모델, 같은 제조사여도 여전히 다른 자동차일 수 있다고 생각하는 것이다.

당신이 만든 객체 세계에서는 당신의 필요에 따라서, 같은 모델과 같은 제조사라면 같은 자동차로 여겨지게도 만들 수 있다.

둘째, 만약 현실 세계처럼 같은 모델, 같은 제조사여도 여러 대 존재해야 한다면, '고유번호' 객체를 추가적으로 캡슐화하면 된다.

만약 '자동차' 클래스가 '제조사', '모델', '고유번호'를 캡슐화한다면, 이제는 당신의 자동차와 이웃의 자동차를 구별지을 수 있지 않겠는가.

 

Q. 적게 캡슐화하는 게 직관에 부합하는 건 알겠는데, 4개라는 숫자가 너무 작게 느껴져요. 4개 이상의 구성요소가 필요하면 어떡하죠?

A. 만약 캡슐화하는 객체의 숫자가 4개를 넘어간다면, 클래스를 더 작은 단위로 분리하면 된다.

예를 들면 설계한 '자동차' 클래스에 '제조사', '모델', '제조년도', '고유번호', '소유자'라는 5개의 요소가 있다고 하자.

그렇다면, '제조자', '모델', '제조년도'를 묶어 '타입'이라는 클래스를 새롭게 만들어 하나의 단위로 묶으면 된다.

그러면 '자동차' 클래스는 '타입', '고유번호', '소유자' 세 개의 객체만을 캡슐화하게 된다. 

 

2. 최소한 뭔가는 캡슐화하세요

반대 극단으로, 아무것도 캡슐화하지 않는 객체는 잘못된 것이다.

아래는 아무것도 캡슐화하지 않는 잘못된 객체의 예시이다.

// ⚠️ bad code
class Year {
  int read() {
    return System.currfentTimeMillis()
      / (1000 * 60 * 60 * 24 * 30 * 12) - 1970;
  }
}

이처럼 프로퍼티가 없는 클래스는 객체지향에서 악명이 높은 정적 메서드(static method)와 유사하다.

어떤 상태와 식별자도 가지지 않고 오직 행동만을 포함한다.

이런 클래스는 어떤 점이 문제일까?

답은 간단하다.

정적 메서드를 포함하지 않고 인스턴스 생성과 실행을 엄격하게 분리하는(생성자에서만 new 연산자 허용) 순수한 OOP에서는 기술적으로 프로퍼티가 없는 클래스를 만드는 게 불가능하기 때문이다.

(순수한 OOP에서는 왜 정적 메서드를 포함하면 안 되고, 인스턴스 생성과 실행을 엄격히 분리해야 하는지는 스스로 생각해보자.)

 

위 잘못된 예시는 아래와 같이 고칠 수 있다.

// ✅ good code
class Year {
  private Number num;
  
  Year(final Millis msec) {
    this.num = new Min(
      new Div(
        msec,
        new Mul(1000, 60, 60, 24, 30, 12)
      ),
      1970
    );
  }
  
  int read() {
    return this.num.intValue();
  }
}

객체가 아무것도 캡슐화하지 않아도 되는 경우는, 해당 객체가 무(nothing)과 매우 유사한 어떤 것인 경우밖에 없다.

세계 안에서 어떠한 좌표도 갖지 않는 존재말이다.

이런 객체는 오직 하나만 존재하며, 생존을 위해서나 자신의 위치를 표시하기 위해 다른 객체를 필요로 하지 않기 때문이다.

한편, 이런 객체가 존재해야 하는 현실적인 이유는 잘 떠오르지 않는다.

 

3. 항상 인터페이스를 사용하세요

객체 간의 상호작용을 위해서는 인터페이스를 사용해야 한다.

하나의 객체가 다른 객체와 상호작용하기 위해서는 객체 간 의존이 발생한다.

그런데 이 의존이 인터페이스가 아닌 구체적인 클래스를 통해 이루어지면 객체 간 결합도가 강해진다.

 

반면 인터페이스를 사용하면 결합도를 낮출 수 있다.

인터페이스에 의존하면 실제 구현체에 대해서는 전혀 몰라도 된다.

 

인터페이스는 일종의 계약(contract)으로 볼 수 있다.

우리의 객체가 다른 객체와 의사소통하기 위해 따라야 하는 계약이다.

 

클래스 안의 모든 퍼블릭 메서드가 최소 하나의 계약을 따르도록 만들어야 한다.

올바르게 설계된 클래스라면 최소 하나의 인터페이스라도 구현하지 않는 퍼블릭 메서드를 포함해서는 안 된다.

 

4. 메서드 이름을 신중하게 선택하세요

4-1. 빌더는 명사다

빌더는 뭔가를 만들어서 새로운 객체로 반환하는 메서드이다.

무언가를 반환하는 메서드의 이름을 동사로 짓는 것은 잘못이다.

이런 이름은 객체지향적인 사고에 어긋난다.

 

마치 제과점에 들어가서 "브라우니를 요리해 주세요"라고 하거나, "커피 한 잔을 끓여 주세요"라고 하는 것과 비슷하다.

브라우니와 커피를 요리하든 끓이든 볶든, 그 방법에 대해서는 우리가 관심 가질 일이 아니다.

그건 만드는 직원이 신경 쓸 일이다.

우리는 "브라우니", "커피"라고만 요구할 수 있다.

 

이런 맥락에서 객체에게 무언가를 반환하도록 요구할 때 그 방법까지 메서드 이름에 명시해서는 안 되는 것이다.

그것은 절차지향적인 사고에 가깝기 때문이다.

// ⚠️ 잘못된 명명
InputStream load(URL url);
String read(File file);
int add(int x, int y);

// ✅ 올바른 명명
InputStream stream(URL url);
String content(File file);
int sum(int x, int y);

여기서 add 대신 sum을 추천하고 있다는 점에 주목하자.

이것이 작고 사소한 변화 같아 보이지만, 실제로는 생각하는 방식에 큰 변화를 만든다.

우리는 객체에게 더하라고(add) 요청하지 않는다.

두 수의 합(sum)을 담은 객체를 반환하라고 요청할 뿐이다.

객체가 합(sum)을 만들어 내는 과정에서 실제로 어떤 일을 하는지는 알 수 없다.

우리가 알 수 있는 건 단지 그 결과가 x와 y의 합과 같다는 사실뿐이다.

 

4-2. 조정자는 동사다

조정자는 객체로 추상화된 실세계의 엔티티를 수정하는 메서드이다.

조정자의 이름은 동사형으로 지어야 한다.

 

예를 들면, 아래의 클래스는 스크린에 표시된 픽셀을 표현한다.

class Pixel {
  void paint(Color color);
}

Pixel center = new Pixel(50, 50);
center.paint(new Color("red"));

여기서 paint 메서드는 스크린 상 (50, 50) 좌표에 위치한 한 픽셀을 칠하도록 요청한다.

여기에서 뭔가 생성될 것이라 예상하지 않는다.

우리는 단지 세계에 변화를 주고 싶을 뿐이고, 객체는 우리를 대신해서 이 일을 수행한다.

 

4-3. boolean을 반환 메서드는 형용사다.

boolean 값을 결과로 반환하는 메서드의 이름은 형용사로 지어야 한다.

boolean empty();
boolean readable();
boolean negative();

위와 같이 형용사 형태로 이름을 짓고, 읽을 때는 앞에 is를 붙여 읽는다.

boolean empty(); // is empty
boolean readable(); // is readable
boolean negative(); // is negative

 

4-4. 빌더와 조정자를 혼합해서는 안 된다.

한편, 빌더와 조정자를 혼합하여 사용해서는 안 된다는 사실에 주의하자.

빌더와 조정자의 역할을 동시에 수행하는 메서드는 너무 복잡하다.

메서드의 목적이 명확하지 않고 초점이 뚜렷하지 않다.

 

OOP의 전체 목적은 개념을 고립시켜 복잡성을 낮추는 것이다.

이런 목적에 부합하는 메서드를 만들기 위해 고민하자.

 

5. 퍼블릭 상수를 사용하지 마세요

퍼블릭 상수라고도 불리는 public static final 프로퍼티는 객체 사이에 데이터를 공유하기 위해 자주 사용된다.

이를 사용해서는 안 된다.

객체들은 어떤 것도 공유해서는 안 되며, 대신 독립적이고 닫혀 있어야 한다.

상수를 이용한 공유 매커니즘은 캡슐화와 객체지향적인 사고 전체를 부정하는 것이다.

 

예시를 살펴보자.

// ⚠️ bad code
public class Constants {
  public static final String EOL = "\r\n";
}
class Records {
  void write(Writer out) {
    for (Record rec : this.all) {
      out.write(rec.toString());
      out.write(Contants.EOL); // ⚠️ 퍼블릭 상수 사용
    }
  }
}
class Rows {
  void print(Writer out) {
    for (Row row : this.fetch()) {
      pnt.printf(
        "${ %s }%s", row, Constants.EOL // ⚠️ 퍼블릭 상수 사용
      );
    }
  }
}

개행을 위한 문자열("\r\n")을 상수로 만들어 재사용했다.

이는 코드 중복 문제를 잘 해결한 것처럼 보이지만, 실은 더 큰 두 가지 문제를 추가했다.

첫째, 결합도가 추가되었다.

둘째, 응집도가 낮아졌다.

 

5-1. 결합도 추가

Records와 Rows 클래스는 모두 같은 객체에 의존하고 있으며, 이 의존성은 하드코딩되어 있다.

의존성을 쉽게 분리할 수 있는 방법이 없다.

Records.write(), Rows.print(), Constants.EOL의 코드가 부분적으로 서로 결합돼 있고 상호 의존하고 있다.

이때 만약 Constants.EOL의 내용을 수정하면 다른 두 클래스의 행동은 예측할 수 없는 방식으로 바뀌고 만다.

 

변경에 따른 행동 변화를 쉽게 예측할 수 없는 이유가 무엇일까?

바로 Constants.EOL의 값을 변경하는 사람이 이 값이 현재 어떻게 사용되고 있는지 알 수 없기 때문이다.

EOL 정도의 상수는 의미가 명확해 비교적 문제가 크지 않지만, 더 복잡한 상수일수록 불확실성이 커진다.

 

상수로 만들어 한 군데에서 편리하게 바꿀 수 있다는 점이 바로 양날의 검이다.

어떤 식으로 사용하는 것인지에 대한 정확한 문맥이 없을 때는 바로 이 점이 단점이 되기 때문이다.

많은 객체들이 다른 객체를 사용하는 상황에서 서로 어떻게 사용하고 있는지 알 수 없다면, 이 객체들은 매우 강하게 결합되어 있는 것이다.

5-2. 응집도 저하

퍼블릭 상수를 사용하면 객체의 응집도가 낮아진다.

이는 객체가 자신의 문제를 해결하는 데 덜 집중한다는 것을 의미한다.

객체가 상수를 다루는 법까지 추가 신경써야 하기 때문이다.

객체는 아주 멍청한 상수 위에 자신만의 의미를 덧칠하는 일까지 신경써야 한다.

 

Constants.EOL은 자신에 대해 그 어떤 것도 알지 못한다.

자신에게 주어진 사명과 목적조차 모르는, 그저 하나의 텍스트 덩어리일 뿐이다.

이에 의미를 추가하기 위해서는 Records, Rows 클래스 안에 코드를 추가해야 한다.

원시적인 정적 상수를 감싸 의미를 더 명확하게 만들어줘야 한다.

하지만 이런 코드는 Records, Rows의 본래의 목적과는 동떨어져 있다.

 

따라서 이 상수를 사용하는 목적(맥락)과 그에 따른 기능을 담은 새로운 객체를 만드는 게 좋다.

"레코드는 내가 처리할 테니, 한 줄의 끝을 처리하는 일은 당신이 해주세요."라고 말하는 것이다.

이처럼 한 줄을 끝내는 작업을 다른 객체에게 위임한다면 각 객체의 응집도가 향상된다.

// ✅ good code
class EOLString {
  private final String origin;
  
  EOLString(String src) {
    this.origin = src;
  }
  
  @Override
  String toString() {
    return String.format("%s\r\n", origin);
  }
}
class Records {
  void write(Writer out) {
    for (Record rec : this.all) {
      out.write(new EOLString(rec.toString())); // ✅ 상수가 아닌 객체를 사용
    }
  }
}

이로써 마지막에 개행 접미사를 덧붙이는 기능을 EOLString 클래스 안으로 고립시켰다.

접미사를 줄에 추가하는 정확한 방법은 이제 EOLString이 책임진다.

우리는 줄 끝에 필요한 접미사가 어떻게 추가되는지를 정확하게 알 수 없다.

오직 EOLString이 그 작업을 책임진다는 사실만을 알고 있을 뿐이다.

 

이제 개행 작업의 동작을 수정하는 일이 매우 쉬워졌다.

EOLString과의 결합이 계약(contract)을 통해 이뤄지게 됐으므로 언제라도 분리될 수 있게 되었다.

계약만 동일하게 유지한다면 동작이나 구체적인 구현 방식을 얼마든지 원하는 대로 수정할 수 있게 되었다.

 

다시 한 번 요약하자면, 퍼블릭 상수는 OOP에서 순수한 악이며 절대 사용해서는 안 된다.

객체 사이에 데이터를 공유해서는 안 된다.

대신 기능을 공유해야 한다.

기능을 공유할 수 있도록 새로운 클래스를 만들어야 한다.

 

간단한 예시 하나를 더 살펴보고 마무리하겠다.

// ⚠️ bad code
String body = new HttpRequest()
  .method(HttpMethods.POST)
  .fetch();
  
// ✅ good code
String body = new PostRequest(new HttpRequest())
  .fetch();

 

[Q&A]

Q. 각 퍼블릭 상수 별로 계약의 의미를 캡슐화하는 새로운 클래스를 만들어야 하는 건가요?

A. 그렇다.

 

Q. 수백 개의 단순한 상수 문자열 리터럴 대신 수백 개의 마이크로 클래스를 만들어야 한다는 건가요?

A. 그렇다.

 

Q. 그러면 마이크로 클래스들에 의해 코드가 더 장황해지고 오염되지 않을까요?

A. 그렇지 않다. 클래스가 더 작아질수록 코드는 더 깔끔해진다. 작은 클래스들 사이에 중복 코드가 존재하지 않도록만 한다면 말이다.

일상생활에서 사용하는 언어에 비유해보겠다. 의도적으로 다양한 동의어를 사용하는 게 아니라면, 단어를 더 많이 사용할수록 문장을 읽기가 쉬워진다. 반대로 몇몇 단어에 다양한 의미를 부여하고 자주 재사용할 경우 문장이 읽기 어려워진다.

1) "내 고양이는 생선을 먹고 우유를 마시는 것을 좋아한다."
2) "내 것은 그것을 먹고 다른 것을 마시는 것을 좋아한다."

여기서 두 번째 문장은 "것"이라는 단어에 너무 많은 의미를 담아 남용하고 있다.

독자는 "것"이라는 단어가 첫 번째, 두 번째, 세 번째 위치에서 정확하게 무엇을  의미하는지 이해해야 한다.

코드도 이와 마찬가지다.

만약 java.io.File이 많은 곳에서 사용될 경우, 각각의 File이 정확히 어떤 파일을 의미하는지 쉽게 파악할 수 없게 된다.

반면, TextFile, JPGFile, TempFile을 구분해 사용한다면 훨씬 수월하게 코드를 파악할 수 있게 될 것이다.

 

6. 불변 객체로 만드세요

모든 클래스를 상태를 변경할 수 없는 불변 클래스(immutable class)로 만들면 유지보수성이 크게 향상된다.

불변 객체가 무엇인지 간단히 살펴보고, 그 이점에는 무엇이 있는지 알아보자.

// ⚠️ 가변 객체
class Cash {
  private int dollars;
  
  public void setDollars(int val) {
    this.dollars = val;
  }
}

// ✅ 불변 객체
class Cash {
  private final int dollars;
  
  Cash(int val) {
    this.dollars = val;
  }
}

불변 객체가 다른 점은 private 프로퍼티 dollars에 final 키워드가 추가됐다는 점이다.

final 키워드로 인해 해당 프로퍼티의 값을 생성자 외부에서 수정할 수 없게 되었다.

 

가변 객체와 불변 객체의 사용법은 아래와 같을 것이다.

// ⚠️ 가변 객체
Cash money = new Cash(5);
money.mul(10);
System.out.printIn(money); // "$50"

// ✅ 불변 객체
Cash five = new Cash(5);
Cash fifty = five.mul(10);
System.out.printIt(fifty); // "$50"

그렇다면 왜 불변 객체가 가변 객체보다 유지보수에 도움이 되는 것일까?

7가지의 항목으로 나누어 정리해보았다.

 

6-1. 식별자 불변성 

불변 객체는 '식별자 변경' 문제를 일으키지 않는다.

반면 가변 객체는 '식별자 변경' 문제를 일으킬 수 있다.

식별자에 가변 객체를 할당하고, 가변 객체의 상태를 변경하면 해당 식별자와 객체가 서로 맞지 않는 문제가 발생하는 것이다.

Cash five = new Cash("$5");

map.put(five, "five");

five.mul(2);
System.out.printIn(map); // {$10=>"five"}

five라는 식별자에 10달러의 Cash 객체가 담기게 됐다.

가변 객체는 이렇게 식별자와 그에 담긴 값의 불일치가 발생할 수 있는 문제를 일으킨다.

이는 매우 심각하고 찾기 어려운 버그로 이어질 수 있다.

이 간단한 코드에서도, 왜 five와 10달러가 연결되어 출력되는 이상한 현상이 벌어졌는지 추적하기 어렵지 않은가.

 

반면, 불변 객체는 상태를 변경할 수 없기 때문에 이런 문제를 일으키지 않는다.

6-2. 실패 원자성

불변 객체는 '실패 원자성'을 보장한다.

'실패 원자성'이란 객체가 완전하고 견고한 상태이거나, 아니면 아예 실패하거나 둘 중 하나만 가능하다는 사실을 의미한다.

중간은 없다.

// ⚠️ 가변 객체(실패 원자성 x)
class Cash {
  private int dollars;
  private int cents;
  
  public void mul(int factor) {
    this.dollars *= factor;
    
    if (/* 뭔가 잘못됐다면 */) {
      throw new RuntimeException("oops...");
    }
    
    this.cents *= factor;
  }
}

// ✅ 불변 객체(실패 원자성 o)
class Cash {
  private final int dollars;
  private final int cents;
  
  public Cash mul(int factor) {
    if (/* 뭔가 잘못됐다면 */) {
      throw new RuntimeException("oops...");
    }
    
    return new Cash(
      this.dollars * factor,
      this.cents * factor
    );
  }
}

물론 가변 객체를 사용하더라도 '실패 원자성'을 달성하는 방법은 있지만, 이보다 훨씬 복잡하게 처리될 것이고 그에 따라 실수할 가능성도 커진다.

반면 불변 객체는 별도의 처리를 하지 않더라도 원자성을 얻을 수 있다.

6-3. 시간적 결합 제거

불변 객체는 시간적 결합(temporal coupling)을 없앨 수 있다.

시간적 결합이란, 개발자가 코드 줄의 순서를 기억해야 한다는 사실을 의미한다.

시간적 결합이 존재할 때, 개발자는 어떤 줄이 앞에 있어야 하고, 어떤 줄이 뒤에 나와야 하는지를 기억해야 한다.

// ⚠️ 가변 객체(시간적 결합 o)
Cash price = new Cash();

// x를 계산하는 50줄의 코드

price.setDollars(x);

// y를 계산하는 30줄의 코드

price.setCents(y);

// 다른 일을 수행하는 25줄의 코드

System.out.printIn(price);

// ✅ 불변 객체(시간적 결합 x)
Cash price = new Cash(29, 95);
System.out.printIn(price);

'setter'들의 실행 순서를 그대로 유지하면서 이들 모두 printIn() 이전에 실행돼야 한다는 사실을 이해하는 것은 절대로 쉬운 일이 아니다.

반면 불변 객체는 객체를 한 줄로 인스턴스화한다.

인스턴스화와 초기화(initialization)을 분리할 수 없으며 둘은 항상 함께 실행돼야 한다.

객체 초기화와 printIn의 순서를 변경하면 코드가 컴파일되지 않기 때문에 순서를 변경할 수 없다.

따라서 불변성은 코드 안에 존재하는 구문 사이의 임의의 시간적인 결합을 제거한다.

 

어떤 일을 하려면, 그 전에 객체를 완전한 상태로 초기화해야 한다.

불변 객체는 자족적이면서 견고하기 때문에, 그 후에 어떤 일이 발생하건 중요하지 않다.

6-4. 부수효과 제거(Side effect free)

불변 객체는 수정할 수 없으므로 부수효과에 노출되지 않는다.

반면 객체가 가변적인 경우 누구든 손쉽게 객체를 수정할 수 있다.

// ⚠️ bad code
void print(Cash price) {
  System.out.printIn("Today price is: " + price);
  price.mul(2);
  System.out.printIn("Buy now! Tomorrow price is: " + price);
}
Cash five = new Cash(5);
print(five);
System.out.printIn(five); // ⚠️ "$10"

이 예시에서 print 함수 내에서 부수효과가 발생해 five 객체의 상태가 의도치 않게 변경됐다.

부수효과의 가능성은 이런 의도치 않은 변경에 대해 의심하게 만든다.

코드가 제대로 동작하지 않는 경우 부수효과를 이해하기 위해 전반적인 코드를 훑고 다녀야 할 것이다.

 

반면 클래스를 불변으로 만들면 객체를 수정하는 일이 불가능해진다.

혹시라도 객체의 상태가 변경된 건 아닌지 의심할 필요가 없어진다.

Cash의 불변성은 five가 언제 어디서나 항상 5달러라는 확신을 줄 것이다.

6-5. NULL 참조 제거

불변 객체를 참조하면 NULL 참조를 제거할 수 있다.

반면, 객체의 생성과 초기화가 다른 시점에 이뤄지는 가변 객체는 일시적으로 프로퍼티가 NULL을 참조하게 된다.

이로 인해 값에 접근하기 전에 NULL 여부를 확인해야 한다.

(이외에도 NULL은 많은 문제의 원인이 되는데, 그것에 대해서는 다음 장에서 더 자세히 알아보겠다.)

// ⚠️ null을 참조하는 가변 객체
class User {
  private final int id;
  private String name = null;
  
  public User(int num) {
    this.id = num;
  }
  
  public void setName(String txt) {
    this.name = txt;
  }
}

 

초기화되어 있지 않은 name 프로퍼티를 가진 User 객체가 필요한 이유는 무엇일까?

다른 클래스가 추가로 필요한 상황임에도 새로운 클래스를 추가하는 일이 너무 귀찮아서일 것이다.

또는 새로운 클래스를 어떻게 만들어야 하는지 몰라서일 것이다.

많은 이유가 있겠지만 결과는 같다.

사용자이면서 고객이고, 사원이면서 데이터베이스 레코드이기도 한 응집도가 떨어지는 매우 큰 클래스를 만들게 된다.

name 프로퍼티가 초기화될 때 이 클래스는 고객을 의미하고, NULL일 때는 사용자를 의미하는 식으로 말이다.

 

반면, 모든 객체를 불변으로 만들면 객체 내부에는 NULL을 참조하는 프로퍼티를 결코 포함하지 않는다.

다시 말해서 작고, 견고하고, 응집도 높은 객체를 생성하도록 강요받을 수밖에 없다.

결과적으로 유지보수하기 훨씬 더 쉬운 객체를 만들게 된다.

6-6. 쓰레드 안전성

불변 객체는 쓰레드 안전성을 보장한다.

쓰레드 안전성이란, 객체가 여러 쓰레드에서 동시에(concurrently) 사용될 수 있고 예측가능한 결과를 보장하는 객체의 특성을 가리킨다.

 

만약 객체의 상태가 가변적이라면, 여러 쓰레드에서 동시에 수행되었을 때의 결과를 예측하기 어렵다.

프로퍼티를 변경하는 코드가 실행되는 순서에 따라 결과가 달라질 수 있기 때문이다.

이러한 병행성 이슈는 발견하고 디버깅하기 가장 어려운 문제 중 하나이다.

 

반면 불변 객체는 실행 시점에 상태를 수정할 수 없게 금지함으로써 이 문제를 완벽하게 해결한다.

아무리 많은 쓰레드가 객체에 접근해도 상관없다.

어떤 쓰레드도 객체의 상태를 수정할 수 없기 때문이다.

6-7. 더 작고 단순한 객체

불변 객체의 가장 큰 장점은 단순함(simplicity)하다.

불변 객체는 크기가 작다.

불변 객체는 아주 크게 만드는 것이 불가능하다. (10개 이상의 인자를 받는 생성자를 허용할 게 아닌 이상)

생성자 안에서만 상태를 초기화할 수 있기 때문이다.

만약 객체에 새로운 기능을 추가해야 한다면 생성자를 더 크게 만들어야 한다.

얼마 안 가 개발자는 뭔가 잘못돼 가고 있다는 사실을 깨닫고 클래스를 더 작은 클래스로 분리할 것이다.

 

불변 객체는 클래스를 더 깔끔하고 더 짧게 만들도록 이끈다.

불변 객체를 이용하여 클래스 하나당 코드 250줄 이하를 유지하라.

 

[Q&A]

Q. 불변 객체를 사용하면, 매번 새로운 객체를 만들어야 하는데요. 그 객체 생성 비용이 너무 커지지 않을까요?

A. 맞다.

객체를 매번 새롭게 생성하므로, 그렇지 않았을 때에 비해 비용이 매우 커질 것이다.

그런데 객체를 생성하는 데 필요한 컴퓨팅 비용보다 개발자의 몸값이 더 비싸다.

컴퓨터의 비용보다는, 유지보수하기 어려운 코드로 인해 낭비될 개발자의 시간을 더 중요하게 생각하자.

 

7. 문서화 대신 테스트를 작성하세요

코드의 사용자에게 사용법을 알려주기 위해 문서가 아닌 테스트를 이용하자.

 

문서화는 유지보수성의 중요한 구성요소(component)이다.

정확히 말하면 문서 작성 그 자체보단, 클래스/메서드의 추가적인 정보에 대한 접근성이 중요하다.

코드를 읽는 사람은 코드 작성자만큼 똑똑하지 않기 때문이다. ('코드를 볼 사람이 모든 것을 알고 있다'고 생각하지 마라!)

 

베스트는 우리가 작성한 코드만으로 읽는 이가 쉽게 이해할 수 있게 만드는 것이다.

// ✅ 코드 자체만으로 의미가 명확히 전달됨
Employee jeff = new department.employee("Jeff");
if (jeff.performance(() < 3.5) {
  jeff.fire();
}

// ⚠️ 끔찍한 클래스/메서드 이름으로 인해 별도의 문서화가 필요함
class Helper {
  int saveAndCheck(float x) { .. }
  float extract(String text) { .. }
  boolean convert(int value, boolean extra) { .. }
}

이처럼 코드를 별도로 문서화할 필요가 없게 하는 게 좋다.

대신 쉽게 이해할 수 있도록 코드를 깔끔하게 만들자.

 

여기에서, 코드를 '깔끔하게 만든다'는 말에는 단위 테스트도 함께 만들라는 의미가 포함된다는 점에 주목하자.

단위 테스트도 클래스의 메서드, 프로퍼티, 이름, 인터페이스의 목록처럼 클래스의 일부로 취급해야 한다.

물론 기술적으로는 단위 테스트가 별도의 파일에 존재할 수밖에 없지만, 개념적으로는 클래스의 일부로 여겨야 한다는 말이다.

 

단위 테스트도 클래스의 일부이기 때문에 훌륭하고 깔끔하게 만들어야 한다.

'메인' 코드만큼 단위 테스트에도 관심을 기울여야 한다.

 

한 장의 그림이 수천 단어만큼의 가치가 있는 것처럼, 하나의 단위 테스트는 한 페이지 분량의 문서만큼이나 가치가 있다.

단위 테스트는 클래스의 사용 방법을 알기 쉽게 보여주는 데 반해, 문서가 들려주는 이야기를 이해하는 일은 어렵다.

문서로 말하지 말고 테스트로 보여주자.

 

단위 테스트는 살아 있는 문서다.

 

8. 모의 객체(Mock) 대신 페이크 객체(Fake)를 사용하세요

단위 테스트를 할 때 흔히 사용하는 모킹 대신 페이크 객체를 사용하자.

 

모킹은 테스트를 위해 특정 객체의 행동을 원하는 대로 임의 조작하는 테스팅 기법이다.

페이크 객체는 테스트를 위해 존재하는, 실제 객체의 인터페이스를 따르는 가짜 객체이다.

먼저 각각을 이용해 테스트한 예시를 살펴보자.

// ⚠️ 모킹을 사용한 테스트
Exchange exchange = Mockito.mock(Exchange.class);

Mockito.doReturn(1.15) // 모킹을 이용해 exchange의 행동을 임의 조작함
  .when(exchange)
  .rate("USD", "EUR")
  
Cash dollar = new Cash(exchange, 500);
Cash euro = dollar.in("EUR");

assert "5.75".equals(euro.toString());
// ✅ 페이크 객체를 이용한 테스트
interface Exchange {
  float rate(String origin, String target);
  
  final class Fake implements Exchange {
    @Override
    float rate(String origin, String target) {
      return 1.2345;
    }
  }
}

Exchange exchange = new Exchange.Fake();
Cash dollar = new Cash(exchange, 500);
Cash euro = dollar.in("EUR");
assert "6.17".equals(euro.toString());

페이크 객체를 사용하는 방식이 모킹보다 나은 이유는 다음과 같다.

  1. 테스트 코드를 짧고 간결하게 만들 수 있다: 모킹을 위해 Mockito를 호출하고 관련 동작을 수행하는 일은 상당히 복잡한 반면, 페이크 객체는 만들어서 일반 객체처럼 사용하는 방식으로 간결하게 테스트할 수 있다. 지금은 객체의 동작이 간단했지만 이보다 훨씬 복잡한 객체라면 모킹으로 인해 테스트코드의 복잡성이 훨씬 크게 증가했을 것이다.
  2. 테스트를 수정(유지보수)하기 쉽다: 모킹은 객체의 특정 동작을 테스트를 위해 임의 동작으로 대체하는 일이다. 이는 다시 말해 객체의 세부 동작에 의존하여 테스트가 구축되었다는 것을 의미한다. 위 모킹 예시에서는 "Cash"가 "Exchange.rate()"를 호출한다고 가정하고 있다. 만약 "Cash.in()"의 세부 구현이 변경된다면 이 테스트는 실패하고 말 것이다. 이런 거짓 양성을 막기 위해선 모킹 로직도 별도로 수정해주어야 할 것이다. 반면, 페이크 객체를 이용한 테스트에서는 Cash가 '블랙박스'이기 때문에 이런 불상사가 생기지 않는다. 만약 interface가 변경되었다면, 그에 맞추어 Fake 클래스의 구현만 수정해주면, 단위 테스트는 별도로 수정할 필요가 없다.
  3. 설계 과정에서 사용자 관점에서 고민하게 만들어준다: 인터페이스를 설계하는 과정에서 인터페이스에 대한 페이크 클래스를 만들다보면 사용자의 관점에서 고민하게 된다. 인터페이스를 다른 각도에서 바라보게 되고, 테스트 리소스를 사용해서 사용자와 동일한 기능을 구현해보게 된다.

그러니, 모킹은 정말 최후의 수단으로만 활용하고 페이크 객체를 적극 사용하자.

 

9. 인터페이스를 짧게 유지하고 스마트(smart)를 사용하세요

추가적인 기능이 필요하게 될 때, 인터페이스의 크기를 키우지 말고 스마트 클래스를 이용하자.

// ⚠️ bad code
interface Exchange {
  float rate(String target);
  float rate(String source, String target);
}

사실 이 인터페이스도 충분히 훌륭하지만, 구현자에게 너무 많은 것을 요구한다는 점에서 나쁜 코드이다.

클라이언트에게 환율을 계산하도록 요구하는 동시에, 환율을 제공하지 않을 경우 기본 환율을 사용하는 구현을 하도록 강제한다.

이는 아래와 같이 스마트 클래스를 이용해 개선할 수 있다.

// ✅ good code
interface Exchange {
  float rate(String source, String target);
 
  final class Smart {
    private final Exchange origin;
   
    public float toUsd(String source) {
      return this.origin.rate(source, "USD");
    }
  }
}

 

이 클래스는 아래와 같이 사용할 수 있다.

float rate = new Exchange.Smart(new NYSE()).toUsd("EUR");

 

이러면 Exchange의 인터페이스의 크기를 키우지 않고도, 환율이 제공되지 않았을 때 'USD' 통화를 적용하는 기능을 공유할 수 있다.

이처럼 Smart 클래스는 공통적인 작업을 수행하는 많은 메서드들을 포함할 수 있다.

그 메서드들은 해당 클래스의 서로 다른 구현 사이에서 공유될 수 있다. (각 구현체가 개별적으로 구현할 필요가 없다)

 

이로써 인터페이스를 작고, 응집도가 높은 상태로 유지하면서도 기능을 추가할 수 있게 되었다.

기본적으로 인터페이스를 짧게 유지하고, 스마트 클래스를 인터페이스와 함께 배포함으로써 공통 기능을 추출하여 코드 중복을 피하자.

 

(제이슨의 의견: 스마트 클래스는 JS의 프로토타입과 유사해 보인다)


짧은 소감

이번 장에서는 사회생활을 잘하는 객체를 만들기 위해 지켜야 할 원칙들에 대해 알아보았다.

책을 읽을수록, 내가 기술적으로는 객체를 활용한 게 맞지만 OOP를 하고 있던 건 아니었다는 사실을 깨닫게 된다.

 

OOP는 문법이나 방법론보다는 사고 방식인 것 같다.

함수로도 객체지향적인 코드를 작성할 수 있고, 클래스로도 절차지향적인 코드를 작성할 수 있다.

중요한 건 단순히 어떤 방법론을 따르느냐, 혹은 어떤 원칙을 지키느냐가 아니다.

어떤 가치에 방점을 두는지, 그리고 그 가치를 달성하기 위해 코드를 어떤 관점에서 바라보려 하는지가 중요하다.

 

피상적인 이해와 본질적인 이해에는 격차가 존재한다는 사실을 새삼 느낀다.

책에서 제시하는 원칙을 그저 따라하기만 해도 그 책을 이해했다는 착각을 느끼곤 한다.

그런데 그런 피상적인 이해는 쉽게 무너지게 되어 있다.

저자가 제시하는 원칙을 당장 적용하려고만 하거나 단편적인 설명만 보고 납득/비판하기보다는, 그러한 원칙을 이야기하는 기저에는 어떠한 생각과 관점이 담겨있는지를 음미해야 한다.

 

저자가 제시한 원칙을 당장 따라할 수 없다는 데에 좌절을 느꼈는데 지금 생각하니 오히려 다행스럽기도 하다.

그가 외치는 원칙을 무작정 따라가기보단 그가 던지는 본질적인 메시지에 닿기 위해 충분히 고뇌하고 있는 것 같기 때문이다.

 

좋은 인풋들을 바탕으로 '좋은 코드'에 대한 나만의 관점을 길러나가자 💪🏻