파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스

· 업데이트: · · 파일 연계, 배타 제어, 설계, Windows 개발

수정 이력(5건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635091)
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건) 대응으로 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
용어를 먼저 맞추는 절을 새로 두고, 안티패턴과 대책의 대응표를 추가했습니다. 공유 폴더 너머 주의점 4가지(공유를 넘는 rename은 원자적이지 않다는 점, 다른 프로세스가 열어 두기만 해도 실패한다는 점, 타임스탬프 보장은 핸들을 닫을 때만이라는 점, 변경 알림 누락)와, 바이트 범위 락이 메모리 매핑에서는 무시된다는 출처를 추가했습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635090)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

Go Komura (2026). 「파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 claim의 베스트 프랙티스」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635090 https://comcomponent.com/ko/blog/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을 한 번의 조작으로 수행하는 것입니다. 확인과 확보가 나뉘어 있으면 그 틈에 다른 프로세스가 끼어듭니다(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로 받아내는 설계가 실무에서는 강력하다고 결론짓습니다.

파일 연계의 배타 제어 지식 맵받아넘기기 프로토콜이 원자적 claim, temp -> rename 공개, done/manifest, lease 방식의 lock file, idempotency를 어떻게 조합하고, 이중 처리나 쓰기 도중 읽기 같은 사고를 어떤 안티패턴에 대응시켜 막는지 보여주는 그림이용한다이용한다이용한다이용한다이용한다이용한다이용한다방지한다원인이 될 수 있다권장되는 대응권장되는 대응원인이 될 수 있다방지한다원인이 될 수 있다권장되는 대응원인이 될 수 있다권장되는 대응권장되는 대응전제로 한다이용한다완화한다사용은 비권장사용은 비권장양립하지 않는다원인이 될 수 있다원인이 될 수 있다권장되는 대응권장되는 대응원인이 될 수 있다받아넘기기 프로토콜원자적 claimtemp -> close -> rename/replace로 공개done/manifest 파일lease 방식의 lock fileidempotency(멱등성)를 전제로 한 처리OS 파일 락(OS 락)원자적 작성(CreateNew / O_CREAT|O_EXCL)이중 처리(이중 계상·이중 전송·업데이트 유실)Exists -> Create의 2단계 체크최종 파일 이름에 직접 쓰기쓰기 중인 파일의 읽기 사고파일 크기 안정 대기에 의한 완료 판정공유 파일 상호 갱신 안티패턴stale lockbyte-range lock(범위 락)이종 시스템 연계advisory lock크로스 볼륨 rename 폴백(복사+삭제)공유 폴더(SMB) 너머의 파일 연계rename은 열려 있는 것만으로 실패파일 타임스탬프의 불확실성주기적인 디렉터리 열거변경 알림 누락

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 29건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

목차

  1. 먼저 결론(한 줄로)
  2. 파일 연계에서 일어나는 경합 패턴(그림)
    • 2.1. 쓰기 도중인 파일을 읽어 버린다
    • 2.2. 여러 워커가 같은 파일을 동시에 집어 간다
    • 2.3. stale lock으로 모두가 멈춘다
  3. 안티패턴
    • 3.1. Exists -> Create의 2단계 확인
    • 3.2. 최종 파일 이름에 직접 쓴다
    • 3.3. 파일 크기가 멈추면 완료로 친다
    • 3.4. 공유 파일을 모두가 갱신한다
    • 3.5. 락 API를 만능이라고 생각한다
  4. 베스트 프랙티스
    • 4.1. temp -> close -> rename / replace로 공개한다
    • 4.2. done / manifest로 완전성을 명시한다
    • 4.3. 수신 측은 claim을 원자적으로 취한다
    • 4.4. lock file에 의지한다면 lease로 만든다
    • 4.5. idempotency를 전제로 한다
  5. 의사 코드(발췌)
  6. 대략적인 구분
  7. 정리
  8. 참고 자료

파일 연계는 코드 자체보다 「전달의 약속」이 더 잘 깨지는 분야입니다. 단위 테스트에서는 통과하는데, 운영 환경의 공유 폴더나 야간 배치에서만 가끔 깨집니다. 게다가 재현하기 어렵습니다. 꽤 흔합니다.

원인 대부분은 파일 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이면 그냥 깨집니다.

수신 측공유 폴더송신 측수신 측공유 폴더송신 측아직 도중행 수 부족 / 파싱 실패 / 일부만 처리orders.csv를 최종 이름으로 생성1행~5000행을 쓰는 중orders.csv를 감지그대로 읽기 시작나머지를 씀

2.2. 여러 워커가 같은 파일을 동시에 집어 간다

「목록을 보고, 미처리면 연다」는 흐름이면 같은 파일을 워커 두 개가 잡을 수 있습니다. 이중 집계나 이중 전송의 시작입니다.

incoming워커2워커1incoming워커2워커1같은 입력을 이중 처리a.csv를 발견a.csv를 발견읽기 시작읽기 시작

2.3. stale lock으로 모두가 멈춘다

lock file만 두는 설계는 비정상 종료 때 막히기 쉽습니다. 누구의 lock인지, 아직 살아 있는지, 언제까지 유효한지 모르면 후속이 끝없이 기다리게 됩니다.

워커Block 파일워커A워커Block 파일워커A여기서 비정상 종료stale인지 판정하지 못해 모두 정지lock을 생성lock의 존재를 확인처리 시작을 보류더 기다림

3. 안티패턴

3.1. Exists -> Create의 2단계 확인

문제는 「확인」과 「확보」가 별도 조작이라는 점입니다. 사이에 다른 프로세스가 끼어들 수 있으므로 배타가 되지 않습니다.

파일 시스템프로세스B프로세스A파일 시스템프로세스B프로세스A양쪽이 진행해 버린다lock이 없는지 확인lock이 없는지 확인없음없음lock을 생성lock을 생성

전형적인 나쁜 예는 이런 형태입니다.

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, Environment.ProcessId.ToString());
    ProcessFile();
}

필요한 것은 「없으면 만든다」를 한 번의 조작으로 만드는 것입니다. .NET이라면 FileMode.CreateNew 계열, POSIX 계열이라면 O_CREAT | O_EXCL 같은 원자적 생성을 사용합니다.

3.2. 최종 파일 이름에 직접 쓴다

수신 측이 「그 이름이 보이면 읽어도 된다」고 해석하고 있다면, 최종 파일 이름에 바로 쓰기 시작한 시점에 이미 진 것입니다. 보이는 것과 읽어도 되는 것을 같게 두지 않는 것이 기본입니다.

final 이름이 보인다수신 측이 감지송신 측은 아직 쓰는 중불완전한 데이터를 읽는다
using var writer = OpenForWrite(finalPath); // 여기서 finalPath가 보이게 된다
foreach (var row in rows)
{
    writer.WriteLine(row);
}

이 방식은 2.1의 사고를 스스로 불러들입니다.

3.3. 파일 크기가 멈추면 완료로 친다

편리해 보이지만 꽤 위험합니다. 네트워크 너머 복사, 송신 측의 일시 정지, 버퍼링, 재시도만으로도 흔들립니다.

수신 측공유 폴더송신 측수신 측공유 폴더송신 측완료로 오판data.zip 복사를 시작도중에 일시 정지크기가 10초 동안 변하지 않음읽기 시작복사 재개
if (currentLength == lastLength && stableSeconds >= 10)
{
    return Ready;
}

완료를 추측으로 정하면 공유 폴더나 큰 파일에서 허를 찔립니다. 완료는 manifest나 done file로 명시하는 편이 안정적입니다.

3.4. 공유 파일을 모두가 갱신한다

하나의 status.csvcounter.json을 모두가 읽고 갱신하는 설계는 대체로 마지막에 쓴 사람이 이깁니다. 파일 연계를 간이 DB로 쓰기 시작하면 여기서 힘들어집니다.

status.csv배치B배치Astatus.csv배치B배치AA의 갱신이 사라진다v1을 읽음v1을 읽음v2-A를 씀v2-B를 씀

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단계 확인 확인과 확보의 틈에 끼어들어, 프로세스 두 개가 동시에 진행한다 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 이름만 보도록 합니다.

고유한 temp 이름을 만든다temp에 전체 내용을 쓴다flush / close한다같은 디렉터리에서 final 이름으로 rename / replace수신 측은 final 이름만 감시

포인트:

  • temp와 final은 같은 디렉터리, 적어도 같은 볼륨 / 파일 시스템에 둔다
  • Windows / .NET이라면 File.Replace 계열을 검토할 수 있다
  • final 이름이 보인 시점에 내용은 이미 완성되어 있다는 약속으로 한다

temp를 다른 드라이브에 두면 rename이 단순한 복사에 가깝게 되거나 Replace가 실패합니다. 이 전제는 눈에 잘 띄지 않지만 매우 중요합니다.

공유 폴더(SMB) 너머에서는 다음 네 가지가 더 흔들립니다. 이 글이 다루는 핵심이 바로 그 지점이므로 따로 적습니다.

  • 같은 공유의 같은 디렉터리 안 rename은 서버 쪽에서 실행됩니다. 그래서 「중간 이름이 보이지 않는다」는 성질 자체는 유지됩니다. 반대로 \\server\shareA에서 \\server\shareB처럼 공유를 넘으면 다른 볼륨으로 취급되고, Windows의 MoveFileExMOVEFILE_COPY_ALLOWED를 지정한 경우 복사와 삭제로 이동을 대체합니다. 즉 원자적이지 않아지고 중간 상태가 보입니다. temp와 final, incomingprocessing을 같은 공유 안에 두는 전제는 로컬일 때보다 더 중요합니다
  • rename은 「누가 열어 두기만 해도」 실패합니다. 공유 폴더에는 바이러스 백신, 검색 인덱서, 다른 거점의 클라이언트처럼 파악하지 못한 상대가 접근합니다. publish와 claim의 rename은 실패를 이상이 아니라 평범한 분기로 다루고, 짧게 기다렸다가 재시도하는 형태가 현실적입니다
  • 타임스탬프는 판단 재료가 되지 않습니다. Microsoft Learn의 File Times에는, 파일 시각에 대해 보장되는 것은 「변경을 수행한 핸들을 닫은 시점에 올바르게 반영된다」는 점뿐이라고 적혀 있습니다. 쓰기 중인 최종 수정 시각은 쓰기용 핸들이 모두 닫힐 때까지 완전히 갱신되지 않습니다. 단위도 파일 시스템에 따라 다르며, FAT의 최종 수정 시각은 2초 단위, NTFS의 최종 접근 시각은 최대 1시간 늦게 갱신됩니다. 게다가 SMB 너머에서는 서버 쪽 시계로 시각이 붙기 때문에, 클라이언트와 서버의 시계가 어긋나 있으면 「갱신부터 N분이 지나면 처리한다」는 판정도 그만큼 어긋납니다. 완료 판정을 시각이나 크기로 하지 않고 4.2의 done / manifest로 하는 이유가 여기에 있습니다
  • 변경 알림도 놓칩니다. 공유 폴더 감시를 이벤트 알림만으로 의지하지 말고, 주기적인 디렉터리 열거와 함께 쓰면 안정적입니다. 이 이야기는 FileSystemWatcher 실무 가이드 - 누락과 중복 대책에 정리되어 있습니다

4.2. done / manifest로 완전성을 명시한다

데이터 본체만이 아니라 「무엇이 완성되었는지」를 별도 파일로 명시하면 수신 측이 안정됩니다. 특히 이종 시스템 연계에서 유효합니다.

data.tmp를 생성data.csv로 공개data.done / manifest.json을 생성수신 측이 done / manifest를 감지파일 이름·크기·해시를 검증

manifest에 넣어 두고 싶은 것은 이 정도 항목입니다.

  • 대상 파일 이름
  • 크기
  • 해시
  • 레코드 수
  • 연계 ID / idempotency key
  • 생성 시각

순서도 중요합니다. 본체 공개보다 먼저 done을 두면, 그것은 완료 통지가 아니라 사고 예고가 됩니다.

4.3. 수신 측은 claim을 원자적으로 취한다

여러 워커가 같은 incoming을 본다면 「읽기 전에 자기 쪽으로 옮긴다」가 이해하기 쉽습니다. incoming에서 processing/<worker>/로의 rename이 성공한 워커만 처리합니다.

processingincoming워커2워커1processingincoming워커2워커1먼저 성공한 쪽만 소유권을 취한다a.csv를 발견a.csv를 발견a.csv를 renamea.csv를 rename

운영상 디렉터리도 나눠 두면 추적하기 쉽습니다.

publishclaim성공실패tempincomingprocessingarchiveerror

claim용 rename도 같은 파일 시스템에서 하는 것이 전제입니다.

4.4. lock file에 의지한다면 lease로 만든다

lock file을 쓴다면 빈 파일이 아니라 유효 기간이 있는 소유 정보로 만듭니다. 누가 잡았는지 모르는 lock은 나중에 반드시 다툼이 납니다.

lock.jsonownerIdhostpidacquiredAtexpiresAtheartbeatAt

포인트:

  • 생성은 원자적으로 한다
  • 갱신이 멈춘 것을 stale 판정 재료로 쓴다
  • 삭제는 원칙적으로 생성자만 한다
  • 해제 누락을 전제로 복구 절차를 정해 둔다

lock file은 어디까지나 협조를 위한 표입니다. 이 한 장으로 완전한 정합성까지 보증하려고 하면 대체로 힘들어집니다.

4.5. idempotency를 전제로 한다

배타 제어는 중요하지만, 실제 운영에서는 「가끔 이중으로 온다」「도중에 다시 실행한다」를 없앨 수 없습니다. 마지막에는 같은 입력을 한 번 더 먹어도 깨지지 않는 설계가 먹힙니다.

아니오입력 + idempotency key이미 처리했는가이중 실행하지 않고 성공으로 친다처리를 실행처리 완료 기록에 남긴다

예를 들어 수신 파일마다 연계 ID를 두고 처리 완료 기록에 남깁니다. 배타가 한 번 깨져도 결과가 이중 집계되지 않게 해 두면 운영이 꽤 편해집니다.

5. 의사 코드(발췌)

여기서 나오는 MakeTempPathSameDirectoryTryClaimBundleByRename은 순서를 보여 주려고 둔 가상의 함수 이름입니다. 실제로 동작하는 구현은 서두에서 소개한 전체 샘플에 있습니다.

이 의사 코드의 구현(라이브러리, 워커 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);
}

문제점은 세 가지입니다.

  • ExistsWriteAllText가 별도 조작
  • 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 락은 같은 앱 군·같은 전제 안에서는 유효하지만, 전달 프로토콜의 대용이 되지는 않는다

마지막 한 항목은 철회 판단이기도 합니다. 파일로 하면 괴로운 문제는 정말로 있습니다.

7. 정리

파일 연계의 배타 제어는 락 함수를 호출하는 일이 아니라 상태 전이를 정하는 일입니다. 이것이 이 글의 뼈대입니다. 생성 중 / 공개됨 / 처리 중 / 처리 완료를 이름이나 디렉터리로 표현하고, Exists -> Create의 2단계 확인이나 최종 파일 이름에의 직접 쓰기, 크기 안정 대기, 공유 파일의 상호 갱신, 락 API 과신을 피합니다. 그 위에 temp -> close -> rename / replace, done / manifest, claim rename, lease와 idempotency를 조합하면 공유 폴더 연계 사고는 꽤 막을 수 있습니다.

파일 연계에서는 「읽을 수 있는 것」과 「읽어도 되는 것」을 같게 두지 않는 것이 요령입니다. 여기를 나누는 것만으로 한밤에만 나오는 유형의 사고가 크게 줄어듭니다.

8. 참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

파일 연계의 배타 제어는 락 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로 받아내는 설계가 실무에서는 강합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기