어느 날 어드민 콘솔에서 실무자들 계정에서 시스템에 New Item 등록이 안 되는 문제가 발생했다. 정작 나는 잘 됐고.
처음 의심한 세 개는 전부 헛다리였고, 진짜 범인은 "2주 전에 추가된 자동완성 기능"이었다.
다른 사람이 작성한 코드를 추적하는 과정이 꽤나 흥미로웠어서 처음부터 끝까지 추적한 기록을 남겨본다.


발단 "저는 되는데요?"

제보는 단순했다. 어드민 콘솔에서 프로젝트 "등록"을 누르면 403이 찍힌다는 것.

Failed to load resource: the server responded with a status of 403 ()
AxiosError: Request failed with status code 403

버그를 재현해보니 개발자에게서

"저는 403 없이 등록 잘 되는데요."

라는 말을 들을 수 있었다.
같은 화면, 같은 버튼인데 사람에 따라 갈린다. 이걸 진지하게 받아들이기까지 나는 세 번 헛다리를 짚었다.


헛다리 3연속

1. "요즘 쿼리 무거워져서 504 뜨던데, 부하 문제 아님?"
근데 상식적으로 트래픽 부하가 사람을 가려서 나는 건 아니니까.. 패-쓰.

2. "어제 머지된 커밋이 범인이겠네"
열어보니 DB 컬럼 하나 딱 추가한 게 끝이었음. 권한 로직은 아무것도 변한 게 없으니.. 패-쓰.

3. "아~ 토큰 만료겠네"
페이지 로드 시 한 번만 토큰을 읽는 기가 막힌 레거시 코드를 발견! 로그아웃 후 다시 로그인하면 해결 되실거라고 전달. 결과는? 여전히 403. ....


드디어..

세 번 틀리면서 bitbucket에 있던 최근 커밋을 다시 뒤져보기 시작했다. 뒤져보면서 다시 처음 왔던 제보를 읽어보니,

  • "사람마다 다르다" -> 부하가 아니라 신원 기반 = 인증/인가
  • "재로그인해도 안 된다" → 로그인(인증) 문제가 아니라 권한(인가) 문제
  • "권한 코드는 몇 달째 그대로인데, 1~2주 전엔 되던 사람이 지금 안 된다" -> 어라라?

드디어 단서를 찾았다.

고장난 지점(권한 체크)이 안 바뀌었는데 고장이 났다.
그럼 범인은 권한이 아니라, 그걸 새로 건드리기 시작한 쪽!


실패한 요청 열어보기

추측을 멈추고 Network 탭에서 빨간 요청을 눌러봤다.

GET  <내부 API 게이트웨이>/user/list?page=1&size=100   →  403
Response:  { "message": "forbidden" }

여기서 세 개가 한 번에 풀렸다.

1. 이건 "등록"(POST)이 아니다. 유저 목록 조회다. 등록 폼이 열릴 때 유저 100명을 부르고 있었다.

2. 401이 아니라 403이다. 이 차이가 열쇠.

의미
401 Unauthorized"너가 누구인지 모르겠다" (로그인/토큰)
403 Forbidden"너가 누군진 아는데, 이건 안 된다" (권한)

토큰은 멀쩡했고(로그인 정상), 그냥 이 행동이 인가에서 거부된 거다. 재로그인이 안 통한 이유가 여기 있었다.

3. 응답 바디가 {"message":"forbidden"}. 우리 앱은 이렇게 안 짖는다(에러코드+상세메시지 구조를 쓴다). 영문 소문자 message 한 줄은 앱이 아니라 그 앞단(게이트웨이)이 만든 응답이라는 지문이다.

-> 403은 백엔드 코드가 아니라 API 게이트웨이 인가 정책이 사람 단위로 잘라낸 것.


계층을 따라 내려가기

가설을 코드로 검증했다.

백엔드: /user/list는 라우터가 대놓고 "관리자 전용" 태그. 핸들러는 토큰+게이트웨이 헤더만 요구하고 역할 기반 403을 직접 안 던지고 있었다. 즉, 403 출처는 앱이 아니라 게이트웨이 확정!!

인프라(IaC): 게이트웨이가 PEP(Policy Enforcement Point, 정책 집행 지점)/PDP(Policy Decision Point, 정책 결정 지점) 정책기반 인가로 돼 있었다.

요청 -> API Gateway ( /user/{proxy+} )
    -> PEP (정책 집행 Lambda)
    -> PDP (정책 결정)
    -> 사용자별 정책 평가 (IAM 스타일: role / policy / action / effect)
    -> Deny -> 403 { "message": "forbidden" }

모든 /user/*가 이 authorizer를 타는 구조. 관리자인 나는 Allow, 비관리자 두 사람은 정책에 없어서 Deny였던 것. "사람마다 다름"의 실체.를 찾아냈다!

결정타 = 인프라 히스토리: 이 인가 규칙 메인 브랜치는 몇 달째 변경 없음. 즉 프로덕션 권한 규칙은 최근에 안 바뀌었다. -> "안 바뀐 권한 + 최근 회귀"가 히스토리로 확증됐다. 범인은 caller..! 요청을 받는 쪽(API GW & PEP/PDP)은 그대로였다면 결국 보내는 쪽이(Client/Caller)가 유력한 용의자인 것.


근본 원인은.. "부차 기능이 핵심 동작을 죽이고 있음"

콘솔 프론트 커밋을 따라가니 2주 전 등록 폼에 "담당자 자동완성"이 추가돼 있었다. 폼 최상단:

// 폼이 마운트되면 무조건 실행
const { data: users } = useUserList({ page: 1, size: 100 });  // → 관리자 전용 /user/list

근데 정작 담당자 필드는 이랬다.

<input list="manager-options" placeholder="담당자 입력 또는 선택" />
<datalist id="manager-options">
  {users?.items.map((u) => <option key={u.id} value={u.nickname} />)}
</datalist>

담당자는 원래부터 자유 텍스트였다. 유저 목록은 자동완성 추천을 채우는 부가기능일 뿐. 목록이 없어도 입력은 되어야 정상이다.

그런데 왜 폼 전체가 죽었나? 전역 설정에 답이 있었다.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { throwOnError: true },   //  모든 쿼리 에러를 ErrorBoundary로 throw
  },
});

useUserList가 이걸 상속. 그래서 비관리자에게서 403이 나면 ErrorBoundary로 throw -> 등록 페이지 전체가 에러 화면으로 대체 -> "등록 안 됨".

한 줄 요약: "자동완성 쿼리가, 전역 throwOnError: true 때문에, "반드시 되어야 하는" 등록의 크리티컬 패스에 하드와이어링돼 있었다. 부가기능 403 하나가 핵심 동작을 통째로 날렸다.


수정은 딱 2줄

핵심은 그 쿼리 하나만 non-fatal로. 공유 훅이라 전역으로 바꾸면 다른 화면에 영향 가니까 호출부에서만 옵트아웃.

// 훅: throwOnError를 옵션으로 (기본값은 기존 동작 유지)
export const useUserList = (params, options?: { throwOnError?: boolean }) =>
  useQuery({
    queryKey: ['users', params],
    queryFn: () => getUsers(params),
    throwOnError: options?.throwOnError ?? true,
  });

// 등록 폼: 자동완성은 실패해도 되니 throw 끄기
const { data: users } = useUserList({ page: 1, size: 100 }, { throwOnError: false });

이제 403이 나도 throw하지 않고, usersundefined, <datalist>만 비고, 입력은 자유 텍스트로 정상.


배포가 더 험난했다

수정은 2줄인데 배포가 산이었다.

  • 인프라 오너 퇴사, 프론트 배포 담당 연차. 결국 내가 배포.
  • 배포 방식이 어디에도 문서화 안 돼 있었다(암묵지). 뒤지니 CodeBuild + SST, main 머지 시 자동 배포 구조였고 담당자에게 짧게 확인받아 확정.
  • 커밋하려니 commitlint가 막았다. [티켓-번호] type: 제목 형식 강제. 맞춰서 재커밋.
  • 브랜치 -> PR -> main 머지 -> 자동 배포 -> 두 사용자 실계정 "등록" 성공 확인. 종료.

회고하며.. 얻은 교훈

1. "안 바뀐 게 고장났으면, 새로 부른 쪽을 찾아보기."
회귀인데 고장난 지점 코드가 안 바뀌었다면, 그걸 새로 의존하기 시작한 코드를 의심하기

2. 401 vs 403 구분.
403 + 재로그인 무효 = 십중팔구 권한 문제

3. 추측 전에 실패한 요청 읽기.
URL·상태코드·응답 바디·헤더. 특히 바디의 형식이 "누가 이 에러를 만들었나"(앱 vs 게이트웨이)를 알려주는 지문

4. Blast radius 관리하기. 부차 기능은 non-fatal로.
크리티컬 패스가 부가기능의, 그것도 상위 권한 API 실패에 묶이면 안 된다. 전역 throwOnError: true는 편하지만 비핵심 쿼리까지 화면을 죽일 수 있음

5. 버그를 권한 확대로 덮지 마라.
"되게 만드는" 가장 빠른 길이 종종 보안 경계를 넓히는 길이 될 수도.... fix와 band-aid를 구분해야 함


요청 흐름 한눈에

[사용자: '등록' 클릭]
        │
        ▼
[등록 폼 마운트] ──> useUserList ──> GET /user/list
                                        │
                                        ▼
                            ┌── API Gateway (PEP/PDP 인가) ──┐
                            │                                │
                   관리자 (Allow)                    비관리자 (Deny)
                            │                                │
                     200 목록 채움                403 {message: forbidden}
                            │                                │
                            │                    ┌── 전역 throwOnError? ──┐
                            │                    │                        │
                            │            Before(버그)                 After(수정)
                            │            ErrorBoundary로 throw       throwOnError:false
                            │            → 폼 전체 붕괴 💥           → datalist만 비고 폼 정상 ✅
                            └────────────────────┴────────────────────────┘

마치며

2줄짜리 수정이었지만, 거기까지 가는 길은 틀린 가설 3개 -> 관찰로 방향 전환 -> 계층 추적이었다. 진짜 어려운 건 코드가 아니라 "어디를 보느냐"였고, 그걸 잡아준 건 화려한 도구가 아니라 제보자의 관찰 세 마디실패한 요청 한 줄이었다.

혹시 지금 "분명 아무것도 안 바꿨는데 갑자기 안 돼요" 같은 이슈를 붙잡고 있다면.. 안 바뀐 걸 새로 부르기 시작한 쪽을 한번 찾아보시길. 피-쓰 ✌️

profile
무럭무럭자라라

0개의 댓글