파일 연계의 배타 제어 기초 지식 - 파일 락과 원자적 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로 받아내는 설계가 실무에서는 강력하다고 결론짓습니다.

파일 연계의 배타 제어 지식 맵받아넘기기 프로토콜이 원자적 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. 여러 워커가 같은 파일을 동시에 잡는다

「목록을 보고 미처리라면 연다」는 흐름이라면, 같은 파일을 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();
}

필요한 것은 「없으면 만든다」를 1조작으로 하는 것입니다. .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단계 체크 확인과 확보 사이에 끼어들어 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) 너머에서는 다음 4가지가 더 흔들립니다. 이 글의 주된 무대가 바로 여기이므로 따로 적어 둡니다.

  • 같은 공유의 같은 디렉토리 안에서의 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);
}

문제점은 3가지 있습니다.

  • 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 락은 같은 앱군·같은 전제 안에서는 유효하지만, 받아넘기기 프로토콜의 대용이 되지는 않는다

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

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

블로그 목록으로 돌아가기