게임을 만들기 위한 단계를 나눈다면 다음과 같다.
- 게임 초기화
- 그리기(Render)
- 입력(Input)
- 처리(Update)
- 2~4단계 반복
- 게임 종료
물론 각 단계도 더 작은 단위로 나눌 수 있고, 지금까지 각 단위를 코드로 작성하여 절차에 맞춰 게임을 만들었다. 하지만 게임의 복잡도가 높아질수록, 고려할 요소가 많아질수록 단계를 나누는 것이 굉장히 어려워지고, 절차를 지키려할수록 코드는 꼬이고, 관리가 어려워지는 상황이 발생한다.
이렇게 절차지향 프로그래밍의 단점을 보완하기 위해 새롭게 등장한 것이 객체지향 프로그래밍이다. 객체지향 프로그래밍은 프로그램 내에 개별적으로 존재하는 객체들을 생성하여 서로 상호작용하도록 하는 코드 관리 패러다임으로, 코드 관리가 용이하다는 장점이 부각되어 프로그래밍에서 가장 많이 활용되고 있다.
객체지향 프로그래밍의 간단한 예시를 들어보자. 자판기를 구현하고자 했을 때 우리는 자판기를 금액 투입부, 잔액 처리부, 음료 관리부로 나누어 볼 수 있다. 이때 만약 잔액 계산이 잘못되는 버그가 발생할 경우, 잔액 처리부의 코드만 따로 검사하는 것으로 디버깅을 편하게 할 수 있다. 이처럼 객체의 단일 책임 원칙을 지향하는 형태의 객체지향 프로그래밍은 디버깅이 비교적 용이하다는 장점이 있다. 또한 비슷한 시스템을 사용하는 코인 노래방에서도 자판기의 금액 투입부를 재활용함으로써, 프로그래밍 시간을 단축할 수 있는데, 이처럼 객체지향의 경우 코드의 재활용성이 뛰어나다는 장점도 존재한다.
객체 지향의 이러한 장점들은 대규모 프로젝트에서 큰 힘을 발휘하는데, 프로젝트를 각각 책임을 갖는 새로운 객체로 나누어 분업이 가능해지기 때문이다. 물론 이처럼 작은 단위로 모듈화시키는 과정이 신중해야하기에 시간이 많이 소비된다는 단점이 있으나, 장점이 더 크기에 많이 활용된다. 따라서 이후 프로젝트를 진행할 우리는 객체지향 프로그래밍에 익숙해지고, 잘 다룰 수 있어야할 것이다.
객체지향의 프로그래밍의 핵심은 현실의 존재하는 물체를 하나의 객체로 생각하되, 그 물체를 이루는 요소 또한 하나의 객체로 생각하고, 조그마한 객체들이 모여 상호작용하면서 큰 객체를 동작시키는 것이라고 생각하면 된다.
class Player
{
// 변수(명사) : 정보
private int level = 1; // 외부에서 레벨, 공격력을 건드릴 수 없음
private int attackPoint = 10;
// 함수(동사) : 행동
public void Attack(Monster monster) // 외부에서 공격 기능 사용 가능
{
Console.WriteLine("플레이어가 몬스터를 공격합니다.");
monster.TakeHit(attackPoint);
Console.WriteLine("플레이가 공격을 마칩니다.");
}
}
프로그램 내에서 객체가 구동되기 위한 데이터(필드)와 동작(메서드)은 객체가 가질 수 있는 성질이다. 이때 불필요한 데이터의 변질을 막기 위해 내부의 데이터와 동작을 감추고 외부에는 필요한 부분만 노출(은닉)하는 것을 캡슐화라고 한다.
class Monster
{
private int hp = 20;
public void TakeHit(int attackPoint)
{
}
}
public class Oac : Monster
{
// 부모 객체인 Monster가 가진 필드와 매서드 재사용
public void Berserk
{
// 오크만의 메서드 추가
}
}
상위 객체에서 정의된 필드와 메서드를 하위 객체에 재활용하여, 반복적인 코드를 최소화하고 공유하는 속성과 기능에 간편히 접근할 수 있도록하는 것을 상속이라고 한다.
class Player
{
// 변수(명사) : 정보
private int level = 1; // 레벨
private int attackPoint = 10; // 공격력
// 함수(동사) : 행동
public void Attack(Monster monster)
{
// 몬스터를 공격
}
}
객체의 여러 세부 사항 중 실제로 사용하는 공통 속성과 기능만 추출하고 나머지 불필요한 사항은 제거하여, 가장 본질적이고 공통적인 부분만 표현하는 것을 추상화라고 한다.
객체지향 프로그래밍에서 추구하는 프로그램 설계 원칙으로 더 체계적인 프로그래밍을 위해 고안되었다. 꼭 지켜야하는 것은 아니지만, 객체를 생성할 때 지켜야할 방향성으로 해석하면 좋을 것 같다.
S : 단일 책임 원칙
클래스는 단 하나의 책임만 가져야한다. 하나의 클래스는 하나의 기능을 담당하여 하나의 책임을 수행하는데 집중되도록 클래스를 개별적으로 설계하는 원칙.
O : 오픈 폐쇄 원칙
확장에 열려있어야하며, 수정에는 닫혀있어야한다. 기능 추가시 기존의 코드를 수정하기보다 추가적인 코드를 작성해 기능을 추가할 수 있어야한다는 원칙.
L : 리스코프 치환 원칙
자식 객체는 언제나 부모 타입으로 교체될 수 있어야한다. 다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 상태에서 부모의 베서드를 사용해도 프로그램이 동작해야한다는 원칙. Monster monster1 = new Orc();
처럼 선언을 Monster
로 하고 Orc
로 인스턴스를 생성했을 경우, monster1
이 Monster
의 필드, 메서드를 사용할 수 있다는 뜻.
I : 인터페이스 분리 원칙
하나의 큰 인터페이스보다 용도에 맞는 인터페이스를 잘게 분리해야한다. 프로그램의 유지보수에서 발생할 수 있는 인터페이스의 분리나 수정으로 인한 많은 양의 코드 수정을 막기 위해 불필요한 정보까지 가질 수 있는 하나의 거대한 인터페이스보다, 상황에 맞도록 소규모로 분리된 인터페이스를 사용할 수 있어야한다는 원칙.
D : 의존 역전 원칙
고수준의 모듈은 저수준 모듈의 구현에 의존해서는 안된다. 객체가 객체를 참조하거나 의존 관계를 맺을 때, 세부구현된 객체보다 상위 객체를 참조함으로써, 세부 구현된 클래스의 변화 발생시에도 유연하게 동작할 수 있는 구조를 맺는 원칙.
public class Monster
{
public string name;
public int hp;
public int level;
public Monster() // 생성자 1
{
level = 1;
hp = 100;
}
public Monster(string _name, int _hp, int _level) // 생성자 2
{
name = _name;
level = _level;
hp = _hp;
}
public Monster(string _name) // 생성자 3
{
name = _name;
switch (name)
{
case "슬라임":
hp = 100;
level = 1;
break;
case "오크":
hp = 30;
level = 10;
break;
case "드래곤":
hp = 1000;
level = 50;
break;
}
}
}
static void Main(string[] args)
{
Monster redDragon = new Monster("레드 드래곤", 1500, 70); // 생성자 2 적용
Monster dragon = new Monster("드래곤"); // 생성자 3 적용
}
반환형이 없는 class
이름의 함수를 생성자라 하며 new 키워드를 통해서 class
의 인스턴스를 만들 때 호출 되는 역할로 사용된다.
개발 과정에서는 class
가 많아질수록 코드가 길어져 관리의 필요성을 느끼게 된다. 따라서 class
는 개별 cs파일로 따로 관리하여 가독성을 높이는 방법을 사용한다. 각각의 cs파일을 class
로써 추가하면 동일한 namespace
에서 사용이 가능하므로 하나의 파일처럼 작업이 가능하며, 정말 잘 만든 class
의 경우 재활용을 할 수 있게 된다는 장점도 갖는다.
만약 다른 namespace
에 정의된 class
의 경우 솔루션이 같다면 using
구문을 활용하여 해당 namespace
를 가져와 사용할 수 있다.
class Student
{
public int age;
}
struct StudentData
{
public int age;
}
static void Main(string[] args)
{
Student student = new Student();
student.age = 20;
Student anotherStudent = student;
student.age = 80;
Console.WriteLine(student.age);
Console.WriteLine(anotherStudent.age);
StudentData studentData;
studentData.age = 20;
StudentData anotherStudentData = studentData;
studentData.age = 80;
Console.WriteLine(studentData.age);
Console.WriteLine(anotherStudentData.age);
}
80 // student.age
80 // anotherStudent.age // 인스턴스 주소 복사
20 // studentData.age
80 // anotherStudentData.age // 스택 값 복사
struct
는 선언과 동시에 스택영역에 데이터가 저장되지만 class
는 선언 시 스택 영역에 null
로 초기화되고, new
키워드를 통해 인스턴스를 생성하여 힙 영역에 인스턴스의 데이터 값을 저장하여 스택 영역에 있는 저장소에 인스턴스 데이터의 주소 값이 담긴다.
이러한 차이로 위처럼 동일한 코드지만 결과가 다른 것을 확인할 수 있는데, 이를 값 형식, 참조 형식의 차이라고 볼 수 있다. struct
의 anotherStudentData
는 studentData
의 값을 복사하여 새로운 스택 영역에 복사한 값을 저장한 것이고, class
인 anotherStudent
는 student
의 주소를 복사하여 새로운 스택 영역에 복사한 주소를 저장하였기에, 주소에 위치한 인스턴스의 데이터가 바뀌면 해당 주소를 저장한 두 변수가 모두 바뀌는 것이다.
따라서 struct
와 class
의 쓰임이 다른데, struct
는 실제 데이터를 복사하면서, 데이터를 보관하는 목적으로 사용하고, class
는 객체 즉 인스턴스가 중요하여 해당 인스턴스를 활용하려는 목적으로 사용된다.
프로그래머라면 메모리를 얼마나 효율적으로 사용할 수 있을지 염두에 두고 제작해야한다. 물론 C#은 메모리 관리를 자동으로 해주긴 하지만, 게임 프로그래밍에서는 성능 최적화를 위해 컴퓨터 내부적으로 메모리가 어떻게 사용되는지 이해할 필요가 있다.
스택 영역
함수의 호출시 할당되며 함수 종료시 삭제된다. 함수 내에서 생성한 지역 변수, 함수에 입력한 매개 변수 등이 여기에 저장된다. 호출 스택 구조를 활용하여 데이터를 효율적으로 관리한다.
힙 영역
클래스 또는 인스턴스를 생성시 할당되며, 더 이상 사용하지 않을시 자동으로 삭제된다. 인스턴스를 참조하는 변수가 없는 경우 사용하지 않는 것으로 간주하여, 가비지 컬렉터가 특정 타이밍에 수거한다.
데이터 영역
전역 변수, 정적(static) 변수 등 프로그램 전 영역에 사용되는 변수들이 저장되어, 프로그램의 시작시 할당되며 종료시 삭제된다. 프로그램 시작부터 끝까지 남아있는 데이터이다.
코드 영역
프로그램을 작동시키기 위해 작성한 코드가 이진법의 형태로 전부 저장된다고 생각하면 된다. 데이터가 변경되지 않는 읽기 전용 데이터이다.