29. placement new와 수동 소멸자 호출(객체 라이프타임 관리)
난이도 최상내 리뷰 · C++
해설 · C++
해설 — placement new와 수동 소멸자 호출(객체 라이프타임 관리)
난이도: 최상
답변 프레임워크: 요약 → 핵심 개념 → 단계별 내부 동작 → 흔한 오해·함정 → 면접 포인트
요약
C++ 에서 "메모리를 갖는 것"과 "그 위에 살아있는 객체가 있는 것"은 별개의 개념이다.
storage_ 배열은 처음엔 그냥 바이트 덩어리(메모리)일 뿐 Session 객체가 아니다.
new (p) Session(id) (placement new)는 새 메모리를 할당하지 않고, 이미 있는 메모리
위에 생성자만 호출해 그 자리를 "살아있는 Session 객체"로 만든다. 반대로 p->~Session()
은 소멸자만 호출할 뿐 메모리를 해제하지 않는다 — 그래서 그 직후 같은 자리에 새 객체를
placement new 하는 것이 안전하다(정확히는, 새 객체의 lifetime 이 시작되며 이전 객체의
lifetime 은 이미 소멸자 호출로 끝나 있어야 한다). 이 분리 덕분에 힙 할당/해제 없이 같은 메모리를
반복 재사용하는 오브젝트 풀을 만들 수 있다.
출력 예측
Session(1) 생성
Session(1) 인사
Session(1) 소멸
Session(2) 생성
Session(2) 인사
main 끝, slot 소멸 예정
Session(2) 소멸
- 두 번째
Acquire(2)는 새로 메모리를 할당하지 않고 같은storage_위에서 생성자를 다시 실행한다 — "재사용 시 생성자가 또 불리는가?"의 답은 그렇다, 단 이는 일반적인 객체 생성이 아니라 placement new 이므로 메모리 할당 비용은 없다. slot이 스코프를 벗어날 때~SessionSlot()→Release()가 호출되지만alive_가 이미true상태(마지막Acquire(2)이후Release()를 안 불렀으므로)라서Session(2)의 소멸자가 정확히 한 번 더 호출된다. 총 소멸자 호출은 2번(id=1, id=2), 이중 소멸 없음.
핵심 개념
- 할당(allocation) vs 생성(construction):
::operator new(n)은 크기 n 바이트의 적합하게 정렬된 원시 메모리만 반환한다 — 생성자를 호출하지 않는다. 일반new Session(id)는 사실 "::operator new(sizeof(Session))로 메모리를 받고, 그 위에 placement new 로 생성자를 호출"하는 두 단계를 합친 문법 설탕이다. - placement new:
new (ptr) T(args...)는 메모리를 할당하지 않고 주어진ptr위에서 생성자만 호출한다. 반환값은ptr을T*로 본 것과 같다(사실은 새 객체의 포인터를 새로 얻어야 엄밀하지만, 실무적으로는ptr를 그대로 써도 대부분 동작한다). - 객체 수명(object lifetime)의 시작/끝: 표준적으로 객체의 수명은 "메모리 확보 + 생성자
완료"에서 시작해 "소멸자 호출 시작"에서 끝난다. 소멸자를 명시적으로 호출(
p->~T())하면 그 객체의 수명은 끝나지만 메모리는 여전히 유효하며, 그 메모리에 같은 타입이든 다른 타입이든 새 객체를 placement new 할 수 있다. - 정렬(alignment):
Session객체를 담을 원시 버퍼는Session이 요구하는 정렬로 배치되어야 한다.alignas(Session) unsigned char storage_[sizeof(Session)]처럼alignas를 명시하지 않으면unsigned char[]는 1바이트 정렬만 보장되어, 정렬이 더 큰 타입(예: 8바이트 정렬이 필요한 멤버 포함)을 올릴 때 정렬 위반이 발생할 수 있다.
단계별 내부 동작
SessionSlot slot;:storage_는sizeof(Session)바이트의 정렬된 원시 메모리로 확보된다. 이 시점엔Session객체가 없다(생성자가 안 불렸다).slot.Acquire(1):reinterpret_cast<Session*>(&storage_)로 그 주소를Session*타입의 포인터 값으로만 재해석한다(아직 객체는 없음).new (p) Session(1)이 그 자리에서Session::Session(1)을 실행 — 이 순간부터 그 메모리는 "살아있는 Session(1) 객체"가 된다.s1->Greet(): 정상적인 멤버 함수 호출. 평범한 객체와 다를 바 없다.slot.Release():p->~Session()이Session::~Session()본문만 실행한다. 메모리는 해제되지 않는다 —storage_배열은 여전히SessionSlot이 소유한 채로 남아있고, 다만 그 위의Session객체 수명은 끝났다(그 메모리를Session으로서 다시 쓰면 UB지만, 재생성하는 것은 유효하다).slot.Acquire(2): 같은 주소에 다시placement new→Session::Session(2)실행. 힙 할당/해제가 전혀 없었으므로malloc/free오버헤드 없이 객체를 "재활용"했다.~SessionSlot():Release()가 호출되고,alive_ == true이므로Session(2)의 소멸자가 호출된다. 그 후storage_배열(스택/멤버 메모리)은SessionSlot자체가 소멸하며 자연히 회수된다 — 여기엔delete가 없다(애초에::operator new로 할당한 적이 없으므로 짝이 맞는 해제도 필요 없다).
흔한 오해·함정
- "소멸자를 명시적으로 부르면
delete와 같다" ✗ —p->~T()는 소멸자 본문만 실행한다. 힙에서::operator new로 할당한 메모리라면 별도로::operator delete(p)를 호출해 메모리도 해제해야 한다(delete p는 이 둘을 합친 것). 이 문제의storage_는 애초에SessionSlot멤버(자동/정적 저장 기간)이므로 별도 해제가 필요 없다 — placement new 로 만든 객체를 일반delete로 지우면 정의되지 않은 동작(할당자 불일치)이 된다. - 이중 소멸(double destruction):
Release()를 두 번 연달아 부르면 이미 죽은 객체의 소멸자를 또 호출하는 UB가 된다. 이 코드가alive_플래그로 방지하는 이유가 바로 이것이다 — 실무 오브젝트 풀에서 "이미 반납된 슬롯을 또 반납"하는 버그가 흔하다. 거꾸로 생성 안 한 채 소멸자 호출(Acquire를 안 부르고Release())도 마찬가지로 위험하다. - 정렬 무시:
unsigned char storage_[sizeof(Session)]처럼alignas를 빼면,Session이 요구하는 정렬보다 느슨한 정렬의 버퍼에 객체를 올리게 되어 특정 아키텍처(비 x86, SIMD 멤버 포함 타입 등)에서 크래시하거나 성능이 떨어질 수 있다. C++17 이후엔alignas(T)대신std::aligned_storage(지금은 폐기 예정) 혹은alignas를 직접 쓰는 것이 표준적이다. - "메모리 재사용 = 값이 초기화된다" ✗ — placement new 는 생성자가 명시적으로 초기화하는 멤버만 초기화한다. 이전 객체가 쓰던 잔여 바이트 패턴이 새 객체의 초기화되지 않은 멤버에 우연히 남아있을 수 있다는 점(엄밀히는 새 객체 생성자가 다 초기화하면 문제 없음)을 오해해 "풀에서 꺼내면 항상 zero-initialized"라고 착각하면 안 된다.
면접 포인트 (예상 질문)
new Session(id)한 줄이 내부적으로 몇 단계로 나뉘는가? 그중 오브젝트 풀을 만들려면 왜 그 단계들을 분리해야 하는가?p->~T()호출 후 그 메모리에 새 객체를 placement new 하는 것이 왜 안전한가? 반대로delete p후 같은 메모리에 placement new 하면 무엇이 문제인가?- 오브젝트 풀에서 이중 소멸/미생성 상태 소멸 버그를 어떻게 방지할 것인가? 정렬은 왜 신경 써야 하는가?