3. C# 중복 접속 / 동시 로그인 처리 (기존 세션 킥)
난이도 상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — C# 중복 접속 / 동시 로그인 처리 (기존 세션 킥)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
"기존 세션 조회 → (락 밖) 킥 → 새 세션 등록"이 **여러 락 구간으로 쪼개진 비원자
연산(check-then-act)**이다. 같은 계정의 로그인 두 개가 동시에 들어오면 둘 다
서로의 등록을 보지 못해 둘 다 살아남거나(중복 세션), 등록 순서가 엇갈려
방금 들어온 세션이 옛 세션에 의해 킥당하는(둘 다 죽거나 잘못된 세션이 죽는)
고전적 TOCTOU 버그가 발생한다. OnSessionClosed도 새 세션을 잘못 지운다.
문제점
(A)+(C) 비원자 check-then-act (TOCTOU) → 중복 세션 (분류: 동시성/정확성)
- 증상: 락을 조회할 때 한 번, 등록할 때 또 한 번 따로 잡는다. 그 사이는 락이
풀려 있다. 같은 계정 로그인 T1, T2가 거의 동시에 들어오면:
- T1: 조회 → old=null
- T2: 조회 → old=null (아직 T1이 등록 안 함)
- T1:
_online[acc]=S1 - T2:
_online[acc]=S2← S1을 킥하지 않고 덮어씀 결과: S1은 맵에서 사라졌지만 Disconnect되지 않아 살아있는 유령, S2도 활성. 계정당 1세션 불변식 깨짐.
- 재현조건: 멀티 디바이스 동시 로그인, 재접속 폭주, 따닥 클릭.
- 근본원인: "조회+킥+등록"이 하나의 임계 구역이어야 하는데 분할됨. 게다가 (B)의
await때문에 그 사이가 더 벌어진다.
(B) await를 사이에 둔 상태 변경 + 락 밖 킥 (분류: 동시성)
- 증상: 인증(
await AuthenticateAsync)이 수십 ms 걸리고, 킥 통지도await. 이 await 구간 동안 다른 로그인이 끼어들어 맵 상태가 바뀐다. 또 "old를 읽은 시점"과 "old를 등록 덮어쓰는 시점"이 await 하나만큼 더 벌어져 race 창이 커진다. - 근본원인: 원자적이어야 할 트랜잭션 사이에 비동기 경계가 들어감.
(C) LoginAsync가 등록 전 새 세션을 누구도 모름 (분류: 정확성)
- 증상: 등록 직전에 들어온 또 다른 로그인은 이 새 세션을 킥 대상으로 인지 못 함. "예약(claim)" 단계가 없어 등록 순간까지 충돌을 막을 방법이 없다.
(D) OnSessionClosed가 무조건 Remove → 새 세션 오삭제 (분류: 정확성/수명관리)
- 증상: 옛 세션 S1이 킥당한 뒤 뒤늦게 자신의 종료 콜백
OnSessionClosed(S1)을 부르는데, 맵에는 이미 새 세션 S2가 들어있다.Remove(s.AccountId)는 키만 보고 지우므로 S2를 날려버린다. 결과적으로 멀쩡한 S2가 온라인 목록에서 사라져 "유령"이 되거나 후속 로그인이 오작동. - 근본원인: 제거 시 "지우려는 세션이 정말 현재 등록된 그 세션인지" 확인 안 함 (identity check 부재). 스테일 콜백(stale callback) 문제.
수정안
핵심: "예약→정착(claim & settle)" 원자 교체, await를 임계 구역 밖으로, 조건부 제거
using System.Collections.Concurrent;
public class Session
{
public long AccountId;
public string SessionKey = Guid.NewGuid().ToString();
private int _alive = 1;
public bool Alive => Volatile.Read(ref _alive) == 1;
public Task SendKickNoticeAsync() => Task.CompletedTask;
public void Disconnect()
{
if (Interlocked.Exchange(ref _alive, 0) == 0) return; // 멱등
// 소켓 종료 등 ...
}
}
public class LoginManager
{
private readonly ConcurrentDictionary<long, Session> _online = new();
public async Task<Session> LoginAsync(long accountId)
{
await AuthenticateAsync(accountId); // 비동기는 임계 구역 밖에서
var newSession = new Session { AccountId = accountId };
// 원자 교체: 어떤 값이 있든 newSession 으로 한 방에 바꾼다.
// AddOrUpdate 의 델리게이트는 해당 키에 대해 직렬화되어 실행된다.
Session displaced = null;
_online.AddOrUpdate(
accountId,
addValueFactory: _ => newSession,
updateValueFactory: (_, existing) => { displaced = existing; return newSession; });
// 교체로 밀려난 옛 세션만 킥(락/임계 구역 밖에서 await)
if (displaced != null && !ReferenceEquals(displaced, newSession))
{
displaced.Disconnect(); // 상태부터 죽이고
await displaced.SendKickNoticeAsync(); // 통지는 best-effort
}
return newSession;
}
public void OnSessionClosed(Session s)
{
// identity check: 현재 등록된 게 정확히 이 세션일 때만 제거.
// ConcurrentDictionary 의 (key,value) 조건부 제거 오버로드 사용.
((ICollection<KeyValuePair<long, Session>>)_online)
.Remove(new KeyValuePair<long, Session>(s.AccountId, s));
// → 맵에 들어있는 값이 s 와 동일 참조일 때만 삭제. S2 를 잘못 지우지 않음.
}
private Task AuthenticateAsync(long accountId) => Task.Delay(20);
}
설명
AddOrUpdate의 update 델리게이트는 같은 키에 대해 원자적으로 실행되므로 "기존 값 확인 + 교체"가 하나의 연산이 된다. 동시에 두 로그인이 와도 한 쪽이 먼저newSession을 넣고, 다른 쪽은 그것을existing으로 받아 다시 교체 → 항상 마지막 하나만 남고 밀려난 세션은 정확히displaced로 회수되어 킥된다. (계정당 정확히 1세션 불변식 유지, "둘 다 살아남음" 차단.)- 킥의 await는 임계 구역 밖으로 빼서 등록 원자성과 분리.
- **조건부 제거(identity check)**로 스테일 콜백이 새 세션을 지우는 (D) 문제 차단.
Disconnect는 멱등이라 두 경로에서 불려도 안전.
주의:
AddOrUpdate의 델리게이트는 락이 걸린 채 실행될 수 있으니 그 안에서await/네트워크 호출을 하면 안 된다(여기선 참조 대입만 하므로 안전).
더 나은 설계
1) 계정 단위 직렬화(per-account lock / login gate)
- 계정 id를 해시해 N개의 락(또는
SemaphoreSlim) 슬롯으로 나누고, 같은 계정의 로그인은 같은 슬롯에서 직렬 처리. "예약→인증→정착" 전체를 한 트랜잭션으로 묶음. - 트레이드오프: 전역 락보다 경합은 낮지만, 같은 계정 따닥 로그인은 어차피 직렬. 분산 서버라면 단일 노드 락으로는 부족(아래 2번).
2) 분산 환경: 중앙 권위(authoritative) 토큰
- 여러 게임 노드가 있으면 로그인 권한을 Redis 등 중앙 저장소의 원자 연산
(
SET key val NX/ Lua 스크립트 CAS)으로 발급. 새 로그인이 토큰 세대를 올리고, 옛 노드는 자신의 토큰이 더 이상 최신이 아님을 보고 자진 킥(fencing token). - 트레이드오프: 네트워크 왕복 비용, Redis 가용성 의존. 하지만 멀티 노드에서 "둘 다 살아남음"을 막는 사실상 표준.
3) 세션 상태머신 + 세대 카운터
Authenticating → Active → Kicked/Closed. 각 세션에 단조 증가generation을 부여하고, 맵에는 "최신 generation"만 유효. 늦게 도착한 콜백/패킷은 generation 비교로 무시(스테일 처리 일반화).
면접 포인트
- "check-then-act가 왜 위험하고, 동시 로그인에서 어떤 형태로 나타나나?"
→ 조회와 등록 사이 락이 풀려 다른 스레드가 끼어듦(TOCTOU). 동시 로그인이면
둘 다 old=null을 보고 서로를 킥하지 못해 중복 세션이 남는다. 해법은 조회+교체를
단일 원자 연산(
AddOrUpdate/CAS/per-key lock)으로 묶는 것. - "
await중에 잡고 있던 상태가 바뀌는 문제를 어떻게 다루나?" → await는 임계 구역 밖으로 빼고, await 전후로 상태를 재검증하거나 세대 토큰으로 stale 여부를 판정.lock안에서await는 (그리고 lock은 await를 못 넘김) 금지. - "단일 서버에선 막아도 멀티 노드면 같은 계정이 두 노드에 붙을 수 있다. 어떻게?" → 중앙 저장소의 원자 CAS로 권한 토큰(fencing token)을 발급하고, 토큰 세대가 뒤처진 세션은 자진 종료. 로컬 락만으로는 분산 중복 접속을 못 막는다.
해설 · C++
해설 — C++ 중복 접속 / 동시 로그인 처리 (기존 세션 킥)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
"기존 세션 조회 → (락 밖) 킥/delete → 새 세션 등록"이 **여러 락 구간으로 쪼개진 비원자
연산(check-then-act)**이다. 같은 계정의 로그인 두 개가 동시에 들어오면 둘 다 서로의
등록을 보지 못해 둘 다 살아남거나(중복 세션), 등록 순서가 엇갈려 방금 들어온
세션이 옛 세션 정리에 휘말리는 고전적 TOCTOU 버그가 발생한다. 게다가 raw 포인터를
delete로 정리하므로 use-after-free / double-free 위험이 겹치고, OnSessionClosed도
새 세션을 잘못 지우고 해제한다.
문제점
(B)+(E) 비원자 check-then-act (TOCTOU) → 중복 세션 (분류: 동시성/정확성)
- 증상: 락을 조회할 때 한 번, 등록할 때 또 한 번 따로 잡는다. 그 사이는 락이
풀려 있다. 같은 계정 로그인 T1, T2가 거의 동시에 들어오면:
- T1: 조회 → old=nullptr
- T2: 조회 → old=nullptr (아직 T1이 등록 안 함)
- T1:
online_[acc]=S1 - T2:
online_[acc]=S2← S1을 킥하지 않고 덮어씀 결과: S1은 맵에서 사라졌지만 Disconnect/delete되지 않아 살아있는 유령(누수), S2도 활성. 계정당 1세션 불변식 깨짐.
- 재현조건: 멀티 디바이스 동시 로그인, 재접속 폭주, 따닥 클릭.
- 근본원인: "조회+킥+등록"이 하나의 임계 구역이어야 하는데 분할됨. (A)의
future.get()블로킹 때문에 그 사이가 더 벌어진다.
(A) future.get() 블로킹을 사이에 둔 상태 변경 (분류: 동시성/성능)
- 증상: 인증(
AuthenticateAsync(...).get())이 수십 ms 동안 IO 스레드를 블로킹. 이 구간 동안 다른 로그인이 끼어들어 맵 상태가 바뀐다. 또 "old를 읽은 시점"과 "old를 등록 덮어쓰는 시점" 사이가 벌어져 race 창이 커진다. IO 스레드가 future를 블로킹 대기하면 스레드 풀 고갈로 처리량도 떨어진다. - 근본원인: 원자적이어야 할 트랜잭션 사이에 블로킹 경계가 들어감. 비동기 인증은
완료 콜백/continuation으로 이어가야지
.get()으로 막으면 안 된다.
(C)+(D) 락 밖 킥 + raw delete → use-after-free / double-free (분류: 수명관리, 치명)
- 증상:
old를 락 밖에서Disconnect후delete. 그런데 같은 계정의 또 다른 로그인이나OnSessionClosed가 동시에 같은old포인터를 들고 있으면 double-free. 또old를delete한 직후 다른 경로(브로드캐스트/콜백)가 그 포인터를 쓰면 UAF. 맵에서 빼지 않은 채(혹은 빼기 전에) delete하면 dangling 포인터가 맵에 남는다. - 근본원인: raw 포인터 소유권이 불명확. "마지막 참조자가 해제" 규칙과 멱등 종료가 없음.
(E) 새 세션이 등록 전 누구도 모름 (분류: 정확성)
- 증상: 등록 직전에 들어온 또 다른 로그인은 이 새 세션을 킥 대상으로 인지 못 함. "예약(claim)" 단계가 없어 등록 순간까지 충돌을 막을 방법이 없다.
(F) OnSessionClosed가 무조건 erase+delete → 새 세션 오삭제 (분류: 정확성/수명관리)
- 증상: 옛 세션 S1이 킥당한 뒤 뒤늦게 자신의 종료 콜백
OnSessionClosed(S1)을 부르는데, 맵에는 이미 새 세션 S2가 들어있다.erase(s->accountId)는 키만 보고 지우므로 S2를 맵에서 날려버린다. 결과적으로 멀쩡한 S2가 온라인 목록에서 사라져 "유령"이 되고, stale 콜백이 잘못된 객체를 delete할 수도 있다. - 근본원인: 제거 시 "지우려는 세션이 정말 현재 등록된 그 세션인지" 확인 안 함 (identity check 부재). 스테일 콜백(stale callback) 문제.
수정안
핵심: shared_ptr 수명 + 단일 임계구역 원자 교체 + 비동기 인증(블로킹 제거) + identity 제거 + 멱등 Close
#include <unordered_map>
#include <mutex>
#include <memory>
#include <atomic>
#include <cstdint>
class Session : public std::enable_shared_from_this<Session> {
public:
int64_t accountId;
std::atomic<bool> alive{true};
void SendKickNotice() { /* best-effort 통지 */ }
void Disconnect() {
bool expected = true;
if (!alive.compare_exchange_strong(expected, false)) return; // 멱등
// 소켓 종료 등 ...
}
};
class LoginManager {
std::unordered_map<int64_t, std::shared_ptr<Session>> online_;
std::mutex mtx_;
public:
// 인증은 완료 콜백으로 이어받아 IO 스레드를 블로킹하지 않는다.
// (여기서는 인증이 끝난 뒤 호출되는 부분만 보인다)
std::shared_ptr<Session> OnAuthenticated(int64_t accountId) {
auto newSession = std::make_shared<Session>();
newSession->accountId = accountId;
std::shared_ptr<Session> displaced;
{
// 조회+교체를 단일 임계 구역으로 묶어 원자화(TOCTOU 제거).
std::lock_guard<std::mutex> lk(mtx_);
auto it = online_.find(accountId);
if (it != online_.end()) displaced = it->second; // 밀려난 옛 세션
online_[accountId] = newSession; // 한 번에 교체
}
// 밀려난 세션만 락 밖에서 킥(미지의 코드 호출을 락 밖으로).
if (displaced && displaced != newSession) {
displaced->Disconnect(); // 멱등 — 상태부터 죽인다
displaced->SendKickNotice(); // best-effort
// delete 없음: 마지막 shared_ptr 가 사라질 때 자동 해제 → double-free/UAF 차단
}
return newSession;
}
void OnSessionClosed(const std::shared_ptr<Session>& s) {
std::lock_guard<std::mutex> lk(mtx_);
auto it = online_.find(s->accountId);
// identity check: 현재 등록된 게 정확히 이 세션일 때만 제거.
if (it != online_.end() && it->second == s)
online_.erase(it); // S2 를 잘못 지우지 않음
// delete 없음
}
};
설명
- 단일 임계 구역: "기존 값 확인 + 교체"를 하나의 락 구간으로 묶어 TOCTOU 제거.
동시에 두 로그인이 와도 락 직렬화로 한 쪽이 먼저 등록하고 다른 쪽이 그것을
displaced로 회수 → 항상 마지막 하나만 남고 밀려난 세션이 정확히 킥된다. (계정당 정확히 1세션 불변식 유지, "둘 다 살아남음" 차단.) shared_ptr수명 관리: rawdelete제거. 킥/콜백/맵이 각자 ref를 들고 마지막에 자동 해제 → double-free / UAF 원천 차단.- 킥/통지는 임계 구역 밖으로 빼서 등록 원자성과 분리, 락 점유 시간 최소화.
- identity check 제거(
it->second == s) 로 스테일 콜백이 새 세션을 지우는 (F) 문제 차단. Disconnect는compare_exchange로 멱등이라 두 경로에서 불려도 안전.- 블로킹 인증 제거:
future.get()대신 비동기 인증의 완료 continuation에서OnAuthenticated를 호출.
더 나은 설계
1) 계정 단위 직렬화(per-account lock / login gate)
- 계정 id를 해시해 N개의 락 슬롯으로 나누고, 같은 계정의 로그인은 같은 슬롯에서 직렬 처리. "예약→인증→정착" 전체를 한 트랜잭션으로 묶음.
- 트레이드오프: 전역 락보다 경합은 낮지만, 같은 계정 따닥 로그인은 어차피 직렬. 분산 서버라면 단일 노드 락으로는 부족(아래 2번).
2) 분산 환경: 중앙 권위(authoritative) 토큰
- 여러 게임 노드가 있으면 로그인 권한을 Redis 등 중앙 저장소의 원자 연산
(
SET key val NX/ Lua 스크립트 CAS)으로 발급. 새 로그인이 토큰 세대를 올리고, 옛 노드는 자신의 토큰이 더 이상 최신이 아님을 보고 자진 킥(fencing token). - 트레이드오프: 네트워크 왕복 비용, Redis 가용성 의존. 하지만 멀티 노드에서 "둘 다 살아남음"을 막는 사실상 표준.
3) 세션 상태머신 + 세대 카운터
Authenticating → Active → Kicked/Closed. 각 세션에 단조 증가generation을 부여하고, 맵에는 "최신 generation"만 유효. 늦게 도착한 콜백/패킷은 generation 비교로 무시(스테일 처리 일반화).
면접 포인트
- "check-then-act가 왜 위험하고, 동시 로그인에서 어떤 형태로 나타나나?" → 조회와 등록 사이 락이 풀려 다른 스레드가 끼어듦(TOCTOU). 동시 로그인이면 둘 다 old=nullptr을 보고 서로를 킥하지 못해 중복 세션이 남는다. 해법은 조회+교체를 단일 임계 구역(또는 CAS/per-key lock)으로 묶는 것.
- "raw 포인터 + delete로 세션을 정리하면 어떤 동시성 위험이 있나?"
→ 다른 스레드/콜백이 같은 포인터를 들고 있으면 double-free 또는 UAF.
shared_ptr로 "마지막 참조자가 해제"하게 하고, 종료는compare_exchange로 멱등화한다. 제거 시 identity check로 stale 콜백의 오삭제를 막는다. - "인증을
future.get()으로 블로킹하면? 단일 서버에선 막아도 멀티 노드면?" → 블로킹은 IO 스레드 풀을 고갈시키고 race 창을 넓힌다. 비동기 continuation으로 이어가야 한다. 멀티 노드 중복 접속은 로컬 락으론 못 막으므로 중앙 저장소의 원자 CAS로 fencing token을 발급하고 세대가 뒤처진 세션은 자진 종료.