- 동작하는 코드만으로는 부족하다. 고칠 수 있는 코드여야 한다.
- 게임은 기획, 몬스터, 밸런스가 계속 바뀐다. 한 가지를 고치려고 열 군데를 손대면 그 코드는 오래 버티지 못한다.
string[] names = { "슬라임", "고블린", "드래곤" };
int[] hps = { 20, 60, 500 };
int[] attacks = { 3, 12, 80 };
MonsterType[] types = { MonsterType.Slime, MonsterType.Goblin, MonsterType.Dragon };
| # | 고칠 곳 | 내용 |
|---|
| 1 | MonsterType | Orc 추가 |
| 2 | names | "오크" |
| 3 | hps | 120 |
| 4 | attacks | 25 |
| 5 | types | MonsterType.Orc |
| 6 | Attack의 switch | case Orc 추가 |
- 하나라도 빠뜨리면 컴파일러가 침묵한 채 조용히 잘못 동작.

<조용히 버그가 생성된다는 것이다>
- 배열 길이 어긋남 → 인덱스 밀려 엉뚱한 값 출력
- case 누락 → 오크가 아무 공격도 안 함 (오류도 없음)
- 병렬 배열: 한 몬스터가 4개 배열에 흩어져 있고, 묶는 것은 인덱스 숫자뿐
- "이 넷이 한 몬스터다"라는 약속이 코드가 아니라 작성자 머릿속에만 있음
- 배열 하나만 정렬해도 대응이 통째로 깨짐, 인수인계 시 약속을 모름
- 동작이 데이터에서 분리: 드래곤이 불을 뿜는 건 드래곤의 정보인데 switch 안쪽에 있음
- 이동, 피격, 사망, 보상까지 붙으면 switch가 5개 → 오크 추가 시 10군데
- 핵심: 몬스터 하나 추가라는 변경이 프로그램 곳곳으로 번진다.
- 문제는 두 겹: ① 데이터가 흩어짐 ② 동작이 데이터에서 떨어짐
- ①은 구조체로 해결 가능 (
struct Monster { Name, Hp, Attack, Type } → 배열 1개)
- ②는 구조체로도 그대로:
Attack(Monster) + switch가 밖에 남음
- 질문: "동작까지 그 안에 넣을 수는 없을까?"
- 오크 추가 시: 오크의 데이터+동작을 한 덩어리로 새로 만들어 넣기만 하면 됨. 기존 코드, 부리는 쪽 코드 수정 없음.

<절차지향처럼 일일이 다 까뒤집을 일은 없단 이야기이다>
- 객체지향 프로그래밍(OOP) = 데이터와 그 데이터를 다루는 동작을 하나로 묶어 프로그램을 구성하는 방식
| 구분 | 절차지향 | 객체지향 |
|---|
| 나누는 기준 | 처리의 순서와 단계 | 등장하는 대상 |
| 데이터와 동작 | 따로 존재 | 한 덩어리 |
| 종류 추가 시 | 관련 분기를 전부 찾아 고침 | 새 덩어리를 만들어 넣음 |
| 잘 맞는 규모 | 짧고 한 번에 읽히는 프로그램 | 오래 유지하며 계속 고치는 프로그램 |
- 어느 쪽이 우수한 게 아니라 무엇을 기준으로 코드를 나누느냐의 차이.
- 클래스(class) → 설계도(정의). 프로그램에 한 번만 적음. 그 자체로는 값 없음.
- 객체(object) / 인스턴스(instance) → 설계도로 실제 만들어져 메모리를 차지하는 것. 몇 개든 생성 가능.
- 객체 → 인스턴스 (같은 것)
- 그 자체를 하나의 대상으로 볼 때 → "객체"
- 어느 클래스에서 나왔는지 함께 말할 때 → "인스턴스" (예: Monster 클래스의 인스턴스)
- 구분하려 애쓸 필요 없음, 실무에서도 섞어 씀.
- 인스턴스는 각자 자기 값을 가진다 (슬라임 3마리의 체력은 따로 줄어듦, 붕어빵 틀 비유)
- 클래스는 "무엇을 가질지"를 정하고, 값은 인스턴스마다 따로 갖는다. ← 이후 클래스 주제의 출발점
- 외울 목록이 아니라 각각 해결하려는 문제가 따로 있는 도구.
| 특성 | 한 줄 요약 | 해결하려는 문제 | 비유 |
|---|
| 캡슐화 | 안쪽 데이터를 감추고 정해진 통로로만 다루게 함 | monsters[0].Hp = -9999; 같은 값이 그냥 통과 | 계기판·페달만 밖에, 엔진은 덮여 있음 |
| 상속 | 공통 부분은 한 곳에, 차이만 따로 정의 | 이름·체력·공격력 등 중복 | Monster(공통) → Slime/Goblin/Dragon |
| 다형성 | 같은 명령에 받는 쪽마다 다르게 동작 | 종류별 switch | 순회하며 "공격하라"만 시킴 |
| 추상화 | 복잡한 속사정은 감추고 필요한 것만 노출 | 쓰는 쪽이 내부 계산식까지 알아야 함 | 덮개 아래 복잡한 속, 손잡이 하나 |
- 캡슐화: 통로(피해를 입는다) 안에서 음수 검사 → 검사 코드가 한 자리, 모든 사용처가 거침.
- 상속: 체력 처리 방식을 바꿀 때 한 군데만 고치면 세 종류 모두 반영.
- 다형성:
foreach (몬스터) { 몬스터에게 공격하라고 시킨다 } → 오크 추가해도 반복문 수정 불필요. 종류 추가 시 기존 코드를 안 건드리는 것이 가장 큰 이득.
- 캡슐화 vs 추상화: 캡슐화는 감추는 쪽, 추상화는 드러낼 것을 고르는 쪽. 실제 코드에선 함께 나타나므로 엄밀히 구분 안 해도 됨.

<필요에 따라 감출 것과 드러낼 것을 취사 선택하면 된다>
| 특성 | 회수되는 주제 |
|---|
| 캡슐화 | 클래스 (접근 제한자, 프로퍼티) |
| 상속 | 상속 (기반/파생 클래스) |
| 다형성 | 상속, 추상 클래스, 인터페이스 (가상 메서드, 재정의) |
| 추상화 | 추상 클래스, 인터페이스 |
- 4가지 특성 = 객체지향이 무엇인지, SOLID = 객체지향으로 어떻게 나눠야 하는지 지침.
- 클래스를 쓸 줄 안다는 것이 꼭 잘 나눈 코드는 아니다. 일단 클래스 하나에 몰아넣어도 문법상 문제 없음
- 지금은 취지와 어긋난 증상까지만. 실제 적용은 상속, 추상 클래스, 인터페이스 주제에서.
| 원칙 | 핵심 | 예시 | 어긋났을 때의 증상 | 이후 주제 |
|---|
| SRP 단일 책임 | 하나는 한 가지 일만 | 몬스터 클래스가 상태 관리+화면 그리기+저장까지 → 수정 이유가 3개, 하나 고치다 다른 걸 건드림 | 클래스가 수백 줄로 불어나고 관계없는 이유로 계속 수정됨 | 클래스 |
| OCP 개방 폐쇄 | 기능 확장은 열어 두고, 기존 코드 수정은 막음 | 오크 추가 시 6군데를 고친 것이 위반 사례 | 기능 추가마다 같은 switch/if 사슬을 찾아다니며 늘림 | 상속·추상 클래스 |
| LSP 리스코프 치환 | 같은 자리에 들어간다고 했으면 실제로 대신 쓸 수 있어야 함 | 어떤 종류만 공격 명령에 오류를 냄 → 순회하는 쪽에서 걸러내야 함 (다형성으로 없앤 switch 부활) | 순회 중 "이 종류일 때만" 검사가 끼어듦 | 상속 |
| ISP 인터페이스 분리 | 쓰지도 않을 것까지 떠안기지 않음 | 공격·이동·비행·수영·마법을 한 목록에 → 못 나는 슬라임도 "난다"를 빈 내용으로 채움 | 내용이 비었거나 "지원하지 않음"만 적힌 기능이 곳곳에 | 인터페이스 |
| DIP 의존 역전 | 구체적인 것이 아니라 약속(규격)에 기대게 함 | 플레이어가 슬라임·고블린 이름을 각각 알면 몬스터 늘 때마다 플레이어도 늘어남 → "공격당할 수 있는 무언가"만 알게 함 (콘센트 규격 비유) | 상위 흐름 코드가 세부 종류 이름을 줄줄이 앎 | 인터페이스 |
- 지금 완전히 이해하지 못해도 OK.
- 이름과 취지만 기억해 두었다가 해당 문법이 나올 때 다시 펼쳐 볼 것.
- 원칙을 지키는 것 자체가 목적이 아니다.
- 목적은 고치기 쉬운 코드.
- 원칙에 맞추려다 읽기 어려워졌다면 잘못된 적용.
- 클래스는 데이터와 동작을 함께 담아 새로운 형식을 만드는 문법이다.
- 구조체와 선언 방식이 거의 같고, 차이는 두 가지:
struct 대신 class
- 인스턴스 생성 시 반드시
new
public class Monster
{
public string Name;
public int Hp;
}
Monster slime = new Monster();
slime.Name = "슬라임";
slime.Hp = 20;
- 구조체는 변수 선언만으로 실물이 생기지만, 클래스는 선언과 인스턴스 생성이 따로인 두 단계.
- 클래스는 참조 형식 → 변수는 인스턴스 자체가 아니라 있는 곳을 가리킴
new를 빠뜨리면 컴파일 오류
Monster slime;
slime.Name = "슬라임";
- 클래스 정의는 한 벌, 인스턴스는 몇 개든 가능.
- 이 교재에서는 클래스를 Program 클래스 밖에, Program과 나란히 정의.
- 클래스 안에 들어가는 것 통틀어 멤버(member).
| 멤버 | 무엇 | 앞선 주제와의 관계 |
|---|
| 필드(field) | 클래스가 갖는 데이터 | 구조체의 멤버 변수와 같음 |
| 메서드(method) | 클래스가 하는 동작 | 함수가 클래스 안에 들어간 것 |
- 메서드 = 클래스 안에 정의된 함수. 매개변수·반환값·오버로딩 그대로 적용.
- 형식:
<접근 제한자> <반환형> <메서드 이름>(<매개변수>) { } (static은 붙이지 않음)
- 지역 변수: 값 없이 쓰면 컴파일 오류
- 필드: 오류 없이 자동 기본값
| 형식 | 기본값 |
|---|
| 정수형 | 0 |
| float, double | 0 |
| bool | false |
| char | \0 |
| string, 클래스 | null |
- 문자열 필드를 초기화 안 하고 쓰면 문제 가능 (null은 이후 주제).
- 초기값 지정 가능:
public int Hp = 100;
- 메서드 예시는 다음과 같다.
public class Monster
{
public string Name;
public int Hp;
public int AttackPower;
public void Attack()
{
Console.WriteLine(Name + "이(가) 공격합니다. 피해 " + AttackPower);
}
public void TakeDamage(int damage)
{
Hp = Hp - damage;
Console.WriteLine(Name + "이(가) " + damage + " 피해를 입었습니다. 남은 체력 " + Hp);
}
public bool IsDead() { return Hp <= 0; }
}
- 메서드 안에서 필드는 이름만으로 접근 (같은 클래스 안).
- 호출은 인스턴스에 점:
slime.TakeDamage(5) → 슬라임의 체력만 깎임. 메서드는 한 벌, 다루는 대상은 인스턴스마다 다름.
this: 자기 자신을 가리킴. 매개변수와 필드 이름이 겹칠 때 구분용.
public void SetName(string name) { this.Name = name; }
- 실행 결과 슬라임 체력이 -5가 됨 → 아무도 막지 않음
- 접근 제한자는 멤버를 어디까지 공개할지 정하는 키워드이다
- 객체지향의 캡슐화가 문법이 된 것

<보이고 싶지 않은 것은 감싼다라는 느낌>
- 문제:
slime.Hp = -9999; → TakeDamage를 거치지 않고 직접 변경 가능, 메서드 안 검사가 무의미
| 접근 제한자 | 접근 범위 |
|---|
public | 어디서든 |
private | 같은 클래스 안에서만 |
protected | 같은 클래스 + 물려받은 클래스 (상속 주제에서 사용) |
internal | 같은 프로젝트 안 (이 과정에선 거의 안 나옴) |
- 지금 쓰는 것은
public과 private.
- hp를
private으로 → slime.hp = -9999; 컴파일 오류, TakeDamage만 통로.
- 아무것도 안 적으면 private (클래스 멤버 기본값). 하지만 잊은 것과 일부러 감춘 것이 구분 안 되므로 의도한 것은 반드시 적기.
| 대상 | 표기 |
|---|
| 클래스 이름 | PascalCase (Monster) |
| public 멤버 | PascalCase (TakeDamage) |
| private 필드 | camelCase (hp) |
| 지역 변수·매개변수 | camelCase (damage) |
public void TakeDamage(int damage)
{
if (damage < 0) { Console.WriteLine("피해량은 음수가 될 수 없습니다."); return; }
hp = hp - damage;
if (hp < 0) { hp = 0; }
}
- 밖에서 hp에 손댈 방법이 없으므로 규칙(체력 0 미만 불가, 음수 피해 거부)이 반드시 지켜짐.
- 남은 문제: 인스턴스 생성 후
Setup을 따로 불러야 하고, 잊으면 이름 없는 몬스터 → 생성자로 해결.
- 생성자(constructor) = 인스턴스가 만들어질 때 자동 실행되는 특별한 메서드
- 규칙 두 가지:
- 이름이 클래스 이름과 완전히 같음
- 반환형을 적지 않음 (void도 X)
- 형식:
public <클래스 이름>(<매개변수>) { // 초기화 }
public Monster(string monsterName, int monsterHp)
{
name = monsterName;
maxHp = monsterHp;
hp = monsterHp;
}
Monster slime = new Monster("슬라임", 20);
- 생성자를 하나도 안 적으면 컴파일러가 매개변수 없는 생성자를 자동 생성 (그래서
new Monster()가 됐던 것)
- 하나라도 직접 적는 순간 자동 생성이 사라짐
Monster slime = new Monster("슬라임", 20);
Monster goblin = new Monster();
- 빈 괄호도 쓰려면 직접 작성.
- 생성자를 여러 개 두는 것. 함수 오버로딩 규칙(매개변수 개수·형식이 달라야 함) 동일.
- 초기화 코드 중복 방지:
: this(...) = 자기 클래스의 다른 생성자를 먼저 실행
public Monster(string monsterName, int monsterHp)
: this(monsterName, monsterHp, 1) { }
public Monster(string monsterName, int monsterHp, int power)
{
name = monsterName; hp = monsterHp; attackPower = power;
}
- 성과: 부르는 것을 잊을 수 있는 메서드(Setup)가 없어짐.
- 소멸자(destructor) = 인스턴스가 정리될 때 실행되는 메서드
- C#에서 안 쓰이는 인스턴스는 가비지 컬렉터(GC)가 정리 → 시점은 프로그램이 판단, 프로그래머가 지정 불가
- 성질:
- 언제 실행될지 알 수 없음
- 프로그램 종료 시 실행된다는 보장도 없음
- 직접 호출 불가
- → 실무에서 직접 작성할 일 거의 없음. (이 장은 존재와 쓰지 않는 이유까지가 범위, 사용 예 없음)
형식: ~<클래스 이름>() { // 정리 작업 }
| 항목 | 규칙 |
|---|
| 개수 | 한 클래스에 하나만 |
| 매개변수 | 불가 |
| 접근 제한자 | 붙일 수 없음 |
| 오버로딩 | 불가 |
| 직접 호출 | 불가 |
| 구조체 | 정의 불가 (클래스에만) |
- 정리 시점을 직접 정해야 하는 경우(파일·외부 자원)는 인터페이스 주제에서 다룸.
- 문제: hp를 private으로 막으니 읽기도 불가 (체력 바 그리기 곤란). public으로 열면 3장 문제 복귀.
- 필요한 것: 읽는 것은 허용, 쓰는 것은 검사를 거치게.
- 프로퍼티(property) = 필드처럼 쓰이지만 실제로는 읽기·쓰기 동작을 대신 수행하는 멤버 (그 사이에 코드가 한 겹 들어감)
public class Monster
{
private int hp;
private int maxHp = 100;
public int Hp
{
get { return hp; }
set
{
if (value < 0) { hp = 0; }
else if (value > maxHp) { hp = maxHp; }
else { hp = value; }
}
}
}
- 프로퍼티 이름은 PascalCase, 뒤의 private 필드는 camelCase.
get: 값을 읽을 때 실행 / set: 값을 넣을 때 실행
value: set 안에서 넘어온 값을 받는 이름 (선언 없이 사용 가능)
- 쓰는 쪽은 필드와 똑같이 보임:
slime.Hp = 50;
slime.Hp = -9999;
slime.Hp = 99999;
Console.WriteLine(slime.Hp);
public int MaxHp { get { return maxHp; } }
public int Hp
{
get { return hp; }
private set { hp = value; }
}
public string Name { get; set; }
public int Level { get; private set; }
- 필드를 따로 선언 안 해도 컴파일러가 만들어 줌.
- 검사 필요 → 전체 형태 / 필요 없음 → 축약형
- 주의: 프로퍼티는 "필드를 길게 쓰는 것"이 아님. 목적은 그 사이에 코드를 끼워 넣는 것.
- 사용 예 핵심
TakeDamage, Heal이 범위를 따로 검사하지 않아도 체력이 0~20을 벗어나지 않음 → 검사가 Hp 프로퍼티 한 곳에만 있고 안에서 쓰든 밖에서 쓰든 그곳을 지나감.
- 생성자 안에서
Hp = monsterMaxHp;로 프로퍼티를 통해 초기화 (※ maxHp를 먼저 대입해야 함)
- 지금까지의 멤버는 인스턴스마다 하나씩. 하지만 "지금까지 만든 몬스터 총 수"처럼 몬스터 전체에 대한 정보는 인스턴스마다 있을 필요 없음.
- static 멤버 = 인스턴스마다가 아니라 클래스 전체에 하나만 존재
- 이미 써왔음:
Console.WriteLine (Console 인스턴스 없이 사용), Main도 static (시작 시점엔 인스턴스가 없으므로)
public class Monster
{
public static int TotalCount = 0;
private string name;
public Monster(string monsterName)
{
name = monsterName;
TotalCount = TotalCount + 1;
}
public static void PrintTotal() { Console.WriteLine("지금까지 만들어진 몬스터 " + TotalCount + "마리"); }
}
Monster.TotalCount;
slime.TotalCount;
public static void PrintName() { Console.WriteLine(name); }
Monster.PrintName()처럼 인스턴스 지정 없이 호출 → name이 슬라임 것인지 드래곤 것인지 정할 수 없어 컴파일러가 막음.
- 반대는 가능 (static은 클래스에 하나뿐이라 헷갈릴 게 없음).
| 어디서 | 인스턴스 멤버 접근 | static 멤버 접근 |
|---|
| 인스턴스 메서드 | 가능 | 가능 |
| static 메서드 | 불가능 | 가능 |
public static class GameSettings
{
public static int MaxLevel = 99;
public static int GetExpForLevel(int level) { return level * 100; }
}
GameSettings.MaxLevel;
- 안에는 static 멤버만 가능, new로 생성 불가
- 인스턴스마다 달라질 것이 없는 설정값·계산식 묶음 용도
- 사용 예 핵심
totalCount(static)는 인스턴스 3개를 만들어도 하나뿐이라 3까지 올라감.
Hp(인스턴스)는 셋이 각자 갖고 있어 20·60·500으로 따로 유지.
- 한 클래스 안에 두 종류의 멤버가 공존하는 것이 요점.