백그라운드 실행과 쓰레드

백그라운드 실행 = 쓰레드?

"백그라운드로 실행한다"는 말이 곧 쓰레드를 쓴다는 뜻일까? 아닙니다. 백그라운드 실행에는 여러 방식이 있고, 쓰레드는 그중 하나일 뿐입니다.

이 포스트에서는 백그라운드 실행의 3가지 방식을 정리하고, 실제 ROS2 환경에서 어떻게 적용되는지 살펴봅니다.


백그라운드 실행의 3가지 방식

백그라운드에서 작업이 돌아가는 메커니즘은 크게 쓰레드, 프로세스, OS 스케줄링 세 가지로 나눌 수 있습니다.

flowchart LR
    BG["🔄 백그라운드 실행"]
    T["쓰레드\n(Thread)"]
    P["프로세스\n(Process)"]
    OS["OS 스케줄링\n(Time Slicing)"]
 
    BG --> T
    BG --> P
    BG --> OS
 
    T --- T_DESC["한 프로세스 안에서\n메모리 공유하며 분리"]
    P --- P_DESC["완전히 별도 프로그램\nIPC로 통신"]
    OS --- OS_DESC["CPU 시간을 나눠\n번갈아 실행"]

1. 쓰레드 (Thread) — 한 프로세스 안에서 분리

쓰레드는 하나의 프로세스 내부에서 실행 흐름을 여러 갈래로 나누는 방식입니다. 같은 메모리 공간을 공유하기 때문에 데이터 교환이 빠르고 간단합니다.

flowchart TB
    subgraph Process["내 노드 프로세스"]
        direction TB
        Main["메인 쓰레드\n제어 루프: 목표 계산 → IK → 발행"]
        Callback["콜백 쓰레드\n/joint_states 수신 → 변수 갱신"]
        Shared[("공유 메모리\nself._current_joints")]
    end
 
    Main <--> Shared
    Callback <--> Shared

핵심 특징:

  • 같은 프로세스 안이므로 메모리를 공유
  • self._current_joints 같은 변수를 쓰레드 간에 바로 읽고 쓸 수 있음
  • 대신 동기화 문제(Race Condition)를 주의해야 함
import threading
 
shared_data = {"joints": [0.0, 0.0, 0.0]}
lock = threading.Lock()
 
def callback_thread():
    """콜백 쓰레드: 센서 데이터를 갱신"""
    while True:
        new_data = receive_joint_states()  # 데이터 수신
        with lock:  # 동기화: 동시 접근 방지
            shared_data["joints"] = new_data
 
def main_thread():
    """메인 쓰레드: 제어 루프 실행"""
    while True:
        with lock:
            current = shared_data["joints"]  # 공유 데이터 읽기
        command = compute_control(current)
        publish(command)

주의: 쓰레드 간 공유 변수에 동시 접근하면 데이터가 깨질 수 있습니다. 반드시 Lock, Mutex 등으로 동기화를 처리하세요.


2. 프로세스 (Process) — 완전히 별도 프로그램

프로세스는 독립된 메모리 공간을 가진 별도의 프로그램입니다. 서로 직접 변수를 공유할 수 없고, IPC(Inter-Process Communication) 메커니즘을 통해 통신합니다.

flowchart LR
    subgraph System["시스템"]
        A["ros2_control\n프로세스"]
        B["robot_state_publisher\n프로세스"]
        C["move_group\n프로세스"]
        D["내 노드\n프로세스"]
    end
 
    A -- "/joint_states\n(토픽)" --> D
    B -- "/tf\n(토픽)" --> C
    C -- "/compute_ik\n(서비스)" --> D

핵심 특징:

  • 각 프로세스는 독립된 메모리 공간을 가짐
  • 하나가 죽어도 다른 프로세스에 직접적인 영향 없음
  • ROS2에서는 토픽, 서비스, 액션 등으로 프로세스 간 통신
# ROS2에서 각각 별도 프로세스로 실행
ros2 run ros2_control_node ros2_control_node &   # 프로세스 1
ros2 run robot_state_publisher robot_state_publisher &  # 프로세스 2
ros2 run moveit2 move_group &                     # 프로세스 3
ros2 run my_pkg my_node &                         # 프로세스 4

3. OS 스케줄링 — sleep 중 다른 작업 처리

쓰레드를 명시적으로 만들지 않아도, 운영체제가 CPU 시간을 쪼개어 여러 작업을 번갈아 실행합니다. 특히 sleep() 호출 시 CPU를 자발적으로 반납하므로, OS가 다른 작업을 처리할 수 있습니다.

sequenceDiagram
    participant Node as 내 노드
    participant OS as 운영체제
    participant Other as 다른 프로세스/쓰레드
 
    Node->>OS: sleep(33ms) 호출
    OS->>Other: CPU 할당 (다른 작업 처리)
    Note over Other: 33ms 동안 실행
    OS->>Node: 33ms 경과 → CPU 재할당
    Node->>Node: 제어 루프 계속 실행

핵심 특징:

  • 쓰레드를 안 써도 OS가 알아서 시분할(Time Slicing) 처리
  • sleep() 호출은 "나 잠깐 쉴게, 다른 거 해"라는 의미
  • 단일 코어에서도 여러 작업이 동시에 돌아가는 것처럼 보이는 이유

3가지 방식 비교

방식메모리통신 방법비유장점단점
쓰레드공유변수 직접 접근한 사무실에서 직원 여러 명이 일함빠른 데이터 공유동기화 필요
프로세스독립IPC (토픽/서비스 등)다른 사무실에서 전화로 소통격리성, 안정성통신 오버헤드
OS 스케줄링해당 없음해당 없음직원 1명이 번갈아 처리추가 구현 불필요진정한 병렬 아님

ROS2에서의 실제 적용

ROS2에서는 Executor가 콜백 처리 방식을 결정합니다. Executor 종류에 따라 쓰레드 사용 여부가 달라집니다.

SingleThreadedExecutor — 쓰레드 1개

from rclpy.executors import SingleThreadedExecutor
 
executor = SingleThreadedExecutor()
executor.add_node(my_node)
executor.spin()
# → 콜백을 순서대로 하나씩 처리. 쓰레드 추가 생성 없음.
sequenceDiagram
    participant E as Executor (쓰레드 1개)
 
    E->>E: 제어 루프 실행
    E->>E: sleep(33ms)
    E->>E: /joint_states 콜백 처리
    E->>E: 제어 루프 재개
    Note over E: 하나의 쓰레드에서 순차 실행
  • 콜백이 "백그라운드에서 도는 것처럼" 보이지만, 실제로는 메인 루프가 쉬는 동안 순차 처리
  • 콜백이 오래 걸리면 제어 루프가 지연될 수 있음

MultiThreadedExecutor — 쓰레드 여러 개

from rclpy.executors import MultiThreadedExecutor
 
executor = MultiThreadedExecutor(num_threads=4)
executor.add_node(my_node)
executor.spin()
# → 콜백을 여러 쓰레드에서 동시 처리
sequenceDiagram
    participant T1 as 쓰레드 1
    participant T2 as 쓰레드 2
    participant T3 as 쓰레드 3
 
    par 병렬 실행
        T1->>T1: 제어 루프 실행
    and
        T2->>T2: /joint_states 콜백
    and
        T3->>T3: /tf 콜백
    end
    Note over T1,T3: 진짜 동시 실행 (멀티코어 활용)
  • 콜백과 제어 루프가 진짜 동시에 실행
  • 대신 공유 변수 접근 시 동기화(Lock) 필수

Executor 선택 가이드

flowchart TD
    Q1{"콜백 처리 시간이\n제어 주기보다 긴가?"}
    Q2{"공유 데이터에\n동시 접근이 있는가?"}
 
    Single["SingleThreadedExecutor\n간단하고 안전"]
    Multi["MultiThreadedExecutor\n+ Lock 동기화"]
    SingleOK["SingleThreadedExecutor\n충분히 빠름"]
 
    Q1 -- "아니오" --> SingleOK
    Q1 -- "예" --> Q2
    Q2 -- "예" --> Multi
    Q2 -- "아니오" --> Multi
상황추천 Executor
콜백이 가볍고 빠름SingleThreadedExecutor
콜백이 무겁거나 블로킹MultiThreadedExecutor
실시간 제어 + 센서 수신 동시MultiThreadedExecutor + Lock

실전 팁과 주의사항

  • GIL (Global Interpreter Lock): Python에서는 GIL 때문에 CPU 바운드 작업은 멀티쓰레드로 성능 향상이 제한됩니다. I/O 바운드(네트워크, 센서 수신)에는 효과적입니다.
  • 콜백 안에서 오래 걸리는 작업 금지: SingleThreadedExecutor에서는 하나의 콜백이 오래 걸리면 다른 콜백이 모두 밀립니다.
  • ReentrantCallbackGroup: ROS2에서 같은 그룹의 콜백을 동시에 실행 가능하게 하려면 ReentrantCallbackGroup을 사용하세요.
  • 디버깅: 멀티쓰레드 버그(Race Condition, Deadlock)는 재현이 어렵습니다. 로깅을 충분히 남기고, 가능하면 단일 쓰레드부터 테스트하세요.

핵심 정리

"백그라운드"라는 말은 쓰레드만을 의미하지 않습니다.

현상실제 메커니즘
/joint_states가 계속 갱신됨ros2_control별도 프로세스로 실행
내 노드 안에서 콜백이 처리됨Executor 방식에 따라 쓰레드일 수도, 순차 처리일 수도 있음
sleep() 중에도 시스템이 멈추지 않음OS 스케줄링이 CPU를 다른 작업에 할당

백그라운드 실행의 본질을 이해하면, 시스템 설계 시 어떤 방식을 선택할지 명확한 판단을 내릴 수 있습니다.