태그 보관물: object-oriented programming

좋은 객체의 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("https://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 클래스여야 한다.