[말랑 퀴즈] 26/09/14 해답
카테고리: MallangQuiz
태그: Quiz
제 말랑말랑 퀴즈 생성기는 이곳에서 확인하실 수 있습니다.
말랑말랑 퀴즈 — 해답지 ✅
날짜: 2026-09-14 문제 수: 6문제 총점: 50.2/60 (84점) ✅ 잘했어요
📊 채점 결과
| 문항 | 유형 | 난이도 | 결과 | 점수 |
|---|---|---|---|---|
| Q1 | 서술형 | 🟡 보통 | 🔺 부분 정답 | 4.8 / 10 |
| Q2 | 객관식 | 🔴 어려움 | ⭕ 정답 | 10 / 10 |
| Q3 | 서술형 · 📌 오답노트 | 🔴 어려움 | 🔺 부분 정답 | 6.8 / 10 |
| Q4 | 빈칸 | 🟢 쉬움 | 🔺 부분 정답 | 8.6 / 10 |
| Q5 | 객관식 | 🟡 보통 | ⭕ 정답 | 10 / 10 |
| Q6 | OX | 🟡 보통 | ⭕ 정답 | 10 / 10 |
| 합계 | 50.2 / 60 |
총평
84점, 최고 기록입니다. 그리고 만점이 세 문항이나 됩니다 — 이것도 처음입니다.
Q6이 특히 좋았습니다. 지난 회차 OX에서 0점을 받은 뒤 바로 다음 OX에서 근거까지 만점입니다. “응집도가 높으므로 한 서버에 모아야 한다 / 나누는 건 응집도가 낮을 때”— 명제가 뒤집어 놓은 두 지점을 정확히 되돌려 놓으셨습니다. Q4도 짚을 만합니다. 9월 9일에 “순서 보장”이라 틀렸던 일관성, 미응답이었던 임계 영역·뮤텍스·잠금 세 기법이 이번엔 전부 정답입니다.
Q3 오답노트는 6.8점으로 아직 stage 0입니다. 지난번(7.0)과 비슷한 점수지만 내용은 달라졌습니다 — 지난번에 빠졌던 “항목 수를 미리 고정” 과 “매핑 DB도 분산” 이 이번엔 들어왔고, 반대로 지난번에 맞혔던 샤드 키를 이번엔 “해시 키”라고 쓰셨습니다. 그리고 SPOF라는 이름은 두 번 연속 못 쓰셨습니다. 이 항목이 다음에 또 나올 텐데, 샤드 키 / SPOF / 그리고 (4)에서 이유 쓰기 — 이 셋만 챙기면 통과합니다.
Q1(4.8점)은 처음 나온 단원이라 낮을 만합니다. (2)의 이름 가림 원리는 정확했으니, using 선언과 전달 함수라는 두 해법만 익히면 됩니다.
Q1. 🟡 보통 — 🔺 4.8/10
문제: 상속 관계에서 이름이 어떻게 탐색되고 가려지는지에 대해 답하시오.
class Base {
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
};
class Derived : public Base {
public:
virtual void mf1();
void mf3();
void mf4();
};
Derived d; int x;
d.mf1(); // ✅ d.mf1(x); // ❌
d.mf2(); // ✅
d.mf3(); // ✅ d.mf3(x); // ❌
(1) mf4() 안에서 mf2()를 호출할 때 유효범위 탐색 순서
(2) d.mf1(x)·d.mf3(x)가 에러인 이유, 그리고 매개변수 차이·가상 여부가 영향을 주는가
(3) ㉠ public 상속에서의 해결법과 접근 영역 ㉡ private 상속에서 특정 버전만 필요할 때의 해결법
📝 내 풀이:
(1) 현재 클래스에서 mf2 함수 탐색 → 부모 클래스에서 mf2 함수 탐색
(2) 함수 선언이 덮이는 바람에 모호해졌기 때문 ㄱ: 매개변수가 다르지만, 이는 함수 이름을 동일하게 덮는다. ㄴ: 가상/비가상, 매개변수 여부와 상관없이 함수 이름을 덮는다.
(3) ㄱ:
Base::mf1(int),Base::mf3()와 같이 명확하게 사용한다. ㄴ: 이 문제는 어설프게 기억나서 다시 한번 봐야할것같다.
정답:
(1) 유효범위 탐색 순서 — 안쪽에서 바깥쪽으로
지역 변수가 전역 변수를 가리는 것과 같은 원리다. mf4() 안에서 mf2()를 만나면 컴파일러는 이렇게 뒤진다.
mf4함수의 유효범위 내부Derived클래스의 유효범위Base클래스의 유효범위 → 여기서mf2를 발견, 탐색 종료- (못 찾았다면)
Base를 담고 있는 네임스페이스 - (그래도 못 찾았다면) 전역 유효범위
가장 가까운 곳에서 이름을 찾는 순간 탐색이 끝난다. 이것이 (2)의 원인이다.
(2) 이름 가림(Name Hiding)
Derived에 mf1이라는 이름이 있으면, 컴파일러는 2단계(Derived 유효범위)에서 mf1을 찾고 거기서 멈춘다. Base까지 가지 않으므로 Base::mf1(int)는 존재하지 않는 것처럼 취급된다. 그래서 d.mf1(x)는 Derived::mf1()에 int를 넘기려다 실패한다. mf3도 똑같다.
즉 파생 클래스의 이름이 기본 클래스의 같은 이름을 통째로 가린다.
- ㉠ 매개변수가 다르다는 사실 — 영향을 주지 않는다. 이름 가림은 이름 단위로 일어난다.
mf1()과mf1(int)는 매개변수가 달라 오버로딩 관계지만, 컴파일러는 그걸 따지기 전에Derived에mf1이 있다는 이유만으로Base의mf1전부를 가린다. - ㉡ 가상/비가상 여부 — 영향을 주지 않는다.
mf1은 가상,mf3는 비가상인데 둘 다 똑같이 가려졌다.
C++이 이렇게 동작하는 이유는 라이브러리나 프레임워크를 이용해 파생 클래스를 만들 때, 멀리 떨어진 기본 클래스로부터 오버로드 버전이 의도치 않게 상속되는 것을 막기 위해서다. 다만 public 상속에서는 이 가림이 바람직하지 않다 — public 상속은 is-a 관계이므로 Base가 할 수 있는 일은 Derived도 할 수 있어야 하기 때문이다.
(3)㉠ public 상속 — using 선언으로 가려진 이름을 끄집어낸다
class Derived : public Base {
public:
using Base::mf1; // Base의 mf1이라는 이름을 가진 것들을 전부 보이게 한다
using Base::mf3;
virtual void mf1();
void mf3();
void mf4();
};
d.mf1(x); // ✅ Base::mf1(int) 호출
d.mf3(x); // ✅ Base::mf3(double) 호출
using 선언은 public 영역에 두어야 한다. 기본 클래스의 public 영역에 있는 이름들은 파생 클래스에서도 public이어야 하기 때문이다 — public 상속에서 Base의 public 인터페이스가 Derived에서 갑자기 private이 되면 is-a가 깨진다.
㉡ private 상속에서 특정 버전만 — 전달 함수(forwarding function)
using 선언은 그 이름에 해당하는 오버로드를 전부 끄집어낸다. mf1()만 원하고 mf1(int)는 원하지 않는다면 using으로는 골라낼 수 없다. 이때 전달 함수를 쓴다.
class Derived : private Base {
public:
virtual void mf1() // 전달 함수 — 암시적으로 인라인이 된다
{ Base::mf1(); }
};
d.mf1(); // ✅ Derived::mf1() → Base::mf1() 호출
d.mf1(x); // ❌ 여전히 가려져 있다 — 의도한 대로
using 선언 |
전달 함수 | |
|---|---|---|
| 사용 상황 | public 상속 | private/protected 상속 |
| 상속 범위 | 해당 이름의 모든 오버로드 | 특정 버전만 선택 가능 |
| 코드 간결성 | 한 줄 | 함수마다 작성 |
🔍 피드백: 논점별로 (1) 1.5 / (2) 2.9 / (3) 0.4를 받아 4.8점입니다.
(2)가 거의 만점입니다. “함수 선언이 덮인다”가 곧 이름 가림이고, ㉠㉡ 모두 “상관없이 덮는다”고 정확히 답하셨습니다. 이게 이 항목의 핵심 원리라 절반은 잡으신 셈입니다. 한 단어만 다듬으면 좋겠습니다 — “모호해졌기 때문”이라 쓰셨는데, 모호한 게 아니라 아예 안 보이는 겁니다. 컴파일러가 Derived에서 mf1을 찾자마자 탐색을 멈추기 때문에 Base::mf1(int)는 후보에 오르지도 못합니다. “둘 중 뭘 고를지 모르겠다”가 아니라 “그런 건 없다”는 에러입니다.
(1)은 뼈대는 맞는데 양 끝이 빠졌습니다. “현재 클래스 → 부모 클래스”라고 하셨는데, 문제가 “처음부터 끝까지, 못 찾았을 때 마지막으로 가는 곳까지” 를 요구했습니다. 그 앞에 mf4 함수 자체의 유효범위(지역 변수부터 먼저 본다)가 있고, 그 뒤에 네임스페이스 → 전역 유효범위가 이어집니다. 지역 변수가 전역 변수를 가리는 것과 같은 규칙이 클래스에도 적용된다는 게 이 단원의 도입부입니다.
(3)㉠에서 다른 해법을 쓰셨습니다. Base::mf1(int)처럼 한정 이름으로 직접 호출하는 것은 컴파일은 되지만 이 항목의 해법이 아닙니다. 그렇게 하면 Derived를 쓰는 모든 코드가 호출할 때마다 Base::를 붙여야 하니까요 — 문제를 사용자에게 떠넘기는 셈입니다. 정답은 클래스 정의 안에서 한 번 선언하는 것입니다.
class Derived : public Base {
public:
using Base::mf1; // ← 이 한 줄
using Base::mf3;
...
};
그리고 public 영역에 둡니다. 이유는 public 상속의 정의 그 자체입니다 — 기본 클래스에서 public이던 이름이 파생 클래스에서도 public이어야 is-a가 성립하니까요.
㉡는 미응답이라 정답만 정리해 둡니다. using 선언은 그 이름의 오버로드를 전부 끄집어내므로, mf1()만 원하고 mf1(int)는 원하지 않을 때는 쓸 수 없습니다. 그때는 전달 함수를 씁니다 — 원하는 버전 하나를 직접 정의하고 안에서 Base::mf1()을 부르는 겁니다. 두 해법의 구분 기준은 “전부 필요한가, 골라야 하는가” 입니다.
출처:
game_dev/cpp/chapter6/2025-10-19-cpp_6_33.md
Q2. 🔴 어려움 — ⭕ 10/10
문제: 두 코드에 대한 설명으로 가장 올바른 것은?
코드 ①
class SpecialWindow : public Window {
public:
virtual void onResize() {
static_cast<Window>(*this).onResize();
...
}
};
코드 ②
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
if (SpecialWindow* psw = dynamic_cast<SpecialWindow*>(iter->get()))
psw->blink();
}
- A. 코드 ①의
static_cast<Window>(*this).onResize()는 현재 객체의 기본 클래스 부분에 대해Window::onResize를 호출하는 올바른 방법이다.Window::onResize();라고 쓰는 것과 동작이 같으며, 캐스트를 명시했으므로 오히려 의도가 더 분명하다. - B. 코드 ②의
dynamic_cast는 vtable 포인터를 한 번 비교하는 것으로 끝나므로 빠르다. 따라서SpecialWindow1,SpecialWindow2,SpecialWindow3… 를else if로 이어 붙이는 폭포식dynamic_cast도 가독성이 떨어질 뿐 성능상 문제는 없다. - C. 코드 ①의
static_cast<Window>(*this)는*this의 기본 클래스 부분에 대한 사본을 임시로 생성하므로,onResize가 현재 객체가 아니라 그 임시 객체에 대해 호출된다. 올바른 방법은Window::onResize();다. 코드 ②의dynamic_cast는 클래스 이름에 대한 문자열 비교 기반이라 매우 느리므로, 원하는 조작을 가상 함수 집합으로 정리해 기본 클래스에 넣어 캐스팅 자체를 없애는 편이 낫다. - D. 신형 캐스트 네 가지 중 객체의 상수성을 없앨 수 있는 것은
static_cast와const_cast둘이다.static_cast는 암시적 변환을 강제하는 것이므로const를 떼는 방향도 가능하기 때문이다.
📝 내 선택: C — A: *this는 사본이므로 동작이 다르다. / B: dynamic_cast 자체가 무엇인지 까먹었는데, C가 맞는 것 같아서 문자열 비교 기반이었던 것 같다. C가 맞다면 성능상 문제가 생길 것이다. / D: 상수성을 없앨 수 있는 건 const_cast뿐이다.
정답: C
해설:
코드 ① — 캐스팅이 사본을 만든다
static_cast<Window>(*this)는 참조나 포인터가 아니라 Window 값으로 캐스팅한다. 그 결과 *this의 기본 클래스 부분이 복사되어 임시 Window 객체가 생기고, onResize()는 그 임시 객체에 대해 호출된다. 현재 객체는 전혀 건드려지지 않는다.
의도 : 현재 객체의 Window 부분에 대해 onResize() 실행
실제 : 현재 객체를 복사한 임시 Window에 대해 onResize() 실행 → 결과가 임시 객체와 함께 사라진다
Window::onResize()가 멤버를 수정한다면 그 수정은 임시 객체에만 반영되고, 현재 객체는 마치 기본 클래스 부분의 onResize가 불리지 않은 것처럼 된다. 올바른 방법은 캐스팅 없이 한정 이름으로 직접 호출하는 것이다.
virtual void onResize() {
Window::onResize(); // ✅ 현재 객체에 대해 기본 클래스 버전 호출
...
}
코드 ② — dynamic_cast는 느리다
dynamic_cast는 클래스 이름에 대한 문자열 비교 연산을 기반으로 구현되어 있어 정말 느리다. 상속 깊이가 깊거나 다중 상속이면 비용이 더 커진다. 속도가 중요한 코드에서는 깊게 고민해야 한다.
해결 방향은 캐스팅이 필요 없게 만드는 것이다.
- 방법 1 — 타입별 컨테이너:
vector<shared_ptr<SpecialWindow>>를 따로 두면 캐스팅 없이blink()를 부를 수 있다. 다만Window에서 파생된 모든 객체를 한 컨테이너에 담을 수 없다는 한계가 있다. - 방법 2 — 가상 함수:
Window에 아무 동작도 하지 않는virtual void blink() {}를 넣고SpecialWindow가 재정의하면, 컨테이너의 모든 원소에 대해 그냥(*iter)->blink()를 부르면 된다. 원하는 조작을 가상 함수 집합으로 정리해 기본 클래스에 넣는 것이 핵심이다.
class Window {
public:
virtual void blink() {} // 기본: 아무것도 안 함
};
class SpecialWindow : public Window {
public:
virtual void blink() { ... } // 재정의
};
for (...) (*iter)->blink(); // ✅ 캐스팅 없음
- A — 정반대다. 캐스팅이 사본을 만들기 때문에 호출 대상이 현재 객체가 아니다. “동작이 같다”는 것은 사실이 아니며, 캐스트를 명시해서 의도가 분명해지기는커녕 버그가 숨는다.
- B —
dynamic_cast는 vtable 비교가 아니라 문자열 비교 기반이라 매우 느리다. 폭포식dynamic_cast는 속도가 매우 느리고,Window클래스 계통이 바뀔 때마다 그 코드도 함께 고쳐야 해서 망가지기 쉽다. 피해야 할 설계다. - D — 상수성을 없애는 것은
const_cast가 유일하다.static_cast는 비상수 → 상수 방향의 변환은 하지만(암시적 변환이므로) 그 반대는 못 한다.
| 캐스트 | 용도 |
|---|---|
const_cast |
상수성 제거 — 유일한 방법 |
dynamic_cast |
안전한 다운캐스팅 — 런타임 비용 높음 |
reinterpret_cast |
하부 수준 캐스팅(포인터↔정수) — 이식성 없음 |
static_cast |
암시적 변환 강제(int→double, 비상수→상수, 기본→파생 포인터, void→T) |
정리: 다른 방법이 가능하다면 캐스팅은 피하고, 꼭 필요하다면 함수 안에 숨기고, 구형 스타일보다 C++ 스타일 캐스트를 쓴다.
🔍 피드백: 만점입니다. 어려움 난이도 객관식 네 회차 연속 정답입니다.
D를 쳐낸 근거가 정확합니다. “상수성을 없앨 수 있는 건 const_cast뿐”— 네 캐스트의 역할 구분 중 가장 자주 헷갈리는 지점인데 확실히 알고 계십니다.
A의 근거는 표현을 조금 고치면 좋겠습니다. “*this는 사본이므로”라고 하셨는데, *this 자체는 사본이 아니라 현재 객체 그 자체입니다. 사본을 만드는 건 static_cast<Window>(...) 쪽입니다 — Window 값으로 캐스팅하면 기본 클래스 부분이 복사되어 임시 객체가 생기고, onResize()는 그 임시 객체에 대해 불립니다. 이 구분이 중요한 이유는, 같은 static_cast라도 참조로 캐스팅하면(static_cast<Window&>(*this)) 사본이 생기지 않기 때문입니다. 함정의 정체는 *this가 아니라 값 캐스팅입니다.
B는 솔직하게 “C에 기대어 추론했다”고 쓰셨는데, 그 추론이 맞았고 근거를 채워 드립니다. dynamic_cast는 안전한 다운캐스팅 — “이 Window*가 사실은 SpecialWindow인가?”를 런타임에 확인하는 캐스트입니다. 그 확인이 클래스 이름 문자열 비교로 구현되어 있어 정말 느리고, 상속이 깊거나 다중 상속이면 더 느려집니다. 그래서 B의 “vtable 비교 한 번”은 틀렸고, 폭포식으로 이어 붙이는 건 느린 데다 클래스 계통이 바뀔 때마다 깨지는 최악의 설계입니다. 이 항목이 제시하는 탈출구는 캐스팅이 필요 없게 만드는 것 — Window에 빈 virtual void blink() {}를 두고 그냥 부르는 겁니다.
출처:
game_dev/cpp/chapter5/2025-07-09-cpp_5_27.md
Q3. 🔴 어려움 · 📌 오답노트 — 🔺 6.8/10
문제: 샤드 10개인 유저 DB를 늘려야 한다. 제안 A(일관된 해시)와 제안 B(매핑 DB)를 검토하시오.
(1) 현재 방식에서 10 → 11로 늘리면 무슨 일이 왜 벌어지고 운영에 어떤 결과를 낳는가? 해시에 넣는 키의 이름은? (2) 제안 A — ㉠ 일반 해시와의 차이(항목 수, 대응 관계) ㉡ 10 → 11일 때 옮길 범위 ㉢ 여전히 비싼 상황 (3) 제안 B — ㉠ 이동이 0인 이유(“계산”과 “조회”) ㉡ 새 위험의 이름(한국어·약어)과 대응책 (4) 상황 ㉮(매달 1개씩)와 ㉯(10 → 40 한꺼번에)에서 각각 무엇을 고를까?
📝 내 풀이:
(1) 전체적으로 해시 재할당이 발생해, 서버가 오래 멈추게 된다. / 해시 키
(2) ㄱ: 항목 수를 미리 여러개 할당해 놓고, 샤드와 해시가 n:1 대응된다. ㄴ: 샤드의 영향을 받은 범위만 레코드를 옮기고 해시 재할당을 해주면 된다. ㄷ: 많은 양의 변경이 들어올때.
(3) ㄱ: 따로 계산할 필요없이 매핑 DB는 키를 기반으로 조회하여 값만 얻으면 되기 때문에, 추가만 하면 된다. ㄴ: DB 오버헤드(overhead). 매핑 DB 서버도 분산하여 둔다.
(4) 가: A, 나: B
정답:
(1) 현재 방식의 문제 — 리해시
hash(ID) % 10으로 샤드를 정하고 있으므로, 샤드 수가 11이 되면 % 11로 바뀌어 거의 모든 ID의 계산 결과가 달라진다. 항목 개수가 달라졌으므로 데이터 항목 재배치가 필요하고, 기존 레코드도 전부 리해시해야 한다. 이것이 리해시다.
운영 결과: 리해시해야 하는 레코드가 많으면 게임 서버를 장시간 점검해야 한다. 서버를 늘리려다 서비스를 멈추게 되는 셈이다.
해시 함수에 넣는 키(ID)는 샤드 키(파티션 키) 라고 부른다.
(2) 제안 A — 일관된 해시 알고리즘
㉠ 일반 해시 테이블과의 차이
| 일반 해시 테이블 | 일관된 해시 | |
|---|---|---|
| 항목 수 | 샤드 수와 같다 (10개면 10칸) | 크게 잡아 고정한다 (예: 0~999, 샤드 수와 무관) |
| 샤드 ↔ 해시 값 | 일대일 대응 | 샤드 하나가 해시 값 다수를 맡는다 (1:多) |
항목 수를 고정해 두면 샤드 수가 바뀌어도 해시 값 자체는 변하지 않는다. 바뀌는 것은 “어느 샤드가 어느 구간을 맡느냐”뿐이다.
㉡ 10 → 11일 때 옮기는 범위 — 샤드 1개분만
[ 샤드 10개 ] 샤드 0: 0~99 샤드 1: 100~199 … 샤드 9: 900~999
[ 샤드 11개 ] 샤드 0: 0~99 샤드 1: 100~199 … 샤드 9: 900~949 샤드 10: 950~999
└── 원래 샤드 9가 맡던 구간을 둘이 나눠 가진다
샤드 개수 변동 1개당 샤드 1개만 재배치하면 된다. 기존 샤드 하나가 맡던 해시 집합을 새 샤드와 나누어 가지고, 나머지 샤드 9개는 전혀 건드리지 않는다. 해시 테이블은 링형으로 보아야 한다(마지막 샤드와 첫 샤드가 인접).
㉢ 여전히 비싼 상황 — 샤드 수가 크게 변할 때
“변동 1개당 1개분”이므로, 샤드가 크게 늘어나거나 줄어들면 결국 많은 양을 리해시해야 한다. 이 방식은 문제를 없앤 것이 아니라 점진적 증설에 한해 완화한 것이다. 데이터 양이 아니라 샤드 수의 변동 폭이 비용을 결정한다.
(3) 제안 B — 매핑 DB
㉠ 이동이 0인 이유 — “계산”에서 “조회”로
제안 A(그리고 현재 방식)는 샤드 위치가 계산 결과로 정해진다. 그래서 샤드 수라는 입력이 바뀌면 계산 결과도 바뀌고, 그만큼 옮겨야 한다.
매핑 DB는 ID → 샤드 인덱스를 표에 기록해 두고 조회한다. 위치가 계산이 아니라 기록이므로 샤드 수와 무관하다. 새 샤드를 추가하면 새 유저부터 거기에 적어 넣기만 하면 된다 — 기존 레코드는 하나도 옮기지 않는다.
① 클라이언트 → 인증 서버 중 하나에 접속
② 인증 서버 → 매핑 DB에 질의 → DB 인덱스(샤드 넘버) 획득
③ 인증 서버 → 해당 유저 DB에 CRUD 질의
(단 샤드 제거는 여전히 일이 있다 — 매핑 DB에서 레코드를 옮기고 유저 DB에도 바뀐 위치를 반영해야 한다.)
㉡ 새로 떠안는 위험 — 단일 실패 지점(SPOF)
모든 접속이 매핑 DB를 거치므로, 매핑 DB에 과부하가 걸리거나 죽으면 전체가 의미를 잃는다. 유저 DB의 병목을 없애려고 만든 구조가 새 병목을 만든 셈이다. 이것이 단일 실패 지점(SPOF, Single Point Of Failure) 이다.
대응책은 매핑 DB에도 확장성을 부여하는 것 — 매핑 DB의 레코드도 여러 샤드에 나누어 여러 대로 운영한다. 유저 DB에 적용한 처방을 매핑 DB에 다시 적용하는 것이다.
(4) 상황별 선택
- 상황 ㉮(매달 1개씩) → 제안 A(일관된 해시)로 충분하다. 한 번에 1개씩 늘리면 매번 샤드 1개분만 재배치하면 되므로 비용이 작고, 매핑 DB라는 새 구성 요소(그리고 그 SPOF 위험)를 떠안을 필요가 없다.
- 상황 ㉯(10 → 40 한꺼번에) → 제안 B(매핑 DB)가 낫다. 일관된 해시는 30개분을 재배치해야 하므로 (2)㉢의 “여전히 비싼 상황”에 정확히 해당한다. 매핑 DB는 이동이 0이다. 다만 매핑 DB 자체를 처음부터 샤딩해 SPOF를 없애고 시작해야 한다.
즉 점진적 증설이면 A, 대규모 변경이면 B다. 두 제안은 우열이 아니라 변동 폭에 따른 선택이다.
덧붙임 — 인증 정보(Credential): 매핑 DB를 거쳐 인증한 뒤 다른 서버에서 또 인증을 거치는 것은 비효율적이다. 인증 서버가 비밀 징표(Credential) 를 발급하면, 클라이언트는 그것으로 다른 서버에 재인증 없이 로그인한다.
🔍 피드백: 논점별로 (1) 1.5 / (2) 2.0 / (3) 1.9 / (4) 1.4를 받아 6.8점입니다. 지난번(7.0)과 비슷한 점수지만 맞고 틀린 자리가 바뀌었습니다.
나아진 곳 세 군데.
- (2)㉠ “항목 수를 미리 여러 개 할당” — 지난번에 빠졌던 전제를 이번엔 쓰셨습니다. 이게 일관된 해시의 출발점입니다.
- (2)㉢ “많은 양의 변경” — 지난번엔 “데이터가 많이 들어올 때”라 하셨는데, 이번엔 변경이라고 쓰셔서 방향이 맞았습니다. 조금만 더 명확히 — 샤드 수가 크게 변할 때입니다.
- (3)㉡ 대응책 “매핑 DB 서버도 분산” — 지난번 “예비 서버”에서 정답으로 옮겨 왔습니다. 유저 DB에 쓴 처방을 매핑 DB에 다시 적용하는 것, 맞습니다.
후퇴한 곳 하나. (1)의 키 이름을 “해시 키” 라고 쓰셨는데, 지난번엔 샤드 키라고 정확히 답하셨습니다. 문제 문구(“해시에 넣는 키”)를 그대로 되돌려 쓴 것 같습니다. 이 키의 이름은 샤드 키(파티션 키) — 무엇에 넣느냐가 아니라 무엇을 고르는 데 쓰느냐로 이름이 붙습니다.
두 번 연속 못 쓰신 곳 하나. (3)㉡의 위험 이름입니다. “DB 오버헤드”라고 하셨는데, 이 위험의 이름은 단일 실패 지점(SPOF, Single Point Of Failure) 입니다. “오버헤드”는 부하가 늘어난다는 뜻이고, SPOF는 여기 하나가 죽으면 전부 죽는다는 뜻이라 성격이 다릅니다. 매핑 DB가 위험한 이유는 느려서가 아니라 모든 접속이 반드시 거쳐 가는 유일한 관문이기 때문입니다. 대응책을 “분산”이라고 정확히 쓰셨으니, 그 이유가 곧 이름입니다 — 단일이라서 분산하는 겁니다.
(4)는 선택은 둘 다 맞았는데 이유가 없습니다. 문제가 “이유와 함께”라고 명시했으니 절반만 드렸습니다. 이유는 (2)㉢과 (3)㉠에서 이미 다 답하셨습니다 — 매달 1개씩이면 1개분만 옮기면 되니 A로 충분하고, 한꺼번에 30개면 일관된 해시도 30개분을 옮겨야 하니 이동이 0인 B가 낫습니다. 앞에서 쓴 답을 뒤에서 한 줄로 연결하기만 하면 됐습니다.
다음에 이 항목이 또 나올 때 챙길 것은 딱 셋입니다 — 샤드 키 / SPOF / 이유 쓰기. 나머지는 이미 잡혀 있습니다.
출처:
server/game_server/9/2026-03-26-game_server_10_2.md오답노트:wn-1da60a18· 2026-09-09 최초 오답 · 이번이 2번째 복습
Q4. 🟢 쉬움 — 🔺 8.6/10
문제: 빈칸을 채우시오.
여러 스레드가 같은 데이터를 건드릴 때 그 상태를 예측할 수 없게 되는 상황을 ① ___ 라고 한다. 이를 막으려면 두 가지 성질 — ② ___(작업 단위가 쪼개지지 않게), ③ ___(여러 멤버가 항상 서로 어울리게) — 을 보장해야 한다. 이를 가능하게 하는 기술을 통틀어 ④ ___ 라 하며, 대표적으로 ⑤ ___(Critical Section), ⑥ ___(Mutex), ⑦ ___(Lock)이 있다.
📝 내 답: 레이스 컨디션(경쟁 상태), 원자성, 일관성, ?, 임계 영역, 뮤텍스, 잠금
정답:
① 경쟁 상태 (데이터 레이스, race condition) ② 원자성 (Atomicity) ③ 일관성 (Consistency) ④ 동기화 (Synchronization) ⑤ 임계 영역 ⑥ 뮤텍스 ⑦ 잠금
해설:
① 경쟁 상태(데이터 레이스) — 두 스레드가 데이터에 접근해서 그 데이터 상태를 예측할 수 없게 되는 상황이다. x += y가 기계어 수준에서 t = x / t = t + y / x = t 세 연산으로 쪼개지기 때문에, 읽기와 쓰기 사이에 다른 스레드가 끼어들면 한쪽의 작업이 통째로 사라진다.
② 원자성 — 하나의 작업 단위가 절대 쪼개지지 않게 만든다. num++, primes.Add() 같은 연산은 반드시 하나의 원자적 동작으로 보장해야 한다. 원자성이 없으면 여러 스레드가 같은 값을 중복 처리하거나 어떤 값을 건너뛴다.
③ 일관성 — 여러 멤버 변수가 항상 서로 어울리는 상태여야 한다. Array<int>는 내부에 배열 포인터와 크기 정보를 갖는데, 재할당 도중 포인터는 바뀌었고 크기는 아직인 순간에 다른 스레드가 끼어들면 이미 해제된 메모리에 접근해 충돌한다. 재할당 중간에 끼어들지 못하도록 보호해야 한다.
④ 동기화 — 이 두 성질을 가능하게 하는 기술의 통칭이다. 요지는 공유 데이터에 접근하는 순간만큼은 다른 스레드가 접근하지 못하도록 막는 것이다.
⑤⑥⑦ 임계 영역 / 뮤텍스 / 잠금 — 동기화를 구현하는 대표적인 세 기법이다.
| 성질 | 막아 주는 문제 |
|---|---|
| 원자성 | num++가 중간에 끊겨 값이 중복되거나 건너뛰는 것 |
| 일관성 | primes의 포인터와 크기가 어긋난 순간에 끼어드는 것 |
🔍 피드백: 7칸 중 6칸이 정답이라 8.6점입니다.
9월 9일과 비교하면 확실한 진전입니다. 그때 서술형 (3)에서 두 번째 성질을 “순서 보장”이라 쓰셨고 기법 세 가지는 미응답이었는데, 이번엔 일관성도 임계 영역·뮤텍스·잠금도 전부 정답입니다. 닷새 사이에 자리를 잡았습니다.
④ 하나가 비었는데, 정답은 동기화(Synchronization) 입니다. 채우신 ⑤⑥⑦을 묶어 부르는 이름이라, 세 기법을 알면 자동으로 따라오는 칸입니다. 문장 구조를 보시면 “이 두 성질을 보장하는 기술을 통틀어 ④라 하며, 대표적으로 ⑤⑥⑦이 있다”— ④는 상위 개념, ⑤⑥⑦은 그 구현체입니다. “동기화 = 임계 영역 / 뮤텍스 / 잠금” 으로 한 덩어리로 기억해 두세요.
출처:
server/game_server/1/2025-05-30-game_server_1_5.md
Q5. 🟡 보통 — ⭕ 10/10
문제: Timer를 이용해 Widget을 만드는 두 방법(㉠ private 상속, ㉡ 객체 합성)에 대한 설명으로 가장 올바른 것은?
- A. private 상속의 의미는 public 상속과 마찬가지로 is-a다. 그래서
Widget : private Timer라고 쓰면Widget객체를Timer&를 받는 함수에 그대로 넘길 수 있고,Timer의 public 멤버는Widget에서도 public으로 남는다. - B. 방법 ㉠이 더 낫다. private 상속을 쓰면
Widget을 상속한 클래스가onTick을 다시 재정의하지 못하도록 설계 차원에서 막을 수 있어 통제가 쉽고, 코드도 ㉡보다 짧기 때문이다. - C. 방법 ㉡이 권장된다. 이유는 두 가지다 — ①
WidgetTimer가Widget의 private 중첩 클래스이므로Widget의 파생 클래스가onTick을 재정의할 수 없게 설계 차원에서 막을 수 있고, ②WidgetTimer의 정의를Widget밖으로 빼내고 포인터만 갖게 하면 컴파일 의존성을 최소화할 수 있다. private 상속은 protected 멤버 접근·가상 함수 재정의·공백 기본 클래스 최적화가 꼭 필요할 때의 예외적 선택이다. - D. 두 방법은 완전히 같은 결과를 내므로 취향의 문제다. 다만 private 상속은 인터페이스와 구현을 모두 물려받는 반면 객체 합성은 구현만 가져오므로, 인터페이스 재사용이 필요하면 ㉠을 써야 한다.
📝 내 선택: C — A: private 상속의 의미는 is-implemented-in-terms-of이다. Timer의 public 멤버는 Widget에서 private로 남는다. / B: 특정한 상황 빼면 ㉡이 더 나았던 것으로 기억난다. 그리고 재정의가 가능하다. / D: 같은 결과를 내지도 않고 취향의 문제도 아니다.
정답: C
해설:
기본 원칙은 “할 수 있다면 객체 합성을 사용하고, 꼭 해야만 한다면 private 상속을 사용하자” 다. 현실적으로 private 상속보다 public 상속 + 객체 합성이 훨씬 자주 쓰인다. 이유는 두 가지다.
이유 1 — 설계 제어
class Widget {
private:
class WidgetTimer : public Timer { // Widget만 아는 private 중첩 클래스
public:
virtual void onTick() const;
};
WidgetTimer timer;
};
WidgetTimer가 Widget의 private 영역에 있으므로, Widget을 상속한 클래스는 WidgetTimer에 접근할 수 없고 따라서 onTick을 재정의할 수 없다. “Widget 파생은 허용하되 onTick 재정의는 막는다”는 설계 의도를 코드로 강제할 수 있다.
반면 방법 ㉠(private 상속)에서는 onTick이 Widget의 private 가상 함수로 남지만, 가상 함수는 접근 지정자와 무관하게 파생 클래스에서 재정의할 수 있으므로 막을 수 없다.
이유 2 — 컴파일 의존성 최소화
WidgetTimer의 정의를 Widget으로부터 빼내고 Widget이 WidgetTimer 객체에 대한 포인터만 갖도록 만들면, Widget을 쓰는 코드가 Timer의 정의를 볼 필요가 없어진다. private 상속에서는 기본 클래스의 정의가 반드시 보여야 하므로 이 분리가 불가능하다.
private 상속을 써야 하는 예외적인 경우 세 가지
- 기본 클래스의 protected 멤버에 접근해야 할 때
- 상속받은 가상 함수를 재정의해야 하는데 객체 합성으로 복잡해질 때
- 공백 기본 클래스 최적화(EBO) 가 필요할 때
- A — private 상속의 의미는 is-a가 아니라 is-implemented-in-terms-of(…는 …를 써서 구현된다)다. 컴파일러는 파생 객체를 기본 객체로 변환하지 않으므로
Timer&에Widget을 넘길 수 없고, 물려받은 멤버는 모조리 private이 된다. - B — 정반대다. private 상속으로는
onTick재정의를 막을 수 없다. 그것을 막을 수 있는 쪽이 객체 합성이다. - D — “같은 결과”가 아니다. 설계 제어와 컴파일 의존성에서 차이가 난다. 그리고 private 상속은 구현만 물려받고 인터페이스는 물려받지 못한다 — 인터페이스 재사용이 필요하면 public 상속을 써야 한다.
| 특징 | 객체 합성 | private 상속 |
|---|---|---|
| 의미 | has-a 또는 is-implemented-in-terms-of | is-implemented-in-terms-of |
| 설계 제어 | 재정의 방지 가능 | 재정의 방지 불가 |
| 컴파일 의존성 | 최소화 가능 | 증가 |
| EBO | 불가 | 가능 |
| protected 접근 | 불가 | 가능 |
🔍 피드백: 만점입니다. 그리고 A를 쳐낸 근거가 이 단원의 핵심 문장 그대로입니다 — “private 상속의 의미는 is-implemented-in-terms-of이고, 물려받은 public 멤버는 private로 남는다.” 8월 26일 이 단원의 OX(EBO)에서 0점이었던 걸 생각하면, 정의 자체가 확실히 자리 잡았습니다.
B의 “재정의가 가능하다”도 정확합니다. 그게 B가 틀린 이유의 전부입니다 — private 상속은 onTick을 private 가상 함수로 두지만, 가상 함수는 접근 지정자와 무관하게 파생 클래스가 재정의할 수 있으므로 설계 제어가 안 됩니다. 반대로 객체 합성은 WidgetTimer를 Widget의 private 중첩 클래스로 숨겨 두어, Widget의 파생 클래스가 그 존재 자체에 접근하지 못하게 막습니다. “재정의를 막는다”의 실체가 접근 지정자가 아니라 클래스를 숨기는 것이라는 점을 함께 기억해 두세요.
“특정한 상황 빼면 ㉡이 더 나았다”는 기억도 맞고, 그 특정한 상황이 원문의 세 가지입니다 — protected 멤버 접근 / 가상 함수 재정의 / EBO. 셋 중 EBO가 8월에 나왔던 그 문제이고, 이번 문항의 onTick이 두 번째(가상 함수 재정의)에 해당하는데도 객체 합성이 권장되는 건 중첩 클래스로 우회할 수 있기 때문입니다.
출처:
game_dev/cpp/chapter6/2025-10-26-cpp_6_39.md
Q6. 🟡 보통 — ⭕ 10/10
문제: 다음 명제가 참(O)인지 거짓(X)인지 판단하라.
플레이어 간 상호작용은 다른 처리들과 성격이 다르다. PVP에서는 간발의 차이로 승부가 결정되므로 정확해야 하고, 그만큼 높은 성능 수준이 요구된다. 기관총 발사처럼 횟수가 매우 잦을 때도 있다. 결과가 모두 반영되거나 전혀 반영되지 않아야 하는 원자성이 필요하며, 히트 판정처럼 지연 시간에 큰 영향을 받는다.
이런 성질들 때문에 플레이어 간 상호작용은 여러 서버에 적극적으로 분산해야 한다. 한 서버에 몰아 두면 정확성과 성능을 모두 만족시킬 수 없기 때문이다. 특히 응집도가 높은 플레이어들, 즉 서로 자주 부딪히는 플레이어들을 서로 다른 서버에 나눠 두어야 부하가 고르게 퍼져 효과가 크다.
📝 내 답: X — 플레이어 간 상호작용은 응집도가 높으므로 한 서버에 모아야 한다. 다른 서버에 나누어야 하는 것은 응집도가 낮을 때이다.
정답: X
해설:
첫 문단은 전부 참이다. 플레이어 간 상호작용의 다섯 가지 특징이 정확히 서술되어 있다.
| 특징 | 내용 |
|---|---|
| 정확성 | 간발의 차이로 승부가 결정된다 |
| 성능 | 높은 성능 수준이 요구된다 |
| 빈도 | 횟수가 매우 잦을 때가 있다 (기관총 발사) |
| 원자성 | 모두 반영되거나 전혀 반영되지 않아야 한다 |
| 지연 시간 | 히트 판정처럼 지연에 큰 영향을 받는다 |
둘째 문단은 결론이 정반대다. 원문의 결론은 이렇다.
플레이어 간 상호작용은 분산하지 않는 것이 일반적이다. 해야 한다면 응집도가 낮은 플레이어들을 분산한다. (예: 지역적 분산 처리)
명제는 두 군데를 뒤집었다.
① “적극적으로 분산해야 한다” → 거짓. 분산하지 않는 것이 일반적이다.
첫 문단의 다섯 특징이 바로 분산하면 안 되는 이유다. 같은 전투에 있는 두 플레이어를 서로 다른 서버에 두면 매 발사마다 서버를 넘나드는 통신이 생기고, 그 통신 하나하나가 지연이 되어 정확성을 깎아먹으며, 두 서버에 걸친 결과의 원자성을 보장하기도 어려워진다. 즉 특징을 근거로 삼았으면 결론은 “나누지 마라”가 되어야 한다.
② “응집도가 높은 플레이어들을 나눈다” → 거짓. 응집도가 낮은 플레이어들을 나눈다.
꼭 분산해야 한다면 서로 만날 일이 없는 — 응집도가 낮은 — 플레이어들끼리 갈라놓는다. 예컨대 지역적 분산(아시아 서버 / 미국 서버)이 그렇다. “서로 자주 부딪히는 플레이어를 나눈다”는 것은 가장 통신이 많이 필요한 쌍을 굳이 서버 경계 너머에 두는 것이라 최악의 선택이다.
이 결론은 앞선 분산 원칙들과 그대로 이어진다 — 9.10의 “분산 처리를 피할 수 있다면 피하자”, “응집력을 확인하고 분산하자”, 그리고 10.3에서 응집도가 낮은 플레이어의 데이터는 동기화하지 않았던 것과 같은 논리다.
참고: 지연 시간이 히트 판정에 미치는 영향을 보정하는 기법이 Server Side Rewind다. 서버가 과거 시점의 캐릭터 위치를 되감아 판정하는 방식으로, 분산과는 다른 층위의 해법이다.
🔍 피드백: 판단 7점, 근거 3점 모두 만점입니다.
지난 회차 OX에서 0점을 받은 직후의 문항인데, 이번엔 근거까지 완벽합니다. 명제가 뒤집어 놓은 두 지점을 정확히 되돌려 놓으셨습니다.
| 명제의 주장 | 쓰신 반박 |
|---|---|
| 적극적으로 분산해야 한다 | 응집도가 높으므로 한 서버에 모아야 한다 ✅ |
| 응집도가 높은 플레이어들을 나눈다 | 나누어야 하는 것은 응집도가 낮을 때다 ✅ |
특히 좋은 건 “응집도가 높으므로” 라는 이유를 붙이신 점입니다. 첫 문단의 다섯 특징(정확성·성능·빈도·원자성·지연)이 곧 “이 처리는 응집도가 극단적으로 높다”는 말이고, 그래서 나누면 안 된다는 논리가 한 줄로 이어집니다. 지난 회차 피드백에서 드린 “참인 사실 뒤에 ‘그러므로’가 붙으면 그 연결을 의심하라” 를 정확히 적용하신 결과입니다 — 다섯 특징은 참이지만, 거기서 “그러므로 분산하라”는 따라 나오지 않습니다.
이 판단은 9.10(분산 전략)·10.3(매치메이킹)·10.5(플레이어 상호작용) 세 단원을 관통하는 응집도 원칙이 잡혔다는 뜻입니다.
출처:
server/game_server/9/2026-03-29-game_server_10_5.md
📌 복습 포인트
- Q3 DB 수평 확장 — 다음 복습에서 챙길 셋 — ① 해시에 넣는 키의 이름은 샤드 키(파티션 키) ② 매핑 DB의 위험은 오버헤드가 아니라 단일 실패 지점(SPOF) — 유일한 관문이라서 ③ 선택 문제에서는 이유까지 쓰기 →
server/game_server/9/2026-03-26-game_server_10_2.md - Q1 이름 가림의 두 해법 — public 상속에서는 클래스 안에
using Base::mf1;(public 영역에), private 상속에서 특정 버전만 필요하면 전달 함수. 구분 기준은 “전부 필요한가, 골라야 하는가”. 탐색 순서는 함수 → 파생 → 기본 → 네임스페이스 → 전역 →game_dev/cpp/chapter6/2025-10-19-cpp_6_33.md - Q4 동기화 — 임계 영역·뮤텍스·잠금을 묶어 부르는 이름이 동기화(Synchronization). 세 기법은 이미 잡혔으니 상위 개념 하나만 →
server/game_server/1/2025-05-30-game_server_1_5.md - Q2 값 캐스팅 vs 참조 캐스팅 —
static_cast<Window>(*this)가 사본을 만드는 이유는*this가 아니라Window값으로 캐스팅했기 때문. 참조(Window&)로 캐스팅하면 사본이 생기지 않는다 →game_dev/cpp/chapter5/2025-07-09-cpp_5_27.md
댓글남기기