POOOLING FOREST
C++: "우리 집에도 try...finally 있어" (하지만 조심해야 할 것) - C++에는 finally 블록이 없지만 RAII와 소멸자를 통해 동일한 기능을 구현합니다. 하지만 소멸자 내
Engineering & Tech

C++: "우리 집에도 try...finally 있어" (하지만 조심해야 할 것)

C++에는 finally 블록이 없지만 RAII와 소멸자를 통해 동일한 기능을 구현합니다. 하지만 소멸자 내 예외 발생 시 프로그램이 즉사하는 위험성을 경계해야 합니다.

김영태

테크리드

안녕하세요. 풀링포레스트 테크리드 김영태입니다.

얼마 전, 자바 백엔드만 하시다가 저희 팀으로 전향하신 주니어 개발자분이 코드 리뷰 중에 고개를 갸웃거리며 물었습니다. "영태님, C++에는 `finally` 블록이 없나요? 리소스 해제는 도대체 어디서 보장하나요?" 그 질문을 듣고 잠시 옛날 생각이 났습니다. 저도 처음 C++를 접했을 때 똑같은 질문을 했거든요. 오늘은 다른 언어에 익숙한 분들이 C++로 넘어올 때 가장 당황해하는 'finally의 부재'와 그 대안, 그리고 그 속에 숨겨진 무시무시한 함정에 대해 이야기해볼까 합니다.

자바나 파이썬, C# 같은 '친절한' 언어들은 try...finally 구조를 가지고 있습니다. 예외가 터지든 말든, 블록을 빠져나갈 때 무조건 실행해야 하는 코드를 finally에 넣어두면 마음이 편안해지죠.

try {
    dangerousOperation();
} finally {
    alwaysCleanup(); // 무조건 실행됨
}

그런데 C++ 표준 스펙에는 finally가 없습니다. 인터넷 밈(Meme) 중에 "엄마, 나 이거 사줘"라고 하면 엄마가 "집에 다 있어"라고 답하는 짤방 아시나요? C++가 딱 그렇습니다. C++는 이렇게 말합니다. "우리 집에도 try...finally 있어. (비록 모양은 좀 다르지만)"

C++에서 finally의 역할을 하는 건 바로 소멸자(Destructor)입니다. C++의 강력한 기능인 RAII(Resource Acquisition Is Initialization) 패턴 덕분에, 스코프를 벗어나는 순간 스택에 있는 객체들의 소멸자가 자동으로 호출됩니다. 마이크로소프트의 라이브러리인 WIL(Windows Implementation Library) 같은 곳에서는 wil::scope_exit이라는 헬퍼를 제공하는데, 원리는 간단합니다. 람다 함수를 하나 받아서, 객체가 소멸될 때 그 람다를 실행시키는 것이죠.

auto ensure_cleanup = wil::scope_exit([&] {
    alwaysCleanup();
});
dangerousOperation();
// 블록을 벗어나면 ensure_cleanup이 소멸되면서 alwaysCleanup() 실행

여기까지만 보면 "아, 그냥 문법만 다른 거구나" 하고 넘어가기 쉽습니다. 하지만 제가 8년 전, 실무 투입 초기에 겪었던 트러블슈팅 경험을 떠올려보면 이건 단순한 문법 차이가 아니었습니다. 등골이 서늘했던 그날의 기억은 바로 "예외 처리 방식의 차이" 때문이었습니다.

상황은 이렇습니다. try 블록 안에서 치명적인 에러가 발생해 예외가 던져졌습니다(Throw). 시스템은 스택을 정리하며(Unwinding) finally 혹은 소멸자를 호출합니다. 그런데, 만약 그 청소하는 과정(cleanup)에서도 또다시 예외가 발생한다면 어떻게 될까요?

자바나 파이썬은 보통 finally에서 발생한 새로운 예외가 이전 예외를 덮어쓰거나(Swallow), 최신 파이썬처럼 문맥을 저장하고 새 예외를 던집니다. 어쨌든 프로그램이 바로 죽지는 않고, 상위 핸들러가 처리할 기회를 줍니다.

하지만 C++는 냉정합니다. 이미 예외가 발생해서 스택을 정리하는 도중에 소멸자에서 또 예외가 나오면, C++ 런타임은 가차 없이 std::terminate()를 호출해버립니다. 즉, 프로그램이 즉사합니다. 로그도, 덤프도 제대로 남기지 못하고 프로세스가 증발해버리는 겁니다.

제가 겪었던 장애도 정확히 이 케이스였습니다. 메인 로직이 실패해서 트랜잭션을 롤백하려는데, DB 연결이 끊어져 있어서 롤백 함수마저 예외를 던진 상황이었죠. 결과는 서버 다운이었습니다. "집에 있는 finally"를 믿고 썼다가 집을 태워 먹은 꼴이었습니다.

그래서 C++에서 '집에 있는 finally'(소멸자 기반 클린업)를 사용할 때는 반드시 지켜야 할 철칙이 있습니다. "소멸자는 절대 예외를 밖으로 던지면 안 된다"는 것입니다.

클린업 코드 내부에서 예외가 발생할 가능성이 1%라도 있다면, 반드시 그 안에서 try-catch로 잡아서 로그만 남기고 삼켜야 합니다. wil::scope_exit 문서에도 "람다가 예외를 던지면 프로세스가 종료된다"고 명시되어 있고, 대안으로 wil::scope_exit_log 같은 기능을 제공해 예외를 로그로 남기고 무시하도록 유도합니다.

C++의 방식이 너무 가혹하게 느껴지시나요? 하지만 생각해보면, 이미 예외 상황이 벌어져서 수습하고 있는데 거기서 또 문제가 터졌다면, 더 이상 프로그램의 상태를 신뢰할 수 없다고 판단하고 빠르게 종료하는 것이 시스템 전체의 안전을 위해 더 나은 선택일 수도 있습니다. '좀비 프로세스'가 되어 엉뚱한 데이터를 오염시키는 것보다는 나으니까요.

다른 언어에서 넘어오신 분들이 C++의 RAII를 접할 때, 단순히 finally의 대체재로만 생각하지 않으셨으면 좋겠습니다. 그 이면에 숨겨진 "리소스의 수명 주기와 예외 안전성(Exception Safety)"에 대한 C++만의 철학을 이해하신다면, 훨씬 더 견고한 백엔드 서버를 만드실 수 있을 겁니다.

오늘의 결론입니다. C++네 집에도 finally는 있습니다. 다만 그 친구는 성격이 좀 까칠해서, 청소 중에 건드리면 집을 폭파할 수도 있으니 조심스럽게 다뤄주세요.

지금 읽으신 내용, 귀사에 적용해보고 싶으신가요?

상황과 목표를 알려주시면 가능한 옵션과 현실적인 도입 경로를 제안해드립니다.