스케줄러가 한 번, 사람이 급해서 손으로 한 번. 같은 스크립트가 두 번 돌면 메일이 두 통 나가고, 글이 두 편 올라가고, 결제가 두 번 됩니다. 스크립트 중복 실행 방지는 보통 “잠금 파일 하나 만들면 되지” 하고 넘어가는데, 실제로 그 흔한 방식을 같은 순간에 5개씩 띄워 20번 돌려보니 20번 중 12번은 두 개 이상이 동시에 통과했습니다. 5개 모두 통과한 회차도 7번이었습니다. 이 글에서는 윈도우 11·파이썬 3.12에서 잠금 방식 세 가지를 직접 경쟁시켜 본 결과와, 잠금만으로는 막을 수 없는 “끝난 뒤 또 한 번”까지 막는 최종 코드를 정리합니다.
실험 준비: 1초짜리 가짜 작업을 동시에 띄우기
진짜 발행을 여러 번 할 수는 없으니, 1초 걸리는 가짜 작업을 만들었습니다. 일을 마치면 published/ 폴더에 프로세스 번호로 파일을 하나 남깁니다. 실행이 끝난 뒤 파일 개수를 세면 “몇 번 일을 했는지”가 나옵니다.
import os, sys, time
from datetime import datetime
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
HERE = Path(__file__).parent
OUT = HERE / "published"
def now():
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
def wait_for_start():
# 테스트용: race.py가 정해준 시각까지 기다렸다가 모두 동시에 출발
t = os.environ.get("RACE_START")
if t:
time.sleep(max(0, float(t) - time.time()))
def do_work():
# 실제 작업 대신 1초(WORK_SECONDS로 조절) 걸리는 "발행"을 흉내 냅니다.
# 결과는 실행마다 파일 하나로 남깁니다(한 파일에 동시에 덧붙이면 줄이 사라짐)
started = now()
time.sleep(float(os.environ.get("WORK_SECONDS", "1")))
OUT.mkdir(exist_ok=True)
(OUT / f"{os.getpid()}.txt").write_text(
f"발행 1건 pid {os.getpid()} 시작 {started} 끝 {now()}", encoding="utf-8")
wait_for_start()는 테스트용입니다. 프로세스를 순서대로 띄우면 시작 시각이 0.1초씩 흩어지는데, 측정기(race.py)가 “지금부터 2초 뒤”라는 시각을 환경변수로 넘겨서 모든 프로세스가 같은 밀리초에 출발하게 만듭니다. 아무 장치도 없는 버전을 5개 띄우면 당연히 5번 다 일합니다.
job_naive.py: 5개 실행 -> 발행 5건, 소요 3.01초, 종료 코드 [0, 0, 0, 0, 0]
발행 1건 pid 30440 시작 10:03:45.752 끝 10:03:46.754
발행 1건 pid 30636 시작 10:03:45.752 끝 10:03:46.754
발행 1건 pid 35792 시작 10:03:45.752 끝 10:03:46.754
발행 1건 pid 38288 시작 10:03:45.752 끝 10:03:46.754
발행 1건 pid 5544 시작 10:03:45.753 끝 10:03:46.754
방법 1: 잠금 파일이 있는지 보고 만들기 (흔하지만 뚫림)
가장 많이 보이는 코드입니다. 잠금 파일이 있으면 끝내고, 없으면 만들고 일한 뒤 지웁니다.
import os, sys
from common import HERE, wait_for_start, do_work
LOCK = HERE / "job.lock"
if __name__ == "__main__":
wait_for_start()
# 흔히 쓰는 방식: 잠금 파일이 있는지 보고, 없으면 만든다 (확인과 생성 사이에 틈이 있음)
if LOCK.exists():
print(f"pid {os.getpid()}: 이미 실행 중이라 종료")
sys.exit(0)
LOCK.write_text(str(os.getpid()))
try:
do_work()
finally:
LOCK.unlink(missing_ok=True)
문제는 “있는지 확인”과 “만들기” 사이의 틈입니다. 두 프로세스가 거의 동시에 exists()를 부르면 둘 다 “없음”을 보고 둘 다 진행합니다. 동시 출발을 20번 반복한 결과입니다.
job_check.py: 20회 중 중복 12회 (1건 8회, 2건 4회, 4건 1회, 5건 7회), 소요 61.6초
자식 프로세스 오류 3회: PermissionError: [WinError 5] 액세스가 거부되었습니다: '...\\dupe\\job.lock'
job_excl.py: 20회 중 중복 0회 (1건 20회), 소요 61.6초
job_oslock.py: 20회 중 중복 0회 (1건 20회), 소요 61.6초
첫 줄이 이 방식입니다. 20번 중 12번 중복이 났고, 그중 7번은 5개가 전부 통과했습니다. 한 회차에서는 두 프로세스가 같은 10:03:51.953에 나란히 일을 시작한 기록도 남았습니다. 덤으로 PermissionError: [WinError 5]로 죽는 프로세스가 3번 나왔는데, 윈도우는 다른 프로세스가 열고 있는 파일을 쓰거나 지우지 못하게 막기 때문입니다. 잠금 파일을 동시에 만지다 잠금 코드 자체가 죽는 셈입니다.
실행 간격이 몇 분씩 벌어지는 평소에는 이 틈에 걸릴 일이 거의 없어서 “잘 되는데?” 하고 넘어가기 쉽습니다. 스케줄러와 사람이 우연히 같은 순간에 실행했을 때 터지기 때문에, 일부러 동시에 출발시켜 봐야 드러납니다.
방법 2: O_EXCL로 한 번에 만들기 (동시 실행은 막음)
확인과 생성을 따로 하지 말고 운영체제에게 “없으면 만들고, 있으면 실패해라”를 한 번에 맡기는 방식입니다. os.open에 O_CREAT | O_EXCL을 주면 됩니다.
import os, sys
from common import HERE, wait_for_start, do_work
LOCK = HERE / "job.lock"
if __name__ == "__main__":
wait_for_start()
try:
# O_CREAT|O_EXCL: "없으면 만들기"를 운영체제가 한 번에 처리 -> 동시에 하나만 성공
fd = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
print(f"pid {os.getpid()}: 이미 실행 중이라 종료")
sys.exit(0)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
try:
do_work()
finally:
LOCK.unlink(missing_ok=True)
위 20회 반복의 두 번째 줄처럼, 같은 조건에서 20번 모두 1건이었습니다. 동시 실행 문제는 이걸로 해결됩니다. 하지만 두 가지 구멍이 남았습니다.
구멍 1 — 강제 종료되면 잠금이 영원히 남습니다. 작업 관리자에서 “작업 끝내기”를 하거나 재부팅되면 finally가 실행되지 않습니다. 5초짜리 작업을 1.5초에 강제 종료해봤습니다.
(job_excl.py)
1차 실행 강제 종료 (종료 코드 1), 잠금 파일 남아 있음: True
2차 실행 출력: pid 21096: 이미 실행 중이라 종료 / 종료 코드 0, 0.06초
(job_oslock.py)
1차 실행 강제 종료 (종료 코드 1), 잠금 파일 남아 있음: True
2차 실행 출력: (없음) / 종료 코드 0, 1.06초
위쪽이 O_EXCL 방식입니다. 남은 잠금 파일 때문에 다음 실행이 0.06초 만에 “이미 실행 중”이라며 그냥 끝났습니다. 아무도 실행 중이 아닌데요. 사람이 잠금 파일을 지울 때까지 매일 이렇게 조용히 아무 일도 안 하게 됩니다. 스케줄러에는 종료 코드 0, 즉 “성공”으로 남는다는 점이 더 곤란합니다.
방법 3: 운영체제 잠금 (죽으면 자동으로 풀림)
파일이 “있느냐”가 아니라 파일에 건 잠금을 쓰면 이 구멍이 막힙니다. 윈도우는 msvcrt.locking, 리눅스·맥은 fcntl.flock을 씁니다. 잠금은 프로세스에 묶여 있어서, 프로세스가 어떻게 끝나든 운영체제가 풀어줍니다.
import os, sys
from common import HERE, wait_for_start, do_work
LOCK = HERE / "job.lock"
def try_lock(f):
# 운영체제 잠금: 프로세스가 어떻게 끝나든(강제 종료 포함) 잠금이 자동으로 풀림
try:
if os.name == "nt":
import msvcrt
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except OSError:
return False
if __name__ == "__main__":
wait_for_start()
lock_file = open(LOCK, "a+") # 파일은 지우지 않고 계속 재사용
if not try_lock(lock_file):
print(f"pid {os.getpid()}: 이미 실행 중이라 종료")
sys.exit(0)
do_work()
20회 반복에서 역시 20번 모두 1건이었고, 강제 종료 테스트(위 출력 아래쪽)에서는 잠금 파일이 그대로 남아 있었는데도 다음 실행이 1.06초 동안 정상적으로 일을 했습니다. 잠금 파일은 지우지 않고 계속 재사용합니다. 지우는 순간 “지우기 직전에 열어둔 프로세스”와 “새로 만든 프로세스”가 서로 다른 파일을 잠그는 틈이 생기기 때문입니다.
| 방식 | 동시 출발 20회 중복 | 강제 종료 후 다음 실행 | 끝난 뒤 또 실행 |
|---|---|---|---|
| exists() 확인 후 생성 | 12회 (+PermissionError 3회) | 막힘(잠금 파일 남음) | 못 막음 |
| O_CREAT | O_EXCL | 0회 | 막힘(잠금 파일 남음) | 못 막음 |
| 운영체제 잠금 | 0회 | 정상 실행 | 못 막음 |
잠금으로는 못 막는 것: 끝난 뒤 또 한 번
표의 마지막 열이 이 글의 진짜 주제입니다. 동시 출발 없이 5개를 순서대로 띄운 초기 실험에서, O_EXCL 방식인데도 3건이 나온 적이 있습니다.
job_excl.py: 5개 동시 실행 -> 발행 3건, 소요 3.38초
발행 1건 pid 39260 시작 10:01:37.249 끝 10:01:38.251
발행 1건 pid 39960 시작 10:01:38.483 끝 10:01:39.484
발행 1건 pid 37052 시작 10:01:39.489 끝 10:01:40.491
시각을 보면 겹친 적이 없습니다. 첫 번째가 38.251에 끝나고 잠금을 지웠고, 프로세스 시작이 1초 넘게 늦어진 두 번째가 38.483에 와서 잠금이 없으니 정상적으로 일을 했습니다. 잠금은 “동시에 둘”을 막을 뿐, “이미 끝난 일을 또 하는 것”은 막지 못합니다. 하루 한 번 보내야 할 리포트가 스케줄러 재시도나 수동 실행으로 두 번 나가는 게 바로 이 경우입니다.
최종 코드: 잠금 + 완료 표시
그래서 “이번 몫을 이미 처리했는가”를 따로 기록합니다. 하루 한 번 도는 작업이면 날짜가 그 단위입니다. 일이 성공한 뒤에 done/2026-10-09.done 같은 표시를 남기고, 다음 실행은 표시가 있으면 그냥 끝냅니다.
import os, sys
from datetime import date
from common import HERE, wait_for_start, do_work
from job_oslock import LOCK, try_lock
DONE = HERE / "done"
if __name__ == "__main__":
wait_for_start()
key = sys.argv[1] if len(sys.argv) > 1 else date.today().isoformat() # "오늘 몫" 단위
marker = DONE / f"{key}.done"
# 1) 동시에 두 개가 돌지 않게: 운영체제 잠금
lock_file = open(LOCK, "a+")
if not try_lock(lock_file):
print(f"pid {os.getpid()}: 이미 실행 중이라 종료")
sys.exit(0)
# 2) 이미 끝낸 일을 또 하지 않게: 완료 표시 (잠금을 잡은 뒤에 확인해야 틈이 없음)
if marker.exists():
print(f"pid {os.getpid()}: {key} 몫은 이미 처리됨 ({marker.read_text(encoding='utf-8')})")
sys.exit(0)
do_work()
# 3) 일이 성공한 뒤에만 완료 표시. 임시 파일에 쓰고 이름을 바꿔 반쯤 쓴 표시가 남지 않게 함
DONE.mkdir(exist_ok=True)
tmp = marker.with_suffix(".tmp")
tmp.write_text(f"pid {os.getpid()}", encoding="utf-8")
os.replace(tmp, marker)
print(f"pid {os.getpid()}: {key} 처리 완료")
순서가 중요합니다. 완료 표시는 잠금을 잡은 뒤에 확인해야 합니다. 잠금 전에 확인하면 방법 1과 똑같은 틈이 생깁니다. 그리고 표시는 일이 끝난 뒤에만 씁니다. 임시 파일에 쓰고 os.replace로 이름을 바꾸는 건, 쓰다 죽어서 반쯤 쓴 표시가 남는 일을 막기 위해서입니다. 실제로 돌린 결과입니다.
$ python race.py job_final.py 5 --sync
pid 26652: 2026-10-09 처리 완료
job_final.py: 5개 실행 -> 발행 1건, 소요 3.01초, 종료 코드 [0, 0, 0, 0, 0]
$ python race.py job_final.py 5 --sync (한 번 더)
pid 7896: 2026-10-09 몫은 이미 처리됨 (pid 26652)
job_final.py: 5개 실행 -> 발행 0건, 소요 2.01초, 종료 코드 [0, 0, 0, 0, 0]
$ python crash_test.py job_final.py (완료 표시를 지우고, 작업 도중 강제 종료)
1차 실행 강제 종료 (종료 코드 1), 잠금 파일 남아 있음: True
2차 실행 출력: pid 13676: 2026-10-09 처리 완료 / 종료 코드 0, 1.05초
동시 출발 5개에서 1건, 다시 실행하면 0건(잠금을 잡은 프로세스가 “이미 처리됨”을 보고 끝남)입니다. 작업 도중 강제 종료한 경우는 완료 표시가 없으니 다음 실행이 이어서 처리했습니다. “중간에 죽으면 다시 한다, 끝났으면 다시 안 한다”가 둘 다 지켜집니다.
이 블로그 발행 자동화는 이렇게 막고 있습니다
이 블로그는 매일 정해진 시각에 글 한 편을 다음 날 아침으로 예약 발행하는 작업이 돕니다. 10월 7일 로그를 보면 이 작업이 하루에 다섯 번 시작됐습니다(그중 세 번은 점검용 DryRun). 실제로 돈 두 번(13:43:08, 14:14:52)은 둘 다 “이미 예약된 글이 1편 있다”를 확인하고 새 글 없이 종료 코드 0으로 끝났습니다.
여기서 완료 표시는 로컬 파일이 아니라 사이트에 결과물이 실제로 있는지입니다. 발행 전에 워드프레스 API로 예약 대기 글이 있는지 먼저 봅니다. 예전에 손으로 갱신하는 캘린더 파일만 믿었다가 이미 있는 글을 한 번 더 발행해 휴지통으로 옮긴 적이 있어서 이렇게 바꿨습니다. 로컬 표시는 빠르고 간단하지만 결과물과 어긋날 수 있습니다. 확인할 수 있는 결과물이 있다면 그걸 직접 보는 쪽이 더 확실합니다.
주의: 같은 파일에 동시에 덧붙이면 줄이 사라진다
측정하다 만난 함정도 하나 적어둡니다. 처음에는 결과를 published.txt 한 파일에 open("a")로 한 줄씩 덧붙여 셌는데, 아무 잠금도 없는 버전을 10개 동시에 띄워도 줄이 10개가 안 나왔습니다.
append_naive.py: 10개 동시 실행 -> 발행 5건, 소요 3.02초, 종료 코드 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
append_naive.py: 10개 동시 실행 -> 발행 4건, 소요 3.02초, 종료 코드 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
append_naive.py: 10개 동시 실행 -> 발행 4건, 소요 3.01초, 종료 코드 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
종료 코드는 10개 모두 0, 즉 전부 “썼다”고 끝났는데 남은 줄은 4~5개였습니다. 한 번은 한글이 중간에 잘린 바이트가 섞여 UnicodeDecodeError까지 났습니다. 윈도우에서 여러 프로세스가 한 파일에 동시에 덧붙이면 서로 덮어쓴다는 뜻입니다. 여러 실행이 같은 로그 파일에 쓰는 구조라면, 이것도 잠금으로 막거나 실행마다 파일을 나누는 게 안전합니다. 덕분에 측정 방식도 실행마다 파일 하나를 남기는 쪽으로 바꿨습니다.
또 하나, 자식 프로세스 출력이 처음엔 한글이 전부 깨져서 나왔습니다. 측정기에만 sys.stdout.reconfigure(encoding="utf-8")를 넣고 자식 스크립트에는 안 넣었기 때문입니다. 자식의 오류 메시지까지 UTF-8로 받으려면 환경변수 PYTHONIOENCODING=utf-8을 넘기면 됩니다.
정리
- “있는지 확인 후 생성”은 동시에 실행되면 뚫립니다. 실측 20회 중 12회.
O_CREAT | O_EXCL은 동시 실행을 막지만, 강제 종료되면 잠금 파일이 남아 이후 실행이 조용히 멈춥니다.- 운영체제 잠금(
msvcrt.locking/fcntl.flock)은 프로세스가 죽으면 자동으로 풀립니다. - 잠금은 “끝난 뒤 또 한 번”을 못 막습니다. 처리 단위(날짜 등)별 완료 표시를 잠금 안에서 확인하고, 성공한 뒤에만 남기세요.
- 확인할 수 있는 결과물(발행된 글, 보낸 메일 기록)이 있다면 로컬 표시보다 그걸 직접 확인하는 게 더 확실합니다.
가장 작은 첫걸음은 지금 매일 도는 스크립트 하나에 위 job_final.py의 잠금·완료 표시 부분만 옮겨 붙이는 것입니다. 30줄 남짓이고 표준 라이브러리만 씁니다.
다음 글에서는 자동화가 남긴 로그와 결과물이 쌓여 디스크를 잡아먹을 때, 어느 폴더가 용량을 차지하는지 찾아내는 스크립트를 다루겠습니다.