파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스
파일 연계의 배타 제어는 공유 폴더나 야간 배치, 다른 프로세스 연계에서 거의 반드시 문제가 됩니다. 특히 검색에서 많은 것은 파일 락만으로 충분한가, 여러 워커가 같은 파일을 잡지 않는 방법은 무엇인가, 쓰기 중인 파일을 어떻게 피할 것인가 같은 고민입니다.
이 글에서는 파일 연계의 배타 제어를 파일 락, 원자적 claim, temp -> rename, idempotency를 축으로 정리합니다.
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 락 단체로 완료 통지나 소유권의 설계까지 짊어지게 하지 않는 편이 좋습니다
4. 베스트 프랙티스
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가 실패하거나 합니다.
이 전제는 수수하지만 매우 중요합니다.
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. 의사 코드(발췌)
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- 최종 파일 이름에의 직접 쓰기
- 크기 안정 대기
- 공유 파일을 모두가 갱신
- 락 API에만 전부를 짊어지게 하는 것
실무에서 효과가 있는 대책:
temp -> close -> rename / replacedone/ manifest로 완전성을 명시- claim rename으로 소유권을 취한다
- lease와 idempotency로 실패에 대비
즉, 파일 연계에서는 「읽을 수 있는 것」과 「읽어도 되는 것」을 같게 두지 않는 것이 요령입니다. 여기를 나누는 것만으로도 한밤중에만 나오는 타입의 사고가 꽤 줄어듭니다.
8. 참고 자료
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
FileSystemWatcher 사용법과 주의점 - 누락, 중복 알림, 완료 판정의 함정
Windows .NET 파일 감시에서 FileSystemWatcher의 이벤트를 완료 알림으로 오인하기 쉬운 함정과, 재스캔 요청·원자적 claim·idempotency를 축으로 누락과 중복을 견디는 안전한 설계 패턴을 정리합니다.
Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유 - 약 15.6ms 입자의 폴링을 피한다
Windows의 짧은 timer wait는 system clock 입자와 스케줄러 지연에 묶여 의도한 정밀도가 나오지 않습니다. 작업 도착·I/O 완료·정지 요청은 event 대기로, 시각 자체는 waitable timer로 나누는 설계 지침을 ...
상정하지 않은 예외가 발생했을 때의 체크리스트 - 앱을 종료시킬지 계속할지, 먼저 보는 판단표
상정 외 예외 시 앱을 종료할지 계속할지를 실패 단위 격리·공유 상태 회복·외부 부작용 설명·네이티브 경계 건전성의 네 축으로 판단하는 흐름을 표와 플로차트로 정리한 글입니다. 독자는 catch 가능 여부가 아니라 불변 조건 회복 가능성으로 가르...
Windows 앱 개발에서 최저한의 보안을 지키기 위한 체크리스트
WPF・WinForms・WinUI・Win32 등 Windows 앱 개발에서 권한, 서명, 비밀 정보, HTTPS, 입력 검증, DLL 읽기, 로그까지 릴리스 전에 빠뜨리면 사고로 이어지는 최저한의 보안 항목을 체크리스트 형태로 정리합니다.
Generic Host / BackgroundService를 데스크톱 앱에 가지고 들어오는 이유 - 기동・수명・graceful shutdown의 정리가 꽤 편해진다
WPF나 WinForms 같은 데스크톱 앱에서 Generic Host와 BackgroundService를 도입해 기동, 상주 처리, graceful shutdown, DI, 로그, 설정의 입구를 한 곳으로 모으는 설계 정리법과 안티패턴을 실무 시...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.