본문 바로가기
개발/Unity 내일배움캠프 TIL

[내일배움단] 사전캠프 C# TIL 7. 클래스와 객체

by 석시 2023. 7. 21.



드디어 나왔다. 객체.

C#을 배울 때에 있어서 가장 중요한 부분이자 내가 가장 자신 없는 부분.

학교에서 컴퓨터 아키텍쳐, 운영체제, 멀티코어 및 GPU 프로그래밍까지 배웠음에도.

나는 객체지향에 대해 배운적이 없었다.

그래서 자신이 없을 뿐더러 내가 처음부터 객체를 생성하고 관리하는 식의 프로젝트를 거의 해본 적이 없기 때문에, 자신이 없는 것이다.

어차피 이번에 유니티 엔진을 다루면서 질리도록 다룰테니 걱정은 말자.

배우는 김에 확실하게, 학교에서 늘 하던 것처럼 공부해보겠다.

절차 지향 프로그래밍

절차라는 말이 번역이 약간 아쉽다고들 한다.

절차는 Procedure, 즉 함수를 기반으로 개발하자는 패러다임이다.

새로운 기능을 만들고자 하면 함수를 새로 만들어, 그 함수를 여러 함수들과 결합하여 구현하는 방식이다.

심플하고 직관적이라는 장점이 있다.

문제는 이제, 그렇게 만든 기능이 순서에 종속적이 되기 때문에 기능을 수정할 일이 생길 경우 이것이 매우 귀찮아진다는 점이다.

기능을 추가할 때도 비슷한 기능의 함수를 여러번 만들어야 하기 때문에 프로그램의 규모가 커질수록 유지보수가 힘들고 흐름이 꼬인다는 단점이 있다.

요즘 다른 서비스의 경우는 절차 지향을 통한 구현도 많이 뜬다고는 하지만, 게임의 경우는 얄짤 없다고 생각한다.

게임의 경우는 Component에 대한 것들이 아주 중요한데, Component가 객체 지향 스타일의 정점이 아닌가 싶어서.

객체 지향 프로그래밍 (Object Oriented Programming, OOP)

객체 지향은 모든 것을 다 Object를 기준으로 생각하게 된다.

객체의 구현 방향은 두 가지가 있다.

  • 속성 (필드)
  • 기능 (메소드)

해당 속성과 기능의 조합을 활용해 어지간한 것들은 다 구현할 수 있다.

속성의 경우 다음과 같이 표현할 수 있다.

class Person
{
    public string name;
    int age;
}

여기까지는 구조체 struct와 동일해보인다.

참고로 속성을 선언할 때 앞에 public을 붙이지 않게 되면, 기본적으로 private 상태가 된다.

그렇게 되면, 해당 속성은 선언한 class 안에서만 사용가능하기 때문에 밖에서 해당 속성을 접근할 수가 없어진다.

보통 속성의 경우는 다 private으로 만드는 것이 좋긴하지만, 지금은 가장 기본적인 형태를 보려는 것이기 때문에 넘어가자.

기능의 경우는 다음 방식으로 구현이 가능하다.

class Person
{
    public string name;
    public int age;

    public void PrintName()
    {
        Console.WriteLine($"My name is {name}");
    }
}

이렇게 만든 것은 설계도만 만든 것이다.

붕어빵을 만드는 틀에 가까운.

붕어빵은 어떻게 만드나요?

Person person = new Person();

다음과 같이 해당 객체가 필요한 부분에서 new 함수를 쓰는 것이다.

다른 언어랑 다르게 new 안쓰면 에러남

이렇게 만들어진 붕어빵을 우리는 인스턴스라고 부른다.

만들어진 인스턴스의 속성과 기능을 다음과 같이 접근할 수 있다.

class Program
{
    static void Main(string[] args)
    {
        Person seoksii = new Person();
        seoksii.name = "seoksii";

        seoksii.PrintName();
    }

}

간단하지 않은가?

하지만 이 파트에서 무엇보다 중요한 것은 객체지향에 담긴 철학을 이해하는 일이라는 것을 명심하자.

구조체 (struct) vs 클래스 (class)

그러면 구조체는 왜 있음?

다 이유가 있다.

형태만 똑같다 그랬지 작동하는 것까지 똑같다곤 안했다.

struct StructPerson
{
    public string name;
    public int age;
}

class ClassPerson
{
    public string name;
    public int age;
}

class Program
{
    static void Main(string[] args)
    {
        StructPerson StructSeoksii;
        ClassPerson ClassSeoksii = new ClassPerson();
    }

}

struct는 선언만 해도 문제가 없지만, class의 경우 new를 사용하지 않으면 생성된 인스턴스에 접근이 되지 않는다.

둘이 무슨 차이가 있길래?

static void StructChangeName(StructPerson person)
{
    person.name = "new name";
}

static void ClassChangeName(ClassPerson person)
{
    person.name = "new name";
}

다음과 같이 값을 수정해보려고 하면, struct는 값이 변경이 안되고 class는 값이 성공적으로 변경된다!

어? 어디서 많이 보던 것 아닌가?

그렇다.

struct는 복사방식으로 접근이 이루어지고 class는 참조방식으로 접근이 이루어지기 때문이다.

ClassPerson Seoksii = new ClassPerson();
ClassPerson notSeoksii = Seoksii;
notSeoksii.name = "notSeoksii";
Console.WriteLine(Seoksii.name);

참조 방식의 놀라운 점은 이건데, 출력의 결과가 Seoksii가 나와야 할 것 같지만, notSeoksii가 나온다.

참조 방식의 가장 큰 포인트다.

SeoksiinotSeoksii 모두 같은 객체를 참조하고 있어 나타나는 현상이다.

ClassPerson notSeoksii = Seoksii;

해당 라인의 경우 ”Seoksii에서 notSeoksii로 복사가 일어났다.” 라고 하기는 뭔가 애매하지 않은가?

따라서 이런 참조방식의 복사를 얕은 복사 (Shallow Copy) 라고 칭한다.

반대로 참조값 뿐만 아니라 모든 값이 새로 선언 및 할당되고 그 값들까지 전부 복사가 되는 경우 깊은 복사 (Deep Copy) 라고 부른다.

복사방식의 변수들을 복사할 때도 그렇고 참조방식의 변수들도 저렇게 일일히 복사를 다 해준다면 깊은 복사라고 부를 수 있는 것이다.

스택 (Stack) vs 힙 (Heap)

참조라는 표현은 C++과 다르게 포인터를 직접적으로 사용하지 않아서 쓰는 것이다. (물론 C++에도 참조 형식은 있다.)

참조와 복사 방식에 대해 근본적인 존재 이유를 묻는다면 저장 방식에 차이가 존재하기 때문이라고 일단은? 답할 수 있겠다.

메모리에 변수가 할당되는 방식은 두 가지가 있다. 스택 (Stack)힙 (Heap) 인데, 해당 자료구조에 대한 자세한 설명은 다른 게시글들에서 공부를 먼저하고 보는 것이 좋겠다.

스택 (Stack)

스택 (Stack) 은 함수를 위해 존재하는 메모리 공간이라고 생각하면 좋겠다. 함수를 보다보면 임시로 변수를 선언하다 삭제하는 일이 많지 않은가?

매개 변수를 받을 때도 그렇고, 함수 안에서 선언하는 변수들도 마찬가지이다.

이 때 종료가 먼저되는 함수 순서대로 변수를 삭제해줄 필요가 있는 것이다.

가장 먼저 필요가 없어진 변수들을 먼저 지워주는 것이다.

그것이 가장 메모리를 효율적으로 사용하는 것일테니.

그렇기에 스택 메모리 구조를 사용하는 것이다.

중첩된 함수 구조들을 보다보면

가장 나중에 실행된 함수부터 차례로 함수가 종료되지 않는가?

따라서 복사 방식을 가진 변수들은 스택에 저장되는 것이다.

이것이 스택 메모리의 철학인 것이다. 나는 처음에 공부했을 때는 너무 멋있다고 생각했다.

힙 (Heap)

참조 방식은 선언하는 변수에 데이터 본체가 저장되는 것이 아니라 본체가 저장된 주소를 담고 있는 개념의 방식이다.

주소가 가리키고 있는 본체들이 바로 힙 (Heap) 에 저장되는 것이다.

물론 참조 방식의 주소가 힙만 가리키는 건 아니지만.

힙에는 동적 할당이라는 방법으로 메모리에 공간을 할당한다.

컴파일 시점에 메모리가 할당이 되는 것을 정적 할당 런타임 시점에 메모리가 할당이 되는 것을 동적 할당이라 한다.

이것도 음… 다루는 글이 많을테니 설명은 패스.

지금까지 써왔던 new가 바로 동적 할당을 위한 문인 것이다.

동적 할당은 언제 메모리가 할당되고 해제될지 알 수 없기에 스택 메모리를 사용하는 것은 부적절하다.

실제 프로그램에서는 동적 할당이 훨씬 많이 일어나기 때문에 스택보다 힙의 크기가 훨씬 크다.

지금까지 참조 방식값 변경이나 얕은 복사와 같은 방식이 모두 힙의 데이터가 복사되는 것이 아닌 스택의 주소값이 복사되기 때문에 일어나는 현상이었던 것이다.

여기서 C#의 특징이라면 동적 할당 받은 메모리가 쓸 일이 없어지면 알아서 메모리를 해제해준다는 점이 있겠다.

C++의 경우 new로 할당을 받았다면, 해당 공간의 메모리가 쓸 일이 없어졌을 때 delete를 사용해 반드시 메모리 해제를 해줘야한다.

생성자

지금까지 알아본 건 new의 기본 동작을 알아본 것과 마찬가지이다.

하지만 new의 기능은 메모리 동적 할당에만 있는 것이 아니다.

변수 선언을 위해 메모리를 할당했다면 그 뒤에 자연스레 따라오는 과정이 있지 않은가? 변수 초기화 같은 것들이 그 예이다.

class의 경우 여러 속성과 기능을 가지고 있기 때문에 초기화할 속성도 너무 많을 것이고 필요에 따라 기본값을 설정한다던지, 다른 기능을 호출한다던지 등의 작동 방식이 필요할 것이다.

이러한 기능을 해주는 것을 우리는 생성자라고 한다.

쉽게 말하면 객체의 인스턴스가 생성될 때 호출되는 함수이다.

C# 생성자에는 규칙이 있다.

  • 함수의 이름이 class의 이름과 같을 것
  • 반환 인자로 아무것도 넣지 않을 것 (void 조차도!)

class ClassPerson
{
    public string name;

    public ClassPerson()
    {
        name = "Seoksii";
        Console.WriteLine("생성자");
    }
}

인자를 받을 수도 있다.

public ClassPerson(string name)
{
    name = name;
    Console.WriteLine("생성자");
}

뭔가…. 뭔가 문제가 하나 있다.

name = name; 이 도대체 무슨 소리니

왼쪽의 name은 내가 생성한 class의 속성 name이고, 오른쪽의 name은 생성자의 매개변수로 넘겨준 name이 아닌가.

둘을 구분해줄 필요가 있다.

public ClassPerson(string name)
{
    this.name = name;
    Console.WriteLine("생성자");
}

this는 현재 다루고 있는 class를 가리키는 문이라고 생각하면 된다.

이렇게 표현한다면 문제 해결.

당연하게 들 수 있는 궁금증.

“속성이 100개면 함수를 도대체 몇개나 써야하는 거임?”

다 방법이 있다.

public ClassPerson(string name) : this()
{
    this.name = name;
    Console.WriteLine("생성자");
}

this() 를 뒤에 붙여주게 되면, 가장 기본 형태의 생성자였던 ClassPerson()이 한번 호출된다.

일종의 디폴트 동작을 호출 시키는 셈.

이런 식으로 하나하나 쌓아만드는 느낌?

static에 대해

static void Main(string[] args) 앞에 붙어있던 이 static.

이것도 결국 메인 함수가 Program이라는 class에 있기 때문에 붙여주는 것이다.

static은 해당 변수나 기능을 인스턴스에 종속된 변수가 아니라 클래스에 종속된 변수로 만들어주는 기능이다.

무슨 소리인가?

class ClassPerson
{
    static public int number = 0;
    public string name;

    public ClassPerson()
    {
        name = "Seoksii";
        Console.WriteLine("생성자");
        ++number;
    }
}

다음과 같은 코드에서 number 변수는 어느 인스턴스에서 호출하든 같은 값을 가진다.

클래스에 종속된 변수기 때문에 단 하나만 존재하는 변수인 것이다.

위 예제에서는 생성자가 호출될 때마다 number를 1씩 증가시켜 현재 총 인스턴스가 몇 개가 있는지 등의 정보를 알 수 있는 것이다.

함수에 왜 static을 붙이는지 슬슬 이해가 간다.

static 함수에는 주의사항이 한 가지 있다.

static 함수에는 static 변수만 접근이 가능하다. 즉, 인스턴스의 개별 속성에 접근이 불가능하다는 사실.

물론 해당 함수 안에서 인스턴스를 생성하면 그 인스턴스에는 접근이 가능하다.

매우 헷갈릴 수 있지만, 내가 쓴 글을 단순히 읽고 있기 때문이다. 이건 진짜 직접 만들어보면 금방 안다.

객체 지향 프로그래밍의 특성

객체 지향 프로그래밍의 특성은 보통 세 가지로 나눈다.

  • 은닉성
  • 상속성
  • 다형성

상속성

여러 개의 클래스를 동시에 만들 때, 그 클래스들이 비슷하거나 같은 속성을 가질 때가 있지 않은가?

해당 속성의 생성과 관리를 위해 객체 지향에서는 클래스에 계층을 부여한다.

이것이 핵심 컨셉이다. 자식 클래스가 부모 클래스로 부터 속성과 기능을 상속받는다.

기반 클래스 - 파생 클래스 라고도 한다.

class Person
{
    static public int number = 0;
    public string name;
    public int age;
}

class Korean : Person
{

}

class American : Person
{

}

다음과 같이 작성하면 부모 클래스 Person이 가진 속성을 자식 클래스 KoreanAmerican도 가지게 된다.

굳이 속성 설정을 여러 번 하지 않아도 되고, 수정이 필요할 때도 편하지 않은가.

class Person
{
    static public int number = 0;
    public string name = "";
    public int age = 0;

    public Person()
    {
        Console.WriteLine("Person");
    }
}

class Korean : Person
{
    public Korean()
    {
        Console.WriteLine("Korean");
    }
}

class Program
{
    static void Main(string[] args)
    {
        Korean seoksii = new Korean();
    }
}

다음과 같이 코드를 작성하면, 상속받은 클래스의 생성자는 어떻게 호출되는가?에 대한 답을 찾을 수 있겠다.

정답은 부모 클래스 생성자 호출 → 자식 클래스 생성자 호출이다.

부모 - 자식 표현이 기가 막힌게, 생성 순서(?)가 부모가 먼저고 자식이 그 다음이 아닌가.

class Person
{
    static public int number = 0;
    public string name = "";
    public int age = 0;

    public Person(string name)
    {
        this.name = name;
        Console.WriteLine("Person");
    }
}

class Korean : Person
{
    public Korean() : base("Korean")
    {
        Console.WriteLine("Korean");
    }
}

다음과 같은 방식으로 base를 이용해 부모 클래스의 생성자를 지정해서 호출할 수도 있겠다.

은닉성

은닉성은 보안과 관련된 특징이다.

프로젝트가 점점 커지다보면, 다른 사람이 작성한 함수를 전부 이해하지 않은 채 사용하는 일도 있을 것이다.

클래스의 어떤 속성과 기능들은 매우 민감한 데이터를 다루고 있어 외부에 노출되면 곤란한 경우가 있다고 해보자.

이 때 쉽게 해당 값이나 함수의 접근이 어렵게 꽁꽁 싸매는 것을 은닉성이라 한다.

접근 한정자에는 대표적으로

public, protected, private이 있다.

public은 가장 개방적인 형태이다.

보안레벨이 굉장히 약한 대신 어디서든 접근이 가능하다.

private은 외부에서 사용을 완전히 차단하는 형태이다.

그러면 해당 값 변경은 어떻게 하는 것이냐?

해당 클래스의 내부 메소드로 가능한 것이다.

다음과 같은 방식으로 말이다.

class Person
{
    private string name;

    public void SetName(string name)
    {
        this.name = name;
    }
}

처음 봤을 땐 이걸 왜 이런 방식으로 해야하는지 와닿지 않을 수 있다.

이렇게 할 때의 매우 큰 장점이 있다.

코드에 어느 위치에서든 SetName()과 같은 함수들로만 name 속성을 수정한다고 할 경우 어디서 해당 속성의 수정이 일어나는 지를 알 수 있게 되는 것이다.

protectedprivate처럼 외부에서의 접근을 막지만 상속 받은 자식 클래스에서는 접근이 가능하게 만들어주는 한정자이다.

아무 한정자를 붙이지 않게되면 기본적으로는 private으로 동작하게 된다.

다형성

클래스 형식 변환이라는 것이 있다.

자식 클래스에서 부모 클래스로 형 변환을 한다고 생각해보자. 이 때는 자식 클래스 고유의 속성이 날아가는 것 빼고는 문제가 없어 보인다.

문제는 반대이다. 부모 클래스에서 자식 클래스로 형변환을 할 수 있는가?

직접 해보면 컴파일 단계에서는 에러가 없지만, 런타임에서 에러가 날 수 있다.

매우 위험한 짓이라는 것이다.

이를 컴파일 단계에서 확인하기 위해

is 라는 구문을 활용할 수 있다.

class Person
{
    static public int number = 0;
    protected string name = "";
    protected int age = 0;

}

class Korean : Person
{
    public int kimchi = 100;
}

class American : Person
{
    public int burger = 100;
}

class Program
{
    static void printKimchi(Person person)
    {
        bool isKorean = (person is Korean);
        if (isKorean)
        {
            Korean korean = (Korean)person;
            Console.WriteLine($"Kimchi is {korean.kimchi}");
        }
    }

    static void Main(string[] args)
    {
        Korean seoksii = new Korean();
    }

다음과 같은 방식으로 부모 클래스를 인자로 받는 함수에서 한 번 체크를 하고 자식 클래스로 형변환을 할 수 있게 된다.

as를 사용하여 is를 대신할 수도 있다.

as는 형변환에 실패하면 null을 뱉는다.

static void printKimchi(Person person)
{
    Korean korean = (person as Korean);
    if (korean != null)
    {
        Korean korean = (Korean)person;
        Console.WriteLine($"Kimchi is {korean.kimchi}");
    }
}

드디어 다형성 이야기를 해보자.

다음과 같은 상황이 있겠다.

class Person
{
    static public int number = 0;
    protected string name = "";
    protected int power = 0;

    public void PrintPower()
    {
        Console.WriteLine($"Power : {power}");
    }
}

class Korean : Person
{
    public int kimchi = 100;
    public new void PrintPower()
    {
        Console.WriteLine($"kimchi : {kimchi}");
    }
}

class American : Person
{
    public int burger = 100;
    public new void PrintPower()
    {
        Console.WriteLine($"burger : {burger}");
    }
}

class Program
{
    static void PrintPower(Person person)
    {
        // ??
    }

    static void Main(string[] args)
    {
        Korean seoksii = new Korean();
    }
}

void PrintPower(Person person)이 각 자식 클래스에 선언된 함수에 맞게 출력이 되려면 어떻게 해야할까?

as를 사용할 수도 있겠지만, 매번 분기를 설정해줘야한다는 번거로움이 있겠다.

이 때 사용하는 것이 virtualoverride이다.

class Person
{
    static public int number = 0;
    protected string name = "";
    protected int power = 0;

    public virtual void PrintPower()
    {
        Console.WriteLine($"Power : {power}");
    }
}

class Korean : Person
{
    public int kimchi = 100;
    public override void PrintPower()
    {
        Console.WriteLine($"kimchi : {kimchi}");
    }
}

class American : Person
{
    public int burger = 100;
    public override void PrintPower()
    {
        Console.WriteLine($"burger : {burger}");
    }
}

class Program
{
    static void PrintPower(Person person)
    {
        person.PrintPower();
    }

    static void Main(string[] args)
    {
        Korean seoksii = new Korean();
        seoksii.PrintPower();
    }
}

이를 통해 런타임 시에 해당 함수가 실행될 때 인스턴스의 클래스를 파악해 그 클래스의 함수를 실행시킬 수 있는 것이다.

그러면 모든 함수에 다 virtual 붙이면 되는 거 아님? 아니다. virtual 함수는 타 함수보다 약간 느리다고 한다. 이유는 나중에.

오버로딩이랑 뭐가 다름? 이라고 물어본다면 오버로딩은 함수 이름을 재사용하는 것 오버라이딩은 다형성의 개념이다.

C#에서만 있는 것 중 sealed라는 것이 있다.

override를 더 이상 못하게 막는 것이다.

public sealed override void PrintPower()
{
    Console.WriteLine($"kimchi : {kimchi}");
}

이렇게 되면 더이상 자식클래스에서 override를 할 수 없다.

잘 쓸 일은 없으니 알아만 두자.

지금까지 객체지향 프로그래밍이었다.

컨셉 자체가 프로그래밍 철학을 다루기에 처음보면 적응이 안되는 부분이 많지만, 실전을 통해 직접 느껴봐야 아는 부분들이 많기 때문에 유니티를 겪다보면 될 부분.

뭐 언젠간 자료구조랑 알고리즘 처럼 뼛속에 박히겠지.


Uploaded by N2T