![[OOP 01] 객체지향이란 무엇이고 왜 쓰나](https://img.thenullpage.com/posts/6620/6620_1_84a163.webp)
01.1 흩어진 데이터와 함수
절차형 코드는 데이터와 그 데이터를 다루는 함수가 서로 떨어져 있다. 프로그램이 작을 때는 이게 문제가 되지 않는다. 그러나 코드가 커지면 어떤 함수가 어떤 데이터를 건드리는지 눈으로 쫓기 어려워진다. 변수 하나를 여러 함수가 제각기 수정하기 시작하면, 값이 언제 어떻게 바뀌었는지 추적하는 데 시간을 다 쓴다.
객체지향(object oriented, 서로 관련된 데이터와 기능을 객체 단위로 묶어 짜는 프로그래밍 방식)은 이 문제를 정면으로 다룬다. 핵심은 간단하다. 함께 다뤄야 할 데이터와 그 데이터를 다루는 규칙을 하나의 객체(object, 데이터와 동작을 함께 지닌 덩어리)로 묶는 것이다. 그러면 데이터를 다루는 규칙이 흩어지지 않고 한곳에 모인다. 같은 코드를 절차형으로 짠 것과 객체로 짠 것을 나란히 놓고 무엇이 달라지는지 보자.
절차형과 객체지향은 사고방식이 다르다. 절차형은 데이터를 함수에 넘겨 처리한다고 본다. 계좌 딕셔너리를 입금 함수에 넣는 식이다. 객체지향은 객체에게 일을 시킨다고 본다. 계좌 객체에게 입금하라고 명령하는 식이다. 데이터가 함수를 따라다니는 대신, 데이터와 그 데이터를 다루는 동작이 한 몸이 되어 스스로 일한다. 이 관점의 전환이 코드 구조를 바꾼다.
01.2 딕셔너리와 함수가 흩어진 계좌
은행 계좌를 딕셔너리로 표현하고, 입금과 출금은 각각 별도 함수로 만든다. 데이터(계좌 딕셔너리)와 기능(입금, 출금 함수)이 완전히 분리된 전형적인 절차형 구조다.
def make_account(owner, balance):
return {"owner": owner, "balance": balance}
def deposit(acc, amount):
acc["balance"] += amount
def withdraw(acc, amount):
if amount > acc["balance"]:
print("잔액 부족")
return
acc["balance"] -= amount
a = make_account("김철수", 1000)
deposit(a, 500)
withdraw(a, 2000)
withdraw(a, 300)
print(a["owner"], a["balance"])
실행하면 다음이 출력된다.
잔액 부족
김철수 1200
동작은 한다. 문제는 구조다. 함수를 부를 때마다 계좌 딕셔너리를 첫 인자로 계속 넘겨줘야 하고, 이 계좌를 다루는 함수가 deposit과 withdraw 말고 또 어디에 있는지는 코드 전체를 뒤져봐야 안다. 계좌가 열 개, 함수가 스무 개로 늘면 관리가 급격히 어려워진다.
01.3 객체로 묶은 계좌
똑같은 계좌를 클래스(class, 객체를 찍어내는 설계도)로 묶는다. 데이터인 owner와 balance, 기능인 deposit과 withdraw가 Account라는 한 덩어리 안에 함께 들어간다.
class Account:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
print("잔액 부족")
return
self.balance -= amount
acc = Account("김철수", 1000)
acc.deposit(500)
acc.withdraw(2000)
acc.withdraw(300)
print(acc.owner, acc.balance)
출력은 앞의 절차형 코드와 똑같다.
잔액 부족
김철수 1200
결과는 같지만 사용하는 모습이 달라졌다. acc.deposit(500)은 계좌에게 입금하라고 시키는 형태다. 계좌 딕셔너리를 인자로 넘기지 않는다. 데이터와 그것을 다루는 규칙이 acc라는 한 덩어리 안에 붙어 다니기 때문이다. 계좌를 다루는 모든 규칙이 Account 안에 모여 있으니, balance를 함부로 바꾸는 코드가 여기저기 흩어질 일이 없다.
class 안에 쓴 __init__은 앞으로 계속 볼 생성자다. Account("김철수", 1000)이라고 쓰면 파이썬이 이 __init__을 자동으로 불러 owner와 balance를 세팅한 새 계좌를 만든다. deposit과 withdraw는 그 계좌가 할 수 있는 동작이다. 절차형 버전에서 계좌 딕셔너리를 첫 인자로 넘기던 자리가, 객체 버전에서는 self로 바뀌었다는 점만 봐도 데이터와 함수가 하나로 합쳐진 것을 알 수 있다.
01.4 전역 변수와 객체 안의 상태
객체가 상태를 자기 안에 가둔다는 말이 무슨 뜻인지, 전역 변수와 비교하면 분명해진다. 게임 점수를 전역 변수로 관리하면 어느 코드에서든 그 점수를 바꿀 수 있어 추적이 어렵다. 같은 점수를 객체 안에 넣으면 그 객체를 통해서만 다루게 된다.
# before: 전역 상태
score = 0
def add_score(n):
global score
score += n
add_score(10); add_score(5)
print("전역 score:", score)
# after: 객체 안에 상태
class Game:
def __init__(self):
self.score = 0
def add(self, n):
self.score += n
g = Game(); g.add(10); g.add(5)
print("객체 score:", g.score)
둘 다 15를 만든다.
전역 score: 15
객체 score: 15
코드에 global 키워드가 등장하면 상태가 프로그램 전역에 퍼져 있다는 신호다. 결정적 차이는 확장할 때 드러난다. 게임을 두 판 동시에 돌리고 싶다고 하자. 전역 변수 방식은 score가 하나뿐이라 두 판의 점수가 뒤섞인다. 객체 방식은 Game()을 두 번 부르면 각자 자기 score를 가진 독립된 게임 두 개가 생긴다.
전역 변수는 누가 언제 바꿨는지 추적이 어렵다는 문제도 있다. 프로그램 어느 구석에서든 score에 손댈 수 있으니, 값이 이상해졌을 때 범인을 찾으려면 코드 전체를 뒤져야 한다. 객체는 상태를 자기 안에 가둔다. g.score를 바꾸는 길은 g의 메서드를 거치는 것뿐이라, 값이 드나드는 통로가 좁고 분명하다. 상태를 자기 안에 가둔다는 이 성질이 안전한 코드의 출발점이다.
01.5 무엇이 좋아졌나
객체지향은 마법이 아니다. 흩어진 데이터와 함수를 관련된 것끼리 묶어 한 덩어리로 만드는 것이 전부의 출발점이다. 이렇게 묶으면 데이터를 다루는 규칙이 한곳에 모여 찾기 쉽고, 같은 종류의 객체를 여러 개 만들기 쉬우며, 상태가 밖으로 새지 않는다.
계좌 예제에서 balance를 다루는 규칙은 Account 안에만 있었고, 게임 예제에서 score는 각 Game 안에 갇혀 있었다. 프로그램이 커질수록 이 차이가 유지보수 비용을 가른다. 코드가 지금 절차형으로 흩어져 있다면, 함께 다뤄야 할 데이터와 그 데이터를 만지는 함수부터 찾아 하나의 클래스로 묶어 보는 것이 객체지향의 첫걸음이다.