노트북을 덮었을 뿐인데 백필이 절반에서 멈췄다, SSH 끊김과 SIGHUP 사후 분석
환경 Bash 5.x · coreutils nohup · systemd-run · logind · 2026-11-02 확인
새 컬럼을 추가하고 기존 행에 값을 채워 넣는 백필 스크립트가 있습니다. 행이 수백만 개라 한 시간은 걸리는 작업이고, 운영 서버에 SSH 로 들어가 프롬프트에서 바로 실행했습니다. 진행률이 40%쯤일 때 자리를 옮기느라 노트북을 덮었고, 다시 열었을 때 터미널에는 연결이 끊겼다는 메시지만 남아 있었습니다. 다시 접속해 보니 스크립트는 떠 있지 않았고, 테이블은 절반만 채워진 채였습니다.
덮개를 닫은 순간부터 스크립트가 사라질 때까지
오후 세 시, 서버에 접속해 백필 스크립트를 띄웁니다. 천 행씩 읽어 값을 계산하고 커밋하는 단순한 반복입니다. 화면에는 처리한 행 수가 몇 초마다 한 줄씩 올라옵니다. 끝날 때까지 기다릴 생각은 없었고, 다른 일을 하다 가끔 들여다볼 참이었습니다.
세 시 이십오 분, 회의실로 옮기려고 노트북을 덮습니다. 노트북이 잠들면 네트워크도 같이 멈추고, 서버 쪽 sshd 는 한동안 응답이 없는 연결을 결국 닫습니다. 연결이 닫히면 그 세션의 가상 터미널도 사라지고, 터미널에 붙어 있던 로그인 셸은 SIGHUP 을 받습니다.
여기서 bash 가 하는 일이 핵심입니다. bash 는 SIGHUP 을 받으면 기본적으로 종료하는데, 대화형 셸은 나가기 전에 자기가 거느린 작업 전부에 SIGHUP 을 다시 보냅니다. 돌고 있는 작업이든 멈춰 있는 작업이든 가리지 않습니다. 백필 스크립트는 그 셸의 작업이었으니 같은 신호를 받았고, 따로 처리하는 코드가 없어 그대로 끝났습니다.
세 시 오십 분, 다시 접속합니다. ps 에는 스크립트가 없고, 로그 파일도 남기지 않았으니 마지막 줄은 이미 사라진 화면에만 있었습니다. 테이블을 세어 보고서야 어디까지 갔는지 압니다. 화면 출력에 기대 진행 상황을 보던 습관이 그대로 구멍이 됐습니다.
# 그날 친 명령 — 프롬프트에서 그냥 실행했다
php spark backfill:display-name
# 다시 접속해서 확인
ps -eo pid,etime,cmd | grep '[b]ackfill'
# (아무것도 안 나온다)
# 같은 상황을 손으로 재현해 보면 (버려도 되는 새 SSH 세션에서)
sleep 600 &
kill -HUP $$ # 셸에 SIGHUP 을 보내면 셸이 sleep 에도 다시 보낸다
# 다른 터미널에서: pgrep -a sleep → 사라져 있다
# SIGHUP 으로 끝난 명령의 종료 코드는 128+1
sh -c 'kill -HUP $$'; echo $? # 129연결이 끊기면 셸이 SIGHUP 을 받고, 셸은 그 신호를 자기 작업에 그대로 넘깁니다
스크립트를 죽인 건 네트워크가 아니라, 끊긴 터미널을 보고 셸이 넘겨준 신호 하나였습니다.
멈춘 자리보다 멈춘 방식이 더 골치였다
절반이 채워진 것 자체는 다시 돌리면 끝날 일처럼 보입니다. 문제는 스크립트가 처음부터 다시 도는 구조였다는 점입니다. 이미 값이 들어간 행도 다시 계산하고 다시 씁니다. 한 시간짜리를 또 한 시간 돌려야 하고, 그동안 운영 테이블에 쓰기 부하가 두 번 걸립니다.
더 곤란한 건 그 사이 화면입니다. 새 컬럼을 읽는 코드는 이미 배포돼 있었고, 값이 비어 있는 행은 기본 문구로 보입니다. 사용자 절반은 새 표시를, 나머지 절반은 빈칸 대신 들어간 기본 문구를 보게 됩니다. 버그 신고처럼 보이는 문의가 들어오기 시작한 게 이때입니다.
끊긴 지점도 깔끔하지 않았습니다. 천 행 단위로 커밋하니 마지막 묶음은 커밋 전이었고 롤백됐습니다. 데이터가 깨지진 않았지만, 어디까지 들어갔는지는 테이블을 세어 봐야만 알 수 있었습니다. 진행 위치를 기록해 두지 않았으니 이어서 돌릴 방법이 처음부터 없었던 셈입니다.
끊길 수 있다는 걸 전제로 하지 않은 스크립트는 끊긴 뒤에 할 수 있는 일이 없습니다.
원인은 세 가지가 한 줄로 이어져 있었다
첫째, 오래 걸리는 작업을 대화형 셸의 자식으로 돌렸습니다. 그 셸은 터미널과 운명을 같이합니다. 터미널이 사라지면 셸이 나가고, 셸은 나가면서 작업을 데려갑니다. 문서에 적힌 그대로의 동작이라 놀랄 일이 아니었는데, 짧은 명령만 치던 손버릇으로 한 시간짜리를 띄운 겁니다.
둘째, 진행 상황을 화면에만 찍었습니다. 표준 출력이 터미널이면 터미널과 함께 사라집니다. 파일로 남겼다면 끊긴 직후에도 마지막으로 처리한 id 를 바로 알았을 겁니다.
셋째, 스크립트가 이어서 돌 수 없었습니다. 이미 채운 행을 건너뛰는 조건도, 마지막 위치를 적어 두는 곳도 없었습니다. 그래서 끊김 한 번이 처음부터 다시 돌리기로 바로 이어졌습니다. 셋 중 하나만 달랐어도 회의 하나 끝나고 다시 붙어 이어 돌리면 되는 일이었습니다.
터미널에 묶인 실행, 화면에만 남긴 기록, 이어 갈 수 없는 구조가 한 줄로 이어져 있었습니다.
nohup 으로 덮기 전에 알아 둔 것
가장 먼저 떠오르는 건 nohup 입니다. nohup 은 명령이 SIGHUP 을 무시하도록 해 놓고 실행합니다. 표준 출력이 터미널이면 nohup.out 에 덧붙이고, 표준 입력이 터미널이면 읽을 수 없는 곳으로 돌립니다. 셸이 넘겨주는 SIGHUP 은 이걸로 막힙니다. 이미 띄운 작업이라면 disown -h 로 그 작업만 SIGHUP 대상에서 뺄 수도 있습니다.
다만 nohup 이 막는 건 신호 하나뿐입니다. systemd 를 쓰는 서버에서는 로그인할 때마다 세션 스코프가 하나 생기고, 로그아웃하면 logind 설정 KillUserProcesses 가 켜져 있는 경우 그 스코프 안의 프로세스를 통째로 정리합니다. 이건 SIGHUP 이 아니라 스코프 종료라 nohup 으로 피할 수 없고, tmux 나 screen 도 세션 안에 있으면 같이 정리됩니다. 이 값의 기본은 배포판 빌드마다 달라서, 서버마다 직접 확인해야 합니다.
그래서 이번에는 실행 위치를 세션 밖으로 옮겼습니다. systemd-run 은 명령을 일시적인 서비스 유닛으로 만들어 서비스 관리자 밑에서 돌립니다. 부모가 셸이 아니라 systemd 이니 터미널이 끊겨도, 세션이 정리돼도 상관이 없습니다. 출력은 저널에 쌓이니 다시 접속해서 journalctl -u 로 이어 볼 수 있고, systemctl status 로 살아 있는지 바로 확인합니다.
# 급할 때: SIGHUP 만 막는다 (출력은 파일로)
nohup php spark backfill:display-name > /var/log/backfill.log 2>&1 &
# 이미 띄운 작업이면 SIGHUP 대상에서만 뺀다
jobs -l
disown -h %1
# 이 서버가 로그아웃 때 세션을 통째로 정리하는지 확인
systemd-analyze cat-config systemd/logind.conf | grep -i KillUserProcesses
# 이번에 바꾼 방식: 세션 밖 서비스로 돌린다
sudo systemd-run --unit=backfill-display-name --collect \
--working-directory=/srv/app -p User=app \
php spark backfill:display-name
# 다시 접속해서 이어 보기
systemctl status backfill-display-name
journalctl -u backfill-display-name -fnohup 은 신호 하나를 막고, systemd-run 은 실행을 세션에서 아예 떼어 냅니다
nohup 은 SIGHUP 을 피할 뿐이고, 세션이 통째로 정리되는 서버에서는 실행 위치부터 옮겨야 합니다.
다음에 끊겨도 이어서 돌 수 있게
실행 위치를 옮긴 것과 별개로 스크립트도 손봤습니다. 값이 이미 들어간 행은 조건에서 빼서, 몇 번을 다시 돌려도 남은 행만 처리하게 했습니다. 마지막으로 커밋한 id 는 묶음마다 로그 파일에 한 줄씩 남깁니다. 끊기면 그 줄부터 다시 시작하면 됩니다.
신호를 받았을 때의 동작도 정했습니다. SIGTERM 을 받으면 지금 묶음까지만 커밋하고 마지막 id 를 적은 뒤 끝냅니다. 서비스로 돌리면 systemctl stop 이 보내는 것도 이 신호라, 중간에 멈추고 싶을 때도 같은 길로 깔끔하게 내려옵니다.
운영 습관도 하나 생겼습니다. 프롬프트에 바로 칠 명령인지, 서비스로 띄울 작업인지를 실행 전에 한 번 가릅니다. 기준은 단순합니다. 끝나기 전에 노트북을 덮을 일이 생길 것 같으면 서비스로 띄웁니다.
돌아보면 고친 건 실행 명령 한 줄, WHERE 조건 하나, 로그 한 줄입니다. 그 몇 줄 때문에 오후 내내 절반짜리 화면을 보고 있었습니다. 오래 걸리는 작업은 터미널이 없어도 돌 수 있는 곳에 두고, 끊겨도 이어 갈 수 있게 짜 둡니다.
# 남은 행만 고르고, 마지막 id 를 기록하며 진행
# SELECT id FROM users
# WHERE display_name IS NULL AND id > :last_id
# ORDER BY id LIMIT 1000;
# 로그 마지막 줄에서 재시작 위치 확인
tail -n 1 /var/log/backfill.log
# 2026-11-02T15:24:51+09:00 committed up to id=4182000
# 서비스로 돌리던 걸 멈출 때 — SIGTERM 이 가고 스크립트가 묶음까지 마치고 내려온다
sudo systemctl stop backfill-display-name건너뛰는 조건과 마지막 위치 기록이 있으면 끊김은 처음부터가 아니라 이어서가 됩니다
조치 후 확인할 것
- 한 시간 넘게 도는 작업을 프롬프트에서 바로 띄우고 있지 않은지 봅니다. 그렇다면 systemd-run 으로 서비스 유닛을 만들어 돌립니다.
- systemd-analyze cat-config systemd/logind.conf 로 KillUserProcesses 값을 확인합니다. 켜져 있으면 nohup 이나 tmux 만으로는 로그아웃을 버티지 못합니다.
- 진행 위치가 화면이 아니라 파일이나 저널에 남는지 확인합니다. 끊긴 뒤 마지막으로 처리한 지점을 바로 알 수 있어야 합니다.
- 스크립트를 두 번 연달아 돌려 봅니다. 두 번째 실행이 남은 행만 처리하고 금방 끝나면 이어 돌리기가 되는 구조입니다.
- 실행 중에 SIGTERM 을 보내 봅니다. 묶음 단위로 커밋하고 마지막 위치를 남긴 뒤 내려오는지 확인합니다.
SSH 연결은 언젠가 끊깁니다. 노트북을 덮어서든, 와이파이가 바뀌어서든, 서버가 오래 응답 없는 연결을 닫아서든 이유는 많습니다. 끊기면 셸이 나가고 셸은 작업을 데려간다는 동작은 앞으로도 그대로일 겁니다. 그러니 오래 걸리는 작업은 처음부터 터미널 밖에 두고, 끊겨도 이어서 돌 수 있게 짜 둡니다.