클래스와 객체의 차이: 인스턴스, 속성, 메서드 이해하기

클래스와 객체란? 속성과 메서드로 프로그램을 설계하는 방법#

객체는 프로그램에서 하나의 대상을 표현하는 단위입니다. 대상이 가진 데이터는 속성, 그 대상과 관련된 동작은 메서드로 다룹니다. 클래스는 이러한 객체를 만들 때 사용할 속성과 메서드의 구조를 정의합니다.

예를 들어 학생 관리 프로그램에는 학생의 이름, 학년, 성적이 필요합니다. 성적 평균을 계산하거나 학생 정보를 출력하는 기능도 필요합니다. 학생 한 명에 해당하는 데이터와 기능을 함께 관리하면, 누구의 성적을 계산하고 누구의 정보를 출력하는지 코드에 드러납니다.

이 글에서는 Python을 예제 언어로 사용합니다. 핵심인 클래스와 객체의 관계, 객체별 상태, 메서드를 통한 동작은 여러 객체 지향 언어에 공통으로 적용됩니다.

먼저 구분하는 네 가지 용어#

용어 의미 학생 프로그램의 예
클래스 객체를 만들기 위한 구조 정의 Student
객체 클래스 등을 바탕으로 만들어 사용 중인 대상 student_a가 가리키는 학생
속성 객체가 가진 데이터 name, grade, scores
메서드 객체에 속한 함수 average_score(), show_info()

클래스로 만든 객체를 그 클래스의 인스턴스라고도 합니다. “student_a는 Student 클래스의 인스턴스다”라는 말은 Student에서 학생 객체를 만들어 student_a로 참조한다는 뜻입니다.

왜 클래스와 객체가 필요할까?#

데이터와 함수를 따로 관리하면 생기는 문제#

학생 한 명의 정보를 변수와 함수로 관리할 수 있습니다.

student_name = "김철수"
student_grade = 2
student_scores = [80, 90, 100]

def calculate_average(scores):
    return sum(scores) / len(scores)

def print_student_info(name, grade, scores):
    print(f"이름: {name}")
    print(f"학년: {grade}")
    print(f"평균: {calculate_average(scores):.1f}")

print_student_info(student_name, student_grade, student_scores)

학생이 한 명이고 작업도 단순하다면 충분히 이해하기 좋은 코드입니다. 함수와 객체 중 하나가 언제나 더 좋은 것은 아닙니다.

문제는 관리할 대상이 늘어날 때 나타납니다.

student_a_name = "김철수"
student_a_scores = [80, 90, 100]

student_b_name = "이영희"
student_b_scores = [95, 85, 90]

print_student_info("김철수", 2, student_b_scores)

마지막 줄은 김철수의 이름에 이영희의 성적을 함께 넘겼습니다. 문법 오류는 없으므로 코드가 실행되지만, 출력 결과는 잘못됩니다. 이름과 성적의 관계가 변수 이름과 함수 호출에 흩어져 있기 때문입니다.

학생의 수가 늘고 출결, 수강 과목, 학년 변경 같은 기능이 추가되면 어떤 데이터가 어느 학생에게 속하는지 관리하기가 더 어려워집니다. 이때 학생별 데이터와 관련 동작을 하나의 객체로 묶어 표현할 수 있습니다.

객체는 관련된 상태와 동작을 연결한다#

학생 객체를 만든다면 다음처럼 생각할 수 있습니다.

  • 상태: 이름, 학년, 성적 목록
  • 동작: 성적 추가, 평균 계산, 정보 출력

자동차라면 현재 속도와 연료량이 상태이고, 가속하거나 주유하는 기능이 동작입니다. 게임 캐릭터라면 이름과 체력이 상태이고, 공격받아 체력이 줄어드는 기능이 동작입니다.

객체 지향 프로그래밍은 프로그램의 대상을 이렇게 나누고, 각 대상이 자신의 데이터와 관련 기능을 맡도록 설계하는 접근 방식입니다. 현실의 사물을 그대로 복제한다는 뜻은 아닙니다. 주문, 장바구니, 예약처럼 눈에 보이지 않는 업무 개념도 객체로 표현할 수 있습니다.

클래스와 객체의 관계#

클래스를 설명할 때 흔히 “클래스는 붕어빵 틀, 객체는 붕어빵”이라고 비유합니다. 하나의 정의로 여러 객체를 만들 수 있다는 점에서는 유용합니다. 다만 각 객체는 같은 틀에서 나왔더라도 서로 다른 값을 갖고, 독립적으로 변할 수 있다는 점까지 이해해야 합니다.

게임의 Monster 클래스로 살펴보겠습니다.

class Monster:
    def __init__(self, name, hp):
        self.name = name
        self.hp = hp

    def show_info(self):
        print(f"이름: {self.name}, 체력: {self.hp}")

Monster는 몬스터를 표현하는 클래스입니다. 이 클래스에는 몬스터 이름과 체력을 설정하는 코드, 정보를 출력하는 메서드가 들어 있습니다.

slime = Monster("슬라임", 50)
goblin = Monster("고블린", 80)

slime.show_info()
goblin.show_info()

실행 결과:

이름: 슬라임, 체력: 50
이름: 고블린, 체력: 80

Monster 클래스 정의는 하나지만, slime과 goblin은 서로 다른 객체를 참조합니다. 두 객체는 show_info()라는 같은 메서드를 사용하면서도 이름과 체력은 각자 따로 가집니다.

클래스와 인스턴스를 코드에서 구분하기#

slime = Monster("슬라임", 50)

이 한 줄을 나누어 보면 다음과 같습니다.

  1. Monster는 클래스 이름입니다.
  2. Monster("슬라임", 50)은 그 클래스로 객체를 생성합니다.
  3. 생성된 객체의 참조를 변수 slime에 할당합니다.
  4. "슬라임"과 50은 새 객체의 초기 상태를 정하는 데 사용됩니다.

따라서 slime이라는 변수 자체가 클래스인 것은 아닙니다. slime은 생성된 객체를 사용하기 위한 이름입니다.

속성은 무엇이고, 메서드는 무엇인가?#

속성: 객체가 가진 상태#

속성은 객체와 연결된 데이터입니다.

print(slime.name)  # 슬라임
print(slime.hp)    # 50

print(goblin.name)  # 고블린
print(goblin.hp)    # 80

점(.)을 사용해 객체의 속성에 접근합니다. slime.hp와 goblin.hp는 같은 이름의 속성이지만 서로 다른 객체에 속합니다.

goblin.hp = 50

print(slime.hp)   # 50
print(goblin.hp)  # 50

지금은 두 값이 우연히 같습니다. 고블린의 체력을 바꿨다고 슬라임의 체력이 함께 바뀐 것은 아닙니다. 이를 더 분명히 보려면 서로 다른 값으로 바꾸면 됩니다.

goblin.hp = 30

print(slime.hp)   # 50
print(goblin.hp)  # 30

이처럼 각 객체는 자신만의 상태를 가진다는 점이 중요합니다.

메서드: 객체와 관련된 동작#

클래스 안에 정의한 함수를 메서드라고 합니다.

class Monster:
    def __init__(self, name, hp):
        self.name = name
        self.hp = hp

    def show_info(self):
        print(f"이름: {self.name}, 체력: {self.hp}")

    def take_damage(self, damage):
        self.hp = self.hp - damage
        print(f"{self.name}이(가) {damage}의 피해를 입었습니다.")

take_damage()는 전달받은 피해량만큼 그 메서드를 호출한 몬스터의 체력을 줄입니다.

slime = Monster("슬라임", 50)
goblin = Monster("고블린", 80)

goblin.take_damage(30)

slime.show_info()
goblin.show_info()

실행 결과:

고블린이(가) 30의 피해를 입었습니다.
이름: 슬라임, 체력: 50
이름: 고블린, 체력: 50

슬라임의 체력은 그대로입니다. goblin.take_damage(30)을 호출했으므로 메서드는 고블린의 상태를 변경합니다.

__init__과 self는 무슨 역할을 할까?#

처음 클래스를 작성할 때 가장 낯선 부분은 보통 __init__과 self입니다.

__init__: 새 객체의 초기 상태 설정하기#

class Monster:
    def __init__(self, name, hp):
        self.name = name
        self.hp = hp

객체를 생성할 때 Python은 새 객체에 대해 __init__ 메서드를 호출해 초기화합니다.

slime = Monster("슬라임", 50)

이 코드에서는 "슬라임"이 name으로, 50이 hp로 전달됩니다. 이후 self.name과 self.hp에 저장되어 슬라임 객체의 속성이 됩니다.

__init__은 흔히 생성자라고 소개되지만, 정확히는 이미 생성 중인 객체를 초기화하는 메서드입니다. 입문 단계에서는 “객체를 만들 때 처음 필요한 값을 설정하는 곳”으로 이해하면 충분합니다.

왜 처음부터 값을 전달할까?#

초기화 메서드가 없다면 객체를 만든 뒤 필요한 속성을 하나씩 채워야 할 수 있습니다.

# 개념을 보여주기 위한 예시
slime = Monster()
slime.name = "슬라임"
slime.hp = 50

이 방식에서는 이름만 설정하고 체력을 빠뜨리는 등의 실수가 생기기 쉽습니다. Monster("슬라임", 50)처럼 필요한 값을 생성 시점에 전달하면 객체를 사용할 준비가 되었는지 판단하기 쉽습니다.

self: 지금 메서드를 호출한 객체#

self는 해당 메서드가 작업할 객체 자신을 가리킵니다.

def show_info(self):
    print(f"이름: {self.name}, 체력: {self.hp}")
slime.show_info()

위 호출에서 show_info() 내부의 self는 slime이 참조하는 객체입니다.

goblin.show_info()

이 호출에서 같은 코드의 self는 goblin이 참조하는 객체입니다. 메서드를 한 번 정의해도 여러 객체에서 사용할 수 있는 이유가 여기에 있습니다.

Python에서 메서드를 정의할 때 self를 첫 번째 매개변수로 적지만, 호출할 때는 직접 넣지 않습니다.

slime.show_info()  # 올바른 호출

slime.show_info(slime)처럼 작성하지 않습니다. 메서드를 객체를 통해 호출하면 Python이 호출 대상 객체를 첫 번째 인자로 전달합니다.

기억할 점: name은 메서드에 전달받은 값일 수 있고, self.name은 해당 객체에 저장된 속성입니다. 이름이 비슷해도 역할이 다릅니다.

예제로 보는 객체 설계: 학생의 성적 관리#

앞의 문제로 돌아가 학생의 데이터와 기능을 한곳에 묶어 보겠습니다.

class Student:
    def __init__(self, name, grade, scores=None):
        self.name = name
        self.grade = grade
        self.scores = list(scores) if scores is not None else []

    def add_score(self, score):
        if not 0 <= score <= 100:
            raise ValueError("점수는 0점부터 100점 사이여야 합니다.")

        self.scores.append(score)

    def average_score(self):
        if not self.scores:
            return None

        return sum(self.scores) / len(self.scores)

    def show_info(self):
        average = self.average_score()

        if average is None:
            average_text = "성적 없음"
        else:
            average_text = f"{average:.1f}점"

        print(
            f"이름: {self.name}, "
            f"학년: {self.grade}, "
            f"평균: {average_text}"
        )

사용 방법은 다음과 같습니다.

student_a = Student("김철수", 2, [80, 90, 100])
student_b = Student("이영희", 3)

student_b.add_score(95)
student_b.add_score(85)

student_a.show_info()
student_b.show_info()

실행 결과:

이름: 김철수, 학년: 2, 평균: 90.0점
이름: 이영희, 학년: 3, 평균: 90.0점

평균이 같더라도 두 학생의 성적 목록은 서로 다릅니다.

print(student_a.scores)  # [80, 90, 100]
print(student_b.scores)  # [95, 85]

이 설계에서 각 부분의 역할을 확인해 보겠습니다.

코드 역할
Student 학생 객체의 구조와 동작을 정의하는 클래스
student_a, student_b 각각의 학생 객체를 참조하는 변수
name, grade, scores 학생별로 보관하는 속성
add_score() 점수의 범위를 확인한 후 성적을 추가하는 메서드
average_score() 해당 학생의 성적 평균을 계산하는 메서드
show_info() 해당 학생의 정보를 출력하는 메서드

이제 평균을 계산할 때마다 성적 목록을 외부에서 따로 전달할 필요가 없습니다. student_a.average_score()는 student_a의 성적을, student_b.average_score()는 student_b의 성적을 사용합니다.

빈 성적 목록은 왜 따로 처리할까?#

다음 코드는 성적이 없는 학생에게 사용할 수 있습니다.

student = Student("박지성", 1)

print(student.average_score())  # None

성적이 없는데 곧바로 sum(self.scores) / len(self.scores)를 실행하면 0으로 나누는 오류가 발생합니다. 이 예제에서는 성적이 아직 없다는 뜻으로 None을 반환하고, show_info()에서 "성적 없음"이라고 표시했습니다.

실제 서비스에서는 None 대신 예외를 발생시키거나, 성적 등록 여부를 별도 상태로 관리할 수도 있습니다. 중요한 것은 데이터가 없는 경우를 어떻게 표현할지 정해 두는 것입니다.

같은 클래스로 만든 객체가 독립적으로 동작하는 이유#

객체마다 다른 상태를 가지는 모습을 주문 예제로도 살펴볼 수 있습니다.

class Order:
    def __init__(self, order_id):
        self.order_id = order_id
        self.items = []

    def add_item(self, name, price):
        self.items.append({"name": name, "price": price})

    def total_price(self):
        return sum(item["price"] for item in self.items)
order_a = Order("A-101")
order_b = Order("B-205")

order_a.add_item("노트", 3_000)
order_a.add_item("펜", 1_000)
order_b.add_item("키보드", 25_000)

print(order_a.total_price())  # 4000
print(order_b.total_price())  # 25000

두 주문은 같은 Order 클래스의 메서드를 사용하지만, 각자의 items 목록을 가집니다. self.items = []가 객체를 초기화할 때마다 실행되기 때문입니다.

반대로 여러 주문이 의도치 않게 같은 목록을 공유하면 한 주문에 추가한 상품이 다른 주문에도 나타날 수 있습니다. Python에서는 변경 가능한 데이터를 어디에 만들었는지 주의해야 합니다.

# 의도와 달리 모든 인스턴스가 같은 목록을 공유할 수 있는 예
class BadOrder:
    items = []  # 클래스에 속한 목록

    def add_item(self, item):
        self.items.append(item)

주문마다 독립된 상품 목록이 필요하다면 앞의 Order처럼 __init__에서 self.items = []로 만들어야 합니다. 이는 클래스와 객체의 차이가 실제 버그로 이어지는 대표적인 사례입니다.

객체를 사용하면 언제 더 편리할까?#

객체는 다음과 같은 상황에서 특히 유용합니다.

같은 종류의 대상을 여러 개 관리할 때#

학생 100명, 게임 몬스터 100마리, 주문 100건처럼 구조는 비슷하지만 상태는 다른 대상을 관리할 수 있습니다. 클래스를 한 번 정의하고 필요한 객체를 생성하면 됩니다.

데이터에 대한 규칙을 한곳에 모을 때#

학생의 점수가 0점에서 100점 사이라면 add_score()에서 확인할 수 있습니다. 주문의 합계를 구하는 규칙이 바뀌면 total_price()를 중심으로 수정할 수 있습니다.

다만 메서드로 접근하도록 만들었다고 해서 모든 데이터가 자동으로 보호되는 것은 아닙니다. 예제의 student.scores는 외부에서 직접 수정할 수 있습니다. 중요한 상태를 어떻게 보호할지는 캡슐화를 설계할 때 더 다루게 됩니다.

코드에서 대상과 책임을 드러내고 싶을 때#

student.add_score(95)
order.add_item("노트", 3_000)
monster.take_damage(10)

누가 어떤 동작을 수행하는지 호출 형태에 드러납니다. 객체는 프로그램을 여러 단위로 나누는 방법이기도 합니다.

그렇다고 모든 값을 클래스로 만들 필요는 없습니다. 입력받은 숫자 두 개를 더하는 짧은 계산이라면 함수 하나가 더 단순합니다.

def add(a, b):
    return a + b

관련된 상태가 있고, 그 상태를 다루는 동작을 함께 관리할 이유가 있을 때 객체 사용을 검토하면 됩니다.

객체를 처음 설계할 때 자주 하는 실수#

모든 속성을 외부에서 직접 수정하기#

student = Student("김철수", 2)
student.scores.append(500)

add_score()는 점수 범위를 검사하지만, 외부에서 scores 목록에 직접 추가하면 그 검사를 건너뛸 수 있습니다. 따라서 앞의 Student는 객체와 메서드의 관계를 설명하는 예제이며, 중요한 점수 규칙을 완전히 강제하는 설계는 아닙니다.

외부에서 목록을 직접 변경하지 못하게 하려면 내부 속성과 읽기용 반환 방식을 별도로 설계할 수 있습니다. 처음에는 **“규칙이 필요한 변경을 어느 메서드에서 처리할 것인가?”**를 생각하는 습관이 중요합니다.

객체마다 달라야 할 데이터를 클래스에 두기#

class BadStudent:
    scores = []

이 목록은 개별 학생을 초기화할 때 생성한 속성이 아닙니다. 학생마다 다른 성적 목록을 원한다면 다음처럼 작성합니다.

class Student:
    def __init__(self, name):
        self.name = name
        self.scores = []

이름만 객체로 만들고 책임은 정하지 않기#

클래스에 속성이 많고 서로 관계없는 메서드가 계속 추가된다면 설계를 다시 살펴볼 필요가 있습니다. 예를 들어 Student 클래스에 결제 처리, 이메일 발송, 서버 로그 정리까지 모두 넣으면 “학생” 객체가 맡아야 할 역할이 불분명해집니다.

클래스를 만들기 전에 다음 질문에 답해 보세요.

  1. 이 객체는 무엇을 표현하는가?
  2. 이 객체가 직접 보관해야 하는 상태는 무엇인가?
  3. 그 상태를 이용하거나 변경하는 동작은 무엇인가?
  4. 이 동작이 정말 이 객체의 책임인가?

직접 확인해 보기#

다음 코드를 실행하기 전에 출력 결과를 예상해 보세요.

class Player:
    def __init__(self, name, hp):
        self.name = name
        self.hp = hp

    def take_damage(self, amount):
        self.hp -= amount

player_a = Player("전사", 100)
player_b = Player("마법사", 70)

player_a.take_damage(20)
player_b.take_damage(10)
player_a.take_damage(15)

print(player_a.name, player_a.hp)
print(player_b.name, player_b.hp)

실행 결과:

전사 65
마법사 60

전사는 두 번 피해를 받아 100 → 80 → 65가 됩니다. 마법사는 한 번 피해를 받아 70 → 60이 됩니다. 같은 take_damage() 메서드를 호출했지만, 메서드가 변경한 대상은 호출마다 달랐습니다.

다음 단계로는 take_damage()에 0보다 작거나 같은 피해량이 들어오면 어떻게 처리할지 정해 보세요. 또 피해량이 현재 체력보다 클 때 체력을 음수로 둘지, 0에서 멈추게 할지 결정해 보세요. 객체가 지켜야 할 규칙을 정하는 일이 클래스 설계의 시작입니다.

정리#

클래스는 객체의 구조와 동작을 정의하고, 객체는 프로그램에서 실제로 생성해 사용하는 대상입니다. 같은 클래스로 여러 객체를 만들 수 있으며, 각 객체는 자신만의 속성 값을 가질 수 있습니다.

속성은 객체의 상태를 표현하고, 메서드는 그 상태와 관련된 동작을 담당합니다. Python에서는 __init__으로 객체의 초기 상태를 설정하고, 메서드 안의 self로 호출 대상 객체에 접근합니다.

객체 지향 설계의 출발점은 모든 코드를 클래스로 바꾸는 것이 아닙니다. 함께 관리해야 하는 데이터와 동작을 찾고, 그 책임을 알맞은 객체에 맡기는 것입니다.