[C언어] 안전한 시그널 처리: Self-Pipe Trick
C/C++로 리눅스/유닉스 환경에서 서버나 데몬을 개발하다 보면 반드시 마주치는 난제가 있습니다. 바로 ‘시그널(signal)’입니다. Ctrl + C
(SIGINT
)로 프로그램을 종료하거나 kill
명령으로 특정 동작을 지시하는 등, 시그널은 프로세스 외부에서 비동기적으로 발생하는 강력한 이벤트입니다.
문제는 이 강력함에 비해 안전하게 다루기가 매우 까다롭다는 점입니다. 혹시 시그널 핸들러 안에서 printf
나 malloc
을 사용했다가 프로그램이 설명할 수 없는 이유로 멈추거나 죽어버린 경험이 있으신가요? 이는 async-signal-safe
라는 개념을 간과했기 때문입니다.
이번 포스트에서는 시그널 핸들러가 왜 위험한지 알아보고, 이 문제를 해결하는 정석적인 기법인 ‘Self-Pipe Trick’을 활용하여 안정적이고 재사용 가능한 시그널 처리 모듈을 C언어로 구현해 보겠습니다.
무엇이 문제인가? Async-Signal-Safety
시그널 핸들러 함수는 프로그램의 정상적인 흐름을 언제든지 중단시키고 실행됩니다. 만약 main
함수가 malloc
을 호출하여 힙 메모리의 잠금(lock)을 획득한 바로 그 순간 시그널이 발생하면 어떻게 될까요?
main
함수가malloc
내부에서 힙 메모리 잠금을 획득합니다.- 시그널이 발생하여 운영체제는
main
의 실행을 중단시키고 등록된 시그널 핸들러를 호출합니다. - 만약 시그널 핸들러 안에서 로그를 남기기 위해
printf
나malloc
을 호출하면, 이 함수들 역시 내부적으로 힙 메모리 잠금을 다시 획득하려고 시도합니다. - 하지만 잠금은 이미
main
함수가 보유하고 있으므로, 시그널 핸들러는 영원히 대기 상태에 빠집니다. 이를 데드락(deadlock) 이라고 합니다.
이처럼 핸들러가 언제 끼어들지 모르기 때문에, 핸들러 내부에서는 재진입(re-entrant)이 가능하고 실행이 중단되더라도 안전한, 즉 async-signal-safe
하다고 보장된 함수만 사용해야 합니다. write
, read
, close
, _exit
등 극소수의 시스템 콜만이 여기에 해당합니다. printf
, malloc
, free
, fopen
등 우리가 흔히 사용하는 대부분의 표준 라이브러리 함수는 안전하지 않습니다.
해결책: Self-Pipe Trick
그렇다면 복잡한 처리가 필요한 시그널은 어떻게 다뤄야 할까요? 해답은 “시그널 핸들러에서는 최소한의 작업만 하고, 실제 처리는 안전한 메인 프로그램의 컨텍스트로 넘기는 것”입니다. Self-Pipe Trick은 이 아이디어를 구현하는 가장 대표적인 방법입니다.
동작 원리는 다음과 같습니다.
- 파이프 생성: 프로그램 시작 시, 자기 자신에게 데이터를 쓸 수 있는 파이프(pipe)를 하나 생성합니다. 파이프는 읽기용(
fd[0]
)과 쓰기용(fd[1]
) 파일 디스크립터(fd) 쌍으로 이루어집니다. - 최소 핸들러 등록: 실제 시그널 핸들러는 오직 시그널 번호를 파이프의 쓰기용 fd(
fd[1]
)에write
하는 작업만 수행합니다.write
는async-signal-safe
함수이므로 안전합니다. - 이벤트 루프에서 감시: 메인 프로그램은
poll
,select
,epoll
같은 I/O 멀티플렉싱 함수를 이용해 파이프의 읽기용 fd(fd[0]
)를 감시합니다. 시그널이 발생해 핸들러가 파이프에 데이터를 쓰면,poll
은 해당 fd가 “읽을 준비가 되었다”고 알려줍니다. - 안전한 처리: 메인 루프는 파이프에서 시그널 번호를
read
하고, 사전에 정의된 적절한 콜백 함수를 호출하여 시그널을 처리합니다. 이 모든 과정은 시그널 핸들러 컨텍스트가 아닌, 안전한 메인 프로그램의 컨텍스트에서 실행됩니다.
결과적으로 시그널 발생이라는 비동기 이벤트를 파일 디스크립터 I/O라는 동기 이벤트로 변환하여 다른 네트워크 소켓 등과 함께 일관되게 처리할 수 있게 됩니다.
재사용 가능한 C 코드 구현
이제 이 개념을 바탕으로 설계된 재사용 가능한 시그널 처리 모듈을 살펴보겠습니다.
1. 헤더 파일: signal_handler.h
먼저 외부로 노출될 API를 정의합니다. 사용자는 이 헤더 파일만 보고도 라이브러리의 기능을 파악할 수 있어야 합니다.
#ifndef SIGNAL_HANDLER_H
#define SIGNAL_HANDLER_H
#include <signal.h>
// 사용자 정의 시그널 핸들러 함수 포인터 타입
typedef void (*SignalHandler)(int signo);
// 시그널 처리 메커니즘을 초기화합니다.
int signal_init(void);
// 시그널 처리 리소스를 해제합니다.
void signal_fini(void);
// 특정 시그널(signo)에 대한 행동(handler)을 설정합니다.
int signal_set_action(int signo, SignalHandler handler);
// 시그널 이벤트를 수신하는 파이프의 읽기 fd를 반환합니다.
int signal_get_fd(void);
// 파이프로부터 시그널을 읽어 등록된 핸들러를 호출합니다.
int signal_dispatch(void);
#endif // SIGNAL_HANDLER_H
2. 구현 파일: signal_handler.c
Self-Pipe Trick의 핵심 로직이 담긴 구현부입니다.
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include "signal_handler.h"
static SignalHandler signal_handlers[NSIG];
static int pipe_fds[2] = {-1, -1};
// [핵심] 오직 시그널 번호를 파이프에 쓰는 역할만 하는 마스터 핸들러
static void master_signal_handler(int signo, siginfo_t *info, void *context) {
(void)info;
(void)context;
// async-signal-safe 함수인 write()만 사용
ssize_t written;
do {
written = write(pipe_fds[1], &signo, sizeof(signo));
} while (written == -1 && errno == EINTR);
}
int signal_init(void) {
if (pipe2(pipe_fds, O_CLOEXEC | O_NONBLOCK) == -1) {
perror("pipe2");
return -1;
}
for (int i = 0; i < NSIG; ++i) {
signal_handlers[i] = NULL;
}
return 0;
}
void signal_fini(void) {
if (pipe_fds[0] != -1) close(pipe_fds[0]);
if (pipe_fds[1] != -1) close(pipe_fds[1]);
}
int signal_set_action(int signo, SignalHandler handler) {
if (signo < 1 || signo >= NSIG) return -1;
signal_handlers[signo] = handler;
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sigemptyset(&sa.sa_mask);
if (handler == SIG_DFL || handler == SIG_IGN) {
sa.sa_handler = handler;
sa.sa_flags = 0;
} else {
sa.sa_sigaction = master_signal_handler;
sa.sa_flags = SA_SIGINFO | SA_RESTART;
}
if (sigaction(signo, &sa, NULL) == -1) {
perror("sigaction");
return -1;
}
return 0;
}
int signal_get_fd(void) {
return pipe_fds[0];
}
int signal_dispatch(void) {
int signo;
ssize_t bytes_read;
int dispatched_count = 0;
while ((bytes_read = read(pipe_fds[0], &signo, sizeof(signo))) > 0) {
if (bytes_read == sizeof(signo)) {
SignalHandler handler = signal_handlers[signo];
if (handler && handler != SIG_DFL && handler != SIG_IGN) {
handler(signo);
dispatched_count++;
}
}
}
return dispatched_count;
}
3. 사용 예제: main.c
이제 우리가 만든 모듈을 사용하여 실제 메인 애플리케이션을 작성해 봅시다. poll
을 이용해 시그널 이벤트를 기다리는 모습을 확인할 수 있습니다.
#include <stdio.h>
#include <unistd.h>
#include <poll.h>
#include <stdlib.h>
#include "signal_handler.h"
volatile sig_atomic_t running = 1;
// SIGINT (Ctrl+C)에 대한 사용자 정의 핸들러
void handle_sigint(int signo) {
printf("\nCaught signal %d (SIGINT). Shutting down...\n", signo);
running = 0;
}
// SIGTERM에 대한 사용자 정의 핸들러
void handle_sigterm(int signo) {
printf("\nCaught signal %d (SIGTERM). Shutting down gracefully...\n", signo);
running = 0;
}
int main(void) {
printf("Starting... PID: %d\n", getpid());
if (signal_init() != 0) {
return EXIT_FAILURE;
}
signal_set_action(SIGINT, handle_sigint);
signal_set_action(SIGTERM, handle_sigterm);
signal_set_action(SIGPIPE, SIG_IGN); // SIGPIPE는 무시
struct pollfd pfd;
pfd.fd = signal_get_fd();
pfd.events = POLLIN;
while (running) {
printf("Waiting for a signal or other events...\n");
int ret = poll(&pfd, 1, -1); // 무한 대기
if (ret > 0 && (pfd.revents & POLLIN)) {
printf("Signal event detected. Dispatching...\n");
signal_dispatch();
}
}
printf("Cleaning up and exiting.\n");
signal_fini();
return EXIT_SUCCESS;
}
마무리하며
Self-Pipe Trick은 시그널을 안전하게 처리하는 매우 강력하고 검증된 방법입니다. 이 기법을 통해 우리는 다음의 이점을 얻을 수 있습니다.
- 안전성:
async-signal-safe
하지 않은 함수 호출로 인한 데드락과 경쟁 상태를 원천적으로 방지합니다. - 통합성: 시그널 이벤트를 파일 I/O 이벤트처럼 취급하여, 복잡한 애플리케이션의 이벤트 루프에 깔끔하게 통합할 수 있습니다.
- 단순성: 시그널 핸들러의 역할을 극도로 단순화시켜 코드의 예측 가능성을 높입니다.
물론 최신 리눅스 커널에서는 이 기법을 커널 수준에서 구현한 signalfd()
라는 더 간편한 대안도 제공합니다. 하지만 Self-Pipe Trick의 원리를 이해하는 것은 모든 POSIX 시스템에서 통용되는 견고한 소프트웨어를 설계하는 데 매우 중요한 밑거름이 될 것입니다.
안전한 시그널 처리로 여러분의 다음 프로젝트가 한 단계 더 견고해지기를 바랍니다.