[말랑 퀴즈] 26/08/31 해답

게시:     수정

카테고리:

태그:

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

말랑말랑 퀴즈 — 해답지 ✅

날짜: 2026-08-31 문제 수: 5문제 총점: 37.2/50 (74점) ✅ 잘했어요

📊 채점 결과

문항 유형 난이도 결과 점수
Q1 빈칸 🟢 쉬움 🔺 부분 정답 7.3 / 10
Q2 객관식 🟡 보통 ⭕ 정답 10 / 10
Q3 서술형 🔴 어려움 🔺 부분 정답 3.2 / 10
Q4 OX 🟡 보통 🔺 부분 정답 9.2 / 10
Q5 서술형 🔴 어려움 🔺 부분 정답 7.5 / 10
합계       37.2 / 50

총평

Q5에서 7.5점이 나왔습니다. 어려움 난이도 서술형에서 처음 보는 점수입니다. 특히 (4)는 만점입니다 — ⓐⓑⓒ를 정확히 갈랐고, 두 예외를 “상속을 나타내는 자리”와 “초기화 리스트”로 자기 말로 정리하셨습니다. (2)㉡의 “변수일 수도, 타입일 수도 있다”도 이 항목의 핵심을 그대로 짚은 문장입니다.

Q4로 OX는 네 회차 연속 정답입니다. 이번엔 X → O → X → X로 번갈아 나오지 않게 냈는데도 맞히셨고, 게다가 거짓인 절(節)을 콕 집어 인용하셨습니다. 이제 확실히 몸에 붙은 것 같습니다.

문제는 Q3(3.2점) 입니다. 여기서 실점한 이유가 한 가지로 모입니다 — 문제가 준 조건을 답에 반영하지 않은 것입니다. (3)은 문제 본문에 “트랜잭션을 쓰지 않고 있다” 고 굵게 적혀 있는데 크래시 대비를 “트랜잭션으로”라고 답하셨습니다. 어제 복습 포인트에 적어 둔 “질문의 마지막 어절을 다시 읽기”가 정확히 이 지점입니다. 정답은 로그를 남겨 두었다가 성공하면 지우는 것이었습니다.

Q1도 아까운 편입니다. ①④⑤는 정확한데 ③에 20(정답 60)을 쓰셨습니다. 60은 초당 60프레임이라는 감각과 같은 숫자이니 함께 묶어 두시면 좋겠습니다.


Q1. 🟢 쉬움 — 🔺 7.3/10

문제: 빈칸을 채우시오.

1인용 게임은 아래 세 단계가 계속 반복되는 게임 루프로 돌아간다.

┌─────────────────────────────────────────────┐
│  ① ___  : 입력 장치를 통해 정보를 획득한다      │
│      ↓                                       │
│  ② ___  : 세션의 상태를 변화시킨다             │
│           (보통 1초에 ③ ___ 번)              │
│      ↓                                       │
│  ④ ___  : 변화된 상태를 화면에 표현한다        │
└──────────────────┘ ← 다시 위로 반복

온라인 게임은 여러 명이 동시에 행동하므로, 서로 간의 상호작용을 중재해서 ⑤ ___ 시켜 주어야 한다. 대부분의 게임 서버는 클라이언트가 하던 ⑥ ___ 처리를 일부 떼어내 서버로 옮긴 뒤 데이터를 주고받는다.

📝 내 답: 입력, 업데이트, 20, 렌더링, 동기화, 계산

정답:

① 입력 받기 ② 게임 로직 처리하기 ③ 60 ④ 렌더링 ⑤ 동기화 ⑥ 게임 로직

해설:

1인용 게임의 게임 루프는 세 단계다.

  1. 입력 받기 — 입력 장치를 통해 정보를 획득하는 과정
  2. 게임 로직 처리하기 — 세션은 보통 1초에 60번 상태가 변화하며, 이를 게임 로직 처리라고 한다
  3. 렌더링 — 변화된 상태를 화면에 표현

온라인 게임은 한 명이 아니라 여러 명이 행동한다. 그래서 서로 간의 상호작용을 중재해 동기화시켜 주어야 하고, 바로 이 지점에서 서버와 클라이언트가 등장한다.

대부분의 게임 서버는 클라이언트의 게임 로직 처리를 일부 떼어내 서버로 옮기고 데이터를 주고받는다. 이유는 두 가지다.

  • 여러 클라이언트의 상태를 한곳에서 맞춰야 하니까
  • 클라이언트가 허튼 수를 부리면 안 되니까 — 판정을 클라이언트에 맡기면 조작할 수 있다

즉 게임 로직을 서버로 옮기는 것은 성능 문제이기 이전에 신뢰의 문제다. 클라이언트는 믿을 수 없는 존재이므로, 결과를 좌우하는 판단은 서버가 쥐고 있어야 한다.

🔍 피드백: ①②④⑤를 정답 처리하고 ⑥에 부분 점수를 주어 7.3점입니다.

② “업데이트”는 정답으로 인정했습니다. 원문의 용어는 “게임 로직 처리하기”지만, 게임 루프를 Input → Update → Render로 부르는 것은 널리 쓰이는 표현이고 “세션의 상태를 변화시킨다” 는 정의와 정확히 같은 단계를 가리킵니다.

③ “20”이 유일한 완전 오답입니다. 정답은 60입니다. 게임 세션은 보통 1초에 60번 상태가 변화합니다. 이 숫자는 따로 외우기보다 이미 알고 계신 감각에 붙이시면 됩니다 — 60fps의 그 60이고, 서버 틱레이트를 말할 때의 “60틱”도 같은 숫자입니다. 화면이 1초에 60번 갱신되려면 상태도 그만큼 갱신되어야 하니까요.

⑥ “계산”에는 부분 점수를 드렸습니다. 정답은 게임 로직입니다. 방향은 맞지만 “계산”은 범위가 너무 넓어 이 문장의 요지를 살리지 못합니다. 여기서 서버로 옮기는 것은 단순 연산이 아니라 판정에 해당하는 로직 — “이 총알이 맞았는가”, “이 아이템을 정말 갖고 있는가” 같은 것들입니다. 그래야 클라이언트가 허튼 수를 부리지 못하게 막는다는 문장의 이유가 성립합니다.

한 가지 더 짚어 두면, ②와 ⑥은 원문에서 같은 용어(“게임 로직”) 입니다. ②를 “업데이트”로 쓰셨는데 그 개념이 곧 ⑥이기도 하다는 걸 연결해 두시면 좋습니다 — 클라이언트가 하던 업데이트 단계의 일부를 서버로 옮기는 것이 온라인 게임의 구조입니다.

출처: server/game_server/4/2026-02-08-game_server_4_3.md


Q2. 🟡 보통 — ⭕ 10/10

문제: 아래 코드에 대한 설명으로 가장 올바른 것은?

std::string* stringArray = new std::string[100];
...
delete stringArray;        // ← []를 빠뜨렸다
  • A. new std::string[100]이 잡은 것은 결국 연속된 메모리 한 덩어리이므로, delete를 쓰든 delete[]를 쓰든 그 블록 전체가 운영체제에 반환된다. delete[]는 관례상 읽기 좋으라고 쓰는 표기일 뿐이다.
  • B. 배열을 할당하면 원소들과 함께 배열의 크기 정보도 저장되고, delete[]는 그 값을 읽어 각 원소의 소멸자를 정확한 횟수만큼 호출한다. delete를 쓰면 그 구조를 모르는 채 단일 객체로 취급하므로 첫 번째 객체만 소멸되고 나머지 소멸자는 호출되지 않는 정의되지 않은 동작이 된다.
  • C. 문제가 되는 것은 std::string처럼 소멸자가 있는 타입일 때뿐이다. new int[100]처럼 소멸자가 없는 타입이라면 해제할 소멸자가 애초에 없으므로 delete로 지워도 표준상 안전하다.
  • D. typedef std::string AddressLines[4]; 처럼 배열을 typedef로 감싸 두면 컴파일러가 그 이름이 배열임을 기억한다. 따라서 new AddressLines로 만든 것은 delete로 지워도 되며, 오히려 이런 실수를 막으려면 배열 타입을 typedef로 감싸 두는 편이 안전하다.

📝 내 선택: B — A: delete를 사용하면 배열의 크기가 전달되지 않아 오답이다. / C: []를 사용하는 것 자체가 operator[]를 사용한다는 것 → 클래스 존재하므로 소멸자도 존재 / D: string[4] 배열을 의미하는 AddressLines일 뿐이다. 컴파일러는 배열임을 기억하지 않으며, 이런 형태에서 typedef를 쓰는 것은 혼동을 주므로 using을 쓰는 것이 옳다.

정답: B

해설:

deletedelete[]표기만 다른 게 아니라 내부 동작이 완전히 다르다. 그 이유는 배열과 단일 객체의 메모리 구조가 다르기 때문이다.

배열 구조

0 1 2
배열 크기 n 객체 1 객체 2

단일 객체 구조

0
객체 1

배열을 할당하면 런타임이 배열의 크기 정보도 함께 저장한다. 이 값이 필요한 이유는 단 하나 — delete[]가 호출될 때 각 원소의 소멸자를 몇 번 불러야 하는지 알아야 하기 때문이다.

new[]로 만든 것을 delete로 지우면 그 크기 정보를 읽지 않는다. 앞에 놓인 크기 값을 객체의 시작으로 착각하거나, 첫 번째 객체 하나만 소멸시키고 나머지 99개의 소멸자는 영영 호출되지 않는다. 결과는 정의되지 않은 동작(UB) 이다.

규칙은 대칭이다.

std::string* stringPtr1 = new std::string;        // 단일
std::string* stringPtr2 = new std::string[100];   // 배열

delete   stringPtr1;    // ✅ []를 쓰지 않았으니 []를 붙이지 않는다
delete[] stringPtr2;    // ✅ []를 썼으니 []를 붙인다
  • A — “메모리 블록이 반환되느냐”만 보고 있다. 진짜 문제는 소멸자가 몇 번 불리느냐다. std::string 99개가 소멸자를 호출받지 못하면 각자 들고 있던 힙 버퍼가 그대로 샌다. delete[]는 스타일이 아니라 의미가 다른 연산자다.
  • C — 소멸자 유무와 무관하게 형태가 맞지 않으면 정의되지 않은 동작이다. 특정 컴파일러에서 우연히 동작할 수는 있어도 표준이 보장해 주지 않는다. “이 타입은 소멸자가 없으니 괜찮다”는 예외 규칙은 존재하지 않는다.
  • D — 정확히 반대다. typedef배열이라는 사실을 감춰 실수를 유도한다.
typedef std::string AddressLines[4];   // AddressLines == string[4]

std::string* pal = new AddressLines;   // 사실은 new string[4] 이다!

delete   pal;    // ❌ 배열인 줄 모르고 단일 객체로 삭제
delete[] pal;    // ✅ 올바른 해제

new AddressLines라고 쓰여 있으면 []가 눈에 보이지 않아 delete[]를 떠올리기 어렵다. 그래서 배열 타입을 typedef로 감추는 것은 피해야 한다.

가장 안전한 대안: 애초에 std::vectorstd::array를 쓰면 이 실수가 불가능해진다. 해제도 소멸자 호출도 자동이고, 타입에 배열이라는 사실이 그대로 드러난다.

std::vector<std::string> pal(4);

🔍 피드백: 만점입니다. A와 D를 쳐낸 근거가 특히 정확합니다.

A에 대해 쓰신 “delete를 사용하면 배열의 크기가 전달되지 않아”가 정답의 핵심입니다. 크기를 모르면 소멸자를 몇 번 불러야 할지 알 수 없으니까요. D에 대한 “컴파일러는 배열임을 기억하지 않으며, typedef는 혼동을 준다”도 정확하고, using을 쓰는 편이 낫다는 지적은 원문보다 한 걸음 더 나간 좋은 관찰입니다. using AddressLines = std::string[4]; 로 쓰면 = std::string[4] 부분에 대괄호가 그대로 보여서 배열이라는 사실이 조금은 덜 감춰집니다. (다만 감춰진다는 문제 자체는 남으므로, 근본 해결은 std::vectorstd::array입니다.)

C를 쳐낸 근거만 다시 짚고 싶습니다. 점수에는 영향이 없지만 개념이 섞여 있습니다.

[]를 사용하는 것 자체가 operator[]를 사용한다는 것 → 클래스 존재하므로 소멸자도 존재”

delete[][]new int[100][]operator[](첨자 연산자)와 아무 관계가 없습니다. delete[]는 그 자체로 하나의 연산자이고, int 같은 기본 타입 배열에도 그대로 씁니다. 클래스가 아니어도 new[]는 쓸 수 있습니다.

C가 오답인 진짜 이유는 더 단순합니다. 소멸자가 있든 없든, 형태가 맞지 않으면 그 자체로 정의되지 않은 동작입니다. new int[100]delete로 지우면 소멸자 문제는 없더라도, 크기 정보가 앞에 붙은 메모리 블록의 시작 주소가 우리가 받은 포인터와 다를 수 있어 해제 자체가 어긋납니다. “이 타입은 소멸자가 없으니 예외”라는 규칙은 표준에 존재하지 않습니다.

출처: game_dev/cpp/chapter3/2025-06-12-cpp_3_16.md


Q3. 🔴 어려움 — 🔺 3.2/10

문제: 게임 서버가 데이터베이스를 다루는 방식에 대해 답하시오.

(1) 질의 구문을 매번 DB로 던지는 방식이 비효율적인 이유 세 가지와, 이를 줄이는 기법의 이름·원리는? (2) ㉠ 송금 코드에 트랜잭션이 필요한 이유 ㉡ 두 프로세스가 A→B / B→A 순으로 접근할 때 벌어지는 일과 그것이 닮은 것 ㉢ 예방법 두 가지 이상 (3) ㉠ 아이템 교환 코드가 트랜잭션 없이도 비교적 안전한 이유 ㉡ 두 db.execute 사이에서 크래시하면?

📝 내 풀이:

(1) 통신량 증가, 안정성(서버 끊김, 유실), ? 기법은 잘 기억 안나지만 질의를 묶어서 보냈던 것 같다.

(2) ㄱ. 트랜잭션이 필요한 이유는 질의 중간에 사이에 게임 서버가 내려갈 수 있기 때문이다. ㄴ. 서로의 자원을 점유하여 놓아주지 않는다. race condition. ㄷ. 자원을 점유하는 순서를 염두에 둔다, 점유하는 동안 자원을 잠금시킨다.

(3) 1->2->1->2 순서를 지키기 때문인 것으로 아는데 정확히 모르겠다. 크래시나는 경우를 대비해 트랜잭션을 통해 이전 작업을 이을수 있도록 한다.

정답:

(1) 질의 구문을 매번 던지는 것이 비효율적인 이유 — 그리고 저장 프로시저

이유는 세 가지다.

  1. 질의 구문 송신/수신까지 디바이스 타임이 발생한다.
  2. 게임 서버와 데이터베이스 간 네트워크 레이턴시가 쌓이면 꽤 긴 시간이 된다.
  3. 데이터베이스는 질의 구문을 수신할 때마다 준비 작업 연산을 해야 한다.

이를 줄이는 기법이 저장 프로시저(Stored Procedure)데이터베이스에 질의 구문 집합을 미리 저장해 두는 것이다.

create procedure LoadCharacterAndItem
    @ID nvarchar(50)                        -- 입력 매개변수
as
begin                                       -- 루틴 시작
    select * from Character where ID=@ID
    select * from Item      where OwnerCharacterID=@ID
end                                         -- 루틴 끝
EXEC LoadCharacterAndItem @ID='xxx'         -- 호출은 이 한 줄

효과는 두 방향이다.

  • 네트워크 비용 감소 — 여러 개의 긴 질의 구문을 보내는 대신 호출 한 번으로 끝난다. 왕복 횟수가 줄어드니 레이턴시도 줄어든다.
  • 미리 컴파일되어 있음 — DB가 매번 하던 준비 작업을 이미 해 둔 상태다.

단점은 유지보수가 복잡해진다는 것이다. 로직 일부가 애플리케이션 코드가 아니라 DB 안에 살게 된다.

(2) 트랜잭션과 데드락

㉠ 트랜잭션이 필요한 이유 — 둘 중 하나만 실행되면 대참사이기 때문이다.

update UserAccount set Money=Money+100 where ID='Kang Bu Ja'
update UserAccount set Money=Money-100 where ID='Hong Gil Dong'

첫 줄만 실행되고 둘째 줄이 실패하면 세상에 없던 100원이 생긴다. 반대면 100원이 증발한다. 트랜잭션은 이 둘을 모두 성공하거나 모두 없던 일이 되게 묶는다. begin transaction으로 시작해 문제가 없으면 commit, 문제가 있으면 rollback transaction으로 begin transaction 이후의 데이터를 원상복구한다.

㉡ 데드락(교착 상태)이 발생한다.

쿼리 프로세스 1              쿼리 프로세스 2
BEGIN TRANS                 BEGIN TRANS
A = A + 10   → A를 잠금
                            B = B + 10   → B를 잠금
B = B - 10   ⚠️ B의 잠금이 풀리기를 기다림
                            A = A - 10   ⚠️ A의 잠금이 풀리기를 기다림
        ↑ 서로 상대가 쥔 잠금을 기다린다 → 영원히 안 풀림

트랜잭션이 건드리는 레코드는 그 트랜잭션이 끝날 때까지 다른 쿼리 프로세스에서 블로킹된다. 위처럼 두 프로세스가 서로 반대 순서로 레코드를 잡으면 각자 상대의 잠금 해제를 기다리며 멈춘다.

이것은 멀티스레드 프로그래밍의 뮤텍스 잠금과 닮았다. 실제로 DB의 레코드 잠금은 뮤텍스와 같은 성질을 가지며, 그래서 데드락을 똑같이 주의해야 한다.

㉢ 예방 방법

  1. 질의 구문은 반드시 레코드 A→B 순서로 접근한다. — 모든 프로세스가 같은 순서로 잠그면 원형 대기가 생기지 않는다. (위 예시에서 프로세스 2도 A부터 잡았다면 데드락이 없다.)
  2. 트랜잭션 내 레코드 수를 최소로, 데이터 액세스 횟수도 최소로 한다. — 잡는 게 적을수록 얽힐 일도 적다.
  3. 교착 상태로 구문 실행이 블로킹되거나 실패했을 때의 오류 처리를 반드시 넣는다. — 예방만으로는 완전하지 않으므로 걸렸을 때의 처리가 필요하다.

성능 이슈도 함께 온다. 트랜잭션의 영향을 받는 레코드는 다른 쿼리 프로세스에서 접근할 때 블로킹되므로 병렬 처리 효율이 떨어진다. 심하면 주변 레코드나 테이블 전체가 잠길 수도 있다.

(3) 게임 서버가 트랜잭션을 쓰지 않는 이유와 크래시 대비

㉠ 게임 서버 메모리에서 사전 검증을 이미 수행하기 때문이다.

    if (!player1.hasItem(item1)) { ResponseExchangeItemsFail(...); return; }
    if (!player2.hasItem(item2)) { ResponseExchangeItemsFail(...); return; }

이 두 줄이 근거다. “player1이 정말 item1을 갖고 있는가”를 DB에 묻지 않고 서버 메모리에서 먼저 확인하고, 아니면 아예 실패로 응답하고 돌아간다. 그리고 이어지는 removeItem / addItem도 전부 메모리상의 처리다. DB로 나가는 update 두 줄은 그 결과를 기록하는 역할일 뿐이다.

판단은 이미 메모리에서 끝났고, DB는 세이브 데이터 역할만 한다. 그래서 트랜잭션이 필요한 상황 자체가 적다.

반대로 트랜잭션을 쓰면 성능 저하와 데드락 가능성이 생긴다. DB가 세이브 데이터 역할 정도만 한다면 트랜잭션의 이득보다 손실이 더 클 수 있다.

㉡ 로그를 남겨 두었다가, 성공하면 로그를 삭제한다.

게임 서버가 크래시하면 마지막 질의 구문을 실행하지 못한 채 끝난다. 그래서 질의 구문을 실행하기 전에 로그로 남겨 두고, 정상적으로 성공하면 그 로그를 지운다. 크래시 후 재기동했을 때 남아 있는 로그가 곧 “끝내지 못한 작업 목록” 이므로, 그걸 보고 복구하면 된다.

정리 — 게임 서버 권장 패턴

기법 장점 단점 사용 시기
저장 프로시저 네트워크 비용 감소, 미리 컴파일됨 유지보수 복잡 자주 쓰는 쿼리
트랜잭션 데이터 무결성 보장 성능 저하, 데드락 위험 금융 거래 등
메모리 우선 빠른 처리, 데드락 없음 크래시 시 손실 게임 서버 일반

게임 로직은 메모리에서 처리하고, DB는 세이브 용도로 쓰고, 중요한 거래에만 트랜잭션을 쓰고, 크래시에 대비해 로그를 남긴다.

🔍 피드백: 논점별로 (1) 1.5 / (2) 1.7 / (3) 0을 받아 3.2점입니다.

(1) “통신량 증가”는 이유 ①②(디바이스 타임 + 네트워크 레이턴시)에 걸쳐 있어 부분 인정했습니다. “안정성(서버 끊김, 유실)”은 이 항목이 든 이유가 아닙니다 — 여기서 지적하는 건 안정성이 아니라 속도입니다. 놓친 세 번째가 특히 중요합니다.

데이터베이스는 질의 구문을 수신할 때마다 준비 작업 연산을 해야 한다.

이게 왜 중요하냐면, 저장 프로시저가 해결하는 두 가지 중 하나가 바로 이것이기 때문입니다.

기법 이름을 못 쓰셨지만 “질의를 묶어서 보냈던 것 같다”는 원리를 정확히 짚으셨습니다. 이름은 저장 프로시저(Stored Procedure) 이고, 하는 일은 딱 그겁니다 — 질의 구문 집합을 DB에 미리 저장해 두고 EXEC LoadCharacterAndItem @ID='xxx' 한 줄로 부릅니다. 효과가 두 방향이라는 점만 덧붙이시면 완성됩니다. ① 왕복 횟수가 줄어 네트워크 비용이 감소하고, ② 미리 컴파일되어 있어 DB의 준비 작업도 사라집니다.

(2)㉠ “질의 사이에 게임 서버가 내려갈 수 있기 때문”은 원인을 말한 것이고, 문제가 물은 건 그래서 뭐가 문제인가였습니다. 답은 둘 중 하나만 실행되면 대참사라는 것입니다. 첫 줄만 실행되면 세상에 없던 100원이 생기고, 둘째 줄만 실행되면 100원이 증발합니다. 트랜잭션은 이 둘을 “모두 되거나 모두 없던 일이 되게” 묶는 장치입니다.

(2)㉡ 현상 서술은 정확한데 이름이 틀렸습니다. “서로의 자원을 점유하여 놓아주지 않는다”가 바로 데드락(교착 상태) 의 정의입니다. 그런데 이름을 race condition이라 쓰셨는데, 이 둘은 다른 개념입니다.

  무슨 일이 벌어지는가 증상
데드락 서로 상대가 쥔 잠금을 기다린다 멈춘다. 영원히 진행되지 않는다
경쟁 조건(race condition) 잠그지 않고 동시에 건드린다 진행은 되는데 결과가 틀린다. 실행할 때마다 달라진다

“멈추면 데드락, 틀리면 경쟁 조건” 으로 기억하시면 헷갈리지 않습니다. 그리고 문제가 물은 “멀티스레딩의 무엇과 닮았는가”의 답은 뮤텍스 잠금입니다 — DB의 레코드 잠금이 뮤텍스와 같은 성질을 갖기 때문에 데드락도 똑같이 생깁니다.

(2)㉢ “자원을 점유하는 순서를 염두에 둔다”는 정확합니다. 정답의 첫 번째 예방법인 “질의 구문은 반드시 레코드 A→B 순서로 접근한다” 와 같은 이야기입니다. 모두가 같은 순서로 잡으면 원형 대기가 아예 만들어지지 않으니까요.

두 번째로 쓰신 “점유하는 동안 자원을 잠금시킨다”는 예방법이 아니라 데드락이 생기는 원인 그 자체입니다. 잠그기 때문에 서로 기다리게 되는 거니까요. 나머지 예방법은 이렇습니다 — 트랜잭션 내 레코드 수와 액세스 횟수를 최소로 하고(잡는 게 적을수록 얽힐 일도 적다), 걸렸을 때의 오류 처리를 넣습니다(예방만으로는 완전하지 않으므로).

(3)이 0점인데, 이유가 하나로 모입니다. 문제 본문에 이렇게 적혀 있었습니다.

아래는 두 플레이어가 아이템을 교환하는 게임 서버 코드다. 트랜잭션을 쓰지 않고 있다.

그런데 ㉡의 답을 “트랜잭션을 통해 이전 작업을 이을 수 있도록 한다” 로 쓰셨습니다. 트랜잭션을 안 쓰는 상황에서 크래시를 어떻게 대비하느냐고 물었으니, 답이 트랜잭션이면 질문이 성립하지 않습니다. 어제 복습 포인트에 적어 둔 “답을 쓰기 전에 질문을 다시 읽기” 가 정확히 여기서 걸렸습니다.

정답은 로그를 남겨 두었다가 성공하면 지우는 것입니다. 질의 구문을 실행하기 전에 로그를 남기고, 정상적으로 끝나면 그 로그를 삭제합니다. 그러면 크래시 후 재기동했을 때 남아 있는 로그가 곧 “끝내지 못한 작업 목록” 이 되어 그걸 보고 복구할 수 있습니다.

㉠도 마찬가지입니다. “1→2→1→2 순서”는 (2)㉢의 잠금 순서 이야기를 여기 가져오신 것 같은데, 여기서 물은 건 “왜 트랜잭션이 없어도 괜찮은가” 였습니다. 답은 코드 맨 위 두 줄에 있습니다.

    if (!player1.hasItem(item1)) { ResponseExchangeItemsFail(...); return; }
    if (!player2.hasItem(item2)) { ResponseExchangeItemsFail(...); return; }

DB에 묻지 않고 서버 메모리에서 먼저 검증하고, 아니면 그 자리에서 실패로 돌아갑니다. removeItem/addItem도 전부 메모리 처리이고, 뒤의 db.execute 두 줄은 결과를 기록만 합니다. 즉 판단은 이미 메모리에서 끝났고 DB는 세이브 파일 역할만 하므로, 트랜잭션이 지켜 줄 것이 별로 없는 겁니다. 이게 이 항목이 말하는 “게임 서버 권장 패턴” 의 핵심입니다.

출처: server/game_server/7/2026-03-05-game_server_7_9.md


Q4. 🟡 보통 — 🔺 9.2/10

문제: 다음 명제가 참(O)인지 거짓(X)인지 판단하라.

분산 처리는 확장성뿐 아니라 안정성에도 효과를 준다. 서버가 한 대뿐이라면 그 한 대가 멈추는 순간 서비스 전체가 멈추기 때문이다.

수평 분산의 경우, 서버 한 대가 중지되어도 그 서버에 있던 플레이어들만 접속이 끊어진다. 그들은 다른 서버에 접속하면 되므로 나머지 플레이어에게는 영향이 없다.

수직 분산 역시 마찬가지다. 기능별로 서버를 나눠 두었으므로 한 대가 멈추면 그 서버가 담당하던 기능을 남은 서버들이 나눠 맡게 되어, 서비스 전체가 아무런 영향 없이 그대로 계속된다.

📝 내 답: X — 수직 분산에서 서버가 담당하던 기능을 나눠 맡는다는 것이 틀렸다.

정답: X

해설:

앞의 두 문단은 참이다.

  • 분산 처리가 확장성뿐 아니라 안정성에도 효과를 준다는 것 ✅
  • 수평 분산에서 서버 한 대가 중지되면 그 서버에 있던 플레이어들만 끊기고, 그들은 다른 서버에 접속하면 된다는 것 ✅

틀린 곳은 마지막 문단이다. 수직 분산이 주는 보장은 그것이 아니다.

수직 분산의 경우, 중지된 서버가 처리하는 기능 “외” 다른 기능은 정상 작동한다.

멈춘 그 기능 자체는 멈춘다. 남은 서버들이 나눠 맡지 않는다. 수직 분산은 기능별로 서버를 나눈 것이라 각 기능을 담당하는 서버가 다르고, 채팅 서버가 죽으면 채팅은 안 된다. 다만 전투·거래 같은 다른 기능은 살아 있으므로 서비스 전체가 죽지는 않는다는 뜻이다.

두 방식의 차이를 정리하면 이렇다.

  나누는 기준 한 대가 죽으면
수평 분산 같은 일을 하는 서버를 여러 대 그 서버의 플레이어만 끊긴다 → 다른 서버로 옮겨 가면 된다
수직 분산 기능별로 서로 다른 서버 기능만 멈춘다 → 다른 기능은 정상 작동한다

명제가 그럴듯하게 들리는 이유는, “남은 서버들이 나눠 맡는다”가 수평 분산의 성질이기 때문이다. 수평 분산은 서버들이 같은 일을 하므로 대체가 가능하지만, 수직 분산은 각자 다른 일을 하므로 대체가 불가능하다.

🔍 피드백: 판단 7점 만점, 근거 3점 중 2.2점으로 9.2점입니다.

OX 네 회차 연속 정답입니다. 이번엔 특히 의도적으로 어렵게 냈습니다. 최근 답이 X → O → X로 나와서 “번갈아 나온다”고 읽으면 O를 찍게 되는 자리인데, 그 유혹에 넘어가지 않으셨습니다. 게다가 거짓인 부분을 “서버가 담당하던 기능을 나눠 맡는다는 것” 이라고 절(節) 단위로 정확히 인용하셨습니다. 명제를 통째로 보지 않고 문장을 쪼개서 검사하고 계시다는 증거입니다.

0.8점만 남았는데, 그건 “그럼 실제로는 어떻게 되는가” 한 줄입니다. 틀린 곳을 지목하는 것까지는 하셨으니, 옳은 서술로 바꿔 놓는 것까지 하면 완결됩니다.

수직 분산에서는 중지된 서버가 담당하던 그 기능은 멈추고, 그 외 다른 기능만 정상 작동한다.

이 차이가 실무에서 갈리는 지점도 함께 기억해 두세요. 수평 분산은 서버들이 같은 일을 하니 서로 대체가 되지만, 수직 분산은 각자 다른 일을 하니 대체가 안 됩니다. 그래서 명제에 쓰인 “남은 서버들이 나눠 맡는다”는 사실 수평 분산의 성질을 수직 분산에 갖다 붙인 문장이었습니다.

출처: server/game_server/9/2026-03-22-game_server_9_11.md


Q5. 🔴 어려움 — 🔺 7.5/10

문제: C++ 템플릿의 typename 키워드에 대해 답하시오.

(1) template<class T>template<typename T>의 차이는? (2) ㉠ 템플릿 매개변수에 종속된 이름과 그렇지 않은 이름의 명칭 ㉡ 컴파일러가 C::const_iterator를 두고 무엇과 무엇 사이에서 헷갈리는가? (3) 코드를 고치고, 일반 규칙을 한 문장으로. (4) ⓐ~ⓒ 중 typename이 필요한 곳과 불필요한 곳, 그리고 두 예외는?

📝 내 풀이:

(1) 기능은 동일하지만, 특별한 조건에서는 class만 사용해야하는 경우가 있었다.

(2) ㄱ: 종속 변수, 비종속 변수 ㄴ: 변수일수도, 타입일 수도 있따는 점이다. 그렇기에 typename을 붙여야한다.

(3) const_iterator와 같은 C++에서 정의하는 이름은 사용하면 안된다.

(4) 필요 없는 곳: a, b. 필요 있는 곳: c 상속을 나타내는 자리와 초기화 리스트에서는 사용하면 안됐다.

정답:

(1) 차이가 없다

템플릿 매개변수를 선언할 때 classtypename서로 바꾸어 써도 된다. 의미상 완전히 동일하다.

template<class    T> class Widget;   // 같다
template<typename T> class Widget;   // 같다

그러나 typename반드시 쓰여야 하는 경우가 따로 있다. 그게 (2)~(3)의 이야기다.

(2) 의존 이름과 컴파일러의 모호성

㉠ 용어

  • 의존 이름(Dependent Name)C::const_iterator처럼 템플릿 매개변수 C에 따라 달라지는 이름. Cvector<int>vector<int>::const_iterator가 되고, list<int>list<int>::const_iterator가 된다.
  • 비의존 이름(Non-dependent Name)int처럼 템플릿 매개변수와 아무 상관 없는 이름.
  • 특히 C::const_iterator처럼 어떤 클래스 안에 중첩되어 있는 의존 이름을 중첩 의존 (타입) 이름이라 한다.

㉡ 컴파일러가 헷갈리는 것 — “타입인가, 변수인가”

문제의 핵심은 C::const_iterator가 타입이라고 장담할 수 없다는 것이다. 세상에는 이런 클래스도 존재할 수 있다.

struct Foo {
    static int const_iterator;    // 타입이 아니라 static 변수!
};

그래서 아래 한 줄이 두 가지로 읽힌다.

C::const_iterator* x;
해석 의미
C::const_iterator타입이라면 x라는 포인터 변수를 선언하는 문장
C::const_iteratorstatic 변수라면 C::const_iteratorx곱하는 연산식 (*가 곱셈 연산자)

컴파일러는 C가 무엇으로 치환될지 모르는 상태에서 이 코드를 파싱해야 하므로, 둘 중 어느 쪽인지 판단할 수 없다. C++은 이런 경우 기본적으로 “타입이 아니다”라고 가정한다. 그래서 원래 코드의 C::const_iterator iter(...) 는 타입 선언으로 받아들여지지 않아 컴파일 에러가 난다.

(3) 고친 코드와 일반 규칙

template<typename C>
void print2nd(const C& container) {
    if (container.size() >= 2) {
        typename C::const_iterator iter(container.begin());   // ✅ typename 추가
        ++iter;
        int value = *iter;
        std::cout << value;
    }
}

규칙: 템플릿 안에서 중첩 의존 이름을 참조할 때는 그 앞에 typename을 붙여, 이것이 타입임을 컴파일러에게 알려 준다.

주의할 점은 중첩 의존 이름을 식별하는 데만 typename을 쓴다는 것이다.

template<typename C>
void f(
    const C& container,          // ❌ typename 쓰면 안 됨 (C는 중첩 의존 이름이 아니다)
    typename C::iterator iter    // ✅ typename 써야 함
);

(4) typename을 붙이면 안 되는 두 예외

template<typename T>
class Derived: public Base<T>::Nested {          // ⓐ ❌ typename 금지
public:
    explicit Derived(int x)
        : Base<T>::Nested(x) {                   // ⓑ ❌ typename 금지
        typename Base<T>::Nested temp;           // ⓒ ✅ typename 필요
        ...
    }
};
  • ⓐ 필요 없음기본 클래스 리스트에 있는 경우
  • ⓑ 필요 없음멤버 초기화 리스트 안의 기본 클래스 식별자로 있는 경우
  • ⓒ 필요함 — 위 두 자리가 아닌 일반적인 위치(함수 본문 등)

세 자리 모두 Base<T>::Nested라는 같은 중첩 의존 타입 이름인데도 취급이 갈린다. ⓐ와 ⓑ는 문법적으로 타입만 올 수 있는 자리라 컴파일러가 헷갈릴 여지가 없기 때문이다. 반면 ⓒ는 (2)에서 본 것처럼 변수일 수도 있는 자리라 typename이 필요하다.

위치 typename 예시
템플릿 매개변수 선택 (class와 동일) template<typename T>
중첩 의존 타입 이름 필수 typename C::iterator
기본 클래스 리스트 금지 class D: public Base<T>::Nested
멤버 초기화 리스트 금지 : Base<T>::Nested(x)
함수 본문 내 필요 시 사용 typename Base<T>::Nested temp;

실전 팁 — 긴 중첩 의존 타입은 typedef로 간소화한다. typename은 typedef 선언에서 한 번만 쓰고, 이후에는 짧은 이름을 쓰면 된다.

template<typename IterT>
void workWithIterator(IterT iter) {
    typedef typename std::iterator_traits<IterT>::value_type value_type;
    value_type temp(*iter);      // iter가 vector<int>::iterator라면 temp는 int
    ...
}

🔍 피드백: 논점별로 (1) 2.0 / (2) 2.0 / (3) 1.0 / (4) 2.5를 받아 7.5점입니다. 어려움 난이도 서술형에서 지금까지 중 가장 높은 점수입니다.

(4)는 만점입니다. ⓐⓑ 불필요 / ⓒ 필요를 정확히 가르셨고, 두 예외를 “상속을 나타내는 자리”(= 기본 클래스 리스트)“초기화 리스트”(= 멤버 초기화 리스트의 기본 클래스 식별자) 로 자기 말로 정리하셨습니다. 여기에 그 두 자리만 예외인지까지 붙이면 완벽해집니다 — 그 자리들은 문법적으로 타입만 올 수 있어서, 컴파일러가 “변수인가 타입인가”로 헷갈릴 여지가 애초에 없기 때문입니다. 세 자리 모두 같은 Base<T>::Nested인데 취급이 갈리는 이유가 그겁니다.

(1) “기능은 동일하지만”이 핵심이고 맞았습니다. 뒤에 붙이신 “특별한 조건에서는 class만 써야 하는 경우가 있었다”도 실제로 사실입니다 — 템플릿 템플릿 매개변수(template<template<typename> class X>)는 C++17 전까지 class만 허용됐습니다. 다만 이 항목이 이어서 하려던 이야기는 그 반대쪽입니다. typename만 써야 하는 경우, 즉 (2)~(3)의 중첩 의존 이름이죠. 두 방향을 함께 기억해 두시면 좋습니다.

(2)㉡의 “변수일 수도, 타입일 수도 있다”가 이 항목 전체의 급소입니다. 정확히 짚으셨습니다. 여기에 문제가 힌트로 준 그 한 줄까지 붙이면 만점이었습니다.

C::const_iterator* x;

이 줄이 x라는 포인터 변수 선언인지, 아니면 C::const_iteratorx를 곱하는 연산식(*가 곱셈)인지 컴파일러가 결정할 수 없다는 게 모호성의 실체입니다. 그리고 C++은 이럴 때 “타입이 아니다”라고 기본 가정하기 때문에, typename을 안 붙이면 타입 선언으로 받아들여지지 않습니다.

㉠은 “종속”이라는 표현은 맞습니다(의존 이름 = 종속된 이름). 다만 “변수”가 아니라 “이름(name)” 입니다. 사소해 보이지만 여기서는 중요합니다 — 애초에 그게 변수인지 타입인지 모르는 것이 문제의 출발점인데, 이름을 “종속 변수”라고 부르면 이미 변수라고 단정해 버리는 셈이거든요. 정확한 용어는 의존 이름 / 비의존 이름입니다.

(3)은 다른 이야기를 하셨습니다.const_iterator와 같은 C++에서 정의하는 이름은 사용하면 안 된다”고 쓰셨는데, 이름 충돌 문제가 아닙니다. const_iterator는 STL 컨테이너가 실제로 제공하는 정상적인 타입 이름이고, 써야 하는 이름입니다.

다만 (2)㉡에서 “그렇기에 typename을 붙여야 한다”고 이미 답하셨기 때문에, 고치는 방법에 해당하는 절반은 인정했습니다. 정답 코드는 이렇습니다.

typename C::const_iterator iter(container.begin());

그리고 일반 규칙 한 문장은 이겁니다.

템플릿 안에서 중첩 의존 이름을 참조할 때는 그 앞에 typename을 붙인다.

여기에 한 가지 단서가 붙습니다 — 중첩 의존 이름을 식별하는 데만 씁니다. void f(const C& container, typename C::iterator iter) 에서 앞의 C에는 붙이지 않고 뒤의 C::iterator에만 붙이는 이유가 그겁니다.

출처: game_dev/cpp/chapter7/2025-10-29-cpp_7_42.md


📌 복습 포인트

  • Q1 게임 루프 — 세션은 1초에 60번 상태가 변한다(60fps의 그 60). 서버로 옮기는 건 “계산”이 아니라 게임 로직 — 클라이언트가 허튼 수를 못 부리게 하려는 목적 → server/game_server/4/2026-02-08-game_server_4_3.md
  • Q3 저장 프로시저 — 이름과 두 효과: 왕복 횟수 감소 + 미리 컴파일되어 DB의 준비 작업 제거server/game_server/7/2026-03-05-game_server_7_9.md
  • Q3 데드락 vs 경쟁 조건멈추면 데드락, 틀리면 경쟁 조건. 데드락이 닮은 것은 뮤텍스 잠금. 예방은 ① A→B 순서 통일 ② 레코드·액세스 최소화 ③ 오류 처리 → 같은 문서
  • Q3 게임 서버가 트랜잭션을 피하는 이유메모리에서 사전 검증을 끝내고 DB는 세이브 역할만 하므로. 크래시 대비는 트랜잭션이 아니라 로그를 남기고 성공하면 삭제 → 같은 문서
  • Q4 수직 분산 (근거 보강) — 옳은 서술: 중지된 서버가 담당하던 기능은 멈추고, 그 외 기능만 정상 작동한다server/game_server/9/2026-03-22-game_server_9_11.md
  • Q5 typename — 용어는 의존 이름 / 비의존 이름(변수 아님). 모호성의 실체는 C::const_iterator* x;포인터 선언 vs 곱셈으로 읽히는 것 → game_dev/cpp/chapter7/2025-10-29-cpp_7_42.md
  • 문제 조건 확인 — Q3(3)은 본문에 “트랜잭션을 쓰지 않고 있다” 고 명시돼 있었는데 답이 트랜잭션이었습니다. 답을 쓰기 전에 문제가 이미 준 조건을 한 번 확인하세요.

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

댓글남기기