'상수' 라고도 불리는 public static final 프로퍼티는 객체 사이에 데이터를 공유하기 위해 사용하는 매우 유명한
메커니즘입니다. 하지만 저자는 객체들은 어떤 것도 공유해서는 안된다고 생각합니다.
대신 독립적이고, 닫혀있어야(closed) 된다고 생각하죠.
예제를 살펴보며 얘기를 해볼게요,
이 코드는 구조화된 데이터를 writer 객체에 기록하고,
각 줄을 '개행(new line)문자'로 종료하는 메서드를 나타낸거에요.
이 예제에서 static final 프로퍼티인 EOL은
1. private이고
2. Records 클래스의 내부에서만 사용
됩니다. 이는 완전히 올바른 상황이에요! 필요할 때마다 반복적으로 '\n\n'을 작성하기는 싫어요.
이제 다른 예제를 볼게요. 매우 유사한 방식으로 동작하지만,
다른 객체를 사용하는 Rows 클래스 입니다.
이 예제는 앞서 본 예제와 다릅니다.
하지만 두 클래스 모두 'EOL'이라는 private 상수를 정의하고 있습니다.
private static final String EOL = "\r\n";
이 문장 말이에요!
이 문장은 중복이죠? C언어의 경우에는
#define 매크로를 사용해서 중복을 제거할 수 있어요.
#define EOL "\r\n"
이렇게 말이에요!
하지만 우리는 C언어를 더이상 사용하지 않죠?
OOP에는 객체가 존재하기 때문에 퍼블릭 상수를 이용해서 중복 문제를 해결하는 것은 잘못된 접근 방식이에요!
(퍼블릭 상수는 매우 절차적이에요)
Java에서는 다음과 같이 객체를 사용해서 중복 문제를 '해결'해야 해요.
public class Constants{
public static final String EOL = "\r\n";
}
이렇게 객체를 사용해서요~!
이 렇게 객체를 사용하는 방법은 앞서 C의 #define과 크게 다르지 않아요.
두 방법 모두 1. 전역 범위에서 2. 어떤 클래스라도 사용할 수 있어요.
이렇게 Constants 클래스를 추가하고, Records와 Rows가 각각 Constants.EOL을 사용하도록 코드를 변경함으로써
코드 중복 문제를 '해결'했습니다.
=> Records와 Row는 더 이상 지역적으로 상수를 정의할 필요가 없죠 !
=> 어디에서나 접근할 수 있는 퍼블릭 상수를 '재사용' 할 수 있기 때문입니다.
하핫 문제를 해결한 것처럼 보이나요?
하지만 절대 아닙니다. xxx
1. 코드 중복 => 해결
2. 결합도 => 높아짐
3. 응집도 => 낮아짐
더 큰 문제를 추가해버렸네요.
2.5.1 결합도 증가
Records 클래스와 Rows클래스의 현재 모습을 살펴볼게요.
1. 두 클래스는 Constants라는 같은 객체에 의존하고 있으며
2. 이 의존성은 하드 코딩되어 있습니다.
1. Records.write()
2. Rows.print()
3. Constants.EOL
이 세 곳에서 코드의 일부가 서로 결합되어 의존하고 있어요.
만약 Constants.EOL의 내용을 수정한다면 ?
다른 두 클래스의 행동은 예상할 수 없는 방향으로 변경될거에요.
왜? Constants.EOL을 변경하는 입장에서는 이 값이 어떻게 사용되고 있는 지 알 수 있는 방법이 없기 때문이에요.
Constants.EOL 객체는 사용방법과 관련된 그 어떤 정보도 제공하지 않은 채
1. 모든 곳에서 접근 가능하고
2. 전역 가시성 안에 방치
그 결과는 어떻게 되었을까요?
=> 유지보수성이 크게 저하
기억하세요!
많은 객체들이 다른 객체를 사용하는 상황에서 서로를 어떻게 사용하는지 알 수 없다면
이 객체들은 매우 강하게 결합되어 있는 것 !!
EOL은 그나마 사용법이 명확하기에 다행이지
상수가 더 복잡해지면 문제는 더 심각해진다구요.
2.5.2 응집도 저하
퍼블릭 상수를 사용하면?
=> 객체의 응집도는 낮아집니다.
객체들은 상수를 다루는 방법을 알고 있어야 합니다.
상수는 ? 매우 멍청합니다. 쉽게 말해 Constants.EOL은 자신의 존재 이유를 이해하지 못하는, 멍청이입니다.
이에 객체는 이 멍청한 상수 위에 자신만의 의미론을 덧붙여야 합니다.
그 의미를 덧붙이기 위해서는 클래스 안에 상수를 다루기 위한(목적을 명확히 만들어줄) 코드를 작성해야 합니다.
하지만 이런 코드를 작성하는 것은 Records와 Rows가 의도했던 원래의
목적과는 상이할 수 밖에 없습니다.
Records와 Rows의 목적은 한 줄의 마지막을 처리하는 것이 아니라 레코드나 로우 자체를 처리하는 것
=> 한 줄을 종료하는 작업을 다른 객체에게 위임한다면 클래스는 더 높은 응집도를 유지할 수 있음
대안 : 데이터가 아니라 기능을 공유
앞선 예제에서 기능을 공통으로 제공할 새로운 클래스를 추가
새로운 클래스는 위와 같습니다. 이제 필요한 곳에서 클래스를 사용할 수 있습니다.
Rows에서도 이렇게 EOLString 사용할 수 있음
Records와 Rows는 더 이상 해당 로직을 포함하지 않습니다.
우리는 각 줄의 끝에 접미사가 추가되는 정확한 방법을 알 지 못해요.
그저 EOLString이 담당하고 있다는 것만 알고 있을 뿐.
앞서서 본 Constants.EOL과 뭐가 달라?!
생각할 수 있지만, EOLString에 대한 결합은 계약을 통해 추가된 것이며,
계약에 의한 결합은 언제라도 분리가 가능하기 때문에 유지보수성을 저하시키지 않습니다.
가정을 하나 해볼게요.
이 프로그램이 Windows에서 실행될 경우에 '\n\n'을 추가하는 대신 예외를 던지도록 코드를 수정하고 싶어요.
다음과 같이 하면 계약(인터페이스)은 동일하게 유지하면서 동작은 변경할 수 있습니다.
public static 으로 했던 이전 예제에서는 이런 변경이 불가능합니다.
이 말은 수백 개의 단순한 상수 문자열 대신 수백 개의 마이크로 클래스를 만들어야 한다는 것을 의미합니다.
코드가 더 장황해지고 오염되지 않을까?
놉 클래스 사이에 중복 코드가 없다면 클래스가 작아질수록 코드는 더 깔끔해집니다.
오히려 애플리케이션을 구성하는 클래스의 수가 많을수록 설계가 더 좋아지고 유지보수하기도 쉬워집니다.
마무리 :
이제 작성하는 코드에 퍼블릭 상수를 사용해서는 안된다는 것 알았죠?
아무리 사소해 보이는 상수라고 항상 작은 클래스를 이용해서 대체합시다.
2.6 불변 객체로 만드세요
모든 클래스를 상태 변경이 불가능한 불변 클래스(immutable class)로 구현하면 유지보수성을 크게 향상시킬 수 있습니다.
불변성은
- 크기가 작고
- 응집력이 높으며
- 느슨하게 결합되고
- 유지보수하기 쉬운
클래스를 만들 수 있도록 합니다.
=> 불변 객체를 기반으로 사고하면 더 깔끔하고, 더 쉽게 이해할 수 있는 코드를 만들 수 있습니다 .
불변객체 : 인스턴스를 생성한 후에 상태를 변경할 수 없는 객체
다음의 Cash클래스를 이용해서 생성한 객체는
상태변경이 가능하기 때문에 가변 객체라고 부릅니다.
다음 클래스는 상태를 변경할 수 없는
불변 객체입니다.
달라진 부분은 private 프로퍼티인 dollors에 final 키워드를 추가했다는 점
final 키워드는 생성자 외부에서 프로퍼티 값을 수정할 경우 컴파일 타임 에러가 발생해야 한다는 사실을 컴파일러에게 알려줍니다. => 불변 객체는 필요한 모든 것을 내부에 캡슐화 하고 변경할 수 없도록 통제합니다.
예시를 봅시다.
class Cash {
private int dollars;
public void mul(int factor) {
this.dollars *= factor;
}
}
가변 클래스로 구현한 코드입니다.
class Cash {
private final int dollars;
public Cash mul(int factor) {
return new Cash(this.dollars * factor);
}
}
불변 클래스의 예입니다.
가변 클래스는 바로 곱해주는 반면, 불변 클래스는 새롭게 객체를 생성하고 이를 반환합니다.
불변 객체는 항상 원하는 상태를 가지는 새로운 객체를 생성해서 반환해야 합니다.
Cash five = new Cash(5);
five.mul(10);
System.out.println(five);
가변 객체의 사용 방법입니다.
Cash five = new Cash(5);
Cash fifty = five.mul(10);
System.out.println(fifty);
불변 객체를 이용해서 동일한 작업을 수행하는 방법입니다.
여기에서 핵심은 절대로 변경 가능한 객체를 만들지 말라는 것입니다.
항상 불변 객체를 사용해야 합니다.
일단 five 객체를 생성하고 나면 five는 fifty가 될 수 없습니다.
5는 5일뿐입니다. 5는 생명이 다하는 그 순간까지 항상 5입니다.
만약 50이 필요하다면 다른 객체를 인스턴스화해야 합니다.
Cash five = new Cash(5);
five.mul(10);
System.out.println(five); // 이런, "$50"이다
모든 사람들은 five 객체가 '5달러'처럼 행동할 것이라고 기대하지만 예상과 달리 객체는 '50달러'처럼 행동하고 있습니다.
five가 five인거지 mul(10)을 했다고 five가 fifty 처럼 행동하는 것이 얼마나 유지보수 관점에서 안좋을 지 깨닫게 되었다 .
이 예제로부터 가변성이 코드를 얼마나 이해하고 유지보수하기 어렵게 만드는 지 이해했다.
불변성을 지지하는 널리 알려진 몇 가지 주장이 있습니다.
불변 객체를 사용해야 하는 이유를 설명하기 전에 잠시 '지연 로딩(lazy loading)' 의 개념에 관해 살펴보겠습니다.
기술적으로 불변 객체를 사용해서 지연 로딩을 구현하는 것은 불가능합니다.
지연 로딩 기법을 사용하면 다음과 같이 객체가 캡슐화하고 있는 프로퍼티를
업데이트할 필요가 있을 때 프로퍼티를 '게으르게' 로드할 수 있습니다.
class Page {
private final String uri;
private String html;
Page(String address) {
this.uri = address; this.html = null;
}
public String content() {
if (this.html == null) {
this.html = /* 네트워크로부터 로드한다 */
}
return this.html;
}
}
이 예제는 지연 로딩이 작동하는 방식을 잘 보여주고 있습니다.
객체를 생성하는 시점에 this.html 안에는 실제 데이터 대신 null이 할당되어 있는 상태입니다.
this.html의 값은 content() 메서드를 최초로 호출하는 시점에 저장됩니다.
content()를 다시 호출하면 네트워크를 통해 다시 데이터를 로드하는 대신 this.html에 이미 저장되어 있는 내용을 반환합니다.
이 클래스는 가변적입니다. 불변으로 만들 수 있을까요? Java에서는 불가능합니다. 실행시간을 단축하기 위해서 html 페이지를 여러번 로드하지 않고 단 한 번만 로드하기 위해서는 지연 로딩이 필요한데 말이죠.
@OnlyOne
public String content(){
return /* 네트워크로부터 로드한다*/
}
이런 어노테이션이 있지만 Java는 제공하고 있지 않습니다.
하지만 객체를 불변으로 유지하면서도 지연 로딩을 구현할 수 있는 다양한 해결방법이 있습니다.
이 방법들은 모두 일종의 해킹이라고 할 수 있고, 임의의 프레임워크나 정적 해시 맵에 기반해서 구현된
다양한 캐싱 기법의 일종입니다.
2.6.1 식별자 가변성(Identity Mutability)
불변 객체에는 '식별자 가변성' 문제가 없습니다.
식별자 가변성 문제 : 동일해 보이는 두 객체를 비교한 후 객체의 상태를 변경할 때 - 두 객체는 더 이상 동일하지 않지만, 우리는 여전히 두 객체가 동일하다고 생각합니다.
예를 보면,
Map<Cash, String> map = new HashMap<>();
Cash five = new Cash("$5");
Cash ten = new Cash("$10");
map.put(five, "five");
map.put(ten, "ten");
five.mul(2);
System.out.println(map); //{$10=>"five", $10=>"ten"}
처음에 five와 ten이라는 동일하지 않은 두 객체를 생성했습니다.
그리고 나서 두 객체를 map에 put 했는데 이 때 키가 서로 다르기 때문에 HashMap은 두 개의 독립적인 엔트리를 생성합니다.
그 후에 five 객체에 mul()을 호출해서 5를 10으로 변경했지만, map은 전혀 인식하지 못하고 매우 혼란스러운 map이 남았습니다.
결국 five.mul(2)을 수행해서 five의 상태를 변경해주었더니 map은 더이상 정상이 아니게 되었습니다. map 안에 동일한 키가 두 개 존재하여 어떠한 결과를 얻을 지 예측할 수 없습니다.
map.get(five); //"ten"과 "five" 중 하나가 반환될 것이다
이 문제가 바로 '식별자 가변성" 이며, 이는 매우 심각하고 찾기 어려운 버그로 이어질 수 있습니다.
불변 객체를 사용하면 객체를 map에 추가하고 난 후에는 변경이 불가능하기 때문에
이 문제가 발생하지 않습니다.
2.6.2 실패 원자성(Failure Atomicity)
불변 객체를 이용해서 얻을 수 있는 또 다른 장점으로 '실패 원자성'이 있습니다.
'실패 원자성'이란 완전하고 견고한 상태의 객체를 가지거나 아니면 실패하거나 둘 중 하나만 가능한 특성입니다.
(실패하면 반역, 성공하면 혁명..?) 중간은 없음
class Cash {
private int dollars;
private int cents;
public void mul(int factor) {
this.dollars *= factor;
if(/*뭔가 잘못 됐다면 */) {
throw new RuntimeException("oops...");
}
this.cents *= factor;
}
}
만약 mul() 메서드를 실행하는 도중에 예외가 던져진다면 객체의 절반만 수정되고
나머지 절반은 원래 값을 유지합니다.
이로 인해 심각하고 발견하기 어려운 버그가 발생할 수 있습니다.
불변객체는 내부의 어떤 것도 수정할 수 없기 때문에 이런 결함이 발생하지 않습니다.
대신 새로운 상태를 가진 새로운 객체를 인스턴스화 합니다.
class Cash {
private final int dollars;
private final int cents;
public Cash mul(int factor) {
if (/* 뭔가 잘못 됐다면*/) {
throw new RuntimeException("oops...");
}
return new Cash(
this.dollars * factor,
this.cents * factor
);
}
}
불변 객체를 사용하면 별도의 처리 없이도 원자성을 얻을 수 있습니다.
그렇다면 가변 객체 안에서 명시적으로 실패 원자성을 보장하는 방법이 안좋은 이유는 무엇일까요?
객체의 복잡성이 높아지고, 그 때문에 실수할 가능성이 더 커지기 때문입니다.
class Cash{
private int dollars;
private int cents;
public void mul(int factor) {
int before = this.dollars;
this.dollars *= factor;
if (/*cents와 관련해서 뭐가 잘못됐다면*/) {
this.dollars = before;
throw new RuntimeException("oops..");
}
this.cents *= factor;
}
}
Cash 클래스는 예외를 던지기 직전에 this.dollars의 값을 임시 변수에 저장해서 값을 복구할 수 있도록 합니다.
하지만 객체의 크기가 커지기 시작하면, 복구해야 하는 프로퍼티를 누락할 가능성이 있습니다.
오늘은 여기서 마치겠습니다.
다음 시간에는 오늘 시간에 이어서 불변 객체의 장점들에 대해서 공부하겠습니다.
'웹개발지식' 카테고리의 다른 글
2.9 인터페이스를 짧게 유지하고 스마트를 사용하세요 ~ 3.2.2 (0) | 2024.05.06 |
---|---|
2.6.3 시간적 결합 ~ (0) | 2024.04.29 |
책, 엘레강트 오브젝트 - Chapter 2. Education 학습 (0) | 2024.04.16 |
책, ' 엘레강트 오브젝트 - 새로운 관점에서 바라본 객체지향' (0) | 2024.04.03 |
디버깅을 해보자.. (0) | 2024.04.02 |