[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 1 - 절차지향과 비교하기

 

순차지향, 절차지향, 객체지향 프로그래밍이란?

‘순차’라는 말과 ‘절차’라는 말은 모두 순서대로 진행한다는 느낌을 주기 때문에, 순차지향 프로그래밍과 절차지향 프로그래밍을 같은 의미로 받아들이기 쉽다. 실제로 두 개념을 비슷하게 설명하는 글도 종종 보인다. 하지만 이 둘은 같은 관점이라고 보기 어렵다.

 

여기서 구분해야 할 핵심은 무엇을 중심으로 프로그램을 구성하느냐이다.

 

순차적으로 코드를 따라가며 문제를 해결하는 관점에서는 실행 흐름이 중심이 된다. 반면 절차지향에서는 문제를 여러 개의 프로시저(함수) 로 나누고, 그 프로시저들의 조합으로 문제를 해결한다. 즉, 절차지향은 단순히 “위에서 아래로 실행된다”는 의미보다, 문제를 함수 단위로 분해하고 그 흐름으로 해결한다는 데 더 가깝다.

 

그래서 자바나 코틀린 같은 객체지향 언어를 사용하더라도, 실제 사고방식이 함수 중심이라면 얼마든지 절차지향적인 코드가 나올 수 있다.

 

!정보 프로그래밍 언어가 곧 프로그래밍 패러다임인 것은 아니다.

 

실무에서도 이런 장면은 익숙하다. 레이어드 아키텍처라는 이름 아래 서비스(Service) 계층에 비즈니스 로직이 몰리고, 클래스는 그저 데이터를 담는 용도로만 사용되는 경우가 많다. 겉으로는 객체지향 언어를 쓰고 있지만, 실제 코드의 사고방식은 절차지향에 가까운 셈이다.

 

객체지향 프로그래밍은 무엇이 다른가?

객체지향 프로그래밍에서는 객체가 메시지를 받고, 자신에게 주어진 책임을 스스로 수행한다. 중요한 것은 외부가 객체의 데이터를 꺼내 직접 계산하는 것이 아니라, 객체가 자신의 데이터를 바탕으로 자신이 해야 할 일을 처리한다는 점이다.

정리하면 다음과 같다.

  • 객체에 어떤 메시지를 전달할 수 있게 됐다.
  • 객체가 어떤 책임을 지게 됐다.
  • 객체는 어떤 책임을 처리하는 방법을 스스로 알고 있다.


이 차이는 코드로 보면 더 분명하게 드러난다.

 

class RestaurantChain {

    private List<Store> stores;
    
    public long calculateRevenue() {
        long revenue = 0;
        for (Store store : stores) {
            //{
            revenue += store.calculateRevenue();
            //}
        }
        return revenue;
    }
    
    public long calculateProfit() {
        long income = 0;
        for (Store store : stores) {
            //{
            income += store.calculateProfit();
            //}
        }
        return income;
    }
}

class Store {

    private List<Order> orders;
    private long rentalFee; // 임대료
    
    //{
    public long calculateRevenue() {
        long revenue = 0;
        for (Order order : orders) {
            revenue += order.calculateRevenue();
        }
        return revenue;
    }
    //}
    
    //{
    public long calculateProfit() {
        long income = 0;
        for (Order order : orders) {
            income += order.calculateProfit();
        }
        return income - rentalFee;
    }
    //}
}
    
class Order {

    private List<Food> foods;
    private double transactionFeePercent = 0.03; // 결제 수수료 3%
    
    //{
    public long calculateRevenue() {
        long revenue = 0;
        for (Food food : foods) {
            revenue += food.calculateRevenue();
        }
        return revenue;
    }
    //}
    
    //{
    public long calculateProfit() {
        long income = 0;
        for (Food food : foods) {
            income += food.calculateProfit();
        }
        return (long) (income - calculateRevenue() * transactionFeePercent);
    }
    //}
}

class Food {

    private long price;
    private long originCost; // 원가
    
    //{
    public long calculateRevenue() {
        return price;
    }
    //}
    
    //{
    public long calculateProfit() {
        return price - originCost;
    }
    //}
}
// RestaurantChain이 전체 매출을 직접 계산하지 않고, 각 Store에 위임한다. // RestaurantChain이 전체 이익을 직접 계산하지 않고, 각 Store에 위임한다. // Store가 주문들의 매출을 모아 자신의 매출을 계산한다. // Store가 주문들의 이익을 모아 임대료를 반영한 자신의 이익을 계산한다. // Order가 음식들의 매출을 모아 자신의 매출을 계산한다. // Order가 음식들의 이익을 모아 수수료를 반영한 자신의 이익을 계산한다. // Food가 자신의 가격으로 매출을 계산한다. // Food가 자신의 원가를 반영해 이익을 계산한다.

 

이 코드의 핵심은 수익과 이익을 계산하는 책임이 한곳에 몰려 있지 않다는 점이다. 결제 수수료는 `Order`가 알고 있고, 임대료는 `Store`가 알고 있으며, 음식 가격과 원가는 `Food`가 알고 있다. 각 객체는 자신이 가진 데이터와 가장 밀접한 계산을 직접 수행한다.

 

이처럼 데이터와 행위가 함께 모여 있을수록 응집도(cohesion)가 높다고 볼 수 있다.

 

 

객체지향은 가독성보다 책임에 더 가깝다

객체지향 코드는 가독성 측면에서 호불호가 갈릴 수 있다. 로직이 하나의 서비스 클래스에 모여 있는 방식보다, 여러 객체로 나뉘어 있는 구조가 처음에는 더 복잡하게 느껴질 수도 있다.

 

하지만 객체지향의 핵심은 단순히 코드를 읽기 쉽게 만드는 데 있지 않다. 객체지향은 누가 어떤 책임을 맡고 있는가에 더 집중한다.

 

각 객체는 자신이 수행해야 할 책임이 무엇인지 알고 있고, 그 책임을 수행하는 데 필요한 데이터도 스스로 가지고 있다. 다시 말해, 자신이 해야 할 일을 자신이 가장 잘 아는 구조를 만드는 것이 객체지향의 중요한 목적이다.

 

물론 책임이 여러 객체에 나뉘면 전체 로직이 분산되어 보일 수 있다. 또 협력 객체의 내부 구현이 직접 드러나지 않기 때문에, 바깥에서는 그 객체가 내부적으로 어떻게 동작하는지 알 수 없다. 그러나 객체지향에서는 바로 그 점이 중요하다. 우리는 객체의 내부 구현이 아니라, 그 객체가 자신의 책임을 올바르게 수행하는지에 관심을 둔다.

 

이런 관점이 바로 캡슐화와 연결된다.

 

!정보 책임을 객체가 나눠 가져야 한다.

 

책임은 객체지향만의 개념이 아니다

그렇다고 절차지향에서는 책임을 나눌 수 없다는 뜻은 아니다. 절차지향에서도 책임은 존재한다. 다만 그 책임이 주로 함수(프로시저) 단위에 할당될 뿐이다.

 

즉, 책임 자체가 객체지향만의 특징은 아니다. 중요한 것은 그 책임을 어디에 할당하느냐이다.

  • 절차지향에서는 책임을 프로시저로 나누고, 프로시저에 할당한다.
  • 객체지향에서는 책임을 객체로 나누고, 객체에 할당한다.

객체지향에서는 책임을 객체로 나누고, 객체에 할당한다.

 

객체지향의 핵심은 역할, 책임, 협력이다

그렇다면 객체지향은 단순히 “책임을 객체에 할당하는 것”으로 설명할 수 있을까? 거기서 한 걸음 더 나아가야 한다. 객체지향에서는 구체적인 객체 자체보다, 그 객체가 수행해야 하는 역할(role) 에 주목한다.

 

역할과 구현을 분리하면 큰 장점이 생긴다. 어떤 역할을 수행할 수 있는 객체라면, 그 객체의 구체적인 종류가 무엇인지는 덜 중요해진다. 나는 특정 구현체와 직접 결합되는 것이 아니라, 그 역할을 수행할 수 있는 대상과 협력하면 된다.

 

이 구조는 확장에도 유연하다. 새로운 요구사항이 생겨도 기존 구조를 크게 흔들지 않고, 같은 역할을 수행하는 새로운 구현체를 추가하는 방식으로 대응할 수 있기 때문이다.

 

그래서 객체지향의 본질은 언어나 문법 자체보다도, 오히려 역할, 책임, 협력에 더 가깝다.

 

추상화, 다형성, 상속, 캡슐화 역시 중요하다. 하지만 그것들은 객체지향의 본질이라기보다, 역할·책임·협력을 더 잘 다루기 위해 언어 차원에서 제공하는 기능에 가깝다. 즉, 대표적인 특징일 수는 있어도 핵심 자체라고 보기는 어렵다.

 

객체는 현실 세계를 그대로 반영하지 않는다

객체지향을 설명할 때 종종 “현실 세계를 소프트웨어로 옮기는 방식”이라고 말하곤 한다. 하지만 이 표현은 객체지향을 이해하는 데 오히려 혼동을 줄 수 있다.

 

예를 들어 Food 객체가 자신의 가격과 원가를 바탕으로 수익이나 이익을 계산한다고 해보자. 현실 세계의 음식이 스스로 원가를 설명하거나 가격을 계산하지는 않는다. 그런 점에서 객체는 현실 세계를 그대로 복사한 존재라고 보기 어렵다.

 

오히려 객체지향은, 자신의 상태와 책임을 가진 주체들이 서로 메시지를 주고받으며 협력하는 방식으로 시스템을 구성하는 패러다임에 더 가깝다.

 

TDA 원칙

그렇다면 어떻게 해야 절차지향적 사고에서 벗어나 객체지향적인 사고 방식을 가질 수 있을까? 절차지향적인 사고에서 벗어나 객체지향적으로 설계하기 위한 대표적인 원칙 중 하나가 바로 TDA(Tell, Don’t Ask) 이다.

 

말 그대로 해석하면 “묻지 말고 시켜라”이다. 객체에게 값을 꺼내 와서 외부에서 처리하지 말고, 그 객체에게 일을 시키라는 의미다.

 

이 원칙은 실무에서 흔히 남발되는 getter와 setter를 돌아보게 만든다. 객체의 내부 데이터에 외부가 너무 쉽게 접근할 수 있으면, 개발자는 자연스럽게 서비스나 매니저 클래스 같은 곳에 로직을 몰아넣게 된다. 왜냐하면 데이터를 꺼내 한 번에 처리하는 방식이 더 쉽고 빠르게 느껴지기 때문이다.

 

하지만 그렇게 되면 객체는 능동적으로 책임을 수행하는 존재가 아니라, 그저 데이터를 담아 나르는 수동적인 구조가 된다.

 

!정보 객체를 덩어리 데이터로 보지 말고 객체에게 책임을 위임하자.

 

객체는 단순한 데이터 컨테이너가 아니라, 자신의 책임을 수행하는 주체처럼 동작해야 한다. 객체지향적인 설계는 결국 데이터를 어디에 둘 것인가보다, 행동을 누구에게 맡길 것인가를 먼저 고민하는 데서 출발한다.

 

다만 이것이 getter를 무조건 없애야 한다는 뜻은 아니다. getter는 분명 필요한 메서드이며, 협력을 위해 외부에 값을 제공해야 하는 상황도 실제로 존재한다. 중요한 것은 getter의 존재 자체가 아니라, 객체의 책임을 외부로 과도하게 밀어내고 있지는 않은지를 계속 점검하는 것이다.

 

!경고 그렇다고 객체에게 모든 일을 시킬 수 만은 없다. 게터는 분명 필요한 메서드이며, 객체에게 일을 최대한 시키려 해도 어딘가에서는 협력을 위해 게터를 사용해야 하는 상황이 분명 발생한다.

 

마무리

절차지향과 객체지향의 차이는 단순히 문법이나 언어에서 생기는 것이 아니다. 더 중요한 차이는 문제를 어떤 단위로 나누고, 그 책임을 어디에 할당하느냐에 있다.

 

절차지향이 프로시저를 중심으로 문제를 해결한다면, 객체지향은 객체가 역할을 가지고 서로 협력하며 문제를 해결한다. 그래서 객체지향을 이해할 때는 추상화, 다형성, 상속 같은 기능보다도, 먼저 역할, 책임, 협력이라는 관점을 붙잡는 것이 중요하다.

 

그리고 그 관점을 실무 코드로 옮길 때 도움이 되는 원칙이 바로 TDA다. 객체를 수동적인 데이터 묶음으로 바라보지 않고, 스스로 책임을 수행하는 존재로 다루기 시작할 때 비로소 객체지향적인 설계에 가까워질 수 있다.