본문 바로가기
IT 공부/객체지향 설계 공부

데이터 중심 설계는 캡슐화, 결합도, 응집도를 해친다 (오브젝트 4장)

by exdus3156 2023. 12. 7.

1. 좋은 객체의 특성 개념 정리

1-1. 캡슐화

내가 처음 자바를 배웠을 때, 캡슐화란 "데이터와 프로세스를 한데 묶어 편의성을 제공해준다"는 식으로 배웠던 기억이 있다. 그때 나는 막 C언어의 절차적 프로그래밍 스타일만 알고 있었기 때문에 캡슐화를 정말 말 그대로 알약과 같은 것이라고 생각했다. 데이터와 프로세스를 한데 묶는 기법 정도로 여겼다.

그러나 캡슐화는 편의성 개념과는 거리가 멀다. 캡슐화는 정보 은닉의 일종으로서, 객체의 내부 구현을 외부로부터 숨기기 위한 전략 중 하나다. 

여기서 중요한 요점은 객체의 내부 구현을 왜 숨겨야 하는가다. 이 질문에 답하기 위해서는 객체란 무엇인지 아는 것으로부터 출발해야 한다.

객체지향 소프트웨어에서 객체란 특정 기능을 수행해주는 작은 프로그램과 같다. 절차적 프로그래밍이 main 함수로부터 시작해 서브루틴으로 기능을 쪼갠 후, 다시 main 함수로 통합되는 것과는 달리, 객체지향 프로그래밍은 각 객체가 특정 책임을 수행하는 독립적이고 자율적인 존재다.

객체지향에서는 모든 것을 수행하는 슈퍼 객체는 없다. 모두 각자의 기능을 단독적으로 수행하며 다른 객체와 메시지를 주고 받으며 전체 시스템의 목표를 달성한다. 여기서 협력이 등장한다.

협력의 퀄리티는 객체지향에서 매우 중요하게 다뤄진다. 왜냐하면 협력의 퀄리티가 떨어지는 경우, 그 소프트웨어는 유지 보수가 어렵기 때문이다.

협력의 퀄리티는 간단히 말해, 각 객체가 독립적이고 자율적인지에 좌우된다. 만약 객체가 독립적이거나 자율적이지 않으면 곧 객체의 변경이 다른 객체에게도 영향을 끼친다는 것을 의미한다. 즉, 의존적이고 수동적인 객체는 자신이 담당해야할 책임 중 일부를 다른 객체에게 전가했다는 것을 말한다.

따라서 객체는 자신에게 부여된 책임과 그 책임이 요청되는 경로인 메시지(퍼블릭 인터페이스)를 제외한 모든 세부 사항을 절대 외부로 드러내어서는 안 된다. 

외부 객체와 소통하는 경로를 고정시키고 나머지는 철저하게 감추라는 것이 캡슐화의 의미다. 그래야만 변경으로부터 자유로워진다. 

만약 알게 모르게 객체가 숨겨야 할 내구 구현 정보를 인터페이스로 흘려버리거나, 외부 객체가 마음대로 접근하게 내버려두면 어떻게 될까? 그런 객체는 변경으로부터 자유롭지 못하다. 자신에게 의존하고 있는 클라이언트 객체가 해당 정보에 밀접하게 관련된 로직을 구성할 수도 있기 때문이다.

실수로 인터페이스에 구현 정보를 흘려버려 클라이언트가 그 정보를 이용하든, 아니면 의도적으로 정보를 흘려 객체가 자신이 담당해야 할 책임을 남에게 전가하든, 어떤 상황이든 상관없다. 실수든 의도든 정보를 외부로 흘려버린 시점에서 캡슐화는 이미 깨지게 되고, 이로 인해 변경으로부터 자유로울 수 없는 객체가 만들어지는 것이다.

<오브젝트> 4장에서는 아래와 같은 예제가 나온다. (코드는 그대로 적지 않고 설명에 필요한 부분만 재구성했다.)

class Movie {
    private Money fee;
    private MovieType type;
    private double discountPercent;
    private int discountAmount;
    
    public MovieType getMovieType() {
        return this.type;
    }
    
    public Money calculateAmountDiscount() {
        return fee.minus(discountAmount);
    }
    
    public Money calculatePercentDiscount() {
        Money amount = fee.multifly(discountPercent);
        return fee.minus(amount);
    }
    
    public Money calculateNoneDiscount() {
        return fee;
    }
}
public class Screening {
    private Movie movie;
    
    public Money getFee() {
        Money fee;
        
        switch (movie.getType()) {
            case AMOUNT_DISCOUNT:
                fee = movie.calculateAmountDiscount();
                break;
            case PERCENT_DISCOUNT:
                fee = movie.calculatePercentDiscount();
                break;
            default:
                fee = movie.calculateNoneDiscount();
                break;
        }
        
        return fee;
    }
}

 

Movie 객체는 Screening의 요청을 받아 영화의 요금을 계산해야 한다. 그런데 정말로 Movie는 요금을 스스로 계산하고 있는가? Movie의 캡슐화는 깨졌다. 왜냐하면 MovieType에 대한 정보를 외부로 흘리고, 그 타입에 따라 외부 클라이언트(Screening)가 직접 로직을 분기하도록 만들었기 때문이다.

타입에 대한 정보는 Movie 자신이 잘 알고 있다. 그런데도 불구하고 외부로 정보를 흘려 자신이 로직을 담당하지 않고 외부 객체가 떠앉게 만든 것이다.

Movie는 객체처럼 보이지만 엄밀히 따지자면 객체가 아니라 그냥 데이터를 묶은 구조체에 가깝다. 왜냐하면 외부 클라이언트 객체가 Movie 내부의 데이터를 꺼내와서 그 데이터를 토대로 호출하는 메소드를 분기 선택하기 때문이다. class 문법을 사용했기에 OOP 처럼 보일 뿐, 저 코드는 그대로 C언어로 바꿔도 아무런 위화감이 없다.

가장 중요한 점은 Screening 객체는 Movie 객체의 변경에 직격탄을 맞는다는 것이다. 새로운 할인 정책이 생기면 switch문 분기 코드를 또 적어야 한다. 할인 정책이 사라지는 경우도 마찬가지다. MovieType을 String으로 썼다가 enum으로 바꾸는 것처럼 데이터 타입을 변경해도 바꿔야 한다.

이 문제의 본질은 Movie 객체가 자신이 담당할 책임(할인 요금 계산) 뒤로 구현을 숨기지 않고 외부로 드러내어 외부 클라이언트 객체가 간단하게 메시지를 전송하지 못하고 이런 저런 로직을 처리하도록 강제했다는 사실에 있다.

즉, 캡슐화는 단순히 데이터와 프로세스를 묶으라는 말이 아니다. 캡슐화의 본질은 객체에게 할당된 책임을 정확하게 수행하여 외부 클라이언트들에게 안전하고 튼튼한 서비스를 제공하라는 뜻이다. 

데이터 중심으로 객체를 설계하면 개발자는 자신도 모르는 사이에 데이터에 의존적인 메소드를 개발하고 만다. 시스템이 객체에게 요구하는 책임이 무엇인지 알기도 전에 데이터부터 구상해버리면 적절한 책임을 식별한다고 한들, 꽉 막힌 데이터 구조로 인해 책임을 할당하지 못하고 이상하게 쪼개져 각 객체에게 할당된다. 위와 같은 예제가 그런 식이다.

※ 캡슐화와 관련된 자세한 내용은 여기 -> 링크

 

 

1-2. 높은 결합도 => 느슨한 결합

결합이 높다는 것은 너무 많은 객체들이 거의 한 덩어리로 움직인다는 의미로, 한 객체의 변경이 그대로 다른 객체들의 변경에 압박을 준다.

위에서 언급한 캡슐화와 공통되는 부분도 있지만 다른 부분도 있다. 캡슐화가 인터페이스를 적절하게 식별하지 못해 외부로 내부 구현 정보를 흘려서 발생하는 의미론적 결합도의 문제를 말한다면, 결합도는 거기서 더 나아가 그러한 결합이 너무 많은 모듈에 걸쳐 발생할 수 있는 잠재적 위험을 말한다.

높은 결합도 또한 단순히 문법적으로 인터페이스를 사용하라는 지침과는 별개라는 사실에 주의하자. 높은 결합도는 설계 상에서 발생하는 문제다. 높은 결합도는 적절한 메시지를 식별하지 못했기 때문에 발생한다. 협력에 필요한 적당한 관계만으로 연결되어야 결합이 느슨해진다. 인터페이스나 추상클래스 사용과는 직접적인 관련이 없다.

인터페이스를 사용한들, 인터페이스를 그저 객체의 내부 데이터를 단순 조작하는 연산으로만 구성한다든가, 인터페이스의 이름 등을 통해 내부 구현에 대한 지식을 흘려 버리면 의미론적 결합도가 높아질 수 있다.  

캡슐화 사례에서도 언급되었지만, 특히 데이터 중심 설계는 내부 데이터를 조작하는 연산으로 구성하는 설계로 이어지기 쉽다. 객체가 외부에게 제공해야 할 책임 서비스부터 구상하지 않고 데이터를 먼저 구상했기 때문에 나중에서야 책임을 할당할 객체가 없거나 데이터들이 각 객체로 분산되어 있다는 사실을 발견하곤 한다.

결국 해당 로직을 구현할 수 있는 데이터를 가지고 있는 객체'들'에게 데이터를 단순 조작하는 서비스를 부여하고, 그것을 조합해 로직을 구성하는 새로운 외부 클라이언트를 만들게 된다. (※객체''이기 때문에 의존하는 객체의 개수 관점에서 본 결합도도 높아진다.)

당연히 이 클라이언트와 단순 데이터 조작을 제공하는 객체들은 서로 강하게 결합된다. 결합의 정도가 너무 높아서 사실상 하나의 거대한 모듈에 불과하다. 코드 상으로만 객체가 분리되었을 뿐이다. 실질적으로는 하나의 모듈, 하나의 덩어리인 것이다.

즉, 낮은 결합도를 위해서는 1) 의존하는 모듈의 결합 수를 줄이고, 2) 모듈 사이의 의미론적 결합도를 낮춰야 한다. (결국 캡슐화다!!)

다만 자바의 String이나 Array같은 표준 라이브러리, 프레임워크 등은 재사용성이 좋은 설계이기 때문에 많은 의존을 허락한다. 이것은 결합도가 높은 것이 아니라 재사용성이 좋은 것이다. 변경되 확률이 적다는 것이 명백하기 때문에 이런 유틸 클래스들을 가지고 결합도를 논하지 않는다.

 

1-3. 낮은 응집도 => 높은 응집도

응집도의 문제는 서로 다른 책임이 한 객체에게 몰려 있는 경우를 말한다. 각 책임이 서로 다른 두 객체를 느슨하게 연결한들, 하나의 객체가 두 가지 이상의 목적으로 사용된다면 응집도는 낮은 것이다.

서로 다른 책임이 하나의 객체에 부여된 경우도 문제지만, 응집도의 문제는 하나의 책임을 적절하게 하나의 역할, 혹은 객체에 부여하지 못할 때도 발생한다.

역시나 이것은 데이터 중심 설계의 문제와 관련이 깊다. 데이터를 중심으로 설계하면 식별된 책임을 부여하기에는 각 객체들이 분산되어 있을 수도 있다. 이때는 책임이 여러 책임으로 다시 쪼개지는데, 쪼개진다고 한들 어차피 책임을 요청하는 클라이언트에서 하나의 책임으로 수렴되어야 한다. 어떤 과정을 거치든 책임을 수행하는 내부 구현 지식들이 서로 다른 객체들로 분산된 시점에서 이미 응집도는 낮아졌을 뿐이다.