grep

Engineering

지속 가능한 소프트웨어를 위한 코딩 방법 - 두 번째

NHN

2020년 1월 16일

원문에서 보기 ↗

디미터의 법칙

DIY 원칙이 지켜진 코드는 중복 코드를 최소화하고, 직교성이 있는 클래스들은 서로 결합도가 줄어듭니다. 그러면 도대체 직교성이 있는 코드를 만들려면 어떻게 해야 하는가?라고 되물을 수 있습니다. 디미터의 법칙을 이용하면 모듈 간의 결합도를 줄여준다고 합니다. 디미터의 법칙은 최소 지식의 원칙(principle of least knowledge)라고 표현하기도 하며, 객체 지향 프로그래밍 소프트웨어 디자인 가이드이기도 합니다. 먼저 결론을 말하자면 디미터의 법칙은 클래스 간의 결합도를 줄이기 위한 원칙들입니다. 이 원칙들은 객체의 메서드를 사용할 때 주의할 것들입니다.

디미터의 법칙은 다음과 같습니다. (원문과 같이 표기합니다.)

Each unit should have only limited knowledge about other units: only units "closely" related to the current unit.
- 각각의 유닛(each unit)은 다른 유닛들(other units)에 대해서 오직 최소의(제한된) 정보만 갖고 있어야 한다 : 현재 유닛(each unit)에 '가깝게' 관련된 유닛들(other units)이어야만 한다.

Each unit should only talk to its friends; don't talk to strangers.
- 각각의 유닛은 오직 친구 유닛들에게만 말을 걸어야 한다. : 모르는 사람에게 말 걸지 마라.

Only talk to your immediate friends.
- 오직 너의 아주 가까운(immediate) 친구들에게만 말을 걸어라.

위의 내용에 첨언하자면 유닛은 객체를 의미하며, 친구는 객체와 관련된 다른 객체들을 의미합니다. 서로 의존성이 있는 객체들입니다. 그리고 말을 건다는 것은 자신 혹은 다른 객체의 메서드들을 호출하는 것입니다. 디미터의 법칙을 몇 가지 키워드로 줄이면 다음과 같습니다.

`가까운`, `친한` 객체들 그리고 말을 거는 것은 매우 `신중`하라. 

참 힘드네요. 도대체 가까운, 친한 의 기준이 뭡니까? 그리고 말을 거는 것에 대해서 매우 어렵게 제한하고 있습니다. 예제를 보면서 다시 설명하도록 하겠습니다.

Object-Oriented Programming의 경우

객체 지향 프로그래밍에서 디미터의 법칙을 적용하면, 다음과 같이 다시 해석할 수 있습니다. 지금부터는 위에서 언급한 친한, 가까운 같은 단어를 기억하고 읽기를 바랍니다.

객체 O (Object의 O)의 메서드 m (method의 m)은 다음과 같은 방식으로 메서드를 호출한다.

1. 객체 O 자신의 메서드는 호출할 수 있다. 
2. 메서드 m의 매개 변수들의 메서드는 호출할 수 있다. 
3. 메서드 m 안에서 생성/초기화 한 객체들의 메서드는 호출할 수 있다. 
4. 호출을 위한 메서드 또는 속성으로서 같은 클래스 안에서 선언된 객체의 메서드는 호출할 수 있다. 
5. 객체 O가 접근할 수 있고, 메서드 m의 스코프에 있는 전역 객체의 메서드는 호출할 수 있다. 

디미터의 법칙이 적용된 코드

흠.. 저 같은 사람은 아직도 무슨 말인지 이해가 안 됩니다. 이런 경우 직접 코딩해서 확인하는 수밖에 없습니다. 다음 코드를 보시죠. 그리고 주석을 달아서 디미터의 법칙이 적용된 코드를 설명합니다. 코드에 앞서 앞에서 사용한 키워드는 다음과 같이 맵핑됩니다.

- 객체 O는 ProductService 객체 
- 메서드 m 은 getProductByCode(String code) 메서드 

아래 코드의 각 주석에 달린 설명과 위의 디미터의 법칙과 같이 비교해보시길 바랍니다.

public class ProductSerivce {

    private ProductRepository productRepository;

    private String purify(String code){
        String purifiedCode = code.subsctring(0, 10); 
        
        // ....
        
        return purifiedCode;
    }

    public ProductDetailResponse getProductByCode(String productCode){
    
        // 2번 케이스 : 메서드의 매개변수 String 객체의 매서드인 trim() 을 호출함 : 
        // 이것도 가까운 사이임.
        String trimedCode = productCode.trim();         

        // 1번 케이스 : 객체 ProductService 자신의 메서드 purify 를 호출함.
        // 매우 가까운 사이임.
        String code = this.purify(productCode);         

        
       Decoder decorder = new ProductDecoder();   
       
       // 3번 케이스 : getProductByCode 안에서 생성한 Decoder 객체의 get() 을 호출함. 
       // 이보다 가까운 사이가 있을까?
       Long productId = decoder.get(code);              
       
       // 4번 케이스 : ProductService 객체 내부에 선언된 productRepository 의 findById 메서드를 호출함.  
       // 같은 클래스니깐 친한 친구 아닐까?
       return  productRepository.findById(productId)                                                 
            .map(ProductDetailResponse::new)
            .orElseThrow(() -> {
                log.error("NotFound #{}",productId);
                throw new NotFoundException("Not Found!");
            });
    }
    
}

디미터의 법칙에 위반하는 코드

디미터의 법칙을 지킨 코드는 잘 확인했습니다. 위의 코드는 매우 일반적이며 매우 보편적인 코드라고 생각합니다. 그러면 디미터의 법칙을 지키지 않는 코드는 무엇인지 궁금하실 겁니다. 그래서 제 경험상, 문제가 있는 코드를 보여드리겠습니다.

public class ProductProcessor {

    // 디미터의 법칙과 별개로 메서드 이름도 이렇게 지으면 안되겠죠? 
    // 메서드 이름이 행위(behavior) 가 드러나있지 않습니다. 
    public void process(ProductContext context){
    
        DeliveryLine deliveryLine = context.getDeliveryLine();
        // .... 생략.... //
    
        ProductCode code = context.getCode();
        
        // 인자로 받은 context 객체에 포함된 ProductCode 객체의 isVarified() 메서드를 호출합니다. 
        // 이는 ProductProcessor 객체와 하나 건너있는 객체 아닌가요? 
        // 모르는 사람과 이야기 하지마라! 원칙과 다르네요.
        if (!code.isVarified())     
            throw new NotVarifiedException("Not varified");
            
        //.....
    }
}

위의 코드에서 ProductContext 클래스는 내부에 수많은 객체를 가질 것 같은 이름을 갖고 있습니다. 맥락(Context)이라는 이름을 갖는 객체들이 대부분 그러합니다. 그리고 우리는 당연하게 Context 객체의 내부에 있는 객체들을 get() 메서드로 꺼내와서, 내부 객체들의 메서드를 마음대로 사용합니다. 심지어 set() 메서드를 이용해서 인자의 값까지도 마구마구 수정하죠. 이렇게 되면 ProductProcessor 객체와 관계가 있는 ProductContext 객체뿐만 아니라 한 다리 건너 있는 ProductCode 객체에도 결합이 생깁니다. 이렇게 객체들의 복잡도가 증가하면 결국 유지보수만 어려울 뿐입니다.

그렇다면 디미터의 법칙은 꼭 지켜야 하나?

디미터의 법칙이 잘 적용됐는지 확인하는 데 있어서, 람다식이나 메서드 체이닝 패턴 (method chainning)으로 구현된 코드는 파악하기 어렵습니다. 체이닝으로 연결된 메서드들이 어떤 객체를 리턴하는지 일일이 확인하기 어렵기 때문입니다. 그렇다고 자바의 람다식을 사용하지 말아야 하나요? 모던 자바에서 제공하는 fluent interface를 포기해야 하나요? 아니요. 디미터의 법칙을 적용하기에는 최근 트렌드와 맞지 않다고 생각합니다.

그럼 왜 제가 디미터의 법칙을 꺼냈을까요? 게다가 위의 예제에서는 디미터의 법칙과 직교성과는 별로 연관이 있어 보이지 않습니다. 직교성은 모듈 간의 공통점을 최소화하기 위한 내용이지만 디미터의 법칙은 객체의 결합도를 줄여주기 위한 내용이지요. 그럼에도 불구하고 디미터의 법칙과 직교성을 엮으려는 저의 의도는 실패한 걸까요...

디미터의 법칙 또한 객체 지향 프로그래밍에 도움이 됩니다. 결합도가 줄어들어서 개발자가 코드를 확인할 때 의식의 흐름대로 읽는데 도움이 되기 때문이죠. 이렇게 변명밖에 할 수 없을까요?

책 "The Pragmatic Programmer"의 저자 David Thomas, Andrew Hunt는 너무 많은 것을 교류하지 않는 샤이(shy) 한 코드를 작성하기를 제안하고 있습니다.

In their book The Pragmatic Programmer, Andrew and Dave suggest writing “shy” code that doesn’t interact with too many things. 

디미터의 법칙을 이루기 위해서는 바로 샤이 코딩(shy coding)이 효과적이라고 하고, 이는 객체 내부에 선언된 데이터들의 결합도를 증가시켜 직교성과 디미터의 법칙을 잘 이뤄주기 때문입니다.

계속..