OOP 제대로 살펴보기

devK08·2025년 12월 24일

SpringBoot

목록 보기
8/9

Alan Kay가 생각한 OOP

캡슐화(Encapsulation)

기존 절차적 프로그래밍을 주로 사용하던 시절 때에는 변경 가능한 공유변수에 대한 고찰이 크게 있었다.

변경 가능한 공유변수에 대한 고찰

상태는 하나지만, 책임은 분산

public class AccountProcedural {

    // 변경 가능한 공유 상태
    static int balance = 1000;

    public static void method1(int amount) {
        balance += amount;
    }

    public static void method2(int amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }

    public static void applyMonthlyFee() {
        balance -= 100;
    }
}

이 코드에서 balance는 시스템의 중심이다.

그러나 이 상태를 변경하는 책임은
method1, method2, applyMonthlyFee로 분산되어 있다.

어떤 비즈니스 규칙이 잔액을 변경했는지
코드를 보아도 알 수 없다.
상태는 공유되지만, 책임은 공유되지 않는다.

시간이 지나 다음과 같은 요구사항이 추가되었다.

“VIP 계좌에는 월 수수료를 부과하지 않는다.”

그러면, 많은 개발자들은 이렇게 코드를 고칠 것이다.

public static void applyMonthlyFee(boolean isVip) {
    if (!isVip) {
		balance -= 100;
    }
}

그리고 테스트를 한 후, 해당 요구사항을 해결했다고 했을 것이다.
그러나 이 변경은 문제를 해결한 것이 아니라 문제를 변형했을 뿐이다.

계좌의 성격 이라는 도메인 정보가
applyMonthlyFee의 호출자에게까지 전파되기 시작했다는 점이다.

이제 이 메서드를 호출하는 모든 코드는
“이 계좌가 어떤 계좌인가”를 알아야만 한다.

Alan Kay가 문제 삼았던 것은
“전역 변수” 그 자체가 아니었다.

그가 경계한 것은 상태에 대한 결정이 시스템 전체로 흩어지는 구조였다.

캡슐화 개념 재정의

보통 OOP 책에서는 캡슐화는 데이터와 메서드를 하나로 묶고, 정보를 은닉하는 것으로 배운다.
하지만, 내가 생각한 진정한 캡슐화는
변경 가능한 상태에 대한 결정이 시스템의 경계를 넘지 못하도록 가두는 것이다.

즉, 어떤 변수의 상태를 변경할 수 있는 결정은 하나의 객체,
혹은 명확한 책임 경계를 가진 Aggregate 내부에서만 일어나야 한다.
외부는 얘가 무엇을 하는지만 알고, 어떻게 변경하는지는 알 필요가 없어야 한다.

메세징(Messaging)

메세징에 대한 일반적인 오해

보통 메시징은 객체의 값을 읽거나 쓰기 위한 수단으로 이해된다.
그래서 메시지는 종종 getter나 setter 호출과 크게 다르지 않게 취급된다.

하지만 객체지향에서의 메시지는
값에 접근하기 위한 수단이 아니라,
객체에게 무엇을 하라고 요청하는 행위다.

메세징은 책임을 정의한다

어떤 메시지를 정의하느냐에 따라 객체가 가지는 책임의 범위가 결정된다.
메세지가 잘못되면 그 책임도 잘못배치되게 된다.
상태를 묻는 메세지는 결정을 호출자쪽으로 이동된다.
그 결과, 원래 객체가 가져야 할 책임이 호출자에게까지 분산된다.
이는 캡슐화를 깨지게 한다.

메세징은 캡슐화를 완성하는 메커니즘

어떤 독자들은 이렇게 의심할 수 있다.
상태를 객체 내부에 감췄다면, 그것만으로 캡슐화는 충분한 것 아니냐고.

하지만 상태를 감추는 것만으로는 부족하다.
외부가 그 상태를 묻고, 그 결과에 따라 판단을 내리기 시작하는 순간,
상태에 대한 결정은 다시 객체의 경계를 넘어 외부로 이동한다.

코드를 보면서 이해해보자.

class Account {
    private int balance;
    private boolean vip;

    boolean isVip() {
        return vip;
    }

    void deduct(int amount) {
        balance -= amount;
    }
}

보기에는 캡슐화를 잘 지킨 객체처럼 보인다.
필드는 모두 private으로 감춰져 있고, 외부에서는 직접 접근할 수 없다.
하지만, 호출자를 보면 생각이 달라진다.

// 호출자 코드 중..
if (!account.isVip()) {
    account.deduct(100);
}

외부에서는 단순히 상태를 조회했을 뿐이라고 생각할 수 있다.
하지만, 실제로는 VIP 여부에 따라 수수료를 부과할지 말지를 외부에서 결정하고 있다.
즉, 외부가 객체의 상태를 해석하고, 그에 따른 행동을 직접 결정하고 있다는 것이다.

이렇게 상태에 대한 판단이 외부로 이동하게 되면,
캡슐화는 형식만 남고 설계적 의미는 사라진다.
그래서 진정한 캡슐화를 완성하기 위해서는 메시징이 필요하다.

진정한 메세징을 쓰는 방법

class Account {
    private int balance;
    private boolean vip;

    void applyServiceFee() {
        if (!vip) {
            balance -= 100;
        }
    }
}

이렇게 짠다면, 호출자는 더 이상 상태를 묻지 않아도 된다.
이 원칙을 Tell Don't Ask 원칙이라고 한다.

account.applyServiceFee();

외부에서는 더 이상 VIP가 무엇인지 수수료 정책이 어떻게 되는지 알 필요가 없다.

진정한 메세징을 썼는지 판단하는 3가지 질문

  • 이 메시지는 상태를 묻지 않는가?
  • 이 메시지는 행위를 요청하고 있는가?
  • 이 메시지를 받은 객체가 결정을 내리고 있는가?

위 3가지 질문에 모두 "예"라고 답할 수 있다면,
진정한 메시징을 지켰다고 볼 수 있다.

내가 메세징을 보고 생각한 철학

이제 더 이상 무지성으로 @Getter, @Setter를 세우지 않기로 했다.
캡슐화를 지킨다는 명분 아래,
객체를 단순한 데이터 박스로 만들고 있었기 때문이다.

또한 increase, decrease처럼
상태를 어떻게 변경하는지를 그대로 드러내는 메서드도
의식적으로 피하려 한다.

그런 이름의 메서드는
“무엇을 하려는가”가 아니라
“어떻게 바꾸는가”를 먼저 말하게 만들고,
결국 객체의 상태에 대한 판단을 호출자 쪽으로 밀어내기 때문이다.

앞으로 나는,
객체의 상태를 만지는 코드를 작성하기보다
객체에게 역할을 요청하는 메시지를 먼저 고민을 해봐야겠다.

동적 바인딩(Dynamic Binding)

OOP의 핵심: 메시지를 보내면 객체가 알아서 반응한다

List<PaymentService> services = getPaymentServices(); // 뭐가 들어올지 모름for (PaymentService service : services) {
    service.pay(); // "결제해줘"라는 메시지만 보냄
}

이 코드의 핵심은 모른다는 것이다.

보내는 쪽은 몰라도 된다.
리스트에 뭐가 들었는지
각 객체가 어떻게 반응할지
앞으로 어떤 결제 수단이 추가될지

받는 쪽이 알아서 한다
CardPayment는 카드 결제 로직으로
CashPayment는 현금 결제 로직으로

각자 자신만의 방식으로 반응
이게 메시지 패싱(Message Passing) 이다.
그리고 이걸 가능하게 하는 게 동적 바인딩(Dynamic Binding) 이다.

바인딩이란?

바인딩은 메시지(호출)와 실제 실행될 메서드를 연결하는 것이다.
그리고 이 연결이 컴파일 시점에 일어난다면, 정적 바인딩
런타임 시점에 일어난다면, 동적 바인딩이라고 부른다.

메세지 패싱

Alan Kay는 OOP의 본질이 객체가 아니라 메세지라고 말했다.
객체는 메세지를 받고 스스로 어떻게 행동할 지 결정한다.

Sender
메세지만 보낸다.
어떻게 결제할지는 관심 없다
누가 받을지도 몰라도 된다

Receiver
메시지를 받고 스스로 판단한다
자신만의 방식으로 반응한다
같은 메시지에 대해 다르게 행동할 수 있다

Late Binding의 철학적 의미
이것이 Late Binding(늦은 바인딩, 동적 바인딩)의 본질이다.
결정을 최대한 늦춘다.

컴파일 시점: "누군가의 메서드가 실행되겠지"
런타임 시점: "아, 이 객체는 A객체구나. 이 메서드를 실행해야지"

실제로 메시지를 받는 순간까지 기다린다.
그래야 누가 받을지 몰라도 코드를 작성할 수 있다.

코드로 보는 다형성

public class PaymentProcessor {
    
    public void processAll(List<PaymentService> services) {
        for (PaymentService service : services) {
            service.pay(); // 동적 바인딩 발생
        }
    }
}

PaymentProcesser는 어떤 구현체가 들어올지 모른다.
또한, 앞으로 추가될 새로운 결제 수단도 모른다.
그저 pay()라는 메서드만 보낼 뿐이다.

그래서 새로운 결제유형이 추가되더라도,
기존, PaymentProcessor 코드는 변경이 일어나지 않는다.
이를 OCP(Open Closed Principle)이라고 한다.

동적 바인딩을 보고 생각한 철학

내가 동적 바인딩을 보고 하나의 원칙을 세웠다.
필드명이나 메서드명에 구체적인 구체화 모델을 담지 않겠다는 것이다.
왜냐하면, 필드명이나 메서드명에 구체화된 모델명이 들어가버리면,
그 구체화 모델에 종속적이게 되버리기 때문이다.
그렇게 되면 OOP의 원칙에 어긋난다.

코드를 작성할 때:

  • processCardPayment() 대신 process()
  • cardPayment 대신 paymentService
  • 구체 타입이 아닌, 역할과 메시지에 집중

이것이 동적 바인딩을 존중하는 방법이라고 생각한다.

profile
안녕하세요. 개발자 지망 고등학생입니다.

0개의 댓글