Engineering
지속 가능한 소프트웨어를 위한 코딩 방법 - 세 번째
2020년 1월 21일
원문에서 보기 ↗앞서 이야기한 것들.
앞서 이야기한 것들을 정리해보겠습니다.
- DRY 원칙으로 중복 코드를 줄여봅시다.
- 중복 코드가 있으면, 비즈니스 로직이 여러 군데 흩어진 것과 같습니다.
- 그래서 버그를 수정해도, 여전히 존재하는 같은 버그가 있을 수 있습니다.
- 중복 코드를 줄이기 위해서는 클래스들이 직교 성질을 갖도록 설계하면 됩니다.
- 직교 성질을 갖는 클래스를 설계하기 위해서 앞선 글에서는 디미터의 법칙을 설명했습니다.
- 디미터의 법칙을 이용하면 클래스들의 결합도를 낮추는 장점이 있습니다.
- 하지만 실제 코드로는 어떻게 작성하면 되는지 반만 설명했습니다.
- 그래서 글을 맺으면서, 샤이 코딩(Shy Coding)이 디미터의 법칙에 효과적이라고 했습니다.
자. 이제 샤이 코딩에 대해서 알아봅시다.
Shy Coding
객체 사이에 협업 관계를 표현하기 위해서 사용하는 전통적인 메타포(비유)는 서버-클라이언트입니다. ("오브젝트 p.176" by 조영호 ) 그림으로 표현하면 다음과 같은 형태로 OrderService 와 ProductEntity 는 서로 결합되어 있습니다.

서버-클라이언트 메타포 관점에서 두 OrderService, ProductEntity 두 객체는 메서드라는 퍼블릭 인터페이스를 통해서 서로 결합하고 있습니다. ProductEntity는 인터페이스를 제공하고 있어서 서버(server)입니다. 반대로 서버의 인터페이스를 사용하는 OrderService는 클라이언트(client)입니다. 서로의 메서드를 호출해서 하나의 큰 기능을 제공하는 것이 소프트웨어입니다. 앞서 설명한 디미터의 법칙은 클라이언트가 서버의 인터페이스를 사용할 때, 객체 사이의 결합도를 낮추기 위한 방법을 가이드합니다. 가까운, 친한이라는 단어를 다시 생각해보시길 바랍니다. 하지만 반대의 서버 클래스를 어떤 식으로 작성해야 객체 사이의 결합도를 낮추는지는 언급하지 않았습니다. 바로 샤이 코드가 적당한 것 같습니다.
Keep it DRY, Shy and Tell the Other guy in The Pragmatic Programmers by Andy Hunt and Dave Thomas
객체 지향을 한 문장으로 "DRY 하게 Shy 하게 그리고 다른 사람에게 말하도록 유지해라"라는 말이 있습니다. 아마도 한 번쯤은 들어본 적 있는 구절입니다. 위의 문장은 서버클래스를 설계할 때, 반드시 필요한 정보 만 오픈하기를 가이드하고 있습니다. 그래서 Shy라는 표현을 하고 있고요. 의도치 않는 정보들이 공개되는 순간 OrderService 같은 클라이언트 클래스들이 매서드를 호출하기 시작합니다. 그 결과 클래스를 개발한 사람의 의도와 상관없이, 결과론적으로 다른 클래스들과 강하게 결합되는 빌미가 됩니다. 그래서 디미터의 법칙을 만족하기 위해서 샤이 코딩을 하라고 가이드를 합니다.
Shy Coding 예제
위에서 언급한 ProductEntity와 OrderService를 사용하여 샤이 코딩 예를 들어보겠습니다. ProductEntity 클래스는 제품을 의미하는 Entity 클래스이고 OrderService 클래스는 주문 유즈-케이스(Use Case) 기능을 포함하는 Service 클래스입니다. 많은 개발자들은 ProductEntity와 같은 JPA 엔티티 클래스를 작성하면 다음 예제와 같이 작성합니다. 여러분들도 아래 예제와 주석을 보고 어떻게 리펙토링 하면 좋을지 생각해보시죠.
// 샤이 코딩을 방해하는 롬복 애너테이션입니다.
@Data
public class ProductEntity{
@Id
private Long productId;
private String name;
private SaleStatus saleStatus
private Long stockCount;
public ProductEntity(){
}
}
@Service
public class OrderService {
private ProductRepository productRepository;
// 제품 아이디로 주문하면 주문 번호 객체를 리턴하는 메서드입니다.
public OrderNumber order(Long productId){
// 디미터의 법칙 '같은 클래스 안에서 선언된 객체의 메서드는 호출할 수 있다' 를 잘 지키고 있습니다.
ProductEntity productEntity = productRepository.findById(productId);
OrderNumber orderNumber;
// 디미터의 법칙 '객체 O 자신의 메서드는 호출할 수 있다' 를 잘 지키고 있네요.
if (this.isPurchaseable(productEntity)){
productEntity.setStockCount(productEntity.getStockCount() - 1);
orderNumber = ..
} else {
orderNumber = ..
}
}
public boolean isPurchaseable(ProductEntity productEntity){
return productEntity.getStockCount() > 0;
}
}
OrderService 클래스의 order() 메서드는 주문을 처리합니다. 이 메서드를 기준으로 OrderService는 productRepository와 productEntity 객체와 결합되어있습니다. 그리고 먼저 클라이언트인 OrderService 객체의 order 메서드가 얼마나 디미터의 법칙을 잘 지키고 있는지 확인해봅시다.
- OrderService 객체 내부에서 생성된 ProductRepository 객체의 메서드의 findById 메서드를 사용하고 있다는 점.
- OrderService 객체의 매서드인 isPurchaseable()를 사용하고 있다는 점.
- isPurchaseable() 역시 인자 ProductEntity 객체의 getStockCount() 메서드를 사용하고 있다는 점.
언뜻 보기에 크게 문제없이 디미터의 법칙이 잘 적용되어있습니다. 하지만 위의 코드도 유지보수에 있어서 취약합니다. ProductEntity 클래스가 개발자가 의도치 않게 너무 많은 정보를 공개하고 있기 때문입니다. ProductEntity 클래스의 모든 멤버 변수들에 대해서 getter / setter 메서드가 열려있습니다. 그래서 자연스럽게 주문을 처리하기 위해서 isPurchaseable()이라는 메서드를 OrderService에 선언합니다. 그리고 메서든 내부에는 제품(ProductEntity)의 재고량(stockCount)을 getStockCount() 메서드로 확인합니다. 다시 말해서 OrderService 클래스는 ProductEntity의 속성인 stockCount 정보를 알아야 합니다. 그러면 다음과 같은 질문을 할 수 있겠지요.
Q. 주문 행위가 제품의 재고량은 서로 밀접한 관계입니까?
Q-1. 만약에 서로 밀접하다면, 왜 두 정보는 하나의 클래스로 묶을 수 없나요?
Q-2. 만약에 서로 밀접하지 않다면, 왜 OrderService의 isPurchaseable() 메서드가 ProductEntity의 stockCount를 알아야 하나요?
저는 주문기능과 제품의 재고 정보는 매우 밀접하지만, 서로 결합되어서는 안 된다고 생각합니다. 여러분도 다음의 상황을 읽어보고 고민해보시길 바랍니다.
샤이 코딩과 유지보수 그리고 결합도
기획자 손군이 부릉을 갑자기 찾더니..
손군 : 이건 아니지. 주문을 왜 재고량으로 확인하나요?
손군 : 재고량(ProductEntity.stockCount) 보다는 일일 생산량(capabilityCount)에 맞춰서 주문을 받고 이걸 일일 배치 처리해주세요.
부릉 : 이건, 확인해보고 이야기드릴게요.
개발자 부릉는 ProductEntity의 capabilityCount 속성을 추가하고 OrderService의 order() 메서드부터 분석하기 시작합니다. 그리고 결국 isPurchaseable() 매서드의 존재를 확인하고 고치기 시작합니다. 만약에 개발자 부릉이 코드 분석을 대충 하거나 혹은 졸면서 분석해서 isPurchaseable() 메서드의 존재를 미쳐 확인하지 못했다면 어떻게 하나요? 그래서 다음과 같이 코딩하면 어떨까 합니다.
// @Data 대신 수정한 부분
@Getter
public class ProductEntity{
@Id
private Long productId;
private String name;
private SaleStatus saleStatus
private Long stockCount;
// 수정된 부분 : 새로 추가된 속성. 일일 생산량
private Long capabilityCount;
public ProductEntity(){
}
// 수정된 부분 : 사실 기획자의 요구 사항은 복잡하지만 수도 코드라서 간단히 정리합니다.
public boolean isPurchaseable(){
if (capabilityCount > 1)
return true;
return false;
}
public void deductCapabilityCount(){
this.capabilityCount--;
}
}
위의 코드는 이전 코드와 비교해서 3 부분을 고쳤습니다. 롬복 @Data 애너테이션 대신 @Getter를 사용했고, 일일 생산량(capabilityCount) 속성과 OrderService에 선언되었던 isPurchaseable() 메서드가 ProductEntity 내부로 옮겨왔습니다. 이제 OrderService의 order()는 ProductEntity의 isPurchaseable()만 호출하면 주문 가능 여부를 알 수 있습니다. OrderService 클래스는 ProductEntity 내부에 어떤 정보가 있는지 알 필요가 없습니다. 그리고 어떻게 구현했는지 확인할 필요도 없지요. 게다가 메서드 이름이 매우 명확하기까지 합니다. 결국 속성과 그 내용을 다루는 메서드가 ProductEntity 내부에 있어서 단단히 잘 결합된 것 같습니다. 그리고 다른 클래스에서 주문 가능한지 여부를 확인하기 위해서는 ProductEntity의 isPurchaseable() 메서드만 호출하면 됩니다. 중복 없이 사용 가능합니다.
우리가 무심코 사용하는 롬복의 @Data 애너테이션이 디미터 법칙의 Shy Code를 깨트리고 있습니다. 참고로 @Data = @ToString + @EqualsAndHashCode + @Getter + @Setter + @RequiredArgsConstructor입니다. getter, setter 덕분에 우리는 ProductEntity에 샤이한 메서드를 생성할 생각을 안 해도 됩니다. 필요하면 OrderService에서 ProductEntity의 getter setter를 이용해서 데이터를 가져오거나 조작하면 되니까요. 적어도 @Data를 @Getter로 바꾸면, ProductEntity 클래스는 많이 샤이 해집니다. 적절한 선에서 타협하고 편리한 롬복의 기능을 사용하고 싶으신 분은 @Getter 정도만 선언하시면 될 것 같습니다.
변경된 코드를 보고 다음과 같은 질문을 생각해봅시다.
- DRY 원칙에 대해서 잘 지켜졌나요?
- OrderService 와 ProductEntity 는 직교 성질 인가요?
- ProductEntity는 디미터 법칙의 Shy Code 인가요?
그렇습니다. 그리고 다음 글귀를 다시한번 생각해봅시다.
Keep it DRY, keep it shy and tell the other guy
(계속...)