Windows 클라이언트에서 다운로드 세 개를 동시에 처리하려다 서버의 파일 락 구조부터 다시 살펴봤다. 기존에는 같은 파일을 읽는 요청도 하나의 락을 두고 경쟁하도록 묶여 있었다. 서버의 읽기·쓰기 구분과 클라이언트 전송 스케줄러를 함께 손본 과정을 정리했다.

전송은 한 번에 하나씩만 처리했다
사내 클라우드 스토리지에는 웹 화면 외에 Windows 클라이언트도 있다. 기존 클라이언트는 모든 파일 전송을 SemaphoreSlim(1, 1) 하나로 제한했다. 파일 하나를 받는 동안 다른 다운로드와 업로드는 차례를 기다려야 했다.
이번에 정한 목표는 다운로드 최대 3개와 업로드 1개를 별도로 처리하는 것이었다. 처음엔 세마포어 숫자만 바꾸면 될 것처럼 보였다. 하지만 클라이언트의 제한을 풀기 전에 서버의 파일 접근 방식도 확인해야 했다.
서버의 파일 락은 읽기도 독점 처리했다
동시 다운로드를 허용하려면 서버에서도 같은 파일을 함께 읽을 수 있어야 했다. 기존 코드는 파일마다 threading.RLock()을 하나씩 만들고, 기본적으로 기다리지 않고 락을 잡도록 했다. 락을 얻지 못하면 HTTP 409 응답을 반환하는 구조였다.
아래는 수정 전 파일 락을 획득하던 핵심 분기를 발췌해 단순화한 예시다. 실제 코드의 나머지 처리와 락 해제 부분은 생략했다.
# 수정 전: 파일마다 RLock 하나를 사용
with self.guard:
lock = self.file_locks.setdefault(
file_id, threading.RLock()
)
# timeout의 기본값은 0
if not lock.acquire(timeout=timeout):
raise self.h.HubError(
"이 파일은 전송 중입니다. "
"전송이 끝난 뒤 다시 시도해 주세요.",
409,
)
문제는 다운로드도 이 락을 똑같이 잡는다는 점이었다. 이 구조라면 같은 파일을 두 사람이 동시에 받을 때 두 번째 요청은 바로 409를 받는다. 반대로 이름 바꾸기와 같은 저장소 안 이동은 이 락을 거치지 않고 DB만 고치고 있었다.

읽기는 공유하고 쓰기는 독점하도록 바꿨다
기존 RLock 대신 threading.Condition을 이용해 읽기·쓰기 락을 직접 구성했다. 다운로드와 미리보기, 스트리밍은 읽기로 분류하고 파일을 변경하는 작업은 쓰기로 분류했다. 파일 접근 규칙은 다음과 같이 정리했다.
- 읽기 요청은 다른 쓰기 작업이 없다면 여러 개가 동시에 진입할 수 있다.
- 쓰기 요청은 다른 읽기나 쓰기 작업이 없을 때 진입할 수 있다. 같은 소유자의 쓰기 재진입은 허용했다.
- 쓰기 요청이 기다리고 있다면 새로운 읽기 요청을 제한한다. 이미 읽고 있는 소유자의 재진입은 예외로 뒀다.
- 읽기 상태에서 쓰기 상태로 직접 전환하는 것은 같은 스레드라도 허용하지 않는다.
읽기 소유자는 스레드 식별자로 구분하고, 소유자별 진입 횟수도 관리하도록 했다. 쓰기 요청이 대기하는 동안 새로운 읽기 요청을 제한한 것은 쓰기 작업이 계속 밀리는 상황을 줄이기 위해서였다. 기존 읽기 소유자의 재진입은 허용해 같은 작업 안에서 읽기를 이어갈 수 있게 했다.
아래 코드는 새 파일 접근 클래스에서 진입 조건만 발췌해 단순화한 예시다. 읽기와 쓰기의 허용 조건을 나눠 검사하도록 했다.
# 수정 후: 진입 가능 여부를 판단하는 핵심 조건
owner = threading.get_ident()
if read_only:
available = (
self.writer == owner
or (
self.writer is None
and (
not self.waiting_writers
or owner in self.readers
)
)
)
else:
# 읽기에서 쓰기로 직접 승격하지 않는다.
available = (
self.writer == owner
or (
self.writer is None
and not self.readers
)
)
같은 파일을 읽는 요청은 서로 공유할 수 있지만, 읽기가 남아 있다면 쓰기는 진입하지 못한다. 지정한 대기 시간이 지나도 락을 얻지 못하면 기존처럼 409 응답을 반환한다. 따라서 변경 요청은 읽기 작업이 끝난 뒤 다시 시도해야 한다.
기존에는 이름 바꾸기와 같은 노드 안의 이동, 잘라내기가 파일 락 없이 DB만 수정했다. 수정 후에는 이 작업들도 같은 파일 락을 획득하고 레코드를 다시 읽도록 했다. 변경 작업이 다운로드와 별개로 진행되지 않도록 접근 규칙을 맞춘 셈이다.
다만 이번에 작성한 락은 단일 워커 내부의 스레드를 기준으로 동작한다. 서로 다른 프로세스까지 동기화하는 구조라고 설명할 수는 없다.
클라이언트는 다운로드 3칸과 업로드 1칸으로 나눴다
서버의 파일 접근 규칙을 정리한 뒤 Windows 클라이언트에는 전송 스케줄러를 구성했다. 다운로드용 SemaphoreSlim(3, 3)과 업로드용 SemaphoreSlim(1, 1)을 별도로 뒀다. 이 구조에서는 업로드가 진행 중이어도 다운로드용 슬롯을 사용할 수 있다.
여기서 한 번 더 걸렸다. 같은 로컬 경로로 받는 요청이 여러 개 오면, 이 요청들이 다운로드 칸을 차지한 채 서로를 기다릴 수 있었다. 그래서 다운로드 칸보다 목적지 경로를 먼저 예약하게 했다. 경로는 전체 경로로 바꾼 뒤 대소문자를 무시하고 비교한다.

같은 목적지로 향하는 요청은 경로 예약 단계에서 먼저 순서를 기다리게 했다. 따라서 중복 요청이 다운로드 슬롯을 모두 점유하는 상황을 피하도록 구성했다. 서로 다른 목적지의 다운로드는 남은 슬롯을 이용할 수 있다.
아래는 다운로드 슬롯보다 경로 예약을 먼저 처리하는 흐름을 축약한 단순화 예시다. 실제로는 다운로드 슬롯 수를 상수로 두고 있다. 경로별 예약 객체 관리와 실제 전송, 해제 처리는 생략했다.
// 목적지 경로를 먼저 정규화한다.
var path = Path.GetFullPath(localPath);
// 같은 목적지는 대소문자를 무시해 비교한다.
// 경로 예약 객체의 생성과 참조 수 관리는 생략했다.
// 목적지 경로를 먼저 예약한다.
await destination.Gate.WaitAsync(cancellation);
// 이후 다운로드 슬롯을 획득한다.
await downloads.WaitAsync(cancellation);
// 실제 전송 및 종료 시 자원 해제 코드는 생략했다.
전송을 마치거나 실패하거나 취소하면 획득한 슬롯과 경로 예약을 반환하도록 했다. 경로 예약의 참조 수가 0이 되면 관리 항목을 제거하고, 해제를 두 번 호출해도 실제 반환은 한 번만 이루어지게 했다.
병렬 전송에 대비해 토큰 갱신도 직렬화했다
파일 전송을 병렬로 처리하면 여러 요청이 인증 세션을 동시에 확인할 수도 있다. 이 클라이언트는 리프레시 토큰을 사용할 때마다 새 토큰으로 교체하는 구조였다. 여러 요청이 같은 이전 토큰으로 갱신을 시도하면 충돌할 수 있었다.
그래서 토큰 갱신, 로그인, 로그아웃을 게이트 하나로 줄 세웠다. 게이트 안에 들어오면 세션 상태를 다시 확인하고, 아직 필요할 때만 갱신한다. 취소된 인증 응답은 저장하지 않게 했고, 취소된 화면 이동을 연결 실패로 보지 않게 했다.

이 방식은 여러 요청이 동시에 세션을 확인하더라도 불필요한 중복 갱신을 피하기 위한 것이다. 세션을 확인하는 순서와 실제 갱신 여부를 하나의 게이트 안에서 결정하도록 정리했다.
테스트 코드를 추가했지만 이번에는 실행하지 않았다
10월 13일 밤의 커밋은 이미 배포돼 있던 변경을 커밋으로 보존한 작업이었다. 같은 날 별도로 진행한 읽기 전용 대조에서는 게이트웨이 소스 55개가 운영 이미지와 SHA-256 기준으로 일치했다. 커밋 작업 자체에서는 버전 변경이나 배포, 설치 파일 빌드, 테스트 실행을 하지 않았다.
서버의 파일 접근 규칙과 Windows 클라이언트의 전송 처리에 관한 테스트 코드는 추가했다. 아래 항목은 테스트가 검증하도록 작성한 시나리오이며, 이번 커밋에서 실행해 통과한 결과를 뜻하지는 않는다.
- 같은 파일에 두 다운로드가 진행 중이면 삭제, 이름 바꾸기, 잘라내기 요청이 409를 받도록 검증한다.
- 첫 번째 다운로드가 끝나도 두 번째 다운로드가 남아 있다면 변경 작업을 계속 막는지 확인한다.
- 두 다운로드가 모두 끝난 뒤에는 삭제 요청이 200 응답을 받는지 확인한다.
- 파일 소유자와 공유받은 사용자가 같은 파일을 함께 다운로드할 수 있는지 확인한다.
- 연결이 끊어진 다운로드의 락이 해제되고 다음 다운로드에 영향을 주지 않는지 확인한다.
- 미리보기와 스트리밍, 다운로드가 같은 파일을 동시에 읽을 수 있는지 확인한다.
- 쓰기 재진입과 읽기에서 쓰기로의 전환 제한, 대기 중인 쓰기 요청의 처리와 시간 초과를 검증한다.
- 클라이언트에서 네 번째 다운로드가 대기하고 업로드는 별도 슬롯에서 순차 처리되는지 확인한다.
- 대기 중 취소와 실패가 전송 슬롯이나 목적지 경로 예약을 남기지 않는지 확인한다.
- 경로의 대소문자 차이와 별칭이 같은 목적지로 처리되고 중복 대기자가 다른 다운로드를 막지 않는지 확인한다.
- 네 개의 세션 확인이 동시에 진행돼도 필요한 토큰 갱신은 한 번만 이루어지는지 확인한다.
- 실제 HTTP 스트리밍 다운로드 세 개가 동시에 진행되고 체크섬이 일치하는지 검증한다.
테스트에 필요한 시나리오는 코드로 남겼지만 이번 작업에서 실행 결과까지 확인한 것은 아니었다. 실제 동시 전송의 속도나 처리량 역시 별도로 측정하지 않았다.
참고한 문서와 이전 작업
읽기·쓰기 제어에 필요한 기본 동기화 도구는 Python threading.Condition 공식 문서에서 확인했다. 클라이언트의 전송 제한에는 .NET SemaphoreSlim 공식 문서를 참고했다. 다만 읽기·쓰기 허용 조건과 대기 정책은 직접 구성한 규칙이다.
같은 클라우드 스토리지의 다른 작업은 읽기 전용 공유 기능을 붙인 글에도 정리해 두었다. 이번에는 공유 읽기를 유지하면서 파일 변경 작업과 클라이언트의 병렬 전송을 어떻게 구분할지 살펴봤다.
이번 작업에서 배운 것
- 락 하나로 모든 접근을 막으면 읽기 요청도 서로 기다리게 된다. 병렬화를 시작하기 전에 어떤 작업이 동시에 진행돼도 되는지부터 구분해야 했다.
- 변경 작업은 락을 획득한 뒤 파일 정보를 다시 확인하도록 했다. 동시 요청 사이에서 변경된 레코드를 그대로 사용하지 않기 위한 선택이었다.
- 전송 슬롯만 늘려서는 같은 목적지로 향하는 중복 요청을 제어하기 어렵다. 경로 예약을 먼저 처리해 슬롯을 불필요하게 점유하지 않도록 했다.
- 병렬화는 인증 토큰처럼 순서에 영향을 받는 상태도 함께 살펴보게 했다. 토큰 갱신과 세션 변경 작업을 하나의 게이트에서 처리하도록 했다.
이번에는 이미 배포된 변경을 정리하고 동시 처리 규칙을 검증할 테스트 코드까지 남겼다. 다음에는 추가한 테스트를 실제로 실행하고, 여러 사용자의 대용량 파일 동시 다운로드 속도도 측정해 볼 생각이다.
'프로젝트 > 사내 클라우드 스토리지' 카테고리의 다른 글
| WebView 미디어 인증 중계 11시간 뒤, 화면을 Compose로 갈아엎었다 (0) | 2026.10.21 |
|---|---|
| 이관 기록과 현재 운영 지침을 나누고, 소스 55개를 원본 SHA-256으로 대조했다 (0) | 2026.10.19 |
| Android 16에서 깨진 로그인, 범인은 OS가 아니라 오래된 주소였다 (0) | 2026.10.17 |
| 읽기 전용 공유, 버튼 하나가 아니었던 날 (0) | 2026.10.16 |
| DownloadManager 다운로드 목록 복원과 늦은 취소의 함정 (0) | 2026.10.15 |