26 May 2017

Implementation Patterns 04

Method

  • 프로그램은 복잡한 제어 흐름이 들어 있는 커다란 루틴
    • 문제: 하나의 거대한 루틴에서는 코드를 읽기가 어려움
  • 프로그램 로직을 여러 개의 메소드로 나누면 로직 간의 연관성을 나타낼 수 있음
    • 메소드 사용시 재사용 문제를 해결할 수 있다.
    • 메소드로 분리하는 것을 통해 전체 작업은 간단해져야 한다.
  • 메소드 관련 패턴
    • 조합 메소드: 다른 메소드에 대한 호출로 메소드를 작성한다.
    • 의도 제시형 이름: 메소드가 의도하는 바를 나타내는 이름을 사용한다.
    • 메소드 가시성: 메소드는 가급적 전용으로 한다.
    • 메소드 객체: 복잡한 메소드는 새로운 객체로 바꾼다.
    • 오버라이드 메소드: 특화를 나타내기 위해 오버라이드를 사용한다.
    • 오버로드 메소드: 같은 연산에 대해 다른 인터페이스를 제공한다.
    • 메소드 반환 타입: 반환 타입에는 가급적 가장 일반적인 타입을 사용한다.
    • 메소드 주석: 코드 자체에서 쉽게 얻을 수 없는 정보는 주석을 통해 나타낸다.
    • 도우미 메소드: 주요 연산을 좀 더 명확하게 표현하기 위해 작은 전용 메소드를 사용한다.
    • 디버그 출력 메소드: toString() 을 사용해서 유용한 디버그 정보를 출력한다.
    • 변환: 객체 형변환은 명확하게 표현한다.
    • 변환 메소드: 단순하고 제한적인 변환에 대해서는 원본 객체에서 변환된 객체를 반환하는 메소드를 제공한다.
    • 변환 생성자: 대부분의 변환에 대해서는 원본 객체를 인자로 취하는 변환될 객체의 생성자를 제공한다.
    • 생성: 객체 생성을 명확히 표현한다.
    • 완결 생성자: 완결된 형태를 갖는 객체를 반환하는 생성자를 작성한다.
    • Factory 메소드: 좀 더 복잡한 객체를 생성할 때, 생성자 대신 정적 메소드를 사용
    • 내부 Factory: 좀 더 많은 설명이 필요하거나 이후 개선이 필요한 객체 생성의 경우, 도우미 메소드로 캡슐화한다.
    • 컬렉션 접근자 메소드: 컬렉션에 제한적인 접근만을 허용하는 메소드를 제공한다.
    • Boolean 설정 메소드: 커뮤니케이션에 도움이 된다면, boolean 값을 설정하는 두 개의 메소드 (상태별로)를 제공한다.
    • Query 메소드: isXXX 라는 이름으로 된 메소드를 사용하여 boolean 값을 반환한다.
    • 동등성 메소드: equals()와 hashCode() 메소드를 정의
    • Getter 메소드: 필드 값을 반환하는 메소드를 사용
    • Setter 메소드: 필드 값을 설정하는 메소드를 사용


조합 메소드

추상화 수준이 비슷한 메소드 호출로 하나의 메소드를 구성하는 것이 좋다. 다음 코드를 보자.

void compute() {
  input();
  flags |= 0x0080; // ?
  output();
}

위의 메소드에서 2번째 라인은 무엇을 의미하는 걸까? 코드를 읽다가 갑자기 추상화 수준이 다르면 읽기 흐름이 깨진다. 이 코드를 다음과 같이 변경하면 코드를 읽을 때 좀 더 이해하기가 쉬울 것이다.

void setTranslate() {
  flags |= 0x0080;
}
void compute() {
  input();
  setTranslate();
  output();
}

위의 코드와 같이 비록 짧은 메소드를 하나 더 만들었지만 compute() 메소드를 분석할 때, 더 이해하기가 쉬울 것이다.

짧은 메소드를 많이 사용하는 것에 대한 반대 의견은 잦은 호출로 인해 성능 저하가 발생한다는 것이다. 그렇지만 CPU는 점점 빨라지고, 성능상 병목 현상을 일으키는 코드는 밀집되어 있는 경향이 있으므로 성능 문제는 실제 수행을 통해 측정 후 개선하는 것이 좋다.

  • 코드 세부 사항을 이해하려 할 때, 긴 메소드는 오히려 방해가 된다.
    • 사람은 수천줄에 이르는 코드의 세부 구현을 한 번에 이해하는 것은 불가능하다.
    • 세부 사항을 쉽고 빠르게 이해하기 위해서는 밀접하게 관련된 코드는 모아두고, 관련성이 떨어지는 코드는 분리하는 것이 좋다.
  • 메소드를 구성할 때는 추측이 아닌 사실에 근거하고 구성하는 것이 좋다.
    • 코드 구성이 시간을 들이면, 구현 도중에 새로 알게 되는 사실로 인해 번복하는 경우가 많다.


의도 제시형 이름

메소드를 사용하는 쪽에서는 메소드 이름을 통해 메소드가 하고자 하는 의도를 쉽게 파악할 수 있어야 한다.

Customer.linearCustomerSearch(String id);
...
Customer.find(String id);

두 번째 메소드 정의처럼 이름을 통해서는 메소드의 의도를 전달하고, 그 외 정보는 다른 방식으로 전달하는 것이 좋다.

  • 프로그래머가 모든 정보를 당장 전달하는 것이 능사는 아니다.
    • 특히 메소드가 어떻게 구현되었느냐는 것은 사용자에게 있어서 별로 중요하지 않을 수 있다.
Customer.findwithLinearSearch(String id);
Customer.findwithHashSearch(String id);

위의 코드와 같이 메소드가 어떤 id를 가지고 선형 탐색할 지, 해쉬 탐색을 할 지는 알 수 있겠지만 사용자 입장에서는 그리 중요한 것이 아닐 수가 있다. 이런 긴 메소드 이름은 이 메소드를 쓰는 코드의 가독성이 떨어진다.

  • 메소드의 이름은 그 메소드를 호출하는 코드가 표현하려는 바에 도움을 줄 수 있도록 지어야 한다.
    • 기존 인터페이스에 대해 유사하게 메소드를 구현한다면 그 인터페이스에 사용된 것과 같은 이름을 사용하라.
      • 가령 iterator를 구현한다고 했을 때, 그 인터페이스를 구현하는 게 아닐지라도 hasNext()나 next()와 같은 이름으로 짓는 것이 좋다.

메소드의 이름을 지을 때는 그 메소드를 호출하는 입장에서 생각해보자. 왜 이 메소드를 사용하는 것인가? 이름은 이 질문에 답을 얻을 수 있어야 한다.


메소드 가시성

자바에서 메소드 가시성, 즉 public, default (package), protected, private 역시 프로그래머의 의도를 전달한다.

  • 좀 더 많은 메소드를 public으로 만들수록, 그 클래스에 대한 인터페이스를 수정하기가 어려워진다.
    • 메소드를 되도록 적게 노출시킴으로써 유연성에 커다란 이득을 얻을 수 있다.
  • 가시성을 선택할 경우, 두 가지 비용을 고려해야 한다.
    • 유연성: 메소드를 적게 노출할 수록, 추후에 인터페이스를 수정하는 것이 용이하다.
    • 사용 비용: 메소드가 적은 클래스의 객체를 사용할 때, 사용 측에서 그 객체를 사용하기 위해 많은 작업을 해야한다.
    • 이 두 가지 비용을 적절하게 균형을 맞추는 것이 중요하다.
  • public
    • 패키지 외부에서 이 메소드를 사용할 수 있다.
    • 이 때 개발자는 이 메소드에 대한 코드 관리를 책임져야 한다. 메소드 로직을 수정할 경우, 모든 수정을 본인이 담당하거나 수정 사항을 알려야할 책임이 있다.
  • default (package)
    • 패키지 내부에서만 이 메소드를 사용할 수 있다.
    • 이 것을 선택하기 전에 이 메소드를 private로 구성할 수 있지 않은지 아니면 public으로 선언하는 것이 더 이득이 아닌지 생각해봐야 한다.
  • protected
    • 하위 클래스에서 코드를 재사용할 때 유용하다.
  • private
    • 외부에는 공개하지 않는다는 점에서 유연성을 확보해준다.

먼저 가장 제한적인 가시성을 가지는 private로 시작하여 필요에 따라 조금씩 가시성을 높여라.

  • final
    • 메소드를 final로 선언하는 것은 그 메소드를 사용하는 것은 자유이나, 메소드를 더 이상 바꿀 수 없음을 의미
    • 메소드를 변경하거나 오버라이드하면 복잡하고 미묘한 결과가 발생할 경우 유용
  • static
    • 복잡한 로직을 구현하는데에는 적합하지 않다.


메소드 객체

복잡하게 짜여진 메소드를 읽기 쉽게, 세부 구현 전달이 쉽도록 바꿔준다.

  1. 많은 수의 파라미터와 임시 변수를 사용하는 메소드를 찾아 그 일부를 서브 메소드로 변환
  2. 서브 메소드의 이름을 따서 클래스 정의하고 로직 구성
  3. 메소드가 사용하는 파라미터, 임시 변수들을 그 클래스의 필드로 정의
  4. 필드 값을 설정할 수 있도록 생성자 정의
  5. 기존 메소드에서 객체 인스턴스를 생성 후, 그 객체의 메소드를 호출하는 구문으로 변경

이 리팩토링된 새로은 클래스는 다시 한 번 리팩토링하기가 쉬울 수 있다. 모든 데이터가 필드로 변경되었으므로 파라미터를 사용할 필요도 없고 리팩토링할 수 있는 여지가 많기 때문이다.


오버라이드

이 오버라이드를 통해 유사한 연산 사의의 차이점을 상위 클래스와 하위 클래스로 나누어 표현할 수 있다.

상위 클래스 메소드를 잘 구성하여 가급적 작은 코드로 구성하였을 경우 오버라이드하기가 쉽다.

  • 오버라이드를 사용한다고 상위 클래스의 메소드를 호출할 수 없는 것이 아니다.
    • 이러한 메소드는 이름이 같은, 하위 클래스의 메소드에서만 호출하는 것이 좋다.
    • 여러 군데서 호출할 경우 흐름을 읽기가 어렵다.


오버로드

오버로드의 의미는 이 메소드를 사용함에 있어서 다양한 포맷이 존재한다라는 의미이다.

같은 메소드 이름에 다른 수의 파라미터를 사용하는 것은 좋지 않다. 메소드 이름 뿐만 아니라 파라미터도 유심히 살펴봐야 되기 때문이다. 너무 복잡해지면 어떤 인자를 사용했을 때 어떤 메소드가 호출될지 알아야 한다.

메소드 오버로드는 파라미터 타입만 다를 뿐, “같은 연산”을 수행해야 한다. 메소드의 의도가 다르다면 다른 이름을 사용해야 한다.


메소드 반환 타입

  • 메소드 작성시, 반환 타입은 기본 타입을 반환하기보다는 의도를 드러낼 수 있는 추상적인 타입을 사용하는 것이 좋다.
    • 추후에 반환 타입을 유연하게 변경할 수 있다.


도우미 메소드

  • 도우미 메소드의 목적은 당장 관련도가 떨어지는 세부 구현을 숨기고, 이 메소드를 사용하는 쪽에서 세부 구현 대신 도우미 메소드를 호출하게 함으로써 좀 더 코드를 읽기 쉽게 하기 위함이다.


toString()

  • 개발자에게 유용한 객체의 정보를 알려주기 위해 사용하는 것이 좋다.
    • 자주 수정되므로 이를 다른 목적으로 사용할 경우 코드가 망가지기 쉽다.
    • 그 밖의 문자열로 나타낼 수 있는 정보는 다른 메소드나 다른 클래스를 통해 표현


변환 메소드

Class Polar {
  Cartesian asCartesian() {
    ...
  }
}

위와 같이 특정 정보에서 다른 형태의 객체를 얻기 위해 변환 메소드를 사용할 수 있지만, 너무 자주 사용하면 클래스 코드를 일기 어려울 뿐만 아니라, 서로 다른 두 클래스간에 의존성이 생긴다.

  • 원래 의존성이 존재하지도 않았는데, 변환 메소드때문에 두 클래스간에 의존성이 생기는 것은 바람직하지 않다.


변환 생성자

다음과 같이 다른 객체를 파라미터로 취하여 새로운 객체를 생성할 때 사용한다.

public Meter {
  public Meter(User user) {
    ...
  }
}

이 생성자는 하나의 특정 타입 객체를 여러 타입으로 변환할 때 유용하다. 변환 메소드를 사용할 때보다 해당 특정 타입의 클래스에 메소드를 일일이 추가할 필요없기 때문이다.


Complete Constructor

객체는 연산을 하기위해 정보가 필요한데, 객체 생성시 생성자를 통해 객체를 사용하기 위한 값들을 전달할 수 있도록 해야한다. 다음과 같은 코드는 좋지 않다.

Rectangle box = new Rectangle();
box.setLeft(0);
box.setWidth(0);
box.setHeight(200);
box.setTop(0);

위와 같은 코드는 사용자 입장에서 이 객체를 생성하기 위해 어떤 파라미터들이 필요한지 정확하게 알기 어렵다.

  • 객체를 생성하는 방법이 여러가지라면, 각 경우마다 제대로 된 객체를 생성하는 생성자를 구현해야 한다.


Factory Method

객체 생성할 때 다른 방법은 그 클래스의 static 메소드를 통해 객체를 생성하는 것이다. 이 팩토리 메소드는 이 메소드를 통해 특정 작업이 필요한 경우에만 사용해야 한다.

  • 객체를 캐시에 저장해두었다가, 캐시 히트가 발생하면 캐시에 있는 객체를 꺼내 리턴하는 경우
  • 런타임에 타입이 결정되는 하위 클래스 객체를 반환하는 경우

팩토리 메소드 사용하면, 코드 읽는 사람의 입장에서는 이 객체를 생성하기 위해 어떤 작업이 일어나는지 궁금해할 수 있다. 다른 사람이 내가 짠 코드를 읽는 시간을 아껴주고 싶으면 평범한 객체 생성에 대해서는 그냥 일반 생성자를 사용하라.


Collection Access Method

다음과 같은 클래스가 있다고 해보자.

class BookStore {
  List<Book> books;

  ...

  List<Book> getBooks() {
    return books;
  }
}

위와 같이 정의했을 때, 사용자가 직접 컬렉션을 조작하게 되므로 컬렉션 필드에 의존하는 다른 객체의 필드가 유효하지 않을 수 있다.

어떤 특정 클래스 컬렉션 필드에 대해서는 제한적이지만 의미있는 접근을 제공하는 메소드를 다음과 같이 제공해야 한다.

class BookStore {
  List<Book> books;

  ...

  public addBook(Book book) {
    books.add(book);
  }
  public int getBookCount() {
    return books.size();
  }
}

만약 사용자가 컬렉션에 대해서 각각의 원소마다 접근할 필요가 생긴다면 iterator를 리턴하는 것이 좋다.

public Iterator getBooks() {
  return books.iterator();
}

또한 만약 내부 컬렉션에 대해서 수정되는 것을 금지하고 싶다면 iterator를 오버라이드하여 특정 메소드에 대해 예외를 던지게하여 금지시킬 수 있다.

public Iterator<Book> getBooks() {
  final Iterator<Book> reader = books.iterator();

  return new Iterator<Book>() {

    public boolean hasNext() {
      return reader.hasNext();
    }

    public Book next() {
      return reader.next();
    }

    public void remove() {
      throw new UnsupportedOperationException();
    }
  };
}

만약 이런 컬렉션에 대한 작업을 대신하는 메소드를 많이 구현하고 있다면 설계 상에 문제가 있을 확률이 높다. 만약 클래스가 적절한 작업을 제공하고 있다면 이런 메소드를 많이 구현할 필요가 없다.


boolean 메소드

어떤 객체의 boolean 상태를 변경하는데에 있어서 다음 코드와 같이 구현할 수 있을 것이다.

void setValid(boolean newState) {
  ...
}

그렇지만 상태를 변경함에 있어서 넘어오는 파라미터가 간단한 값이라면, 다음과 같이 프로시저 형태로 제공하는 것이 인터페이스를 더욱 명확히 할 수 있다.

public void valid();
public void invalid();

하지만 다음 코드와 같이 상태 변경에 있어 파라미터에 따라 분기문과 같이 로직이 달라질 경우, setValid(boolean newState) 형태로 제공하는 것이 낫다.

...
if (...boolean expression...) {
  ...
} else {
  ....
}


동등성 메소드

어느 특정 객체에 대해 동일성이 아닌, 동등성을 비교해야 한다면 equals()hashCode() 메소드를 구현해야 한다. 동등한 객체는 같은 해시 값을 가져야 하므로, hashCode() 메소드를 구현할 때, equals() 메소드를 구현하는데 사용한 데이터만을 사용해야 한다.


Getter

객체의 어떤 필드에 대해 접근을 허용하기 위한 한 가지 방법은 getXXX()와 같은 getter를 제공하는 것이다. 그런데 이런 getter를 사용한다는 것은 이 데이터를 사용하는 코드가 다른 곳에 있다는 것을 의미한다.

  • 데이터와 그 것을 다루는 로직은 한 군데에 있어야 변경할 때도 최소 영역에만 반영되므로, 이 원칙에 따라 getter는 최소한으로 제공하는 것이 좋다.
    • Getter를 무작정 만들기보다는 그 getter가 필요한 로직을 객체의 필드와 가까운 곳 (예를 들면 클래스 메소드를 따로 만들던지)에 위치시킬 수 없는지 확인하자.


Setter

Setter는 getter와는 다르게, 메소드 이름은 클라이언트 입장에서 짓는 것이 좋다. 다음과 같이 setter를 제공할 경우, 클래스 내부 구현이 노출된다.

object.setJustification(Paragraph.CENTERED);

위와 같이 정의하는 것보다는 다음과 같이 인터페이스 이름을 정해주면 코드 읽기가 쉬워질 것이다.

object.centered();
  • 관건은 가급적 외부에서 setter를 사용하는 것을 최소화하는 것이다. Getter 때와 마찬가지로 데이터와 그와 관련된 로직은 한 군데에 있는 것이 좋다.

Tags:
Stats:
0 comments