문승현 튜터님 강의 - ‘OOP에 대하여 + 면접 준비’
🧩 객체 지향 프로그래밍(OOP)
지금까지 우리는 공장(컴퓨터)이 어떻게 돌아가는지 배웠습니다.
이제 우리는 공장장으로서 ‘작업 지시서(코드)를 어떻게 효율적으로 짤 것인가’를 고민해야 합니다.
과거에는 “A 하고, B 하고, C 하라”는 식의 절차 지향(Procedural) 방식을 썼습니다.
하지만 프로그램이 거대해지자, 코드가 수만 줄이 넘어가면서 관리가 불가능해졌습니다.
그래서 등장한 것이 객체 지향(OOP: Object-Oriented Programming)입니다.
“모든 것을 ‘물건(Object)’으로 보고, 그 물건들이 서로 대화하게 만들자!” 라는 방식입니다.
이 방식의 핵심은 공장의 부품들을 규격화(Class)하고 양산(Object)하는 것에 있습니다.
1. 클래스와 객체
흔히 “클래스는 붕어빵 틀, 객체는 붕어빵”이라고 합니다.
아주 좋은 비유지만, 컴퓨터 공학적으로는 살짝 부족합니다. 우리는 메모리 관점에서 진짜 의미를 파헤쳐 봅시다.
아래의 AWarrior 클래스 코드를 봐주세요.
이 코드는 컴파일되는 순간 3조각으로 찢어져서 서로 다른 메모리 영역에 저장됩니다.
class AWarrior
{
public:
// [A] 정적 변수 (모든 전사가 공유하는 데이터)
static int32 WarriorCount;
// [B] 멤버 함수 (행동/로직)
void Attack()
{
// 공격 로직 (기계어 명령어)
UE_LOG(LogTemp, Log, TEXT("칼을 휘두릅니다!"));
}
// [C] 멤버 변수 (개별 데이터)
int32 Hp = 100;
int32 Mp = 50;
};
// 정적 변수 초기화
int32 AWarrior::WarriorCount = 0;
위 코드가 실행될 때 메모리에서는 무슨 일이 일어날까요?
① [B] 멤버 함수 (void Attack) → Code(Text) 영역
- 정체: “공격하려면 팔을 뻗어라”라는 작업 매뉴얼(명령어)입니다.
- 위치: Code 영역 (읽기 전용).
- 특징:
전사가 100명이든 1만 명이든, 이 코드는 메모리에 딱 하나만 존재합니다.
모든 전사 객체는 공격할 때 이 공유된 매뉴얼을 봅니다(객체마다 함수가 복사되는 게 아닙니다!).
② [A] 정적 변수 (static WarriorCount) → Data(Static) 영역
- 정체: “현재까지 생산된 전사 총 숫자”를 적어두는 공용 상황판입니다.
- 위치: Data 영역.
- 특징:
프로그램이 시작되자마자 메모리에 자리를 잡습니다.
전사 객체를 하나도 만들지 않아도 이미 존재하며, 모든 전사가 이 하나의 변수를 공유합니다.
③ [C] 멤버 변수 (int Hp, Mp) → Heap(힙) 영역
- 정체: 실제 전사의 개별 상태 데이터입니다.
- 위치: 클래스 코드만 있을 때는 아무 곳에도 존재하지 않습니다. (정의만 된 상태)
- 생성 시점: 우리가
new AWarrior()를 호출하여 객체(Object)를 만드는 순간, Heap 영역에 공간이 생깁니다.
이제 실제로 공장을 돌려 객체를 찍어내 봅시다.
void SpawnWarriors()
{
// 1. 전사 A 생성 (Heap 0x1000 번지 할당)
AWarrior* A = new AWarrior();
// 2. 전사 B 생성 (Heap 0x5000 번지 할당)
AWarrior* B = new AWarrior();
}
이때 Heap 메모리(0x1000, 0x5000)에 실제로 저장되는 것은 무엇일까요?
놀랍게도 함수(Attack)는 없습니다. 오직 변수(Hp, Mp)만 들어있습니다.
객체 A의 메모리 내부(0x1000)
int 자료형은 4칸(4바이트)을 차지합니다.
그래서 0x1000 ~ 0x1003까지는 Hp의 땅입니다. 그다음 0x1004 ~ 0x1007까지는 Mp의 땅입니다.
[ 메모리(Heap 영역) ]
주소: 0x1000 0x1001 0x1002 0x1003 0x1004 0x1005 0x1006 0x1007
┌───────┬───────┬───────┬───────┐ ┌───────┬───────┬───────┬───────┐
내용: │ │ │ │ │ │ │ │ │ │
│ H P │ H P │ H P │ H P │ │ M P │ M P │ M P │ M P │
│ (1/4) │ (2/4) │ (3/4) │ (4/4) │ │ (1/4) │ (2/4) │ (3/4) │ (4/4) │
└───────┴───────┴───────┴───────┘ └───────┴───────┴───────┴───────┘
▲ ▲
│ │
시작(+0) 여기부터(+4)
(A->Hp) (A->Mp)
그럼 A->Attack()은 어떻게 실행할까요?
컴파일러가 Attack() 호출 코드를 보면, Code 영역에 있는 AWarrior::Attack 함수의 주소로 점프하도록 연결해 줍니다.
정리하자면, 클래스는 코드(Code)와 데이터 구조를 정의한 설계도이고,
객체는 힙(Heap)에 할당된 변수 덩어리입니다.
2. OOP의 4대 기둥 (The 4 Pillars)
💊 2-1. 캡슐화 (Encapsulation)
-
개념:
데이터(변수)와 그 데이터를 다루는기능(함수)을 하나로 묶고,
외부에서 함부로 데이터에 접근하지 못하게 막는 것(Information Hiding)입니다. -
이유:
만약Hp가 공개(public)되어 있다면, 누군가 실수로Player.Hp = -999;라고 이상한 값을 넣을 수 있습니다.
이러면 버그를 찾기 위해 모든 코드를 뒤져야 합니다.- 데이터 보호 / 구조 은닉
- 데이터 보호 / 구조 은닉
[나쁜 예: 다 보여주는 공장]
class APlayer {
public:
int32 Hp; // 누구나 만질 수 있음 (위험!)
};
void GameLogic() {
APlayer P;
P.Hp = -5000; // 💥 사고 발생! 체력이 음수가 되면 로직이 꼬임.
}
[좋은 예: 캡슐화된 공장]
class APlayer {
private:
int32 Hp; // 외부 접근 금지 (내장 보호)
public:
// 오직 이 함수(버튼)를 통해서만 Hp를 조작할 수 있음
void TakeDamage(int32 Amount) {
Hp -= Amount;
// [안전장치] 데이터 무결성 보장
if (Hp < 0) {
Hp = 0;
Die(); // 체력이 0이 되면 자동으로 사망 처리
}
}
// 읽는 건 허용 (Getter)
int32 GetHp() const { return Hp; }
};
언리얼에서 코드를 작성할 때, 대부분의 멤버 변수는 private이나 protected로 선언하고,
블루프린트에서 접근해야 한다면 UFUNCTION(BlueprintCallable)로 접근 함수를 열어주는 것이 정석입니다.
👪 2-2. 상속 (Inheritance)
- 개념: 부모 클래스(기반 클래스)의 모든 속성과 기능을 자식 클래스(파생 클래스)가 물려받아 재사용하는 것입니다.
- 목적: 똑같은 코드를 두 번 짜지 않기 위해서입니다. (Code Reusability)
[코드 예시]
// [부모] 몬스터의 공통 특징
class AMonster {
public:
int32 Hp;
void Move() { UE_LOG(LogTemp, Log, TEXT("뚜벅뚜벅 걷는다.")); }
};
// [자식] 슬라임 (몬스터의 기능을 공짜로 물려받음)
class ASlime : public AMonster {
public:
// Move() 함수는 다시 짤 필요 없음! (이미 가지고 있음)
// 슬라임만의 기능 추가
void Jump() { UE_LOG(LogTemp, Log, TEXT("통통 뛴다!")); }
};
// [자식] 오크
class AOrc : public AMonster {
public:
void Smash() { UE_LOG(LogTemp, Log, TEXT("몽둥이로 때린다!")); }
};
ASlime을 만들면 코드에는 없지만 Hp 변수와 Move() 함수를 자동으로 가집니다.
공통 기능은 부모(AMonster)에만 수정하면 자식들에게 일괄 적용되는 강력한 유지보수성을 가집니다.
🎭 2-3. 다형성 (Polymorphism)
★ OOP에서 가장 중요하고 강력한 개념입니다(개인적인 생각).
- 개념:
같은 이름의 함수(Attack)를 호출해도,
그 객체가 무엇이냐(SwordvsGun)에 따라 실제 행동이 달라지는 것입니다.
- 같은 호출 -> 다른 행동
- 같은 호출 -> 다른 행동
-
구현:
virtual(가상 함수)과override(재정의) 키워드를 사용합니다. - 비유:
공장장(프로그래머)이 “가동!” 버튼 하나만 누르면, 용광로는 불을 뿜고, 컨베이어 벨트는 돌아가고, 로봇팔은 조립을 시작합니다.
각 기계마다 “가동”의 의미가 다르지만, 명령은 하나로 통일됩니다.
[코드 예시]
// [부모] 무기 (추상적인 개념)
class AWeapon {
public:
// "자식들아, 공격(Attack) 기능은 필수다. 내용은 너희가 정해라." - '순수 가상 함수 + 추상 클래스' 패턴도 가능
virtual void Attack() {
UE_LOG(LogTemp, Log, TEXT("무기로 공격합니다."));
}
};
// [자식 1] 칼
class ASword : public AWeapon {
public:
// 부모의 Attack을 내 방식대로 덮어씀 (Override)
virtual void Attack() override {
UE_LOG(LogTemp, Log, TEXT("칼을 휘두릅니다! 슉슉!"));
}
};
// [자식 2] 총
class AGun : public AWeapon {
public:
virtual void Attack() override {
UE_LOG(LogTemp, Log, TEXT("총을 쏩니다! 탕탕!"));
}
};
// [사용] 다형성의 힘
void PlayerUseWeapon(AWeapon* MyWeapon) {
// 이 함수는 MyWeapon이 칼인지 총인지 몰라도 됩니다.
// 그저 "공격해!"라고 명령하면, 알아서 맞는 행동을 합니다.
MyWeapon->Attack();
}
나중에 ALaserGun이 추가되어도 PlayerUseWeapon 함수는 수정할 필요가 없습니다. 이것이 유연한 코드입니다.
🧱 2-4. 추상화 (Abstraction)
- 개념:
불필요한 세부 구현(How)은 숨기고, 사용자에게는중요한 기능(What)만 심플한 인터페이스로 보여주는 것입니다. - 목적: 사용자가 내부의 복잡한 로직을 몰라도 대상을 사용할 수 있게 합니다.
- OS가 대표적인 예시
- 하드웨어의 다양한 자원들을 프로그래머나 사용자가 다루지 않아도 됨
- 하드웨어의 다양한 자원들을 프로그래머나 사용자가 다루지 않아도 됨
- OS가 대표적인 예시
[코드 예시: 자동차 운전]
운전자는 엔진의 ‘4행정 사이클(흡입-압축-폭발-배기)’을 몰라도 운전할 수 있습니다.
class ACar {
private:
// [복잡한 내부 로직] - 숨김
void InjectFuel() { /* 연료 주입 로직 */ }
void IgniteSparkPlug() { /* 점화 플러그 로직 */ }
void MovePiston() { /* 피스톤 운동 로직 */ }
public:
// [추상화된 인터페이스] - 공개
// 운전자는 이 함수만 알면 됨. 내부에서 뭘 하든 신경 안 씀.
void Drive() {
InjectFuel();
IgniteSparkPlug();
MovePiston();
UE_LOG(LogTemp, Log, TEXT("자동차가 앞으로 갑니다."));
}
};
void Driver() {
ACar MyCar;
// "기름을 넣고.. 점화를 하고.." (X) -> 이런 거 몰라도 됨
MyCar.Drive(); // "가라!" (O) -> 이것만 알면 됨
}
언리얼의 AActor 클래스는 내부에 수만 줄의 복잡한 렌더링/물리 코드가 있지만,
우리는 단순히 SetActorLocation() 함수 하나만 호출해서 물체를 이동시킵니다.
이것이 추상화의 위력입니다.
3. SOLID 원칙
1️⃣ S: 단일 책임 원칙 (SRP - Single Responsibility Principle)
“하나의 클래스는 하나의 일(책임)만 잘해야 한다.” (클래스를 수정해야 할 이유는 단 하나여야 한다)
❌ [Bad Case] God Object
APlayer 클래스가 전투와 UI 표시를 동시에 하고 있습니다.
// APlayer.cpp
void APlayer::TakeDamage(int Damage)
{
// 1. 체력 깎기 (게임 로직)
Hp -= Damage;
// 2. UI 업데이트 (시각적 연출) -> 여기가 문제!
// UI 팀원이 "체력바 색깔 좀 바꿔주세요"라고 요청하면,
// 전투 담당자가 'Player.cpp'를 열어서 고쳐야 합니다.
if (Hp < 30)
{
HealthBarWidget->SetColor(Red);
PlayLowHpSound();
}
}
💥 문제 상황
UI 팀원이 HealthBarWidget 코드를 고치다가 실수로 Hp -= Damage 줄을 지워버렸습니다.
그 결과 UI를 고쳤는데, 갑자기 플레이어가 무적이 되는 버그가 발생합니다. 로직과 연출이 섞여 있어서 서로에게 악영향을 줍니다.
✅ [Good Case] 책임 분리 (Component 패턴)
전투 담당자와 UI 담당자의 작업 공간을 분리합니다.
// 1. Player (전투 로직 담당)
void APlayer::TakeDamage(int Damage)
{
Hp -= Damage;
// UI가 뭘 하든 신경 안 씀. 그냥 "나 맞았어!"라고 방송(Delegate)만 함.
OnHpChanged.Broadcast(Hp);
}
// 2. PlayerUI (연출 담당)
void UPlayerUI::Initialize(APlayer* Player)
{
// "맞았다는 신호가 오면 UpdateHealthBar 함수를 실행해줘"라고 등록
Player->OnHpChanged.AddDynamic(this, &UPlayerUI::UpdateHealthBar);
}
void UPlayerUI::UpdateHealthBar(int NewHp)
{
// 여기서 색을 바꾸든 춤을 추든 Player 로직에는 영향 0%
if (NewHp < 30) HealthBarWidget->SetColor(Red);
}
이제 UI 코드를 아무리 망가뜨려도, 플레이어의 Hp 계산 로직은 절대 고장 나지 않습니다.
2️⃣ O: 개방-폐쇄 원칙 (OCP - Open/Closed Principle)
“새 기능을 추가할 때(Open), 기존 코드를 건드리지 마라(Closed).”
기획자가 “몬스터 처치 퀘스트”에 이어 “아이템 수집 퀘스트”, “지역 방문 퀘스트”를 계속 추가하는 상황입니다.
❌ [Bad Case] CheckQuest 함수의 비대화
퀘스트 매니저가 모든 퀘스트의 완료 조건을 직접 알고 있습니다.
// UQuestManager.cpp
void UQuestManager::CheckQuestCompletion(EQuestType Type, AActor* Target)
{
// 퀘스트 종류가 늘어날 때마다 이 함수를 계속 '수정'해야 함 (Closed 위반)
if (Type == EQuestType::KillMonster)
{
// 몬스터 처치 확인 로직 (50줄...)
}
else if (Type == EQuestType::GatherItem)
{
// 아이템 수집 확인 로직 (50줄...)
}
else if (Type == EQuestType::VisitLocation) // 새로 추가됨
{
// 지역 방문 확인 로직 (50줄...)
}
// 💥 문제: 실수로 'KillMonster' 부분의 괄호를 건드리면,
// 멀쩡하던 사냥 퀘스트까지 다 고장 남.
}
✅ [Good Case] 다형성(Polymorphism) 활용
퀘스트 매니저는 구체적인 내용은 모르고, 그냥 “점검해(Check)”라고 명령만 내립니다.
// [부모] 모든 퀘스트의 공통 규격
class UQuestBase : public UObject
{
public:
// "완료됐는지 스스로 체크해라"
virtual bool CheckCompletion(AActor* Target) { return false; }
};
// [자식 1] 처치 퀘스트 (이 파일만 새로 만듦)
class UKillQuest : public UQuestBase
{
virtual bool CheckCompletion(AActor* Target) override
{
// 타겟이 죽었는지 확인...
return true;
}
};
// [자식 2] 수집 퀘스트 (이 파일만 새로 만듦)
class UGatherQuest : public UQuestBase
{
virtual bool CheckCompletion(AActor* Target) override
{
// 인벤토리에 아이템이 있는지 확인...
return true;
}
};
// [퀘스트 매니저]
void UQuestManager::UpdateQuest(UQuestBase* CurrentQuest, AActor* Target)
{
// 매니저 코드는 퀘스트가 100개가 되든 1000개가 되든 절대 수정되지 않음!
// (수정에 닫혀 있음 Closed)
if (CurrentQuest->CheckCompletion(Target))
{
RewardUser();
}
}
- 퀘스트 베이스를 상속을 통해 ‘조건’을 알아서 클리어하도록 수정
3️⃣ L: 리스코프 치환 원칙 (LSP) - “신뢰의 문제”
핵심: “자식 클래스는 언제나 부모 클래스의 자리를 대체할 수 있어야 한다.”(자식이 부모의 약속을 어기거나, 부모인 척 사기를 치면 안 된다)
LSP 위반은 “논리적 오류”를 만듭니다. 컴파일은 되지만, 게임 로직이 깨집니다
❌ [Bad Case] 잘못된 상속으로 인한 치환
수학적으로 정사각형은 직사각형의 일종이니까, 상속받아도 될 것 같습니다.
하지만 여기서 대참사가 일어납니다.
// [부모] 직사각형 구역 (Trigger Zone)
// 약속: 가로(Width)를 바꾼다고 세로(Height)가 변하지 않는다! (독립적)
class ARectangleZone
{
protected:
float Width;
float Height;
public:
virtual void SetWidth(float W) { Width = W; }
virtual void SetHeight(float H) { Height = H; }
float GetArea() { return Width * Height; }
};
// [자식] 정사각형 구역
// 정사각형의 정의: 가로와 세로가 항상 같아야 함.
class ASquareZone : public ARectangleZone
{
public:
// 💥 사고의 원인: 부모의 메서드를 오버라이딩하면서 '부작용'을 만듦.
virtual void SetWidth(float W) override
{
// 정사각형이니까 가로를 바꾸면 세로도 같이 바뀜!
Width = W;
Height = W;
}
virtual void SetHeight(float H) override
{
// 세로를 바꾸면 가로도 같이 바뀜!
Height = H;
Width = H;
}
};
맵 디자이너는 당연히 부모(Rectangle)의 규칙대로 작동할 거라 믿고,
길쭉한 함정(100 x 5)을 만들려고 합니다.
void MakeLongTrap(ARectangleZone* Zone)
{
// 1. 가로를 100으로 설정 (현재 상태: 100 x ???)
Zone->SetWidth(100);
// 2. 세로를 5로 설정 (기대 상태: 100 x 5 = 면적 500)
Zone->SetHeight(5);
// 3. 면적 확인
// 만약 Zone이 진짜 직사각형이면 -> 500이 나옴 (정상)
// 만약 Zone이 '정사각형' 자식이라면? -> 25가 나옴 (버그!!)
// (SetHeight(5)를 하는 순간 가로인 Width도 5로 줄어들어 버림)
if (Zone->GetArea() != 500)
{
UE_LOG(LogTemp, Error, TEXT("어? 맵 에디터가 고장났네?"));
}
}
작동은 하지만, “가로와 세로는 독립적이다”라는 부모의 철칙을 자식이 마음대로 깨버렸습니다.
이로 인해 부모(Rectangle) 타입으로 코드를 짠 모든 로직이 Square가 들어오는 순간 오작동을 일으킵니다.
✅ [Good Case] 올바른 구현
이제 Square와 Rectangle은 남남입니다. 오직 IShape라는 먼 조상만 같을 뿐입니다.
// 1. [공통 조상] 도형
// "모든 도형은 면적을 구할 수 있다"는 사실만 약속합니다.
// (가로/세로 길이를 바꾸는 함수는 여기에 없습니다! 도형마다 다르니까요.)
class IShape
{
public:
virtual float GetArea() = 0;
virtual ~IShape() {} // 소멸자
};
// 2. [직사각형]
// 가로와 세로를 독립적으로 바꿀 수 있는 도형
class ARectangle : public IShape
{
protected:
float Width, Height;
public:
// 직사각형만의 고유 기능 (가로/세로 따로 설정)
void SetWidth(float W) { Width = W; }
void SetHeight(float H) { Height = H; }
virtual float GetArea() override { return Width * Height; }
};
// 3. [정사각형]
// 한 변의 길이만 있는 도형
class ASquare : public IShape
{
private:
float Side;
public:
// 정사각형만의 고유 기능 (변의 길이 설정)
// ★중요★: 이제 SetWidth, SetHeight 함수 자체가 아예 없습니다!
void SetSide(float S) { Side = S; }
virtual float GetArea() override { return Side * Side; }
};
❌ [Bad Case] 거짓말쟁이 자식
Monster는 움직일 수(Move) 있다고 정의했습니다.
그런데 움직이지 못하는 Turret(포탑)을 Monster의 자식으로 만들었습니다.
class AMonster
{
public:
virtual void Move() { /* 걷는 로직 */ }
};
class ATurret : public AMonster
{
public:
// 포탑은 못 움직임. 근데 부모가 Move를 강요하니까 어쩔 수 없이 만듦.
virtual void Move() override
{
// 💥 사고 발생!
// 누군가 Monster인 줄 알고 Move()를 시켰는데, 아무 일도 안 일어나거나 에러를 뱉음.
// "전 움직일 수 없는데요?" -> 부모의 약속(계약) 위반!
UE_LOG(LogTemp, Error, TEXT("포탑은 못 움직여요!"));
}
};
void CommandAllMonsters(TArray<AMonster*> Monsters)
{
for (auto M : Monsters)
{
M->Move(); // 포탑 차례가 되면 로직이 꼬임
}
}
✅ [Good Case] 올바른 족보 정리
움직일 수 있는 놈과 없는 놈을 족보 단계에서 분리합니다.
// 1. 최상위: 그냥 '적' (존재함)
class AEnemy : public AActor {};
// 2. 움직이는 적 (Move 가능)
class AMobileEnemy : public AEnemy
{
public:
virtual void Move() { /* ... */ }
};
// 3. 자식들 구현
class AOrc : public AMobileEnemy { /* 오크는 움직임 가능 */ };
class ATurret : public AEnemy { /* 포탑은 Move 함수 자체가 없음 */ };
// 이동 명령은 'MobileEnemy'한테만 내림 (포탑은 아예 이 배열에 못 들어옴)
void CommandMove(TArray<AMobileEnemy*> Mobs)
{
for (auto M : Mobs) M->Move();
}
4️⃣ I: 인터페이스 분리 원칙 (ISP - Interface Segregation Principle)
“안 쓰는 기능은 억지로 만들게 시키지 마라.”(인터페이스를 뚱뚱하게 만들지 말고, 잘게 쪼개라)
❌ [Bad Case] 뚱뚱한 인터페이스
게임 내의 ‘상호작용(Interaction)’을 정의합니다.
class IInteractable
{
public:
virtual void OpenDoor() = 0; // 문 열기
virtual void LootItem() = 0; // 아이템 줍기
virtual void HackPC() = 0; // 해킹하기
};
// [상황] '보물상자' 클래스를 만드는데...
class ATreasureChest : public IInteractable
{
public:
virtual void OpenDoor() override { /* 상자 열기 OK */ }
// 💥 문제: 상자는 해킹할 수 없는데?
// 인터페이스가 강요하니까 어쩔 수 없이 빈 껍데기를 만들어야 함.
virtual void HackPC() override { /* ??? 아무것도 안 함 */ }
virtual void LootItem() override { /* ??? */ }
};
✅ [Good Case] 인터페이스 다이어트
기능별로 인터페이스를 잘게 쪼갭니다.
class IOpenable { virtual void Open() = 0; };
class IHackable { virtual void Hack() = 0; };
// 보물상자는 '열기' 기능만 탑재
class ATreasureChest : public IOpenable
{
virtual void Open() override { /* ... */ }
};
// 전자 도어락은 '열기'와 '해킹' 둘 다 탑재
class AElectronicLock : public IOpenable, public IHackable
{
virtual void Open() override { /* ... */ }
virtual void Hack() override { /* ... */ }
};
5️⃣ D: 의존성 역전 원칙 (DIP - Dependency Inversion Principle)
“구체적인 것(자식/하수인)에 의존하지 말고, 추상적인 것(부모/대장)에 의존하라.”(갈아끼우기 쉽게 만들어라)
❌ [Bad Case] 구체적인 것에 의존 (하드 코딩)
전사가 RustySword(녹슨 칼)라는 특정 클래스에 딱 붙어버렸습니다.
class ARustySword { public: void Slash() {} };
class AWarrior
{
public:
// 💥 문제: 변수 타입이 '녹슨 칼'로 고정됨.
// 나중에 '전설의 검'을 얻어도, 이 변수에는 담을 수가 없음!
// 코드를 뜯어고쳐서 'ALegendarySword* MyWeapon'으로 바꿔야 함.
ARustySword* MyWeapon;
void Attack()
{
// 만약 MyWeapon이 nullptr이면? 녹슨 칼이 없으면 공격도 못 함.
MyWeapon->Slash();
}
};
✅ [Good Case] 추상적인 것에 의존 (유연함)
전사는 구체적인 칼 이름은 모르고, 그냥 “무기(Weapon)”라는 개념만 알고 있습니다.
// [추상] 무기라는 개념
class AWeapon { public: virtual void Attack() {} };
// [구체] 실제 무기들
// [ARustySword.h] - 녹슨 칼
class ARustySword : public AWeapon
{
public:
virtual void Attack() override
{
// 녹슨 칼만의 구체적인 행동
UE_LOG(LogTemp, Log, TEXT("끼이익... 녹슨 칼을 힘겹게 휘두릅니다."));
UE_LOG(LogTemp, Log, TEXT("데미지: 5"));
// PlaySound("RustSound"); // 이런 구체적인 로직들이 들어감
}
};
// [ALegendarySword.h] - 전설의 검
class ALegendarySword : public AWeapon
{
public:
virtual void Attack() override
{
// 전설의 검만의 화려한 행동
UE_LOG(LogTemp, Log, TEXT("쿠우웅! 전설의 검에서 빛이 뿜어져 나옵니다!"));
UE_LOG(LogTemp, Log, TEXT("데미지: 9999 + 화상 효과 적용"));
// SpawnEmitter("GoldenLight"); // 이펙트 재생
}
};
class AWarrior
{
public:
// "나는 무기라면 뭐든지 쥘 수 있다."
// 이제 이 변수에는 녹슨 칼, 전설의 검, 몽둥이 다 들어감.
AWeapon* MyWeapon;
void Equip(AWeapon* NewWeapon)
{
MyWeapon = NewWeapon; // 갈아끼우기(교체)가 매우 쉬워짐!
}
};
SOLID 원칙을 지키면 “코드 한 줄을 고쳤을 때,
다른 곳에서 와르르 무너지는 일”이 사라집니다.
- S (책임 분리): 클래스가 너무 뚱뚱하면 쪼개세요.
- O (확장 가능):
if-else/switch문이 길어지면 다형성을 쓰세요. - L (자식의 의무): 상속받을 때 부모의 동작을 억지로 끄지 마세요.
- I (인터페이스): 필요한 기능만 골라 쓰게 작게 나누세요.
- D (의존성): 변수 타입은 가능한 한 부모 클래스나 인터페이스로 선언하세요.
면접 준비
Q. C++ 4대 캐스팅 방법에 대한 설명해 주세요.
C++에서 제공하는 4가지 캐스팅 연산자는
타입 변환의 의도를 명확히 하고, 컴파일러나 런타임에 안전성을 보장하기 위해 사용됩니다.
기존 C언어 스타일의 캐스팅((int)variable)은 모든 변환을 허용하여 위험할 수 있으므로,
C++에서는 이 4가지를 상황에 따라 사용할 것을 권장합니다.
1. static_cast
가장 일반적인 캐스팅 방법입니다. 컴파일 타임에 타입 검사를 수행하며, 논리적으로 변환 가능한 경우에만 허용됩니다.
주요 용도:
- 기본 자료형 간의 변환 (예:
float→int,char→int) - 상속 관계에서의 업캐스팅 (자식 → 부모, 안전함)
- 상속 관계에서의 다운캐스팅 (부모 → 자식, 안전하지 않음)
특징: 런타임 성능 저하가 없습니다 (컴파일 타임에 결정).
double d = 3.14;
int i = static_cast<int>(d); // 3 (값 잘림 발생, 하지만 허용)
Parent* p = new Child();
Child* c = static_cast<Child*>(p); // 부모 -> 자식 (다운캐스팅, 책임은 프로그래머에게)
2. dynamic_cast
런타임에 상속 관계를 검사하여 안전한 형 변환인지 확인합니다.
주로 다형성(Polymorphism)을 활용할 때 사용됩니다.
주요 용도:
- 상속 관계에서의 안전한 다운캐스팅 (부모 → 자식)
특징: 런타임에 검사하므로 static_cast보다 속도가 느립니다.
#include <iostream>
#include <string>
// 1. 부모 클래스
class Animal {
public:
virtual ~Animal() {} // 가상 소멸자 (필수)
virtual void makeSound() {
std::cout << "동물 소리" << std::endl;
}
};
// 2. 자식 클래스: 개
class Dog : public Animal {
public:
void makeSound() override {
std::cout << "멍멍!" << std::endl;
}
// Dog만의 고유 기능
void wagTail() {
std::cout << "꼬리를 흔듭니다 살랑살랑~" << std::endl;
}
};
// 3. 자식 클래스: 고양이
class Cat : public Animal {
public:
void makeSound() override {
std::cout << "야옹~" << std::endl;
}
// Cat만의 고유 기능
void scratch() {
std::cout << "발톱으로 긁기!" << std::endl;
}
};
int main() {
// 부모 포인터로 자식 객체들을 가리킴 (업캐스팅 - 항상 안전)
Animal* myPet1 = new Dog();
Animal* myPet2 = new Cat();
std::cout << "--- myPet1 (실제 Dog) 캐스팅 시도 ---" << std::endl;
// 시도: myPet1을 Dog*로 변환
Dog* dogPtr = dynamic_cast<Dog*>(myPet1);
if (dogPtr != nullptr) {
// 성공! 실제로 Dog 객체였음
std::cout << "성공: 이 동물은 강아지입니다." << std::endl;
dogPtr->wagTail(); // Dog만의 함수 호출 가능
} else {
std::cout << "실패: 이 동물은 강아지가 아닙니다." << std::endl;
}
std::cout << "\n--- myPet2 (실제 Cat)를 Dog로 캐스팅 시도 ---" << std::endl;
// 시도: myPet2(실제 Cat)를 Dog*로 변환하려고 함 -> 논리적 오류
Dog* wrongPtr = dynamic_cast<Dog*>(myPet2);
if (wrongPtr != nullptr) {
wrongPtr->wagTail();
} else {
// 실패! Cat은 Dog가 아니므로 nullptr가 반환됨
std::cout << "실패: 변환 결과가 nullptr입니다. (이 동물은 강아지가 아님)" << std::endl;
}
// 메모리 해제
delete myPet1;
delete myPet2;
return 0;
}
3. const_cast
const_cast는 타입을 바꾸는 것이 아니라, const 성격만 잠시 끄거나 켜는 역할을 합니다.
주요 용도:
- 컴파일러에게 “이 변수 수정하지 않겠다고 약속(
const)했지만, 이번 한 번만 약속 깰게, 에러 내지 마.”라고 통보하는 것입니다.
특징: const_cast는 변수 자체의 메모리 보호 속성(Read-only Memory)까지 바꿀 수는 없습니다.
void LegacyLibraryFunction(int* ptr) {
*ptr = 20;
}
int main() {
int value = 10; // 1. 원래 수정 가능한 변수
const int* cPtr = &value; // 2. 포인터가 const로 가리킴 (읽기 전용)
// LegacyLibraryFunction(cPtr); // 에러! (const int* -> int* 불가능)
// 3. const를 떼고 원본(value)을 수정하도록 넘김
LegacyLibraryFunction(const_cast<int*>(cPtr));
// value는 이제 20이 됨
}
4. reinterpret_cast
reinterpret_cast는 “값은 그대로 두고, 쳐다보는 관점만 바꾸는 것”입니다.
주요 용도:
- “특정 메모리 주소를 포인터로 연결할 때” 사용합니다.
임베디드 시스템에서는 하드웨어 장치(LED, 센서 등)가 특정한 메모리 주소(예: 0xFFFF0000)에 연결되어 있습니다.
정수로 된 주소값을 포인터로 변환하여 제어해야 합니다.
// 0xFFFF0000 주소가 LED 컨트롤 레지스터라고 가정
#define LED_ADDRESS 0xFFFF0000
void TurnOnLED() {
// 단순 숫자(0xFFFF0000)를 "메모리 주소를 가리키는 포인터"로 재해석
// volatile은 컴파일러 최적화를 막기 위해 사용
volatile int* ledPtr = reinterpret_cast<volatile int*>(LED_ADDRESS);
}
댓글남기기