스터디

[객체지향의 사실과 오해] 7장 정리

nkdev 2025. 8. 31. 20:17

객체지향 설계 안에 존재하는 3가지 상호 연관된 관점

  • 동일한 클래스를 세 가지 다른 방향에서 바라보는 것을 의미함
  • 1은 클래스, 2는 인터페이스, 3은 속성과 메서드를 반영한다.
  • 클래스는 세 관점을 모두 수용할 수 있도록 개념, 명세, 구현을 함께 드러내야 함
  • 동시에 코드 안에서 세 관점을 쉽게 식별할 수 있도록 깔끔하게 분리해야 함

-> 세 관점을 나눠 설계하면 유지보수와 확장에 강한 코드를 만들 수 있음. 개념을 분명히 하는 건 당연하고, 인터페이스 중심으로 설계하면 구현이 바뀌어도 협력 관계가 유지되고 외부 코드가 바뀌지 않으므로

 

1. 개념 관점(Conceptual Perspective) :

설계는 도메인 안에 존재하는 개념과 개념들 사이의 관계를 표현

실제 도메인 규칙과 제약을 최대한 유사하게 반영하는 것이 핵심

예) '은행 계좌'라는 개념과 그 관계를 정의 -> 현실 세계의 은행 계좌처럼 입금/출금, 잔액 확인 가능

 

2. 명세 관점(Specification Perspective) :

소프트웨어 안의 객체들의 책임에 초점

객체의 인터페이스를 바라보게 됨

객체가 협력을 위해 무엇을 할 수 있는가에 초점을 맞춤

예) 은행 계좌가 제공해야 할 기능(=행위)를 정의

//은행 계좌가 제공해야 할 기능(행위)
public interface Account {
    void deposit(double amount);   // 입금
    void withdraw(double amount);  // 출금
    double getBalance();           // 잔액 조회
}

 

3. 구현 관점(Implementation Perspective) :

객체들이 책임을 수행하는 데 필요한 동작하는 코드를 작성

책임을 어떻게 수행할 것인가에 초점

인터페이스를 구현하는 데 필요한 속성, 메서드를 클래스에 추가

예) 실제로 입금/출금/잔액조회를 어떻게 수행할지 구현

//실제로 입금, 출금 로직이 동작하도록 코드 작성
public class BankAccount implements Account {
    private double balance;  // 상태(속성): 잔액
    
    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public void deposit(double amount) {
        if (amount > 0) balance += amount;
    }

    @Override
    public void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
        }
    }

    @Override
    public double getBalance() {
        return balance;
    }
}

 

가장 중요한 것은 '인터페이스와 구현을 분리'하는 것

 

실제로 훌륭한 설계를 결정하는 측면은 명세 관점인 객체의 인터페이스이다.

명세 관점이 설계를 주도하면 설계 품질이 향상될 수 있다.

  • 인터페이스 : 클래스가 외부에 무엇을 할 수 있는지 알려주는 명세
  • 구현 : 실제로 그 기능이 어떻게 동작하는지 작성한 것
  • 인터페이스에는 '무엇을 할 수 있는지'만 정의하고, 구현은 인터페이스 뒤에 숨기는 것이 중요
  • 캡슐화 위반(인터페이스에 구현 세부사항을 드러냄) -> 구현이 바뀔 때마다 인터페이스도 바뀌어 수정 범위 늘어나게 됨
// 명세 관점인데 내부 데이터 접근을 그대로 노출 → 캡슐화 위반
interface Account {
    double balance = 0;          // 구현 세부사항이 인터페이스에 들어감
    void deposit(double amount);
    void withdraw(double amount);
    double getBalance();
}

 

// 명세 관점: 외부에서 쓸 수 있는 기능(행위)만 선언
interface Account {
    void deposit(double amount);
    void withdraw(double amount);
    double getBalance();
}

// 구현 관점: 실제 동작 로직과 상태는 내부에 감춤
class BankAccount implements Account {
    private double balance;   // 내부 상태는 구현 클래스에서만 관리

    @Override
    public void deposit(double amount) {
        if (amount > 0) balance += amount;
    }

    @Override
    public void withdraw(double amount) {
        if (amount > 0 && amount <= balance) balance -= amount;
    }

    @Override
    public double getBalance() {
        return balance;
    }
}

 

커피 전문점 도메인 설계해보기

위에서 배운 대로 3가지 관점에서 커피 전문점 도메인을 설계해보자.

1. 도메인 모델 설계

협력에 필요한 객체의 종류를 찾아보자.

커피 전문점 도메인 안에는 다음과 같은 객체가 있다.

  • 손님
  • 메뉴 항목
  • 메뉴판
  • 바리스타
  • 커피

그리고 이 객체들의 관계를 도메인 모델로 나타내면 다음과 같다.

마름모 -> 포함 관계(containment) 또는 합성 관계 (composition) : 메뉴판이 메뉴 항목을 포함함

선 -> 연관 관계 (association) : 한쪽이 다른 쪽을 포함하진 않지만 서로 알고 있어야 함

 

2. 협력 관계 찾기

협력에 필요한 객체들이 메시지를 어떻게 주고받는지 설계해보자.

 

5장에서 배웠듯이 협력을 설계할 때는 메시지가 객체를 선택해야 한다.

* 책임 위의 작은 화살표는 메시지에 함께 담겨 전달될 인자이다. (예: 아메리카노를 주문하라)

  • '커피를 주문하라'라는 메시지를 수신할 객체는 '손님'이다. 
  • 손님이 할당된 책임을 수행하는 도중 스스로 할 수 없는 일이 있다면 다른 객체에게 요청해야 한다.
  • '메뉴 항목을 찾아라'라는 메시지를 수신할 객체는 '메뉴판'이다.
  • '손님'은 '메뉴판'에게 요청해서 '메뉴 항목'을 얻었으니, '커피를 제조하라'라는 요청을 할 수 있다.
  • '커피를 제조하라'라는 메시지를 수신할 객체는 '바리스타'이다.
  • '바리스타'는 자율적으로 '커피'를 제조하여 '손님'에게 돌려준다.

이제 이 도식을 바탕으로 인터페이스를 정리해보자.

메시지가 객체를 선택했고, 선택된 객체는 본인이 수신 가능한 메시지를 자신의 인터페이스로 받아들인다.

객체가 어떤 메시지를 수신할 수 있다는 것은 그 객체의 인터페이스 안에 메시지에 해당하는 오퍼레이션이 존재한다는 것을 의미한다.

 

class Customer {
	void orderCoffee(MenuName);
}

class Menu {
	MenuItem choose(MenuName);
}

class MenuItem {}

class Barista {
	Coffee makeCoffee(MenuItem);
}

class Coffee {
	Coffee();
}

-> 혼자 짜본 인터페이스

3. 구현하기

클래스의 인터페이스를 식별했으므로 오퍼레이션 수행 방법을 메서드로 구현하자.

 

Customer가 Menu, Barista 객체에게 메시지를 전송해야 한다.

책에서는 객체에 대한 참조를 얻기 위해 order() 메서드의 인자로 두 객체를 전달받는다.

이런 방식을 사용하면 Customer의 인터페이스가 변경된다. 

 

-> 책에서 전하고자 하는 바는, 구현 도중 인터페이스가 변경될 수 있다는 점이다. 머릿속으로 구상만 하는 설계 작업에 오랜 시간을 쏟지 말고 최대한 빨리 코드를 구현해서 설계에 이상이 없는지, 설계가 구현 가능한지 판단해야 한다.

class Customer {
	public void order(String menuName, Menu menu, Barista barista) {
		Menuitem menuitem = menu.choose(menuName);
		Coffee cofee = barista.makeCoffee(menultem);
	}
}

 

Menu는 menuName에 해당하는 MenuItem을 찾아야 하는 책임이 있다.

이 책임을 수행하기 위해서는 Menu가 내부적으로 MenuItem을 관리하고 있어야 하므로 items를 포함시켰다.

 

-> MenuItem의 목록을 Menu의 속성으로 포함시킨 결정 역시 구현 도중에 내려졌다. 객체 속성은 객체 내부 구현에 속하기 때문에 캡슐화되어야 한다. 따라서 객체에게 책임을 할당하고 인터페이스를 정하는 단계에서는 가급적 객체 내부에 어떤 속성을 가져야 되는지, 어떤 구현이 있어야 하는지 전혀 고려하지 말자. 그래야 인터페이스에 객체 구현 세부사항 노출되는 것도 막고 인터페이스와 구현을 깔끔하게 분리할 수 있다.

class Menu {
	private List<MenuItem> items;
	
	public Menu(List<MenuItem> items) {
		this.items = items;
	}
	
	public Menuitem choose(String name) {
		for(MenuItem each : items) {
			if (each.getNameO.equals(name)) {
					return each;
				}
		}
		return null;
	}
}

 

Barista는 커피를 제조하고

class Barista {
	public Coffee makeCoffee(Menuitem menuitem) {
		Coffee coffee = new Coffee(menultem);
		return coffee;
	}
}

 

Coffee는 자기 자신을 생성하기 위한 생성자를 제공한다.

class Coffee {
	private String name;
	private int price;
	public Coffee(Menuitem menuitem) {
		this.name = menuitem.getName();
		this.price = menultem.cost();
	}
}

 

MenuItem은 getName(), cost()에 응답할 수 있게 메서드를 구현해야 한다.

public class Menuitem {
	private String name;
	private int price;

	public MenuItem(String name, int price) {
		this.name = name;
		this.price = price;
	}

	public int cost() {
		return price;
	}

	public String getName() {
		return name;
	}

}

 

완성된 코드를 클래스 다이어그램으로 나타내면 다음과 같다.

 

책에서 계속 강조하는 것은 설계를 간단히 끝내고 최대한 빨리 코드 구현을 시작하라는 것이다.

설계가 제대로 그려지지 않아도 고민하지 말고 실제로 코드를 작성해가면서 협력의 밑그림을 그려야 한다.

 

 

ISP(인터페이스 분리 원칙, Interface Segregation Principle)

  • 한 인터페이스가 너무 많은 기능을 가지면 변경 위험이 커짐
  • 작은 인터페이스 여러 개로 나누어 필요한 것만 의존하게 설계

OCP(개방-폐쇄 원칙, Open-Closed Principle)

  • 인터페이스는 확장에는 열려 있고, 수정에는 닫혀 있어야 함
  • 새 기능 추가 시 기존 인터페이스는 그대로 두고 새 구현체 추가