대상: 리눅스에서 “계속 도는 프로그램”을 만들어야 하는 C/C++/Python 개발자
환경: Ubuntu 18.04 기준, 터미널 사용 가능, systemd 환경(일반적인 데스크톱/서버)
리눅스에서 센서나 카메라, 시리얼, 네트워크 장비를 다루다 보면
한 번 실행해두고 계속 돌아가야 하는 프로그램을 만들 일이 많다.
보통 이런 식으로 시작하지.
while (true) {
// 데이터 읽고 처리
}
겉으로 보기엔 “계속 도는 프로그램”처럼 보이지만,
실제로는 서비스/데몬으로 제대로 설계된 것과는 차이가 꽤 크다.
이 글에서는:
- 왜
while(true)만 있다고 서비스가 아닌지 - 로그를 어떻게 남기는 게 좋은지
- 설정(config)을 어떻게 분리해야 하는지
- 리소스 누수를 어떻게 막을지
- 종료 시그널(SIGINT, SIGTERM)을 어떻게 처리할지
- 마지막으로 systemd 서비스로 등록하는 흐름 개요
까지, C++/Python 공통으로 적용되는 개념 위주로 정리해볼 거야.
1. 왜 while(true)만 있다고 서비스가 아닌가?
가장 흔한 패턴:
int main()
{
while (true)
{
// 할 일
}
return 0;
}
겉으로만 보면 “계속 도니까 서비스 아님?” 싶지만,
실제로는 아래 같은 문제가 거의 필수로 따라온다.
- 예외/에러 처리 없음
- 중간에 예외 하나 터지면 바로 종료
- I/O 에러, 장치 분리 등 예외 상황에 전혀 대응하지 못함
- 리소스 정리 없음
- 루프 안에서 할당/생성만 하고 해제는 안 하면 메모리/핸들 누수
- 종료 요청에 반응하지 못함
kill -TERM, 시스템 종료 시그널이 와도 “모른 척”- 강제
kill -9를 써야 해서 상태 저장/로그 마무리가 안 됨
- 관찰 불가능
- 로그가 없어서 “뭐 하다가 죽었는지” 알 수 없음
즉, “무한 루프”라는 형태 자체보다는,
- 에러/예외를 견디고,
- 리소스를 관리하고,
- 상태를 남기고,
- 종료 요청에 깔끔하게 반응하는 구조
까지 갖춰졌을 때 비로소 운영 가능한 백그라운드 프로그램이라고 볼 수 있다.
2. 로그 관리 – stdout만 쓰지 말고, 로그 파일/레벨을 분리하자
초기에는 std::cout / printf / print()만 써도 상관 없지만,
실제로 운영하다 보면 로그를 이렇게 관리하고 싶어질 거다.
- 파일에 일자별로 나눠서 저장 (로그 로테이션)
- 레벨별 로그 구분 (INFO, WARN, ERROR, DEBUG)
- 문제가 생겼을 때 “최근 1~2일 로그”만 빠르게 보고 싶음
2-1. 최소한 해두면 좋은 것들
- 표준 출력은 INFO/DEBUG 위주로
- 표준 에러는 ERROR/WARN 위주로
- 필요하면 로그 파일을 하나 두고, 파일에도 같이 써주기
예를 들어 C++ 라면:
#include <iostream>
void logInfo(const std::string& msg)
{
std::cout << "[INFO] " << msg << std::endl;
}
void logError(const std::string& msg)
{
std::cerr << "[ERROR] " << msg << std::endl;
}
이렇게 레벨만 나눠도 나중에 systemd에서 journalctl로 볼 때
INFO/ERROR가 구분되어 보여서 훨씬 편하다.
Python도 비슷하게 logging 모듈을 쓰면:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logging.info("Service started")
logging.error("Something went wrong")
이 정도만 해도, “언제 무슨 일이 있었는지” 추적이 훨씬 쉬워진다.
3. 설정 파일(config)을 코드와 분리해두기
처음에는 모든 파라미터를 하드코딩하기 쉽다.
const std::string SERIAL_PORT = "/dev/ttyUSB0";
const int BAUDRATE = 115200;
const int LOOP_INTERVAL_MS = 10;
근데 운영하면서 상황이 바뀔 때마다 코드를 다시 빌드/배포해야 하는 건 꽤 귀찮다.
예를 들어:
- 시리얼 포트가
/dev/ttyACM0로 바뀌었다 - 타임아웃을 10ms → 50ms로 바꾸고 싶다
- 로깅 레벨을 DEBUG로 올려 보고 싶다
이런 것들을 코드 안이 아니라 설정 파일 한 곳에서 바꾸게 해두면
운영이 훨씬 편해진다.
3-1. 설정 파일 예시 (INI/JSON/YAML 중 아무거나)
가장 간단히 JSON 예시로 들면:
{
"serial": {
"device": "/dev/ttyUSB0",
"baudrate": 115200
},
"loop": {
"interval_ms": 10
},
"log": {
"level": "INFO"
}
}
프로그램 시작 시 이 파일을 읽어오고,
중간에 변경되면 다시 읽도록 만들 수도 있다(핫 리로드).
중요한 포인트는:
- 환경에 따라 달라질 수 있는 값 (포트 이름, 경로, 주기, 타임아웃 등)을 분리한다.
- 코드와 설정의 책임을 분리해두면, 나중에 다른 사람이 운영할 때도 편하다.
4. 리소스(스레드, 메모리, 파일 핸들) 누수 체크
“계속 도는 프로그램”은 누수가 있으면 언젠가는 반드시 터진다.
- 한 번 돌고 끝나는 툴은 누수가 있어도 그냥 프로세스가 종료되면서 OS가 리소스를 정리해 줌
- 근데 24시간 365일 돌려야 하는 백그라운드는 다르다
대표적인 누수 후보:
- 스레드 생성 후
join()/detach()제대로 안 한 경우 - 루프 안에서
new만 하고delete안 하는 경우 - 파일/소켓을 열기만 하고 닫지 않는 경우
- 반복적으로 이벤트 핸들러/콜백을 등록만 하고 해제 안 하는 경우
4-1. C++에서 기본적으로 신경 쓰면 좋은 것들
- RAII 패턴 적극 사용
std::unique_ptr,std::shared_ptr같은 스마트 포인터std::thread+std::jthread(C++20 이상) 등
- 루프 안에서 동적 할당을 최대한 줄이고,
- 바깥에서 미리 버퍼를 할당해두고 재사용
예를 들어:
std::vector<uint8_t> buffer(1024);
while (running)
{
// 매번 new/delete 하지 말고 buffer 재사용
}
이런 사소한 차이들이 장시간 운영에서는 꽤 큰 안정성 차이를 만든다.
5. 종료 시그널(SIGINT, SIGTERM) 처리 – “깨끗하게 죽을 수 있어야 한다”
백그라운드 프로그램이 진짜 “운영 모드”에 들어가면,
보통 이런 방식으로 종료된다.
- OS가 재부팅되면서
SIGTERM전달 systemctl stop myservice호출- 누군가
kill -TERM <pid>요청
이때 아무 반응 없이 계속 돌아가면,
결국 kill -9(SIGKILL)로 강제 종료해야 하고,
- 열려 있는 파일/소켓/시리얼/카메라가 적절히 닫히지 않고,
- 마지막 상태 저장, 버퍼 flush, 로그 마무리가 안 된 채 죽게 된다.
그래서 보통 이런 패턴을 많이 쓴다.
5-1. C++에서 시그널 핸들러 예시
#include <csignal>
#include <atomic>
#include <iostream>
std::atomic<bool> g_running(true);
void signalHandler(int signum)
{
std::cout << "[INFO] Signal (" << signum << ") received. Stopping..." << std::endl;
g_running = false;
}
int main()
{
std::signal(SIGINT, signalHandler); // Ctrl+C
std::signal(SIGTERM, signalHandler); // kill, systemd stop 등
while (g_running)
{
// 메인 루프
// ...
}
// 여기서 자원 정리 (파일/소켓/스레드 등)
std::cout << "[INFO] Clean shutdown complete." << std::endl;
return 0;
}
이렇게 하면:
- Ctrl+C 또는
kill -TERM이 들어왔을 때 g_running이 false로 바뀌고- 루프를 빠져나와 정상 종료 루틴으로 들어갈 수 있다.
Python도 signal 모듈로 비슷하게 작성할 수 있다.
6. systemd 서비스로 등록하는 흐름 개요
“계속 도는 프로그램”의 최종 형태는 보통 systemd 서비스다.
- OS 부팅 시 자동 시작
- 죽으면 자동 재시작
systemctl status myservice로 상태 확인journalctl -u myservice로 로그 조회
6-1. 간단한 systemd 서비스 파일 예시
예를 들어 /usr/local/bin/my_service 라는 실행 파일이 있다고 치면,/etc/systemd/system/my_service.service 파일을 만들어서:
[Unit]
Description=My Background Service
After=network.target
[Service]
ExecStart=/usr/local/bin/my_service
Restart=on-failure
User=ubuntu
Group=ubuntu
WorkingDirectory=/usr/local/bin
[Install]
WantedBy=multi-user.target
이렇게 적어둘 수 있다.
설정 후에는:
sudo systemctl daemon-reload
sudo systemctl enable my_service
sudo systemctl start my_service
sudo systemctl status my_service
이 흐름이 익숙해지면:
- 프로그램을 직접 터미널에서 실행시키는 대신
- systemd에 맡겨서 운영하면서
- 상태/로그를 일관되게 관리할 수 있다.
이 글에서는 개념만 간단히 보고,
나중에 실제 예제(service 파일 + 샘플 실행 파일)를 한 번에 구성해볼 수도 있다.
7. 요약 – “서비스스럽게” 만들기 위해 체크할 것들
리눅스에서 “계속 도는 프로그램”을 만들 때,
앞으로는 아래 체크리스트를 기준으로 설계해보면 좋다.
- 무한 루프만 있다고 서비스가 아니다
- 예외, 에러, 종료 조건을 어떻게 처리할지 먼저 생각하기
- 로그 전략
- stdout/stderr 분리, 레벨(INFO/WARN/ERROR/DEBUG) 최소한 나누기
- 설정 분리
- 포트 이름, 경로, 주기, 타임아웃 등을 코드 밖 설정 파일로 빼두기
- 리소스 관리
- 스레드, 메모리, 파일 핸들을 RAII/스마트 포인터 등으로 관리
- 시그널 처리
- SIGINT/SIGTERM을 받아서 깨끗하게 종료할 수 있게 만들기
- systemd 연동
- 최종적으로는 service 파일을 만들어 OS가 관리하게 하기