C++ 기술면접 질문 10가지|임베디드 개발자가 꼭 준비할 핵심 개념
임베디드 C++ 기술면접의 핵심은 문법을 외웠는지가 아니라, 각 기능이 메모리·실행 시간·객체 수명·동시성에 어떤 영향을 주는지 설명할 수 있는지야. virtual, 객체 복사, volatile, 상수 표현식, RAII, 스마트 포인터, 메모리 정렬, undefined behavior, 인터럽트 제약을 이 기준으로 정리하면 질문이 달라져도 답을 연결할 수 있어.
아래는 임베디드 개발자 면접에서 자주 이어지는 질문 10가지야. 각 항목은 바로 말할 수 있는 한 줄 답부터 보고, 그다음 이유와 대표적인 함정을 확인하면 돼.
임베디드에서 virtual과 가상함수는 왜 메모리·성능 부담이 될까?
한 줄 답: 가상함수는 일반적인 구현에서 객체의 vptr, 클래스별 vtable, 간접 호출을 사용하므로 작은 메모리와 예측 가능한 실행 시간이 중요한 펌웨어에서는 비용을 확인해야 해.
vtable과 vptr는 C++ 표준이 이름과 구체적인 배치를 정해 둔 기능은 아니야. 다만 많은 컴파일러와 ABI에서 다형성이 있는 객체에 가상 함수 테이블을 두고, 객체가 그 테이블을 가리키도록 구현해. 그래서 객체마다 포인터 크기의 추가 데이터가 생길 수 있고, 가상 함수가 있는 클래스의 코드와 테이블도 메모리를 차지할 수 있어.
호출 시점에 실제 함수가 간접적으로 선택되기 때문에 컴파일러가 호출 대상을 미리 확정해 인라인화하기 어려운 경우도 있어. 호출 비용이 항상 크다고 단정할 수는 없지만, 짧고 반복적인 제어 루프나 실행 시간이 엄격하게 관리되는 경로라면 직접 호출과 비교해 확인해야 해. 임베디드에서는 SRAM·플래시 사용량과 실행 시간의 예측 가능성이 함께 중요하기 때문이야.
struct UartDriver {
virtual ~UartDriver() = default;
virtual void write(std::uint8_t value) = 0;
};
struct HardwareUart : UartDriver {
void write(std::uint8_t value) override {
// 하드웨어 레지스터에 전송
}
};
void send_byte(UartDriver& driver) {
driver.write(0x55); // 실행 시점에 실제 구현 선택
}
그렇다고 virtual을 무조건 금지해야 하는 건 아니야. 드라이버 교체나 테스트용 가짜 객체처럼 런타임 다형성이 주는 구조적 이점이 크다면 사용할 수 있어. 다만 면접에서는 “가상함수는 느리다”에서 멈추지 말고, 대상 ABI의 객체 크기와 호출 경로를 확인하고, 필요한 곳에는 템플릿·정적 다형성·함수 테이블 같은 대안을 비교한다고 답하는 편이 정확해.
생성자나 소멸자에서 virtual 함수를 호출하면 왜 기대한 동적 디스패치가 되지 않을까?
한 줄 답: 생성·소멸 중인 객체는 아직 완전한 파생 클래스 상태가 아니므로 virtual 호출이 현재 생성·소멸 중인 클래스의 구현으로 제한되고, 순수 가상함수 호출은 특히 위험해.
기반 클래스 생성자는 파생 클래스의 멤버가 초기화되기 전에 실행돼. 이때 파생 클래스의 함수를 호출하도록 동적 디스패치를 허용하면 아직 준비되지 않은 상태를 읽게 될 수 있어서, C++는 현재 생성 중인 클래스의 범위까지만 가상 호출을 수행해.
struct Base {
Base() {
init();
}
virtual void init() {
// Base::init()이 호출됨
}
};
struct Derived : Base {
Derived() : ready_(true) {}
void init() override {
// Base 생성자에서는 이 함수가 호출되지 않음
if (ready_) {
// 파생 멤버는 아직 초기화 전일 수 있음
}
}
private:
bool ready_{};
};
소멸자에서도 비슷해. 파생 클래스 소멸자가 끝난 뒤 기반 클래스 소멸자가 실행되므로, 기반 소멸자에서 파생 클래스 구현을 호출할 수 없어. 순수 가상함수를 생성자나 소멸자에서 호출하면 undefined behavior가 될 수 있고, 런타임이 오류를 표시하더라도 그 동작에 기대면 안 돼.
초기화가 필요하다면 생성자에서는 현재 클래스의 상태만 준비하고, 객체가 완전히 만들어진 뒤 명시적인 start()나 initialize()를 호출하는 방식이 안전해. 생성자와 소멸자에서 가상 호출을 발견하면 “동적 디스패치가 제한되는 이유”와 “파생 상태를 사용하지 않는 초기화 방법”까지 함께 설명하면 돼.
memcpy로 C++ 객체를 복사해도 될까? 복사 생성자와 trivially copyable의 차이는?
한 줄 답: memcpy는 trivially copyable 타입의 객체 표현을 옮길 때에만 적절하고, 자원 소유나 사용자 정의 복사 규칙이 있는 객체는 복사 생성자나 대입 연산자를 사용해야 해.
memcpy는 객체의 의미를 이해하지 않고 바이트를 그대로 옮겨. 따라서 타입이 복사될 때 별도의 동작이 필요 없는 trivially copyable 타입이라면 같은 타입의 객체 표현을 복사하는 용도로 사용할 수 있어.
struct FrameHeader {
std::uint16_t length;
std::uint8_t type;
};
static_assert(std::is_trivially_copyable_v<FrameHeader>);
FrameHeader source{12, 1};
FrameHeader target{};
std::memcpy(&target, &source, sizeof target);
반대로 포인터가 가리키는 메모리의 소유권, 파일·락·하드웨어 핸들 같은 자원을 객체가 관리한다면 바이트 복사는 위험해. 포인터 값만 복제되어 두 객체가 같은 자원을 해제하거나, 복사 과정에서 필요한 참조 수 증가·깊은 복사·소유권 이전이 실행되지 않을 수 있어.
struct OwnedBuffer {
std::uint8_t* data;
std::size_t size;
~OwnedBuffer() {
release(data);
}
};
// memcpy로 복사하면 data 포인터 값만 복제될 수 있음
면접에서 POD라는 단어를 들을 수도 있지만, 바이트 복사 가능 여부를 설명할 때는 std::is_trivially_copyable이 더 직접적인 기준이야. trivially copyable이어도 패딩 바이트가 있을 수 있고, 통신 패킷의 바이트 순서·엔디언·정렬까지 보장하는 건 아니야. 그러므로 memcpy가 곧 직렬화라는 뜻도 아니고, 객체의 복사 생성자가 필요한 타입에 대한 만능 복사 방법도 아니야.
volatile은 왜 스레드 안전을 보장하지 않으며 레지스터 접근에는 언제 써야 할까?
한 줄 답: volatile은 컴파일러가 해당 메모리 접근을 임의로 없애거나 합치지 않도록 알릴 뿐, 원자성·상호 배제·메모리 순서를 보장하지 않아.
메모리 매핑 하드웨어 레지스터는 프로그램이 읽거나 쓰는 것 외에도 하드웨어가 값을 바꿀 수 있고, 읽기 자체가 상태를 지우는 등 접근에 부작용이 있을 수 있어. 이런 객체에는 volatile 접근이 필요해.
struct UartRegisters {
volatile std::uint32_t status;
volatile std::uint32_t data;
};
constexpr std::uintptr_t UART_BASE = 0x40000000u; // 주소는 예시
auto* const UART =
reinterpret_cast<volatile UartRegisters*>(UART_BASE);
std::uint32_t status = UART->status;
하지만 인터럽트와 메인 코드가 공유하는 변수를 volatile로 선언하는 것만으로는 충분하지 않아. 예를 들어 ready를 확인한 뒤 값을 갱신하는 여러 단계가 원자적으로 묶이지 않고, 여러 바이트로 된 데이터의 일관성도 보장되지 않아. C++ 스레드 사이에서는 volatile이 데이터 경쟁을 해결하지 않으며, ISR과의 공유 상태도 플랫폼이 제공하는 원자 연산·인터럽트 마스킹·락 프리 큐 같은 방법을 목적에 맞게 선택해야 해.
volatile bool ready = false;
// ready가 보인다는 것과 다음 갱신이 원자적이라는 것은 다름
if (!ready) {
ready = true;
}
따라서 답변은 “volatile은 레지스터나 외부에서 변할 수 있는 메모리 접근을 표현하는 도구이고, 동기화에는 원자 타입이나 적절한 동기화 수단을 쓴다”로 정리하면 돼. volatile을 만능 동기화 키워드처럼 설명하면 감점되기 쉬워.
const, constexpr, consteval은 무엇이 다르고 임베디드의 ROM 상수에는 어떻게 적용할까?
한 줄 답: const는 변경 금지, constexpr는 컴파일 타임 평가가 가능한 표현식, consteval은 호출할 때마다 반드시 컴파일 타임 평가해야 하는 함수를 뜻해.
const 객체는 이름을 통해 값을 변경할 수 없지만, 초기값이 실행 중에 계산될 수도 있어. constexpr 변수는 상수 표현식으로 초기화되어야 하고, constexpr 함수도 인수가 충분하면 컴파일 타임에 평가할 수 있지만 일반 실행 경로에서 호출될 가능성까지 사라지는 건 아니야.
std::uint32_t read_timeout();
const std::uint32_t timeout = read_timeout(); // 실행 중 초기화 가능
constexpr std::uint32_t frame_size = 32; // 컴파일 타임 상수
consteval은 C++20의 즉시 함수야. 호출 결과를 런타임에 계산하는 것을 허용하지 않으므로, 설정값이나 테이블 생성 규칙을 빌드 시점에 반드시 검증하고 싶을 때 사용할 수 있어.
consteval std::uint32_t adc_mask() {
return (std::uint32_t{1} << 10) - 1u;
}
constexpr std::uint32_t ADC_MASK = adc_mask();
임베디드에서 constexpr를 사용하면 테이블과 설정값을 컴파일 타임에 만들 수 있어 코드 실행 중 계산을 줄이는 데 도움이 돼. 다만 const나 constexpr라고 선언했다고 항상 ROM이나 플래시에 배치된다고 단정하면 안 돼. 실제 배치는 변수의 저장 기간, 사용 방식, 컴파일러·링커 설정, 섹션 배치에 따라 달라지므로 맵 파일과 링커 스크립트로 확인해야 해.
또한 consteval은 사용하는 언어 표준과 컴파일러가 C++20을 지원해야 해. 면접에서는 세 키워드의 정의뿐 아니라 “컴파일 타임 계산 가능 여부”와 “실제 메모리 영역 배치는 별도 문제”라는 점을 구분해 말하면 좋아.
예외를 끈 펌웨어에서도 RAII는 왜 유효하며 수동 해제보다 무엇이 나을까?
한 줄 답: RAII는 자원의 수명을 객체의 수명에 묶는 방식이라 예외를 사용하지 않아도 return과 일반적인 스코프 종료에서 정리를 자동화하고, 해제 누락 경로를 줄여 줘.
RAII는 예외 처리 전용 기법이 아니야. 객체가 블록 안에서 만들어지고 블록을 빠져나갈 때 소멸자가 실행된다는 C++의 수명 규칙을 이용해 락, 인터럽트 상태, DMA 채널, 핀 설정 같은 자원의 획득과 해제를 한 곳에 묶는 방식이야.
class InterruptGuard {
public:
InterruptGuard() noexcept
: previous_state_(disable_interrupts()) {}
~InterruptGuard() noexcept {
restore_interrupts(previous_state_);
}
InterruptGuard(const InterruptGuard&) = delete;
InterruptGuard& operator=(const InterruptGuard&) = delete;
private:
bool previous_state_;
};
void update_registers() {
InterruptGuard guard;
if (!configuration_is_valid()) {
return; // guard가 자동으로 상태 복원
}
write_registers();
}
수동 해제 방식에서는 중간 return이나 새로운 오류 경로를 추가할 때 restore_interrupts()를 빼먹기 쉬워. RAII는 이런 제어 흐름을 한 객체로 감싸서 정상 종료 경로를 일관되게 만들 수 있어.
예외를 끈 펌웨어라면 특히 소멸자가 예외를 던지지 않도록 noexcept로 설계하고, 소멸자 안에서 동적 할당·블로킹·긴 처리·실패를 보고하는 복잡한 동작을 피해야 해. RAII가 정전이나 리셋까지 자원을 정리해 주는 건 아니며, 소멸자 비용과 실행 시점을 예측할 수 있는 자원에 적용해야 한다는 점도 함께 기억하면 돼.
힙이 제한된 펌웨어에서 unique_ptr과 shared_ptr은 언제 써야 할까?
한 줄 답: unique_ptr는 단일 소유권을 비교적 작은 형태로 표현할 때 선택할 수 있지만, 두 스마트 포인터 모두 사용 방식에 따라 동적 할당이 필요하고 shared_ptr는 제어 블록과 참조 카운트 비용까지 고려해야 해.
unique_ptr의 핵심은 소유자가 하나라는 점이야. 복사할 수 없고 이동만 가능하며, 소유자가 범위를 벗어나면 관리 객체를 정리해. 하지만 unique_ptr 자체가 힙을 없애 주는 건 아니야.
std::unique_ptr<Device> device = std::make_unique<Device>();
// make_unique 시점에 동적 할당이 일어날 수 있음
힙이 제한된 환경에서는 정적 저장 영역, 스택, 고정 크기 객체 풀, 영역 할당기 같은 수명 관리 방식을 먼저 검토하는 게 좋아. 동적 생성이 꼭 필요하다면 unique_ptr에 풀 전용 deleter를 연결해 할당·해제 정책을 고정하는 방법도 있어. 이렇게 하면 소유권은 타입으로 드러내면서도 전역 힙 사용을 피할 수 있어.
shared_ptr는 여러 객체가 하나의 수명을 공유해야 할 때 사용하는 도구야. 보통 관리 객체와 별도의 제어 블록에 참조 카운트가 필요하고, 생성 방식에 따라 동적 할당이 추가될 수 있어. 참조 카운트가 0이 되는 시점에 파괴되므로 수명 시점이 명확해야 하는 드라이버나 실시간 경로에는 부담이 될 수 있고, 순환 참조가 생기면 weak_ptr 같은 설계도 필요해.
따라서 면접에서는 “스마트 포인터를 쓰면 무조건 안전하다”가 아니라, 먼저 소유권과 수명을 정하고, 그다음 할당 장소·할당 실패·해제 시점·실행 시간 예측 가능성을 확인한다고 답하면 돼. 단일 소유권이 필요하면 unique_ptr, 실제 공유 수명이 필요할 때만 shared_ptr를 검토하는 흐름이 기본이야.
구조체 패딩·alignment·packing은 레지스터 맵과 통신 패킷에서 왜 문제가 될까?
한 줄 답: 컴파일러는 정렬을 맞추기 위해 구조체에 패딩을 넣을 수 있으므로, 구조체를 하드웨어 레지스터나 외부 바이트 형식에 바로 겹쳐 놓기 전에는 오프셋·정렬·엔디언·접근 폭을 모두 검증해야 해.
예를 들어 1바이트 멤버 다음에 4바이트 정수 멤버가 오면, 정수 정렬을 맞추기 위한 빈 공간이 들어갈 수 있어. 그래서 아래 구조체를 네트워크 패킷의 5바이트 형식이라고 생각하고 sizeof(Packet)만큼 복사하면 패딩까지 포함한 다른 크기의 데이터가 만들어질 수 있어.
struct Packet {
std::uint8_t type;
std::uint32_t value;
};
// 멤버 사이와 끝에 패딩이 들어갈 수 있음
#pragma pack이나 컴파일러 속성으로 패킹을 강제할 수는 있지만, 이는 표준 C++ 기능이 아니고 대상 컴파일러에 의존해. 정렬되지 않은 멤버 접근이 만들어져 성능 저하나 하드웨어 예외가 발생할 수도 있어. 패킹은 패딩을 줄이는 도구일 뿐, 엔디언·객체 수명·하드웨어 접근 폭 문제까지 자동으로 해결하지 않아.
하드웨어 레지스터 맵은 필요한 예약 영역을 명시하고, 예상 오프셋을 검증하는 방식이 더 분명해.
struct UartRegisters {
volatile std::uint32_t control; // 0x00
volatile std::uint32_t status; // 0x04
};
static_assert(offsetof(UartRegisters, status) == 0x04);
통신 패킷은 객체를 그대로 보내기보다 프로토콜이 정한 바이트 순서로 명시적으로 인코딩하는 편이 안전해.
std::array<std::uint8_t, 5> encode(
std::uint8_t type, std::uint32_t value) {
return {
type,
static_cast<std::uint8_t>(value >> 24),
static_cast<std::uint8_t>(value >> 16),
static_cast<std::uint8_t>(value >> 8),
static_cast<std::uint8_t>(value)
};
}
위 코드는 큰 엔디언 형식을 예로 든 것뿐이고, 실제 프로토콜 규칙에 맞춰야 해. 레지스터 맵에서는 volatile만 붙이면 끝나는 게 아니라 레지스터 폭, 읽기·쓰기 부작용, 예약 주소, 오프셋을 하드웨어 문서와 함께 확인해야 해.
임베디드 C++ 면접에서 자주 나오는 undefined behavior에는 무엇이 있을까?
한 줄 답: 잘못된 타입 별칭 접근, 유효하지 않은 시프트, 끝난 객체 수명에 대한 접근은 undefined behavior가 될 수 있으므로, 우연히 동작한 MCU 결과가 아니라 C++ 언어 규칙에 맞춰 작성해야 해.
undefined behavior는 “이번 빌드에서 우연히 원하는 결과가 나왔다”는 사실로 보장되지 않아. 컴파일러는 그런 상황이 프로그램에 없다고 가정하고 최적화할 수 있기 때문에, 최적화 옵션이나 코드 주변의 작은 변경만으로 결과가 달라질 수 있어.
첫 번째 단골은 strict aliasing 위반이야. float 객체의 주소를 std::uint32_t*로 바꾼 뒤 그 포인터를 역참조하는 방식은 허용된 타입 별칭 규칙을 어길 수 있어.
std::uint32_t bits_of(float value) {
std::uint32_t result{};
static_assert(sizeof result == sizeof value);
std::memcpy(&result, &value, sizeof result);
return result;
}
C++20을 사용할 수 있다면 같은 크기의 trivially copyable 타입 사이에서 std::bit_cast를 검토할 수도 있어. 단순히 reinterpret_cast를 했다는 사실보다, 변환한 포인터를 호환되지 않는 타입으로 역참조했다는 점이 문제야.
두 번째는 시프트 범위야. 시프트 수가 음수이거나 피연산자의 비트 수 이상이면 문제가 되고, signed 정수에서 표현 범위를 넘기는 왼쪽 시프트도 피해야 해.
std::uint32_t set_bit(std::uint32_t value, unsigned bit) {
if (bit >= std::numeric_limits<std::uint32_t>::digits) {
return value;
}
return value | (std::uint32_t{1} << bit);
}
세 번째는 객체 수명이 끝난 뒤의 접근이야. 지역 변수의 주소를 반환하거나, 해제된 메모리를 다시 사용하거나, 수명이 끝난 객체를 이전 포인터로 읽는 코드는 모두 위험해. 이 세 가지는 레지스터를 직접 다루는 코드에서도 예외가 아니므로, 타입·범위·수명을 코드 리뷰의 별도 항목으로 확인하는 게 좋아.
인터럽트 컨텍스트에서 피해야 할 C++ 동작은 무엇일까?
한 줄 답: ISR에서는 실행 시간이 길거나 막힐 수 있는 동작을 피하고, 동적 할당·블로킹 락·예외 전파 대신 최소한의 상태 기록과 작업 전달만 수행해야 해.
인터럽트 핸들러는 일반 스레드와 달리 언제든 다른 코드의 실행을 끊고 들어올 수 있어. 그래서 처리 시간이 길어지면 다른 인터럽트의 응답이 늦어지고, ISR에서 이미 다른 코드가 가진 락을 기다리면 교착 상태가 생길 수 있어.
다음 동작은 기본적으로 ISR에서 피하는 쪽으로 답하면 돼.
new,delete,make_unique,make_shared같은 동적 할당과 해제- 일반 mutex를 포함한 블로킹 락, 대기·수면·조건 변수 호출
- 예외를 던지거나 ISR 밖으로 전파하는 코드
- 긴 루프, 포맷팅이 필요한 로그, 블로킹 입출력
- 첫 호출 때 초기화나 내부 동기화가 발생할 수 있는 복잡한 라이브러리 기능
ISR에서는 고정 크기 버퍼에 데이터를 넣고, 처리할 일이 있다는 사실만 메인 루프나 워커에 전달하는 구조가 흔히 더 예측 가능해.
void Uart_IRQHandler() noexcept {
const std::uint8_t byte = UART->data;
rx_queue.try_push_from_isr(byte); // bounded, non-blocking
notify_worker_from_isr(); // 플랫폼의 ISR 전용 API
}
try_push_from_isr()와 notify_worker_from_isr()는 특정 RTOS의 동작을 일반화한 함수가 아니라, 해당 플랫폼이 ISR에서 호출해도 된다고 보장한 전용 인터페이스라는 뜻이야. noexcept를 붙였다고 함수 내부의 모든 동작이 ISR에 적합해지는 것도 아니야. 공유 데이터에는 플랫폼이 보장하는 원자 연산이나 인터럽트 안전 큐를 사용하고, 원자 타입이 내부적으로 락을 사용할 가능성까지 확인해야 해.
면접에서는 “ISR에서 모든 C++를 금지한다”보다 “ISR의 실행 시간과 재진입성을 예측할 수 있는가를 기준으로 판단하고, 수신·상태 기록 후 나머지는 지연 처리한다”고 설명하는 게 정확해. 플랫폼과 RTOS에 따라 허용 API가 다르다는 단서도 함께 붙이면 좋아.
정리: C++ 기술면접 답변을 연결하는 세 가지 기준
10개 질문은 서로 따로 떨어져 있지 않아. virtual과 스마트 포인터는 메모리와 실행 시간 비용, memcpy·정렬·undefined behavior는 객체 표현과 언어 규칙, volatile과 ISR은 동시성과 실행 맥락으로 연결돼.
면접에서 답할 때는 다음 순서로 말하면 핵심을 놓치기 어려워.
- 언어 규칙: 이 기능이 C++에서 무엇을 보장하고 무엇을 보장하지 않는지 말하기
- 임베디드 영향: 플래시·SRAM·힙·실행 시간·객체 수명·ISR 제약 중 무엇과 관련되는지 연결하기
- 대표 함정과 대안: 흔히 잘못 쓰는 예를 짚고, 정적 메모리·명시적 직렬화·RAII·원자 연산·지연 처리 같은 대안을 제시하기
결국 좋은 C++ 면접 답변은 기능 이름을 나열하는 답이 아니야. “이 기능을 어디에, 어떤 보장과 비용을 확인한 뒤 사용할 것인가”까지 설명하는 답이 임베디드 개발자의 관점에 가까워.
면접 답을 정리할 때 ChatGPT로 질문 리스트를 뽑는 사람도 많아. 구독이 부담되면 할인 경로만 보면 돼.