12. 귓속말: 대상 로그아웃/채널이동 직후 전송 · C#
난이도 하해설 — 귓속말: 대상 로그아웃/채널이동 직후 전송 · C#
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
핵심은 이름→세션 조회와 송신이 동시 로그아웃과 동기화되지 않는다는 것이다.
(A) _byName[targetName] 는 키가 없으면 KeyNotFoundException 을 던지고, (B) 찾은
세션의 생사(Alive)를 확인하지 않고 송신하며 오프라인 시 발신자 통지도 없다. (C)
_byName 이 일반 Dictionary 라 Whisper(읽기)와 OnLogout(Remove)이 동시에 일어나면
"Collection was modified"/내부 손상 이 난다. C++ 판과 달리 GC 덕에 UAF(메모리 해제
후 접근)는 없지만, 오송신·예외·자료구조 손상은 그대로다. 정답의 한 줄: 레지스트리를
ConcurrentDictionary(또는 락)로 보호하고, TryGetValue + 생사 확인 후 송신, 오프라인이면
발신자에게 통지.
문제점
(A) 인덱서 조회 — 없는 키 예외 (정확성) ★간판
- 증상: 오프라인 대상에게 귓속말하면
KeyNotFoundException으로 핸들러가 죽는다. - 재현 조건: 존재하지 않거나 막
Remove된targetName._byName[targetName]예외. - 근본 원인:
TryGetValue로 조회하고 없으면 "오프라인" 으로 처리해야 한다.
(B) 생사 확인 없이 송신 — 검증 누락 (정확성)
target.Send(...)전에target.Alive를 확인하지 않는다. 막 끊긴 세션으로 송신. 오프라인 시 발신자에게 "상대가 접속 중이 아닙니다" 통지도 없어 메시지가 조용히 사라진다.
(C) Dictionary 동시 접근 — 자료구조 손상/예외 (동시성) ★간판
- 증상: 부하 시 간헐적
InvalidOperationException, 드물게 무한 루프/오독. - 재현 조건: T1
Whisper가_byName을 읽는 동안 T2OnLogout이Remove.Dictionary는 스레드 안전이 아니다. - 근본 원인: 공유 맵에 동기화가 없다.
ConcurrentDictionary또는 락 필요.
(보조) 채널이동 staleness — 정확성
- 이동을 "로그아웃→로그인"으로 처리하면 그 사이 공백에 귓속말 유실. 이동 중 상태를 별도 표현하면 UX↑.
수정안
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, Session> _byName = new();
public bool Whisper(Session from, string targetName, string text)
{
// (A) TryGetValue: 예외 대신 안전 조회
if (!_byName.TryGetValue(targetName, out var target) || !target.Alive) // (B) 생사 확인
{
from.Send("상대가 접속 중이 아닙니다.");
return false;
}
target.Send($"{from.Name} (귓속말): {text}");
return true;
}
public void OnLogin(Session s) => _byName[s.Name] = s;
public void OnLogout(Session s)
{
s.Alive = false;
// 내가 등록한 세션일 때만 제거(재로그인이 덮어쓴 경우 보호)
_byName.TryRemove(new KeyValuePair<string, Session>(s.Name, s));
}
Alive는volatile/Interlocked로 가시성 확보(또는 잠금). 송신 직후 끊길 수 있으니Send내부에서 닫힌 소켓을 안전 처리.TryRemove(KeyValuePair)로 "값까지 일치할 때만 제거" 해 재로그인 세션을 보호.
더 나은 설계
1) 안정 키 라우팅
- 닉네임 대신 불변
playerId로 라우팅하고 이름→id 인덱스를 분리. 닉변경/대소문자/이동에 강함.
2) 오프라인 귓속말 큐
- 짧은 TTL 버퍼로 이동/재접속 중 메시지를 흡수, 도착 시 전달·만료 시 통지(트레이드오프: 메모리/복잡도).
3) 단일 소유 액터
- 세션 레지스트리를 한 스레드가 소유하고 등록/해제/조회를 채널로 직렬화 → 락 제거.
4) 전송 결과 피드백
- 송신 실패(큐 폐기/소켓 닫힘)를 발신자에게 일관되게 통지해 "보냈는데 안 갔다" 혼란 제거.
면접 포인트
- 핵심: 공유 레지스트리 보호 + 조회-사용 사이 상태 변화(생사) 처리. C# 은 UAF는 없지만
Dictionary비스레드안전·인덱서 예외가 함정. - 예상 질문:
- "
dict[key]와TryGetValue차이가 왜 중요한가?" → 없는 키 예외 vs 안전 분기. - "
Dictionary와ConcurrentDictionary중 무엇을, 왜?" → 다중 스레드 읽기/쓰기엔 후자(또는 락). 일관성 요구가 크면 액터. - "재로그인이 막 들어온 사이 옛 세션이 로그아웃하면?" → 값 일치 조건부 제거로 새 세션 보호.
- "
해설 — 귓속말: 대상 로그아웃/채널이동 직후 전송 · C++
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
핵심은 이름→세션 조회와 송신이 동시 로그아웃/해제와 동기화되지 않는다는 것이다.
(A) byName_[targetName] 는 operator[] 라 없는 대상이면 nullptr 를 삽입하고, (B)
그 결과(또는 막 로그아웃된 세션)를 널/생사 확인 없이 역참조해 송신한다. (C) OnLogout
이 다른 스레드에서 erase + delete s 를 하므로, Whisper 가 들고 있던 Session* 는
해제된 메모리(UAF) 가 되거나 byName_ 동시 변경으로 자료구조가 손상된다. 즉 흔한
크래시 3종(널 역참조, UAF, 맵 동시변경 UB)이 한 자리에 모였다. 정답의 한 줄: 레지스트리는
락/동시맵으로 보호하고, 세션 수명은 shared_ptr 로 잡은 뒤(find 로) 생사 확인하고 송신,
오프라인이면 발신자에게 통지.
문제점
(A) operator[] 로 조회 — 묵시적 삽입 + 널 (C++/정확성) ★간판
- 증상: 오프라인 대상에게 귓속말하면
nullptr이 맵에 삽입되고 곧 널 역참조로 크래시. - 재현 조건: 존재하지 않거나 막
erase된targetName.byName_[targetName]가 기본값nullptr을 삽입해 반환 → (B)에서 역참조. - 근본 원인: 조회는
find로 해야 하고, 없으면 "오프라인" 으로 처리해야 한다.
(B) 생사/널 확인 없이 송신 — 검증 누락 (정확성)
target->Send(...)전에target != nullptr && target->alive를 확인하지 않는다. 막 끊긴 세션으로 송신하거나 널을 역참조한다. 오프라인 시 발신자 통지도 없다.
(C) 레지스트리/세션 수명 미보호 — UAF·동시변경 (동시성/메모리) ★간판
- 증상: 간헐적 크래시(특히 부하 시), 손상된 맵, 해제 메모리 접근.
- 재현 조건: 워커 T1 이
Whisper에서target을 얻은 직후, T2 가OnLogout에서byName_.erase+delete s. T1 의target->Send는 해제된 객체를 만진다. 또한unordered_map을 두 스레드가 동시에 읽고/쓰면 UB. - 근본 원인: ① 맵 접근에 동기화가 없고, ② 세션 객체 수명을 raw pointer +
delete로 관리해 "조회 후 사용" 구간에서 해제될 수 있다.
(보조) 채널이동 시 이름 키의 staleness — 정확성
- 채널/인스턴스 이동을 "로그아웃→로그인"으로 처리하면 그 사이 짧은 공백에 귓속말이 유실. 이동 중 상태를 별도로 표현(이전 중/큐잉)하는 편이 사용자 경험에 낫다.
수정안
핵심: ① 레지스트리는 락(또는 concurrent map)으로 보호, ② 세션은 shared_ptr 로 관리해
조회 중 수명을 잡고, ③ find + 생사 확인, ④ 오프라인이면 발신자에게 통지.
class ChatService {
public:
bool Whisper(const std::shared_ptr<Session>& from,
const std::string& targetName, const std::string& text)
{
std::shared_ptr<Session> target;
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = byName_.find(targetName); // (A) find: 묵시적 삽입 방지
if (it != byName_.end()) target = it->second; // shared_ptr 사본으로 수명 고정
}
if (!target || !target->alive.load()) { // (B) 생사 확인
from->Send("상대가 접속 중이 아닙니다.");
return false;
}
target->Send(from->name + " (귓속말): " + text); // (C) UAF 없음: 사본이 수명 보장
return true;
}
void OnLogout(const std::shared_ptr<Session>& s) {
std::lock_guard<std::mutex> lk(mtx_);
s->alive.store(false);
auto it = byName_.find(s->name);
if (it != byName_.end() && it->second == s) byName_.erase(it);
// delete 하지 않음: 마지막 shared_ptr 가 사라질 때 자동 해제
}
private:
std::mutex mtx_;
std::unordered_map<std::string, std::shared_ptr<Session>> byName_;
};
alive는std::atomic<bool>. 송신 직전 확인해도 직후 끊길 수 있으나, 그건Send내부에서 닫힌 소켓을 안전 처리(에러 무시/큐 폐기)하면 된다. 핵심은 UAF 제거.
더 나은 설계
1) 세션 핸들/약참조
- 직접 포인터 대신
sessionId+ 약참조(weak_ptr)로 들고, 사용 직전lock()으로 승격. 레지스트리가 비대해지지 않고 수명 경계가 명확.
2) 이름이 아니라 안정 키
- 닉네임 변경/대소문자/채널이동을 고려하면 이름 대신 불변
accountId/playerId로 라우팅하고, 이름→id 는 별도 인덱스로. 이동 중엔 "이전 중" 상태를 둬 짧은 공백을 흡수.
3) 오프라인 귓속말 큐
- 대상이 잠깐 이동/재접속 중이면 짧은 TTL 버퍼에 담았다가 도착 시 전달, 만료 시 발신자 통지. UX↑(트레이드오프: 메모리/복잡도).
4) 단일 소유 액터
- 세션 레지스트리를 단일 스레드(액터)가 소유하고 모든 등록/해제/조회를 메시지로 직렬화하면 락·UAF 자체가 사라진다.
면접 포인트
- 핵심: "조회한 객체를 쓰는 동안 다른 스레드가 지운다" 는 전형적 UAF. 수명(ownership)과 레지스트리 보호를 분리해 사고하라.
- 예상 질문:
- "
map[key]대신find를 써야 하는 이유는?" → 묵시적 nullptr 삽입·널 역참조 방지. - "raw
Session*를shared_ptr로 바꾸면 무엇이 해결되나?" → 조회~사용 구간 수명 보장 (UAF 제거). 단 레지스트리 동시변경은 별도 락 필요. - "송신 직전엔 살아있었는데 직후 끊기면?" →
Send가 닫힌 소켓을 안전 처리. 생사 확인은 best-effort, UAF 제거가 본질.
- "
구문 검증:
g++ -std=c++17 -fsyntax-only problem.cpp통과.