프로그래밍 언어 - C#(8) 객체지향 프로그래밍과 클래스

이형눈이이상해
이형눈이이상해
∙∙조회 0

1. 객체지향 프로그래밍

1.1. 개요

  • 동작하는 코드만으로는 부족하다. 고칠 수 있는 코드여야 한다.
  • 게임은 기획, 몬스터, 밸런스가 계속 바뀐다. 한 가지를 고치려고 열 군데를 손대면 그 코드는 오래 버티지 못한다.

1.2. 앞선 방식(절차 지향 + 병렬 배열)이 막히는 지점

csharp
string[] names = { "슬라임", "고블린", "드래곤" };
int[] hps = { 20, 60, 500 };
int[] attacks = { 3, 12, 80 };
MonsterType[] types = { MonsterType.Slime, MonsterType.Goblin, MonsterType.Dragon };
// Attack(type, attack) 안에 switch로 종류별 분기
  • 오크 하나 추가 시 고칠 곳 = 6군데
#고칠 곳내용
1MonsterTypeOrc 추가
2names"오크"
3hps120
4attacks25
5typesMonsterType.Orc
6Attack의 switchcase Orc 추가
  • 하나라도 빠뜨리면 컴파일러가 침묵한 채 조용히 잘못 동작.

<조용히 버그가 생성된다는 것이다>

  • 배열 길이 어긋남 → 인덱스 밀려 엉뚱한 값 출력
  • case 누락 → 오크가 아무 공격도 안 함 (오류도 없음)
  1. 병렬 배열: 한 몬스터가 4개 배열에 흩어져 있고, 묶는 것은 인덱스 숫자뿐
  • "이 넷이 한 몬스터다"라는 약속이 코드가 아니라 작성자 머릿속에만 있음
  • 배열 하나만 정렬해도 대응이 통째로 깨짐, 인수인계 시 약속을 모름
  1. 동작이 데이터에서 분리: 드래곤이 불을 뿜는 건 드래곤의 정보인데 switch 안쪽에 있음
  • 이동, 피격, 사망, 보상까지 붙으면 switch가 5개 → 오크 추가 시 10군데
  • 핵심: 몬스터 하나 추가라는 변경이 프로그램 곳곳으로 번진다.

1.3. 데이터와 동작을 함께 묶는다

  • 문제는 두 겹: ① 데이터가 흩어짐 ② 동작이 데이터에서 떨어짐
  • ①은 구조체로 해결 가능 (struct Monster { Name, Hp, Attack, Type } → 배열 1개)
  • ②는 구조체로도 그대로: Attack(Monster) + switch가 밖에 남음
  • 질문: "동작까지 그 안에 넣을 수는 없을까?"
csharp
monsters[0].Attack();   // switch 없음, 판별 코드 없음. 공격 방법은 슬라임 자신이 안다.
  • 오크 추가 시: 오크의 데이터+동작을 한 덩어리로 새로 만들어 넣기만 하면 됨. 기존 코드, 부리는 쪽 코드 수정 없음.

<절차지향처럼 일일이 다 까뒤집을 일은 없단 이야기이다>

  • 객체지향 프로그래밍(OOP) = 데이터와 그 데이터를 다루는 동작을 하나로 묶어 프로그램을 구성하는 방식
구분절차지향객체지향
나누는 기준처리의 순서와 단계등장하는 대상
데이터와 동작따로 존재한 덩어리
종류 추가 시관련 분기를 전부 찾아 고침새 덩어리를 만들어 넣음
잘 맞는 규모짧고 한 번에 읽히는 프로그램오래 유지하며 계속 고치는 프로그램
  • 어느 쪽이 우수한 게 아니라 무엇을 기준으로 코드를 나누느냐의 차이.

1.4. 객체와 인스턴스

  • 클래스(class) → 설계도(정의). 프로그램에 한 번만 적음. 그 자체로는 값 없음.
  • 객체(object) / 인스턴스(instance) → 설계도로 실제 만들어져 메모리를 차지하는 것. 몇 개든 생성 가능.
  • 객체 → 인스턴스 (같은 것)
  • 그 자체를 하나의 대상으로 볼 때 → "객체"
  • 어느 클래스에서 나왔는지 함께 말할 때 → "인스턴스" (예: Monster 클래스의 인스턴스)
  • 구분하려 애쓸 필요 없음, 실무에서도 섞어 씀.
  • 인스턴스는 각자 자기 값을 가진다 (슬라임 3마리의 체력은 따로 줄어듦, 붕어빵 틀 비유)
  • 클래스는 "무엇을 가질지"를 정하고, 값은 인스턴스마다 따로 갖는다. ← 이후 클래스 주제의 출발점

1.5. 객체지향의 네 가지 특성

  • 외울 목록이 아니라 각각 해결하려는 문제가 따로 있는 도구.
특성한 줄 요약해결하려는 문제비유
캡슐화안쪽 데이터를 감추고 정해진 통로로만 다루게 함monsters[0].Hp = -9999; 같은 값이 그냥 통과계기판·페달만 밖에, 엔진은 덮여 있음
상속공통 부분은 한 곳에, 차이만 따로 정의이름·체력·공격력 등 중복Monster(공통) → Slime/Goblin/Dragon
다형성같은 명령에 받는 쪽마다 다르게 동작종류별 switch순회하며 "공격하라"만 시킴
추상화복잡한 속사정은 감추고 필요한 것만 노출쓰는 쪽이 내부 계산식까지 알아야 함덮개 아래 복잡한 속, 손잡이 하나
  • 캡슐화: 통로(피해를 입는다) 안에서 음수 검사 → 검사 코드가 한 자리, 모든 사용처가 거침.
  • 상속: 체력 처리 방식을 바꿀 때 한 군데만 고치면 세 종류 모두 반영.
  • 다형성: foreach (몬스터) { 몬스터에게 공격하라고 시킨다 } → 오크 추가해도 반복문 수정 불필요. 종류 추가 시 기존 코드를 안 건드리는 것이 가장 큰 이득.
  • 캡슐화 vs 추상화: 캡슐화는 감추는 쪽, 추상화는 드러낼 것을 고르는 쪽. 실제 코드에선 함께 나타나므로 엄밀히 구분 안 해도 됨.

<필요에 따라 감출 것과 드러낼 것을 취사 선택하면 된다>

  • 이후 어디서 회수되나
특성회수되는 주제
캡슐화클래스 (접근 제한자, 프로퍼티)
상속상속 (기반/파생 클래스)
다형성상속, 추상 클래스, 인터페이스 (가상 메서드, 재정의)
추상화추상 클래스, 인터페이스

1.6. SOLID 원칙

  • 4가지 특성 = 객체지향이 무엇인지, SOLID = 객체지향으로 어떻게 나눠야 하는지 지침.
  • 클래스를 쓸 줄 안다는 것이 꼭 잘 나눈 코드는 아니다. 일단 클래스 하나에 몰아넣어도 문법상 문제 없음
  • 지금은 취지와 어긋난 증상까지만. 실제 적용은 상속, 추상 클래스, 인터페이스 주제에서.
원칙핵심예시어긋났을 때의 증상이후 주제
SRP 단일 책임하나는 한 가지 일만몬스터 클래스가 상태 관리+화면 그리기+저장까지 → 수정 이유가 3개, 하나 고치다 다른 걸 건드림클래스가 수백 줄로 불어나고 관계없는 이유로 계속 수정됨클래스
OCP 개방 폐쇄기능 확장은 열어 두고, 기존 코드 수정은 막음오크 추가 시 6군데를 고친 것이 위반 사례기능 추가마다 같은 switch/if 사슬을 찾아다니며 늘림상속·추상 클래스
LSP 리스코프 치환같은 자리에 들어간다고 했으면 실제로 대신 쓸 수 있어야 함어떤 종류만 공격 명령에 오류를 냄 → 순회하는 쪽에서 걸러내야 함 (다형성으로 없앤 switch 부활)순회 중 "이 종류일 때만" 검사가 끼어듦상속
ISP 인터페이스 분리쓰지도 않을 것까지 떠안기지 않음공격·이동·비행·수영·마법을 한 목록에 → 못 나는 슬라임도 "난다"를 빈 내용으로 채움내용이 비었거나 "지원하지 않음"만 적힌 기능이 곳곳에인터페이스
DIP 의존 역전구체적인 것이 아니라 약속(규격)에 기대게 함플레이어가 슬라임·고블린 이름을 각각 알면 몬스터 늘 때마다 플레이어도 늘어남 → "공격당할 수 있는 무언가"만 알게 함 (콘센트 규격 비유)상위 흐름 코드가 세부 종류 이름을 줄줄이 앎인터페이스
  • 지금 완전히 이해하지 못해도 OK.
  • 이름과 취지만 기억해 두었다가 해당 문법이 나올 때 다시 펼쳐 볼 것.
  • 원칙을 지키는 것 자체가 목적이 아니다.
  • 목적은 고치기 쉬운 코드.
  • 원칙에 맞추려다 읽기 어려워졌다면 잘못된 적용.

2. 클래스

2.1. 클래스와 인스턴스

  • 클래스는 데이터와 동작을 함께 담아 새로운 형식을 만드는 문법이다.
  • 구조체와 선언 방식이 거의 같고, 차이는 두 가지:
    1. struct 대신 class
    2. 인스턴스 생성 시 반드시 new
csharp
public class Monster
{
    public string Name;
    public int Hp;
}

Monster slime = new Monster();
slime.Name = "슬라임";
slime.Hp = 20;
  • 구조체는 변수 선언만으로 실물이 생기지만, 클래스는 선언과 인스턴스 생성이 따로인 두 단계.
  • 클래스는 참조 형식 → 변수는 인스턴스 자체가 아니라 있는 곳을 가리킴
  • new를 빠뜨리면 컴파일 오류
    csharp
    Monster slime;
    slime.Name = "슬라임";   // 오류: 아직 아무것도 가리키지 않음
    
  • 클래스 정의는 한 벌, 인스턴스는 몇 개든 가능.
  • 이 교재에서는 클래스를 Program 클래스 밖에, Program과 나란히 정의.
  • 클래스 안에 들어가는 것 통틀어 멤버(member).

2.2. 필드와 메서드

멤버무엇앞선 주제와의 관계
필드(field)클래스가 갖는 데이터구조체의 멤버 변수와 같음
메서드(method)클래스가 하는 동작함수가 클래스 안에 들어간 것
  • 메서드 = 클래스 안에 정의된 함수. 매개변수·반환값·오버로딩 그대로 적용.
  • 형식: <접근 제한자> <반환형> <메서드 이름>(<매개변수>) { } (static은 붙이지 않음)

필드 기본값 (지역 변수와의 차이)

  • 지역 변수: 값 없이 쓰면 컴파일 오류
  • 필드: 오류 없이 자동 기본값
형식기본값
정수형0
float, double0
boolfalse
char\0
string, 클래스null
  • 문자열 필드를 초기화 안 하고 쓰면 문제 가능 (null은 이후 주제).
  • 초기값 지정 가능: public int Hp = 100;
  • 메서드 예시는 다음과 같다.
csharp
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: 자기 자신을 가리킴. 매개변수와 필드 이름이 겹칠 때 구분용.
    csharp
    public void SetName(string name) { this.Name = name; }  // this.Name=필드, name=매개변수
    
  • 실행 결과 슬라임 체력이 -5가 됨 → 아무도 막지 않음

2.3. 접근 제한자

  • 접근 제한자는 멤버를 어디까지 공개할지 정하는 키워드이다
  • 객체지향의 캡슐화가 문법이 된 것

<보이고 싶지 않은 것은 감싼다라는 느낌>

  • 문제: 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)
csharp
public void TakeDamage(int damage)
{
    if (damage < 0) { Console.WriteLine("피해량은 음수가 될 수 없습니다."); return; }
    hp = hp - damage;
    if (hp < 0) { hp = 0; }
}

  • 밖에서 hp에 손댈 방법이 없으므로 규칙(체력 0 미만 불가, 음수 피해 거부)이 반드시 지켜짐.
  • 남은 문제: 인스턴스 생성 후 Setup을 따로 불러야 하고, 잊으면 이름 없는 몬스터 → 생성자로 해결.

2.4. 생성자

  • 생성자(constructor) = 인스턴스가 만들어질 때 자동 실행되는 특별한 메서드
  • 규칙 두 가지:
    1. 이름이 클래스 이름과 완전히 같음
    2. 반환형을 적지 않음 (void도 X)
  • 형식: public <클래스 이름>(<매개변수>) { // 초기화 }
csharp
public Monster(string monsterName, int monsterHp)
{
    name = monsterName;
    maxHp = monsterHp;
    hp = monsterHp;
}

Monster slime = new Monster("슬라임", 20);   // new Monster(...)의 괄호 = 생성자 매개변수 자리

2.5. 생성자를 하나라도 선언하면 빈 괄호가 막힌다

  • 생성자를 하나도 안 적으면 컴파일러가 매개변수 없는 생성자를 자동 생성 (그래서 new Monster()가 됐던 것)
  • 하나라도 직접 적는 순간 자동 생성이 사라짐
    csharp
    Monster slime = new Monster("슬라임", 20);  // 정상
    Monster goblin = new Monster();              // 오류: 그런 생성자가 없음
    
    
  • 빈 괄호도 쓰려면 직접 작성.

2.6. 생성자 오버로딩

  • 생성자를 여러 개 두는 것. 함수 오버로딩 규칙(매개변수 개수·형식이 달라야 함) 동일.
  • 초기화 코드 중복 방지: : this(...) = 자기 클래스의 다른 생성자를 먼저 실행
    csharp
    public Monster(string monsterName, int monsterHp)
        : this(monsterName, monsterHp, 1) { }          // 공격력 기본값 1
    
    public Monster(string monsterName, int monsterHp, int power)
    {
        name = monsterName; hp = monsterHp; attackPower = power;   // 실제 초기화는 한 곳
    }
    
    
  • 성과: 부르는 것을 잊을 수 있는 메서드(Setup)가 없어짐.

2.7. 소멸자

  • 소멸자(destructor) = 인스턴스가 정리될 때 실행되는 메서드
  • C#에서 안 쓰이는 인스턴스는 가비지 컬렉터(GC)가 정리 → 시점은 프로그램이 판단, 프로그래머가 지정 불가
  • 성질:
    • 언제 실행될지 알 수 없음
    • 프로그램 종료 시 실행된다는 보장도 없음
    • 직접 호출 불가
  • → 실무에서 직접 작성할 일 거의 없음. (이 장은 존재와 쓰지 않는 이유까지가 범위, 사용 예 없음)

형식: ~<클래스 이름>() { // 정리 작업 }

항목규칙
개수한 클래스에 하나만
매개변수불가
접근 제한자붙일 수 없음
오버로딩불가
직접 호출불가
구조체정의 불가 (클래스에만)
  • 정리 시점을 직접 정해야 하는 경우(파일·외부 자원)는 인터페이스 주제에서 다룸.

2.8. 프로퍼티

  • 문제: hp를 private으로 막으니 읽기도 불가 (체력 바 그리기 곤란). public으로 열면 3장 문제 복귀.
  • 필요한 것: 읽는 것은 허용, 쓰는 것은 검사를 거치게.
  • 프로퍼티(property) = 필드처럼 쓰이지만 실제로는 읽기·쓰기 동작을 대신 수행하는 멤버 (그 사이에 코드가 한 겹 들어감)
csharp
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 안에서 넘어온 값을 받는 이름 (선언 없이 사용 가능)
  • 쓰는 쪽은 필드와 똑같이 보임:
    csharp
    slime.Hp = 50;      // set
    slime.Hp = -9999;   // set → 0으로 잘림
    slime.Hp = 99999;   // set → maxHp(100)로 잘림
    Console.WriteLine(slime.Hp);  // get
    

2.9. 읽기 전용 / 제한된 쓰기

csharp
public int MaxHp { get { return maxHp; } }      // set 생략 → 읽기만 가능

public int Hp
{
    get { return hp; }
    private set { hp = value; }                  // 밖에선 읽기만, 클래스 안에선 쓰기 가능
}

2.10. 자동 구현 프로퍼티 (축약형)

csharp
public string Name { get; set; }
public int Level { get; private set; }
  • 필드를 따로 선언 안 해도 컴파일러가 만들어 줌.
  • 검사 필요 → 전체 형태 / 필요 없음 → 축약형
  • 주의: 프로퍼티는 "필드를 길게 쓰는 것"이 아님. 목적은 그 사이에 코드를 끼워 넣는 것.
  • 사용 예 핵심
    • TakeDamage, Heal이 범위를 따로 검사하지 않아도 체력이 0~20을 벗어나지 않음 → 검사가 Hp 프로퍼티 한 곳에만 있고 안에서 쓰든 밖에서 쓰든 그곳을 지나감.
    • 생성자 안에서 Hp = monsterMaxHp;로 프로퍼티를 통해 초기화 (※ maxHp를 먼저 대입해야 함)

2.11. static 멤버

  • 지금까지의 멤버는 인스턴스마다 하나씩. 하지만 "지금까지 만든 몬스터 총 수"처럼 몬스터 전체에 대한 정보는 인스턴스마다 있을 필요 없음.
  • static 멤버 = 인스턴스마다가 아니라 클래스 전체에 하나만 존재
  • 이미 써왔음: Console.WriteLine (Console 인스턴스 없이 사용), Main도 static (시작 시점엔 인스턴스가 없으므로)
csharp
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;        // 오류: 인스턴스로는 접근 불가

2.12. static 메서드에서 인스턴스 멤버는 쓸 수 없다

csharp
public static void PrintName() { Console.WriteLine(name); }  // 오류
  • Monster.PrintName()처럼 인스턴스 지정 없이 호출 → name이 슬라임 것인지 드래곤 것인지 정할 수 없어 컴파일러가 막음.
  • 반대는 가능 (static은 클래스에 하나뿐이라 헷갈릴 게 없음).
어디서인스턴스 멤버 접근static 멤버 접근
인스턴스 메서드가능가능
static 메서드불가능가능

2.13. 정적 클래스

csharp
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으로 따로 유지.
    • 한 클래스 안에 두 종류의 멤버가 공존하는 것이 요점.