3장에서는 거대한 객체, 정적 메서드, NULL 참조, getter, setter, new 연산자 사용을 반대하고 있다.
그 이유에 대해 자세하게 알아보도록 한다.
3.1 5개 이하의 public
메서드만 노출하세요
작은 객체가 응집력이 높고, 테스트도 용이하고, 유지보수가 가능하다.
적절한 public 메서드 수는 5개 (private
제외, protected
포함)
- 5개가 정확한 숫자는 아니지만 그 이상이 된다면 하나의 책임을 수행하고 응집도가 높은 클래스인지 확인하라.
- 10개보다 3개의 메서드들이 조화를 이루도록 만드는 것이 쉽다.
- 응집도가 높으면 각 메서드가 클래스의 모든 프로퍼티를 사용한다. (메서드들이 서로 다른 프로퍼티를 사용한다면 응집도가 낮음)
Review
메서드 갯수를 작게 유지하는 것이 좋다는 사실은 알고 있었고 충분히 공감되는 내용이었다.
하지만 entity에서 디미터 법칙을 어기지 않기 위해 public
메서드가 많이 생기게 되던데
과연 내가 올바르게 코드를 작성하고 있는 것인지 의문이 들었다.
3.2 정적 메서드를 사용하지 마세요.
정적 메서드 대신 객체를 사용해야 한다. 더이상 정적 메서드의 사용을 중단하라.
소프트웨어 어디에도 static 키워드를 사용하면 안된다.
3.2.1 객체 대 컴퓨터 사고 (Object vs Computer thinking)
정적 메서드는 절차적인 코드를 유도하게 되므로 객체로 만들어야 한다.
- 컴퓨터의 흐름은 항상 순차적이며 스크립트의 위에서 아래로 흐른다.
- 우리는 CPU 에게 명령을 내리는 것이 아닌 정의하는 것이고 그 명령의 흐름을 제어할 책임이 있다.
int max(int a, int b) {
if (a > b) {
return a;
}
return b;
}
모든 컴퓨터에서 실제 위 코드 처럼 제공된 명령어를 하나씩 순차적으로 실행한다. (절차지향)
class Max implements Number {
private final Number a;
private final Number b;
public Max(Number left, Number right) {
this.a = left;
this.b = right;
}
}
Number a = new Max(5,9);
객체 지향으로 작성하려면 코드는 위와 같이 변경되어야 한다.
실제 이 코드는 최댓값을 계산하지 않고 단순히 객체만 생성한다.
3.2.2 선언형 스타일 대 명령형 스타일 (Declarative vs Imperative style)
명령형 프로그래밍과 선언형 프로그래밍은 다른 클래스, 객체, 메서드가 사용하는 방법에 차이가 있다.
명령형 프로그래밍
프로그램의 상태를 변경하는 문장을 사용하여 계산 방식을 서술
선언형 프로그래밍
제어 흐름을 서술하지 않고 계산 로직을 표현
public static int between(int l, int r, int x) {
return Math.min(Math.max(l, x), r);
}
between()
메서드를 호출하면 즉시 CPU 가 계산하여 결과를 받는데 이것이 명령형 스타일이다.
class Between implements Number {
private final Number num;
Between(Number left, Number right, Number x) {
this.num = new Min(new Max(left, x), right);
}
@Override
public int intValue() {
return num.intValue();
}
}
Number y = new Between(4, 9, 10);
이 방식은 무엇인지만 정의하고 아직 CPU 에게 계산하라고 하지 않았기 때문에 선언형 스타일이다.
선언형 스타일의 장점
- 직접 성능 최적화를 할 수 있다.
- 계산 결과가 필요한 시점과 위치를 결정하도록 CPU에게 위임하고, 요청이 있을 때만 계산을 실행할 수 있어 더 빠르다.
- 다형성
- 모두 클래스로 이루어져 있기 때문에 코드 블록사이의 의존성을 쉽게 끊을 수 있다. (정적 메서드는 분리할 수 없다)
- 객체를 다른 객체로 완전 분리하기 위해서는 메서드나 주 ctor 에 new 연산자를 사용하면 안된다.
- 표현력 (expressiveness)
- 선언형 방식은 결과를 이야기하지만, 명령형 방식은 수행 가능한 한 가지 방법을 이야기한다. (명령형은 결과를 예상하고 머릿속에서 코드를 실행해봐야함)
- 응집도(cohesion)
- 아래 코드를 보면 Filtered 통해 한줄에 선언했다. 모든 코드들이 한곳에 모여있어서 실수로라도 분리가 불가능.
Collection<Integer> evens = new Filtered(
numbers,
new Predicate<Integer>() {
@Override
public boolean suitable(Integer number) {
return number % 2 == 0;
}
});
이미 많은 라이브러리에서 정적 메소드를 사용하고 있다. 객체를 직접 직접 처리할 수 있도록 정적 메서드를 감싸는 클래스를 만들어 분리해야 한다.
3.2.3 유틸리티 클래스 (Utility classes)
유틸리티 클래스
편의를 위해 정적 메서드들을 모아 놓은 정적 메서드들의 컬렉션 (helper라고도 함)
1.1 -er로 끝나는 이름을 사용하지 마세요 에서 클래스를 객체의 팩토리라고 표현했지만 이 클래스는 인스턴스를 생성하지 않기 때문에 클래스라고 부를 수 없다.
3.2.4 싱글톤(Singleton) 패턴
싱글톤 패턴은 정적메서드를 대신 사용할 수 있는 패턴이다. 일반적인 객체와는 매우 다르다.
싱글톤 패턴은 안티패턴이다.
싱글톤은 단순 전역 변수 그 이상도 이하도 아니다.
정적 메서드 또는 유틸리티 클래스가 존재하지만 싱글톤 패턴이 생긴 이유?
Math.max(5,9); // 유틸리티 클래스
Math.getInstance().max(5,9) // 싱글톤
싱글톤 패턴은 상태를 캡슐화 할 수 있다.
- 유틸리티 클래스라도 정적 필드를 선언한다면 동일하게 상태를 유지할 수 있다.
싱글톤은 분리가 가능하지만 유틸리티 클래스는 분리가 불가능하다.
- 정적 메서드는 객체가 없기 때문에 변경이 불가능하지만, 싱글톤은 내부에 캡슐화된 객체를 mock, fake 등 다른 객체로 대체할 수 있다.
- 이러한 이유로 싱글톤이 더 좋긴 하지만 그래도 안티패턴이다.
싱글톤을 대체할 수 있는 것은 바로 캡슐화이다! 정보가 필요한 모든 객체 안에 캡슐화를 해주도록 한다.
3.2.5 함수형 프로그래밍
작은 객체, 불변성, 정적메소드가 없어도 함수형 프로그래밍 보다는 객체 프로그래밍이 더 낫다.
- 함수형 프로그래밍은 함수만 사용할 수 있지만 OOP는 객체와 메서드 조합이 가능해 표현력이 뛰어나고 강력하다.
3.2.6 조합 가능한 데코레이터 (composable decorator)
그저 다른 객체를 감싸면 데코레이터(decorator)지만 다중 계층 구조라면 조합이 가능
names = new Sorted(
new Unique(
new Capitalized(
new Replaced(
....
)
)
)
);
위 코드는 단지 선언만 했을 뿐인데도 객체가 무엇인지 파악이 가능하다. 이런 객체들을 조합가능한 데코레이터라고 한다.
// 절차적인 코드
float rate;
if (client.age() > 65) {
rate = 2.5;
} else {
rate = 3.0;
}
// 객체 지향에서는 아래처럼 변경되어야 한다.
float rate = new If(
new GreaterThan(new AgeOf(client), 65),
2.5, 3.0
);
Review
간단한 로직들은 자주 유틸리티 클래스를 선언해서 사용해왔었다.
편리하게 사용하고 가독성을 위한다고 생각 했었지만 OOP 에 오히려 독이라니 반성해야겠다.
특히, 마지막에 if문까지 객체를 만들 수 있다는 점은 나에게 너무 새롭게 다가왔다.
3.3 인자의 값으로 NULL을 절대 허용하지 마세요
코드에 null 이 존재한다면 잘못된 것이다.
- null 은 객체가 자신의 행동을 온전히 책임진다는 객체 패러다임과 상반된다.
- 인자의 값으로 null 을 허용하면 비교문이 계속 생겨나게 되고 점점 객체를 퇴화시키게 된다.
- null 은 Java 언어가 안고 있는 설계상 커다란 실수이다.
null 대처 방법
- 방어적인(defensive) 방법으로 null 체크한 후 예외 던지기
public Iterable<File> find(Mask mask) { if(mask == null){ throw new IllegalArgumentException("Mask can`t be NULL;"); } }
- null 이 아니라고 가정하여 대응하지 말고 무시
- 필자가 선호하는 방식으로 NullPointerException 이 던져지도록 JVM 표준 방식으로 처리
3.4 충성스러우면서 불변이거나, 아니면 상수이거나
불변 객체로 모델링할 수 없다고 혼란이 생기는 이유는 상태(state) 와 데이터(data) 오해가 있기 때문이다.
- 불변 객체의 메서드는 항상 상수(constant)처럼 동일한 데이터를 반환할거라 기대하지말라.
- 상수처럼 동작하는 것은 불변성의 특별한 경우이다.
객체란 실제 엔티티의 대표자(representative)이다.
public void echo() {
File f = new File("/tmp/test.txt");
System.out.println("File size: %d", file.length());
}
위 코드에서 echo
메서드 안에서 만큼 객체 f
는 파일이다.
디스크에 저장된 파일의 좌표를 알아야 하는데 이 좌표가 바로 객체의 상태(state)이다.
모든 객체는 식별자(identity), 상태(state), 행동(behavior)을 포함한다.
- 불변 객체의 식별자는 객체의 상태와 완전 동일하다. (실세계의 엔티티에게 충성한다.)
- 가변 객체는 상태변경이 가능하므로 독립적인 식별자가 필요하다.
Review
사실 이번 섹션의 내용이 약간 추상적으로 설명되어있어서 이해하기 어려웠다.
하지만 상태와 데이터의 차이점을 이해하고 특별한 경우 상수 객체, 그 외에는 항상 불변 객체를 사용하라는 이야기였던 것 같다.
가변 객체가 아닌 불변 객체를 사용하자.
3.5 절대 getter
와 setter
를 사용하지 마세요
setter
와 getter
가 가지고 있는 데이터는 단순 자료 구조(data structure)이다.
3.5.1 객체 대 자료구조
-
클래스는 어떤 식으로든 멤버에게 접근과 노출을 허용하지 않는다.
이것이 바로 캡슐화(encapsulation) 이며, OOP가 지향하는 설계 원칙이다. -
선언형 스타일을 유지하기 위해 데이터를 객체안으로 감추로 노출하지 않아야 한다.
자료구조 : 투명, glass box, 수동적, 죽어있음
객체 : 불투명, black box 능동적, 살아있음
3.5.2 좋은 의도, 나쁜 결과
setter
와getter
는 캡슐화 원칙을 손쉽게 위반한다.- 메서드라 부가적인 로직을 추가할 수 있지만, 데이터에 직접 접근할 수 있는 진입점과 동일하다.
3.5.3 접두사에 관한 모든 것
setter
와 getter
은 끔찍한 안티패턴이다. 절대 이렇게 짓지 말자
setter
와getter
접두사가get
과set
이라는 사실이 중요하다.get
접두사는 객체를 데이터의 저장소로 취급하는 것이다.getDollars()
는 데이터를 노출하지만,dollars()
는 노출하지 않는다.
Review
이번 섹션은 항상 의문을 가지고 있던 내용이었기 때문에 기분좋게 읽었다.
처음 개발을 공부할 때 캡슐화 개념과 함께 setter
와 getter
선언 하는 것을 배웠던 것 같다.
데이터를 숨기라면서 왜 setter
와 getter
로 노출하지 라고 항상 이상하다고 여겼다.
이 패턴을 이용하는 라이브러리로 인한 어쩔 수 없는 경우 아니라면 무조건 사용하지 말아야겠다.
3.6 부 ctor 밖에서는 new를 사용하지 마세요
유일하게 부 생성자에서만 new
를 연산자를 사용할 수 있다.
class Cash {
private final int dollars;
public int euro() {
return new Exchange().rate("USD", "EUR") * this.dollars;
}
}
euro
메서드안에서new
연상자를 사용해 인스턴스를 사용하는데 이는 하드코딩된 의존성(hard-coded dependency) 이다.- 의존성을 끊기 위해서는
Cash
의 어쩔 수 없이 내부코드를 수정해야 한다. - 근본적인 원인은
new
를 사용한 것이다.
class Cash {
private final Exchange exchange;
private final int dollars;
public int euro() {
return this.exchange.rate("USD", "EUR") * this.dollars;
}
}
new
연산자 사용을 금지하면 객체를 프로퍼티안으로 캡슐화 하면 된다.- 부생성자를 제외한 주 생성자, 메서드 등 어떤 곳에서
new
를 사용하지 마세요. (객체간 분리, 테스트 용이성, 유지보수성 향상)
이 규칙이 의존성 주입(dependency injection)과 제어 역전(inversion of control) 관해 알아야하는 것이다. 규칙만 잘 지키면 코드는 깔끔해지고 언제라도 의존성 주입이 가능하다.
Review
현재 메서드 내부에서 새로운 객체를 생성하고 반화하는 코드들이 존재한다.
해당 데이터의 정보를 통해 새로운 객체를 만들어야 하는데 이 글을 읽고 다시 생각하게 되었다.
객체의 역할에 대해 더 고민해보고 이 규칙을 지켜봐야겠다.
3.7 인트로스펙션과 캐스팅을 피하세요
instanceof
연산자와 캐스팅을 하는 것은 안티패턴이다.
Java 의 instanceof
연산자, Class.cast()
등 타입 인트로스펙션(introspection)과 캐스팅(casting) 사용하면 안된다.
- 타입 인트로스펙션은 리플렉션(reflection) 일종으로 강력한 기법이지만 유지보수하기 어렵고 가독성이 떨어진다.
- 타입에 따라 객체를 차별하기 때문에 OOP 사상을 훼손
- 메서드 오버로딩(method overloading)을 통해 다른 타입의 새로운 메서드를 정의하라
Review
이 방식들은 정말 런타임중 에러를 일으킬 수 있는 위험한 코드이고 유지보수도 어렵다. 해결 방식으로 이 책에서는 메서드 오버로딩 방식을 소개했지만 전략 패턴이나 제너릭을 이용하여 클래스로 역할을 나누는 것은 어떨까 생각했다.