Alice는 워크스페이스 alpha의 구성원입니다. /workspaces/alpha/documents/doc-b/를 요청했는데 응답에는 beta의 문서가 담겨 있습니다. 서버는 Alice를 식별했고 워크스페이스 소속도 확인했습니다. 둘 다 통과했습니다. 요청은 어디서 잘못되었을까요?

이 학습 예시는 가상의 문서와 신원을 사용합니다. 실행에는 Node.js 22 이상이 필요하지만 읽기 방법은 다른 언어와 프레임워크에도 적용할 수 있습니다. 질문은 구체적입니다. 어떤 워크스페이스를 확인했고, 어떤 워크스페이스로 문서 조회를 제한했을까요?

요청 하나와 충족해야 할 규칙에서 시작하세요

메서드, 경로, 신원, 기대 결과를 함께 기록하세요.

GET /workspaces/alpha/documents/doc-b/
Demo identity: alice, a member of alpha only
Stored document: doc-b belongs to beta
Expected: 404, with no document in the response
Broken result: 200, containing Beta release notes

예시 정책은 구성원에게 해당 워크스페이스의 모든 문서 접근을 허용합니다. 문서별 공유나 관리자 예외는 없습니다. alpha 구성원이 alpha 밖의 문서를 요청하면 없는 문서와 같은 상태 코드와 본문을 받습니다. 가입하지 않은 워크스페이스를 지정하면 문서 저장소 호출 전에 403이 됩니다.

따라갈 관계는 두 가지입니다. 인증된 사용자가 URL의 워크스페이스에 속해야 하며, 반환되는 문서도 같은 워크스페이스에 속해야 합니다. 첫 관계를 확인했더라도 데이터 조회에서 두 번째 관계를 보장해야 합니다.

실제 보고서에서는 애플리케이션 빌드나 소스 버전도 기록하세요. 작업 사본의 라우트가 요청을 처리한 코드와 일치해야 합니다. 로컬 재현에는 가상 fixture를 사용하세요.

핸들러를 열기 전에 라우트 등록을 찾으세요

request-flow.mjs와 request-flow.test.mjs를 같은 폴더에 다운로드하세요. 편집기의 텍스트 검색으로 경로의 특징적인 명사인 workspaces, documents 등을 찾으세요. ripgrep이 있다면 다음 명령으로 범위를 좁힐 수 있습니다.

rg -n 'workspaces|documents' request-flow.mjs
rg -n 'middleware|runMiddleware|requireWorkspaceMember|findById|findInWorkspace' request-flow.mjs

createApp을 여세요. 라우트 객체에 메서드, 경로 패턴, 미들웨어, 선택한 핸들러가 모여 있습니다.

const route = {
  method: 'GET',
  pattern: /^\/workspaces\/([a-z0-9-]+)\/documents\/([a-z0-9-]+)\/$/,
  middleware: [authenticate, requireWorkspaceMember],
  handler: lookup === 'unscoped' ? getDocumentUnsafe : getDocument,
};

다음 몇 줄도 따라가세요. handle은 new URL(request.url).pathname을 매칭하고 메서드를 확인한 뒤 캡처한 두 구간을 workspaceId, documentId에 할당합니다. 두 값 모두 쿼리 매개변수에서 오지 않습니다. 예시는 소문자, 숫자, 하이픈을 허용하며 위와 같이 끝 슬래시가 필요합니다. 범용 라우터는 아닙니다.

프레임워크 애플리케이션에서는 전체 경로가 /workspaces/:workspaceId 같은 상위 마운트와 /documents/:documentId 같은 하위 라우트로 나뉠 수 있습니다. 마운트를 읽고 부모 매개변수가 자식에게 어떻게 전달되는지 확인하세요. 핸들러의 검색 결과만으로는 어떤 미들웨어가 호출자를 감싸는지 알 수 없습니다.

미들웨어를 실제 실행 순서로 읽으세요

runMiddleware는 reduceRight로 핸들러를 감쌉니다. [authenticate, requireWorkspaceMember]에서는 인증, 소속 확인, 핸들러 순서로 실행하며 각 단계가 next()를 호출해야 다음으로 진행합니다. return next()의 값은 handle로 돌아가고, 그곳의 catch가 던져진 HttpError를 응답으로 바꿉니다.

Request → path and method match → authenticate
        → requireWorkspaceMember → document lookup → JSON Response
                                   ↘ thrown error → handle's catch → error Response

authenticate는 고정 맵에서 요청의 Authorization 헤더를 조회해 ctx.user에 결과를 넣습니다. demo-alice, demo-bob은 검증된 세션을 대신하는 공개 fixture 입력입니다. 예시에서 주체의 출처를 따라가기에는 충분하지만 실제 애플리케이션의 세션이나 토큰 검증은 별도로 읽어야 합니다.

이제 소속 확인 미들웨어를 여세요.

async function requireWorkspaceMember(ctx, next) {
  const { workspaceId } = ctx.params;
  const allowed = ctx.user.workspaceIds.includes(workspaceId);
  ctx.trace.push(`membership:${workspaceId}:${allowed ? 'allowed' : 'denied'}`);
  if (!allowed) throw new HttpError(403, 'Forbidden');
  return next();
}

Alice와 alpha를 지정한 경로에서 allowed는 참입니다. 이 함수는 documentId를 살펴보거나 문서를 읽지 않습니다. 메모에는 정확히 “Alice는 alpha 라우트에 진입할 수 있다”라고 적으세요. “권한 있음”이라고만 쓰면 실제 검사 범위를 넘어 해석하기 쉽습니다.

실행이 멈추는 지점도 표시하세요. 데모 신원이 없거나 알려지지 않았으면 소속 확인 전에 401을 반환합니다. 비구성원은 문서 핸들러 전에 403을 받습니다. 미들웨어가 판단 전에 next()를 호출하면 핸들러가 이미 실행될 수 있습니다. 이 호출 위치는 미들웨어 이름만큼 중요합니다.

사용자, 요청한 워크스페이스, 저장된 문서를 구분하세요

실패하는 요청은 네 줄로 정리할 수 있습니다.

값 출처 이 요청의 값 다음에 따라갈 곳
사용자와 소속 authenticate가 fixture 신원 맵 조회 Alice, ['alpha'] requireWorkspaceMember
요청한 워크스페이스 첫 번째 라우트 캡처 alpha 소속 검사, 이어서 조회 인자
요청한 문서 두 번째 라우트 캡처 doc-b 문서 조회
문서의 실제 워크스페이스 저장된 행 beta 조회 조건과 응답

생산자와 소비자를 확인할 때 파일과 줄 위치도 추가하세요. 큰 애플리케이션에서는 서로 다른 패키지의 타입일 수 있지만 구분은 같습니다. URL은 요청을 표현하고 신원은 소속을 제공하며 저장된 행은 소유 관계를 제공합니다.

두 ID는 함께 도착합니다. 핸들러를 나갈 때도 함께 있는지 확인하세요.

핸들러가 평범해 보여도 조회를 여세요

의도적으로 안전하지 않게 만든 핸들러는 다음과 같습니다.

async function getDocumentUnsafe(ctx) {
  ctx.trace.push(`lookup:unscoped:${ctx.params.documentId}`);
  const document = await ctx.store.findById(ctx.params.documentId);
  if (!document) throw new HttpError(404, 'Not found');
  return json(200, document);
}

저장소에는 documentId만 전달됩니다. findById를 따라 createStore로 가면 조건은 document.id === documentId입니다. 워크스페이스 비교도, 직렬화 전의 후속 소유 관계 검사도 없습니다. 앞의 미들웨어는 alpha를 허용했지만 이 조회는 어느 워크스페이스의 행이든 반환할 수 있습니다.

경쟁 상태나 위조 세션은 필요하지 않습니다. Alice가 자신의 신원과 허용된 alpha 경로를 유지하면서 fixture의 doc-b ID만 지정하면 됩니다. 애플리케이션은 한 리소스 범위를 검사하고 다른 범위를 읽습니다.

실제 프로젝트에서 헬퍼 이름만으로 판단하면 근거가 부족합니다. 저장소 메서드가 내부에서 테넌트 필터를 추가하거나 범위가 지정된 데이터베이스 세션을 상속하거나 데이터베이스 행 수준 정책에 의존할 수 있습니다. 인자가 없다고 범위 제한도 없다고 결론 내리기 전에 선택된 구현과 설정을 읽으세요. 여기서는 메모리 내 조건식이 조회의 전부이므로 직접 확인할 수 있습니다.

전체 핸들러 체인으로 반례를 실행하세요

두 다운로드 파일이 있는 폴더에서 실행하세요.

node request-flow.mjs
node --test request-flow.test.mjs

설치 단계, 계정, 서비스는 필요하지 않습니다. makeRequest가 Web Request를 만들고 테스트가 이를 앱의 handle 함수에 전달하면 Web Response를 반환합니다. example.invalid URL은 메모리에서 파싱만 합니다. 예시는 fetch를 호출하거나 수신 소켓을 열지 않습니다.

데모는 다음 응답 줄을 출력하고 각 줄 아래에 방문 단계와 기록된 저장소 호출을 보여 줍니다.

normal: 200 {"id":"doc-a","workspaceId":"alpha","title":"Alpha release notes"}
reproduce: 200 {"id":"doc-b","workspaceId":"beta","title":"Beta release notes"}
fixed: 404 {"error":"Not found"}

잘못된 요청의 방문 단계는 다음과 같습니다.

request:GET -> route:alpha:doc-b -> authenticate:alice -> membership:alpha:allowed -> lookup:unscoped:doc-b -> response:200

trace 배열은 이 fixture에 추가한 계측이며 응답 밖에 보관합니다. 테스트는 반환 문서와 기록된 findById('doc-b') 호출도 확인합니다. 이 검증문이 분기 순서와 실제 반환 데이터를 연결합니다.

reproduce:라는 테스트는 안전하지 않은 구현이 beta의 문서를 반환하면 통과합니다. 녹색 결과는 결함 재현에 성공했다는 뜻입니다. 별도의 테스트는 같은 요청에서 수정된 구현이 404를 반환하도록 요구합니다.

확인한 워크스페이스를 조회에 전달하세요

수정한 핸들러는 두 라우트 값을 모두 유지합니다.

async function getDocument(ctx) {
  const { workspaceId, documentId } = ctx.params;
  ctx.trace.push(`lookup:scoped:${workspaceId}:${documentId}`);
  const document = await ctx.store.findInWorkspace(workspaceId, documentId);
  if (!document) throw new HttpError(404, 'Not found');
  return json(200, document);
}

findInWorkspace 안의 조건은 document.workspaceId === workspaceId와 document.id === documentId를 모두 요구합니다. 소속 미들웨어는 이미 같은 workspaceId를 확인했습니다. fixture는 두 단계 사이에서 ctx.params를 변경하지 않으며 각 실행 동안 문서와 소속도 고정됩니다.

/workspaces/alpha/documents/doc-b/는 이제 일치하는 결과가 없습니다. 없는 ID와 다른 워크스페이스의 ID 모두 404와 {"error":"Not found"}를 반환합니다. 상태와 본문을 같게 해 어느 경우인지 명시적으로 드러내지 않습니다. 테스트는 타이밍이나 다른 부채널에 관해 주장하지 않습니다.

전역 조회 뒤에 document.workspaceId를 확인해도 이 응답 유출은 막을 수 있습니다. 범위를 지정한 조회는 저장소 호출에서 제약을 명시하고 다른 워크스페이스의 행을 핸들러에 돌려주지 않습니다. 이 메모리 예시에서는 Array.find가 여전히 조건을 평가하기 위해 행을 살펴봅니다. 기록된 호출은 조회 인자를 보여 줄 뿐 데이터베이스 I/O 횟수나 물리적 격리를 보여 주지 않습니다.

SQL이나 ORM에 적용한다면 생성된 조건과 매개변수 바인딩을 살펴보고 실제 데이터 계층으로 테스트하세요. 소속 취소, 문서 이동, 공유 문서, 다른 라우트에는 각자의 규칙과 검사가 필요합니다.

성공 경로와 실행이 멈춰야 할 지점을 테스트하세요

테스트는 열 개입니다. 의도적인 재현 하나와 수정 앱의 검사 아홉 개이며 일부는 여러 입력을 다룹니다.

요청 또는 조건 기대 응답 확인할 저장소 동작
Alice가 alpha/doc-a, Bob이 beta/doc-b 읽기 200, 올바른 문서 두 ID가 findInWorkspace에 전달됨
Alice가 alpha/doc-b 읽기 404, 문서 없음 조회 범위가 alpha로 유지됨
Alice가 alpha/missing 읽기 이전 행과 같은 상태와 본문 일치하는 문서 없음
데모 신원이 없거나 알려지지 않음 401 저장소 호출 없음, 소속 검사에 도달하지 않음
Alice가 beta/doc-b 읽기 403 저장소 호출 없음
Alice가 alpha/doc-b?workspaceId=beta 읽기 404 쿼리 매개변수가 라우트 범위를 대체할 수 없음
알 수 없는 경로, 알려진 경로의 잘못된 메서드 404, Allow: GET이 있는 405 저장소 호출 없음
저장소가 예상 밖의 오류를 던짐 500, 일반 오류 본문 오류가 응답 래퍼에 전달됨

표의 축약 경로는 /workspaces/{workspaceId}/documents/{documentId}/로 확장되며 쿼리 문자열은 끝 슬래시 뒤에 붙습니다. 성공하는 두 읽기는 모든 요청을 거부하는 “수정”을 막습니다. 무호출 검증은 문서 저장소에 접근한 후에야 거부하는 구현을 잡아냅니다. 함께 사용하면 최종 상태 코드보다 더 많은 것을 확인합니다.

이 테스트는 해당 라우트의 프로세스 내 요청 처리, 미들웨어 순서, 조회 인자, JSON 응답을 다룹니다. HTTP 전송, 역방향 프록시, 운영 신원 검증, 데이터베이스는 실행하지 않습니다. 실제 애플리케이션에 적용할 때는 실제 HTTP 어댑터에서 시작해 추적한 핸들러와 미들웨어를 선택하는지 확인하세요.

다른 사람이 검증할 수 있는 메모를 남기세요

요청과 그 값이 사용되는 정확한 지점을 중심으로 결과를 쓰세요.

Trigger: Alice + GET /workspaces/alpha/documents/doc-b/
Route: createApp captures alpha and doc-b.
Membership: requireWorkspaceMember allows Alice for alpha.
Old read: getDocumentUnsafe passes only doc-b to findById.
Observed: 200 returns a row whose workspaceId is beta.
Fix: getDocument passes alpha and doc-b to findInWorkspace.
Regression: same request returns 404; both valid member reads still return 200.
Scope: in-memory request pipeline; production adapter and storage are untested.

소스 위치와 테스트 명령을 첨부하세요. 동료가 “이 라우트는 이미 권한을 검사하지 않나요?”라고 물으면 메모가 검사 범위와 워크스페이스 사용이 끊긴 지점을 정확히 보여 줍니다.