여러 프로세스가 API 토큰을 한 번만 발급받게 한 파일 캐시 설계

여러 프로세스가 같은 외부 API 접근 토큰을 함께 써야 할 때, 발급을 정확히 한 번만 하도록 파일 캐시를 설계한 과정을 정리했다. 발급 횟수 제한이 있는 OAuth 계열 API를 여러 워커에서 호출하는 사람에게 필요한 글이다.
배경
개인 프로젝트로 증권사 Open API를 조회하는 중계 서버를 만들었다. 이 API는 앱 키와 시크릿으로 접근 토큰을 발급받는 구조이고, 토큰에는 만료 시각이 있다. 발급 요청을 짧은 간격으로 반복하면 제한에 걸릴 수 있어서 토큰은 받아 두고 재사용해야 했다.
문제는 중계 서버가 프로세스 하나로 끝나지 않는다는 점이었다. 여러 워커가 동시에 뜨면 각자 "캐시에 토큰이 없다"고 판단하고 동시에 발급을 요청한다. 목표는 둘이었다.
- 몇 개의 프로세스가 동시에 시작해도 발급 요청은 한 번만 나간다.
- 토큰과 키는 로그·응답·진단 출력 어디에도 남지 않는다.
선택지와 결정
- 프로세스 메모리 캐시: 가장 단순하지만 프로세스마다 따로 발급하므로 목표와 맞지 않는다.
- Redis 같은 외부 저장소: 분산 잠금까지 해결되지만 토큰 하나를 위해 인프라를 늘리는 것은 과했다. 비밀값이 저장되는 곳도 하나 더 늘어난다.
- 파일 캐시 + 파일 잠금: 같은 서버의 프로세스끼리만 공유하면 되므로 충분했다. 소유자만 읽을 수 있는 권한(0600)으로 범위를 좁힐 수 있다.
세 번째를 택했다. Python 표준 라이브러리의 `fcntl.flock`과 `os.replace`만으로 구현할 수 있다는 점도 컸다.
구현 요점
잠금, 읽기, 쓰기를 각각 방어적으로 만들었다.
```python
lock = os.open(name + ".lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600, dir_fd=directory)
while True:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise Unavailable()
time.sleep(0.02)
```
- 대기 예산: 잠금을 무한히 기다리지 않는다. `LOCK_NB`로 시도하고, 정해진 마감 시각(이 구현은 8초)까지만 짧게 재시도한다. Python 문서는 `LOCK_NB`로 잠금을 얻지 못하면 `OSError`(EACCES 또는 EAGAIN)가 난다고 설명한다.
- 잠금 안에서 다시 확인: 잠금을 얻은 뒤 캐시를 다시 읽는다. 앞선 프로세스가 이미 발급했다면 그 토큰을 그대로 쓴다. 이 한 단계가 "한 번만 발급"을 만든다.
- 원자적 쓰기: 임시 파일을 `O_EXCL`로 만들어 쓰고 `fsync`한 뒤 `os.replace`로 교체한다. POSIX에서 이름 바꾸기는 원자적이므로 읽는 쪽은 반쯤 쓴 파일을 보지 않는다.
- 파일 검증: 일반 파일인지, 소유자가 현재 사용자인지, 링크 수가 1인지, 권한이 0600인지 확인한다. 디렉터리도 다른 사용자에게 열려 있으면 거부한다. 경로는 디렉터리 디스크립터(`dir_fd`)와 `O_NOFOLLOW`로 열어 심볼릭 링크 바꿔치기를 막는다.
- 갱신 시점: 만료 60초 전부터 새로 발급한다. 발급 실패 직후 60초 동안은 재시도하지 않고 "일시적으로 사용 불가"로 응답해 제한에 걸리는 연쇄를 막는다.
- 키 식별: 앱 키와 시크릿을 해시한 값을 캐시에 함께 저장한다. 설정이 바뀌면 이전 토큰을 쓰지 않는다.
운영하면서 알게 된 것
4·8개 프로세스를 동시에 띄우는 시험에서 잠금 파일을 처음 만드는 `os.open`이 드물게 ENOENT를 냈다. 권한 검사를 느슨하게 하는 대신 이 생성 오류만 같은 대기 예산 안에서 재시도하도록 했다. 이후 4·8·16개 프로세스 시험과 오류 주입 시험에서 발급은 매번 한 번이었다.
또 "모의 투자"와 "실전" 환경을 한 프로세스에 섞지 않는 편이 안전했다. 실전 조회는 별도 프로세스로 떼고, 공개 경로는 GET 조회만 남겼다. 비밀값을 가진 프로세스의 범위가 좁을수록 캐시 설계도 단순해진다.
정리
- 같은 서버 안의 프로세스 공유라면 파일 캐시와 `flock`으로 충분하다.
- 잠금을 얻은 뒤 캐시를 다시 읽어야 중복 발급이 사라진다.
- 쓰기는 임시 파일과 `os.replace`로 원자적으로 한다.
- 잠금 대기에는 마감 시각을 두고, 실패 후 재시도 간격을 강제한다.
- 비밀값 파일은 권한·소유자·링크 수까지 확인하고 진단 출력에 남기지 않는다.
참고 자료
- fcntl — The fcntl and ioctl system calls — Python Software Foundation
- os — Miscellaneous operating system interfaces — Python Software Foundation