[말랑 퀴즈] 26/08/29 문제

게시:     수정

카테고리:

태그:

제 말랑말랑 퀴즈 생성기는 이곳에서 확인하실 수 있습니다.

말랑말랑 퀴즈 📝

날짜: 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]인 그래프가 완성되어 의도된 바를 구할 수 있게 된다.

ㄹ: 매 발사 때 적분하면 샷건 펠릿 탄환마다 적분을 하게 되기 때문이다.

MallangQuiz 카테고리 내 다른 글 보러가기

댓글남기기