PH pullh
입문 블로그 / 객체지향을 처음부터 깊게 파지 않아도 되는 이유
코드 기초 5분 Beginner

객체지향을 처음부터 깊게 파지 않아도 되는 이유

클래스와 객체는 중요하지만, 초보자가 가장 먼저 붙잡아야 할 개념은 아닐 때가 많다. 순서를 다시 세워 본다.

OOP 타이밍 커버 이미지

객체지향은 프로그래밍에서 자주 등장하는 주제입니다. 그래서 초보자는 이것을 빨리 알아야 제대로 공부하는 것처럼 느끼기도 합니다.

하지만 입력, 조건, 반복, 함수 흐름이 아직 불안정한 상태에서 클래스 개념까지 한 번에 올리면 오히려 머릿속이 더 엉키기 쉽습니다. 이 글은 객체지향을 건너뛰라는 이야기가 아니라, 함수로 충분한 지점과 객체가 필요해지는 지점을 코드로 구분해 보자는 이야기입니다.

먼저 단단해져야 하는 것은 흐름입니다

클래스는 구조를 정리하는 도구이지, 기초 흐름을 대신 이해하게 해 주는 도구는 아닙니다. 조건문과 함수가 아직 낯선 상태라면 객체지향은 새로운 문법 묶음처럼만 보입니다. self가 왜 붙는지, 생성자가 언제 불리는지 같은 질문에 시간을 쓰는 동안, 정작 데이터가 어디서 어디로 흘러가는지는 그대로 흐릿합니다.

더 실질적인 문제도 있습니다. 클래스를 먼저 배우면 설계 판단을 내릴 재료가 없는 상태에서 구조를 짜게 됩니다. 어떤 값이 함께 다니고 어떤 규칙이 반복되는지는 코드를 몇 번 고쳐 본 뒤에야 보입니다. 그 경험 없이 만든 클래스는 대개 이름만 그럴듯하고, 안을 열어 보면 함수 몇 개를 억지로 묶어 둔 상자입니다.

함수와 딕셔너리로 충분한 크기

장바구니 합계를 계산하는 프로그램을 예로 들어 보겠습니다. 상품은 딕셔너리 하나, 장바구니는 그 딕셔너리들의 리스트, 계산은 함수입니다. 이 정도 크기에서는 이 구조가 가장 읽기 쉽습니다.

FUNCTIONS ONLY

cart = [
    {"name": "키보드", "price": 42000, "qty": 1},
    {"name": "마우스패드", "price": 9000, "qty": 2},
]

def subtotal(cart):
    return sum(item["price"] * item["qty"] for item in cart)

def shipping_fee(amount):
    return 0 if amount >= 50000 else 3000

def total(cart):
    amount = subtotal(cart)
    return amount + shipping_fee(amount)

print(subtotal(cart))  # 60000
print(total(cart))     # 60000

값이 들어가고 값이 나옵니다. 숨은 상태가 없어서 각 함수를 따로 시험해 볼 수 있습니다.

이 코드의 장점은 눈에 잘 안 띄지만 실제로는 큽니다. subtotal은 장바구니 하나만 넣으면 어디서든 같은 값을 돌려주고, 배송비 규칙이 바뀌면 고칠 곳이 shipping_fee 한 곳뿐입니다. 값이 어디에 숨어 있지 않으니, 결과가 이상할 때 확인할 것은 넣은 입력과 함수 세 개가 전부입니다. 초보 단계에서 이런 코드를 여러 개 써 보는 경험이 나중에 구조를 판단하는 재료가 됩니다.

여기에 클래스를 씌운다고 나아지는 것은 없습니다. Cart 클래스를 만들고 self.items에 리스트를 넣어도, 코드는 줄이 늘어난 만큼만 길어집니다. 이 단계에서 클래스를 쓰지 않는 것은 게으름이 아니라 정확한 선택입니다.

객체가 필요해지는 지점

요구사항이 늘어나면 그림이 달라집니다. 수량을 바꾸는 기능, 같은 상품을 다시 담으면 합치는 기능, 수량이 0이 되면 빼는 기능, 쿠폰을 하나만 적용하는 기능이 붙었다고 해 봅시다. 이제 cart라는 리스트를 여기저기서 직접 고치는 코드가 생기고, 고칠 때마다 “수량은 1 이상”, “쿠폰은 하나만” 같은 규칙을 매번 다시 확인해야 합니다. 한 군데에서만 확인을 빠뜨려도 규칙이 깨집니다.

이 시점에 드러나는 사실이 있습니다. 데이터와 그 데이터를 다루는 규칙이 늘 함께 다닌다는 것입니다. 클래스는 바로 이 둘을 한 상자에 넣고, 상자 밖에서는 정해진 방법으로만 손대게 만드는 도구입니다.

WHEN A CLASS EARNS ITS PLACE

class Cart:
    def __init__(self):
        self._items = {}      # name -> {price, qty}
        self._coupon = None

    def add(self, name, price, qty=1):
        if qty < 1:
            raise ValueError("수량은 1 이상이어야 합니다")
        row = self._items.setdefault(name, {"price": price, "qty": 0})
        row["qty"] += qty

    def remove(self, name, qty=1):
        row = self._items.get(name)
        if row is None:
            return
        row["qty"] -= qty
        if row["qty"] <= 0:
            del self._items[name]

    def apply_coupon(self, rate):
        if self._coupon is not None:
            raise ValueError("쿠폰은 하나만 적용됩니다")
        self._coupon = rate

    def total(self):
        amount = sum(r["price"] * r["qty"] for r in self._items.values())
        if self._coupon:
            amount = int(amount * (1 - self._coupon))
        return amount + (0 if amount >= 50000 else 3000)

규칙을 어기는 방법이 줄어듭니다. 수량이 0인 항목이나 쿠폰 두 장은 이 클래스를 통해서는 만들 수 없습니다.

바뀐 것을 하나씩 보면 이유가 보입니다. 항목이 리스트에서 딕셔너리로 바뀌면서 같은 상품이 두 줄로 들어가는 상황이 아예 없어졌고, 수량이 0 이하가 되면 remove 안에서 항목을 지우므로 “수량 0짜리 항목”이라는 이상한 상태가 남지 않습니다. _items 앞의 밑줄은 밖에서 직접 만지지 말라는 표시입니다. 파이썬에서 강제되지는 않지만, 손댈 통로를 add와 remove로 좁혀 두겠다는 약속입니다.

클래스가 해결한 문제는 두 가지입니다. 하나는 상태와 규칙의 동거입니다. 장바구니 내용과 그것을 바꾸는 방법이 같은 자리에 있으니, 규칙을 고칠 때 찾아갈 곳이 한 군데입니다. 다른 하나는 불변식 보장입니다. “수량은 1 이상”처럼 항상 참이어야 하는 조건을 진입로마다 확인하는 대신, 진입로 자체를 몇 개로 좁혀 둡니다. 함수 버전에서도 같은 규칙을 지킬 수는 있지만, 지키는 책임이 호출하는 쪽 전부에게 흩어집니다.

상속은 조금 더 미뤄도 됩니다

객체지향을 배우기 시작하면 상속이 곧바로 따라옵니다. 그런데 입문 단계에서 상속을 먼저 만나면 오해가 하나 생깁니다. 클래스를 잘 쓴다는 것이 곧 계층을 깊게 쌓는 일이라고 여기게 되는 것입니다. 동물에서 포유류로, 다시 개와 고양이로 내려가는 예제가 워낙 많다 보니, 실제 프로그램에서도 무언가를 물려받을 자리부터 찾게 됩니다.

실제 코드에서 상속은 생각만큼 자주 필요하지 않습니다. 부모 클래스가 바뀌면 자식 전부가 영향을 받고, 동작을 이해하려면 계층을 위아래로 오가며 읽어야 하기 때문입니다. 대부분의 경우 합성이 더 단순합니다. 할인 정책을 상속으로 나누는 대신 Cart가 할인 계산기를 하나 들고 있게 하면, 정책을 갈아 끼우기도 쉽고 각 정책을 따로 시험하기도 쉽습니다. “A는 B의 한 종류다”가 정말로 성립할 때만 상속을 쓰고, “A는 B를 가지고 있다”에 가깝다면 필드로 두는 편이 안전합니다.

알아두면 좋은 점

클래스를 도입할지 판단하는 기준은 크기가 아니라 “같이 움직이는 값이 있는가”입니다. 함수에 넘기는 인자 서너 개가 늘 같은 조합으로 붙어 다니고, 그 조합을 검사하는 코드가 여러 곳에 복사돼 있다면 그때가 묶을 때입니다.

객체지향을 미루면 곤란한 경우

이 조언이 통하지 않는 상황도 분명히 있습니다. 첫째, 첫 언어를 자바나 C#으로 골랐다면 이야기가 달라집니다. 이 언어들에서는 클래스가 문법의 기본 단위여서, 가장 짧은 프로그램조차 클래스 안에 들어갑니다. 이때 “객체지향은 나중에”라는 말은 현실적으로 지킬 수가 없습니다. 다만 이 경우에도 첫 주에 필요한 것은 상속이나 다형성이 아니라 클래스가 코드를 담는 그릇이라는 사실까지입니다. 나머지는 그대로 미뤄도 됩니다.

둘째, 팀이나 프레임워크가 클래스 기반이라 첫날부터 남의 클래스를 읽어야 하는 상황입니다. 안드로이드나 스프링, 게임 엔진처럼 화면 하나를 만들려면 정해진 클래스를 상속받아야 하는 환경이 여기 해당합니다. 이때는 설계 이론을 공부하기보다, 지금 손에 든 클래스에서 어떤 메서드가 언제 불리는지를 따라가는 편이 훨씬 빨리 도움이 됩니다. 쓰는 법과 만드는 법은 다른 능력이고, 순서상 쓰는 법이 먼저 와도 괜찮습니다.

반대 방향의 과잉도 함께 봐야 합니다. “모든 것을 클래스로”라는 습관은 할 일 없는 래퍼를 만들어 냅니다. 함수 하나를 감싸기만 한 클래스, 필드 하나에 게터와 세터만 달린 객체, 인스턴스를 만들 필요조차 없는데 이름만 클래스인 코드가 대표적입니다. 이런 코드는 줄 수만 늘리고 읽는 사람에게 “여기에 뭔가 의미가 있겠지”라는 헛된 기대를 심습니다. 상태가 없다면 그냥 함수로 두는 편이 정직합니다.

자주 하는 실수

함수 버전을 클래스로 옮기면서 동작을 동시에 바꾸는 경우가 많습니다. 그러면 문제가 생겼을 때 구조 때문인지 로직 때문인지 구분할 수 없습니다. 옮길 때는 결과가 똑같이 나오는지 먼저 확인하고, 기능 추가는 그다음 커밋에서 합니다.

지금 확인해 볼 것

  • 가장 최근에 쓴 프로그램에서 함수 인자 세 개 이상이 늘 같이 붙어 다니는 곳이 있는지 찾아봅니다.
  • 같은 검사 조건이 두 군데 이상 복사돼 있다면, 그 조건이 지켜야 하는 규칙을 한 문장으로 적어 봅니다.
  • 위 장바구니 함수 버전을 그대로 옮겨 적고, 수량 변경 기능을 함수만으로 추가해 봅니다.
  • 그 상태에서 불편해진 지점을 메모한 뒤, 클래스 버전으로 바꿔 무엇이 사라졌는지 비교합니다.
  • 내가 쓴 클래스 중 필드가 하나뿐이고 메서드가 게터·세터뿐인 것이 있으면 함수로 되돌립니다.

더 볼 만한 곳

클래스 문법 자체를 손에 붙이고 싶다면 파이썬 객체지향 가이드가 위 예제와 이어지고, 첫 언어가 자바라면 자바 객체지향 가이드부터 보는 편이 현실적입니다. 언어별로 클래스가 얼마나 앞에 나오는지는 언어 비교에서 확인할 수 있고, 함수를 언제 쪼갤지에 대한 감각은 함수를 언제 나눌 것인가와 함께 보면 정리가 빨라집니다.

객체지향이 중요하지 않아서가 아니라, 더 먼저 단단해져야 할 기초가 있기 때문에 순서를 조절하는 것입니다.

코드 기초

Next Read

웹사이트를 한 번 열 때 실제로 무슨 일이 일어날까

브라우저에 주소를 입력하는 단순한 행동 뒤에 어떤 흐름이 있는지 이해하면 웹 공부가 훨씬 덜 추상적으로 느껴진다.