해당 포스팅은 레퍼런스에 명시된 번역본 포스팅을 읽으며 스스로 요약하고 정리한 글임을 밝힙니다.
서문 : 클래스 VS 객체
- 클래스는 새로운 객체를 생성하는 것 뿐만 아니라 더 이상 사용하지 않는 객체를 파괴하는 책임 또한 가지고 있다.
- 클래스는 해당 클래스를 상속받은 자식 클래스들이 따라야하는 계약, 즉 어떤 상태 값과 행위를 가지는지 알고 있다.
- 클래스를 객체 템플릿으로 정의하는 관점도 있는데 이는 클래스를 굉장히 수동적인 위치에 있는 것으로 생각하는 것이다.
- 하지만 객체들이 클래스에게 새로운 객체를 만들어달라고 요청했을 때, 클래스는 객체를 만들어내는 등 굉장히 주체적으로 자신의 책임을 수행한다.
1. 객체가 현실세계에 존재한다.
- 객체는 하나의 생명체로써 자신만의 생명주기, 행위, 습관을 지닌 독립적인 개체이다.
- 우리가 구현하는 프로그램의 범위 바깥에 존재하는 모든 것을 현실 세계라고 가정하자.
- 현실 세계에는 스스로 생명주기, 행위, 습관을 지닌 피조물들이 존재한다.
- 객체는 이러한 현실세계의 피조물들을 대표하며 프로그램 상에서 해당 피조물의 대리자(proxy) 역할을 수행하는 것이다.
- 컨트롤러, 파서, 필터, 검증기, 서비스 로케이터, 싱글턴, 팩토리 등은 이러한 관점에서 좋은 객체가 아니다.
- 이들은 GoF 패턴들로 안티패턴이라 불린다.
- 안티패턴 : 실제로 많이 사용되는 패턴이지만 비효율적이거나 비생산적인 패턴을 의미한다.
- 이들은 현실 세계의 피조물을 대표하는 대리자들이 아니며 단지 다른 객체와 함께 사용하기 위해 만들어낸 것들이다.
- 자신이 구현한 객체가 현실세계의 어떤 피조물을 대표하는지 생각해보고 답을 찾을 수 없다면 리팩토링을 고려해야 한다.
2. 객체가 계약에 따라 동작한다.
- 객체는 스스로의 특성이 아니라 자신이 준수하는 계약에 따라 사용되길 예상한다.
- 여기서 계약은 자식 클래스가 상속받는 인터페이스/부모클래스로부터 스스로가 어떻게 동작해야 하는지를 결정받는 것이라 이해했다.
- 좋은 객체 안에 담긴 모든 공용 메서드는 인터페이스 상에 선언된, 상응하는 메서드들을 구현한 것이다.
- 만약 자신이 구현한 객체에 어떤 인터페이스에도 상속되지 않은 공용 메서드가 있다면 그 객체는 잘못 설계된 것이다.
- 이렇게 판단하는 데에는 다음과 같은 2가지 이유가 있다.
- 첫째, 상위 클래스와 계약 없이 동작하는 객체는 단위 테스트에서 목킹하는 것이 불가능하다.
- 둘째, 계약 없는 객체는 데코레이션을 통해 확장하는 것이 불가능하다.
- 데코레이션 : 주어진 상황 및 용도에 따라 어떤 객체에 책임을 덧붙이는 패턴으로, 기능 확장이 필요할 때 서브클래싱 대신 쓸수 있는 유연한 대안이 될 수 있다.
- 이렇게 판단하는 데에는 다음과 같은 2가지 이유가 있다.
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();
}
}
- 위 코드를 아래와 같이 사용해 인스턴스를 생성했을 때 모든 인스턴스는 서로 동일한 것으로 판단된다.
first = new HTTPStatus();
second = new HTTPStatus();
assert first.equals(second);
- page라는 URL 타입을 캡슐화하고 있는데 왜 복제본이냐라고 생각할 수 있다.
- page에 담기는 URL 객체는 항상 로컬호스트 주소값을 가지는 URL 객체이므로 일종의 상수처럼 적용되기 때문에 캡슐화된 고유한 상태 값이 없다고 볼 수 있는 것이다.
- 정적(static) 메서드만이 담긴 유틸리티 클래스는 좋은 객체를 인스턴스화 할 수 없다.
- 유틸리티 클래스는 단지 현대 객체지향 언어를 발명한 사람들이 정적 메서드를 사용할 수 있게 만들어 뒀다는 이유로 존재하는 것 뿐이다.
4. 객체가 불변적이다.
- 좋은 객체는 자신이 캡슐화한 상태를 절대 변경하지 않는다.
- 불변성이 모든 객체 내 메서드가 동일한 값만을 반환하는 것이라고 이해하면 안된다.
- 오히려 좋은 불변 객체는 매우 역동적이며 그럼에도 자신의 내부 상태는 절대 변경하지 않는다.
- 다음은 불변성이 왜 좋은 객체의 덕목에 포함되는지에 대한 이유들이다.
- 불변 객체는 생성, 테스트, 사용하기가 더 간단하다.
- 진정한 불변 객체는 언제나 스레드 세이프하다.
- 불변 객체는 시간적 결합(temporal coupling)을 피하는 데 도움이 된다.
- 불변 객체의 사용은 부수효과를 발생시키지 않는다(방어적 복사를 하지 않아도 된다).
- 불변 객체는 언제나 실패 원자성을 띤다.
- 불변 객체는 캐싱하기가 훨씬 더 쉽다.
- 불변 객체는 NULL 참조를 방지한다.
5. 객체의 클래스에 정적 멤버가 없다.
- 정적 멤버는 객체의 행위가 아니라 클래스의 행위를 구현하는 것이다.
- 정적 메서드는 객체지향 프로그래밍을 클래스 지향 프로그래밍으로 바꾸기 떄문에 문제를 발생시킨다.
- 클래스 지향 프로그래밍의 경우 분해(decomposition)가 더 이상 작동하지 않는 데 있다.
- 객체지향 프로그래밍의 위력은 객체를 범위 분해(scope decomposition)를 위한 도구로 사용할 수 있다는데 있다.
- 메서드 내에서 객체를 인스턴스화 하면 해당 객체는 특정 과업을 수행하기 위해 전념한다.
- 이 때 해당 객체는 메서드 외부 다른 모든 객체들로부터 완벽하게 고립될 수 있다. (메서드의 지역변수)
- 하지만 정적 메서드를 가진 클래스는 어떻게 사용하건 간에 내부 변수들이 전역변수처럼 사용된다.
- 따라서 정적 메서드를 가지는 경우 범위 분해의 도구로 사용될 수 있다는 장점을 상실하게 된다.
6. 객체의 이름이 직명을 나타내지 않는다.
- 객체의 이름은 객체가 무엇인지 말해야 하지 무슨 일을 수행하는지 말해서는 안된다.
- 우리가 '페이지 모음기' 대신 '책'을 '물 보관기' 대신 '컵' 등의 이름을 붙이는 것과 같다.
- 일반적으로 -er 로 끝나는 이름은 피하자. 대부분의 경우 좋지 않은 설계로 이어지게 되는 이름들이다.
- FileReader 대신 FileWithData 혹은 DataFile 등과 같은 이름을 사용하도록 하자.
- 언제나 객체가 무슨 일을 하는지가 아니라 그것이 본질적으로 무엇인지 고민하도록 하자.
7. 객체의 클래스가 Final이나 Abstract이다.
- 좋은 객체는 final 클래스나 abstract 클래스에서 온다.
- final 클래스는 더 이상 쪼갤 수 없는 블랙박스와 같은 클래스이다.
- 클래스가 설계된 동작 그대로 동작하고, 그것을 사용하거나 사용하지 않으면 그만이다.
- final 클래스를 다른 클래스에 상속시킬 수는 없다.
- final 클래스를 확장하는 유일한 방법은 final 클래스의 자식을 데코레이션하는 것이다.
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;
}
}
- 위 클래스는 HTTPStatus 클래스를 직접 상속받고 있다.
- 이는 부모 클래스인 HTTPStatus 메서드 중 하나인 read()를 오버라이드 함으로써 전체 부모 클래스의 로직을 망가뜨릴 위험을 무릅쓰는 것이다.
- 즉 HTTPStatus 클래스의 다른 기존 메서드들이 새롭게 오버라이드된 OnlyValidStatus 클래스의 read 메서드를 사용하게 되는 것이다.
- 말 그대로 클래스에 새로운 '구현의 일부'를 삽입하는 것으로 매우 위험한 행위이다.
- 반면 final 클래스를 확장하려면 확장하는 대상을 블랙박스처럼 간주하고 우리가 직접 구현하는 구현체에 데코레이터 패턴을 사용해야 한다.
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의 인스턴스는 생성자를 통해 클래스에 전달되고 캡슐화된다.
- 그 뒤 필요한 경우 HTTPStatus의 인스턴스에 대한 모든 호출을 가로채서 다른 방식으로 구현할 것이다.
- 이 설계에서는 HTTPStatus 객체를 블랙박스로 취급하고 그것의 내부 로직은 전혀 건드리지 않는다.
- 위 사례들에서 알 수 있었듯 클래스에 final 선언을 해주지 않으면 누군가가 클래스를 확장한 다음 잘못된 기능 삽입을 행할 수 있다.
- 그러니 추가적인 확장을 원하지 않는다는 것을 명시하기 위해 final 키워드를 반드시 사용해주자.
- 추상 클래스의 경우 정확히 반대의 경우이다.
- 추상 클래스는 현재 클래스가 불완전하고 '지금 모습 그대로' 사용할 수 없음을 말해준다.
- 따라서 추상 클래스에 별도로 로직을 구현해줘야 하며 이 때 우리가 건드릴 수 있게 허용한 기능만 구현할 수 있다.
- 이렇게 허용된 기능 즉 메서드를 abstract 키워드를 통해 명시한다.
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로 방어하고 추가 구현을 원하는 기능만 abstract로 명시를 해줬기에 해당 클래스를 상속하는 것은 문제가 없다.
- 정리하자면 클래스를 설계할 떄에는 반드시 final이나 abstract를 지정해야 하며 그 중간은 없다.
Reference
'우아한테크코스 > 학습 정리' 카테고리의 다른 글
[Level 2] Layered Architecture에 대한 개인적인 고찰 (0) | 2023.04.27 |
---|---|
[Level 2] Repository와 Dao를 분리하는 기준 (5) | 2023.04.21 |
[Level 2] 공식문서를 통한 프레임워크 학습 방법에 대한 고찰 (1) | 2023.04.16 |
[Level 1] 인터페이스와 추상클래스에 대한 개인적인 고찰 (0) | 2023.04.08 |
[Level 1] Dto 사용에 관한 개인적인 고찰 (3) | 2023.03.19 |