Engineering
지속 가능한 소프트웨어를 위한 코딩 방법 - 네 번째
2020년 1월 23일
원문에서 보기 ↗불변 클래스 (Immutable Class)
불변 클래스는 객체가 생성된 후에는 그 값을 변경할 수 없는 것을 의미합니다. 자바에서는 대표적인 불변 클래스는 String입니다. Integer, Double 같은 프리미티브 타입을 감싸고 있는 Wrapper 클래스도 불변 클래스입니다. 그러면 우리는 왜 불변 클래스를 사용해야 하는지, 어떤 장점이 있는지 알아봅시다. 그리고 불변 클래스를 디자인하는 방법과 우리가 놓치고 있는 부분에 대해서도 간단히 설명하겠습니다.
불변 클래스의 장점은 다음과 같습니다.
- 불변 객체는 하나의 상태만을 갖고 있으므로, 데이터를 신뢰할 수 있다.
- Thread-Safe 하므로 멀티 스레드 환경에서 안전하게 사용할 수 있다.
다음과 같은 가격표가 있다고 생각해봅시다. 그리고 이 클래스는 불변 클래스가 아닙니다. 이때 발생할 수 있는 상황을 살펴보죠.
// 문제가 발생할 소지가 많은 롬복의 @Data 애너테이션입니다.
@Data
public class PriceTag {
private Long priceTagId;
private BigDecimal listPrice;
private BigDecimal discountPrice;
private BigDecimal memberShipPrice;
public void initialize(PriceEntity priceEntity,
DiscountEntity discountEntity,
MemberShipEntity memberShipEntity){
this.priceTagId = priceEntity.getPriceId();
this.listPrice = priceEntity.getListPrice();
this.discountPrice = priceEntity.getListPrice()
.subtract(discountEntity.getDiscountAmount());
this.memberShipPrice = priceEntity.getListPrice()
.subtract(priceEntity.getListPrice().mutiply(memberShipEntity.getDiscountRate()));
}
}
위의 PriceTag 클래스는 가격표를 의미하는 것으로 여러 가지 데이터를 조합하여 판매가, 할인가, 멤버십 할인가를 포함합니다. 만약 이 클래스를 다음과 같이 사용한다면 코드는 정상적으로 동작할까요?
public class OrderService{
public FinalPrice calculate(Long memberId, Long productId){
// CASE #1. 기본 생성자(default constructor)로 객체를 생성하고 맴버변수 값이 비어있는 priceTag 객체 생성.
PriceTag priceTag = new PriceTag();
//... 코드 생략
this.applyMemberShip(priceTag);
//... 코드 생략
return priceTag.getDiscountPrice();
}
private void applyMemberShip(PriceTag priceTag){
// CASE #2 인자의 값을 변경하는 경우. 첫번째 글에서 하지 말아야 하는 액션이라고 말씀드렸네요.
BigDecimal memberShipPrice = // 생략
priceTag.setMemberShipPrice(memberShipPrice);
}
}
어떤가요? OrderService 객체의 calculate() 메서드 안에서 priceTag 객체의 값은 private applyMemberShip() 메서드에 의해서 변경됩니다. 만약 applyMemberShip() 매서드의 실행 순서가 바뀌면 calculate() 메서드의 결괏값은 이전과 다르게 나올지도 모릅니다. 여러분은 저 PriceTag 객체를 믿고 사용하실 수 있나요? 그래서 불변 객체를 만들어서 믿고 사용하시길 바랍니다. 불변 클래스를 설계하는 방법은 다음과 같습니다.
1. 클래스를 반드시 final로 선언할 것.
2. 클래스의 멤버 변수들을 반드시 final로 선언할 것.
3. 생성자를 잘 관리할 것.
4. 멤버 변수에 대해서 setter 메서드를 만들지 말고 getter 메서드를 만들어서 사용할 것.
위의 설계 방법으로 다시 PriceTag를 설계해봅시다.
// 4번 항목을 적용했습니다.
@Getter
// 1번 항목을 적용했습니다.
public final class PriceTag {
// 2번 항목을 적용했습니다.
private final Long priceTagId;
private final BigDecimal listPrice;
private final BigDecimal discountPrice;
private final BigDecimal memberShipPrice;
// 3번 항목을 적용했습니다.
public static PriceTag of(PriceEntity priceEntity, DiscountEntity discountEntity, MemberShipEntity memberShipEntity){
PriceTag priceTag = new PriceTag();
priceTag.priceTagId = priceEntity.getPriceId();
priceTag.listPrice = priceEntity.getListPrice();
priceTag.discountPrice = priceEntity.getListPrice().subtract(discountEntity.getDiscountAmount());
priceTag.memberShipPrice = priceEntity.getListPrice().subtract(priceEntity.getListPrice().mutiply(memberShipEntity.getDiscountRate()));
return priceTag;
}
// 3번 항목의 일부 입니다.
// 기본 생성자를 private 로 선언하여 외부에서는 기본 생성자를 만들 수 없게 합니다.
private PriceTag() {
}
}
위와 같이 리펙토링 되면, 다음과 같은 위의 PriceTag의 메서드들을 사용할 수 없습니다.
// 멤버 변수가 비어있는 priceTag 상태가 나올 수 없습니다.
// 위의 PriceTag의 생성자를 private으로 선언했기 때문입니다.
PriceTag priceTag = new PriceTag();
// 멤버 변수가 다음 메서드에 의해서 중간에 변경될 일 이 없습니다.
priceTag.initliaze(......)
// 중간에 memberShipPrice 가 변경된 priceTag 상태가 나올 수 없습니다.
priceTag.setMemberShipPrice(new BigDecimal("1000.00"));
불변 클래스에서 우리가 놓치고 있는 부분.
-
불변 클래스를 만들 때 생성자를 관리하지 못하는 경우 Java에서는 생성자를 선언하지 않으면 기본 생성자(default constructor)가 자동으로 생기며, 다른 클래스에서 이를 마음대로 호출할 수 있습니다. 해당 클래스를 애써 설계했지만, 기본 생성자가 있어서 여러 상태(state)가 있는 객체를 만들 수 있습니다. 이는 더 이상 불변 클래스라고 부를 수 없습니다. 그러므로 위의 PriceTag 예제처럼 private constructor를 선언해주시길 바랍니다. PriceTag를 사용하는 사람은 이 객체가 불변(immutable)인지 아닌지 관심 없고 생성할 수 있는 모든 방법으로 생성합니다.
-
불변 클래스는 TestCase를 만들 때 불편하지 않나요? 네 불편합니다. 위의 PriceTag 객체를 생성하기 위해서는 스테틱 펙토리 메서드인 of()를 사용해야만 합니다. 그러므로 PriceEntity priceEntity, DiscountEntity discountEntity, MemberShipEntity memberShipEntity 객체를 모두 만들어야 합니다. 하지만 Java 리플렉션을 이용하면 어느 정도는 편해집니다. 스프링에서는 ReflectionTestUtils과 같은 유틸성 클래스도 있습니다. 물론 코드의 변화에는 취약하지만 불변 클래스가 주는 장점은 그것을 덮고도 남습니다.
-
다음과 같은 코드는 작성하시면 곤란합니다. 아래 코드처럼 final를 선언한다고 해서 immutableList를 만들지는 않습니다.
@Test
public void testImmutable(){
final List<String> immutableList = new ArrayList<>(); // final 은 immutable 과 아무 관련없습니다.
immutableList.add("1");
System.out.println(immutableList);
}
- 마지막으로 클래스 선언 시 final 키워드를 사용할 것. 꽤 많은 분들이 class를 final 키워드로 선언하지 않습니다. 위의 PriceTag의 class 선언부를 확인해주세요. final 키워드를 선언하지 않으면 클래스 상속이 발생할 수 있으며 PriceTag의 매서드들이 오버라이드 될 수 있습니다. 그러므로 더 이상 불변 클래스가 될 수 없겠네요. 여러분들의 코드를 누군가가 쉽게 상속받을 수 있다고 생각하십시오. 그들이 여러분이 만든 불변 클래스를 변경할 수 도 있습니다.
단정적 프로그래밍과 불변 클래스
실용주의 프로그래머에서는 단정적 프로그래밍을 하라고 제안합니다. 절대 일어날 일이 없는 상황을 만들라는 의미입니다. 위의 PriceTag 클래스를 예를 들어 봅시다. 일반적인 상황에서, 가격표라는 것을 떠올리면 다음과 같은 상황은 발생해서는 안됩니다.
PriceTag 클래스의 priceTagId 는 절대 null 이 될 수 없다.
> 개발 조건 입니다.
PriceTag 클래스의 listPrice 는 절대 음수가 될 수 없다.
> 음수가 되면 물건을 사면 생산자가 구매자에게 돈을 줘야 하나요?
PriceTag 클래스의 discountPrice나 memberShipPrice 는 listPrice 보다 클 수 없다.
> 할인을 했는데 기본 가격보다 더 비싸면 안되겠죠?
이런 상황이 발생할 수 없도록 단정하라고 하는 것이 핵심입니다. 그러면 PriceTag 객체의 메서드를 사용하는 객체는 믿음이 생기고 견고한 프로그래밍이 될 수 있습니다. 이를 적용하면 다음과 같이 코딩할 수 있습니다.
public static PriceTag of(PriceEntity priceEntity, DiscountEntity discountEntity, MemberShipEntity memberShipEntity){
if (priceEntity == null)
throw new IllegalArgumentException("priceEntity is null");
if (priceEntity.getListPrice() == null)
throw new IllegalArgumentException("priceEntity.listPrice is null #" + priceEntity.getPriceId());
if (priceEntity.getListPrice().doubleValue() < 0)
throw new IllegalStateException("priceEntity.listPrice is negative #" + priceEntity.getPriceId());
PriceTag priceTag = new PriceTag();
// 생략
if (priceTag.discountPrice > priceTag.listPrice || priceTag.memberShipPrice > priceTag.listPrice)
throw new IllegalStateException("discountPrice, memberShipPrice is bigger than list price #" + priceEntity.getPriceId());
return priceTag;
}
이렇게 여러 상황에 대해서 단정을 하면 보다 견고한 불변 객체를 만들 수 있습니다. 이 단정적 프로그래밍은 불변 클래스를 설계할 때뿐만 아니라 여러 메서드에서 인자를 검증하는 데 사용해도 좋습니다. if 문 몇 개 더 들어갔다고 성능에 영향 있다고 생각하시지는 마시죠. 8비트 패미콤 시대가 아닙니다. 잘못된 데이터가 저장되거나 혹은 잘못된 값을 리턴하여 애플리케이션이 오동작하는 게 더욱 안 좋은 상황입니다. 실용주의 프로그래머에서도 망치지 말고 멈추라라고 하네요.
클래스 불변식 (Class Invariant)
모듈(class)들은 서로의 인터페이스(method)를 통해서 서로 데이터를 전달하고, 각자 유기적으로 동작해서 하나의 기능(Feature)을 수행합니다. '오브젝트' 책에서 협력, 책임, 역할이라는 관점에서 모든 클래스들의 유기적인 역할에 대해서 설명하고 있습니다. 이때 객체들이 서로 신뢰하지 못하면 프로그램은 어떻게 될까요.
Eiffel 언어의 창시자인 버트런드 마이어는 계약에 의한 설계(Design By Contract)라는 개념을 제시합니다. 소프트웨어의 모듈은 권리와 책임을 문서화하고, 그것을 검증하는 것이 핵심입니다. 예를 들어서 배송비와 세금 그리고 물건값을 합치는 메서드가 있다고 생각합시다. 그 메서드의 책임은 3개 요금을 계산해서 전체 금액을 반환하는 것이 책임입니다. 그리고 전달 인자는 널값이거나 혹은 음수 값이면 예외를 던지는 것이 권리입니다. 마이어는 다음과 같은 3개의 상태를 제안합니다.
- 선행 조건 : 메서드가 실행되기 전에 항상 true 이어야 하는 조건들.
- 후행 조건 : 메서드가 실행되고 나서 리턴하고 나서의 상태
- 클래스 불변식 : 호출자의 입장에서 이 조건이 언제나 참이라고 클래스가 보장하는 것,
여기까지 글을 읽어주신 여러분은 클래스 불변식에 대해서 이미 알고 계십니다. 저는 단정적 프로그래밍 + 불변 클래스가 바로 클래스 불변식의 기본이라고 생각합니다. 그래서 PriceTag 도 불변 클래스이자 클래스 불변식인 거죠. 생성자를 호출하면 해당 클래스의 멤버 변수의 값은 다음과 같은 조건들이 항상 참이죠.
PriceTag 클래스의 priceTagId는 절대 null 이 될 수 없다.
PriceTag 클래스의 listPrice는 절대 음수가 될 수 없다.
PriceTag 클래스의 discountPrice나 memberShipPrice는 listPrice 보다 클 수 없다.
이 클래스 불변식 위와 같은 불변 클래스나 혹은 Entity 클래스 외에도 다음과 같이 만들 수 있습니다. 아래의 getTotalPrice()가 호출되면 반환 값은 항상 0 이상이고 not null 인 조건이 항상 참입니다.
public class OrderService {
//...
public BigDecimal getTotalPrice(BigDecimal listPrice, BigDecimal deliverPrice, Double taxRate) {
this.assertNotNull(listPrice);
this.assertNotNull(deliverPrice);
this.assertNotNull(taxRate);
this.assertPositive(listPrice);
this.assertPositive(deliverPrice);
this.assertPositive(taxRate);
BigDecimal tax = listPrice.multiply(new BigDecimal(taxRate));
return listPrice.add(tax).add(deliverPrice);
}
//...
}