절차 지향·객체 지향·함수형 프로그래밍의 차이: 같은 문제로 이해하는 세 가지 설계 방식

절차 지향·객체 지향·함수형 프로그래밍의 차이: 같은 문제로 이해하는 세 가지 설계 방식#

프로그래밍 패러다임은 프로그램을 어떤 단위로 나누고, 데이터와 동작의 관계를 어떻게 구성할지에 대한 접근 방식입니다.

같은 주문 금액 계산 기능도 여러 방식으로 만들 수 있습니다.

  • 절차 지향: 주문 데이터를 어떤 순서로 처리할까?
  • 객체 지향: 주문이라는 대상은 어떤 상태와 동작을 책임질까?
  • 함수형: 입력 데이터를 어떤 함수들로 변환해 결과를 얻을까?

세 방식은 서로 다른 문제를 푸는 기술이 아닙니다. 같은 문제를 바라보는 설계 관점이 다릅니다. 이 글에서는 하나의 주문 계산 문제를 세 번 구현하며 차이를 확인합니다. 예제에는 Python을 사용하지만, 설명하는 관점은 특정 언어에 한정되지 않습니다.

먼저 비교표로 보는 핵심 차이#

구분 절차 지향 객체 지향 함수형
주로 생각하는 질문 어떤 순서로 실행할까? 누가 이 데이터와 동작을 맡을까? 입력을 어떤 결과로 변환할까?
코드를 나누는 중심 단계와 함수 객체와 책임 함수와 데이터 변환
상태를 다루는 대표적인 방식 변수를 단계별로 갱신 객체가 자신의 상태를 관리 기존 값을 바꾸지 않고 새 결과를 만드는 방식을 선호
유용한 상황 처리 순서가 분명한 작업 상태와 동작을 함께 관리할 대상이 많은 작업 입력과 결과를 분명히 나눌 수 있는 계산·변환 작업
주의할 점 공유 변수가 늘면 변경 위치를 추적하기 어려움 필요 이상의 클래스와 관계를 만들 수 있음 함수 호출을 과도하게 중첩하면 흐름을 읽기 어려움

이 표는 각 패러다임의 일반적인 경향을 보여줍니다. 절차 지향은 함수를 쓸 수 없고, 객체 지향은 데이터를 변환할 수 없고, 함수형은 상태가 존재할 수 없다는 뜻이 아닙니다. 실제 프로그램에서는 서로 다른 방식이 함께 쓰입니다.

먼저 해결할 문제를 정해 보자#

쇼핑몰 주문의 상품 목록이 있습니다.

items = [
    {"name": "노트", "price": 3_000, "quantity": 2},
    {"name": "키보드", "price": 25_000, "quantity": 1},
    {"name": "펜", "price": 1_000, "quantity": 3},
]

각 상품의 금액은 가격 × 수량입니다.

상품 계산 금액
노트 3,000원 × 2개 6,000원
키보드 25,000원 × 1개 25,000원
펜 1,000원 × 3개 3,000원
상품 합계 34,000원

이번 예제의 규칙은 다음과 같습니다.

  1. 상품 금액을 모두 합산합니다.
  2. 상품 합계가 30,000원 이상이면 10%를 할인합니다.
  3. 최종 결제 금액을 구합니다.

따라서 이 주문의 할인 금액은 3,400원이고, 최종 금액은 30,600원입니다.

이 규칙은 세 구현에서 동일하게 유지합니다. 달라지는 것은 코드가 책임을 나누고 값을 다루는 방식입니다.

예제에서는 가격을 원 단위 정수로 다루며, 할인 금액도 정수로 떨어집니다. 실제 결제에서는 할인액의 소수점 처리와 반올림 규칙을 별도로 정해야 합니다.

절차 지향: 처리 순서를 따라 프로그램 구성하기#

절차 지향 프로그래밍은 해야 할 작업을 단계로 나누고 순서대로 실행 흐름을 구성하는 방식입니다. 주문 계산을 말로 적으면 다음과 같습니다.

  1. 합계를 0으로 시작한다.
  2. 상품을 하나씩 읽는다.
  3. 각 상품의 가격과 수량을 곱해 합계에 더한다.
  4. 합계가 할인 기준 이상인지 확인한다.
  5. 할인 금액을 빼고 결과를 출력한다.

이 순서를 그대로 코드로 옮겨 보겠습니다.

items = [
    {"name": "노트", "price": 3_000, "quantity": 2},
    {"name": "키보드", "price": 25_000, "quantity": 1},
    {"name": "펜", "price": 1_000, "quantity": 3},
]

subtotal = 0

for item in items:
    line_total = item["price"] * item["quantity"]
    subtotal += line_total

if subtotal >= 30_000:
    discount = subtotal * 10 // 100
else:
    discount = 0

total = subtotal - discount

print(f"상품 합계: {subtotal}원")
print(f"할인 금액: {discount}원")
print(f"최종 금액: {total}원")

실행 결과:

상품 합계: 34000원
할인 금액: 3400원
최종 금액: 30600원

절차 지향 코드에서는 무엇이 중심인가?#

코드를 읽을 때 가장 먼저 보이는 것은 처리 순서입니다. subtotal은 상품을 하나씩 처리하면서 변하고, 그 결과를 이용해 discount와 total을 계산합니다.

이 방식은 한 번의 계산 흐름을 따라가기에 좋습니다. 입력을 받고, 검사하고, 계산하고, 출력하는 작은 프로그램에서는 직관적입니다.

절차를 함수로 나눌 수도 있습니다. 함수 사용 여부로 절차 지향과 객체 지향을 구분하는 것은 아닙니다.

def calculate_subtotal(items):
    subtotal = 0

    for item in items:
        subtotal += item["price"] * item["quantity"]

    return subtotal


def calculate_discount(subtotal):
    if subtotal >= 30_000:
        return subtotal * 10 // 100

    return 0


subtotal = calculate_subtotal(items)
discount = calculate_discount(subtotal)
total = subtotal - discount

전체 흐름은 여전히 “합계 계산 → 할인 계산 → 최종 금액 계산”입니다. 각각의 단계를 함수로 나눠 읽기 쉽게 만든 것입니다.

절차 지향의 문제가 아니라 공유 상태의 문제#

절차 지향이 커지면 무조건 코드가 복잡해진다는 설명은 정확하지 않습니다. 문제가 자주 생기는 지점은 여러 함수가 같은 변경 가능한 데이터를 자유롭게 수정하는 구조입니다.

current_total = 0


def add_item_price(price):
    global current_total
    current_total += price


def apply_discount(amount):
    global current_total
    current_total -= amount

어느 함수가 언제 current_total을 바꿨는지 확인하려면 관련 호출을 모두 추적해야 합니다. 새로운 기능까지 같은 변수를 건드리기 시작하면 결과를 예상하기 어려워집니다.

반대로 함수 사이에 값을 명시적으로 전달하고 반환하면, 절차 중심으로 작성해도 충분히 관리하기 좋은 코드가 될 수 있습니다.

subtotal = calculate_subtotal(items)
discount = calculate_discount(subtotal)
total = subtotal - discount

절차 지향 자체와 무분별한 전역 변수 사용은 같은 뜻이 아닙니다.

객체 지향: 데이터와 동작의 책임을 대상에 맡기기#

객체 지향 프로그래밍은 프로그램에서 의미 있는 대상을 찾고, 그 대상이 관리할 상태와 동작을 함께 설계합니다.

주문 계산에서는 다음과 같이 나눠 생각할 수 있습니다.

  • 주문 상품: 상품명, 가격, 수량을 가진다. 자신의 상품 금액을 계산한다.
  • 주문: 여러 주문 상품을 가진다. 합계와 할인액, 최종 금액을 계산한다.

Python의 dataclass로 각 대상의 데이터를 표현해 보겠습니다. dataclass는 여기서 클래스의 반복적인 초기화 코드를 줄이기 위한 도구입니다. 객체 지향의 핵심이 dataclass 사용 자체에 있는 것은 아닙니다.

from dataclasses import dataclass


@dataclass
class OrderItem:
    name: str
    price: int
    quantity: int

    def line_total(self) -> int:
        return self.price * self.quantity


class Order:
    def __init__(self, items):
        self.items = list(items)

    def subtotal(self) -> int:
        return sum(item.line_total() for item in self.items)

    def discount(self) -> int:
        amount = self.subtotal()

        if amount >= 30_000:
            return amount * 10 // 100

        return 0

    def total(self) -> int:
        return self.subtotal() - self.discount()

사용하는 코드는 다음과 같습니다.

order = Order([
    OrderItem("노트", 3_000, 2),
    OrderItem("키보드", 25_000, 1),
    OrderItem("펜", 1_000, 3),
])

print(f"상품 합계: {order.subtotal()}원")
print(f"할인 금액: {order.discount()}원")
print(f"최종 금액: {order.total()}원")

실행 결과:

상품 합계: 34000원
할인 금액: 3400원
최종 금액: 30600원

객체 지향 코드에서는 무엇이 중심인가?#

계산을 호출하는 코드가 order.total()처럼 주문이라는 대상에게 자신의 최종 금액을 요청합니다.

주문 상품의 금액은 OrderItem.line_total()이 계산합니다. Order는 상품들을 모아 주문의 합계와 할인액을 계산합니다. 관련된 데이터와 동작이 각각의 책임에 따라 모여 있습니다.

주문이 여러 건이라면 같은 클래스로 서로 다른 객체를 만들 수 있습니다.

first_order = Order([
    OrderItem("노트", 3_000, 2),
])

second_order = Order([
    OrderItem("키보드", 25_000, 1),
    OrderItem("펜", 1_000, 3),
])

print(first_order.total())   # 6000
print(second_order.total())  # 28000

두 주문은 같은 구조와 메서드를 사용하지만, 서로 다른 상품 목록을 가지고 결과를 계산합니다.

클래스 안에 넣었다고 모두 좋은 설계일까?#

그렇지는 않습니다. 다음처럼 모든 계산을 하나의 거대한 객체에 넣는다면, 클래스 문법을 사용했어도 책임을 잘 나눈 것은 아닙니다.

class ShopManager:
    def calculate_order(self):
        ...

    def send_email(self):
        ...

    def update_inventory(self):
        ...

    def generate_report(self):
        ...

주문 계산, 이메일 발송, 재고 변경, 보고서 작성은 서로 다른 이유로 변경될 수 있습니다. 한 클래스에 모두 넣으면 변경의 영향 범위가 커집니다.

반대로 앞의 작은 주문 계산 예제에 클래스가 꼭 필요하다고 단정할 수도 없습니다. 단발성 계산이라면 함수 몇 개가 더 단순합니다. 상태와 동작을 하나의 대상으로 관리할 이유가 있을 때 객체 지향 설계가 힘을 발휘합니다.

함수형: 입력을 받아 새로운 결과로 변환하기#

함수형 프로그래밍은 함수를 조합해 데이터를 변환하는 관점에 무게를 둡니다. 특히 같은 입력에 같은 결과를 내고 바깥 상태를 변경하지 않는 순수 함수, 기존 값을 직접 바꾸지 않는 불변성이 중요한 개념입니다.

주문 계산을 함수들이 주고받는 값의 흐름으로 표현해 보겠습니다.

def line_total(item):
    return item["price"] * item["quantity"]


def calculate_subtotal(items):
    return sum(map(line_total, items))


def calculate_discount(subtotal):
    return subtotal * 10 // 100 if subtotal >= 30_000 else 0


def calculate_total(subtotal, discount):
    return subtotal - discount
items = [
    {"name": "노트", "price": 3_000, "quantity": 2},
    {"name": "키보드", "price": 25_000, "quantity": 1},
    {"name": "펜", "price": 1_000, "quantity": 3},
]

subtotal = calculate_subtotal(items)
discount = calculate_discount(subtotal)
total = calculate_total(subtotal, discount)

print(f"상품 합계: {subtotal}원")
print(f"할인 금액: {discount}원")
print(f"최종 금액: {total}원")

결과는 앞의 두 구현과 같습니다.

상품 합계: 34000원
할인 금액: 3400원
최종 금액: 30600원

함수형 코드에서는 무엇이 중심인가?#

각 함수가 입력을 받아 결과를 반환합니다.

  • line_total(item)은 상품 하나를 금액으로 변환합니다.
  • calculate_subtotal(items)은 상품 금액들을 합산합니다.
  • calculate_discount(subtotal)은 합계를 할인 금액으로 변환합니다.
  • calculate_total(subtotal, discount)는 최종 금액을 만듭니다.

함수 안에서 전역 합계를 바꾸지 않고, 전달받은 상품 목록에도 항목을 추가하거나 삭제하지 않습니다. 같은 입력으로 함수를 다시 호출하면 같은 결과를 얻기 쉽습니다.

print(calculate_discount(34_000))  # 3400
print(calculate_discount(34_000))  # 3400

이러한 함수를 순수 함수라고 부를 때는 단지 두 번 같은 값이 나왔다는 사실만 보는 것이 아닙니다. 함수가 외부 상태를 읽거나 바꾸지 않고, 같은 입력에 같은 결과를 낸다는 조건을 함께 봅니다.

map()을 써야 함수형일까?#

아닙니다. 같은 계산을 다음처럼 작성할 수도 있습니다.

def calculate_subtotal(items):
    return sum(
        item["price"] * item["quantity"]
        for item in items
    )

map()이나 람다식을 사용했다는 사실 자체가 함수형 설계의 기준은 아닙니다. 데이터를 어떻게 전달하고, 함수가 바깥 상태를 변경하는지가 더 중요합니다.

불변성을 지향한다는 것은 무슨 뜻일까?#

예를 들어 주문 금액에 할인 금액을 기록하기 위해 원본 딕셔너리를 직접 바꾼다고 해보겠습니다.

order_data = {"subtotal": 34_000}

order_data["discount"] = 3_400

이 코드는 원래 딕셔너리의 상태를 변경합니다. 다른 코드도 같은 딕셔너리를 참조하고 있다면 변경의 영향을 받을 수 있습니다.

새 결과를 만드는 방식은 다음과 같습니다.

order_data = {"subtotal": 34_000}

calculated_order = {
    **order_data,
    "discount": 3_400,
    "total": 30_600,
}

print(order_data)
# {'subtotal': 34000}

print(calculated_order)
# {'subtotal': 34000, 'discount': 3400, 'total': 30600}

원본 order_data는 그대로 두고 계산 결과를 새 딕셔너리에 담았습니다.

다만 새 객체를 계속 만들면 비용이 들 수 있고, 실제 프로그램에서는 파일 저장이나 화면 출력처럼 외부에 영향을 주는 작업도 해야 합니다. 함수형 접근은 그런 작업이 존재하지 않는다고 가정하는 것이 아니라, 계산과 외부 작업을 가능한 한 구분해 다루는 데 도움이 됩니다.

같은 결과인데 설계가 왜 다른가?#

세 구현 모두 30,600원을 계산합니다. 차이는 결과가 아니라 코드를 읽고 변경할 때 어디를 먼저 보게 되는가에 있습니다.

변경 상황 절차 지향에서 먼저 볼 곳 객체 지향에서 먼저 볼 곳 함수형에서 먼저 볼 곳
할인 기준 금액 변경 할인 조건을 실행하는 단계 주문 또는 할인 정책을 맡은 객체의 메서드 할인 계산 함수
상품 금액 계산 방식 변경 합계를 누적하는 반복문 또는 함수 OrderItem.line_total() line_total()
주문 여러 건 관리 주문별 데이터와 처리 함수의 연결 주문 객체들의 상태와 책임 각 주문 데이터를 함수에 전달하는 흐름
계산 결과 오류 추적 어느 단계에서 값이 바뀌었는가 어느 객체의 상태나 메서드가 잘못되었는가 어느 함수의 입력과 출력이 달라졌는가

같은 코드라도 설계 규모에 따라 변경 위치는 달라질 수 있습니다. 이 표는 패러다임별로 문제를 추적하는 관점을 비교하기 위한 것입니다.

속도 차이로 패러다임을 고르면 될까?#

“절차 지향은 빠르고 객체 지향이나 함수형은 느리다”라고 일반화하기는 어렵습니다. 실제 실행 속도는 사용 언어, 자료구조, 알고리즘, 입력 크기, 구현 방식에 영향을 받습니다.

예를 들어 전체 주문을 매번 여러 번 계산하도록 작성한 객체 지향 코드는 불필요한 작업을 할 수 있습니다. 앞의 Order.total()은 subtotal()을 계산하고, discount() 안에서 subtotal()을 다시 계산합니다. 작은 예제에서는 읽기 쉽지만 상품이 매우 많고 계산이 무겁다면 결과를 한 번 계산해 재사용하는 설계를 검토해야 합니다.

어떤 패러다임을 선택하든 실제 병목은 측정해서 확인해야 합니다. 이름만으로 성능을 단정할 수 없습니다.

세 방식을 함께 사용할 수도 있다#

실제 프로그램에서는 패러다임을 하나만 골라 모든 코드를 그 방식으로 작성할 필요가 없습니다.

예를 들어 쇼핑몰에서는 다음처럼 역할을 나눌 수 있습니다.

  • 주문 전체의 상태와 주문 처리 책임은 객체로 관리합니다.
  • 할인 금액 계산은 입력과 결과가 분명한 함수로 분리합니다.
  • 결제 요청, 성공 확인, 영수증 발송은 절차적인 실행 순서로 구성합니다.

개념을 짧은 코드로 나타내면 다음과 같습니다.

def calculate_discount(subtotal):
    return subtotal * 10 // 100 if subtotal >= 30_000 else 0


class Order:
    def __init__(self, items):
        self.items = list(items)

    def total(self):
        subtotal = sum(
            item["price"] * item["quantity"]
            for item in self.items
        )
        return subtotal - calculate_discount(subtotal)


def checkout(order, payment_gateway):
    amount = order.total()
    payment_success = payment_gateway.process_payment(amount)

    if not payment_success:
        return "결제 실패"

    return "결제 완료"

Order는 주문을 표현하는 객체입니다. calculate_discount()는 금액을 받아 할인액을 반환하는 함수입니다. checkout()은 금액 계산 뒤 결제를 요청하고 결과를 판단하는 순서를 표현합니다.

이 코드에서 payment_gateway는 process_payment()를 제공하는 결제 객체라고 가정합니다. 실제 서비스라면 결제 중복 방지, 저장 시점, 실패 복구 등 추가 규칙이 필요합니다. 여기서는 서로 다른 설계 관점이 하나의 기능 안에서 공존할 수 있다는 점을 보여줍니다.

각 방식은 언제 선택하면 좋을까?#

절차 지향이 적합한 경우#

처리 순서가 분명하고, 데이터가 여러 곳에서 복잡하게 변경되지 않는 작업에 잘 맞습니다.

예를 들어 파일을 읽고, 형식을 검사하고, 계산한 다음 결과를 저장하는 짧은 프로그램은 단계별 함수만으로 충분할 수 있습니다.

rows = read_rows()
valid_rows = validate_rows(rows)
result = calculate_result(valid_rows)
save_result(result)

여기서 read_rows() 등은 단계별 동작을 나타내기 위한 예시 함수입니다. 흐름이 명확하다면 클래스 구조를 추가할 이유가 없을 수 있습니다.

객체 지향이 적합한 경우#

여러 대상이 각자의 상태를 유지하고, 그 상태를 다루는 규칙과 동작이 있는 경우에 유용합니다.

주문, 장바구니, 게임 캐릭터, 예약처럼 같은 종류의 객체가 여러 개 존재하면서 각각 다른 값을 가진다면 객체로 책임을 정리할 수 있습니다.

함수형 접근이 적합한 경우#

입력 데이터에서 결과를 계산하는 과정이 중심이고, 계산 중 다른 상태를 바꾸지 않도록 관리하고 싶을 때 유용합니다.

가격 계산, 데이터 정제, 목록 필터링, 통계 집계처럼 입력과 출력을 비교하기 쉬운 작업이 예가 됩니다.

선택하기 어려울 때 던질 질문#

  1. 무엇이 변하는가? — 처리 단계인가, 객체의 상태인가, 데이터의 형태인가?
  2. 변경 규칙은 어디에 두어야 이해하기 쉬운가?
  3. 같은 데이터를 여러 코드가 수정하고 있지는 않은가?
  4. 기능을 추가할 때 어느 부분을 고쳐야 하는가?
  5. 현재 문제의 크기에 비해 구조가 지나치게 복잡하지 않은가?

패러다임 이름을 먼저 고르고 코드를 맞추기보다, 변경되는 곳과 책임의 경계를 먼저 살펴보는 편이 실용적입니다.

새로운 언어를 배울 때는 무엇을 살펴봐야 할까?#

문법을 외우기 전에 해당 언어가 다음 일을 어떻게 표현하는지 확인해 보세요.

데이터는 어떻게 묶는가?#

클래스, 구조체, 딕셔너리 같은 방법 가운데 무엇을 주로 사용하는지 살펴봅니다. 데이터를 변경할 수 있는지, 변경할 수 있다면 누가 변경하는지도 중요합니다.

동작은 어떻게 전달하는가?#

함수를 변수에 담거나 다른 함수에 전달할 수 있는지 확인합니다. 람다식이나 익명 함수가 있다면 목록 정렬, 이벤트 처리, 데이터 변환에 어떻게 쓰이는지 살펴보세요.

상태와 실패는 어떻게 관리하는가?#

객체 상태를 어떻게 보호하는지, 함수의 입력과 출력을 어떻게 표현하는지, 예외나 오류 결과를 어떻게 전달하는지 확인합니다.

대부분의 실용 언어는 한 가지 패러다임만 허용하지 않습니다. Python도 함수를 순서대로 호출하고, 클래스로 객체를 만들며, 순수 함수처럼 데이터를 변환할 수 있습니다. 언어 이름 하나를 패러다임 하나와 일대일로 연결하기보다, 그 언어에서 각 방식이 어떻게 쓰이는지 보는 편이 정확합니다.

직접 비교해 보기: 짝수 제곱의 합#

마지막으로 원문에서 다룬 간단한 문제를 세 관점에서 다시 확인해 보겠습니다. 문제는 1부터 10까지의 짝수를 제곱해 모두 더하는 것입니다. 정답은 4 + 16 + 36 + 64 + 100 = 220입니다.

절차 중심: 반복하며 합계를 갱신하기#

total = 0

for number in range(1, 11):
    if number % 2 == 0:
        total += number * number

print(total)  # 220

각 단계에서 total이 어떻게 바뀌는지 따라가면 됩니다.

객체 중심: 필요한 상태와 동작을 객체에 두기#

class EvenSquareCalculator:
    def __init__(self, numbers):
        self.numbers = list(numbers)

    def calculate(self):
        total = 0

        for number in self.numbers:
            if number % 2 == 0:
                total += number * number

        return total


calculator = EvenSquareCalculator(range(1, 11))
print(calculator.calculate())  # 220

같은 결과를 얻지만 이 계산 하나만을 위해 클래스를 만드는 것은 오히려 번거로울 수 있습니다. 객체로 표현할 대상과 유지해야 할 상태가 있는가를 판단해야 하는 이유입니다.

함수 중심: 입력을 결과로 변환하기#

def even_square_total(numbers):
    return sum(
        number * number
        for number in numbers
        if number % 2 == 0
    )


print(even_square_total(range(1, 11)))  # 220

입력 numbers를 받아 결과를 반환하며, 외부 합계 변수를 변경하지 않습니다.

세 코드의 답은 같습니다. 설계의 차이는 변화하는 값을 따라갈지, 책임을 가진 객체를 만들지, 입력과 출력의 변환을 표현할지에 있습니다.

정리#

절차 지향, 객체 지향, 함수형 프로그래밍은 서로 우열을 가리는 단계가 아닙니다.

  • 절차 지향은 작업을 어떤 순서로 실행하는지 드러냅니다.
  • 객체 지향은 어떤 대상이 상태와 동작을 맡을지 정리합니다.
  • 함수형은 입력 데이터를 어떤 함수로 결과에 변환할지 강조합니다.

작은 계산에는 함수 하나면 충분할 수 있습니다. 상태와 규칙을 가진 대상이 늘어나면 객체가 도움이 됩니다. 전체 기능에서는 순서대로 실행해야 할 단계도 반드시 존재합니다.

좋은 설계는 하나의 이름표를 코드 전체에 붙이는 일이 아닙니다. 문제의 어떤 부분에 상태가 있고, 어떤 부분이 계산이며, 작업이 어떤 순서로 진행되어야 하는지 구분한 다음 각 부분을 이해하기 쉬운 방식으로 표현하는 것입니다.

이 페이지의 목차