멤버 추가는 성공했는데 권한이 없었다 — 원본과 파생 값의 커밋 경계

읽는 데 11분
목차

팀 멤버 추가 API는 성공했는데 사용자 역할에는 새 팀이 없었다. 멤버십 원본과 빠른 조회를 위한 역할 데이터가 따로 저장되는 구조였다. 원본 저장 뒤 역할 동기화 작업을 제출했지만, 그 작업이 실패해도 API는 이미 성공할 수 있었다.

9월에 이 경로를 수정하면서 문제를 두 단계로 나눠 봤다. 왜 역할 동기화가 실패했는지, 그리고 그런 실패가 원본과 역할을 서로 다른 상태로 남겨도 되는지다. 역할 작업이 이번 한 번 성공하게 만드는 것만으로는 저장 중 실패했을 때의 불일치까지 막을 수 없었다.

자기 변경을 막는 보호 장치를 만났다

사용자 권한을 바꾸는 동안 오래된 캐시가 읽히지 않도록 변경 중이라는 표시를 남겼다. 이 글에서는 이를 읽기 차단 fence라고 부른다. 오래된 writer의 저장을 거절하는 fencing token과는 구별한다.

문제가 된 순서는 다음과 같았다. 이름과 흐름은 설명에 필요한 부분만 남겼다.

사용자 읽기 차단
  → 멤버십 저장·커밋
  → 역할 동기화 작업 제출
  → 역할 작업이 캐시 조회 시도
  → 아직 변경 중이므로 조회 거절
  → 읽기 차단 해제

보호 장치는 의도대로 작동했다. 실행 순서가 맞지 않았다. 실제 Redis와 MongoDB를 사용한 회귀 재현에서도 이 경합을 확인했다.

차단을 먼저 해제한 다음 작업을 제출하면 이 오류는 피할 수 있다. 하지만 제출 뒤 프로세스가 종료되면 메모리 속 작업이 사라질 수 있고, 역할 저장 자체가 실패해도 원본은 이미 확정돼 있다. 잠깐 기다렸다 재시도하는 것만으로 API의 저장 계약을 보장할 수는 없었다.

성공 응답에 필요한 값을 같은 트랜잭션에 넣었다

읽기용 역할 데이터는 멤버십에서 계산한 파생 값이다. 데이터를 미리 계산해 저장하는 materialized view로 볼 수 있다. 조회 비용은 줄지만 원본이 바뀔 때 함께 관리할 책임이 생긴다.

이번 요청에서는 성공 직후 권한 판단에 필요한 역할이 저장돼 있어야 했다. 두 데이터가 같은 MongoDB에 있었으므로 새 메시지 큐를 추가하는 대신 같은 세션·트랜잭션에서 변경하도록 옮겼다.

사용자별 쓰기 잠금 획득
  → MongoDB 트랜잭션 시작
  → 멤버십 변경
  → 같은 세션으로 원본 멤버십 조회
  → 사용자 역할 계산·저장
  → 함께 커밋

MongoDB는 여러 문서·컬렉션의 변경을 하나의 트랜잭션으로 묶을 수 있다. 모든 연산이 해당 세션에 연결돼야 한다. 함수 이름에 transaction이 들어가더라도 내부 조회나 갱신에 일반 context를 넘기면 의도한 경계를 벗어날 수 있다. MongoDB 트랜잭션 문서

역할을 저장하지 못해 트랜잭션이 중단되면 같은 요청의 멤버십도 남지 않는다. 일괄 추가라면 두 번째 사용자의 역할 저장 실패가 첫 번째 사용자만 반영된 상태로 끝나지 않도록 수락한 항목을 함께 확정한다. 입력 검증에서 어떤 항목을 수락하는지는 기존 API 계약을 유지했다.

트랜잭션만으로 오래된 writer가 사라지지는 않는다

로그인이나 별도 사용자 동기화도 같은 역할 데이터를 썼다. 멤버십 수정 경로만 트랜잭션으로 바꾸면 다음 실행 순서가 가능하다.

동기화 작업 A: 예전 역할을 읽음
멤버십 작업 B: 새 원본과 새 역할을 함께 커밋
동기화 작업 A: 예전 역할을 나중에 저장

그래서 두 writer가 기존 사용자별 잠금을 공유하게 했다. 여기에도 함정이 있었다. 잠금 안에 들어갔더라도 요청에 미리 담아 둔 역할을 그대로 쓰면 과거 값을 저장한다. 잠금은 코드의 실행 순서를 정할 뿐, 이미 읽은 데이터의 시간을 바꾸지 않는다.

동기화 작업은 잠금을 얻은 뒤 요청 스냅샷을 제거하고 다시 조회한다. 멤버십 트랜잭션은 캐시 reader를 거치지 않고 같은 DB 세션에서 원본을 읽는다. 여러 사용자를 잠글 때는 ID를 정렬해 획득 순서도 맞췄다.

트랜잭션·잠금·fence가 맡는 일

세 장치는 서로 다른 실패를 막는다.

장치이 경로에서 맡는 일
MongoDB 트랜잭션멤버십과 역할 저장을 함께 확정하거나 취소
사용자별 쓰기 잠금참여하는 writer 사이의 읽기·쓰기 순서 조정
캐시 읽기 차단 fence변경 도중 오래된 권한 캐시를 제공하지 않도록 차단

이 표가 전 시스템의 선형화 가능성을 증명하는 것은 아니다. 같은 잠금에 참여하지 않는 writer, lease 만료, 별도 저장소의 갱신은 각자의 경계를 확인해야 한다.

오래된 읽기를 작은 예제로 확인하기

다음 Python 코드는 경쟁의 순서만 재현한다. MongoDB 트랜잭션이나 Redis 잠금을 모사하는 구현은 아니다.

membership = {"role": "reader"}
projection = {"role": "reader"}

old_snapshot = membership["role"]
membership["role"] = "editor"
projection["role"] = "editor"  # 다른 writer가 함께 확정한 결과

projection["role"] = old_snapshot
assert projection["role"] != membership["role"]

# 쓰기 순서를 확보한 뒤 현재 원본을 읽는 경우
projection["role"] = membership["role"]
assert projection["role"] == "editor"

검사의 초점은 두 번째 assert보다 첫 번째 assert가 성립하는 실행 순서다. 실제 회귀 검사에서는 다른 writer를 잠금 전후에 끼워 넣어 양쪽 순서를 확인했다. 잠금 대기 중 일반 멤버가 관리자로 바뀌는 경우도 별도로 검사했다. 관리자 제거 권한이나 역할 회수 여부는 기다리기 전에 읽은 값으로 판단하면 안 되기 때문이다.

확인된 결과와 남은 비용

당시 로컬 검증에는 실제 Redis·MongoDB의 fence 경합, 역할 쓰기 실패 시 전체 롤백, writer 경합, 현재 역할 재검사가 포함됐다. 전체 검사의 첫 실행은 정적 검사와 일부 fixture에서 실패했다. 실패 항목을 수정·재검증하고 바뀌지 않은 통과 결과를 재사용한 기록이다. 전체 명령 한 번이 깨끗하게 통과했다고 요약하지 않는다.

이번 글에서는 소스와 그 검증 기록을 대조하고 위 예제를 실행했다. 배포 상태와 과거에 누락된 역할 데이터는 확인하지 않았다. 코드 수정만으로 기존 불일치 행이 복구되지는 않는다.

함께 커밋하는 데도 비용이 든다

역할 계산이 요청 트랜잭션 안으로 들어가면서 DB 읽기와 잠금 보유 시간이 늘어난다. 대량 작업의 처리량은 따로 측정해야 한다. MongoDB 밖의 후처리까지 같은 트랜잭션에 들어간 것도 아니다. 또 커밋 확인 뒤 잠금 해제가 실패한 경우와 커밋 응답 자체를 잃은 경우는 다르다. 전자는 확정된 저장을 되돌릴 수 없고, 후자는 저장 결과를 추가로 확인해야 한다.

다른 서비스에서 읽기용 데이터를 따로 저장한다면 성공 응답 직후 필요한 일관성을 먼저 정해 보자. 둘째 저장을 일부러 실패시켰을 때 원본만 남아도 되는가. 남아도 된다면 복구 가능한 비동기 전달이 필요하고, 남으면 안 된다면 함께 확정할 수 있는 저장 경계부터 찾아야 한다.