C++ 기술 면접 질문 30선: 포인터·RAII·가상 함수·STL·동시성 답변 정리
이 글의 핵심
C++ 기술 면접에서는 포인터·RAII·가상 함수·STL·동시성 등 개념을 구두로 설명할 수 있어야 합니다. 이 글에서는 자주 나오는 질문 30가지와 답변 흐름에 더해, vtable·shared_ptr 제어 블록·이동과 복사 생략·템플릿 인스턴스화처럼 꼬리 질문으로 이어지는 내용을 함께 정리했습니다.
들어가며
C++ 기술 면접은 코딩 테스트와 달리 개념을 말로 설명하는 능력을 봅니다. “포인터와 참조의 차이는?”, “가상 함수는 어떻게 동작하나요?”, “멀티스레딩에서 race condition을 어떻게 막나요?” 같은 질문이 기본으로 나오고, 답변에 따라 “그럼 vtable은 어디에 있나요?”, “make_shared가 항상 좋은가요?” 같은 꼬리 질문이 이어집니다.
이 글은 포인터와 메모리 관리, 객체지향과 가상 함수, STL과 템플릿, 멀티스레딩, 모던 C++로 나누어 자주 나오는 30개 질문과 답변 흐름을 정리하고, 몇몇 질문에는 꼬리 질문에 대비한 심화 설명을 붙였습니다.
포인터와 메모리 관리
Q1. 포인터와 참조의 차이는?
| 특징 | 포인터 | 참조 |
|---|---|---|
| 재지정 | 가능 (ptr = &other;) | 불가능 (초기화 후 고정) |
| null | 가능 | 유효한 프로그램에서는 불가능 |
| 초기화 | 선택 | 선언 시 필수 |
| 문법 | *ptr, ptr-> | 일반 변수처럼 사용 |
| 저장 공간 | 포인터 크기(64비트에서 보통 8바이트) | 표준은 저장 공간을 차지하는지 규정하지 않음 |
참조의 크기는 흔히 “별칭이라 메모리를 안 쓴다”고 설명하지만 정확하지 않습니다. 지역 참조는 최적화로 사라지는 경우가 많지만, 클래스의 참조 멤버는 구현상 포인터처럼 저장 공간을 차지합니다. 표준은 참조가 저장 공간을 요구하는지 명시하지 않고, sizeof에 참조 타입을 넣으면 참조 대상 타입의 크기가 나옵니다.
#include <iostream>
int main() {
int x = 10;
int* ptr = &x;
ptr = nullptr; // 포인터는 다른 대상으로 바꿀 수 있음
int& ref = x; // 참조는 선언 시 바인딩, 이후 변경 불가
ref = 20; // x에 20을 대입하는 것이지 재바인딩이 아님
std::cout << x << "\n"; // 20
}
실무 기준으로는 함수가 읽기만 하는 큰 객체는 const 참조로, 수정해야 하면 비const 참조로 받습니다. “없을 수도 있음”을 표현해야 하면 포인터나 std::optional을 씁니다. 이때 소유권을 넘기는 것이 아니라면 raw 포인터는 관찰용으로만 쓰고, 소유권은 스마트 포인터로 표현합니다.
#include <iostream>
#include <string>
#include <unordered_map>
void print(const std::string& str) { // 복사 없음, 수정 없음
std::cout << str << '\n';
}
void modify(std::string& str) { // 복사 없음, 수정 가능
str += " modified";
}
// 없을 수도 있는 값을 관찰용 포인터로 반환 (소유권은 users가 가짐)
const std::string* findUser(const std::unordered_map<int, std::string>& users, int id) {
auto it = users.find(id);
return it == users.end() ? nullptr : &it->second;
}
new로 만든 객체를 raw 포인터로 반환하면 호출자가 delete를 잊기 쉽습니다. 소유권을 넘겨야 한다면 std::unique_ptr을 반환하는 것이 모던 C++의 관례입니다.
Q2. 스택과 힙의 차이는?
| 특징 | 스택 | 힙(자유 저장소) |
|---|---|---|
| 할당 | 자동 (지역 변수) | 명시적 (new, malloc) |
| 해제 | 스코프를 벗어나면 자동 | 명시적 (delete, free) 또는 RAII |
| 속도 | 스택 포인터 조정만으로 끝나 매우 빠름 | 할당자가 빈 블록을 찾아야 해서 상대적으로 느림 |
| 크기 | 스레드마다 고정된 한도 (Linux 메인 스레드 기본 8MB, Windows 기본 1MB) | 프로세스 가상 메모리 한도까지 |
| 수명 | 함수 반환 시 종료 | 해제할 때까지 유지 |
void func() {
int a = 10; // 스택
int* p = new int(20); // 힙
delete p; // 명시적 해제
} // a는 자동으로 사라짐
스택 할당이 빠른 이유는 함수 진입 시 스택 포인터를 필요한 만큼 한 번 옮기는 것으로 모든 지역 변수의 공간이 확보되기 때문입니다. 힙 할당은 할당자가 크기에 맞는 빈 블록을 찾고, 메타데이터를 관리하고, 멀티스레드 환경에서는 동기화까지 해야 합니다. 현대 할당자(glibc malloc의 tcache, jemalloc, tcmalloc)는 작은 할당의 빠른 경로를 스레드 로컬 캐시로 처리해 꽤 빠르지만, 여전히 스택보다는 비싸고 캐시 지역성도 떨어집니다.
힙이 필요한 경우는 크기가 커서 스택 한도를 넘을 수 있을 때, 객체가 함수보다 오래 살아야 할 때, 크기가 런타임에 결정될 때입니다. 스레드의 스택 크기는 Linux에서 ulimit -s로 확인할 수 있고, 새로 만든 스레드는 보통 별도 기본값(glibc는 ulimit -s 값을 따름)을 씁니다.
Q3. 메모리 누수란? 어떻게 방지하나요?
메모리 누수는 동적으로 할당한 메모리를 더 이상 가리키는 포인터가 없어 해제할 수 없게 된 상태입니다. 프로세스가 종료되면 OS가 회수하지만, 서버처럼 오래 도는 프로세스에서는 메모리 사용량이 계속 늘어납니다.
void leak() {
int* p = new int(42);
// delete p;를 하지 않음
} // p는 사라지지만 힙 메모리는 남음
가장 확실한 방지법은 소유권을 객체의 수명에 묶는 것입니다. 동적 할당은 스마트 포인터로 관리하고, 파일이나 소켓 같은 자원은 RAII 클래스로 감쌉니다.
#include <cstdio>
#include <memory>
void noLeak() {
auto p = std::make_unique<int>(42); // 스코프를 벗어나면 자동 해제
}
class FileHandle {
std::FILE* file_;
public:
explicit FileHandle(const char* path) : file_(std::fopen(path, "r")) {}
~FileHandle() { if (file_) std::fclose(file_); }
FileHandle(const FileHandle&) = delete; // 복사하면 fclose가 두 번 호출됨
FileHandle& operator=(const FileHandle&) = delete;
std::FILE* get() const { return file_; }
};
RAII 클래스를 만들 때 복사를 막지 않으면, 복사본과 원본이 각자 소멸자에서 같은 자원을 해제하는 이중 해제 버그가 생깁니다. 면접에서 RAII 클래스를 직접 쓰라고 하면 이 부분을 빠뜨리지 않는 것이 중요합니다.
누수를 찾을 때는 Linux에서 valgrind --leak-check=full ./myapp이나 AddressSanitizer의 LeakSanitizer(-fsanitize=address)를 씁니다. ASan은 Valgrind보다 훨씬 빠르지만 다시 빌드해야 한다는 차이가 있습니다.
Q4. 얕은 복사와 깊은 복사의 차이는?
얕은 복사는 포인터 값(주소)만 복사해서 두 객체가 같은 메모리를 가리키게 되고, 깊은 복사는 포인터가 가리키는 데이터까지 새로 할당해 복사합니다. 컴파일러가 만드는 기본 복사 생성자는 멤버별 복사이므로, raw 포인터 멤버에 대해서는 얕은 복사가 됩니다.
#include <utility>
class MyClass {
int* data;
public:
explicit MyClass(int val) : data(new int(val)) {}
MyClass(const MyClass& other) : data(new int(*other.data)) {} // 깊은 복사
MyClass& operator=(MyClass other) { // copy-and-swap
std::swap(data, other.data);
return *this;
}
~MyClass() { delete data; }
};
기본 복사 생성자를 그대로 쓰면 MyClass b = a; 이후 두 객체가 같은 data를 가리키고, 소멸 시 같은 메모리를 두 번 delete하는 이중 해제가 됩니다. 소멸자를 직접 정의했다면 복사 생성자와 복사 대입 연산자도 함께 정의해야 한다는 것이 “3의 법칙”이고, 이동 연산까지 포함하면 “5의 법칙”입니다. 더 좋은 방법은 std::unique_ptr이나 std::vector 같은 자원 관리 타입을 멤버로 써서 특수 멤버 함수를 하나도 직접 쓰지 않는 “0의 법칙”입니다.
Q5. 스마트 포인터 종류와 차이는?
| 종류 | 소유권 | 복사 | 주 용도 |
|---|---|---|---|
unique_ptr | 단독 소유 | 불가 (이동만) | 기본 선택, 소유권이 명확한 자원 |
shared_ptr | 공유 소유 (참조 카운트) | 가능 | 소유자가 여럿이고 누가 마지막인지 정할 수 없을 때 |
weak_ptr | 소유하지 않음 | 가능 | shared_ptr 순환 참조 끊기, 관찰자 |
#include <memory>
auto p1 = std::make_unique<int>(42);
// auto p2 = p1; // 컴파일 에러: 복사 불가
auto p2 = std::move(p1); // 이동, 이후 p1은 nullptr
auto s1 = std::make_shared<int>(42);
auto s2 = s1; // 복사, 참조 카운트 2
심화: 제어 블록과 비용
unique_ptr은 별도의 제어 블록이 없습니다. 기본 삭제자를 쓰면 크기가 raw 포인터와 같고, 상태가 있는 커스텀 삭제자를 쓰면 그 삭제자가 unique_ptr 객체 안에 저장되어 크기가 커집니다. 삭제자 타입이 unique_ptr의 템플릿 인자이기 때문입니다.
shared_ptr은 관리 대상 객체와 별도로 제어 블록을 가집니다. 제어 블록에는 강한 참조 카운트(shared_ptr 수), 약한 참조 카운트(weak_ptr 수와 관련), 그리고 타입이 지워진 삭제자와 할당자가 들어갑니다. 강한 카운트가 0이 되면 객체가 파괴되고, 약한 카운트까지 0이 되면 제어 블록이 해제됩니다. 삭제자가 제어 블록 안에 타입 소거되어 들어가므로, 같은 shared_ptr<Widget> 타입이라도 인스턴스마다 서로 다른 삭제자를 가질 수 있습니다. 이 점이 삭제자가 타입의 일부인 unique_ptr과의 차이입니다.
std::make_shared<T>()는 객체와 제어 블록을 한 번에 할당하므로 할당 횟수가 줄고 지역성이 좋아집니다. shared_ptr<T>(new T)는 객체와 제어 블록을 따로 할당합니다. 하지만 make_shared에는 함정이 있습니다. 객체와 제어 블록이 한 덩어리이므로, 강한 참조가 0이 되어 객체의 소멸자가 호출되더라도 weak_ptr이 하나라도 남아 있으면 객체가 차지하던 메모리까지 해제되지 않습니다. 큰 객체를 오래 사는 weak_ptr로 관찰하는 구조라면 따로 할당하는 편이 메모리 사용 면에서 낫습니다. 커스텀 삭제자가 필요할 때도 make_shared는 쓸 수 없습니다.
참조 카운트 증감은 멀티스레드 안전을 위해 원자적 연산으로 구현되므로, shared_ptr을 복사하고 소멸시키는 비용은 unique_ptr을 이동하는 것보다 큽니다. 여러 스레드가 같은 shared_ptr을 자주 복사하면 참조 카운트가 있는 캐시 라인이 코어 사이를 오가는 경합도 생깁니다. 그래서 함수 인자로 shared_ptr을 값으로 넘기기보다, 소유권이 필요 없으면 const T&나 T*로 넘기는 것이 좋습니다. 참고로 참조 카운트가 원자적이라고 해서 가리키는 객체 접근이나 같은 shared_ptr 인스턴스에 대한 동시 대입까지 스레드 안전해지는 것은 아닙니다.
enable_shared_from_this를 상속한 클래스는 shared_from_this()로 자신을 가리키는 shared_ptr을 얻을 수 있습니다. 내부적으로는 shared_ptr이 처음 객체를 소유할 때 채워지는 weak_ptr를 사용하므로, 객체가 아직 shared_ptr로 관리되지 않는 상태(스택 객체나 생성자 안)에서 호출하면 C++17부터는 std::bad_weak_ptr 예외가, 그 이전에는 정의되지 않은 동작이 발생합니다. this로 shared_ptr<T>(this)를 새로 만들면 제어 블록이 두 개가 되어 이중 해제로 이어지므로, 이 기능이 필요한 이유를 설명할 수 있으면 좋은 답이 됩니다.
Q6. 댕글링 포인터란?
이미 해제되었거나 수명이 끝난 객체를 가리키는 포인터입니다. 이런 포인터로 읽거나 쓰는 것은 정의되지 않은 동작(UB)입니다.
int* ptr = new int(42);
delete ptr;
*ptr = 10; // UB: 해제된 메모리에 쓰기
int* bad() {
int local = 5;
return &local; // 함수가 끝나면 local의 수명이 끝남
}
delete 후 ptr = nullptr;로 두면 같은 포인터를 다시 쓰는 실수는 막을 수 있지만, 다른 곳에 복사된 포인터는 여전히 댕글링 상태라는 한계가 있습니다. 근본적인 해결은 소유권을 스마트 포인터로 명확히 하고, 관찰만 하는 쪽은 수명을 확인할 수 있는 weak_ptr을 쓰는 것입니다. 발견에는 AddressSanitizer의 use-after-free 검사가 가장 효과적입니다.
Q7. new와 malloc의 차이는?
| 특징 | new | malloc |
|---|---|---|
| 소속 | C++ 연산자 | C 라이브러리 함수 |
| 반환 타입 | T* | void* (캐스트 필요) |
| 생성자/소멸자 | 호출함 (delete가 소멸자 호출) | 호출하지 않음 |
| 실패 시 | std::bad_alloc 예외 (new(std::nothrow)는 nullptr) | NULL 반환 |
| 크기 | 타입에서 자동 계산 | 바이트 수를 직접 지정 |
| 해제 | delete / delete[] | free |
#include <cstdlib>
MyClass* obj = new MyClass(10); // 메모리 할당 + 생성자 호출
delete obj; // 소멸자 호출 + 메모리 해제
// malloc은 메모리만 줄 뿐 객체를 만들지 않음
void* raw = std::malloc(sizeof(MyClass));
std::free(raw);
malloc으로 받은 메모리에 객체를 만들려면 placement new(new (raw) MyClass(10))로 생성하고, 해제 전에 소멸자를 직접 호출해야 합니다. new로 할당한 것을 free로, malloc으로 할당한 것을 delete로 해제하는 것은 UB이고, new[]는 반드시 delete[]와 짝을 맞춰야 합니다. C++ 코드에서는 둘 다 직접 쓰기보다 std::make_unique와 표준 컨테이너를 쓰는 것이 권장됩니다.
Q8. RAII란?
RAII(Resource Acquisition Is Initialization)는 자원(메모리, 파일, 락, 소켓 등)의 수명을 객체의 수명에 묶는 기법입니다. 생성자에서 자원을 얻고 소멸자에서 해제하므로, 함수가 정상 반환하든 예외로 빠져나가든 스코프를 벗어나는 순간 자원이 정리됩니다. C++에서 스택 되감기(stack unwinding) 중에도 소멸자가 호출된다는 것이 이 기법의 근거입니다.
#include <mutex>
class LockGuard {
std::mutex& mtx_;
public:
explicit LockGuard(std::mutex& m) : mtx_(m) { mtx_.lock(); }
~LockGuard() { mtx_.unlock(); }
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
};
std::mutex g_mtx;
void process() {
LockGuard lock(g_mtx); // 생성 시 lock
// 여기서 예외가 나도 소멸자에서 unlock
}
표준 라이브러리의 예로는 std::unique_ptr, std::lock_guard와 std::scoped_lock, 파일을 자동으로 닫는 std::ifstream, 그리고 메모리를 관리하는 모든 컨테이너가 있습니다. 소멸자에서 예외를 던지면 스택 되감기 중에 std::terminate가 호출될 수 있으므로, 소멸자는 예외를 던지지 않도록 작성해야 합니다(C++11부터 소멸자는 기본적으로 noexcept입니다).
Q9. 가상 소멸자는 왜 필요한가요?
베이스 클래스 포인터로 파생 클래스 객체를 delete할 때 베이스의 소멸자가 가상이 아니면 정의되지 않은 동작입니다. 실제 구현에서는 보통 베이스 소멸자만 호출되어 파생 클래스의 자원이 해제되지 않지만, 표준상으로는 어떤 일이든 일어날 수 있습니다.
#include <iostream>
class Base {
public:
~Base() { std::cout << "~Base\n"; } // 가상이 아님
};
class Derived : public Base {
int* data;
public:
Derived() : data(new int[100]) {}
~Derived() { delete[] data; std::cout << "~Derived\n"; }
};
int main() {
Base* ptr = new Derived();
delete ptr; // UB: 보통 ~Base만 호출되어 data가 누수됨
}
베이스 소멸자를 virtual ~Base()로 선언하면 delete ptr;이 ~Derived, ~Base 순서로 호출됩니다. 다형적으로 쓸 클래스(가상 함수가 하나라도 있는 클래스)라면 소멸자를 가상으로 두는 것이 원칙입니다. 반대로 베이스 포인터로 삭제할 일이 없게 설계했다면 소멸자를 protected 비가상으로 두는 방법도 있습니다. 참고로 std::shared_ptr<Base> p = std::make_shared<Derived>();는 제어 블록이 Derived의 삭제자를 기억하므로 가상 소멸자가 없어도 올바르게 파괴되지만, unique_ptr은 그렇지 않습니다.
Q10. 스택 오버플로는 언제 발생하나요?
스레드의 스택 한도를 넘어 스택을 사용할 때 발생합니다. 대부분의 OS는 스택 끝에 보호 페이지를 두어 접근 시 세그멘테이션 폴트(Windows에서는 스택 오버플로 예외)로 프로세스를 종료합니다.
void recursive(int n) {
recursive(n + 1); // 종료 조건 없는 재귀
}
int main() {
int arr[10'000'000]; // 약 40MB: Linux 기본 8MB 스택을 넘음
arr[0] = 1;
}
해결책은 재귀에 종료 조건을 두거나 깊이가 입력에 비례하는 재귀를 반복문과 명시적 스택으로 바꾸는 것, 큰 배열은 std::vector로 힙에 두는 것입니다. 실무에서 자주 만나는 경우는 사용자 입력 크기에 따라 재귀 깊이가 달라지는 트리·그래프 탐색과, 스택이 작은 스레드(예: 스레드 풀 워커)에서 큰 지역 배열을 쓰는 경우입니다.
객체지향과 가상 함수
Q11. 가상 함수는 어떻게 동작하나요?
C++ 표준은 구현 방식을 규정하지 않지만, 주요 컴파일러는 모두 가상 함수 테이블(vtable)을 씁니다. 가상 함수가 있는 클래스마다 가상 함수 주소 배열인 vtable이 하나 만들어지고, 각 객체에는 자기 동적 타입의 vtable을 가리키는 숨은 포인터(vptr)가 들어갑니다. 가상 함수를 호출하면 객체의 vptr을 읽고, vtable의 해당 슬롯에서 함수 주소를 읽어 간접 호출합니다.
Derived 객체:
[vptr][Base 멤버들][Derived 멤버들]
Base의 vtable: Derived의 vtable:
[0] Base::show [0] Derived::show (오버라이드)
[1] Base::other [1] Base::other (상속)
#include <iostream>
class Base {
public:
virtual void show() { std::cout << "Base\n"; }
virtual ~Base() = default;
};
class Derived : public Base {
public:
void show() override { std::cout << "Derived\n"; }
};
int main() {
Base* ptr = new Derived();
ptr->show(); // "Derived": ptr의 vptr → Derived vtable → Derived::show
delete ptr;
}
vptr은 생성자가 실행되는 동안 단계적으로 설정됩니다. Base 생성자가 실행되는 동안에는 vptr이 Base의 vtable을 가리키므로, 생성자나 소멸자 안에서 가상 함수를 호출하면 파생 클래스의 오버라이드가 아니라 현재 생성 중인 클래스의 버전이 호출됩니다. 면접에서 자주 이어지는 질문입니다.
심화: 레이아웃과 디스패치 비용
Itanium C++ ABI(GCC, Clang)와 MSVC 모두 단일 상속에서는 vptr을 객체의 맨 앞에 둡니다. vtable에는 가상 함수 슬롯 외에도 RTTI(type_info) 포인터와 오프셋 정보가 함께 들어가고, Itanium ABI에서는 가상 소멸자가 “완전 객체 소멸자”와 “삭제 소멸자” 두 슬롯을 차지하는 등 슬롯 배치는 ABI마다 다릅니다.
객체 슬라이싱도 이 구조로 설명할 수 있습니다. Base b = derived;처럼 파생 객체를 베이스 타입 값으로 복사하면 베이스 부분의 멤버만 복사되고, b의 vptr은 복사되지 않고 Base 생성자가 설정한 Base의 vtable을 그대로 가리킵니다. 그래서 b.show()는 Base::show를 호출하고, 파생 클래스의 정보는 사라집니다.
다중 상속에서는 파생 객체 안에 베이스 서브객체가 여러 개 있고, 두 번째 이후 베이스는 객체 시작 주소와 다른 위치에 있습니다. 이 때문에 파생 객체에는 vptr이 여러 개 생기고, Base2*로 가상 함수를 호출하면 this를 파생 객체 시작 주소로 보정하는 썽크(thunk)를 거칠 수 있습니다. 가상 상속은 공유 베이스의 위치를 런타임에 찾아야 하므로 멤버 접근과 생성·소멸이 더 복잡해집니다.
가상 호출의 비용은 vptr 로드, 슬롯 로드, 간접 분기입니다. 호출 대상이 일정하면 분기 예측이 잘 되어 비용이 작지만, 더 큰 비용은 컴파일러가 호출 대상을 모르기 때문에 인라인화를 못 한다는 점입니다. 그래서 작은 가상 함수를 핫 루프에서 수백만 번 부르는 코드에서는 차이가 드러나고, I/O나 락 경합이 지배적인 코드에서는 거의 보이지 않습니다. 컴파일러는 동적 타입을 알 수 있을 때(지역 객체를 직접 호출, final 클래스나 final 함수, LTO로 전체 계층을 볼 수 있을 때) 가상 호출을 직접 호출로 바꾸는 탈가상화(devirtualization)를 합니다. 런타임 다형성이 필요 없다면 CRTP로 컴파일 타임 다형성을 쓰거나, 타입 집합이 닫혀 있다면 std::variant와 std::visit을 쓰는 대안이 있습니다.
Q12. 순수 가상 함수란?
= 0으로 선언한 가상 함수입니다. 순수 가상 함수가 하나라도 있는 클래스는 추상 클래스가 되어 직접 인스턴스를 만들 수 없고, 파생 클래스가 모든 순수 가상 함수를 오버라이드해야 인스턴스화할 수 있습니다.
#include <iostream>
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
double radius;
public:
explicit Circle(double r) : radius(r) {}
double area() const override { return 3.141592653589793 * radius * radius; }
};
int main() {
// Shape s; // 컴파일 에러: 추상 클래스
Circle c(5.0);
std::cout << c.area() << '\n';
}
주로 인터페이스를 정의할 때 씁니다. 잘 알려지지 않은 사실로, 순수 가상 함수도 정의(본문)를 가질 수 있습니다. 특히 순수 가상 소멸자는 파생 클래스 소멸 시 반드시 호출되므로 정의를 제공해야 링크 에러가 나지 않습니다.
Q13. override 키워드는 왜 쓰나요?
C++11에서 추가된 override는 이 함수가 베이스 클래스의 가상 함수를 오버라이드한다는 의도를 컴파일러에 알려 줍니다. 시그니처가 조금이라도 다르면 오버라이드가 아니라 새 함수가 되는데, override를 붙이면 이런 실수가 컴파일 에러로 드러납니다.
class Base {
public:
virtual void show() const {}
virtual ~Base() = default;
};
class Derived : public Base {
public:
void show() {} // const가 빠져 오버라이드가 아닌 새 함수, 경고 없이 컴파일됨
// void show() override {} // 컴파일 에러: 오버라이드할 함수가 없음
};
베이스 클래스의 시그니처가 나중에 바뀌었을 때(매개변수 타입 변경 등) 모든 파생 클래스에서 에러가 나므로 리팩터링에도 도움이 됩니다. 더 이상 오버라이드되지 않아야 하는 함수나 상속되지 않아야 하는 클래스에는 final을 씁니다.
Q14. 다중 상속의 문제점은?
대표적인 문제는 다이아몬드 상속입니다. 두 부모 클래스가 같은 조상을 상속하면, 파생 클래스 안에 조상 서브객체가 두 개 생겨 멤버 이름이 모호해집니다.
class A { public: int value = 0; };
class B : public A {};
class C : public A {};
class D : public B, public C {}; // D 안에 A가 두 개
int main() {
D d;
// d.value = 10; // 컴파일 에러: B::A::value인지 C::A::value인지 모호
d.B::value = 10; // 경로를 명시해야 함
}
가상 상속을 쓰면 공유 조상이 하나만 존재합니다.
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {}; // A는 하나
int main() {
D d;
d.value = 10; // 모호하지 않음
}
가상 상속에서는 가장 많이 파생된 클래스(D)가 가상 베이스(A)의 생성자를 직접 호출해야 하고, 멤버 접근에 간접 단계가 추가됩니다. 이런 복잡성 때문에 실무에서는 구현을 다중 상속하기보다 데이터가 없는 인터페이스(순수 가상 함수만 있는 클래스)를 여러 개 상속하는 방식이 일반적입니다.
Q15. static 멤버 변수는 언제 쓰나요?
클래스의 모든 객체가 공유하는 변수로, 객체와 무관하게 클래스당 하나만 존재합니다.
#include <iostream>
class Counter {
static int count; // 선언
public:
Counter() { ++count; }
static int getCount() { return count; }
};
int Counter::count = 0; // 정의 (한 번역 단위에서)
// C++17부터는 inline으로 클래스 안에서 정의 가능
class Counter17 {
inline static int count = 0;
};
int main() {
Counter c1, c2, c3;
std::cout << Counter::getCount() << '\n'; // 3
}
객체 수 추적, 클래스 전체의 공유 설정 등에 씁니다. 여러 스레드에서 동시에 갱신한다면 std::atomic이나 락이 필요합니다. 전역 초기화 순서 문제(static initialization order fiasco)를 피하려면 함수 안의 지역 static 변수로 바꾸는 방법이 흔하며, C++11부터 지역 static 변수의 초기화는 스레드 안전이 보장됩니다.
Q16. const 멤버 함수는 무엇인가요?
객체의 관찰 가능한 상태를 바꾸지 않겠다고 선언한 멤버 함수입니다. 함수 안에서 this가 const T*가 되므로 멤버를 수정할 수 없고, const 객체나 const 참조를 통해서는 const 멤버 함수만 호출할 수 있습니다.
#include <iostream>
class Point {
int x, y;
public:
Point(int x, int y) : x(x), y(y) {}
int getX() const { return x; }
void setX(int val) { x = val; }
};
int main() {
const Point p(10, 20);
std::cout << p.getX() << '\n'; // OK
// p.setX(30); // 컴파일 에러: const 객체에서 비const 함수 호출
}
캐시나 뮤텍스처럼 논리적 상태와 무관한 멤버는 mutable로 선언하면 const 함수 안에서도 바꿀 수 있습니다. 표준 라이브러리는 const 멤버 함수를 여러 스레드에서 동시에 호출해도 안전하다고 가정하므로, mutable 멤버를 쓴다면 스레드 안전도 직접 책임져야 합니다.
Q17. friend 키워드는 언제 쓰나요?
특정 외부 함수나 클래스가 private 멤버에 접근할 수 있게 허용합니다.
#include <ostream>
class Box {
int width;
public:
explicit Box(int w) : width(w) {}
friend std::ostream& operator<<(std::ostream& os, const Box& b) {
return os << "Box(" << b.width << ")";
}
};
가장 흔한 용도는 operator<<처럼 왼쪽 피연산자가 다른 타입이어서 멤버 함수로 만들 수 없는 연산자입니다. 위처럼 클래스 안에서 정의한 friend 함수는 ADL로만 찾을 수 있는 “숨은 friend”가 되어, 관련 없는 타입의 오버로드 해석에 끼어들지 않는다는 장점도 있습니다. 캡슐화를 우회하는 기능이므로 테스트 편의를 위해 남발하는 것은 피합니다.
STL과 템플릿
Q18. vector와 list의 차이는?
| 특징 | vector | list |
|---|---|---|
| 구조 | 연속 메모리의 동적 배열 | 이중 연결 리스트 |
| 임의 접근 | O(1) | O(N) |
| 끝에 추가 | 분할 상환 O(1) | O(1) |
| 중간 삽입·삭제 | O(N) (뒤 원소 이동) | 위치 이터레이터가 있으면 O(1) |
| 메모리 | 연속, 원소당 오버헤드 없음 | 노드마다 할당, 포인터 2개 오버헤드 |
| 캐시 효율 | 높음 | 낮음 |
대부분의 경우 vector가 기본 선택입니다. list의 중간 삽입이 O(1)이라고 해도 그 위치를 찾는 데 O(N) 순회가 필요하고, 노드가 흩어져 있어 순회할 때마다 캐시 미스가 납니다. 그래서 원소 수가 수천 개 수준이면 중간 삽입이 잦아도 vector가 더 빠른 경우가 흔합니다. list가 확실히 유리한 경우는 원소의 주소나 이터레이터가 삽입·삭제 후에도 유효해야 할 때, splice로 다른 리스트와 노드를 옮겨야 할 때, 원소를 이동할 수 없는 타입일 때입니다.
Q19. map과 unordered_map의 차이는?
| 특징 | map | unordered_map |
|---|---|---|
| 구조 | 균형 이진 탐색 트리(보통 레드-블랙 트리) | 해시 테이블(체이닝) |
| 순서 | 키 순서로 정렬 | 순서 없음 |
| 탐색·삽입 | O(log N) 보장 | 평균 O(1), 최악 O(N) |
| 키 요구 사항 | operator< (또는 비교자) | 해시 함수와 operator== |
| 범위 조회 | lower_bound, upper_bound 가능 | 불가 |
키 순서가 필요하거나 범위 조회를 해야 하거나 최악 성능을 보장해야 하면 map을, 그렇지 않고 조회가 많으면 unordered_map을 먼저 고려합니다. 메모리는 둘 다 노드 기반이라 어느 쪽이 항상 적다고 말하기 어렵습니다. map은 노드마다 포인터 세 개와 색 정보를, unordered_map은 버킷 배열과 노드마다 다음 포인터(구현에 따라 캐시된 해시값)를 추가로 씁니다. 외부 입력을 키로 받는 서버에서는 해시 충돌을 유도하는 공격으로 unordered_map이 최악 성능에 빠질 수 있다는 점도 언급할 만합니다.
Q20. 템플릿 특수화란?
특정 타입 인자에 대해 일반 템플릿과 다른 구현을 제공하는 기법입니다.
#include <iostream>
#include <string>
template <typename T>
T add(T a, T b) {
return a + b;
}
template <>
std::string add<std::string>(std::string a, std::string b) {
return a + " " + b; // 문자열은 공백을 넣어 이어 붙임
}
int main() {
std::cout << add(3, 5) << '\n'; // 8
std::cout << add<std::string>("Hello", "World") << '\n'; // Hello World
}
클래스 템플릿은 전체 특수화와 부분 특수화(예: 모든 포인터 타입 T*에 대한 특수화)가 모두 가능하지만, 함수 템플릿은 부분 특수화가 안 되고 전체 특수화만 됩니다. 함수 템플릿 특수화는 오버로드 해석에 참여하지 않아서 직관과 다르게 동작하는 경우가 있으므로, 함수는 특수화보다 일반 오버로드(std::string add(const std::string&, const std::string&))를 추가하는 방식이 더 권장됩니다.
Q21. 이터레이터가 무효화되는 경우는?
vector 기준으로 정리하면 다음과 같습니다. push_back이나 insert로 용량(capacity)을 넘어 재할당이 일어나면 모든 이터레이터, 포인터, 참조가 무효화됩니다. 재할당이 없는 insert는 삽입 위치와 그 뒤의 이터레이터를 무효화합니다. erase는 재할당을 일으키지 않지만 삭제 위치와 그 뒤의 이터레이터를 무효화합니다. 반면 list와 map은 노드 기반이라 삭제된 원소의 이터레이터만 무효화됩니다.
#include <vector>
std::vector<int> v = {1, 2, 3, 4, 5};
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it == 3) {
v.erase(it); // it가 무효화된 뒤 ++it를 하므로 UB
}
}
erase는 삭제된 원소 다음 위치의 유효한 이터레이터를 반환하므로 그 값을 받아 진행합니다.
for (auto it = v.begin(); it != v.end(); ) {
if (*it == 3) {
it = v.erase(it);
} else {
++it;
}
}
조건에 맞는 원소를 모두 지우는 목적이라면, 원소를 한 칸씩 여러 번 당기는 위 방식은 O(N²)이 될 수 있습니다. erase-remove 관용구(v.erase(std::remove_if(v.begin(), v.end(), pred), v.end()))나 C++20의 std::erase_if(v, pred)는 한 번의 순회로 O(N)에 처리합니다.
Q22. emplace_back과 push_back의 차이는?
push_back은 이미 만들어진 객체를 받아 컨테이너 안으로 복사하거나 이동하고, emplace_back은 생성자 인자를 받아 컨테이너 안의 저장 공간에서 객체를 직접 생성합니다.
#include <string>
#include <utility>
#include <vector>
std::vector<std::pair<int, std::string>> v;
v.push_back({1, "apple"}); // 임시 pair 생성 후 이동
v.emplace_back(2, "banana"); // 벡터 안에서 바로 생성
차이는 임시 객체 하나를 만들고 이동하는 비용이므로, 이동이 싼 타입에서는 거의 차이가 없고 이동이 비싸거나 불가능한 타입에서 의미가 있습니다. 주의할 점도 있습니다. emplace_back은 생성자를 직접 호출하므로 explicit 생성자도 호출합니다. 예를 들어 std::vector<std::unique_ptr<T>> v; v.emplace_back(raw_ptr);는 컴파일되지만 push_back(raw_ptr)은 컴파일되지 않습니다. 또 std::vector<std::vector<int>> v; v.emplace_back(10);은 원소 10개짜리 벡터를 추가하는데, 의도가 한눈에 보이지 않을 수 있습니다. 그래서 이미 객체가 있으면 push_back, 생성자 인자로 만들 때는 emplace_back으로 나눠 쓰는 것이 무난합니다.
Q23. 템플릿 메타프로그래밍이란?
템플릿 인스턴스화 과정을 이용해 컴파일 타임에 계산하거나 타입에 따라 코드를 선택하는 기법입니다. 결과가 컴파일 타임에 결정되므로 런타임 비용이 없습니다.
#include <iostream>
template <int N>
struct Factorial {
static constexpr int value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static constexpr int value = 1;
};
int main() {
static_assert(Factorial<5>::value == 120);
std::cout << Factorial<5>::value << '\n'; // 120
}
C++11 이후에는 같은 계산을 constexpr 함수로 더 읽기 쉽게 씁니다. constexpr 변수 초기화나 static_assert처럼 상수 표현식이 필요한 문맥에서 호출하면 컴파일 타임 평가가 보장됩니다.
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int result = factorial(5); // 컴파일 타임에 120
심화: 템플릿 인스턴스화와 컴파일 모델
템플릿은 미리 컴파일된 함수가 아니라 코드를 만드는 규칙입니다. 번역 단위에서 foo<int>() 같은 사용이 나타나면 컴파일러가 그 인자 조합에 대한 코드를 인스턴스화합니다. 같은 std::vector<int>::push_back을 여러 .cpp에서 쓰면 각 오브젝트 파일에 같은 인스턴스가 생기고, 링커가 COMDAT(약한 심볼) 메커니즘으로 하나만 남깁니다. 결과 바이너리에는 하나만 남지만 컴파일 시간은 번역 단위마다 반복해서 들어갑니다.
이 중복을 줄이는 도구가 명시적 인스턴스화와 extern template입니다. 헤더에 extern template class MyContainer<int>;를 선언하면 이를 포함한 번역 단위는 그 인스턴스를 만들지 않고, 한 .cpp에만 template class MyContainer<int>;를 두어 거기서만 인스턴스화합니다.
템플릿 정의 안의 이름은 두 단계로 해석됩니다(two-phase lookup). 템플릿 인자에 의존하지 않는 이름은 정의 시점에, 의존하는 이름(dependent name)은 인스턴스화 시점에 해석됩니다. 그래서 T::value_type이 타입이라는 것을 컴파일러에 알리려면 typename T::value_type으로 써야 하고, 의존 이름의 멤버 템플릿을 호출할 때는 obj.template get<0>()처럼 template 키워드가 필요합니다.
템플릿 정의를 헤더에 두는 이유는 인스턴스화하는 번역 단위가 정의를 볼 수 있어야 하기 때문입니다. 여러 번역 단위에 같은 정의가 들어가도 ODR(One Definition Rule)은 템플릿과 inline 함수에 대해 “모든 정의가 토큰 단위로 같다면” 허용합니다. 만약 매크로나 전처리 조건 때문에 번역 단위마다 정의가 달라지면 링커는 그중 하나를 골라 버리고, 진단 없이 잘못 동작하는 ODR 위반이 됩니다.
멀티스레딩과 동시성
Q24. race condition이란? 어떻게 방지하나요?
여러 스레드가 공유 데이터에 접근할 때 결과가 실행 순서에 따라 달라지는 상황입니다. C++ 메모리 모델에서는 최소 한 스레드가 쓰기를 하는 비원자적 변수에 동기화 없이 동시에 접근하는 것을 데이터 레이스라고 부르며, 이는 정의되지 않은 동작입니다.
counter++는 원자적 연산이 아니라 읽기, 증가, 쓰기의 세 단계입니다. 두 스레드가 동시에 실행하면 다음과 같은 일이 생길 수 있습니다.
- 스레드 1이
counter값 100을 읽고 101을 계산합니다(아직 쓰지 않음). - 스레드 2가 같은 100을 읽고 101을 계산해 씁니다.
- 스레드 1이 101을 씁니다.
두 번 증가했으니 102여야 하지만 결과는 101입니다.
#include <iostream>
#include <thread>
int counter = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
++counter; // 데이터 레이스
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter << '\n'; // 200000이 아닐 수 있음
}
공유 데이터를 뮤텍스로 보호하면 한 번에 한 스레드만 임계 구역에 들어갑니다.
#include <mutex>
std::mutex mtx;
int counter = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++counter;
} // 반복마다 자동 unlock
}
이 예에서는 반복마다 락을 잡고 놓으므로 경합이 심합니다. 실제로는 지역 변수에 누적한 뒤 마지막에 한 번만 락을 잡거나, 단순 카운터라면 std::atomic을 쓰는 편이 낫습니다. 데이터 레이스는 ThreadSanitizer(-fsanitize=thread)로 찾을 수 있습니다.
Q25. 데드락은 언제 발생하나요?
두 개 이상의 스레드가 서로 상대가 가진 락을 기다리며 영원히 멈추는 상황입니다. 상호 배제, 점유 대기, 비선점, 순환 대기의 네 조건이 모두 성립할 때 발생하며, 실무에서 가장 흔한 원인은 락을 잡는 순서가 스레드마다 다른 순환 대기입니다.
#include <chrono>
#include <mutex>
#include <thread>
std::mutex mtx1, mtx2;
void thread1() {
std::lock_guard<std::mutex> lock1(mtx1);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock2(mtx2); // thread2가 mtx2를 잡고 있으면 대기
}
void thread2() {
std::lock_guard<std::mutex> lock2(mtx2);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock1(mtx1); // thread1이 mtx1을 잡고 있으면 대기
}
해결책은 모든 코드에서 락을 같은 순서로 잡도록 규칙을 정하거나, 여러 락을 한 번에 데드락 없이 잡는 std::scoped_lock(C++17)을 쓰는 것입니다. C++11에서는 std::lock과 std::adopt_lock을 조합합니다.
void thread1_fixed() {
std::scoped_lock lock(mtx1, mtx2); // 두 락을 데드락 회피 알고리즘으로 획득
}
void thread1_cpp11() {
std::lock(mtx1, mtx2);
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
}
락을 잡은 상태에서 콜백이나 가상 함수처럼 어떤 락을 잡을지 모르는 외부 코드를 호출하는 것도 데드락의 흔한 원인이므로, 락 구간은 최대한 짧게 유지하는 것이 좋습니다.
Q26. atomic이란?
std::atomic<T>는 해당 변수에 대한 읽기, 쓰기, 읽기-수정-쓰기 연산이 다른 스레드에게 쪼개져 보이지 않도록 보장하는 타입입니다. int나 포인터 같은 작은 타입은 대부분의 플랫폼에서 CPU의 원자적 명령으로 구현되어 락이 없습니다(lock-free). 큰 구조체 같은 타입은 내부적으로 락을 쓸 수 있으며, is_lock_free()나 is_always_lock_free로 확인할 수 있습니다.
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed); // 원자적 증가
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter << '\n'; // 항상 200000
}
단순 카운터처럼 다른 데이터와의 순서가 중요하지 않으면 memory_order_relaxed로 충분합니다. 반대로 “데이터를 준비한 뒤 플래그를 세운다”처럼 다른 메모리 접근과의 순서가 중요하면 release/acquire 순서가 필요합니다(기본값은 가장 강한 seq_cst). 여러 변수를 함께 일관되게 바꿔야 하는 경우는 원자 변수 여러 개로는 해결되지 않으므로 뮤텍스를 써야 합니다.
Q27. condition_variable은 언제 쓰나요?
어떤 조건이 참이 될 때까지 스레드를 잠재웠다가, 다른 스레드가 조건을 바꾸고 알려 주면 깨우는 동기화 도구입니다. 생산자-소비자 큐가 대표적인 예입니다.
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
void producer() {
for (int i = 0; i < 10; ++i) {
{
std::lock_guard<std::mutex> lock(mtx);
q.push(i);
}
cv.notify_one();
}
}
void consumer() {
for (int i = 0; i < 10; ++i) {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !q.empty(); }); // 조건이 참이 될 때까지 대기
int val = q.front();
q.pop();
lock.unlock();
std::cout << val << '\n';
}
}
wait에 조건자(predicate)를 넘기는 것이 중요합니다. 조건 변수는 알림 없이 깨어나는 가짜 깨어남(spurious wakeup)이 허용되고, 알림이 대기 시작 전에 와서 놓칠 수도 있습니다. 조건자 버전의 wait은 깨어날 때마다 조건을 다시 확인하고 대기 전에도 확인하므로 두 문제를 모두 막아 줍니다. 조건 변수가 보호하는 상태(여기서는 q)는 반드시 같은 뮤텍스 아래에서 바꿔야 합니다.
Q28. thread_local이란?
스레드마다 독립적인 인스턴스를 가지는 변수입니다. 각 스레드가 처음 사용할 때(또는 스레드 시작 시) 초기화되고, 스레드가 끝날 때 파괴됩니다.
#include <iostream>
#include <thread>
thread_local int counter = 0;
void increment() {
for (int i = 0; i < 100; ++i) {
++counter; // 스레드마다 별도의 counter
}
std::cout << counter << '\n'; // 각 스레드가 100 출력 (출력 순서와 줄은 섞일 수 있음)
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
}
스레드별 난수 생성기, 스레드별 버퍼나 통계 누적처럼 공유가 필요 없는 상태에 씁니다. 스레드 풀처럼 스레드가 재사용되는 환경에서는 이전 작업의 값이 남아 있다는 점에 주의해야 합니다.
모던 C++
Q29. 이동 시맨틱이란?
복사 대신 자원의 소유권을 옮겨 성능을 높이는 기법으로, C++11에서 rvalue 참조(T&&)와 함께 도입되었습니다. std::vector<int> 백만 개짜리를 복사하면 새 메모리를 할당하고 모든 원소를 복사해야 하지만, 곧 사라질 임시 객체라면 내부 포인터만 넘겨받고 원본을 비워 두면 됩니다.
값 범주로 보면 이름이 있어 주소를 취할 수 있는 식은 lvalue(x, arr[0]), 리터럴이나 임시 객체를 만드는 식은 prvalue(42, x + y), 곧 자원을 넘겨줄 수 있다고 표시된 식은 xvalue(std::move(x))이며, prvalue와 xvalue를 합쳐 rvalue라고 부릅니다. rvalue 참조 매개변수를 받는 이동 생성자는 rvalue 인자에 대해 선택됩니다.
#include <cstring>
#include <utility>
class MyString {
char* data;
public:
explicit MyString(const char* str) {
data = new char[std::strlen(str) + 1];
std::strcpy(data, str);
}
MyString(const MyString& other) { // 복사: 새로 할당
data = new char[std::strlen(other.data) + 1];
std::strcpy(data, other.data);
}
MyString(MyString&& other) noexcept : data(other.data) { // 이동: 포인터만 가져옴
other.data = nullptr;
}
MyString& operator=(MyString other) noexcept { // 복사·이동 대입을 하나로
std::swap(data, other.data);
return *this;
}
~MyString() { delete[] data; }
};
이동 생성자에 noexcept를 붙이는 것이 중요합니다. std::vector가 재할당할 때 기존 원소를 옮기는데, 이동 생성자가 예외를 던질 수 있으면 중간에 실패했을 때 원래 상태로 되돌릴 수 없으므로 강한 예외 안전 보장을 지키기 위해 복사 생성자를 대신 씁니다(std::move_if_noexcept). noexcept가 빠진 이동 생성자는 벡터 재할당에서 쓰이지 않는다는 점이 면접에서 자주 나오는 꼬리 질문입니다.
심화: 복사 생략, RVO, NRVO
가장 좋은 것은 이동조차 하지 않고 객체를 최종 위치에 바로 만드는 것입니다. C++17부터는 return T(...);처럼 prvalue를 반환하거나 prvalue로 객체를 초기화할 때 복사·이동이 아예 일어나지 않도록 언어가 보장합니다(guaranteed copy elision). 그래서 복사와 이동이 모두 삭제된 타입도 이런 방식으로는 반환할 수 있습니다.
이름 있는 지역 변수를 반환할 때의 최적화는 NRVO(Named Return Value Optimization)라고 하며, 이는 여전히 허용일 뿐 보장이 아닙니다. 반환 경로마다 다른 지역 변수를 반환하면 NRVO가 적용되기 어렵습니다. 다만 NRVO가 안 되더라도 return local;은 자동으로 이동으로 처리되므로, return std::move(local);로 쓸 필요가 없고 오히려 NRVO를 막아 역효과가 납니다.
std::move는 아무것도 옮기지 않는 캐스트(static_cast<T&&>)입니다. 실제 이동은 그 결과를 받는 이동 생성자나 이동 대입 연산자가 합니다. const 객체에 std::move를 적용하면 const T&&가 되어 이동 생성자에 맞지 않으므로 조용히 복사가 일어납니다. 이동된 객체는 “유효하지만 지정되지 않은 상태”가 되어, 파괴하거나 새 값을 대입하는 것은 안전하지만 값에 의존해서는 안 됩니다. std::unique_ptr처럼 이동 후 null이 된다고 명시한 타입도 있지만, std::string이나 std::vector가 비어 있다는 것은 표준이 보장하지 않습니다.
Q30. 람다 표현식의 캡처 방식은?
람다는 대괄호 안의 캡처 목록으로 바깥 변수를 어떻게 가져올지 정합니다.
| 캡처 | 의미 |
|---|---|
[] | 캡처하지 않음 |
[x] | x를 값으로 복사 |
[&x] | x를 참조로 캡처 |
[=] | 본문에서 사용하는 바깥 변수를 값으로 캡처 |
[&] | 본문에서 사용하는 바깥 변수를 참조로 캡처 |
[this] | 현재 객체의 포인터를 캡처 (멤버는 참조처럼 접근) |
[*this] | 현재 객체를 복사해서 캡처 (C++17) |
[p = std::move(p)] | 초기화 캡처로 새 변수를 만들어 캡처 (C++14) |
#include <memory>
int x = 10, y = 20;
auto lambda1 = [x]() { return x + 1; }; // x 복사
auto lambda2 = [&x]() { ++x; }; // x 참조
auto lambda3 = [=]() { return x + y; }; // 사용한 x, y를 복사
auto lambda4 = [&]() { ++x; ++y; }; // 사용한 x, y를 참조
auto lambda5 = [x]() mutable { return ++x; }; // 복사본을 수정하려면 mutable
auto p = std::make_unique<int>(42);
auto lambda6 = [p = std::move(p)]() { return *p; }; // 이동 전용 타입 캡처
[=]와 [&]는 모든 바깥 변수가 아니라 본문에서 실제로 사용한 변수만 캡처합니다. 실무에서 가장 흔한 버그는 참조 캡처한 람다를 비동기 작업이나 콜백으로 넘겨 원래 변수보다 오래 살게 만드는 것입니다. 또 멤버 함수 안에서 [=]로 캡처하면 멤버 변수는 복사되지 않고 this 포인터가 캡처되므로, 객체가 먼저 파괴되면 같은 문제가 생깁니다. 이 혼동 때문에 C++20은 [=]에 의한 this 암묵 캡처를 deprecated로 지정했습니다.
경력직 면접에서 답변의 깊이를 더하는 법
경력직이나 시니어 포지션에서는 문법 지식보다 트레이드오프를 설명하는 능력을 봅니다. 앞의 질문들을 몇 가지 관점과 연결하면 같은 답도 훨씬 설득력이 생깁니다.
성능 질문에는 단정하지 않고 측정을 먼저 이야기합니다. “가상 함수가 느리다”, “shared_ptr이 무겁다”는 일반론보다, 프로파일러로 CPU 시간이 어디에 쓰이는지(캐시 미스, 할당, 락 경합, 인라인화 실패)를 확인하고 결정한다는 흐름이 좋습니다. 마이크로벤치마크가 최적화로 코드가 사라지거나 실제와 다른 입력 분포를 쓰는 함정도 언급할 수 있습니다.
RAII와 스마트 포인터 질문은 예외 안전성 수준(기본 보장, 강한 보장, 예외 없음 보장)과 연결하면 좋습니다. 앞에서 본 것처럼 벡터 재할당이 이동과 복사 중 무엇을 고를지가 noexcept에 달려 있다는 예가 대표적입니다.
동시성 질문에서는 뮤텍스와 atomic의 사용법을 넘어 불변 조건을 이야기합니다. “어떤 필드들이 항상 함께 일관되어야 하는가”를 먼저 정하고, 락의 범위를 그 불변 조건에 맞춘다는 설명은 코드 리뷰나 장애 분석에서 실제로 쓰는 언어입니다.
라이브러리 경계 질문에서는 ABI 호환성이 자주 나옵니다. 공개 헤더의 클래스에 멤버를 추가하면 객체 크기가 바뀌어 이미 배포된 바이너리와 호환되지 않으므로, 구현 세부를 숨기기 위해 Pimpl 패턴을 쓴다는 설명이 좋은 예입니다. std::string 같은 표준 타입을 DLL 경계에 노출하면 양쪽이 같은 컴파일러와 표준 라이브러리 버전이어야 한다는 제약도 여기에 속합니다.
경험 질문에는 상황, 과제, 행동, 결과 순서로 답합니다. 예를 들어 메모리 사용량이 계속 늘었던 문제라면 어떤 지표(RSS, 할당률)로 발견했고, 어떤 도구(힙 프로파일러, ASan, 코어 덤프)로 원인을 좁혔고, 코드와 설계를 어떻게 바꿨으며, 결과가 어떻게 확인되었는지를 한 문장씩 말합니다.
그리고 어떤 답변이든 “결론, 이유, 이 결론이 틀릴 수 있는 조건” 세 부분을 넣으면 깊이가 드러납니다. 예를 들어 “make_shared를 기본으로 씁니다. 할당이 한 번이고 지역성이 좋기 때문입니다. 다만 큰 객체를 weak_ptr이 오래 관찰하는 구조라면 메모리가 늦게 회수되므로 따로 할당합니다”처럼 말하는 식입니다.
면접 답변 팁
구조 없이 떠오르는 대로 말하는 답변(“포인터는… 주소를 가리키고… 참조는… 별칭이고…”)보다, 개수를 먼저 말하고 하나씩 설명하는 답변이 훨씬 전달력이 좋습니다. “포인터와 참조의 주요 차이는 세 가지입니다. 첫째, 포인터는 다른 대상으로 바꿀 수 있지만 참조는 초기화 후 고정됩니다. 둘째, 포인터는 null일 수 있지만 참조는 항상 유효한 객체에 바인딩됩니다. 셋째, 포인터는 *와 ->로 접근하고 참조는 일반 변수처럼 씁니다.”
개념을 설명한 뒤 두세 줄짜리 코드 예시를 덧붙이면 이해도가 드러나고, 실무에서 어떻게 쓰는지를 한 문장 붙이면 경험이 드러납니다. 예를 들어 “소유권은 기본적으로 unique_ptr로 표현하고, 공유가 정말 필요한 경우에만 shared_ptr을 씁니다” 같은 식입니다.
모르는 질문이 나오면 모른다고 인정하되, 아는 사실에서 출발해 추론하는 과정을 보여 주는 것이 좋습니다. “정확히는 모르지만, 가상 함수가 vtable로 동작한다는 점을 생각하면 이렇게 될 것 같습니다”처럼 말하면 면접관이 힌트를 주며 대화를 이어가는 경우가 많습니다.
다음으로 신입 개발자 면접 준비나 코딩테스트 준비를 함께 보면 좋습니다.
자주 묻는 질문 (FAQ)
Q. 면접에서 코드를 완벽하게 외워야 하나요?
아닙니다. 개념과 원리를 이해하고 대략적인 코드 구조를 설명할 수 있으면 됩니다. 면접관이 정확한 문법 대신 의사 코드로 설명해 보라고 하는 경우도 많습니다. 다만 RAII 클래스나 스레드 안전한 카운터처럼 짧은 코드는 직접 써 볼 수 있도록 연습해 두는 것이 좋습니다.
Q. 신입인데 멀티스레딩 질문이 나오면?
race condition, mutex, deadlock 같은 기본 개념을 정확히 설명할 수 있으면 됩니다. 실무 경험이 없다면 학교나 개인 프로젝트에서 thread와 mutex를 써 본 경험을 솔직하게 말하는 편이 낫습니다.
Q. 모던 C++ (C++11 이후)를 모르면 불리한가요?
요즘은 거의 필수입니다. 최소한 auto, 람다, 스마트 포인터, 이동 시맨틱은 설명할 수 있어야 합니다.
Q. “이 코드의 문제점은?”이라는 질문이 나오면?
자원 관리(new와 delete 짝, 복사 시 이중 해제), 예외 안전성(예외가 나면 자원이 해제되는지), 효율(불필요한 복사, 비효율적인 알고리즘), 경계 조건(빈 입력, nullptr, 오버플로), 동시성(공유 데이터 보호) 순서로 훑어보면 대부분의 문제를 짚을 수 있습니다.