3.1 클래스 단위로 잘 동작하도록 설계하기
클래스는 클래스 하나로도 잘 동작할 수 있도록 설계해야 한다.
또한 복잡한 초기 설정을 하지 않아도 곧바로 사용할 수 있게 만들어야 한다. 그리고 클래스를 마음대로 조작해서 클래스 전체가 고장 나는 일(버그 발생)이 없게, 최소한의 조작 방법(메서드)만 외부에 제공해야 한다.
3.1.1. 클래스의 구성요소
- 인스턴스 변수
- 메서드
- 잘 만들어진 클래스: 인스턴스 변수에 잘못된 값이 할당되지 않게 막고, 정상적으로 조작하는 메서드
두 가지 요소를 모두 갖춰야한다. 둘 중 하나라도 빠지면 안된다. 왜일까?
데이터 클래스는 일반적으로 인스턴스 변수를 조작하는 로직이 다른 클래스에 구현되어 있다. 심지어 초기화도, 잘못된 값으로부터 방어하기 위한 유효성 검사도 다른 클래스에 구현되어 있다.
즉, 데이터 클래스는 다른 클래스가 여러 가지 준비해 줘야만 잘 작동하고, 혼자서는 아무것도 할 수 없는 미성숙한 클래스라는 것을 알 수 있다.
3.1.2. 모든 클래스가 갖추어야 하는 자기 방어 임무
클래스 스스로 자기 방어 임무를 수행할 수 있어야 소프트웨어의 품질을 높이는 데 도움이 된다. 그럼 데이터 클래스는 어떻게 해야할까?
데이터 클래스에 자기 방어 임무를 부여해서, 다른 클래스에 맡기던 일을 스스로 할 수 있게 설계하면 된다.
3.2 성숙한 클래스로 성장시키는 설계 기법
그럼 데이터 클래스를 성숙한 클래스로 차근차근 성장시켜 보자.
import java.util.Currency;
class Money {
int amount; // 금액
Currency currency; // 통화 단위
}
금액을 나타내는 Money 클래스. 인스턴스 변수만 갖고 있는 전형적인 데이터 클래스이다.
3.2.1. 생성자로 확실하게 정상적인 값 설정하기
데이터 클래스는 디폴트 생성자(매개변수 없는 생성자)를 사용해서 인스턴스를 생성한 뒤, 인스턴스 변수에 따로 값을 할당해서 초기화한다. 이는 ‘raw data object(로우 데이터 객체)’로서 ‘초기화되지 않은 상태’를 유발하는 클래스 구조이다.
로우 데이터 객체를 방지하려면, 적절한 초기화 로직을 생성자에 구현하면 된다.
class Money{
int amount;
Currency currency;
Money(int amount, Currency currency){
this.amount = amount;
this.currency = currency;
}
}
이렇게 하면 인스턴스 변수가 무조건 초기화된다. 하지만 이것만으로는 매개변수로 잘못된 값이 전달될 수 있다.
Money money = new Money(-100, null);
이렇게 잘못된 값이 유입될 수 있기 때문에 유효성 검사를 생성자 내부에 정의하도록 한다.
- amount: 0 이상의 정수
- currency: null 이외의 것
class Money {
// 생략
Money(int amount, Currency currency) {
if(amount < 0){
throw new IllegalArgumentException("금액은 0 이상의 값을 지정해 주세요.");
}
if(currency == null) {
throw new NullPointerException("통화 단위를 지정해 주세요.");
}
this.amount = amount;
this.currency = currency;
}
}
이렇게 처리 범위를 벗어나는 조건을 메서드 가장 앞부분에서 확인하는 코드를 guard(가드)라고 부른다.
가드를 배치해 두면, 잘못된 값이 전달될 때 생성자에서 예외가 발생할 것이다. 따라서 잘못된 값을 가진 Money 인스턴스는 존재할 수 없게 된다.
3.2.2. 계산 로직도 데이터를 가진 쪽에 구현하기
이전 예시인 데이터 클래스는 금액 추가 등의 계산 로직이 다른 클래스에 구현되어 있다. 이렇게 ‘데이터’와 ‘데이터를 조작하는 로직’이 분리되어 있는 구조를 ‘응집도가 낮은 구조’라고 한다.
응집도가 낮은 구조에서는 여러 가지 문제가 발생하기 때문에 스스로 할 수 있게 만들어야 한다.
class Money {
// 생략
void add(int other){
amount += other;
}
}
예를 들어 금액을 추가하는 메서드를 추가하는 것이다.
3.2.3. 불변 변수로 만들어서 예상하지 못한 동작 막기
변수의 값이 계속해서 바뀌면, 값이 언제 변경되었는지, 지금 값은 무엇인지 계속 신경 써야 한다. 또, ‘예상치 못한 부수 효과’가 쉽게 발생할 수 있다.
따라서 인스턴스 변수를 final을 사용해 불변 변수로 만들어 준다.
class Money {
final int amount;
final Currency currency;
Money(int amount, Currency currency){
// 생략
this.amount = amount;
this.currency = currency;
}
}
3.2.4. 변경하고 싶다면 새로운 인스턴스 만들기
‘그런데 불변이면 아예 변경할 수 없는 것 아닌가?’ 라고 생각할 수 있다.
인스턴스 변수의 내용을 변경하는 것이 아니라, 변경된 값을 가진 새로운 인스턴스를 만들어서 사용하면 된다.
class Money{
// 생략
Money add(int other){
int added = amount + other;
return new Money(added, currency);
}
}
합산 금액을 값으로 갖는 Money 인스턴스를 생성하고 리턴하는 로직이다. 이렇게 하면 불변을 유지하면서도 값을 변경할 수 있다.
3.2.5. 메서드 매개변수와 지역 변수도 불변으로 만들기
기본적으로 매개변수는 변경하지 않는 것이 좋다. 지역변수도 마찬가지로 중간에 값을 변경하면, 값의 의미가 바뀔 수도 있기 때문에 final을 붙여 불변으로 만든다.
3.2.6. 엉뚱한 값을 전달하지 않도록 하기
잘못된 값이 전달되지 않도록 주의해야 한다.
class Money {
// 생략
Money add(final Money other){
final int added = amount + other.amount;
return new Money(added.currency);
}
}
Money 자료형만 매개변수로 받을 수 있도록 메서드를 수정해줬다.
이전 코드에서는 같은 int 자료형이라면 의미가 다른 값을 잘못 전달해도 컴파일 오류가 발생하지 않는다. 이런 문제는 발견하기도 어렵기 때문에 지정한 자료형 외에는 전달을 못하도록 처리하는 것도 하나의 방법이다.
추가로 통화 단위가 다른 두 금액을 더하는 상황을 막기 위해 예외를 발생시킬 수도 있다.
3.2.7. 의미 없는 메서드 추가하지 않기
만약 시스템 상 합계 금액만 필요로 하는데, 금액을 곱하거나 나누는 메서드를 추가한다면 불필요하다. 누군가 이를 무심코 사용했을 때 버그가 될 수 있다.
시스템 사양에 필요한 메서드만 정의하자.
3.3 악마 퇴치 효과 검토하기
import java.util.Currency;
class Money{
final int amount;
final Currency currency;
Money(final int amount, final Currency currency){
if(amount < 0){
throw new IllegalArgumentException("금액은 0 이상의 값을 지정해 주세요.");
}
if(currency == null){
throw new NullPointerException("통화 단위를 지정해 주세요.");
}
this.amount = amount;
this.currency = currency;
}
Money add(final Money other){
if(!currency.equals(other.currency){
throw new IllegalArgumentException("통화 단위가 다릅니다.");
}
final int added = amount + ohter.amount;
return new Money(added, currency);
}
}
지금까지의 예시를 종합한 Money클래스의 소스 코드이다.
- 중복 코드 감소
- 수정 누락 감소
- 가독성 상승
- 쓰레기 객체 제거
- 잘못된 값 방지
- 생각하지 못한 부수 효과로부터 안전
- 값 전달 실수 방지
인스턴스 변수를 중심으로, 인스턴스 변수가 잘못된 상태에 빠지지 않게 설계하면 악마를 퇴치할 수 있다.
클래스 설계란 인스턴스 변수가 잘못된 상태에 빠지지 않게 하기 위한 구조를 만드는 것이다.
로직이 한곳에 모여 있는 구조를 응집도가 높은 구조라고 한다. 또한 ‘데이터’와 ‘그 데이터를 조작하는 로직’을 하나의 클래스로 묶고, 필요한 절차(즉 메서드)만 외부에 공개하는 것을 캡슐화라고 한다.
3.4 프로그램 구조의 문제 해결에 도움을 주는 디자인 패턴
응집도가 높은 구조로 만들거나, 잘못된 상태로부터 프로그램을 방어하는 등 프로그램의 구조를 개선하는 설계 방법을 디자인 패턴이라고 부른다.
3.4.1. 완전 생성자
완전 생성자는 잘못된 상태로부터 클래스를 보호하기 위한 디자인 패턴이다.
쓰레기 객체를 방지하기 위해 인스턴스 변수를 모두 초기화해야만 객체를 생성할 수 있게, 매개변수를 가진 생성자를 만든다.
그리고 생성자 내부에서는 가드를 사용해서 잘못된 값이 들어오지 않게 만든다.
이렇게 설계하면, 값이 모두 정상인 완전한 객체만 만들어질 것이다. Money 클래스의 생성자가 바로 완전 생성자 구조이다.
3.4.2. 값 객체
값 객체란 값을 클래스(자료형)로 나타내는 디자인 패턴이다. 값 객체를 만들어서 사용하면, 각각의 값과 로직을 응집도가 높은 구조로 만들 수 있다.
Money.add 메서드에 매개변수로 Money 자료형만 받을 수 있도록 수정한 코드가 예시이다.
‘값 객체 + 완전한 생성자’는 객체 지향 설계에서 폭넓게 사용되는 기법이다. 제약과 의도를 자료형으로 표현할 수 있고, 안전한 코드를 작성할 수 있다.
'내 코드가 그렇게 이상한가요?' 카테고리의 다른 글
6장 조건 분기: 미궁처럼 복잡한 분기 처리를 무너뜨리는 방법 (0) | 2024.03.30 |
---|---|
5장 응집도: 흩어져 있는 것들 (0) | 2024.03.28 |
4장 불변 활용하기: 안정적으로 동작하게 만들기 (0) | 2024.03.28 |
2장 설계 첫걸음 (변수명) (0) | 2024.03.12 |
1장 잘못된 구조의 문제 깨닫기 (0) | 2024.03.11 |