
지난 글에서는 캡슐화, 상속, 다형성, 추상화를 통해서
객체 지향 프로그래밍(OOP)이라는 거대한 산의 지도를 펼쳐보았습니다.
이제 직접 등반 장비를 챙겨 클래스(Class)로 그 산을 오를 시간입니다.
이번 글에서는 객체를 만들어 내는 설계도, 클래스가 무엇인지,
그리고 어떻게 작성하고 사용하는지 차근차근 알아보겠습니다.
클래스(Class)는 '설계도'이고 그 설계도로 만들어낸 '실체'가 바로
객체(Object) 또는 인스턴스(Instance)라고 생각하시면 됩니다.
클래스를 이해하는 가장 좋은 방법은 '붕어빵 틀'에 비유하는 것입니다.
클래스는 붕어빵의 '모양'과 '재료'(팥, 슈크림)를 정의하는 설계도입니다.
객체(붕어빵)는 붕어빵 틀을 사용해 실제로 만들어낸 결과물입니다.
각 붕어빵은 같은 틀에서 나왔지만, 어떤 건 팥이 더 많고
어떤 건 살짝 탔을 수도 있죠. 즉, 각 객체는 고유한 상태를 가집니다.
클래스는 class키워드로 정의하며 new키워드를 통해 객체를 생성합니다.
[코드]
using System;
// 1. Dog 라는 클래스(설계도)를 정의합니다.
// public은 접근 제한자 입니다.
// 접근 제한자를 생략하면 internal이 기본값으로 적용됩니다.
public class Dog
{
// 강아지의 필드(데이터)
// 여기서는 이해를 돕기 위해 public 필드를 사용하지만,
// 실제로는 보통 private 필드 + public 속성(Property) 조합을 사용합니다.
public string Name;
public int Age;
// 강아지의 행동(기능)
public void Bark()
{
Console.WriteLine($"{Name}: 멍멍!");
}
public void DisplayInfo()
{
Console.WriteLine($"이름: {Name}, 나이: {Age}살 입니다.");
}
}
// internal은 접근 제한자 입니다.
// 접근 제한자를 생략하면 internal이 기본값으로 적용됩니다.
internal class Program
{
static void Main()
{
// 2. new 키워드를 사용해 Dog 클래스로부터 실제 객체(인스턴스)를 생성합니다.
Dog happy = new Dog(); // 'happy'라는 이름의 강아지 객체 생성!
happy.Name = "해피";
happy.Age = 3;
Dog choco = new Dog(); // 'choco'라는 이름의 강아지 객체 생성!
choco.Name = "초코";
choco.Age = 5;
// 3. 각 객체의 기능을 호출합니다.
happy.DisplayInfo();
choco.DisplayInfo();
happy.Bark();
choco.Bark();
}
}
[실행 결과]
이름: 해피, 나이: 3살 입니다.
이름: 초코, 나이: 5살 입니다.
해피: 멍멍!
초코: 멍멍!
C# 컴파일러는 클래스와 메소드의 순서를 신경쓰지는 않지만,
나중에 코드를 읽는 사람 입장에서 생각해야 합니다.
우선 구조적 관점, 논리적 흐름 관점에 대한 이해가 필요합니다.
Program클래스의 Main메소드는 Dog라는 설계도을 알아야만
new Dog()와 같이 객체(제품)를 만들 수 있습니다.
따라서 Dog라는 설계도가 무엇인지 먼저 정의(선언)해주는 것이 자연스럽습니다.
마치 건물을 짓기 전에 설계도가 먼저 있어야 하는 것과 같습니다.
이것은 한 클래스 내에서 코드의 실행 흐름을 파악하는 가독성에 대한 이야기입니다.
클래스의 가장 중요한 핵심을 담은 메소드(예: Main, Run등)를 상단에 배치합니다.
코드를 읽는 사람은 이 핵심 메소드를 통해 큰 그림을 먼저 파악할 수 있습니다.
그리고 핵심 메소드에서 호출되는 세부적인 기능(보조 메소드)들은
아래쪽에 배치하여, 필요할 때만 찾아볼 수 있도록 합니다. (Top-Down 접근 방식)
마치 신문 기사에서 가장 중요한 요약(두괄식)을 먼저 보여주고
상세 내용을 나중에 설명하는 것과 같습니다.
Top-Down 접근 방식 대신, 접근 제한자(public → private 순서)나
기능별 그룹(생명주기, 이벤트 처리 등)로 묶기도 합니다.
어떤 스타일을 쓰든, 팀에서 일관되게 유지하는 것이 더 중요합니다.
이 두 가지 스타일은 서로 다른 대상을 위한 원칙이므로,
각자의 영역에서 충돌 없이 조화롭게 사용할 수 있습니다.
[코드]
using System;
// 1. 구조적 의존성 관점: '설계도'인 Dog 클래스를 먼저 정의
public class Dog
{
public string Name;
public void Bark()
{
Console.WriteLine($"{Name}: 멍멍!");
}
}
// '설계도'를 사용하는 Program 클래스를 나중에 정의
internal class Program
{
// 2. 논리적 흐름 관점: '핵심 로직'인 Main 메소드를 먼저 배치
static void Main()
{
Dog happy = CreateDog("해피");
Dog choco = CreateDog("초코");
happy.Bark();
choco.Bark();
}
// '보조 로직'인 헬퍼 메소드를 나중에 배치
static Dog CreateDog(string name)
{
return new Dog { Name = name };
}
}
[실행 결과]
해피: 멍멍!
초코: 멍멍!
필드는 클래스 내부에 선언된 변수이며 실제 데이터를 저장하는 공간입니다.
필드를 public으로 선언하는 것은 마치 금고 문을 활짝 열어놓은 것과 같습니다!
이는 데이터가 오염되거나 잘못된 값으로 변경될 위험이 아주 큽니다.
그래서 필드는 보통 private으로 선언하여 외부의 직접적인 접근을 막습니다.
[코드]
using System;
class BankAccount
{
// public 필드: 금고 문이 열려있음!
public int Balance;
}
class Program
{
static void Main()
{
BankAccount myAccount = new BankAccount();
// 누구나 금고에 직접 손을 댈 수 있습니다.
myAccount.Balance = 10000; // 입금
Console.WriteLine($"현재 잔액: {myAccount.Balance}");
myAccount.Balance = -5000; // 앗! 잔액이 마이너스가 되어버렸네요?!
Console.WriteLine($"현재 잔액: {myAccount.Balance}");
}
}
[실행 결과]
현재 잔액: 10000
현재 잔액: -5000
이름 그대로 '오직 읽기만 가능한 필드'를 의미합니다.
readonly키워드를 선언해서 사용하며, 한번 값이 할당되면
그 이후에는 절대로 값을 변경할 수 없습니다.
마치 사람의 '주민등록번호'나 '생일'과 같아요.
[코드]
using System;
class Student
{
// 1. 선언과 동시에 초기화하는 readonly 필드
public readonly string SchoolName = "C# 고등학교";
// 2. 생성자에서 초기화될 readonly 필드
public readonly int StudentId;
public readonly string Name;
// 생성자(Constructor): 클래스의 객체가 처음 만들어질 때 호출되는 특별한 메서드
public Student(int studentId, string name)
{
// 생성자 안에서는 readonly 필드의 값을 할당할 수 있습니다.
// readonly 필드가 값을 할당할 수 있는 '마지막 기회'입니다.
this.StudentId = studentId;
this.Name = name;
Console.WriteLine($"신입생 등록! 학번: {this.StudentId}, 이름: {this.Name}");
}
}
class Program
{
static void Main()
{
// 생성자를 통해 readonly 필드 값을 초기화하며 객체 생성
Student Kim = new Student(2024001, "김철수");
Student Lee = new Student(2024002, "이영희");
Console.WriteLine("\n--- 학생 정보 출력 ---");
Console.WriteLine($"{Kim.SchoolName}, 학번: {Kim.StudentId}, 이름: {Kim.Name}");
Console.WriteLine($"{Lee.SchoolName}, 학번: {Lee.StudentId}, 이름: {Lee.Name}");
// 생성된 객체의 readonly 필드 값을 외부에서 변경하려고 시도해도 오류 발생!
// Kim.StudentId = 1234; // 이 줄의 주석을 풀면 오류 발생!
}
}
[실행 결과]
신입생 등록! 학번: 2024001, 이름: 김철수
신입생 등록! 학번: 2024002, 이름: 이영희
--- 학생 정보 출력 ---
C# 고등학교, 학번: 2024001, 이름: 김철수
C# 고등학교, 학번: 2024002, 이름: 이영희
속성을 통해 private필드의 데이터를 안전하게 접근할 수 있어요.
데이터를 읽거나(get) 변경하는(set) 로직을 제어해서 캡슐화를 실현합니다.
set으로 값을 전달할 때는 value키워드를 사용합니다.
[코드]
using System;
class BankAccount
{
// 1. private 필드: 실제 돈은 금고 안에 안전하게 보관합니다.
// private 필드는 이름 앞에 밑줄(_)을 붙이는 관례가 있습니다.
private int _balance;
// 2. public 속성: 고객이 이용할 수 있는 안전한 창구 직원입니다.
public int Balance
{
// 'get' 접근자: 잔액을 조회(출금)할 때 호출됩니다.
get
{
// 여기서 추가적인 로직을 넣을 수도 있습니다. (예: 조회 기록 남기기)
return _balance;
}
// 'set' 접근자: 잔액을 변경(입금)할 때 호출됩니다.
set
{
// 'value'는 set에 전달된 값(입금하려는 돈)을 의미하는 키워드입니다.
if (value >= 0)
{
_balance = value;
}
else
{
Console.WriteLine("오류: 음수 금액은 입금할 수 없습니다.");
}
}
}
}
class Program
{
static void Main()
{
BankAccount myAccount = new BankAccount();
// 창구 직원(속성)을 통해 안전하게 입금합니다.
myAccount.Balance = 10000; // set 접근자가 호출됨
Console.WriteLine($"현재 잔액: {myAccount.Balance}"); // get 접근자가 호출됨
// 잘못된 요청은 창구 직원이 막아줍니다!
myAccount.Balance = -5000; // set 접근자가 호출되어 if문에서 걸러짐
Console.WriteLine($"잘못된 입금 시도 후 잔액: {myAccount.Balance}");
}
}
[실행 결과]
현재 잔액: 10000
오류: 음수 금액은 입금할 수 없습니다.
잘못된 입금 시도 후 잔액: 10000
private필드는 이름 앞에 밑줄(_)을 붙이는 관례가 있습니다.
이 관례는 오랫동안 C# 개발자들 사이에서 널리 사용되어 왔으며,
코드만 봐도 이 변수가 private필드라는 것을 즉시 알 수 있어요.
다만, 일부 개발자들 사이에선 밑줄(_)이 불필요한 기호라고 생각하고 있으며
this.를 사용했을 때 코드의 의도를 정확하게 전달한다고 봅니다.
스타일1: _clock = clock; 스타일2: this.clock = clock;
"입출금에 특별한 규칙이 없다면, 항상 저렇게 길게 써야 하나요?"
이런 경우 C#은 자동 구현 속성이라는 아주 편리한 문법을 제공합니다.
컴파일러가 우리 눈에 보이지 않는 숨겨진 private필드를 알아서 만들어주고
그 필드에 값을 넣고 빼는 get, set을 자동으로 연결해 줍니다.
public string Name { get; set; }
메서드는 객체가 수행할 수 있는 행동이나 기능을 정의합니다.
public class Warrior
{
public string Name { get; set; }
public int Level { get; set; }
// Attack 이라는 행동(메서드) 정의
public void Attack()
{
Console.WriteLine($"{Level}레벨 전사 {Name}이(가) 공격합니다!");
}
}
생성자(Constructor)는 객체의 생성을 담당하고
종료자(Finalizer)는 객체의 소멸을 담당합니다.
로봇이 만들어지고 폐기되는 과정을 상상하시면 쉽게 이해될 거예요!
생성자는 객체를 처음 만들 때 한 번만 호출되는 특별한 메서드입니다.
new키워드를 사용해 객체를 '생성'하고 '초기화'하는 역할을 담당합니다.
생성자는 메소드와 마찬가지로 오버로딩이 가능합니다.
따라서 같은 이름의 생성자를 여러 개 정의할 수 있습니다.
[코드]
using System;
class Robot
{
// 이름은 한번 정해지면 바꿀 수 없도록 get만 허용
public string Name { get; }
public string Id { get; }
// 매개변수가 없는 '기본 생성자'
public Robot()
{
Name = "Unknown";
Id = "N/A";
Console.WriteLine("기본 로봇이 조립되었습니다.");
}
// 매개변수가 있는 '생성자' (오버로딩)
public Robot(string name, string id)
{
this.Name = name; // 전달받은 이름으로 초기화
this.Id = id;
Console.WriteLine($"{name} 로봇(ID: {id})으로 재조립되었습니다.");
}
}
class Program
{
static void Main()
{
// 1. 기본 생성자 호출
Robot basicBot = new Robot();
Console.WriteLine($"이름: {basicBot.Name}, ID: {basicBot.Id}\n");
// 2. 매개변수가 있는 생성자 호출
Robot specialBot = new Robot("강아지", "SN-8790");
Console.WriteLine($"이름: {specialBot.Name}, ID: {specialBot.Id}");
}
}
[실행 결과]
기본 로봇이 조립되었습니다.
이름: Unknown, ID: N/A
강아지 로봇(ID: SN-8790)으로 재조립되었습니다.
이름: 강아지, ID: SN-8790
종료자는 객체가 메모리에서 제거되기 직전에
가비지 컬렉터(Garbage Collector)에 의해 호출되는 특별한 메서드입니다.
하지만 C# 프로그래밍에서는 꼭 필요한 경우가 아니라면 종료자를 사용하지 않습니다.
종료자는 가비지 컬렉터가 원할 때 호출되므로, 언제 실행될지 전혀 예측할 수 없습니다.
그래서 종료자 대신 IDisposable인터페이스와 using문을 주로 사용합니다.
종료자를 사용 할 때는 클래스 이름 앞에 물결표(~)를 붙여서 만듭니다.
종료자는 매개변수와 접근 제한자(public 등)를 가질 수 없습니다.
[코드]
using System;
class RobotWithFinalizer
{
public RobotWithFinalizer()
{
Console.WriteLine("로봇이 생성되었습니다.");
}
// 이것이 바로 종료자입니다.
~RobotWithFinalizer()
{
// 이 코드는 가비지 컬렉터가 객체를 수거할 때 실행됩니다.
// 프로그램이 끝날 때쯤 실행될 수도 있고, 아닐 수도 있습니다.
Console.WriteLine("로봇을 제거합니다.");
}
}
class Program
{
static void Main()
{
// 1. 기본 생성자 호출
RobotWithFinalizer robot = new RobotWithFinalizer();
Console.WriteLine("메인 메서드를 종료합니다.");
}
}
[실행 결과]
로봇이 생성되었습니다.
메인 메서드를 종료합니다.