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

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

파일 연계의 배타 제어는 공유 폴더나 야간 배치, 다른 프로세스 연계에서 거의 반드시 문제가 됩니다. 특히 검색에서 많은 것은 파일 락만으로 충분한가, 여러 워커가 같은 파일을 잡지 않는 방법은 무엇인가, 쓰기 중인 파일을 어떻게 피할 것인가 같은 고민입니다.

이 글에서는 파일 연계의 배타 제어를 파일 락, 원자적 claim, temp -> rename, idempotency를 축으로 정리합니다.

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 락 단체로 완료 통지나 소유권의 설계까지 짊어지게 하지 않는 편이 좋습니다

4. 베스트 프랙티스

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가 실패하거나 합니다. 이 전제는 수수하지만 매우 중요합니다.

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. 의사 코드(발췌)

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
  • 최종 파일 이름에의 직접 쓰기
  • 크기 안정 대기
  • 공유 파일을 모두가 갱신
  • 락 API에만 전부를 짊어지게 하는 것

실무에서 효과가 있는 대책:

  • temp -> close -> rename / replace
  • done / manifest로 완전성을 명시
  • claim rename으로 소유권을 취한다
  • lease와 idempotency로 실패에 대비

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

8. 참고 자료

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

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

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

자주 묻는 질문

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

파일 락만으로 배타 제어가 충분한가요?
아닙니다. 락 API는 전 참가자가 같은 약속으로 움직일 때만 효과가 있습니다. Linux의 flock은 advisory lock이라 약속을 무시하는 상대는 그냥 쓸 수 있고, Windows의 byte-range lock은 메모리 매핑 파일에서 무시됩니다. 파일 연계에서는 배타 제어라기보다 받아넘기기 프로토콜의 설계가 본체이며, 생성 중/공개 완료/처리 중/처리 완료를 파일 이름이나 디렉토리로 나누어 표현하는 것이 중요합니다.
쓰기 중인 파일을 읽어 버리는 사고는 어떻게 막나요?
temp -> close -> rename/replace로 공개하는 것이 왕도입니다. 생성 중인 파일은 temp 이름에 가두고, close한 뒤에 final 이름으로 전환하며, 수신 측은 final 이름만 보도록 합니다. temp와 final은 같은 디렉토리, 적어도 같은 볼륨에 두어야 하고, final 이름이 보인 시점에 내용은 완성 완료라는 약속으로 합니다. 파일 크기가 멈추면 완료로 취급하는 방식은 네트워크 복사나 일시 정지로 흔들려 위험합니다.
여러 워커가 같은 파일을 잡지 않게 하려면 어떻게 하나요?
읽기 전에 claim을 원자적으로 취하는 방식이 이해하기 쉽습니다. incoming에서 processing/<worker>/로의 rename이 성공한 워커만 처리하도록 하면, 먼저 성공한 쪽만 소유권을 가집니다. File.Exists로 확인한 뒤 Create하는 2단계 체크는 사이에 다른 프로세스가 끼어들 수 있어 배타가 되지 않으므로, FileMode.CreateNew나 O_CREAT | O_EXCL 같은 원자적 작성을 써야 합니다.
idempotency는 왜 필요한가요?
실운용에서는 가끔 이중으로 오거나 도중에 재실행하는 상황을 제로로 할 수 없기 때문입니다. 수신 파일마다 연계 ID(idempotency key)를 가지게 하고 처리 완료 대장에 기록해 두면, 같은 입력을 한 번 더 받아도 이중 실행하지 않고 성공 취급할 수 있습니다. 배타가 한 번 깨져도 결과가 이중 계상되지 않는 형태로 해 두면 운영이 훨씬 편해집니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기