3. 틱 루프의 GC 압박, LOH, 풀 이중 반납
난이도 상해설 — 틱 루프의 GC 압박, LOH, 풀 이중 반납
난이도: 상
요약
풀을 도입했지만 핫패스 곳곳에서 숨은 힙 할당이 발생해 GC 압박이 그대로다. 매 틱·매 룸마다 문자열을 만들고(LogVerbose가 꺼져 있어도), params object[]로 int를 박싱한다. 결정적으로 EventMessage의 페이로드가 96KB(98,304바이트) 라 LOH(Large Object Heap) 임계 85,000바이트를 넘겨 실제로 LOH에 할당된다. 게다가 Return을 두 번 호출(이중 반납) 해 같은 객체가 풀에 중복 등록되고, 이후 두 곳에서 동시에 Rent되어 상태 오염/데이터 손상과 풀 무결성 붕괴를 일으킨다. 풀이 (C)로 깨지거나 부하로 비면 96KB LOH 객체를 빈번히 새로 할당 → Gen2(LOH는 Gen2와 함께 수집됨) 컬렉션과 틱 스파이크를 만든다.
문제점
(A) LogVerbose용 문자열 연결 — 무조건 할당 (분류: 성능/메모리)
- 증상:
VerboseEnabled == false인데도 매 틱·매 룸마다string한 개씩 Gen0에 쌓인다. 60Hz × 룸 수 → 초당 수백 개. - 재현조건: 항상. 로그가 꺼져 있어도 문자열은 호출 전에 이미 만들어진다(인자 평가는 항상 일어남).
- 근본원인:
"tick " + tickNo + " room " + ...는 boxing 없는 string이지만 매번 새 string + 내부 임시 할당. 게다가 비활성 로그를 위해 비용을 지불한다. 핫패스 로그의 전형적 안티패턴.
(B) RecordStat(... params object[]) — 박싱 + 배열 할당 (분류: 성능/메모리)
- 증상: 매 호출마다 (1)
object[]배열 1개, (2) 각int인자가object로 박싱되어 힙 객체 생성. Gen0 압박. - 재현조건: 항상.
- 근본원인:
params object[]는 호출 시 배열을 할당하고, 값타입(int)을 넣으면 박싱(힙 할당 + 복사)이 일어난다.Convert.ToInt64(object)에서 다시 언박싱. 핫패스에서 값타입을object로 다루는 건 금물.
(C) DrainBroadcast의 이중 반납 — 풀 무결성 붕괴 (분류: 정확성/메모리, 가장 심각)
- 증상: 같은
EventMessage인스턴스가 풀에 두 번 들어간다. 이후 서로 다른 두Rent호출이 동일 객체를 받아 동시에 사용 → 한 틱의 RoomId/Payload가 다른 틱에서 덮어써지는 데이터 손상, 그리고 풀의 논리적 크기가 부풀어 통계/추적이 깨진다. 최악의 경우 아직 브로드캐스트 큐에 있는 객체가 재대여되어 use-after-return. - 재현조건: 부하가 올라
Rent/Return이 잦아질수록 충돌 확률 급증. - 근본원인: 리팩터링 잔재로
Return을 두 번 호출. 풀은 "한 객체 = 한 번만 반납"이라는 불변식을 가지는데 이를 깬다.ConcurrentBag은 중복 추가를 막아주지 않는다.
(D) EventMessage.Payload = new byte[96 * 1024] — 실제 LOH 할당 (분류: 메모리)
- 증상: 96KB = 98,304바이트는 LOH 임계 85,000바이트를 초과하므로 이 배열은 GC가 LOH(Large Object Heap) 에 둔다. 풀이 (C) 때문에 신뢰를 잃거나, 부하 급증으로
_bag이 비면new EventMessage()가 자주 호출되어 96KB LOH 배열을 계속 할당한다. LOH는 Gen2와 함께(전체 GC에서만) 수집되므로, 이 할당이 잦으면 비싼 Gen2 컬렉션이 빈번해지고 틱 스파이크가 생긴다. - 재현조건: 풀 고갈(부하 급증 또는 (C)의 무결성 붕괴 이후) 시. 또한 모든 메시지가 항상 96KB를 점유해 메모리 풋프린트도 과대.
- 근본원인: 메시지마다 최대치 버퍼를 고정 보유. 대부분 페이로드는 수 바이트인데 96KB를 점유한다. LOH는 기본적으로 컴팩션되지 않아(
GCSettings.LargeObjectHeapCompactionMode로 명시 요청해야만 한 번 컴팩션) 단편화도 누적된다. 임계가 정확히 85,000바이트(≈83KB, 85KB가 아님) 라는 점도 핵심: 84KB짜리 버퍼였다면 SOH였겠지만 96KB는 확실히 LOH다.
수정안
핵심: (1) 로그는 가드 또는 구조적 로깅, (2) 통계는 박싱 없는 전용 메서드, (3) 이중 반납 제거 + 재대여 안전장치, (4) 페이로드는 ArrayPool<byte>로 풀링(LOH 직접 할당 제거).
public sealed class EventMessage
{
public int RoomId;
public int EventType;
public byte[] Payload; // (D) 풀에서 빌린 버퍼를 가리킴 (고정 96KB 배열 제거)
public int PayloadLen;
// 풀 재대여 방지용 플래그 (디버그/안전)
internal bool InPool;
public void Reset()
{
RoomId = 0; EventType = 0; PayloadLen = 0;
// Payload는 ArrayPool로 별도 관리
}
}
public sealed class MessagePool
{
private readonly ConcurrentBag<EventMessage> _bag = new();
public EventMessage Rent()
{
if (_bag.TryTake(out var m))
{
m.InPool = false;
return m;
}
return new EventMessage();
}
public void Return(EventMessage m)
{
// (C) 이중 반납 방어: 이미 풀에 있으면 거부
if (m.InPool)
throw new InvalidOperationException("double return detected");
m.Reset();
m.InPool = true;
_bag.Add(m);
}
}
private void DrainBroadcast()
{
while (_broadcastQueue.TryDequeue(out var msg))
{
Broadcast(msg);
if (msg.Payload != null) // (D) 빌린 버퍼 반납
{
ArrayPool<byte>.Shared.Return(msg.Payload);
msg.Payload = null;
}
_pool.Return(msg); // (C) 단 한 번만
}
}
public void Tick(int tickNo)
{
foreach (var roomId in _activeRooms)
{
// (A) 가드로 비활성 시 문자열 자체를 안 만듦.
// 더 나은 형태: 구조적 로깅(보간 문자열 핸들러 활용)
if (VerboseEnabled)
LogVerbose($"tick {tickNo} room {roomId} processing");
var msg = _pool.Rent();
msg.RoomId = roomId;
msg.EventType = 7;
// (D) 필요 크기만 풀에서 빌림. ArrayPool은 큰 버퍼를 재사용하므로
// 매번 LOH에 새로 할당하지 않는다(풀 내부가 LOH 버킷을 보유).
msg.Payload = ArrayPool<byte>.Shared.Rent(96 * 1024);
msg.PayloadLen = FillPayload(msg.Payload, roomId, tickNo);
_broadcastQueue.Enqueue(msg);
RecordStat("room_event", roomId + tickNo); // (B) 박싱 없는 시그니처
}
DrainBroadcast();
}
// (B) params object[] 제거 → 값타입 전용
private void RecordStat(string name, long value)
{
lock (s_stats)
{
s_stats.TryGetValue(name, out var prev);
s_stats[name] = prev + value;
}
}
LogVerbose($"...")도if (VerboseEnabled)가드 안에 있으므로 비활성 시 보간 문자열을 만들지 않는다. .NET 6+의[InterpolatedStringHandler]기반 로깅(예:ILogger.LogDebug)을 쓰면 가드 없이도 비활성 레벨에서 할당을 피한다.
주의:
ArrayPool<byte>.Shared.Rent(n)은 요청보다 큰 버퍼를 줄 수 있다(2의 거듭제곱 버킷). 그래서 실제 길이는 별도 필드(PayloadLen)로 추적하고, 슬라이스(Span<byte>)로 다뤄야 한다. 또한 큰 버킷은 풀 자체가 내부적으로 LOH에 보관하지만, 매 틱 새로 할당하지 않고 재사용하므로 LOH로 가는 신규 할당이 사라진다 — 이게 핵심이다.
더 나은 설계
-
struct헤더 + 버퍼 분리:EventMessage를 가벼운struct(또는 헤더만 클래스)로 두고 페이로드는ArrayPool<byte>/Memory<byte>로 분리하면 메시지 자체 할당이 사라진다. 트레이드오프: struct 복사 비용, 큐에 넣을 때ConcurrentQueue<T>는 T가 struct여도 박싱 없음(인터페이스 캐스팅만 피하면 됨). -
풀 무결성 강화:
InPool플래그 외에 디버그 빌드에서HashSet으로 대여/반납 추적, 또는 토큰/버전 카운터로 use-after-return을 탐지. 운영 빌드는 비용 0인 플래그만. -
LOH 회피 원칙: 85,000바이트 이상 객체는 LOH로 가고 기본적으로 컴팩션 안 됨(
LargeObjectHeapCompactionMode.CompactOnce로 1회 명시 요청만 가능) → 큰 버퍼는 반드시 풀링(ArrayPool)하거나 청크로 분할. 본 코드처럼 메시지마다 큰 고정 배열을 들지 말 것. 임계를 외워둘 것: 85,000바이트(약 83KB이며 흔히 헷갈리는 64KB·85KB가 아니다). -
GC 모드 튜닝: 서버 GC(
<ServerGarbageCollection>true) + Concurrent/Background GC, .NET 8의 DATAS 고려. LOH가 Gen2와 함께 수집되므로 LOH 할당을 줄이는 것이 곧 Gen2 빈도 감소다. 단, 근본은 "할당을 안 만드는 것"이다. GC 설정은 보조. -
할당 측정:
dotnet-counters(LOH 크기/Gen2 횟수 모니터링)/dotnet-trace, ETW, BenchmarkDotNet의[MemoryDiagnoser]로 "틱당 바이트 할당"과 LOH 증가를 0에 수렴시키는 것을 목표로 회귀 테스트.
면접 포인트
- LOH의 임계 크기는 정확히 몇 바이트인가? (85,000바이트) 64KB(65,536B)짜리 배열은 LOH인가 SOH인가? (SOH — 임계 미만) LOH가 기본적으로 컴팩션되지 않는 이유와 단편화 대응책은?
- LOH는 어느 세대와 함께 수집되는가? (Gen2/전체 GC) 그래서 LOH 빈번 할당이 왜 틱 스파이크로 이어지는가?
- C#에서 박싱이 정확히 언제 일어나는가?
params object[],Dictionary<string, object>, 인터페이스로 값타입 전달 등 핫패스에서 박싱이 숨는 곳을 예로 들어라. 오브젝트 풀에서 "이중 반납"이 왜 위험한가? (use-after-return, 동일 인스턴스 동시 사용, 풀 카운트 오염)
해설 — 틱 루프의 할당자 압박, 대용량 버퍼, 풀 이중 반납
난이도: 상
요약
풀을 도입했지만 핫패스 곳곳에서 숨은 힙 할당이 발생해 할당자 압박이 그대로다. 매 틱·매 룸마다 std::ostringstream으로 로그 문자열을 만들고(verbose가 꺼져 있어도), std::string 키로 통계 맵을 조회·삽입한다. 결정적으로 EventMessage의 페이로드가 96KB(98,304바이트) 라, 풀이 비거나 깨지면 매번 큰 블록을 새로 할당해 glibc malloc의 mmap 임계(기본 128KB 근처, M_MMAP_THRESHOLD)나 큰 free 블록 처리로 단편화/지연을 만든다. 더 심각한 건 Return을 두 번 호출(이중 반납) 해 같은 포인터가 free 리스트에 중복 등록되고, 이후 두 곳에서 동시에 Rent되어 같은 객체 동시 사용 → 데이터 손상, 그리고 한쪽이 delete하면 double-free가 된다. (게임서버 C++ 풀의 전형적 함정: GC가 없으므로 이중 반납은 곧바로 댕글링/이중해제 크래시로 이어진다.)
문제점
(A) std::ostringstream 로그 — 무조건 할당 (분류: 성능/메모리)
- 증상:
verboseEnabled_ == false인데도 매 틱·매 룸마다ostringstream내부 버퍼 + 결과std::string이 힙에 할당·해제된다. 60Hz × 룸 수 → 초당 수백 개의 단명 할당. - 재현조건: 항상. 로그가 꺼져 있어도 문자열은 호출 전에 이미 만들어진다(인자 평가는 항상 일어남).
- 근본원인:
oss << ...; LogVerbose(oss.str())는 비활성 로그를 위해 비용을 전부 지불한다.ostringstream은 무거운 객체(로케일·내부 버퍼)이고.str()은 새std::string을 복사 할당한다. 핫패스 로그의 전형적 안티패턴.
(B) RecordStat의 문자열 키 — 임시 할당 + 맵 탐색 (분류: 성능/메모리)
- 증상: 매 호출마다
std::string임시(SSO를 넘으면 힙) +std::map노드 탐색/삽입.std::map은 노드 기반 트리라 첫 삽입 시 노드 할당이 일어난다. - 재현조건: 항상.
- 근본원인: 핫패스에서 문자열을 키로 쓰면 매번 임시 문자열·해시/비교 비용이 든다. 키가 고정된 소수면
enum인덱스 배열이나 컴파일타임 식별자로 대체해야 한다.std::map보다std::unordered_map이 낫지만, 더 근본적으로는 문자열 키 자체를 피한다.
(C) DrainBroadcast의 이중 반납 — 풀 무결성 붕괴 / double-free (분류: 정확성/메모리, 가장 심각)
- 증상: 같은
EventMessage*가 free 리스트에 두 번 들어간다. 이후 서로 다른 두Rent가 동일 포인터를 받아 동시에 사용 → 한 틱의 RoomId/Payload가 다른 틱에서 덮어써지는 데이터 손상(엉뚱한 룸 페이로드). 풀 정리 시 같은 포인터를 두 번delete하면 double-free → 힙 손상/크래시. 아직 큐에 있는 객체가 재대여되면 use-after-return. - 재현조건: 부하가 올라
Rent/Return이 잦아질수록 충돌 확률 급증. - 근본원인: 리팩터링 잔재로
Return을 두 번 호출. 풀은 "한 객체 = 한 번만 반납"이라는 불변식을 가지는데 이를 깬다.std::vector<EventMessage*>free 리스트는 중복 추가를 막아주지 않는다. C++엔 GC가 없어 이 버그가 곧장 메모리 안전성 위반이 된다.
(D) Payload = std::vector<std::uint8_t>(96 * 1024) — 메시지마다 96KB 고정 (분류: 메모리)
- 증상: 96KB = 98,304바이트 블록을 메시지마다 점유. 풀이 (C)로 신뢰를 잃거나 부하 급증으로 free 리스트가 비면
new EventMessage()가 96KBvector를 계속 새로 할당한다. glibc malloc은 큰 요청(기본M_MMAP_THRESHOLD≈ 128KB 이상, 단 동적 조정됨)을mmap으로 처리하거나 큰 free 청크를 관리하므로, 빈번한 대형 할당/해제는 페이지 폴트·단편화·지연 스파이크를 만든다. - 재현조건: 풀 고갈((C)의 무결성 붕괴 이후 또는 부하 급증). 또한 모든 메시지가 항상 96KB를 점유해 메모리 풋프린트도 과대.
- 근본원인: 메시지마다 최대치 버퍼를 고정 보유. 대부분 페이로드는 수 바이트인데 96KB를 점유한다. 큰 버퍼는 별도 버퍼 풀(고정 크기 슬랩) 에서 빌려야 하고, 메시지 객체는 가벼운 헤더만 들어야 한다.
참고: C#판은 같은 시나리오를 LOH(85,000바이트 임계)/GC Gen2/이중 반납 으로 다룬다. C++엔 GC가 없으므로 "LOH" 대신 할당자 단편화 + 이중 반납에 따른 double-free/UB 가 핵심이 된다. 임계 크기(98,304B)와 "큰 버퍼는 풀링"이라는 결론은 동일하다.
수정안
핵심: (1) 로그는 가드/지연 평가, (2) 통계는 고정 인덱스, (3) 이중 반납 제거 + 재대여 안전장치, (4) 페이로드는 별도 버퍼 풀에서 빌림(메시지 객체는 가벼움).
struct EventMessage
{
int RoomId = 0;
int EventType = 0;
std::uint8_t* Payload = nullptr; // (D) 풀에서 빌린 버퍼를 가리킴 (고정 96KB 제거)
int PayloadLen = 0;
bool InPool = false; // (C) 이중 반납 방어 플래그
void Reset() { RoomId = 0; EventType = 0; PayloadLen = 0; }
};
class MessagePool
{
public:
EventMessage* Rent()
{
std::lock_guard<std::mutex> lk(mtx_);
if (!free_.empty())
{
EventMessage* m = free_.back();
free_.pop_back();
m->InPool = false;
return m;
}
return new EventMessage();
}
void Return(EventMessage* m)
{
std::lock_guard<std::mutex> lk(mtx_);
if (m->InPool) // (C) 이중 반납 거부
throw std::logic_error("double return detected");
m->Reset();
m->InPool = true;
free_.push_back(m);
}
private:
std::mutex mtx_;
std::vector<EventMessage*> free_;
};
void DrainBroadcast()
{
while (!broadcastQueue_.empty())
{
EventMessage* msg = broadcastQueue_.front();
broadcastQueue_.pop_front();
Broadcast(msg);
if (msg->Payload) // (D) 빌린 버퍼 반납
{
bufferPool_.Release(msg->Payload); // 고정 96KB 슬랩 풀로 반납
msg->Payload = nullptr;
}
pool_.Return(msg); // (C) 단 한 번만
}
}
void Tick(int tickNo)
{
for (int roomId : activeRooms_)
{
// (A) 가드로 비활성 시 문자열을 아예 안 만든다. ostringstream 제거.
if (verboseEnabled_)
std::printf("tick %d room %d processing\n", tickNo, roomId);
EventMessage* msg = pool_.Rent();
msg->RoomId = roomId;
msg->EventType = 7;
msg->Payload = bufferPool_.Acquire(); // (D) 고정 크기 버퍼를 풀에서 빌림
msg->PayloadLen = FillPayload(msg->Payload, roomId, tickNo);
broadcastQueue_.push_back(msg);
RecordStat(StatId::RoomEvent, roomId + tickNo); // (B) 문자열 키 제거
}
DrainBroadcast();
}
// (B) 문자열 키 → enum 인덱스 배열
enum class StatId { RoomEvent, Count_ };
std::array<std::atomic<long>, (size_t)StatId::Count_> stats_{};
void RecordStat(StatId id, long value)
{
stats_[(size_t)id].fetch_add(value, std::memory_order_relaxed);
}
bufferPool_(예: 고정 96KB 블록을 미리 N개 할당해 재사용하는 슬랩 풀)을 두면 매 틱 큰 블록을 새로new/mmap하지 않는다. 실제 길이는PayloadLen으로 추적하고 슬라이스(std::span)로 다룬다.
더 나은 설계
-
헤더/버퍼 분리:
EventMessage를 작은 POD 헤더로 두고 페이로드는 별도 버퍼 풀(슬랩/memory_resource)로 분리. 메시지 객체 자체 할당이 거의 사라진다. 트레이드오프: 버퍼 풀 관리 코드. -
풀 무결성 강화:
InPool플래그 외에 디버그 빌드에서std::unordered_set으로 대여/반납을 추적하거나, 노드에 세대(generation) 토큰을 두어 use-after-return을 탐지. 운영 빌드는 비용 0인 플래그만. AddressSanitizer로 double-free/UAF를 CI에서 잡는다. -
std::pmr(다형 메모리 자원):std::pmr::monotonic_buffer_resource나unsynchronized_pool_resource로 틱 동안의 임시 할당을 아레나에서 처리하고 틱 끝에 일괄 reset. 단명 할당의 할당자 압박을 크게 줄인다. 트레이드오프: 컨테이너를 pmr 인지형으로 바꿔야 함. -
대형 할당 정책: 큰 버퍼는 반드시 풀링하거나 청크로 분할. malloc의
M_MMAP_THRESHOLD/M_TRIM_THRESHOLD를 인지하되, 근본은 "큰 단명 블록을 만들지 않는 것". 임계 크기를 외워둘 것(이 코드: 98,304바이트). -
할당 측정:
perf,heaptrack, valgrind massif, jemalloc/tcmalloc의 프로파일러로 "틱당 할당 바이트/횟수"를 0에 수렴시키는 것을 회귀 테스트로.
면접 포인트
- C++ 오브젝트 풀에서 "이중 반납"이 왜 위험한가? (free 리스트 중복 → 동일 객체 동시 대여 → 데이터 손상, 그리고 double-free → 힙 손상). GC가 있는 C#과 없는 C++에서 결과가 어떻게 달라지는가?
- 큰 버퍼(수십~수백 KB)를 메시지마다 들면 어떤 할당자 문제가 생기나? glibc malloc의 mmap 임계/단편화, 그리고 슬랩/아레나 풀링으로 어떻게 해결하는가?
- 핫패스에서 숨은 할당이 생기는 C++ 패턴을 예로 들어라(
ostringstream/.str(), 문자열 키 맵, 임시std::string/std::function등). 비활성 로그의 비용을 0으로 만드는 방법은?