본문 바로가기
OOP/<객체지향과 스프링의 이해>, by me

[객체지향과 스프링의 이해] 3. 객체지향 이해하기

by 민휘 2023. 5. 30.

이 글은 2023 GDSC Sookmyung Core Member Session을 텍스트로 정리한 것입니다. (사실 대본 작성하는 김에 쓴거예요-!!) 영상으로 보고 싶으신 분들은 이 링크를 참고해주세요. 대부분의 내용은 조영호 님의 <객체지향의 사실과 오해>, <오브젝트>, 이일민 님의 <토비의 스프링 vol1>을 참고했습니다.


 

저번 포스팅에서 절차지향적이었던 커피 주문 코드를 객체 내부로 로직을 이동시켜 객체지향적으로 개선해보았습니다. 그 결과 결제 수단이 변경되더라도 손님에게 메시지를 보내는 외부는 전혀 영향을 받지 않았습니다. 좀더 변경에 유연한 구조가 되었습니다.

 

 

이번에는 개선 과정에서 살펴본 객체지향의 가치와 평가 기준, 핵심 요소와 자바 언어의 구현에 대해 알아보겠습니다. 무슨 말이냐면 좀더 이론적인 부분을 다룹니다.

 

 

개선 전의 코드와 개선 후의 코드를 평가해보겠습니다. 객체지향 커뮤니티에서 많이 사용하는 평가 기준은 캡슐화 정도, 객체 간 결합도, 객체의 응집도입니다. 캡슐화가 많이 될수록, 결합도가 낮을수록, 응집도가 높을수록 유연한 설계가 가능해집니다.

 

 

캡슐화는 변하는 모든 것을 숨기는 것을 말합니다. 구체적인 데이터, 구체적인 행동 방식, 반환하는 값, 예외까지 변화할 있는 모든 것을 숨기는 것이 캡슐화의 본질입니다. 즉 객체 내부를 블랙박스화하는 것을 말합니다. 캡슐화가 많이 될수록, 외부에서는 객체의 구현사항을 볼 수 없으므로 내부 구현 사항의 변경이 외부에 미치는 영향이 줄어듭니다.

 

 

처음 자바 언어를 공부할 때 캡슐화의 예시로 private 접근 지시자를 사용하고, 조회와 수정 작업은 게터 세터 메소드를 사용할 수 있다고 배우는데, 사실 캡슐화가 제대로 되지는 않습니다. 게터와 세터 메소드에 어떤 데이터를 사용하는지 그대로 드러나기 때문입니다. 만약 데이터의 이름이나 타입이 바뀌면 기존에 이 데이터를 참조하던 클라이언트에도 영향이 갈 것입니다. 이렇게 단순히 필드를 private으로 선언하고 접근자와 수정자에 과도하게 의존하는 설계 방식을 추측에 의한 설계라고 합니다. 객체가 어떤 맥락에서 클라이언트의 요청을 받을지 고민하지 않고, 다양한 상황에서 사용될 것이라는 막연한 추측을 기반으로 설계하기 때문에 대부분의 내부 데이터가 퍼블릭 인터페이스에 노출되어 캡슐화를 깨뜨립니다. 추측에 기반한 설계는 변경에 취약합니다. 맨 처음 자료구조 클래스에 게터 세터를 사용했고, 그로 인해 Cafe의 enter가 데이터 변경에 큰 영향을 받았던 코드가 이 추측에 기반한 설계에 속합니다.

 

 

개선한 코드는 캡슐화 정도가 이전과 비교했을 때 향상되었습니다. 손님과 바리스타, 메뉴판은 자신이 어떤 데이터를 가지고 있는지는 외부에서 볼 수 있는 인터페이스에 드러내지 않았습니다. 대신 외부에서 메시지 요청을 받고 스스로 상태를 변화합니다. 그래서 손님이 가지고 있는 결제 수단이 바뀌더라도, 바리스타가 직접 커피를 만들지 않도록 바꾸더라도 이 객체를 사용하는 클라이언트 객체에는 영향이 없습니다.

다음으로 결합도와 응집도입니다. 결합도는 두 객체가 서로 얼마나 알고 있는지를 뜻하고, 응집도는 하나의 객체가 얼마나 자신의 행동에 충실한지를 의미합니다. 결합도가 높으면 두 객체가 서로의 구현에 대해서 잘 알고 있으므로 함께 변동될 가능성이 높습니다. 응집도가 낮으면 객체의 행동이 다른 객체 여기저기에 퍼져있기 때문에, 행동의 구현 방식이 바뀌면 이 행동을 구성하고 있는 외부의 다른 객체도 함께 변경됩니다. 변경에 유연한 설계를 만드려면 결합도는 낮추고 응집도는 높여야합니다.

 

 

개선한 카페 코드는 캡슐화를 통해서 결합도를 낮추었고, 어느 코드가 어디에 있는 것이 더 자연스러운 맥락인지 고민해보면서 응집도를 높였습니다. 그 결과 Cafe를 주축으로 강하게 결합되었던 다른 객체와의 결합도가 낮아졌습니다. 그리고 Customer가 결제를 하고 메뉴를 고르고 주문을 요청하는 일련의 행동들이 Customer 내부로 모여 응집도가 높아졌습니다.

 

 

보통 캡슐화를 하면 결합도는 낮아지고, 응집도는 높아집니다. 하지만 항상 그런 것은 아닙니다. 캡슐화를 하더라도 기존 기능을 정상적으로 수행해기 위해 필요한 의존성이 추가되어 결합도가 높아질 수도 있습니다. 아까 Customer에 바리스타에게 주문을 요청하는 행동을 추가하면서 기존에는 없던 Barista에 대한 의존도가 새로 생겼습니다. 전체적인 시스템의 측면에서 결합도는 증가했습니다. 이런 경우, 변화를 적용하기 전후를 비교해서 더 좋다고 생각하는 쪽을 선택해야합니다. Customer에 orderCoffee를 추가한 이유는 Cafe가 Customer와 Barista의 결제 흐름을 제어하고 있기 때문이었습니다. Cafe에 orderCoffee를 추가하면 Customer와 Barista의 응집도는 높아지지만, Customer에 Barista의 결합도가 생깁니다. 저는 응집도를 높이는 편이 더 가치 있다고 생각해서 orderCoffee를 추가했습니다.

// 응집도가 올라간 Barista
@AllArgsConstructor
public class Barista {

    private Integer amount;

    private Coffee makeCoffee(MenuItem menuItem) {
        return new Coffee(
                menuItem.getName(),
                menuItem.getPrice());
    }

    private void receive(Integer value) {
        this.amount += value;
    }

    // 추가
    public Coffee takeOrder(MenuItem menuItem, Customer customer) {
        customer.pay(menuItem.getPrice()); // 손님의 결제 수단에 상관없이 결제만 요청
        receive(menuItem.getPrice());
        return makeCoffee(menuItem);
    }
}

// 응집도가 올라간 Customer
// 없던 Barista와의 의존성이 생김
public class Customer {

    private Integer money;
    private Coffee coffee;

    public Customer(Integer money) {
        this.money = money;
    }

    public void orderCoffee(Barista barista, Menu menu) {
        MenuItem menuItem = menu.recommend();
        Coffee coffee = barista.takeOrder(menuItem, this);
        this.coffee = coffee;
    }

    public void pay(Integer cost) {
        if (money >= cost) {
            this.money -= cost;
        } else {
            throw new RuntimeException("잔액이 부족합니다!");
        }
    }

}

 

 

지금까지 한 작업은 캡슐화를 통해 객체간 결합도를 낮추고 객체의 응집도를 높이는 작업이었습니다. 이 작업을 통해 얻은 것은 무엇인가요? 이전보다 변경에 유연하고 확장성 있는 설계가 가능해졌습니다. 결제 방법이 바뀌더라도 손님 안에서만 변화가 일어날 뿐, 손님을 사용하는 클라이언트 객체에는 어떠한 영향도 없습니다. 객체지향에서 좋은 설계는 오늘 요구하는 기능을 온전히 수행하면서 내일의 변경을 매끄럽게 수용할 수 있는 설계입니다. 그러려면 요구사항이 바뀌었을 때 수정해야하는 부분이 제한적이어야 합니다.

 

 

객체지향적으로 만들기 위한 기법은 무엇이 있을까요? 디자인패턴, SOLID 규칙, GRASP 패턴, 상속과 합성 등등 다양한 용어와 가이드라인이 있지만, 이를 관통하는 것은 낮은 결합도와 높은 응집도를 추구하는 것입니다. 그래서 객체는 자신의 상태만을 변경하고, 다른 객체의 상태 변경을 기대할 경우 메시지를 보내야 합니다. 그리고 반복되는, 즉 변경의 이유가 같은 부분과 그렇지 않은 부분을 분리하고, 코드를 재사용하는 것을 시도해보아야 합니다.

 

 

객체지향적인 코드를 만드는 방법을 어느 정도 알아보았으니, 객체지향 패러다임을 이루는 핵심요소를 알아보겠습니다. 바로 협력, 책임, 역할입니다. 협력은 객체들이 애플리케이션 기능을 구현하기 위해 수행하는 상호작용을 뜻합니다. 카페 예제에서는 커피를 주문하는 과정을 의미합니다. 책임은 객체가 협력에 참여하기 위해 수행하는 로직이자 객체의 행동을 말합니다. 손님의 현금 결제, 바리스타의 커피 제조가 이에 속합니다. 역할은 객체들이 협력 안에서 수행하는 책임의 집합이자 추상화된 책임을 말합니다. 카페 예제에서 결제 수단에 상관없이 결제를 수행하는 메시지가 이에 속합니다.

 

 

카페 예제에서 결제 수단이 추가되면 손님의 pay 메소드 구현을 변경한다고 했습니다. 하지만 결제 수단이 추가될 때마다 조건을 추가하고 pay 메소드 구현을 변경하면, 결제 수단을 나타내는 객체와 손님의 결합도가 올라갈 것입니다. 이 부분은 개선이 필요해보이네요.

public class Customer {

		// 결제 수단이 추가되면 여기에 조건문 추가
    public void pay(Integer cost) {
        if (money >= cost) {
            this.money -= cost;
        } else {
            throw new RuntimeException("잔액이 부족합니다!");
        }
    }

}

 

 

손님의 결제 방법이 다양해지면 결제 방법에 따라 결제라는 행동이 계속 추가될 것입니다. 현금 결제, 카드 결제, 애플페이 결제 등으로요. 이때 여러 행동들을 “결제”라는 하나의 기능으로 추상화할 수 있습니다. 그리고 이렇게 추상화된 책임을 역할로 표현할 수 있습니다. 결제라는 역할을 도입하면, 손님이 어떻게 결제하는지 그 방법은 지우고, 결제를 할 수 있다는 메시지만 외부에 드러낼 수 있습니다. 그러면 손님 객체 자체도 자신의 구체적인 결제 수단을 모르기 때문에, 결제 수단 변경에 따른 영향을 받지 않습니다. 그리고 결제를 요청하는 외부의 바리스타도 결제 수단을 모를겁니다.

 

 

이렇게 역할을 사용하면 바리스타는 손님에게 결제하라는 메시지만 날려도, 손님은 원하는 방식으로 다르게 결제할 수 있습니다. 이러한 특성을 다형성이라고 합니다. 동일한 메시지를 수신했을 때 다르게 응답할 수 있는 능력을 다형성이라고 합니다. 역할을 사용하면 다형적인 협력을 만들 수 있습니다.

 

 

역할이 메시지를 사용해 다양한 방식의 실행을 이끌어낼 수 있는 특징을 잘 드러내는 디자인 패턴 중에 프록시 패턴이 있습니다. 프록시는 대리인이라는 뜻인데요, 보통 클라이언트가 원래 타깃에 보내는 메시지를 대신 받아서 처리한다는 의미입니다. 캐시나 보안 검사, 부가기능 적용 등의 기능이 필요할 때 적용할 수 있습니다. 구현 상의 특징은 프록시 객체가 클라이언트와 동일한 인터페이스를 구현한다는 점인데요, 클라이언트 입장에서는 인터페이스에게만 메시지를 보낼 수 있기 때문에, 해당 메시지를 실제로 실행하는 메소드가 프록시에서 수행되는지 타깃에서 수행되는지는 알 수 없습니다. 사실 관심도 없고요. 그래서 클라이언트가 인터페이스에 메시지를 보내면 클라이언트는 요청을 타깃에게 보낸다고 생각하지만, 실제론 프록시 객체가 타깃 대신 메시지를 받아서 처리합니다. 카페 예제에 적용해본다면, 바리스타가 커피를 바로 뽑아서 주는 것이 아니라 이미 뽑아둔 커피를 제공하고 싶다면, 커피를 제공하는 추상적인 역할을 인터페이스로 만들어두고, 이미 뽑아둔 커피를 제공하는 메소드를 가진 프록시에게 요청이 가도록 만들어주면 됩니다.

 

 

우리가 살펴본 객체지향의 중요한 개념들을 객체지향 프로그래밍 언어인 자바에서는 어떤 문법으로 지원하는지 알아보겠습니다. 우선 프로그램의 중심이 되는 객체. 이 객체는 클래스로 구현을 합니다. 좀더 정확하게 말하자면 클래스는 객체의 타입을 구현합니다. 프로그램 상에서 객체는 인스턴스로 생성되어 동작하지만, 코드를 작성할 때 매번 이 인스턴스를 하드코딩해서 만들기는 어렵습니다. 프로그램 안에서 존재할 수 있는 객체를 타입으로 만들어 구현하게 해주는 클래스 문법이 있습니다. 카페 예제에서 커피는 여러 오브젝트가 생성될 수 있지만, 커피를 하나의 타입으로 간주하여 클래스로 만들었던 것처럼요. 참고로 객체를 만드는 방법은 클래스만 있는 것은 아닙니다. 자바스크립트에서는 프로토타입 문법으로 인스턴스를 하나씩 생성할 수 있다고 하네요.

 

 

객체의 행동을 나타내는 책임, 특히 외부에 드러나는 퍼블릭 인터페이스는 퍼블릭 접근 지시자를 사용하는 메소드와 추상 메소드로 구현할 수 있습니다. 역할은 아까 프록시 패턴에서 살펴봤듯이 인터페이스를 사용할 수 있고요, 추상 메소드를 포함하는 추상 클래스로도 구현이 가능합니다. 역할의 본질은 책임의 집합이니까요. 협력은 객체들이 서로 호출하는 메시지를 통한 상호작용으로 구현됩니다. 참고로 객체가 여러가지 협력에 참여하면서 여러 역할을 가질 수 있습니다. 결제 협력에 참여하면 손님은 결제 역할을 가지고, 주문 협력에 참여하면 손님은 주문 역할을 가집니다. 그래서 자바 문법에서는 인터페이스를 여러개 구현할 수 있도록 허용하는 것이 아닐까, 라는 생각이 들었습니다.

이번 포스팅에서는 객체지향의 핵심적인 내용들을 이론 위주로 다루어보았습니다. 다음 포스팅에서는 결제 역할을 도입해서 현금 결제, 카드 결제 등의 다형적인 협력을 만들어보겠습니다.