Skip to content

좋은 객체의 7가지 덕목

좋은 객체의 7가지 덕목 published on 좋은 객체의 7가지 덕목에 댓글 없음

참고: 이 글은 아래 글을 저자의 허락하에 번역한 것입니다.


마틴 파울러는 이렇게 말한다:

라이브러리는 본질적으로 호출 가능한 함수의 집합으로서, 오늘날에는 보통 클래스로 구성된다.

클래스로 구성된 함수? 초면에 정말 죄송하지만 이건 틀렸다. 그리고 객체지향 프로그래밍의 클래스에 대해 아주 흔히 하는 오해다. 클래스는 함수로 구성된 것이 아니다. 그리고 객체는 자료구조가 아니다.

그럼 “적절한” 객체란 무엇일까? 무엇이 적절한 객체이고, 무엇이 적절한 객체가 아닐까? 차이점이 뭘까? 이것이 격렬한 비판을 받을 만한 주제이긴 하지만 아주 중요하다. 객체가 무엇인지 이해하지 않으면 어떻게 객체지향 소프트웨어를 작성할 수 있을까? 글쎄, 자바, 루비 등의 언어 덕분에 그렇게 하는 게 가능하다. 그런데 그럼 얼마나 좋을까? 안타깝게도 이것은 엄밀한 과학이 아니고, 사람마다 의견이 분분하다. 다음은 좋은 객체의 품질을 나열한 것이다.

클래스 vs. 객체

badge

객체에 관해 이야기하기에 앞서 클래스란 무엇인가에 대해 정의해보자. 클래스는 객체가 태어나는(인스턴스화라고도 하는) 곳이다. 클래스의 주된 책임은 필요에 따라 새로운 객체를 생성하고 더는 사용하지 않을 때 객체를 파괴하는 것이다. 클래스는 해당 클래스의 자식들이 어떤 모습이어야 하고 어떻게 행동해야 하는가를 알고 있다. 다시 말해서, 해당 클래스의 자식들이 따라야 하는 계약에 대해 알고 있다.

이따금 클래스를 “객체 템플릿”으로 부르는 것(예를 들면 위키피디아에서 그렇게 하고 있다)을 듣곤 한다. 이 같은 정의는 정확하지 않은데, 이 정의에 따르면 클래스는 수동적인 위치에 있기 때문이다. 이 정의는 누군가가 템플릿을 가지고 그것을 사용해 객체를 만들어낸다고 가정한다. 그럴 수도 있지만 엄밀히 말하자면 개념적으로 틀린 말이다. 다른 아무도 여기에 관여해서는 안 된다. 클래스와 그것의 자식들만 있을 뿐이다. 객체는 클래스에게 또 다른 객체를 만들라고 요청하고, 클래스는 객체를 만들어낸다. 이게 전부다. 루비에서는 이 같은 개념을 자바나 C++보다 훨씬 잘 표현한다.

photo = File.new('/tmp/photo.png')

photo 객체는 File 클래스에 의해 생성된다(new는 클래스에 대한 진입점이다). 한번 생성된 객체는 스스로 동작한다. 자신을 누가 만들었고 클래스에 형제 자매가 얼마나 더 있는지 알아서는 안 된다. 그렇다. 리플렉션은 엉터리 같은 생각이며, 이후 글에서 이 주제에 대해 다루겠다 🙂 이제 객체 자체를 비롯해 객체의 가장 좋은 측면과 가장 나쁜 측면에 대해 알아보자.

1. 객체가 현실 세계에 존재한다

badge

먼저, 객체는 생명체다. 게다가 객체는 의인화, 즉 인간처럼 취급해야 한다(또는 애완동물을 선호한다면 애완동물처럼). 이것은 기본적으로 객체가 자료구조나 함수의 집합이 아니라는 것을 의미한다. 그 대신 객체는 자신만의 생명주기, 행위, 습관을 지닌 독립적인 개체다.

직원, 부서, HTTP 요청, MySQL 안의 테이블, 파일 안의 한 줄, 파일 자체는 적절한 객체다. 그것들은 현실 세계에 존재하고, 심지어 우리가 소프트웨어를 종료하더라도 존재하기 때문이다. 좀 더 정확히 말하자면 객체는 현실 세계의 피조물을 대표하는 것이다. 객체는 다른 모든 객체 앞에 있는 그러한 현실 세계의 피조물의 대리자(proxy)다. 그러한 피조물이 없다면 당연히 객체도 존재하지 않는다.

photo = File.new('/tmp/photo.png')
puts photo.width()

이 예제에서는 File로 하여금 새로운 photo 객체를 생성하도록 요청하고 있는데, 이 객체는 디스크 상의 실제 파일을 대표할 것이다. 여러분은 파일이 무언가 가상적이고 컴퓨터를 켰을 때만 존재하는 것이라고 말할 수도 있다. 나는 이 말에 동의하고 “현실 세계”의 의미를 다음과 같이 수정하고자 한다: 객체가 살아가는 프로그램의 범위에서 벗어난 곳에 존재하는 모든 것. 디스크 파일은 위 프로그램의 범위 밖에 존재한다. 프로그램 내에서 그것의 대표자를 만들어내는 것이 완벽하게 맞는 것은 바로 그런 이유에서다.

컨트롤러, 파서, 필터, 검증기, 서비스 로케이터, 싱글턴, 팩터리는 좋은 객체가 아니다(그렇다, 대부분의 GoF 패턴은 안티패턴이다!). 이것들은 소프트웨어 밖의 현실 세계에는 존재하지 않는다. 단지 다른 객체와 함께 사용하기 위해 만들어낸 것이다. 그것들은 인공적이고 가짜 피조물이다. 그것들은 아무것도 대표하지 않는다. 진심으로 말하건대 XML 파서는 누구를 대표할까? 아무도.

그것들 중 일부는 이름을 바꾼다면 좋아질 수 있지만 일부는 자신의 존재를 증명할 길이 전혀 없다. 예를 들어, XML 파서는 “파싱 가능한 XML”로 이름을 바꾸고 우리가 말하는 범위 바깥에 존재하는 XML 문서를 대표할 수 있다.

늘 다음과 같이 자문해 보라. “내가 만든 객체 배후에 존재하는 현실 세계의 개체는 무엇일까?” 이 질문의 답을 찾을 수 없다면 리팩터링을 고려해 봐야 한다.

2. 객체가 계약에 따라 동작한다

badge

좋은 객체란 언제나 계약에 따라 동작한다. 객체는 자신의 특성이 아니라 자신이 준수하는 계약 때문에 사용되길 예상한다. 한편으로 우리가 객체를 사용할 때는 특정 클래스의 일부 특정 객체가 우리를 위해 동작하도록 차별하거나 예상해서는 안 된다. 우리는 아무 객체라도 우리의 계약에 명시된 일을 하도록 예상해야 한다. 객체가 우리가 원하는 일을 하기만 한다면 해당 객체가 만들어진 클래스, 성별, 종교에 관심을 둬서는 안 된다.

예를 들어, 화면에 사진을 보여주고 싶다고 해보자. 사진을 PNG 형식의 파일로부터 읽어오고 싶다. 이를 위해 DataFile 클래스의 객체와 계약해서 해당 이미지의 바이너리 콘텐츠를 가져다 주도록 요청하려고 한다.

그런데 잠깐. 해당 콘텐츠를 정확히 어디에서 가져와야 할지(즉 디스크 상의 파일, HTTP 요청, 또는 드롭박스 내의 문서)에 대해 내가 관심이 있을까? 사실 그렇지 않다. 내가 관심 있는 것은 어떤 객체가 나에게 PNG 콘텐츠가 담긴 바이트 배열을 준다는 것이다. 따라서 내 계약은 다음과 같은 모습일 것이다.

interface Binary {
  byte[] read();
}

이제, 어떤 클래스의 어떤 객체라도(DataFile만이 아니라) 나를 위해 일해줄 수 있다. 그 객체가 자격을 갖추기 위해 해야 하는 일이라곤 Binary 인터페이스를 구현함으로써 계약을 준수하는 것뿐이다.

여기서 발견할 수 있는 규칙은 간단하다. 좋은 객체 안에 담긴 모든 공용 메서드는 인터페이스 상에 선언된, 상응하는 메서드들을 구현해야 한다는 것이다. 여러분이 만든 객체에 어떤 인터페이스에서도 상속되지 않은 공용 메서드가 있다면 그 객체는 잘못 설계된 것이다.

여기엔 두 가지 실제적인 이유가 있다. 첫째, 계약 없이 동작하는 객체는 단위 테스트에서 목킹(mock)하는 것이 불가능하다. 둘째, 계약 없는 객체는 데코레이션을 통해 확장하는 것이 불가능하다.

3. 객체가 고유하다

좋은 객체는 언제나 고유하기 위해 무언가를 캡슐화해야 한다. 캡슐화할 것이 없다면 객체가 동일한 복제본을 가질 수도 있으며, 이것은 바람직하지 않다고 생각한다. 다음은 복제본을 가질 수 있는 나쁜 객체의 예다.

class HTTPStatus implements Status {
  private URL page = new URL("http://localhost");
  @Override
  public int read() throws IOException {
    return HttpURLConnection.class.cast(
      this.page.openConnection()
    ).getResponseCode();
  }
}

다음과 같이 HTTPStatus 클래스의 인스턴스를 몇 개 생성할 수 있으며, 모든 인스턴스는 서로 동일한 것으로 판단될 것이다.

first = new HTTPStatus();
second = new HTTPStatus();
assert first.equals(second);

당연히 정적(static) 메서드만이 담긴 유틸리티 클래스는 좋은 객체를 인스턴스화할 수 없다. 좀 더 일반적으로 말하자면 유틸리티 클래스는 이번 글에서 언급한 이점을 아무것도 갖지 않으며 “클래스”라고 부를 수조차 없다. 유틸리티 클래스는 단순히 객체 패러다임을 엉터리로 남용하는 것에 불과하며, 현대 객체지향 언어를 발명한 사람들이 정적 메서드를 사용할 수 있게 만들어 뒀다는 이유로 존재하는 것뿐이다.

4. 객체가 불변적이다

좋은 객체라면 자신이 캡슐화한 상태를 절대 변경하지 않을 것이다. 기억해 두자. 객체는 현실 세계에 존재하는 개체의 대표자이며, 이 객체는 객체의 전 생애에 걸쳐 변하지 않은 채로 머물러야 한다. 다시 말해서, 객체는 그것이 대표하는 것들을 절대 배신해서는 안 된다. 절대 주인을 바꿔서는 안 된다. 🙂

불변성(immutability)이 모든 메서드가 언제나 동일한 값을 반환한다는 것을 의미하지는 않는다는 점에 유의해야 한다. 오히려 좋은 불변 객체는 매우 역동적이다. 그럼에도 자신의 내부 상태는 절대 변경하지 않는다. 다음 예제를 보자.

@Immutable
final class HTTPStatus implements Status {
  private URL page;
  public HTTPStatus(URL url) {
    this.page = url;
  }
  @Override
  public int read() throws IOException {
    return HttpURLConnection.class.cast(
      this.page.openConnection()
    ).getResponseCode();
  }
}

read() 메서드가 서로 다른 값을 반환할 수 있음에도 객체는 불변적이다. 이 객체는 특정 웹 페이지를 가리키고 다른 어떤 곳도 가리키지 않을 것이다. 자신의 캡슐화된 상태를 변경하지도 않을 테고, 자신이 나타내고 있는 URL을 배신하는 일 따윈 없을 것이다.

왜 불변성이 덕목일까? Object Should Be Immutable에서 이를 상세하게 설명하고 있다. 요약하자면 불변 객체는 다음과 같은 이유로 더 낫다.

  • 불변 객체는 생성, 테스트, 사용하기가 더 간단하다.
  • 진정한 불변 객체는 언제나 스레드 안전하다.
  • 불변 객체는 시간적 결합(temporal coupling)을 피하는 데 도움이 된다.
  • 불변 객체의 사용은 부수효과를 발생시키지 않는다(방어적 복사를 하지 않아도 된다).
  • 불변 객체는 언제나 실패 원자성을 띤다.
  • 불변 객체는 캐싱하기가 훨씬 더 쉽다.
  • 불변 객체는 NULL 참조를 방지한다.

물론 좋은 객체는 객체의 상태를 변경해서 URL을 배신하도록 강제할 수도 있는 설정자 메서드를 갖지 않는다. 다시 말해서 setURL() 메서드를 도입할 경우 HTTPStatus 클래스에 심각한 실수를 저지르는 것이다.

게다가 불변 객체는 How Immutability Helps라는 글에서 설명하는 것과 같이 언제나 여러분으로 하여금 좀 더 응집력 있고, 견고하며, 이해하기 쉬운 설계를 하도록 강제한다.

5. 객체의 클래스에 정적 멤버가 없다

정적 멤버는 클래스의 행위를 구현하지, 객체의 행위를 구현하지 않는다. File이라는 클래스가 있고, 이 클래스의 자식으로 size() 메서드가 있다고 해보자.

final class File implements Measurable {
  @Override
  public int size() {
    // 파일의 크기를 계산해서 반환
  }
}

지금까지는 괜찮다. size() 메서드가 이곳에 있는 이유는 Measurable 계약 때문이며, File 클래스의 모든 객체는 자신의 크기를 측정할 수 있을 것이다. 끔찍한 실수는 이 클래스가 정적 메서드를 갖도록 설계하는 것일 것이다(이 같은 설계는 유틸리티 클래스로도 알려져 있으며, 자바, 루비를 비롯한 거의 대부분의 OOP 언어에서 흔히 사용된다).

// 끔찍한 설계, 사용하지 마시오!
class File {
  public static int size(String file) {
    // 파일의 크기를 계산해서 반환
  }
}

이 설계는 객체지향 패러다임에 완전히 반한다. 왜일까? 정적 메서드는 객체지향 프로그래밍을 “클래스지향(class-oriented)” 프로그래밍으로 바꾸기 때문이다. size() 메서드는 객체가 아니라 클래스의 행위를 노출한다. 이것이 뭐가 잘못됐냐고 궁금할 수도 있겠다. 우리가 작성한 코드에 일급 시민으로 객체와 클래스 모두 두지 못할 이유가 있나? 객체와 클래스가 모두 메서드와 프로퍼티를 갖지 못할 이유가 있나?

문제는 클래스지향 프로그래밍의 경우 분해(decomposition)가 더는 작동하지 않는 데 있다. 복잡한 문제를 여러 부분으로 분해할 수 없는데, 전체 프로그램에 클래스의 단 하나의 인스턴스만이 존재하기 때문이다. OOP의 위력은 객체를 범위 분해(scope decomposition)를 위한 도구로서 사용할 수 있다는 것이다. 메서드 내에서 객체를 인스턴스화할 경우 해당 객체는 부여받은 특정 과업을 수행하기 위해 전념한다. 객체는 메서드 주변의 다른 모든 객체로부터 완벽하게 고립된다. 이 객체는 메서드 유효범위에서 지역 변수에 해당한다. 정적 메서드를 가진 클래스는 내가 해당 클래스를 어디에서 사용하건 언제나 전역 변수에 해당한다. 이러한 이유로 이 변수와의 상호작용을 다른 것들과 고립시킬 수가 없는 것이다.

객체지향 원칙에 개념적으로 반하는 것 외에도 공용 정적 메서드는 몇 가지 실제적인 단점을 지니고 있다.

첫째, 목킹하는 것이 불가능하다(PowerMock을 사용할 수도 있겠지만 이렇게 할 경우 자바 프로젝트에서 저지를 수 있는 가장 끔찍한 결정이 될 것이다… 몇 년 전 나도 딱 한 번 그렇게 한 적이 있다).

둘째, 정의상 스레드 안전하지 않다. 왜냐하면 모든 스레드에서 접근 가능한 정적 변수와 언제나 상호작용하기 때문이다. 스레드 안전하게 만들 수도 있겠지만 그러려면 언제나 명시적인 동기화가 필요할 것이다.

공용 정적 메서드를 보게 될 때마다 그 즉시 그것을 재작성하라. 정적(또는 전역) 변수가 얼마나 끔찍한지는 굳이 언급하고 싶지도 않다. 그건 굳이 말 안 해도 알 것이라 생각한다.

6. 객체의 이름이 직명을 나타내지 않는다

badge

객체의 이름은 이 객체가 무엇인지 말해야 하고 무슨 일을 하는지 말해서는 안 된다. 이것은 마치 현실 세계의 객체에 이름을 부여하는 것과 마찬가지인데, ‘페이지 모음기’ 대신 ‘책’을, ‘물 보관기’ 대신 ‘컵’을, ‘몸 감싸개’ 대신 ‘티셔츠’라고 이름을 붙이는 것과 같다. 물론 프린터나 컴퓨터 같은 예외도 있지만 그것들은 아주 최근에 발명됐고 이 글을 읽지 않는 사람들에 의해 만들어진 것들이다. 🙂

예를 들어, 다음과 같은 이름은 그것의 소유자가 누구인지에 대해 우리에게 말해준다. 사과, 파일, 일련의 HTTP 요청, 소켓, XML 문서, 사용자 리스트, 정규 표현식, 정수, PostgreSQL 테이블, 제프리 레보스키(옮긴이-“위대한 레보스키”라는 영화의 주인공). 적절한 이름을 가진 객체는 언제나 작은 그림으로 그려질 수 있다. 심지어 정규 표현식조차 그려질 수 있다.

반대로 다음은 그것의 소유자가 무엇을 하는지 우리에게 알려주는 이름의 예다. 파일 리더, 텍스트 파서, URL 검증기, XML 프린터, 서비스 로케이터, 싱글턴, 스크립트 실행기, 자바 프로그래머. 이것들 중에서 그림으로 그릴 수 있는 것이 있는가? 그렇지 않을 것이다. 이러한 이름은 좋은 객체로 적합하지 않다. 그것들은 끔찍한 설계로 이어지는 끔찍한 이름들이다.

일반적으로, “-er”로 끝나는 이름은 피하라. 그것들 중 대부분은 좋지 않다.

“그럼 FileReader 대신 뭘 써야 할까요?”라고 묻고 싶을 것이다. 더 나은 이름은 무엇일까? 어디 한번 보자. 이미 디스크 상의 실제 파일을 나타내는 File을 가지고 있다. 이 이름만으로는 충분히 강력하지 않은데, 파일의 내용을 어떻게 읽어야 할지 알지 못하기 때문이다. 그러한 능력을 지닌 좀 더 강력한 것을 만들고 싶다. 그것을 뭐라고 불러야 할까? 기억해 두자. 이름은 그것이 무엇인지를 말해야 하고, 무슨 일을 하는지 말해서는 안 된다. 그 객체는 무엇인가? 그것은 데이터를 가진 파일이다. File 같은 단순한 파일이 아니라 데이터를 가진 좀 더 세련된 것이다. 그럼 FileWithData나 단순히 DataFile은 어떨까?

같은 논리를 다른 모든 이름에도 적용할 수 있을 것이다. 언제나 그것이 무슨 일을 하는지가 아니라 그것이 무엇인지에 관해 생각하자. 객체에 직명 대신 실제의, 유의미한 이름을 부여하라.

Don’t Create Objects That End With -ER에서 좀 더 자세한 내용을 확인할 수 있다.

7. 객체의 클래스가 Final이나 Abstract다

badge

좋은 객체는 final 클래스나 abstract 클래스에서 온다. final 클래스는 상속을 통해 확장할 수 없는 클래스다. abstract 클래스는 인스턴스를 가질 수 없는 클래스다. 간단히 말해서, 클래스는 다음과 같이 말해야 한다. “넌 나를 분해할 수 없어. 난 블랙박스야”라거나 “난 이미 망가졌어. 날 먼저 고친 후에 사용해.”

그 사이에는 아무것도 없다. final 클래스는 어떤 식으로도 변경할 수 없는 블랙박스다. final 클래스는 원래 동작 그대로 동작하고, 그것을 사용하거나 사용하지 않으면 그만이다. final 클래스의 프로퍼티를 상속하는 또 다른 클래스는 만들 수 없다. final 수정자 때문에 이렇게 하는 것은 금지된다. 이러한 final 클래스를 확장하는 유일한 방법은 final 클래스의 자식을 데코레이션하는 것을 통해서다. HTTPStatus(앞에서 예로 든)라는 클래스가 있고, 이 클래스가 마음에 들지 않는다고 해보자. 음, 이 클래스가 마음에 들긴 하지만 원하는 만큼 강력하진 않다. 나는 HTTP 상태가 400 이상일 경우 이 클래스가 예외를 던지게 만들고 싶다. 그래서 read() 메서드에서 지금 하는 일보다 더 많은 일을 하길 바란다. 전통적인 방법은 클래스를 확장해서 이 메서드를 오버라이드하는 것이다.

class OnlyValidStatus extends HTTPStatus {
  public OnlyValidStatus(URL url) {
    super(url);
  }
  @Override
  public int read() throws IOException {
    int code = super.read();
    if (code >= 400) {
      throw new RuntimeException("Unsuccessful HTTP code");
    }
    return code;
  }
}

이건 왜 잘못됐을까? 이 방법이 아주 잘못된 이유는 부모 클래스의 메서드 중 하나를 오버라이드함으로써
전체 부모 클래스의 로직을 망가뜨릴 위험을 무릅써야 하기 때문이다. 자식 클래스에서 read() 메서드를 오버라이드하게 되면 부모 클래스의 모든 메서드가 자식 클래스의 새로운 버전을 사용하게 된다는 점을 기억하자. 말 그대로 클래스에 새로운 “구현의 일부”를 곧바로 집어넣고 있는 것이다. 철학적으로 말하자면 이것은 범죄 행위다.

반면 final 클래스를 확장하려면 그것을 블랙박스로 간주하고 여러분만의 구현체로 그것을 장식(Decorator 패턴으로도 알려진)해야 한다.

final class OnlyValidStatus implements Status {
  private final Status origin;
  public OnlyValidStatus(Status status) {
    this.origin = status;
  }
  @Override
  public int read() throws IOException {
    int code = this.origin.read();
    if (code >= 400) {
      throw new RuntimeException("Unsuccessful HTTP code");
    }
    return code;
  }
}

이 클래스는 원본 클래스인 Status와 동일한 인터페이스를 구현하고 있음을 눈여겨보자. HTTPStatus의 인스턴스는 생성자를 통해 이 클래스로 전달되고 캡슐화될 것이다. 그런 다음, 모든 호출을 가로채어 필요한 경우 다른 방식으로 구현될 것이다. 이 설계에서는 원본 객체를 블랙박스로 취급하고 그것의 내부 로직은 전혀 건드리지 않는다.

final 키워드를 사용하지 않는다면 누군가(여러분 자신을 포함해서)가 클래스를 확장한 다음… 범죄 행위를 저지를 것이다 🙁 그러니 final을 사용하지 않는 클래스는 나쁜 설계다.

추상 클래스는 정확히 정반대의 경우다. 추상 클래스는 그것이 불완전하고 “지금 모습 그대로” 사용할 수 없음을 말해준다. 따라서 추상 클래스에 별도의 구현 로직을 집어넣어야 하며, 이때 우리가 건드릴 수 있게 허용한 곳에만 넣을 수 있다. 이러한 지점에는 명시적으로 abstract 메서드로 표시돼 있다. 예를 들어, HTTPStatus는 다음과 같을 것이다.

abstract class ValidatedHTTPStatus implements Status {
  @Override
  public final int read() throws IOException {
    int code = this.origin.read();
    if (!this.isValid()) {
      throw new RuntimeException("Unsuccessful HTTP code");
    }
    return code;
  }
  protected abstract boolean isValid();
}

보다시피 이 클래스는 HTTP 코드의 유효성을 정확히 어떻게 검증해야 할지 알지 못하며, 그 부분은 상속과 isValid() 메서드의 오버라이드를 통해 로직을 주입하길 기대한다. 이 클래스를 상속하는 것으로는 이 클래스에 대해 범죄 행위를 저지르지 않는 셈인데, 다른 메서드는 모두 final로 방어했기 때문이다(메서드의 수정자를 눈여겨보라). 따라서 이 클래스는 우리의 범죄 행위에 대비하고 있고, 완벽하게 방어하고 있다.

정리하자면 클래스를 설계할 때는 final이나 abstract를 지정해야 하며, 그 중간은 없다.

업데이트(2017년 4월): 구현 상속이 나쁘다는 주장에 동의한다면 여러분이 작성하는 모든 클래스는 final 클래스여야 한다.

메서드명은 주의 깊게 골라라

메서드명은 주의 깊게 골라라 published on 메서드명은 주의 깊게 골라라에 댓글 없음

참고: 이 글은 Elegant Object 책의 샘플 PDF로 제공되는 내용을 저자의 허락하에 번역한 것입니다.


2.4 메서드명은 주의 깊게 골라라

이미 1.1 절에서 클래스 이름을 지정하는 방법을 살펴봤다. 이제 메서드명을 적절히 지어볼 시간이다. 가장 간단하고도 중요한 규칙은 다음과 같다: 빌더(builder)는 명사이고, 조종자(manipulator)는 동사다. 이것이 무슨 뜻인지 알아보자.

무언가를 만들어 새로운 객체를 반환하는 메서드를 빌더라고 한다. 이 이름은 그냥 내가 만들긴 했지만 나에게는 논리적인 이름으로 보인다. 빌더는 항상 뭔가를 반환한다. 빌더는 절대 void를 반환하지 않으며, 빌더의 이름은 항상 명사다.예를 들면 다음과 같다.

int pow(int base, int power);
float speed();
Employee employee(int id);
String parsedCell(int x, int y);

마지막 메서드인 parsedCell()을 눈여겨보자. 이 메서드는 명사는 아니지만 앞에 형용사가 붙어있다. 이렇게 해도 원칙을 위배하지는 않는다. 단지 이름을 좀 더 자세히 설명할 뿐이다. 이 이름은 여전히 명사지만 이 메서드에 관한 정보를 더 담고 있다. 단순히 셀이 아니라 파싱된 셀인 것이다. 우리는 이 메서드가 어떤 식으로든 내용을 변형한 셀을 반환하리라 예상할 것이다.

객체를 통해 추상화된 현실 세계의 개체에 변형을 가하는 메서드를 조종자라고 한다. 조종자는 항상 void를 반환하고 그것의 이름은 늘 동사다. 예를 들면 다음과 같다.

void save(String content);
void put(String key, Float value);
void remove(Employee emp);
void quicklyPrint(int id);

마지막 메서드인 quicklyPrint()를 눈여겨보자.이 메서드의 이름은 앞에 부사가 붙은 동사다. 여기서 핵심적인 부분은 “print”라는 동사이고, “quickly”는 단순히 이 동사를 설명해줌으로써 이 메서드의 문맥과 목적에 관한 추가 정보를 준다는 것이다.

다시 말하지만, 이러한 빌더와 조종자는 이번 장의 내용에 맞춰 내가 만들어낸 용어다. 빌더와 조종자를 다른 식으로 불러도 되지만 ‘빌더는 무언가를 만들고 조종자는 조작한다’라는 원칙은 온전히 지키려고 노력해야 한다. 그리고 빌더와 조종자 사이에는 아무것도 없다. 무언가를 조작하고 반환하는 메서드뿐 아니라 무언가를 만들고 동시에 조작하기도 하는 메서드가 있어서는 안 된다. 몇 가지 나쁜 예를 들어 보겠다.

// 저장된 전체 바이트 수를 반환
 int save(String content);

// 맵이 변경됐다면 TRUE를 반환
 boolean put(String key, Float value);

// 속도를 저장하고 이전 값을 반환 
float speed(float val);

나중에 3.5절에서 “설정자(setter)”와 “접근자(getter)”에 대해 살펴보겠지만 여기서는 이미 get으로 시작하는 이름이 잘못됐다는 점이 훤히 드러난다. 그 이유는 “get”은 동사지만 접근자 메서드는 기본적으로 뭔가를 반환하도록 만들어진 빌더이기 때문이다. 따라서 이것이 바로 “접근자” 메서드에 대한 나의 첫 주장이다.

이제 이 아이디어에 대해 설명해야 할 것 같다. 이 주장에 찬성하는 몇 가지 논거들이 있다.

2.4.1 빌더는 명사다

우선 메서드에서 뭔가를 반환할 경우 메서드명을 동사로 짓는 것은 잘못됐다고 생각한다. 이러한 이름은 객체 중심의 사고와 충돌한다. 내가 제과점에 들렀다면 “브라우니 하나 만들어 주세요”라거나 “커피 한 잔 끓여주세요”라고 말하지 않는다. 그 대신 “브라우니 하나 주세요”나 “커피 한 잔 주세요.”라고 말할 것이다. 뭔가를 “만들어 주세요”라거나 “끓여주세요”라고 말한다면 말투가 다소 공격적으로 느껴질 것이다. 브라우니가 정확히 어떻게 만들어지거나 커피가 정확히 어떻게 끓여지는지 신경 써서는 안 된다. 그것들을 어떻게 만들지는 제과점에서 신경 쓸 일이다. 나는 객체(브라우니나 커피)를 요구하고 있다. 제과점은 내 요구를 충족시킬 수 있다. 제과점 내부에서 이 같은 일이 정확히 어떻게 일어나는지는 내가 상관할 바가 아니다. 다음은 제과점을 코드로 표현한 예다.

class Bakery {
  Food cookBrownie();
  Drink brewCupOfCoffee(String flavor);
}

위의 두 메서드는 사실 객체의 메서드가 아니다. 이 두 메서드는 절차(procedure)에 해당한다. 제과점에서 명명한 이름을 통해 우리는 제과점을 하나의 자족적이고 자주적인 객체로서 존중해야 하고 제과점이 어떤 역할을 하는지 이해할 수 있다. 이것은 절차적인 접근법이지 객체 지향적인 접근법이 아니다. 다음은 이 두 가지 절차가 C에서는 어떻게 설계될지 보여주는 예다.

Food* cook_brownie() {
  // 브라우니를 만듬
  // 만든 브라우니를 반환
}

Drink* brew_cup_of_coffee(char* flavor) {
  // 커피를 끓임
  // 끓인 커피를 반환
}

제과점이 아무데도 관여하지 않는다. 단순히 C 문법으로 된 두 개의 기계어 명령이 있고 그것들을 호출할 뿐이다. 이를 C 언어에서는 함수라고 하지만 함수는 함수형 프로그래밍과는 거의 무관하기 때문에 실제로는 절차에 해당한다. 우리는 컴퓨터에게 이러한 명령어를 실행해서 그 결과를 반환하도록 요청한다. 이것은 컴퓨터처럼 생각하는 것이지 객체처럼 생각하는 것이 아니다. 우리는 제과점을 신뢰하지 않기 때문에 마시고 싶은 커피를 요청한 다음 결과물이 만들어지도록 맡기는 대신 “커피를 끓여달라고”라고 주문하는 것이다.

너무 철학적인 이야기처럼 들리고 싶진 않지만 이러한 이름 짓기라는 주제는 사실 매우 추상적이고 개념적이다. 적절히 명명된 메서드는 사용자가 객체의 설계 목적과 사명, 존재 목적을 비롯해 객체에게 삶의 의미란 무엇인가를 더 잘 이해하도록 도와준다. 반면 부적절한 메서드명은 객체의 전체적인 인상을 망칠 수도 있고 사용자로 하여금 단순히 객체를 데이터 보관함이나 절차의 모음으로 여기게 만든다. 이것은 OOP 라이브러리, SDK, API 등에서 반복적으로 일어나는 아주 전형적인 실수다. 객체는 계약에 따라 동작하고 싶어하지 단순히 명령을 따르기만을 원하지는 않는다. 이 둘 사이에는 큰 차이점이 있다.

이 같은 이유로 메서드의 이름이 동사라면 그것은 기본적으로 객체가 “어떤 일을 할 것인가”를 알려준다. 그리고 객체에게 무언가를 “만들라”고 요청하는 것은 객체를 활용하는 공손하고 존중하는 방법이 아니다. 단지 만들고자 하는 것을 요청하기만 하고 그것을 어떻게 만들지는 객체가 결정하도록 내버려두자. 다음과 같은 이름은 모두 잘못된 것이다.

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(x,y) 대신 sum(x,y)을 사용하도록 제안한다는 점을 눈여겨보자. 사소하고 별로 중요하지 않은 변화로 보일 수도 있지만 실제로 사고하는 데 큰 차이를 빚어낸다. 우리는 객체가 xy에 더하도록 요청하지 않는다. 대신 객체가 두 값의 합을 만들어서 새로운 객체를 반환하도록 요청한다. 그 객체가 실제로 합계를 알아낼까? 알 수 없다. 아마도 그럴 테지만. 내가 유일하게 아는 것은 결과가 xy의 합계처럼 보일 것이라는 점이다. 다시 말하지만, 나는 객체가 무엇을 하라고 말하는 게 아니라 결과가 특정 계약, 즉 정수형 숫자라는 것을 준수해야 함을 요청하는 것이다.

이것이 바로 나의 첫 번째 주장이자 첫 번째 사용 예다. 우리는 객체로부터 뭔가를 받고 있는데, 다시 말해 객체로 하여금 무언가를 만들도록 요구하고 있다. 이제 우리가 객체로 하여금 무언가를 조작하도록 요청할 때 일어나는 두 번째 논쟁거리이자 사용 사례로 넘어가자.

2.4.2 조종자는 동사다

기억하고 있다시피 객체는 현실 세계의 개체를 표현한 것이다. File 클래스의 객체는 디스크상의 파일을 표현하고, Pixel 클래스의 객체는 화면상의 픽셀을 표현하며, Integer 클래스의 인스턴스는 램의 4바이트를 나타낸다(놀랐는가? 이 주제에 대해서는 3.4절에서 좀 더 자세히 살펴보겠다).

현실 세계의 개체를 조작해야 하는 경우, 우리는 객체가 그렇게 하도록 요청한다. 다음 예제를 보자.

class Pixel {
  void paint(Color color);
}
Pixel center = new Pixel(50, 50);
center.paint(new Color("red"));

예제에서는 center 객체가 50×50 좌표에 위치한 픽셀을 칠하도록 요청하고 있다. 이 경우 아무것도 생성되지 않을 것으로 예상한다. 단지 우리는 무언가에 변화를 가하고 싶고 객체는 그것을 표현한다. 이제 이것이 어떻게 절차가 아닌지 의문이 들지도 모른다. 이것은 이름이 동사로 돼 있고, 기본적으로 객체가 우리 대신 무언가를 하도록 지시하고 있다. 그렇다, 좋은 질문이지만 핵심적인 차이점은 반환된 결과다.

paint() 메서드는 결과를 반환하지 않는다. 제과점 은유를 사용하자면 이것은 바텐더에게 음악 볼륨을 높여달라고 부탁하는 것과 비슷하다. 바텐더가 음악을 더 크게 틀 것인가? 아마 그렇게 할 것이다. 어쩌면 그렇지 않을지도 모른다. 우리의 요청은 그냥 무시될 수도 있다. 이 경우 우리에게 무언가가 되돌아오리라 예상하지 않으므로 이것은 공격적이거나 무례한 것이 아니다. 앞서 부탁한 내용을 다른 식으로 표현하면 어떻게 들릴지 상상해 보자. “음악을 키워주시고 볼륨을 키우고 나면 볼륨이 얼마나 되는지 저한테 말해주십시오.” 값을 반환하는 조종자는 정확히 이런 모습이다. 굉장히 무례한 것이다.

따라서 차이점은 바로 반환값에 있다. 오직 빌더만이 값을 반환할 수 있고, 빌더의 이름은 반드시 명사여야 한다. 객체가 우리로 하여금 무언가를 조작하도록 허용할 경우 그 객체의 이름은 동사여야 하고 반환값이 없어야만 한다.

이 같은 주요 원칙을 염두에 둔다면 조금 덜 엄격한 명명 관례는 준수하는 것이 가능하다고 생각한다. 예를 들어, 빌더 패턴을 사용할 경우 메서드명 앞에 with 접두사를 둘 수 있다.

class Book {
  Book withAuthor(String author);
  Book withTitle(String title);
  Book withPage(Page page);
}

예제에서 withTitle이라는 이름은 bookWithTitle을 짧게 줄인 형태다. 모든 메서드에서 이러한 book 접두사를 생략하려면 with 접두사를 쓰기만 하면 된다. 그렇지만 원칙은 여전히 지켜지고 있다. 즉, 이러한 메서드는 빌더이고, 빌더의 이름들은 명사로 분류되고 있는 것이다.