이 글은 <내 코드가 그렇게 이상한가요?>의 일부를 다루고 있으며, 책의 코드와 유사하게 직접 작성한 코드로 구성되어 있습니다.
데이터 클래스는 값만 가지는 클래스이다. 모든 필드가 public이며, 값의 조작이 객체 외부에서 이루어진다.
만약 금액 데이터 클래스가 있는데, 외부의 여러 군데에 값을 변경하는 로직이 존재한다면 장애가 발생하기 쉽다.
이런 경우는 데이터를 담고 있는 클래스와 데이터를 사용하는 계산 로직이 멀리 떨어져 있을 때 자주 발생한다.
인스턴스 변수 + 인스턴스 변수에 잘못된 값이 할당되지 않게 막고, 정상적으로 조작하는 메서드로 구성되어야 한다.
잘못된 값이 할당되지 않게 막는 로직을 클래스 내에 구성하는 것이다. 이는 클래스 단독으로도 자신을 보호할 수 있는 방법이다.
class Dog {
int age;
Breed breed
public Dog(final int age, final Breed breed) {
if(age < 1) throw new IllegalArgumentException();
if(breed == null) throw new IllegalArgumentException();
this.age = age;
this.breed = breed;
}
}
위 코드에서 보면, 매개변수가 있는 생성자로만 객체를 생성할 수 있는데, 그 생성자 안에서 유효성을 체크하고 있다. 따라서 객체를 생성할 때 무조건 유효한 값을 가지고 생성할 수 있다.
만약 정적 팩토리 메서드 패턴을 사용한다면, 생성자를 private으로 만들어 생성자로 객체를 생성하는 것을 막는다.
Money클래스가 있다고 생각해보자. 예시를 들기 위해 필드를 amount 하나만 가진 클래스라고 가정한다.
만약 두 값을 더해야할 경우가 있다고 생각해보자.
Money extraMoney = new Money(10000);
Money change = new Money(3000);
extraMoney.amount += change.amount
위와 같이 구현했을 때 어떤 문제가 발생할까?
우선 필드가 외부에 노출된다. 필드를 public으로 선언해야 한다.
그리고 유효성을 검사하는건 생성자에서 검사하므로, 이후 값을 변경할 때 유효하지 않은 값이 들어갈 수 있다.
만약 금액을 빼려고 한다면 음수값이 들어갈 가능성이 생긴다.
이를 막기 위해서는 계산 로직도 데이터를 가진 쪽에 구현해야 한다.
예를 들어 Money클래스내에 add메소드를 추가하여, 매개변수로 Money타입의 값만 받을 수 있도록 구현할 수 있다.
클래스의 필드를 final로 만들고, 변경이 필요할 때는 객체를 재생성하는 방법을 사용한다.
또한 메서드의 매개변수와 지역 변수도 불변으로 만들어 재할당을 막고, 재할당에 따른 의도치 않은 장애 발생을 막는다.
예를 들어, money라는 변수를 가지고 계산하는 로직 내에서 계산 중간중간 money에 값을 재할당한다면?
후임자가 보기에 이해하기도 어렵고, 코드 중간을 봤을 때 이 줄의 money가 어느 값을 의미하는지 알 수가 없다.
따라서 가능한 final로 만들어 재할당을 금지시킨다.
앞서 말했듯 Money클래스의 add메소드에서 int형 amount값을 받고 내부에서 조작하는 것보다,
매개변수부터 Money클래스의 인스턴스만 받는 것이 오류를 막을 수 있는 방법이다.
만약 Money클래스에 public Money(Integer amount){} 가 있다면, amount로 전달되는 값이 유효할지에 대해 장담할 수 없다.
유효한 값이 들어가 있음을 장담할 수 있는 Money를 전달받아 계산하는 것이 합리적이다.
디폴트 생성자로 생성되는 객체는 값의 초기화가 따로 이루어지므로 쓰레기값이 들어갈 수 있다. 이러한 잘못된 상태를 막을 수 있는 패턴이 완전 생성자이다.
쓰레기 객체를 방지하기 위해 매개변수가 있는 생성자를 만들고, 생성자 내부에서 유효성을 검사하자.
앞서 말한 Money클래스와 같은 경우가 값 객체이다. 금액이 숫자라고 해서 단순히 Integer로 관리한다면, 화폐 단위를 통일하거나 유효한 값 범위를 지정하기 어렵다. 이런 모든 행위가 클래스 외부에서 일어나면 돈을 계산하는 로직을 만들 때 누락될 수 있다.
인스턴스의 재사용이 문제를 일으키는 경우를 살펴보자.
만약 A라는 사람의 지갑과, B라는 사람의 지갑이 있다고 하자. 둘은 같은 용돈을 받는다.
이를 구현하기 위해서 아래와 같이 코드를 짰다.
Money allowance = new Money(10000);
Wallet walletA = new Wallet(allwance);
Wallet walletB = new Wallet(allwance);
그런데 갑자기 A가 시험을 100점을 받아 왔다. A의 용돈을 올려줘야 하지 않을까?
이런 사양 변경이 발생할 경우 잘못 설계된 구조가 문제를 일으킬 수 있다.
아래 예시를 살펴 보자.
A의 용돈을 15000원으로 올려주었다.
이 때 문제가 발생한다. A의 용돈만 올려주었는데, B의 용돈까지 올라간 것이다.
Money allowanceA = new Money(10000);
Money allowanceB = new Money(10000);
Wallet walletA = new Wallet(allwance);
Wallet walletB = new Wallet(allwance);
이처럼 내부에 같은 값을 가지고 있더라도 인스턴스를 재사용하지 않으면, 위와 같은 장애 발생 위험을 줄일 수 있다.
함수가 매개변수를 받아 값을 리턴하는 것 이외에, 외부 상태를 변경하는 경우를 생각하자. 아래와 같은 경우가 있다.
함수는 다음 조건을 만족하는 것이 좋다.
“데이터(상태)는 매개변수로 받고, 상태를 변경하지 않고, 값은 함수의 리턴값으로 돌려준다”
값을 변경하고 싶다면 새 객체를 생성하자.
만약 값을 더하는 메소드가 필요하다면, 인스턴스 변수를 변경하지 말고 더한 값을 가진 새 객체를 리턴하자.
예를 들어, 돈을 더하는 로직을 작성하고 싶다고 하자.
Money클래스에는 add메소드가 추가될 것이다. 이 때 기존 Money의 amount필드의 값을 변경하지 않고, Money를 매개변수로 받아 새로운 Money객체를 리턴하는 메소드를 만들면 된다.
아래와 같이 작성될 것이다.
class Money {
...
public Money add(final Money money) {
return new Money(this.amount = money.amount
}
}
❓그런데 위처럼 작성한다는건 amount가 private이 아니어야 하는 건데, 괜찮나?
책의 예제에서는 공격력 클래스 AttackPower의 메소드를 이와 같이 구현하여, 무력화 전후의 공격력이 서로 영향을 주지 않도록 한다.
이렇게 되면 서로 영향을 주지 않으므로, 한 스레드에서 공격력을 무력화했을 때 의도치 않은 곳에서 공격력이 무력화되지 않도록 막을 수 있다.
인스턴스 변수의 값을 변경해야 한다면, 새 객체를 생성하여 반환하는 클래스를 만들자.
기본으로 final을 사용하되, 가변이 필요한 경우에는 사용하도록 하자.
반복 처리 스코프에서만 사용되는 지역 변수의 경우는 당연하고, 대량의 데이터를 빠르게 처리해야 하는 경우 등은 가변을 사용하는게 좋을 수도 있다.
데미지를 입고 HP가 0이 되면 사망상태로 변경해야 하는 상황이다.
class Member {
final HitPoint hitPoint;
final States states;
void damage(int damageAmount) {
hitPoint.amount -= damageAmount;
}
}
class HitPoint {
}
Member객체를 생성할 때에는 생성자 혹은 정적 팩터리 메서드 내에 유효한 값을 위한 validate로직이 들어가게 하면 된다.
그런데 damage메소드에서 HitPoint의 amount가 음수가 된다면 사망상태로 바꾸어야 하지 않나?
이렇게 상태를 변화시키는 메서드를 뮤테이터라고 한다.
damage내부는 이렇게 바뀔 것이다.
void damage(final int damageAmount) {
hitPoint.damage(damageAmount);
if(hitPoint.isZero()) {
states.add(StateType.dead);
}
}
❓여기서는 HitPoint타입을 매개변수로 받아서 새 HitPoint를 반환하여 사용하지 않고 있다. 앞에서 나온 내용들에 의하면 HitPoint는 새 인스턴스를 생성해야 하지 않나?
그런데 또 새 인스턴스를 생성하게 되면 Member의 HitPoint라는 인스턴스 변수에 값을 재할당해야 한다.
이럴 때에는 바로 위 코드처럼 primitive type을 받아서 HitPoint인스턴스를 그대로 사용하며 값을 변경하는게 맞는 것일까?
파일을 읽고 쓰는 IO도작은 코드 외부의 상태에 의존한다.
아무리 주의 깊게 작성하더라도, 파일이나 DB는 코드 외부에 존재하므로 외부 상태에 의존하도록 코드를 작성하면 동작 예측이 힘들어진다.
이를 최소화하기 위한 것이 Repository 패턴이다.
데이터가 있는 여러 저장소를 추상화하여 결합도를 낮춘다.
도메인에서는 비즈니스 로직에만 집중하고, 데이터의 출처는 신경 쓰지 않는다. Repository레이어가 제공하는 데이터를 사용만 하면 되기 때문.
라고 한다.
응집도가 낮은 대표적인 예시가 바로 데이터 클래스이다.
클래스는 값만 가지고 있으며, 초기화 및 조작 로직이 모두 클래스 외부에 존재한다.
이 경우 장애를 유발하기 쉽다. 쓰레기 값이 들어갈 가능성, 수정사항이 누락될 가능성, 잘못된 값이 할당될 가능성 등 장애 발생 여지가 많다.
하나씩 살펴보자.
static은 클래스의 인스턴스를 생성하지 않고도 메서드를 호출할 수 있다.
static메서드는 데이터 클래스와 사용되는 경우가 많다.
데이터는 데이터 클래스에 있고, 이를 조작하는 로직은 static메서드에 존재하는 것이다.
데이터와 로직이 서로 다른 클래스에 있다. 즉, 응집도가 낮다.
만약 static 메서드를 이용해 두 Money의 amount를 더하는 로직을 작성한다면,
차라리 인스턴스 변수를 사용해서 계산하도록 변경하자.
class Money {
...
Money add(final Money other) {
final int added = amount + other.amount
return new Money(added);
}
}
이렇게 하면 데이터와 데이터를 조작하는 로직이 같은 클래스에 존재한다.
로그 출력 전용 메서드나 포맷 변환 전용 메서드는 응집도와 관계가 없으므로 static으로 설계해도 좋다.
팩토리 메서드 패턴도 static으로 설계하는 것이 좋다.
팩터리 메서드 패턴은 아래 링크를 참고하자.
https://bcp0109.tistory.com/367
GiftPoint 회원가입포인트 = new GiftPoint(3000);
GiftPoint 프리미엄회원가입포인트 = new GiftPoint(5000);
이런식으로 상황에 따라 다른 값으로 초기화하여 사용한다고 하자.
이 경우 생성자는 public이다.
그런데 만약 프리미엄 회원 등급의 연회비를 높이고, 대신 포인트도 더 높여주고 싶다면?
코드 전체를 확인해야 한다. 포인트 수정 누락이 발생할 수 있기 때문이다.
따라서 목적에 따라 초기화할 수 있도록 생성자는 private으로 선언하여 외부 호출을 막고, 무조건 정적 팩토리 메서드 패턴으로 객체를 만들게 해보자.
생성자는 private으로 만드는 대신에, 이 생성자는 같은 클래스 내의 static메소드(팩터리 메소드)에서 사용하면 된다.
GiftPoint클래스 내에 회원가입 포인트와 프리미엄 회원가입 포인트는 private static final로 선언하여 고정하자.
그리고 private생성자 내부에서 쓰레기값이 들어가지 않도록 조건을 걸고,
static GiftPoint 회원가입포인트() { return new GiftPoint(회원가입 포인트 상수); }
static GiftPoint 프리미엄회원가입포인트() { return new GiftPoint(프리미엄회원가입 포인트 상수); }
이렇게 static으로 상황에 따라 필요한 객체를 반환하는 메소드를 사용하도록 만들면 된다.
이제 포인트를 변경하고 싶을 때 단순히 클래스 내부의 포인트 상수만 변경하면 된다.
그런데 이런 메소드가 너무 많아진다면, 클래스가 무거워지고 가독성이 낮아진다.
이 경우 생성 전용 팩토리 클래스를 따로 분리할 수 있다.
팩토리 패턴
객체를 생성하기 위한 인터페이스를 정의하고, 실제 인스턴스 생성은 서브 클래스에서 결정하게 하는 패턴이다.
일반적으로 범용 클래스는 응집도를 낮추지만, 횡단 관심사 - 로그출력, 오류확인, 디버깅, 예외처리, 캐시, 동기화, 분산처리 - 등은 범용 클래스로 작성해도 괜찮다.
예를 들어 Log.debug(”로그 찍기”); 와 같이 사용할 수 있다.
현위치와 이동할 x좌표 y좌표 크기를 받아서 위치를 바꾼다고 해 보자.
이 때 매니저 클래스 내에 메소드 매개변수로 현위치, x, y값을 받고 매개변수로 받은 현위치의 값을 변경하는 것보다는 x, y만 매개변수로 받아 새 위치 객체를 반환하게 하자.
기본 자료형을 사용하면 유효한 값에 대한 검사를 여기저기서 수행할 수 있다.
당연히 응집도가 낮아지고, 수정 누락이 발생할 위험이 증가한다.
여러 메서드를 연결해서 리턴 값의 요소에 차례차례 접근하는 방법 (stream api와 같이)은 사양이 바뀌면 모두 찾아 수정해야 한다. 영향을 미치는 범위가 커진다.
밖에서는 명령만 하고, 상세한 판단과 동작은 내부에서 담당하도록 하자.
마법의 종류를 enum으로 정의하고, 어떤 동작을 할 때마다 swich문으로 분기를 처리하면 요구 사항 변경시 수정이 누락될 수 있다.
조건식이 같을 때 조건 분기를 여러 곳에서 작성하지 않고 한 번에 작성하는 단일 책임 선택의 원칙(1객체=1책임)을 고려하자.
이런 경우에는 인터페이스를 사용하여 분기와 같은 기능을 구현하면 된다.
Shape shape = new Circle(10);
System.out.println(shape.area());
shape = new Rectangle(10);
System.out.println(shape.area());
이렇게 하면 shape가 Circle인 경우와 Rectangle인 경우에 대한 분기를 작성하지 않아도 분기와 같이 동작하는 효과를 볼 수있고, 인터페이스로 강제함으로서 수정 누락을 방지할 수 있다.
종류별로 다르게 처리해야 하는 기능을 인터페이스의 메서드로 정의하자.