파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스
· 업데이트: · 小村 豪 · 파일 연계, 배타 제어, 설계, Windows 개발
수정 이력(1건, 최종 수정 2026년 09월 01일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635091)
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635090)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
小村 豪 (2026). 「파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635090 https://comcomponent.com/ko/blog/2026/03/07/001-file-integration-locking-best-practices-komurasoft-style/
- DOI(최신 버전)
- 10.5281/zenodo.21635090
- DOI(이 버전)
- 10.5281/zenodo.22217409
파일 연계의 배타 제어는 공유 폴더나 야간 배치, 다른 프로세스 연계에서 거의 반드시 문제가 됩니다. 특히 검색에서 많은 것은 파일 락만으로 충분한가, 여러 워커가 같은 파일을 잡지 않는 방법은 무엇인가, 도중에 쓰기 중인 파일을 어떻게 피할 것인가 같은 고민입니다.
이 글에서는 파일 락, 원자적 claim, temp -> rename, idempotency를 축으로 파일 연계의 배타 제어를 살펴봅니다.
용어를 먼저 맞추기
이 분야는 영어 그대로 정착한 말이 많아, 의미가 흐릿한 채로 두면 읽기 어려워집니다. 먼저 이 글에서의 의미를 고정해 둡니다.
| 용어 | 이 글에서의 의미 |
|---|---|
| 원자적(atomic) | 도중 상태가 다른 곳에서 보이지 않는 조작을 말합니다. 성공했거나, 아무 일도 일어나지 않았거나 둘 중 하나로만 끝납니다 |
| claim | 「이 파일은 자신이 처리한다」고 처리권을 확보하는 것입니다. 이 글에서는 주로 incoming에서 processing/<worker>/로 rename에 성공한 쪽만 소유자가 되는 형태를 가리킵니다 |
| 원자적 claim | 그 claim을 1번의 조작으로 하는 것입니다. 확인과 확보가 나뉘어 있으면 그 틈에 다른 프로세스가 끼어들 수 있습니다(3.1) |
| lease | 유효 기한이 붙은 소유권입니다. lock file에 「누가」「언제까지」를 적어 두고, 기한이 끝나면 다른 워커가 넘겨받을 수 있게 합니다(4.4) |
| stale | 소유자가 비정상 종료했는데도 남아 있는 lock이나 claim의 상태입니다. 살아 있는지 죽어 있는지 판정할 수 없으면 모두가 멈춥니다(2.3) |
| manifest | 본체 파일과는 별도로 두는, 내용을 설명하는 파일입니다. 파일명, 크기, 해시, 레코드 수 등을 적어 두고 수신 측의 검증에 씁니다. done 파일은 그 최소판입니다(4.2) |
| idempotency(멱등성) | 같은 입력을 다시 한번 처리해도 결과가 바뀌지 않는 성질입니다(4.5) |
| advisory lock | 참가자 전원이 그 약속을 지킨다는 전제에서만 효과가 있는 락입니다. OS가 강제하지 않으므로, 무시하고 읽고 쓰는 프로그램도 얼마든지 만들 수 있습니다. Linux의 flock이 이 유형입니다 |
| byte-range lock | 파일 전체가 아니라 지정한 범위만을 대상으로 하는 락입니다. Windows의 LockFileEx가 대표적이며, 이쪽은 OS가 강제합니다. 다만 예외가 있습니다(3.5) |
이 글의 지식 맵
이 기사는 공유 폴더나 야간 배치의 파일 연계를 OS 락에만 맡기지 않고 받아넘기기 프로토콜로 설계하는 사고방식을 정리합니다. 읽기 전에 처리권을 원자적 claim으로 확보하고, 생성 중인 파일은 temp 이름에 가두었다가 close한 뒤 final 이름으로 rename하여 공개하며, 완료는 크기나 타임스탬프로 추측하지 않고 done·manifest로 명시합니다. lock file을 쓴다면 ownerId나 expiresAt을 가진 lease 형식으로 하여 stale lock에 대비하고, Windows의 byte-range lock이 메모리 매핑 파일에서는 무시된다는 점과 advisory lock이 약속을 무시하는 상대에게는 효과가 없다는 점을 감안한 뒤, 마지막은 같은 입력을 다시 처리해도 망가지지 않는 idempotency로 받아내는 설계가 실무에서는 강력하다고 결론짓습니다.
flowchart LR
accTitle: 파일 연계의 배타 제어 지식 맵
accDescr: 받아넘기기 프로토콜이 원자적 claim, temp -> rename 공개, done/manifest, lease 방식의 lock file, idempotency를 어떻게 조합하고, 이중 처리나 쓰기 도중 읽기 같은 사고를 어떤 안티패턴에 대응시켜 막는지 보여주는 그림
file_handoff_protocol["받아넘기기 프로토콜"]
atomic_claim["원자적 claim"]
temp_then_rename_publish["temp -> close -> rename/replace로 공개"]
done_manifest_file["done/manifest 파일"]
lock_file_lease["lease 방식의 lock file"]
idempotent_processing["idempotency(멱등성)를 전제로 한 처리"]
os_file_lock["OS 파일 락(OS 락)"]
atomic_creation["원자적 작성(CreateNew / O_CREAT|O_EXCL)"]
duplicate_processing["이중 처리(이중 계상·이중 전송·업데이트 유실)"]
exists_then_create_antipattern["Exists -> Create의 2단계 체크"]
direct_final_write_antipattern["최종 파일 이름에 직접 쓰기"]
partial_write_read["쓰기 중인 파일의 읽기 사고"]
size_stability_completion_check["파일 크기 안정 대기에 의한 완료 판정"]
shared_file_mutual_update_antipattern["공유 파일 상호 갱신 안티패턴"]
stale_lock["stale lock"]
byte_range_lock["byte-range lock(범위 락)"]
heterogeneous_system_integration["이종 시스템 연계"]
advisory_lock["advisory lock"]
cross_volume_rename_fallback["크로스 볼륨 rename 폴백(복사+삭제)"]
smb_share_file_handoff["공유 폴더(SMB) 너머의 파일 연계"]
rename_fails_on_open_handle["rename은 열려 있는 것만으로 실패"]
file_timestamp_unreliability["파일 타임스탬프의 불확실성"]
periodic_directory_listing["주기적인 디렉터리 열거"]
change_notification_loss["변경 알림 누락"]
file_handoff_protocol -->|"이용한다"| atomic_claim
file_handoff_protocol -->|"이용한다"| temp_then_rename_publish
file_handoff_protocol -->|"이용한다"| done_manifest_file
file_handoff_protocol -->|"이용한다"| lock_file_lease
file_handoff_protocol -->|"이용한다"| idempotent_processing
file_handoff_protocol -.->|"이용한다"| os_file_lock
atomic_claim -.->|"이용한다"| atomic_creation
atomic_claim -->|"방지한다"| duplicate_processing
exists_then_create_antipattern -->|"원인이 될 수 있다"| duplicate_processing
atomic_claim -->|"권장되는 대응"| exists_then_create_antipattern
atomic_creation -->|"권장되는 대응"| exists_then_create_antipattern
direct_final_write_antipattern -->|"원인이 될 수 있다"| partial_write_read
temp_then_rename_publish -->|"방지한다"| partial_write_read
size_stability_completion_check -.->|"원인이 될 수 있다"| partial_write_read
done_manifest_file -->|"권장되는 대응"| size_stability_completion_check
shared_file_mutual_update_antipattern -->|"원인이 될 수 있다"| duplicate_processing
idempotent_processing -->|"권장되는 대응"| duplicate_processing
lock_file_lease -->|"권장되는 대응"| stale_lock
lock_file_lease -->|"전제로 한다"| atomic_creation
os_file_lock -->|"이용한다"| byte_range_lock
os_file_lock -.->|"완화한다"| duplicate_processing
os_file_lock -->|"사용은 비권장"| heterogeneous_system_integration
advisory_lock -.->|"사용은 비권장"| heterogeneous_system_integration
temp_then_rename_publish -.->|"양립하지 않는다"| cross_volume_rename_fallback
smb_share_file_handoff -.->|"원인이 될 수 있다"| cross_volume_rename_fallback
smb_share_file_handoff -.->|"원인이 될 수 있다"| rename_fails_on_open_handle
done_manifest_file -->|"권장되는 대응"| file_timestamp_unreliability
periodic_directory_listing -->|"권장되는 대응"| change_notification_loss
smb_share_file_handoff -.->|"원인이 될 수 있다"| change_notification_loss
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 29건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
목차
- 먼저 결론(한마디로)
- 파일 연계에서 일어나는 경합 패턴(그림)
- 2.1. 쓰기 도중인 파일을 읽어 버린다
- 2.2. 여러 워커가 같은 파일을 동시에 잡는다
- 2.3. stale lock으로 전원이 멈춘다
- 안티패턴
- 3.1.
Exists -> Create의 2단계 체크 - 3.2. 최종 파일 이름에 직접 쓴다
- 3.3. 파일 크기가 멈추면 완료 취급
- 3.4. 공유 파일을 모두가 갱신한다
- 3.5. 락 API를 만능이라고 생각한다
- 3.1.
- 베스트 프랙티스
- 4.1.
temp -> close -> rename / replace로 공개한다 - 4.2.
done/ manifest로 완전성을 명시한다 - 4.3. 수신 측은 claim을 원자적으로 취한다
- 4.4. lock file에 의지한다면 lease로 한다
- 4.5. idempotency를 전제로 한다
- 4.1.
- 의사 코드(발췌)
- 대략적인 구분 사용
- 정리
- 참고 자료
파일 연계는 코드 자체보다 「받아넘기기의 약속」쪽이 깨지기 쉬운 분야입니다. 단위 시험에서는 통과하는데 실제 운영의 공유 폴더나 야간 배치에서만 가끔 깨진다. 게다가 재현하기 어렵다. 흔히 있는 일입니다.
원인의 대부분은 파일 I/O API 자체보다 다음 세 가지가 애매한 데 있습니다.
- 언제 읽어도 되는가
- 누가 처리권을 갖는가
- 실패했을 때 어떻게 복구하는가
이 글에서는 파일 연계의 배타 제어를 OS 락 이야기로만 끝내지 않고, 받아넘기기 프로토콜로 정리합니다.
또한 이 글에 등장하는 코드는 빌드·실행할 수 있는 샘플 일체(라이브러리, 2개 워커의 claim 경합이나 lease 인수를 시연하는 데모, 경합·손상·stale lock을 재현하는 유닛 테스트)로 GitHub에 공개하고 있습니다.
file-integration-locking-best-practices-komurasoft-style - komurasoft-blog-samples (GitHub)
1. 먼저 결론(한마디로)
- 파일 연계에서 가장 중요한 것은 최종 파일 이름이 보인 시점에 「이미 읽어도 되는」 상태를 만드는 것
- 생성 중 / 공개 완료 / 처리 중 / 처리 완료를 파일 이름이나 디렉토리로 나누어 표현할 것
- 여러 워커가 있다면 읽기 전에 claim을 원자적으로 취할 것
- lock file이나 OS 락은 보조로 쓰고, 마지막은 idempotency로 받아낼 것
요컨대 파일 연계에서는 배타 제어라기보다 받아넘기기 프로토콜의 설계가 본체입니다. 락 함수를 하나 호출하면 끝, 이 아닙니다.
2. 파일 연계에서 일어나는 경합 패턴(그림)
2.1. 쓰기 도중인 파일을 읽어 버린다
최종 파일 이름에 직접 쓰기 시작하면 이 사고가 일어납니다. JSON이라면 닫는 괄호가 없고, CSV라면 줄 수가 부족하고, ZIP이라면 평범하게 망가집니다.
sequenceDiagram
participant 送信 as 송신 측
participant 共有 as 공유 폴더
participant 受信 as 수신 측
送信->>共有: orders.csv를 최종명으로 생성
送信->>共有: 1행째~5000행째를 쓰는 중
受信->>共有: orders.csv를 검지
受信->>共有: 그대로 읽기 시작
Note over 受信: 아직 도중
送信->>共有: 나머지를 쓴다
Note over 受信: 행 수 부족 / 해석 실패 / 일부만 처리
2.2. 여러 워커가 같은 파일을 동시에 잡는다
「목록을 보고 미처리라면 연다」는 흐름이라면, 같은 파일을 2개의 워커가 잡을 수 있습니다. 이중 계상이나 이중 송신의 시작입니다.
sequenceDiagram
participant W1 as 워커1
participant W2 as 워커2
participant Dir as incoming
W1->>Dir: a.csv를 발견
W2->>Dir: a.csv를 발견
W1->>Dir: 읽기 시작
W2->>Dir: 읽기 시작
Note over W1,W2: 같은 입력을 이중 처리
2.3. stale lock으로 전원이 멈춘다
lock file을 두기만 하는 설계는 이상 종료 시에 막히기 쉽습니다. 누구의 lock인지, 아직 살아 있는지, 언제까지 유효한지를 알 수 없으면 후속이 영원히 기다리게 됩니다.
sequenceDiagram
participant A as 워커A
participant Lock as lock 파일
participant B as 워커B
A->>Lock: lock을 작성
Note over A: 여기서 이상 종료
B->>Lock: lock의 존재를 확인
B->>Lock: 처리 개시를 보류
B->>Lock: 더 기다린다
Note over B,Lock: stale인지 판정할 수 없어 전원 정지
3. 안티패턴
3.1. Exists -> Create의 2단계 체크
이것은 「확인」과 「확보」가 별도 조작이 되어 있는 것이 문제입니다. 사이에 다른 프로세스가 끼어들 수 있으므로 배타가 되지 않습니다.
sequenceDiagram
participant A as 프로세스A
participant B as 프로세스B
participant FS as 파일 시스템
A->>FS: lock이 없는지 확인
B->>FS: lock이 없는지 확인
FS-->>A: 없음
FS-->>B: 없음
A->>FS: lock을 작성
B->>FS: lock을 작성
Note over A,B: 양쪽이 진행되어 버린다
전형적인 나쁜 예는 이런 형태입니다.
if (!File.Exists(lockPath))
{
File.WriteAllText(lockPath, Environment.ProcessId.ToString());
ProcessFile();
}
필요한 것은 「없으면 만든다」를 1조작으로 하는 것입니다.
.NET이라면 FileMode.CreateNew 계, POSIX 계라면 O_CREAT | O_EXCL 같은 원자적 작성을 씁니다.
3.2. 최종 파일 이름에 직접 쓴다
수신 측이 「그 이름이 보이면 읽어도 된다」고 해석하고 있다면, 최종 파일 이름에 직접 쓰기 시작한 시점에 지는 것입니다. 보이는 것과 읽어도 되는 것을 같게 두지 않는 것이 기본입니다.
flowchart LR
A[final 이름이 보인다] --> B[수신 측이 검지]
B --> C[송신 측은 아직 쓰는 중]
C --> D[불완전한 데이터를 읽는다]
using var writer = OpenForWrite(finalPath); // 여기서 finalPath가 보여 버린다
foreach (var row in rows)
{
writer.WriteLine(row);
}
이 방식은 2.1의 사고를 스스로 불러들입니다.
3.3. 파일 크기가 멈추면 완료 취급
이것은 편리해 보이지만 꽤 위험합니다. 네트워크 너머의 복사, 송신 측의 일시 정지, 버퍼링, 리트라이로 평범하게 흔들립니다.
sequenceDiagram
participant 送信 as 송신 측
participant 共有 as 공유 폴더
participant 受信 as 수신 측
送信->>共有: data.zip을 복사 개시
送信->>共有: 도중에 일시 정지
受信->>共有: 크기가 10초 변하지 않음
Note over 受信: 완료로 오판정
受信->>共有: 읽기 시작
送信->>共有: 복사 재개
if (currentLength == lastLength && stableSeconds >= 10)
{
return Ready;
}
완료를 추측으로 정하면, 공유 폴더나 큰 파일에서 발목을 잡힙니다. 완료는 manifest나 done file로 명시하는 편이 안정됩니다.
3.4. 공유 파일을 모두가 갱신한다
하나의 status.csv나 counter.json을 모두가 읽고 갱신하는 설계는 대체로 마지막에 쓴 사람이 이깁니다.
파일 연계를 간이 DB로 쓰기 시작하면, 여기서 고생하게 됩니다.
sequenceDiagram
participant A as 배치A
participant B as 배치B
participant F as status.csv
A->>F: v1을 읽음
B->>F: v1을 읽음
A->>F: v2-A를 씀
B->>F: v2-B를 씀
Note over F: A의 갱신이 사라진다
append-only로 우회하는 안도 있지만, 파일 시스템이나 배치 형태로 의미가 흔들립니다. 공유 갱신이 필요하다면 여기는 파일 연계에서 무리하지 않는 편이 좋습니다.
3.5. 락 API를 만능이라고 생각한다
락 API는 중요하지만, 전 참가자가 같은 약속으로 움직일 때만 효과가 있습니다. 이종 시스템 연계에서는 여기를 과신하지 않는 편이 안전합니다.
보충:
- Linux의
flock은 advisory lock이므로 약속을 무시하는 상대는 평범하게 쓸 수 있습니다 - Windows의 byte-range lock은 메모리 매핑 파일에서는 무시됩니다
- 즉, OS 락 단체로 완료 통지나 소유권의 설계까지 짊어지게 하지 않는 편이 좋습니다
두 번째는 Windows의 사양으로 명기되어 있습니다. Microsoft Learn의 Locking and Unlocking Byte Ranges in Files에는 다른 프로세스가 락이 걸린 범위에 접근하면 반드시 실패한다(즉 Windows의 범위 락은 advisory가 아니라 강제된다)고 적힌 직후에, 메모리 매핑 파일을 사용하는 경우에는 byte-range lock이 무시된다는 주의가 놓여 있습니다. 상대가 CreateFileMapping을 거쳐 같은 파일을 다루고 있다면, 이쪽의 락은 그냥 통과된다는 뜻입니다.
.NET에서 범위 락을 취한다면 FileStream.Lock / Unlock입니다(Windows의 경우).
using var stream = new FileStream(
path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
// 맨 앞 1바이트만을 「처리 중」 팻말로 배타 락을 건다
stream.Lock(0, 1);
try
{
// 여기서 본체의 읽기/쓰기를 한다
}
finally
{
// 닫기 전에 반드시 해제한다
stream.Unlock(0, 1);
}
이 형태는 같은 약속으로 움직이는 앱끼리라면 유효합니다. 다만 앞서 말했듯 상대가 메모리 매핑 경유라면 효과가 없고, 애초에 다른 시스템이 이 팻말을 봐 준다는 보장도 없습니다. 그래서 4장의 받아넘기기 프로토콜 쪽이 본체가 됩니다.
4. 베스트 프랙티스
먼저 3장의 안티패턴과의 대응을 나란히 정리해 둡니다. 어느 하나를 밟고 있다는 자각이 있다면, 대응하는 절부터 읽어도 통합니다.
| 안티패턴 | 무슨 일이 일어나는가 | 대응하는 대책 |
|---|---|---|
3.1. Exists -> Create의 2단계 체크 |
확인과 확보 사이에 끼어들어 2개의 프로세스가 동시에 진행된다 | 4.3 claim을 원자적으로 취한다(rename이나 FileMode.CreateNew) |
| 3.2. 최종 파일 이름에 직접 쓴다 | 쓰기 도중인 파일을 수신 측이 읽는다 | 4.1 temp -> close -> rename / replace로 공개한다 |
| 3.3. 파일 크기가 멈추면 완료 취급 | 복사의 일시 정지를 완료로 오판정한다 | 4.2 done / manifest로 완료를 명시한다 |
| 3.4. 공유 파일을 모두가 갱신한다 | 나중에 쓴 쪽에 덮어써져 갱신이 사라진다 | 4.3에서 쓰는 쪽을 하나로 좁히고, 4.5에서 이중 처리를 흡수한다. 그래도 부족하면 6장의 철수 판단 |
| 3.5. 락 API를 만능이라고 생각한다 | 약속을 지키지 않는 상대나 메모리 매핑 경유로 뚫린다 | 4.4 lease로서의 lock file과 4.5 idempotency로 받아낸다 |
4.1. temp -> close -> rename / replace로 공개한다
왕도입니다. 생성 중인 파일은 temp 이름에 가두고, close한 뒤에 final 이름으로 전환합니다. 수신 측은 final 이름만 보도록 합니다.
flowchart LR
A[고유한 temp 이름을 만든다] --> B[temp에 전 내용을 쓴다]
B --> C[flush / close한다]
C --> D[같은 디렉토리에서 final 이름으로 rename / replace]
D --> E[수신 측은 final 이름만을 감시]
포인트:
- temp와 final은 같은 디렉토리, 적어도 같은 볼륨 / 파일 시스템에 둔다
- Windows / .NET이라면
File.Replace계를 검토할 수 있다 - final 이름이 보인 시점에 내용은 완성 완료라는 약속으로 한다
temp를 다른 드라이브에 두면 rename이 단순한 복사 상당이 되거나 Replace가 실패하거나 합니다.
이 전제는 수수하지만 매우 중요합니다.
공유 폴더(SMB) 너머에서는 다음 4가지가 더 흔들립니다. 이 글의 주된 무대가 바로 여기이므로 따로 적어 둡니다.
- 같은 공유의 같은 디렉토리 안에서의 rename은 서버 측에서 실행됩니다. 그래서 「도중의 이름이 보이지 않는다」는 성질 자체는 유지됩니다. 반대로
\\server\shareA에서\\server\shareB처럼 공유를 넘어가면 별도의 볼륨으로 취급되어, Windows의MoveFileEx는MOVEFILE_COPY_ALLOWED를 지정한 경우 복사와 삭제로 이동을 대신합니다. 즉 원자적이지 않게 되어 도중 상태가 보입니다. temp와 final,incoming과processing을 같은 공유 안에 두는 전제는 로컬일 때 이상으로 중요합니다 - rename은 「누군가가 열어 두고 있는 것만으로」 실패합니다. 공유 폴더에는 백신, 검색 인덱서, 다른 거점의 클라이언트 등 이쪽이 파악하지 못한 상대가 접근해 옵니다. publish와 claim의 rename은 실패를 이상이 아니라 통상적인 분기로 다루고, 짧은 대기를 두고 재시도하는 형태로 해 두는 것이 현실적입니다
- 타임스탬프는 판단 재료가 되지 않습니다. Microsoft Learn의 File Times에는 파일 시각에 대해 보증되는 것은 「변경을 수행한 핸들을 닫은 시점에 올바르게 반영되는 것」뿐이라고 적혀 있습니다. 쓰기 중의 마지막 수정 시각은 쓰기용 핸들이 모두 닫힐 때까지 완전하게는 갱신되지 않습니다. 정밀도도 파일 시스템에 따라 다르며, FAT의 마지막 수정 시각은 2초 단위, NTFS의 마지막 접근 시각은 최대 1시간 늦게 갱신됩니다. 게다가 SMB 너머에서는 서버 측의 시계로 시각이 붙기 때문에, 클라이언트와 서버의 시계가 어긋나 있으면 「갱신에서 N분 지나면 처리한다」는 판정도 그대로 어긋납니다. 완료 판정을 시각이나 크기로 하지 않고 4.2의
done/ manifest로 하는 이유가 여기에 있습니다 - 변경 통지도 놓칩니다. 공유 폴더 감시를 이벤트 통지만으로 의지하지 말고, 정기적인 디렉토리 열거와 병용해 두면 안정됩니다. 이 이야기는 FileSystemWatcher 실무 가이드 - 놓침과 중복 대책에 정리되어 있습니다
4.2. done / manifest로 완전성을 명시한다
데이터 본체뿐만 아니라 「무엇이 완성됐는지」를 별도 파일로 명시하면 수신 측이 안정됩니다. 특히 이종 시스템 연계에서는 유효합니다.
flowchart TD
A[data.tmp를 생성] --> B[data.csv로 공개]
B --> C[data.done / manifest.json을 작성]
C --> D[수신 측이 done / manifest를 검지]
D --> E[파일명·크기·해시를 검증]
manifest에 넣어 두면 좋은 항목은 예를 들어 다음과 같은 것입니다.
- 대상 파일명
- 크기
- 해시
- 레코드 수
- 연계 ID / idempotency key
- 생성 시각
순서도 중요합니다.
본체의 공개보다 먼저 done을 두면, 그것은 완료 통지가 아니라 사고 예고가 됩니다.
4.3. 수신 측은 claim을 원자적으로 취한다
여러 워커가 같은 incoming을 본다면 「읽기 전에 자기 것으로 옮긴다」가 이해하기 쉽습니다.
incoming에서 processing/<worker>/로의 rename이 성공한 워커만이 처리합니다.
sequenceDiagram
participant W1 as 워커1
participant W2 as 워커2
participant IN as incoming
participant PR as processing
W1->>IN: a.csv를 발견
W2->>IN: a.csv를 발견
W1->>PR: a.csv를 rename
W2->>PR: a.csv를 rename
Note over W1,W2: 먼저 성공한 쪽만이 소유권을 취한다
운영상 디렉토리도 나누어 두면 추적하기 쉽습니다.
flowchart LR
T[temp] -->|publish| I[incoming]
I -->|claim| P[processing]
P -->|성공| A[archive]
P -->|실패| E[error]
claim용의 rename도 같은 파일 시스템 상에서 하는 것이 전제입니다.
4.4. lock file에 의지한다면 lease로 한다
lock file을 쓴다면 단순한 빈 파일이 아니라 유효 기한 붙은 소유 정보로 합니다. 누가 취했는지 알 수 없는 lock은 나중에 반드시 다툼이 됩니다.
flowchart TD
L[lock.json] --> A[ownerId]
L --> B[host]
L --> C[pid]
L --> D[acquiredAt]
L --> E[expiresAt]
L --> F[heartbeatAt]
포인트:
- 작성은 원자적으로 한다
- 갱신 정지를 stale 판정의 재료로 한다
- 삭제는 원칙으로서 작성자만이 한다
- 해제 누락을 전제로 회복 절차를 정해 둔다
lock file은 어디까지나 협조를 위한 패입니다. 이 한 장으로 완전한 정합성까지 보증하려고 하면 대체로 힘들어집니다.
4.5. idempotency를 전제로 한다
배타 제어는 중요하지만, 실운용에서는 「가끔 이중으로 온다」, 「도중에 재실행한다」를 제로로 할 수는 없습니다. 마지막은 같은 입력을 한 번 더 먹어도 망가지지 않는 설계가 효과가 있습니다.
flowchart LR
A[입력 + idempotency key] --> B{기처리인가}
B -- 예 --> C[이중 실행하지 않고 성공 취급]
B -- 아니오 --> D[처리를 실행]
D --> E[처리 완료 대장에 기록]
예를 들어 수신 파일마다 연계 ID를 가지게 하고, 처리 완료 대장에 기록합니다. 배타가 한 번 깨져도 결과가 이중 계상되지 않는 형태로 해 두면 운영이 꽤 편합니다.
5. 의사 코드(발췌)
여기서부터 나오는 MakeTempPathSameDirectory나 TryClaimBundleByRename은 순서를 보여 주기 위해 둔 가상의 함수명입니다. 실제로 동작하는 구현은 서두에서 소개한 샘플 일체에 있습니다.
이 의사 코드의 구현(라이브러리, 2개 워커의 claim 경합 데모, 유닛 테스트) - komurasoft-blog-samples (GitHub)
5.1. 전형적인 실패 패턴
var lockPath = finalPath + ".lock";
if (!File.Exists(lockPath))
{
File.WriteAllText(lockPath, "");
using var writer = OpenForWrite(finalPath); // 최종명에 직접 쓴다
WritePayload(writer);
File.Delete(lockPath);
}
문제점은 3가지 있습니다.
Exists와WriteAllText가 별도 조작finalPath가 쓰기 도중에 보여 버린다- 이상 종료 시에
lock이 남는다
5.2. 올바른 방향의 예(거칠게 쓰면 이렇게)
var tempPath = MakeTempPathSameDirectory(finalPath);
WritePayload(tempPath);
FlushAndClose(tempPath);
PublishByRenameOrReplace(tempPath, finalPath); // 같은 FS / 같은 volume 전제
PublishDoneFile(finalPath + ".done", new
{
FileName = Path.GetFileName(finalPath),
Size = GetFileSize(finalPath),
Hash = ComputeHash(finalPath),
IdempotencyKey = integrationId
});
if (!TryClaimBundleByRename(baseName, incomingDir, processingDir))
{
return; // 다른 워커가 먼저 취득
}
var manifest = ReadDoneFile(Path.Combine(processingDir, baseName + ".done"));
VerifyPayload(Path.Combine(processingDir, baseName), manifest);
if (AlreadyProcessed(manifest.IdempotencyKey))
{
MoveBundle(processingDir, archiveDir, baseName);
return;
}
Process(Path.Combine(processingDir, baseName));
RecordProcessed(manifest.IdempotencyKey);
MoveBundle(processingDir, archiveDir, baseName);
이쯤은 구현의 세부보다 순서가 중요합니다. 「쓴다」 「공개한다」 「소유권을 취한다」 「처리 완료를 기록한다」를 섞지 않는 편이 망가지기 어려워집니다.
6. 대략적인 구분 사용
- 단일 writer / 단일 reader / 같은 호스트라면, 우선
temp -> rename만으로도 꽤 안정된다 - 여러 consumer가 있다면
incoming -> processing의 claim rename을 넣는다 - 이종 시스템 연계, NAS, 공유 폴더라면 manifest / done과 idempotency까지 넣는 편이 안전
- 여러 writer가 같은 논리 상태를 갱신하고 싶다면 파일 연계에서 너무 분발하지 말고 DB나 큐도 검토한다
- OS 락은 같은 앱군·같은 전제 안에서는 유효하지만, 받아넘기기 프로토콜의 대용이 되지는 않는다
마지막 1항목은 철수 판단이기도 합니다. 파일로 하면 괴로운 문제는 정말로 있습니다.
7. 정리
파일 연계의 배타 제어는 락 함수를 호출하는 것이 아니라 상태 전이를 정하는 것. 이것이 이 글의 골자입니다. 생성 중 / 공개 완료 / 처리 중 / 처리 완료를 이름이나 디렉토리로 표현하고, Exists -> Create의 2단계 체크나 최종 파일 이름으로의 직접 쓰기, 크기 안정 대기, 공유 파일의 상호 갱신, 락 API에 대한 과신을 피합니다. 그 위에서 temp -> close -> rename / replace, done / manifest, claim rename, lease와 idempotency를 조합하면 공유 폴더 연계의 사고는 상당히 막을 수 있습니다.
파일 연계에서는 「읽을 수 있는 것」과 「읽어도 되는 것」을 같게 두지 않는 것이 요령입니다. 여기를 나누는 것만으로도 한밤중에만 나오는 타입의 사고가 꽤 줄어듭니다.
8. 참고 자료
- 이 글의 샘플 코드 일체(라이브러리, 데모, 유닛 테스트) - komurasoft-blog-samples (GitHub)
- LockFileEx function (Win32)
- Locking and Unlocking Byte Ranges in Files (Win32)
- Moving and Replacing Files (Win32)
- MoveFileEx function (Win32)
- File Times (Win32)
- FileStream.Lock Method (.NET)
- File.Replace Method (.NET)
- rename — POSIX
- open — POSIX (
O_CREAT | O_EXCL) - flock(2) — Linux manual page
- open(2) — Linux manual page
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
FileSystemWatcher 사용법과 주의점 - 누락, 중복 알림, 완료 판정의 함정
Windows .NET 파일 감시에서 FileSystemWatcher의 이벤트를 완료 알림으로 오인하기 쉬운 함정과, 재스캔 요청·원자적 claim·idempotency를 축으로 누락과 중복을 견디는 안전한 설계 패턴을 정리합니다.
업무 시스템의 코드 설계 ── 상품 코드・고객 코드 정하는 방법과 체크 디지트
상품 코드・고객 코드 등 업무 시스템의 코드 체계를 정하는 실전 가이드. 유의미 코드와 무의미 일련번호의 판단표, JAN・Luhn 등 체크 디지트 산식과 C# 구현, Excel의 앞자리 0 소실 대책, 자릿수 초과와 이관까지 정리합니다.
ADR(Architecture Decision Record) 입문 ── 소규모 개발에서 '왜 이런 설계로 했는가'를 남기는 최소한의 방법
코드는 '왜 그렇게 했는지'를 말해주지 않습니다. ADR(Architecture Decision Record)로 설계 판단의 이유를 1결정=1파일의 Markdown으로 남기는 방법을, 템플릿과 작성 여부 판단표, 실제 사례와 함께 해설합니다.
Windows 앱의 웹 전환, 하지 않는 편이 나은 경우 ── 판단표와 '분할'이라는 현실적 해법
'사내 Windows 앱을 웹으로 만들고 싶다'는 요청이 늘고 있지만, 장치 연동·로컬 파일 처리·오프라인 운용·고속 입력 UI를 갖춘 앱에서는 웹 전환이 오히려 비용 증가와 기능 저하를 부를 수 있습니다. 웹 전환에 적합한 경우와 부적합한 경우...
Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유 - 약 15.6ms 분해능의 폴링을 피한다
Windows의 짧은 timer wait는 system clock 분해능과 스케줄러 지연에 묶여 의도한 정밀도가 나오지 않습니다. 작업 도착·I/O 완료·정지 요청은 event 대기로, 시각 자체는 waitable timer로 나누는 설계 지침을...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 파일 연계의 배타 제어는 락 API만으로 충분한가요?
- 불충분한 경우가 많습니다. Linux의 flock은 advisory lock이라 약속을 무시하는 상대는 얼마든지 만들 수 있고, Windows의 byte-range lock은 메모리 매핑 파일에서는 무시됩니다. OS 락은 같은 앱군·같은 전제 안에서는 유효하지만 어디까지나 보조로 쓰고, temp -> rename, done/manifest, 원자적 claim, idempotency 같은 받아넘기기 프로토콜의 설계를 본체로 삼는 것이 기본입니다.
- 쓰기 도중인 파일을 읽히지 않게 하려면 어떻게 해야 하나요?
- temp -> close -> rename/replace로 공개하는 것이 왕도입니다. 생성 중인 파일은 temp 이름에 가두고, close한 뒤 같은 디렉토리 안에서 final 이름으로 전환하며, 수신 측은 final 이름만 보도록 합니다. temp와 final은 같은 디렉토리, 적어도 같은 볼륨/파일 시스템에 두는 것이 전제이며, final 이름이 보인 시점에 내용은 완성되어 있다는 약속으로 합니다.
- 여러 워커가 같은 파일을 동시에 처리하지 못하게 하려면 어떻게 해야 하나요?
- 읽기 전에 claim을 원자적으로 취합니다. 구체적으로는 incoming에서 processing/<worker>/로의 rename이 성공한 워커만 처리하는 형태로 합니다. Exists -> Create의 2단계 체크는 「확인」과 「확보」가 별개 조작이 되어 있어 그 사이에 다른 프로세스가 끼어들 수 있으므로 배타가 되지 않습니다. 원자적 작성이 필요하다면 .NET의 FileMode.CreateNew 계열이나 POSIX의 O_CREAT | O_EXCL을 씁니다.
- lock file을 쓸 때 주의점이 있나요?
- 단순한 빈 파일이 아니라 ownerId, host, pid, acquiredAt, expiresAt, heartbeatAt을 가진 유효 기한 붙은 lease(소유 정보)로 합니다. 작성은 원자적으로 하고, 갱신 정지를 stale 판정의 재료로 삼고, 삭제는 원칙적으로 작성자만 하며, 해제 누락을 전제로 회복 절차를 정해 둡니다. lock file 한 장으로 완전한 정합성까지 보증하려 하지 말고, 마지막은 idempotency로 받아내는 설계가 실무에서는 강력합니다.