[말랑 퀴즈] 26/08/29 해답
카테고리: MallangQuiz
태그: Quiz
제 말랑말랑 퀴즈 생성기는 이곳에서 확인하실 수 있습니다.
말랑말랑 퀴즈 — 해답지 ✅
날짜: 2026-08-29 문제 수: 5문제 총점: 38.2/50 (76점) ✅ 잘했어요
📊 채점 결과
| 문항 | 유형 | 난이도 | 결과 | 점수 |
|---|---|---|---|---|
| Q1 | 서술형 | 🔴 어려움 | 🔺 부분 정답 | 4.5 / 10 |
| Q2 | OX | 🟡 보통 | 🔺 부분 정답 | 9.0 / 10 |
| Q3 | 객관식 | 🔴 어려움 | ⭕ 정답 | 10 / 10 |
| Q4 | 빈칸 | 🟢 쉬움 | 🔺 부분 정답 | 6.3 / 10 |
| Q5 | 서술형 | 🟡 보통 | 🔺 부분 정답 | 8.4 / 10 |
| 합계 | 38.2 / 50 |
총평
48점 → 76점. 이번 회차에서 달라진 건 점수만이 아니라 답을 쓰는 방식입니다. “모르겠다”로 넘긴 칸이 사실상 사라졌고, 대신 아는 만큼 끝까지 쓰셨습니다. 그 차이가 그대로 점수로 나왔습니다.
특히 세 곳을 짚고 싶습니다. Q2에서 O를 골랐습니다. 최근 두 회차의 OX가 연달아 X였는데 반사적으로 X를 찍지 않고 내용을 실제로 검사했고, 심지어 “260826 퀴즈에서 나왔던 내용”이라고 출처까지 연결하셨습니다. Q3은 B를 고른 데다 덧붙인 두 문장이 정확히 오답 A와 D를 무력화합니다 — “최초 복제 이후 휴면에 들어간다”는 A를, “채널이 닫히더라도 파괴하라는 정보는 보낼 수 있다”는 D를 정면으로 반박합니다. 찍은 게 아니라 판단한 답입니다. Q5(1)은 2πr·dr까지 직접 유도하셨습니다. 근거를 수식으로 세운 답은 이번이 처음입니다.
약한 곳은 Q1(swap)입니다. (1)에서 복사 3번의 정체를 정확히 짚고 “pImpl만 바꾸면 된다”는 결론까지 맞혔는데, (2)~(4)는 흔들렸습니다. 이 항목은 “어디서 막히느냐”가 단계마다 다르다는 게 핵심이라, 아래 피드백에 세 단계를 표로 정리해 두었습니다.
Q4는 ①~⑤를 전부 맞히고 ⑥⑦⑧에서 놓쳤는데, 세 칸 모두 해결책 부분입니다. “왜 위험한가”는 잡혀 있고 “그래서 어떻게 하는가”가 비어 있으니, 복습은 그 절반만 하면 됩니다.
Q1. 🔴 어려움 — 🔺 4.5/10
문제: 표준 라이브러리의 std::swap은 대략 아래처럼 구현되어 있다.
namespace std {
template<typename T>
void swap(T& a, T& b) {
T temp(a);
a = b;
b = temp;
}
}
그런데 실제 데이터를 다른 객체에 두고 포인터만 들고 있는 타입(pimpl 관용구)에서는 이 구현이 손해다.
class WidgetImpl {
private:
int a, b, c;
std::vector<double> v; // 무거운 데이터
};
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) {
*pImpl = *(rhs.pImpl); // 가리키는 실체까지 통째로 복사한다
return *this;
}
private:
WidgetImpl* pImpl;
};
(1) std::swap(w1, w2)가 Widget 두 개를 맞바꿀 때 어떤 복사가 몇 번 일어나는지 세어 보고, 사실 무엇만 바꿔주면 충분한지 쓰시오.
(2) Widget이 클래스 템플릿이 아닐 때, template<>로 std::swap을 완전 특수화한 코드가 컴파일되지 않는 이유는? 해결하려면 무엇을 추가하고 특수화 버전을 어떻게 바꿔야 하는가?
(3) Widget이 클래스 템플릿일 때, 시도 A(부분 특수화)와 시도 B(std 안에서 오버로딩)는 각각 왜 안 되는가? 올바른 방법은?
(4) std::swap(obj1, obj2); 로 호출하면 우리가 만든 swap이 불리지 않는 이유는? 올바른 호출 형태와 탐색 순서는?
📝 내 풀이:
(1) 복사 생성자, 복사 대입 생성자, 복사 대입 생성자
pImpl만 바꿔주면 충분하다.
(2) 네임스페이스 내에서 템플릿 특수화가 불가능했던 것으로 알고 있지만, 왜 그랬었는지는 정확히 기억나지 않는다. 해결하기 위해서는 네임스페이스 내부에 쓰는 것이 아닌, Widget 내에 friend로 doSomething을 선언한다. 그리고, 비멤버함수로서 std::swap을 호출한다.
(3) 네임스페이스 내에서 템플릿을 새로 정의하고 있기 때문이며, 2번의 방법을 사용한다.
(4) using namespace std::swap; 을 해준다? 정확히 기억이 안난다.
정답:
(1) 복사 3번 × 두 겹 = 낭비
std::swap의 기본 구현은 T temp(a); a = b; b = temp; 이므로 복사가 총 3번 일어난다. 그런데 T가 Widget이면 이 3번의 복사가 각각 pImpl이 가리키는 실체까지 통째로 복사한다(operator=가 *pImpl = *(rhs.pImpl)이므로).
- Widget 객체 3개 복사
- 그에 딸려 WidgetImpl 객체 3개 복사 —
vector<double>같은 무거운 데이터가 세 번 복사된다
하지만 두 Widget을 맞바꾸는 데 실제로 필요한 것은 pImpl 포인터 두 개를 서로 바꾸는 것뿐이다. 실체는 그대로 두고 가리키는 손가락만 바꾸면 된다.
(2) 컴파일되지 않는 이유 — pImpl이 private이기 때문
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
swap(a.pImpl, b.pImpl); // ❌ pImpl은 Widget의 private 멤버
}
}
특수화 자체(template<>를 이용한 완전 템플릿 특수화)는 문법적으로 문제가 없다. 문제는 이 함수가 Widget의 private 멤버 pImpl에 접근한다는 점이다.
해결책은 Widget에 public 멤버 함수 swap을 추가하고, std::swap의 특수화 버전은 그 멤버 함수를 호출하기만 하는 것이다.
class Widget {
public:
void swap(Widget& other) { // ✅ public 멤버 swap
using std::swap;
swap(pImpl, other.pImpl); // 내부라서 private 접근 가능
}
};
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
a.swap(b); // ✅ 멤버 swap에 위임
}
}
이 형태는 안전하고 표준 위반도 아니다.
(3) 클래스 템플릿일 때 — 두 시도가 안 되는 이유
시도 A — 함수 템플릿의 부분 특수화는 C++이 허용하지 않는다.
template<typename T>
void swap<Widget<T>>(Widget<T>& a, Widget<T>& b) { a.swap(b); } // ❌ 컴파일 에러
std::swap은 함수 템플릿이고 Widget은 클래스 템플릿이다. C++은 클래스 템플릿에 대해서는 부분 특수화를 허용하지만, 함수 템플릿에 대한 부분 특수화는 허용하지 않는다. 그래서 컴파일 에러가 난다.
시도 B — 컴파일은 되지만 표준 위반이고 결과는 미정의(UB)다.
namespace std {
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) { a.swap(b); } // ⚠️ 컴파일은 됨
}
이건 특수화가 아니라 std 네임스페이스에 새로운 템플릿을 추가하는 오버로딩이다. std 안의 템플릿을 완전 특수화하는 것은 허용되지만, std에 새 템플릿을 추가하는 것은 허용되지 않는다. 결과는 미정의 사항(UB)이다.
올바른 방법 — Widget과 같은 네임스페이스에 비멤버 swap을 오버로딩한다.
namespace WidgetStuff {
template<typename T> class WidgetImpl { ... };
template<typename T>
class Widget {
public:
void swap(Widget& other) {
using std::swap;
swap(pImpl, other.pImpl);
}
private:
WidgetImpl<T>* pImpl;
};
// ✅ std가 아니라 WidgetStuff 안에 둔다
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) { a.swap(b); }
}
이렇게 두면 인자 기반 탐색(ADL) 에 의해 swap(a, b) 호출 시 이 함수가 선택된다.
(4) std::swap(obj1, obj2); 가 안 되는 이유 — 네임스페이스를 못 박아 버렸다
std::를 앞에 붙이면 네임스페이스가 강제되어 오직 std::swap만 후보가 된다. ADL이 개입할 여지가 사라지므로, 우리가 WidgetStuff에 만들어 둔 사용자 정의 swap은 절대 선택되지 않는다.
올바른 호출 형태는 다음과 같다.
template<typename T>
void doSomething(T& obj1, T& obj2) {
using std::swap; // ✅ std::swap을 후보에 "포함"만 시킨다
swap(obj1, obj2); // ✅ 네임스페이스 없이 호출
}
이렇게 쓰면 컴파일러는 아래 순서로 swap을 찾는다.
- 전역 유효범위 또는 타입
T와 같은 네임스페이스에T전용 swap이 있는지 찾는다. (ADL) - 없으면 std 네임스페이스에서,
T에 대한std::swap특수화 버전을 찾고,- 그것도 없으면
std::swap일반 버전을 쓴다.
즉 using std::swap;은 “std 버전을 쓰겠다”는 선언이 아니라 “std 버전도 후보에 넣되, 더 좋은 게 있으면 그걸 쓰라” 는 뜻이다.
덧붙임: 멤버 swap은 예외를 던지면 안 된다. swap이 강력한 예외 안전성의 기반이 되기 때문이며, 포인터 같은 기본 제공 타입만 맞바꾸면 자연히 예외가 나지 않는다.
🔍 피드백: 논점별로 (1) 2.0 / (2) 0.5 / (3) 1.0 / (4) 1.0을 받아 4.5점입니다.
(1)이 가장 좋았습니다. T temp(a) = 복사 생성자, a = b = 복사 대입, b = temp = 복사 대입 — 세 연산의 정체를 하나도 틀리지 않고 짚었고, “pImpl만 바꿔주면 충분하다”는 결론도 정확합니다. 한 가지만 더 붙이면 만점이었습니다. 그 복사 3번이 각각 pImpl이 가리키는 실체까지 통째로 복사한다는 점 — operator=가 *pImpl = *(rhs.pImpl)이므로, 실제로는 Widget 3개 + WidgetImpl 3개가 복사됩니다. vector<double>이 세 번 복사되는 게 진짜 손해입니다.
(2)에서 방향이 어긋났습니다. “네임스페이스 내에서 템플릿 특수화가 불가능하다”고 하셨는데, 반대입니다 — std 안의 템플릿을 완전 특수화하는 것은 허용됩니다. 그래서 template<>로 쓴 그 코드 자체는 문법상 정당합니다. 컴파일이 막히는 이유는 훨씬 단순합니다. pImpl이 Widget의 private 멤버라서 바깥 함수가 건드릴 수 없는 겁니다.
friend를 떠올리신 건 “접근 권한 문제”라는 방향은 잡으셨다는 뜻입니다. 다만 이 항목이 택한 해법은 friend가 아니라 Widget에 public 멤버 swap을 두고, std::swap 특수화는 a.swap(b) 한 줄만 호출하는 것입니다. private에 접근할 권한을 밖으로 내주는 대신, 접근이 필요한 코드를 클래스 안으로 들여놓는 방식입니다.
(3)에서 시도 B는 정확히 맞혔습니다. “네임스페이스 내에서 템플릿을 새로 정의하고 있기 때문”이 바로 B가 UB인 이유입니다 — std에 새 템플릿을 추가하는 것은 표준 위반입니다. 놓친 건 시도 A입니다. A는 swap<Widget<T>> 형태로 함수 템플릿을 부분 특수화하려는 건데, C++은 클래스 템플릿의 부분 특수화만 허용하고 함수 템플릿의 부분 특수화는 허용하지 않습니다. 그래서 아예 컴파일 에러입니다. 두 시도가 실패하는 지점이 다르다는 게 이 소문항의 핵심이었습니다 — A는 컴파일도 안 되고, B는 컴파일은 되지만 UB입니다.
(4)는 핵심을 거의 맞히셨습니다. using 선언으로 std::swap을 끌어와야 한다는 발상이 정답입니다. 문법만 using namespace std::swap;이 아니라 using std::swap; 입니다(using namespace는 네임스페이스 전체를, using은 이름 하나를 가져옵니다). 자신 없다고 쓰셨지만 내용이 맞았으니 감점하지 않았고, 대신 이유가 비어 있어 만점은 아닙니다. 이유는 이렇습니다 — std::를 붙여 버리면 네임스페이스가 강제되어 std::swap만 후보가 되고, ADL이 끼어들 자리가 사라집니다. using std::swap;은 “std 버전을 쓰겠다”가 아니라 “std 버전도 후보에 넣되, 더 나은 게 있으면 그걸 쓰라” 는 뜻입니다.
정리 — 이 항목은 단계마다 막히는 지점이 다릅니다.
| 상황 | 막히는 지점 | 해법 |
|---|---|---|
| 클래스 템플릿 아님 | pImpl이 private |
public 멤버 swap 추가 → std::swap 완전 특수화가 그걸 호출 |
| 클래스 템플릿임 | 함수 템플릿은 부분 특수화 불가(A), std에 새 템플릿 추가 금지(B) | 같은 네임스페이스에 비멤버 swap 오버로딩 → ADL이 찾아 준다 |
| 호출하는 쪽 | std::를 붙이면 ADL이 죽는다 |
using std::swap; 후 swap(a, b) 로 호출 |
출처:
game_dev/cpp/chapter4/2025-07-02-cpp_4_25.md
Q2. 🟡 보통 — 🔺 9.0/10
문제: 다음 명제가 참(O)인지 거짓(X)인지 판단하라.
복사 생성자와 복사 대입 연산자를 묶어 객체 복사 함수라 부른다. 컴파일러는 이 둘을 자동으로 생성해 주지만 그 복사는 멤버별 얕은 복사이므로, 포인터나 리소스를 쥐고 있는 클래스에서는 직접 정의해야 한다.
그런데 이 둘을 직접 정의할 때, 중복을 줄이겠다고 아래처럼 한쪽에서 다른 쪽을 호출하면 안 된다.
Customer::Customer(const Customer& rhs) { *this = rhs; // 복사 생성자 안에서 복사 대입 연산자 호출 }복사 생성자는 아직 초기화되지 않은 객체를 만들고 있는 중인데, 복사 대입 연산자는 이미 초기화가 끝난 객체를 전제로 동작하는 함수이기 때문이다. 반대 방향(복사 대입 연산자 안에서 복사 생성자를 호출하는 것)도 마찬가지로 말이 안 된다.
중복을 없애고 싶다면, 두 함수가 서로를 호출하는 대신 공통 코드를 별도의 private 함수로 분리해 양쪽에서 함께 호출하면 된다.
📝 내 답: O — 260826 퀴즈에서 나왔던 내용으로, 공통 코드를 InitCopy(rhs)의 형태로 호출하여 양쪽에서 함께 호출해주었다.
정답: O
해설:
명제의 모든 문장이 참이다.
- 객체 복사 함수 — 복사 생성자와 복사 대입 연산자를 묶어 부르는 이름이 맞다. 컴파일러가 자동 생성해 주지만 그 복사는 멤버별 얕은 복사라서, 포인터·리소스를 쥐거나 복사 방식이 중요한 클래스에서는 직접 정의해야 한다. ✅
- 서로 호출하면 안 되는 이유 — 복사 생성자는 아직 초기화되지 않은 객체를 만드는 중이고, 대입 연산자는 이미 초기화가 끝난 객체를 전제로 동작한다. 그러니 복사 생성자 안에서
*this = rhs;를 부르는 것은 아직 만들어지지도 않은 객체에 대입을 수행하는 위험한 행동이다. 반대 방향도 마찬가지로 말이 안 된다(이미 존재하는 객체를 다시 생성하려는 셈이다). ✅ - 올바른 중복 제거 — 두 복사 함수가 서로를 호출하는 대신, 공통 동작을 별도의 private 함수로 분리해 양쪽에서 함께 호출한다. ✅
class Customer {
public:
Customer(const Customer& rhs) {
logCall("복사 생성자");
copyFrom(rhs);
}
Customer& operator=(const Customer& rhs) {
logCall("복사 대입 연산자");
if (this != &rhs) { // 자기 대입 방어
copyFrom(rhs);
}
return *this;
}
private:
void copyFrom(const Customer& rhs) { // ✅ 공통 코드를 여기 모은다
name = rhs.name;
lastTransaction = rhs.lastTransaction;
}
std::string name;
Date lastTransaction;
};
이렇게 하면 중복도 줄고, 멤버가 늘어날 때 고칠 곳이 copyFrom 한 군데로 모여 “객체의 모든 부분을 빠짐없이 복사” 하기가 쉬워진다.
주의: 이 명제는 참이다. OX 문제에서 “긴 지문 + 그럴듯한 결론”이라고 무조건 거짓인 것은 아니다. 문장 하나하나를 실제로 검사해야 한다.
🔍 피드백: 판단 7점 만점, 근거 3점 중 2.0점으로 9.0점입니다.
이 문항이 이번 회차의 진짜 성과입니다. 최근 두 회차의 OX가 연달아 “앞 문단은 참, 마지막 문장만 거짓”이라 정답이 X였습니다. 그래서 이번엔 일부러 전부 참인 명제를 냈습니다 — 패턴만 외워 반사적으로 X를 찍으면 틀리도록요. 여기서 O를 고르셨다는 건 명제를 문장 단위로 실제로 검사했다는 뜻입니다. 게다가 “260826 퀴즈에서 나왔던 내용”이라고 이전 회차와 연결까지 하셨습니다. 함수 이름을 InitCopy로 기억하신 것도 문제없습니다 — 이름은 무엇이든 상관없고 공통 코드를 private 함수 하나로 모은다는 구조가 핵심이니까요.
근거에서 1점 깎인 이유는, 인용하신 부분이 명제의 마지막 문단(해결책) 이기 때문입니다. 판정의 중심축은 그 앞 문단 — “왜 서로 호출하면 안 되는가” 였습니다. 한 줄만 더하면 만점이었습니다.
복사 생성자는 아직 초기화되지 않은 객체를 다루고, 복사 대입 연산자는 이미 초기화가 끝난 객체를 전제로 동작한다. 그래서 서로를 호출하면 전제가 깨진다.
이 한 문장이 26/08/26 회차 Q2(4)에서 0점이었던 부분입니다. 이제 해결책 쪽은 확실히 잡으셨으니, 이유 쪽만 붙이면 이 항목은 완결됩니다.
출처:
game_dev/cpp/chapter2/2025-06-08-cpp_2_12.md
Q3. 🔴 어려움 — ⭕ 10/10
문제: UE5에서 바닥에 뿌려지는 픽업 액터가 수백 개다. 매 프레임 복제 후보에 오르지 않게 하려고 아래처럼 설정했다.
NetDormancy = DORM_Initial;
SetNetCullDistanceSquared(25000000.f); // 5000cm의 제곱
SetNetUpdateFrequency(1.f);
- 아직 한 번도 본 적 없는 픽업 → 5000cm 밖에서는 클라이언트에 아예 존재하지 않는다.
- 한 번 본 적 있는 픽업 → 멀리 걸어가도 클라이언트 화면에서 사라지지 않고 남아 있다.
두 번째 현상은 버그가 아니라 이 설정이 노린 결과다. 그 이유에 대한 설명으로 가장 올바른 것은?
// UActorChannel::CleanUp
else if (Dormant && (CloseReason == EChannelCloseReason::Dormancy) && !Actor->GetTearOff())
{
Connection->Driver->ClientSetActorDormant(Actor); // ★ 액터를 살려둔다
bWasDormant = true;
}
else if (...)
{
// Destroy the actor ← 그 외의 이유면 클라에서 파괴
}
- A.
DORM_Initial은 이름 그대로 “초기 휴면”이므로 스폰된 순간부터 휴면 상태다. 복제가 애초에 일어나지 않으니 서버가 액터를 지워도 클라이언트는 그 사실을 모르고, 그래서 화면에 남는다. - B. 결과를 가르는 것은 “채널이 닫혔는가”가 아니라 “왜 닫혔는가” 다. 릴러번시(멀어져서)로 닫히면 클라이언트에서 액터가 파괴되지만, 휴면(보낼 걸 다 보내서)으로 닫히면
ClientSetActorDormant로 액터가 유지된다. 픽업은 릴러번시로 닫히기 전에 휴면으로 먼저 닫히므로 화면에 남고, 그동안 대역폭은 0이다. - C. 휴면 상태가 되면 서버가 그 액터를 복제 후보 목록에서 통째로 빼 버리므로 릴러번시 검사 자체에 도달하지 못한다. 클라이언트는 “멀어졌다”는 통보를 받을 기회가 없어서 액터를 지우지 못하는 것이다.
- D. 휴면 중에는 채널이 없어서 “이 액터가 파괴됐다”는 정보를 보낼 수단이 없다. 그래서 다른 플레이어가 아이템을 주워도 내 화면에는 픽업이 계속 남으며, 이를 막으려면 상태가 바뀔 때마다
FlushNetDormancy()를 호출해야 한다.
📝 내 선택: B — DORM_Initial은 최초 복제 이후, 휴면에 들어간다. 채널이 닫히더라도 파괴하라는 정보는 보낼 수 있다.
정답: B
해설:
핵심은 채널이 닫혔다는 사실이 아니라, 닫힌 이유다. 엔진의 UActorChannel::CleanUp이 그 분기를 그대로 보여 준다.
| 닫힌 이유 | 클라이언트 결과 |
|---|---|
Relevancy (멀어져서) |
액터 파괴. 다시 가까이 가면 채널 재생성 + 초기 번들 재전송 |
Dormancy (다 보내서) |
액터 유지. 채널만 닫히고 화면엔 그대로 |
픽업은 릴러번시로 닫히기 전에 휴면으로 먼저 닫힌다. 그래서 한 번 본 픽업은 멀어져도 화면에 남고, 그동안 대역폭은 0이다. 이 설정이 없으면 이 액터는 릴러번시 경로로 가서 멀어질 때마다 사라졌다 나타나고, 그때마다 초기 번들을 다시 받는다. 팝핑과 대역폭이 여기서 갈린다.
정리하면 두 현상이 한 문장으로 설명된다.
- 아직 못 본 픽업 → 채널이 없으니 릴러번시 검사를 받고, 5000cm 밖이면 클라에 존재하지 않는다 (컬링이 작동)
-
한 번 본 픽업 → 채널은 닫혔지만 액터는 살아 있고 대역폭은 0
- A —
DORM_Initial의 이름에 속은 답이다.DORM_Initial의 특별 취급은 맵에 배치된 액터(IsNetStartupActor())에만 적용된다. 스폰된 픽업은 여기 걸리지 않아 정상 네트워크 액터 리스트로 들어가고,ShouldActorGoDormant가!Channel이면 false를 반환하므로 채널이 열려 한 번 복제된 뒤에야 휴면이 시작된다. 즉 “초기 1회 복제 후 휴면”이라는 결과는 맞지만DORM_Initial이라서가 아니라, 동적 스폰이라DORM_Initial이 적용되지 않기 때문이다. (그래서 여기서는DORM_DormantAll을 써도 동작이 같다.) 또한 “복제가 애초에 일어나지 않는다”면 클라에 액터가 보일 리도 없다. - C — 서버 루프 위치로 설명하려 들면 틀린다. 휴면으로 채널이 닫히면 다음 패스에서
if (!Channel)분기에 다시 들어가므로 릴러번시 검사에는 도달한다. 클라에서 안 사라지는 이유는 서버가 검사를 건너뛰어서가 아니라, 클라가 이미ClientSetActorDormant로 “유지” 판정을 받았기 때문이다. - D — 엔진이 그 경우를 따로 처리한다. 휴면이거나 최근 휴면이었던 액터에 대해서는 별도의 destruction info 경로로 파괴를 전달한다. (“…even though the client knew about the actor at some point, it doesn’t have a channel to handle destruction.”) 채널이 한 번도 열린 적 없는 클라에는 아무것도 보내지 않는데, 그 클라는 애초에 액터를 모르므로 무해하다. 그리고 이 픽업에서 복제되는 값은
ItemId하나뿐이고 스폰 시점에 정해져 바뀌지 않으므로FlushNetDormancy()를 부를 일 자체가 없다.
덧붙임 — 단위 함정.
NetCullDistanceSquared는 이름 그대로 제곱값이다. 기본값이225000000(= 15000cm의 제곱)인데 여기에5000을 그대로 넣으면 컬링 거리가 √5000 ≈ 70cm가 되어 픽업이 코앞에서만 보인다.
🔍 피드백: 만점입니다. 그런데 이 문항에서 정말 좋았던 건 정답을 맞힌 것보다 덧붙인 두 문장입니다.
- “DORM_Initial은 최초 복제 이후, 휴면에 들어간다” → 보기 A의 “스폰된 순간부터 휴면이라 복제가 애초에 일어나지 않는다”를 정면으로 반박합니다.
- “채널이 닫히더라도 파괴하라는 정보는 보낼 수 있다” → 보기 D의 “파괴 정보를 보낼 수단이 없다”를 정면으로 반박합니다.
네 보기 중 세 개를 근거를 갖고 배제하고 남은 하나를 고른 겁니다. 찍어서 맞힌 답과 판단해서 맞힌 답은 다르고, 이건 명백히 후자입니다.
한 가지만 정교하게 다듬을 여지가 있습니다. “최초 복제 이후 휴면에 들어간다”는 결과는 정확한데 이유가 미묘하게 다릅니다. 스폰된 액터는 IsNetStartupActor()가 false라 DORM_Initial의 특별 취급을 아예 받지 않고, ShouldActorGoDormant가 !Channel이면 false를 반환하기 때문에 채널이 한 번 열린 뒤에야 휴면이 시작되는 겁니다. 즉 DORM_Initial이라서가 아니라 DORM_Initial이 적용되지 않아서 그렇게 되는 것이고, 그래서 여기서는 DORM_DormantAll로 바꿔 써도 동작이 같습니다. 객관식이라 감점은 없지만, 이름이 동작을 설명해 주지 않는 대표적인 사례라 기억해 둘 만합니다.
출처:
game_dev/devlog/2026-08-02-EP_Loot-2.md
Q4. 🟢 쉬움 — 🔺 6.3/10
문제: 빈칸을 채우시오.
객체를 생성할 때는 ① ___ 클래스의 생성자가 먼저 호출된다. 이 시점에는 ② ___ 클래스 부분이 아직 초기화되지 않은 상태이므로, 생성자 안에서 호출한 가상 함수는 ③ ___ 클래스의 버전으로 동작한다.
소멸할 때는 순서가 반대라서 ④ ___ 클래스 쪽부터 소멸된다. 따라서 소멸자 안에서 가상 함수를 호출해도 마찬가지로 ⑤ ___ 클래스의 버전이 불린다.
한편 비가상 함수 init()을 한 번 거쳐서 호출하면 눈에 잘 띄지 않지만, ⑥ ___ 호출일 뿐 위험은 똑같다.
해결책은 가상 함수를 호출하는 대신, 필요한 정보를 ⑦ ___ 로 전달해 비가상 함수로 처리하는 것이다. 이때 파생 클래스는 그 정보를 만드는 함수를 ⑧ ___ 함수로 둔다.
📝 내 답: 기본, 파생, 기본, 파생, 기본, ?, 파생 클래스, 비멤버 함수
정답:
① 기본 ② 파생 ③ 기본 ④ 파생 ⑤ 기본 ⑥ 간접 ⑦ 생성자 매개변수(생성자 인자) ⑧ 정적(static)
해설:
생성자는 기본 클래스 → 파생 클래스 순서로 호출된다. 기본 클래스 생성자가 도는 시점에는 파생 클래스 부분이 아직 초기화되지 않았으므로, 이때 가상 함수를 부르면 기본 클래스 버전이 동작한다.
소멸자는 그 반대로 파생 클래스 → 기본 클래스 순서다. 기본 클래스 소멸자가 돌 시점이면 파생 클래스 부분은 이미 사라진 뒤이므로, 여기서도 기본 클래스 버전이 불린다.
위험한 이유는 단순히 “엉뚱한 함수가 불린다”가 아니다. 생성자 시점에 파생 클래스 멤버는 아직 초기화되지 않았는데 그 상태로 파생 클래스의 가상 함수가 돌면 정의되지 않은 동작(UB) 이며, 심하면 비어 있는 포인터 접근이나 프로그램 종료로 이어진다.
⑥ 간접 — init() 같은 비가상 함수를 한 번 거치면 코드만 봐서는 잘 안 보인다. “알면서도 실수”가 아니라 “모르고도 실수” 할 수 있는 형태라 더 위험하다.
⑦⑧ 해결책 — 가상 함수를 호출하는 대신 필요한 정보를 생성자 매개변수로 넘겨 받아 비가상 함수로 처리한다. 파생 클래스는 그 정보를 만드는 함수를 정적(static) 함수로 두는데, 정적 함수는 객체의 상태를 필요로 하지 않으므로 “아직 초기화되지 않은 멤버를 건드릴” 위험이 없기 때문이다.
class Transaction {
public:
explicit Transaction(const std::string& logInfo) {
logTransaction(logInfo); // ✅ 비가상 함수 호출
}
void logTransaction(const std::string& logInfo) const; // 비가상
};
class BuyTransaction : public Transaction {
public:
BuyTransaction(/* parameters */)
: Transaction(createLogString(/* parameters */)) { // ✅ 정적 함수로 만들어 넘김
}
private:
static std::string createLogString(/* parameters */); // ✅ static
};
🔍 피드백: 빈칸 8개 중 ①②③④⑤를 전부 맞혀 6.3점입니다. 생성자·소멸자의 호출 순서와 그 결과는 완전히 잡혀 있습니다.
놓친 ⑥⑦⑧이 공교롭게도 전부 “그래서 어떻게 하는가” 쪽입니다. 위험을 진단하는 절반은 확실하고, 처방하는 절반이 비어 있는 상태라 복습 범위가 명확합니다.
- ⑥ 간접 — 문장에 “비가상 함수
init()을 한 번 거쳐서 호출하면 눈에 잘 띄지 않지만”이라는 단서가 있었습니다. 직접 부르지 않고 한 다리 건너 부르는 것이니 간접 호출입니다. 이 항목이 특별히 강조하는 지점이기도 합니다 — 직접 호출은 “알면서도 실수”지만,init()을 낀 간접 호출은 “모르고도 실수” 할 수 있어서 더 위험합니다. - ⑦ 생성자 매개변수 — “파생 클래스”라고 쓰셨는데, 빈칸이 있는 문장은 “필요한 정보를 ⑦ 로 전달해” 였습니다. 정보를 담아 나르는 통로를 묻는 자리입니다. 가상 함수로 “파생 클래스에게 물어보는” 대신, 파생 클래스가 미리 만들어서 기본 클래스 생성자에 인자로 넘겨 주는 것이 이 항목의 해법입니다 —
Transaction(createLogString(params))처럼요. - ⑧ 정적(static) 함수 — “비멤버 함수”라고 쓰셨는데 정답은 static 함수입니다. 이유가 중요합니다. 그 함수가 호출되는 시점은 기본 클래스 생성자의 인자를 만드는 때, 즉 객체가 아직 아무것도 초기화되지 않은 순간입니다. 정적 함수는 객체 상태를 전혀 필요로 하지 않으므로 미초기화 멤버를 건드릴 위험이 없습니다. (비멤버 함수도 그 점은 같지만, 클래스 내부 정보를 쓰면서 상태는 안 쓰겠다는 의도를 드러내려면
private static이 적절합니다.)
한 줄 요약: 생성자/소멸자에서 가상 함수로 “물어보지” 말고, 필요한 정보를 static 함수로 미리 만들어 생성자 인자로 넘겨받아 비가상 함수로 처리한다.
출처:
game_dev/cpp/chapter2/2025-05-29-cpp_2_9.md
Q5. 🟡 보통 — 🔺 8.4/10
문제: FPS 게임에서 산탄총 스프레드를 아래처럼 구현했더니 탄착군이 가운데가 비고 바깥에 몰리는 도넛 모양으로 나왔다.
const float R = FMath::FRand(); // 반경비율 0~1 균등
const float Theta = R * HalfAngle;
const float Phi = FMath::FRand() * TWO_PI; // 각도 0~2π 균등
(1) 왜 이런 모양이 나오는가? 반경 r인 원에서 고리 하나의 넓이가 r에 따라 어떻게 변하는지를 근거로 설명하시오.
(2) sqrt(FRand())로 면적 균등을 만들면 해결되는데, 왜 √r 보정을 정답으로 삼지 않고 커브 데이터로 뺐는가?
(3) ㉠ 커브와 ㉡ 256칸 테이블은 확률론에서 각각 무엇인가? ㉢ 균등 난수 U 하나로 어떻게 원하는 분포가 나오는가? ㉣ BeginPlay에서 1회만 테이블을 굽는 이유는?
📝 내 풀이:
(1) r에 따른 고리 하나의 넓이는 2πr*dr이다.
r == 0: 0 / r == 1: 2πdr / r == 2: 4πdr / r == 3: 6πdr
즉 반경 r이 점점 커질수록 고리의 넓이도 증가하므로, 상대적으로 바깥으로 몰릴 확률이 늘어나게 된다.
(2) 구현에는 정답이 없으며, 디자이너가 코드를 볼 필요없이 커브 데이터만 변경함으로써 분포를 조절할 수 있도록 하기 위함이었다.
(3) ㄱ: PDF, CDF
ㄷ: PDF를 정규화된 CDF(누적 밀도 함수)로 바꾼 뒤, 256칸으로 X축 적분한다. 그러면 X축: U[0,1], Y축: R[0,R]인 그래프가 완성되어 의도된 바를 구할 수 있게 된다.
ㄹ: 매 발사 때 적분하면 샷건 펠릿 탄환마다 적분을 하게 되기 때문이다.
정답:
(1) 반경 균등이지 면적 균등이 아니기 때문
반경 r인 원에서 고리 하나의 넓이는 r에 비례해 커진다.
r=0.1 근처 고리 : 좁다
r=0.9 근처 고리 : 9배 넓다
그런데 FRand()는 0.1도 0.9도 같은 확률로 뽑는다. 결과적으로 넓은 바깥 고리와 좁은 중심 고리에 같은 수의 탄이 떨어진다. 넓이당 밀도로 보면 바깥이 훨씬 성겨야 하는데 개수가 같으니, 눈에는 “바깥에 몰린다” 로 보인다.
정확히 말하면 이 구현은 면적 균등이 아니라 반경 균등이라 시각적 분포가 의도와 다른 것이다. 게임에서 원하는 건 보통 중심 집중인데, 기본 구현은 오히려 그 반대에 가깝다.
(2) √r 보정도 “정답”은 아니기 때문 — 면적 균등 역시 하나의 분포일 뿐이다
sqrt(FRand())를 쓰면 면적 균등이 된다. 하지만 면적 균등이 우리가 원하는 분포라는 보장이 없다.
무기마다 원하는 탄착군이 다르다. 소총은 중심 집중, 산탄총은 약간 퍼짐, 특수 무기는 도넛형일 수도 있다. √r은 그중 딱 한 가지(면적 균등) 만 표현한다. 그래서 코드에 분포를 박아 넣는 대신 분포 자체를 데이터로 만들어, 무기 에셋마다 디자이너가 커브로 정하게 했다.
(3) 역변환 샘플링(inverse transform sampling)
- ㉠ 커브 = PDF(확률 밀도 함수). 디자이너가 그리는 “X축 = 반경비율, Y축 = 상대 확률” 곡선이 곧 확률 밀도다. 정규화하므로 Y값의 절댓값은 무관하다 — Y=2와 Y=200이 같은 결과를 낸다. 디자이너가 스케일을 신경 쓸 필요가 없다.
- ㉡ 256칸 테이블 = CDF(누적 분포 함수). 커브를 사다리꼴 적분해 누적합을 만들고 정규화한 것이다.
㉢ 균등 난수 하나로 원하는 분포가 나오는 원리
PDF (커브가 그리는 것) CDF (적분한 것)
↑ ↑ 1.0
│╲ │ ╱───
│ ╲ │ ╱
│ ╲___ │╱
└──────→ r └──────→ r
↑
균등 난수 U를 Y축에서 찍고 ─┘ 대응하는 r을 읽으면
그 r은 PDF를 따르는 분포가 된다
균등 난수 U(0~1)를 CDF의 Y축에서 찍고, 그에 대응하는 r을 읽는다(테이블에서 이진 탐색). 이때 CDF의 기울기가 가파른 구간, 즉 PDF가 높은 구간이 Y축을 더 넓게 차지하므로 더 자주 뽑힌다. 균등하게 뽑은 U가 자동으로 커브 모양의 분포로 변환되는 것이다. 이게 역변환 샘플링의 전부다.
㉣ 1회만 굽는 이유 — 매 발사마다 적분하면 낭비이기 때문
커브를 적분해 CDF를 만드는 일은 발사마다 결과가 달라지지 않는다. 그래서 BeginPlay에서 한 번만 256칸으로 구워 두고, Fire()에서는 이진 탐색만 한다. 256칸이면 8회 비교면 끝나므로 산탄총 10펠릿이어도 무시할 비용이다.
덧붙임: 커브가 없는 기존 무기 에셋에는
FMath::FRand()로 폴백한다. 그래서 마이그레이션 비용이 0이다.
🔍 피드백: 논점별로 (1) 2.5 / (2) 3.0 / (3) 2.9를 받아 8.4점입니다. 이번 회차에서 두 번째로 잘한 문항입니다.
(1)에서 2πr·dr을 직접 유도하신 게 인상적입니다. 요구한 건 “고리 넓이가 r에 따라 어떻게 변하는지”였는데, 미소 고리의 넓이 공식까지 세우고 r=1, 2, 3에 대해 2π, 4π, 6π로 값을 뽑아 비례 관계를 수치로 확인하셨습니다. 근거를 수식으로 세운 답은 이번이 처음입니다.
0.8점이 깎인 건 마지막 한 문장 때문입니다. “바깥으로 몰릴 확률이 늘어난다” 고 쓰셨는데, 사실 확률은 전혀 늘지 않습니다. FRand()는 r=0.1도 r=0.9도 똑같은 확률로 뽑으므로, 각 고리에 떨어지는 탄의 개수는 같습니다. 문제는 그 개수를 담는 넓이가 다르다는 것입니다.
넓이는 바깥이 9배 넓은데 탄 개수는 같다 → 넓이당 밀도가 바깥에서 훨씬 낮아야 정상인데 그렇지 않다 → 눈에는 “바깥에 몰렸다”로 보인다
즉 “확률이 늘어서”가 아니라 “확률이 안 늘어서” 생기는 문제입니다. 넓은 영역에는 그만큼 더 많이 떨어져야 균등해 보이는데, 반경만 균등하게 뽑으니 그렇게 되지 않는 겁니다. 세우신 2πr·dr이 바로 그 이야기를 하고 있으니, 결론 한 문장만 방향을 맞추면 됩니다.
(2)는 거의 만점입니다. “구현에는 정답이 없으며, 디자이너가 코드를 볼 필요 없이 커브 데이터만 변경함으로써 분포를 조절할 수 있도록” — 핵심을 정확히 짚었습니다. 여기에 “면적 균등도 하나의 분포일 뿐” 이라는 점만 명시하면 완벽합니다. 소총은 중심 집중, 산탄총은 약간 퍼짐, 특수 무기는 도넛형 — 무기마다 원하는 탄착군이 다르기 때문에 √r이라는 특정 분포 하나를 코드에 박는 게 답이 될 수 없었던 겁니다.
(3) ㉠ PDF / CDF 정확합니다. ㉣도 정확합니다.
㉢에서 사실상 정답에 도달하셨습니다. “X축: U[0,1], Y축: R”인 그래프가 완성된다 — 이건 CDF를 뒤집은 역함수(inverse CDF) 를 정확히 묘사한 표현입니다. U를 넣으면 R이 나오는 그 함수가 맞습니다.
빠진 건 “왜 그렇게 뽑은 R이 커브가 의도한 분포를 따르는가” 입니다. 이유는 CDF의 기울기에 있습니다.
PDF가 높은 구간 → CDF가 가파르게 올라간다 → 그 구간이 Y축(=U가 뽑히는 축)을 더 넓게 차지한다 → 균등하게 뽑은 U가 그 구간에 더 자주 떨어진다 → 결과적으로 그 r이 더 자주 나온다
“균등한 U를 CDF라는 자로 재면, PDF가 높은 곳이 자에서 더 긴 눈금을 차지한다” 고 이해하시면 됩니다. 이게 역변환 샘플링의 전부입니다.
㉣도 이유는 맞았습니다. 한 가지만 덧붙이면 왜 그 낭비가 아까운지가 숫자로 드러납니다 — 미리 구워 두면 Fire()에서는 256칸 이진 탐색, 즉 8회 비교면 끝납니다. 매번 적분하는 것과 8회 비교는 비교가 안 되는 비용 차이죠.
출처:
game_dev/devlog/2026-05-24-EP_GAS-4.md
📌 복습 포인트
- Q1 swap 지원 (단계별로 막히는 곳이 다름) — ① 비템플릿:
pImpl이 private → public 멤버 swap +std::swap완전 특수화(이건 허용됨) ② 클래스 템플릿: 함수 템플릿 부분 특수화 불가, std에 새 템플릿 추가 금지 → 같은 네임스페이스에 비멤버 swap(ADL) ③ 호출:std::를 붙이면 ADL이 죽는다 →using std::swap;후swap(a, b)→game_dev/cpp/chapter4/2025-07-02-cpp_4_25.md - Q2 복사 함수가 서로를 호출하면 안 되는 “이유” — 해결책(공통 private 함수 분리)은 잡혔으니, 복사 생성자는 미초기화 객체 / 대입 연산자는 초기화 끝난 객체라는 전제 차이만 덧붙이기 →
game_dev/cpp/chapter2/2025-06-08-cpp_2_12.md - Q4 생성자/소멸자 가상 함수 — “해결책” 절반 — ⑥ 간접 호출도 똑같이 위험 / ⑦ 정보를 생성자 매개변수로 넘김 / ⑧ 그 정보를 만드는 함수는 static(객체 상태가 필요 없으므로 안전) →
game_dev/cpp/chapter2/2025-05-29-cpp_2_9.md - Q5 도넛이 되는 진짜 이유 — 확률이 늘어서가 아니라 확률이 안 늘어서다. 넓이는 바깥이 9배인데 탄 개수가 같아 밀도가 어긋난다. 그리고 역변환 샘플링은 CDF 기울기가 가파른 구간이 Y축을 더 차지한다가 원리 →
game_dev/devlog/2026-05-24-EP_GAS-4.md
댓글남기기