리눅스에서 계속 실행중인 백그라운드 프로그램 설계

대상: 리눅스에서 “계속 도는 프로그램”을 만들어야 하는 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. 최소한 해두면 좋은 것들

  1. 표준 출력은 INFO/DEBUG 위주로
  2. 표준 에러는 ERROR/WARN 위주로
  3. 필요하면 로그 파일을 하나 두고, 파일에도 같이 써주기

예를 들어 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. 요약 – “서비스스럽게” 만들기 위해 체크할 것들

리눅스에서 “계속 도는 프로그램”을 만들 때,
앞으로는 아래 체크리스트를 기준으로 설계해보면 좋다.

  1. 무한 루프만 있다고 서비스가 아니다
    • 예외, 에러, 종료 조건을 어떻게 처리할지 먼저 생각하기
  2. 로그 전략
    • stdout/stderr 분리, 레벨(INFO/WARN/ERROR/DEBUG) 최소한 나누기
  3. 설정 분리
    • 포트 이름, 경로, 주기, 타임아웃 등을 코드 밖 설정 파일로 빼두기
  4. 리소스 관리
    • 스레드, 메모리, 파일 핸들을 RAII/스마트 포인터 등으로 관리
  5. 시그널 처리
    • SIGINT/SIGTERM을 받아서 깨끗하게 종료할 수 있게 만들기
  6. systemd 연동
    • 최종적으로는 service 파일을 만들어 OS가 관리하게 하기

댓글 남기기