파일 핸들과 동시 쓰기
110분 안팎
학습 목표
제공된 데이터 쓰기 테스트에서 경쟁 상태를 재현하고 잠금·파일 닫기로 결과를 일치시킵니다.
개념
갱신 유실은 조용한 장애입니다
응답 200이 계속 나와도 데이터가 잘못될 수 있습니다. 두 요청이 같은 count를 읽고 각각 한 번 증가해 저장하면 두 번 처리했는데 한 번만 증가한 결과가 생깁니다. 파일을 여는 데 성공했거나 예외가 없다는 사실은 공유 자료의 정확성을 보장하지 않습니다. 이 레슨은 읽기-수정-쓰기의 범위를 잠금으로 묶고 반복 작업 뒤 열린 핸들이 기준으로 돌아오는지 확인합니다. 운영자는 응답 성공과 상태 변화 성공을 나누어 볼 수 있어야 합니다.
핸들은 경로와 다릅니다
파일 디스크립터는 프로세스가 열린 파일·소켓·파이프 등을 참조하는 번호입니다. 이름을 가진 파일 개수와 열린 핸들 개수는 같지 않습니다. 같은 파일을 여러 번 열면 여러 핸들이 생길 수 있고 HTTP 소켓도 자원에 포함됩니다. Linux /proc/PID/fd에서 번호와 대상을 관찰하되 권한 제한 때문에 보이지 않는 경우를 핸들이 없다고 기록하지 않습니다. 프로세스가 종료된 뒤의 PID 조회 역시 실행 중 관측과 구분합니다.
닫기를 코드 구조에 넣습니다
open 이후 반환이나 예외가 생기는 모든 경로에서 파일을 닫아야 합니다. Python의 with 블록을 사용하면 정상 완료와 예외 처리 시 컨텍스트 종료에서 닫기가 수행됩니다. 쓰기를 마친 뒤 close한다는 한 줄을 끝에 붙이는 방식은 중간 예외가 생겼을 때 누락될 수 있습니다. 이번 Store의 read와 increment는 각각 파일을 여는 범위를 with로 제한합니다. 파일을 닫는 일과 데이터를 저장장치에 영속화하는 보장은 별개라 전원 장애 내구성까지 주장하지 않습니다.
경쟁 순서를 먼저 그립니다
초기 count 0을 A가 읽고 B도 0을 읽은 뒤 A와 B가 각각 1을 저장하면 결과는 1입니다. 이 순서는 코드가 매우 빨라도 허용될 수 있습니다. 잠깐 sleep을 넣어 재현 빈도를 바꾸는 것은 올바른 보호 규칙이 아닙니다. 먼저 어떤 상태가 공유되는지, 한 갱신의 시작과 끝이 어디인지 정합니다. 파일 쓰기 한 줄만 잠그면 이전 읽기가 같은 값을 가져오는 경쟁은 남을 수 있습니다.
한 잠금을 공유합니다
Store는 인스턴스 생성 때 threading.Lock 하나를 만들고 read와 increment에서 같은 잠금을 사용합니다. increment는 기존 JSON 읽기·count 증가·새 JSON 쓰기를 하나의 임계 구역으로 보호합니다. 읽기도 같은 잠금을 사용해 쓰는 도중의 JSON을 관찰하지 않도록 합니다. 매 호출마다 새로운 Lock을 만들면 서로 다른 잠금을 얻은 작업이 동시에 진입할 수 있으므로 보호가 되지 않습니다. 어떤 함수가 같은 Store를 공유하는지도 함께 확인합니다.
스레드와 프로세스의 경계
threading.Lock은 이 프로세스에서 공유하는 스레드 간 규칙입니다. 앱을 두 번 띄워 같은 파일을 쓰게 하면 각각 다른 메모리의 잠금을 사용하므로 이 구현은 안전하지 않습니다. 다음 단계에서 다중 인스턴스가 필요하면 파일 잠금이나 데이터베이스 트랜잭션 등 별도의 설계를 선택해야 합니다. 이번 앱은 단일 프로세스이며 동시에 처리하는 요청은 그 내부 스레드 범위로 제한합니다. 잠금 추가만으로 모든 종류의 동시 쓰기를 해결했다고 설명하지 않습니다.
진행 가능성도 확인합니다
잠금으로 값이 맞아도 작업이 끝나지 않으면 서비스가 쓸 수 없습니다. 이번 Store는 잠금 하나만 사용하고 보호 중 다른 Store 메서드를 다시 호출하지 않습니다. 일반 Lock을 가진 상태에서 같은 잠금을 다시 얻으려는 호출은 진행을 막을 수 있습니다. 읽기 함수를 재사용하고 싶다면 잠금 획득 위치와 내부 함수를 나누어 구조를 검토합니다. 파일 작업 예외가 생겨도 with lock 컨텍스트가 잠금을 풀어 다음 작업의 진행을 돕습니다.
핸들 기준을 비교합니다
검사는 작업 전 디스크립터 수를 세고 80회 증가와 200회 읽기를 실행한 뒤 다시 셉니다. 각 갱신의 count가 누적 80인지 확인하며 마지막 핸들 수는 시작 값보다 2개를 넘게 늘지 않도록 허용 범위를 둡니다. 검사 도구가 잠깐 여는 디렉터리 핸들 등 잡음을 고려한 범위입니다. 이것은 모든 장기 누수를 증명하는 검사가 아니라 반복 읽기의 닫기 누락을 찾는 작은 회귀 검사입니다. 소켓과 스레드도 종료된 뒤 비교합니다.
재현을 결과와 분리합니다
경쟁 재현은 실행 순서가 달라 표본마다 결과가 같지 않을 수 있습니다. 따라하기에서는 두 작업의 읽기를 Barrier로 맞추어 갱신 유실을 분명하게 보여 줍니다. 실제 수정 검사는 ThreadPoolExecutor로 여러 작업을 보내 최종 값을 대조합니다. 한번 통과했다고 모든 스케줄에 대한 증명이 되는 것은 아니므로 잠금 범위를 코드로도 읽습니다. starter는 증가량이 잘못돼 테스트가 확실히 실패하며 학습자가 증가와 임계 구역을 함께 완성합니다.
오류의 의미
JSONDecodeError가 동시 요청에서만 나타나면 잘못된 입력뿐 아니라 쓰는 도중의 읽기를 의심합니다. Too many open files는 파일만이 아니라 소켓·파이프 누수와 한도도 조사해야 합니다. 한도를 올리기 전에 반복 요청 후 핸들이 회수되는지 봅니다. PermissionError는 잠금 오류와 다른 계정·경로 문제입니다. 데이터 파일을 지우고 새로 만드는 우회는 이전 모듈의 증거를 훼손할 수 있으므로 임시 테스트 경로에서 먼저 원인을 좁힙니다.
미션으로 연결합니다
완성한 Store를 작은 웹 앱에 포함하고 이전 권한을 유지합니다. HTTP 경로는 데이터를 읽는 기능을 확인하고 동시 증가 기능은 자동 테스트로 검사합니다. 웹 API에 임의의 갱신 경로를 추가하는 과제는 아닙니다. 실측 열린 파일·자원 기록과 자동 검사 결과를 processes.md에 구분해 적습니다. 다음 모듈은 이 단일 프로세스 앱을 systemd로 관리하므로 코드와 데이터 경로, 실행 계정 계약을 그대로 인계합니다.
명령·API 의미의 기준은 공식 문서에서 확인합니다. 상세 원리는 더 읽기의 서재 장으로 연결합니다.
따라하기
갱신 유실 재현
두 작업이 같은 초기 값을 읽도록 맞춥니다. 쓰기만 잠가서는 갱신 하나가 유실됩니다.
import threading
state={'count':0}
b=threading.Barrier(2)
lock=threading.Lock()
def update():
old=state['count']; b.wait()
with lock: state['count']=old+1
ts=[threading.Thread(target=update) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print('count='+str(state['count']))실행 결과
count=1
닫기 확인 연습
컨텍스트 종료 후 파일이 닫혔는지 실행해 확인합니다.
import tempfile
with tempfile.TemporaryFile(mode='w+') as f:
f.write('lab-web')
print('closed='+str(f.closed))실행 결과
closed=True
수정 검사
app.py를 수정하고 80회 증가·반복 읽기·빈 데이터·없는 파일 테스트를 실행합니다.
bash check.shVM 핸들 관측
PID는 VM에서 실행 중인 앱의 실제 번호로 바꿉니다. 앱은 30초 후 자연 종료하며 읽기 파일 핸들은 짧게 열려 관측에서 보이지 않을 수 있습니다.
sudo ls -l /proc/PID/fd확인 문제
실습
Store.increment를 올바르게 구현하고 읽기-수정-쓰기를 같은 Lock으로 보호합니다. 80회 갱신과 반복 읽기 후 핸들 회수를 확인합니다.
실행 명령
bash check.sh
기대 결과
PASS store: 80 updates, handle baseline, empty and missing
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 프로세스와 systemd 서비스 상태의 차이를 설명합니다.