Skip to main content
Summary

객체 지향은 값을 묶는 방식에서 시작한다

지금까지 우리는 변수, 조건문, 반복문, 메서드, 배열과 컬렉션을 배웠다. 이 문법만으로도 작은 프로그램은 만들 수 있다. 하지만 프로그램이 커지면 문제가 생긴다. 예를 들어 은행 계좌를 다룬다고 생각해 보자.

string owner = "민수";
decimal balance = 10000m;

balance += 5000m;
balance -= 3000m;

Console.WriteLine($"{owner}의 잔액: {balance:N0}원");

실행 결과:

민수의 잔액: 12,000원

간단한 예제에서는 괜찮다. 그러나 계좌가 10개, 100개가 되면 owner1, balance1, owner2, balance2처럼 변수가 흩어진다. 입금과 출금 규칙도 여러 곳에 반복된다. 객체 지향 프로그래밍은 이런 문제를 줄이기 위해 관련 있는 데이터와 동작을 하나로 묶는 방식이다.

C#에서 그 묶음의 설계도가 클래스이고, 설계도로 실제 만들어진 값이 객체다.

객체 지향 프로그래밍의 출발점AI 생성 이미지

클래스와 객체

클래스는 "이런 데이터와 이런 기능을 가진 것이 있다"라고 정의하는 코드다. 객체는 그 클래스를 바탕으로 메모리에 만들어진 실제 대상이다.

BankAccount account = new BankAccount();

account.Owner = "민수";
account.Balance = 10000m;

account.Deposit(5000m);
account.Withdraw(3000m);

Console.WriteLine($"{account.Owner}의 잔액: {account.Balance:N0}원");

class BankAccount
{
    public string Owner = "";
    public decimal Balance;

    public void Deposit(decimal amount)
    {
        Balance += amount;
    }

    public void Withdraw(decimal amount)
    {
        Balance -= amount;
    }
}

실행 결과:

민수의 잔액: 12,000원

여기서 BankAccount는 클래스다. new BankAccount()는 BankAccount 객체를 만든다. account 변수는 그 객체를 가리킨다.

클래스 안의 Owner, Balance는 필드다. 필드는 객체가 가지고 있는 데이터를 저장한다. Deposit, Withdraw는 메서드다. 메서드는 객체가 할 수 있는 동작을 표현한다.

정리하면 다음과 같다.

용어의미예
클래스객체를 만들기 위한 설계도BankAccount
객체클래스로 만든 실제 값account
필드객체가 가진 데이터Owner, Balance
메서드객체가 수행하는 동작Deposit, Withdraw

이 예제는 이해하기 쉽지만 아직 문제가 있다. 외부에서 Balance를 마음대로 바꿀 수 있다.

account.Balance = -1000000m;

은행 계좌의 잔액이 외부 코드 한 줄로 음수가 되는 것은 위험하다. 객체 지향에서는 객체 내부의 값을 아무나 직접 바꾸지 못하게 하고, 정해진 메서드나 프로퍼티를 통해서만 다루게 만든다. 이것을 캡슐화라고 한다.

생성자로 올바른 초기 상태 만들기

객체는 만들어지는 순간부터 사용할 수 있어야 한다. 그런데 앞의 예제에서는 객체를 만든 뒤 Owner와 Balance를 따로 넣었다. 만약 이름을 넣지 않거나 잔액을 설정하지 않으면 불완전한 계좌가 된다.

이럴 때 생성자를 사용한다. 생성자는 객체를 만들 때 자동으로 실행되는 특별한 메서드다. 클래스 이름과 같은 이름을 가지며, 반환형을 쓰지 않는다.

BankAccount account = new BankAccount("민수", 10000m);

account.Deposit(5000m);
bool success = account.Withdraw(3000m);

Console.WriteLine($"출금 성공: {success}");
Console.WriteLine($"{account.Owner}의 잔액: {account.Balance:N0}원");

class BankAccount
{
    private decimal balance;

    public string Owner { get; }

    public decimal Balance
    {
        get { return balance; }
    }

    public BankAccount(string owner, decimal initialBalance)
    {
        Owner = owner;
        balance = initialBalance;
    }

    public void Deposit(decimal amount)
    {
        if (amount <= 0)
        {
            return;
        }

        balance += amount;
    }

    public bool Withdraw(decimal amount)
    {
        if (amount <= 0)
        {
            return false;
        }

        if (balance < amount)
        {
            return false;
        }

        balance -= amount;
        return true;
    }
}

실행 결과:

출금 성공: True
민수의 잔액: 12,000원

이제 계좌를 만들려면 반드시 소유자 이름과 초기 잔액을 전달해야 한다.

BankAccount account = new BankAccount("민수", 10000m);

생성자의 장점은 객체의 시작 상태를 한곳에서 정할 수 있다는 점이다. 실제 프로그램에서는 생성자 안에서 잘못된 값도 검사한다. 예를 들어 초기 잔액이 음수라면 객체를 만들지 못하게 하거나, 기본값으로 바꾸는 식이다. 예외 처리는 뒤에서 자세히 배우므로 여기서는 "생성자는 객체가 처음부터 말이 되는 상태가 되도록 돕는다"라고 이해하면 충분하다.

접근 제한자와 캡슐화

앞 예제에서 balance 앞에 private이 붙어 있다.

private decimal balance;

private은 이 필드를 클래스 내부에서만 사용할 수 있다는 뜻이다. 그래서 외부에서는 다음 코드가 불가능하다.

// account.balance = -1000m; // 오류

대신 외부 코드는 Deposit, Withdraw, Balance를 통해 계좌를 다룬다. 이렇게 내부 데이터를 숨기고, 안전한 통로만 공개하는 것이 캡슐화다.

C#에서 자주 보는 접근 제한자는 다음과 같다.

접근 제한자의미
public어디서든 접근할 수 있다. 외부에 공개할 기능에 사용한다.
private같은 클래스 안에서만 접근할 수 있다. 내부 구현을 숨길 때 사용한다.
protected같은 클래스와 이를 상속한 클래스에서 접근할 수 있다. 상속은 다음 장에서 다룬다.
internal같은 프로젝트 안에서 접근할 수 있다. C#에서는 같은 어셈블리라고도 말한다.

처음에는 단순하게 생각해도 된다. 외부에서 꼭 써야 하는 것은 public, 내부에서만 관리할 값은 private으로 둔다. 특히 필드는 가능한 한 private으로 두는 습관이 좋다. 필드를 공개하면 어떤 코드가 값을 바꾸는지 추적하기 어려워진다.

프로퍼티로 안전하게 접근하기

필드를 모두 숨기면 외부에서 값을 읽거나 바꿀 방법이 필요하다. C#에서는 이 역할을 프로퍼티가 맡는다. 프로퍼티는 겉으로는 필드처럼 보이지만, 내부에서는 값을 읽는 get과 값을 쓰는 set을 제어할 수 있다.

class Member
{
    private int age;

    public string Name { get; set; } = "";

    public int Age
    {
        get { return age; }
        set
        {
            if (value >= 0)
            {
                age = value;
            }
        }
    }
}

사용 코드는 필드처럼 간단하다.

Member member = new Member();

member.Name = "지우";
member.Age = 20;
member.Age = -5;

Console.WriteLine($"{member.Name}: {member.Age}세");

실행 결과:

지우: 20세

Age에 -5를 넣으려고 했지만, set 안의 조건 때문에 값이 바뀌지 않았다. 여기서 value는 프로퍼티에 대입하려는 값을 의미한다.

프로퍼티는 두 가지 방식으로 많이 쓴다.

public string Name { get; set; } = "";

이것은 자동 구현 프로퍼티다. 특별한 검사가 필요 없을 때 간결하게 쓴다.

public decimal Balance
{
    get { return balance; }
}

이것은 읽기 전용에 가까운 프로퍼티다. 외부에서는 잔액을 읽을 수 있지만 직접 바꿀 수는 없다. 잔액 변경은 Deposit과 Withdraw를 통해서만 가능하다.

좋은 클래스는 "무엇을 공개하고 무엇을 숨길지"를 신중하게 결정한다. 모든 것을 공개하면 편해 보이지만, 프로그램이 커질수록 버그가 늘어난다.

인스턴스 멤버와 static 멤버

클래스 안에 있는 필드, 프로퍼티, 메서드를 통틀어 멤버라고 부른다. 멤버는 크게 두 종류로 나뉜다.

첫째, 인스턴스 멤버다. 객체마다 따로 존재하는 멤버다. Owner, balance, Deposit, Withdraw는 특정 계좌 객체를 기준으로 동작한다.

둘째, static 멤버다. 객체 하나에 속하지 않고 클래스 자체에 속하는 멤버다. Console.WriteLine도 Console 클래스의 static 메서드다. 그래서 new Console()을 만들지 않고 바로 사용할 수 있었다.

계좌가 몇 개 만들어졌는지 세는 값을 추가해 보자.

BankAccount first = new BankAccount("민수", 10000m);
BankAccount second = new BankAccount("지우", 20000m);

first.Deposit(5000m);

Console.WriteLine($"{first.Owner}: {first.Balance:N0}원");
Console.WriteLine($"{second.Owner}: {second.Balance:N0}원");
Console.WriteLine($"만든 계좌 수: {BankAccount.CreatedCount}");

class BankAccount
{
    private decimal balance;

    public string Owner { get; }
    public decimal Balance { get { return balance; } }

    public static int CreatedCount { get; private set; }

    public BankAccount(string owner, decimal initialBalance)
    {
        Owner = owner;
        balance = initialBalance;
        CreatedCount++;
    }

    public void Deposit(decimal amount)
    {
        if (amount > 0)
        {
            balance += amount;
        }
    }
}

실행 결과:

민수: 15,000원
지우: 20,000원
만든 계좌 수: 2

first와 second는 서로 다른 객체다. 그래서 Owner와 balance 값이 각각 따로 있다. first.Deposit(5000m)을 호출해도 second의 잔액은 바뀌지 않는다.

반면 CreatedCount는 static이다. 객체마다 하나씩 있는 값이 아니라 BankAccount 클래스에 하나만 있는 값이다. 그래서 first.CreatedCount처럼 객체를 통해 접근하기보다 BankAccount.CreatedCount처럼 클래스 이름으로 접근한다.

메모리 관점에서 간단히 말하면 이렇다.

  • 인스턴스 필드: 객체를 만들 때 객체마다 따로 생긴다.

  • static 필드: 클래스에 하나만 생기며 모든 객체가 함께 사용한다.

  • 인스턴스 메서드: 특정 객체의 필드에 접근할 수 있다.

  • static 메서드: 특정 객체 없이 호출되므로 인스턴스 필드에 바로 접근할 수 없다.

그래서 static은 "공유되는 값이나 기능"에만 사용해야 한다. 아무 값이나 static으로 만들면 프로그램 전체가 같은 값을 공유하게 되어 예상하지 못한 버그가 생길 수 있다.

흔한 실수

클래스와 객체를 같은 것으로 생각한다

BankAccount는 설계도이고 new BankAccount(...)로 만든 결과가 객체다. 클래스 자체에는 특정 계좌의 잔액이 없다. 잔액은 객체마다 다르다.

필드를 모두 public으로 만든다

처음에는 public decimal Balance;처럼 작성하는 것이 쉬워 보인다. 하지만 그러면 외부 코드가 잔액을 마음대로 바꿀 수 있다. 중요한 값은 private 필드에 두고, 메서드나 프로퍼티로 통제하자.

생성자에서 초기화를 빠뜨린다

객체가 만들어졌는데 꼭 필요한 값이 비어 있으면 이후 코드가 복잡해진다. 객체를 만들 때 반드시 필요한 값은 생성자 매개변수로 받는 것이 좋다.

static을 전역 변수처럼 남용한다

static은 편리하지만 모든 객체가 공유한다. 객체마다 달라야 하는 값은 static으로 만들면 안 된다. 계좌 잔액은 계좌마다 달라야 하므로 인스턴스 필드여야 한다.

매개변수와 필드 이름을 헷갈린다

다음 코드는 의도대로 동작하지 않는다.

private string owner;

public BankAccount(string owner)
{
    owner = owner;
}

양쪽 owner가 모두 매개변수를 가리키기 때문이다. 이런 경우에는 필드 이름을 다르게 하거나 this를 사용한다.

private string owner;

public BankAccount(string owner)
{
    this.owner = owner;
}

this는 현재 객체를 의미한다.

작은 연습 문제

  1. Book 클래스를 만들어 보자. 제목(Title), 저자(Author), 가격(Price)을 갖게 하고, 생성자로 세 값을 초기화하라. 가격은 외부에서 직접 바꾸지 못하게 만들어 보자.

  2. Book 클래스에 Discount(decimal amount) 메서드를 추가하라. 할인 금액이 0보다 크고 현재 가격보다 작을 때만 가격을 줄이게 만들어 보자.

  3. Book 객체가 몇 개 만들어졌는지 세는 static 프로퍼티 CreatedCount를 추가하라. 책을 2권 만든 뒤 개수를 출력해 보자.

이번 장의 핵심은 클래스가 데이터와 동작을 함께 묶는다는 점이다. 객체는 그 클래스로 만든 실제 대상이다. 생성자는 객체의 시작 상태를 정하고, 접근 제한자와 프로퍼티는 객체의 내부 데이터를 안전하게 지킨다. 다음 장에서는 이 클래스를 확장하는 방법인 상속, 그리고 같은 명령이 객체에 따라 다르게 동작하는 다형성을 배운다.

책 목록

C# 학습: 기초 문법부터 작은 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기