[말랑 퀴즈] 26/08/29 문제
카테고리: MallangQuiz
태그: Quiz
제 말랑말랑 퀴즈 생성기는 이곳에서 확인하실 수 있습니다.
말랑말랑 퀴즈 📝
날짜: 2026-08-30 문제 수: 5문제
Q1. 🔴 어려움
표준 라이브러리의 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을 완전 특수화한 것인데, 컴파일되지 않는다. 왜인가? 그리고 이를 해결하려면 Widget 클래스에 무엇을 추가하고, 특수화 버전은 어떻게 바꿔야 하는가?
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
swap(a.pImpl, b.pImpl);
}
}
(3) 이번엔 Widget이 클래스 템플릿(template<typename T> class Widget)이다. 아래 두 시도는 각각 왜 안 되는가? 그리고 올바른 방법은 무엇인가?
// 시도 A
namespace std {
template<typename T>
void swap<Widget<T>>(Widget<T>& a, Widget<T>& b) { a.swap(b); }
}
// 시도 B
namespace std {
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) { a.swap(b); }
}
(4) 위 준비를 다 해 두어도, 사용자 코드에서 아래처럼 호출하면 우리가 만든 swap이 절대 불리지 않는다. 왜인가? 올바른 호출 형태를 쓰고, 그렇게 썼을 때 컴파일러가 어떤 순서로 swap을 찾는지 설명하시오.
template<typename T>
void doSomething(T& obj1, T& obj2) {
std::swap(obj1, obj2);
}
출처:
game_dev/cpp/chapter4/2025-07-02-cpp_4_25.md
📝 내 풀이:
(1) 복사 생성자, 복사 대입 생성자, 복사 대입 생성자
pImpl만 바꿔주면 충분하다.
(2) 네임스페이스 내에서 템플릿 특수화가 불가능했던 것으로 알고 있지만, 왜 그랬었는지는 정확히 기억나지 않는다.
해결하기 위해서는 네임스페이스 내부에 쓰는 것이 아닌, Widget 내에 friend로 doSomething을 선언한다. 그리고, 비멤버함수로서 std::swap을 호출한다.
(3) 네임스페이스 내에서 템플릿을 새로 정의하고 있기 때문이며, 2번의 방법을 사용한다.
(4) using namespace std::swap; 을 해준다? 정확히 기억이 안난다.
Q2. 🟡 보통
다음 명제가 참(O)인지 거짓(X)인지 판단하라.
복사 생성자와 복사 대입 연산자를 묶어 객체 복사 함수라 부른다. 컴파일러는 이 둘을 자동으로 생성해 주지만 그 복사는 멤버별 얕은 복사이므로, 포인터나 리소스를 쥐고 있는 클래스에서는 직접 정의해야 한다.
그런데 이 둘을 직접 정의할 때, 중복을 줄이겠다고 아래처럼 한쪽에서 다른 쪽을 호출하면 안 된다.
Customer::Customer(const Customer& rhs) { *this = rhs; // 복사 생성자 안에서 복사 대입 연산자 호출 }복사 생성자는 아직 초기화되지 않은 객체를 만들고 있는 중인데, 복사 대입 연산자는 이미 초기화가 끝난 객체를 전제로 동작하는 함수이기 때문이다. 반대 방향(복사 대입 연산자 안에서 복사 생성자를 호출하는 것)도 마찬가지로 말이 안 된다.
중복을 없애고 싶다면, 두 함수가 서로를 호출하는 대신 공통 코드를 별도의 private 함수로 분리해 양쪽에서 함께 호출하면 된다.
출처:
game_dev/cpp/chapter2/2025-06-08-cpp_2_12.md
📝 내 답: O. 260826 퀴즈에서 나왔던 내용으로, 공통 코드를 InitCopy(rhs)의 형태로 호출하여 양쪽에서 함꼐 호출해주었다.
Q3. 🔴 어려움
UE5에서 바닥에 뿌려지는 픽업 액터가 수백 개다. 매 프레임 복제 후보에 오르지 않게 하려고 아래처럼 설정했다.
NetDormancy = DORM_Initial;
SetNetCullDistanceSquared(25000000.f); // 5000cm의 제곱
SetNetUpdateFrequency(1.f);
이 상태로 Play As Listen Server에서 테스트하니 다음 두 현상이 관찰됐다.
- 아직 한 번도 본 적 없는 픽업 → 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()를 호출해야 한다.
출처:
game_dev/devlog/2026-08-02-EP_Loot-2.md
📝 내 선택: B. DORM_Initial은 최초 복제 이후, 휴면에 들어간다. 채널이 닫히더라도 파괴하라는 정보는 보낼 수 있다.
Q4. 🟢 쉬움
빈칸을 채우시오.
아래 코드에서 BuyTransaction b; 를 실행하면, Transaction의 생성자가 호출하는 logTransaction()은 BuyTransaction의 것이 아니라 Transaction의 것이 불린다.
class Transaction {
public:
Transaction() {
logTransaction(); // ❌ 생성자에서 가상 함수 호출
}
virtual void logTransaction() const = 0;
};
class BuyTransaction : public Transaction {
public:
virtual void logTransaction() const override { /* 거래 로깅 */ }
};
BuyTransaction b;
객체를 생성할 때는 ① ___ 클래스의 생성자가 먼저 호출된다. 이 시점에는 ② ___ 클래스 부분이 아직 초기화되지 않은 상태이므로, 생성자 안에서 호출한 가상 함수는 ③ ___ 클래스의 버전으로 동작한다.
소멸할 때는 순서가 반대라서 ④ ___ 클래스 쪽부터 소멸된다. 따라서 소멸자 안에서 가상 함수를 호출해도 마찬가지로 ⑤ ___ 클래스의 버전이 불린다.
한편 아래처럼 비가상 함수 init()을 한 번 거쳐서 호출하면 눈에 잘 띄지 않지만, ⑥ ___ 호출일 뿐 위험은 똑같다.
class Transaction {
public:
Transaction() { init(); }
virtual void logTransaction() const = 0;
private:
void init() { logTransaction(); }
};
해결책은 가상 함수를 호출하는 대신, 필요한 정보를 ⑦ ___ 로 전달해 비가상 함수로 처리하는 것이다. 이때 파생 클래스는 그 정보를 만드는 함수를 ⑧ ___ 함수로 두는데, 그런 함수는 객체의 상태를 필요로 하지 않아 미초기화 문제를 피할 수 있기 때문이다.
출처:
game_dev/cpp/chapter2/2025-05-29-cpp_2_9.md
📝 내 답: 기본, 파생, 기본, 파생, 기본, ?, 파생 클래스, 비멤버 함수
Q5. 🟡 보통
FPS 게임에서 산탄총 스프레드(탄 퍼짐)를 아래처럼 구현했다. 원뿔 안에서 반경비율과 각도를 각각 균등 난수로 뽑는 방식이다.
// AEPWeapon::Fire
for (int32 i = 0; i < Count; i++)
{
const float R = FMath::FRand(); // 반경비율 0~1 균등
const float Theta = R * HalfAngle;
const float Phi = FMath::FRand() * TWO_PI; // 각도 0~2π 균등
OutPellets.Add(/* 구면 좌표 → 방향 벡터 */);
}
벽에 쏴 보니 탄착군이 가운데가 비고 바깥에 몰리는 도넛 모양으로 나왔다. 중심에 모이길 원했는데 정반대다.
(1) 왜 이런 모양이 나오는가? 반경 r인 원에서 고리 하나의 넓이가 r에 따라 어떻게 변하는지를 근거로 설명하시오.
(2) 이 문제는 FRand() 대신 sqrt(FRand())를 쓰면 면적 균등이 되어 해결된다. 그런데 실제 구현에서는 √r 보정으로 끝내지 않고 분포 자체를 디자이너가 만드는 커브 데이터로 뺐다. 왜 √r 보정을 정답으로 삼지 않았는가?
(3) 그래서 다음과 같은 구조를 만들었다. 디자이너는 X축 = 반경비율, Y축 = 상대 확률인 커브를 그린다.
[BeginPlay] 커브 → 사다리꼴 적분 → 누적합 → 정규화 → 256칸 테이블
[Fire()] 균등 난수 U(0~1) → 테이블에서 이진 탐색 → 반경비율 R 반환
- ㉠ 디자이너가 그리는 커브와, ㉡ BeginPlay에서 구워 둔 256칸 테이블은 확률론에서 각각 무엇에 해당하는가? (두 용어를 쓰시오.)
- ㉢ 균등 난수 U 하나를 뽑았을 뿐인데 어떻게 커브가 의도한 분포를 따르게 되는가? 그 원리를 설명하시오.
- ㉣ 매 발사 때 적분하지 않고 BeginPlay에서 1회만 테이블을 굽는 이유는 무엇인가?
출처:
game_dev/devlog/2026-05-24-EP_GAS-4.md
📝 내 풀이:
(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]인 그래프가 완성되어 의도된 바를 구할 수 있게 된다.
ㄹ: 매 발사 때 적분하면 샷건 펠릿 탄환마다 적분을 하게 되기 때문이다.
댓글남기기