기술

화면이 깜박인다는 신고, 응답 코드는 401 이 아니라 428 이었습니다

2026.09.299분 읽기

신고는 한 줄이었습니다. '로그인 오류, 화면이 깜박깜박'. 신고한 쪽의 추정도 같이 왔습니다. 토큰이 만료된 것 같다는 이야기였습니다.

그럴듯한 추정입니다. 세션이 끊기면 화면이 로그인으로 튕기고, 사용자 눈에는 그것이 깜박임으로 보입니다. 다만 그 추정은 가설 하나입니다. 로그를 열기 전에 결론을 옮겨 적으면, 맞는 설명과 그럴듯한 설명을 구분할 기회가 사라집니다.


로그가 가리킨 것은 401 이 아니라 428 이었습니다

서버 로그를 열었습니다. 인증 실패를 뜻하는 401 은 늘어나지 않았습니다. 대신 HTTP 428(Precondition Required, 서버가 '선행 조건을 먼저 처리하라'고 답하는 상태 코드)이 한 분에 1,747건 찍혀 있었습니다. 그날 오후 시작 시점부터 누적으로는 9,913건이었습니다.

이 프랜차이즈 ERP 는 비밀번호 만료를 서버 인터셉터에서 판정합니다. 만료된 계정이 어떤 API 를 부르든 428 과 함께 '비밀번호 변경이 필요하다'는 에러 코드를 돌려줍니다. 즉 이 숫자는 인증이 끊긴 것이 아니라, 서버가 '지금부터 이 상태다'라고 계속 말하고 있었다는 뜻입니다.

숫자를 화면 단위로 환산하면 사건의 크기가 보입니다. 대시보드는 한 번 로드될 때 API 를 7개 부릅니다. 분당 1,747건은 대시보드 전체 로드가 1분에 약 250회 일어났다는 말입니다. 사용자 한 명은 76초 동안 로그인을 3번 시도했습니다. 루프에서 빠져나오려 한 흔적입니다.


일괄 발급은 일괄 만료가 됩니다

왜 하필 그날이었는지는 계정 발급일을 보고 알았습니다. 문제가 난 계정들의 발급일이 전부 같은 날이었습니다. 비밀번호 만료 주기는 3개월입니다. 정확히 석 달 뒤 낮에, 그 계정들의 비밀번호가 한꺼번에 만료됐습니다.

여기서 중요한 것은 시각입니다. 만료가 로그인 시점에 걸린 것이 아니라 사람들이 화면을 보고 있는 세션 도중에 걸렸습니다. 로그인 화면은 만료를 다루도록 설계돼 있었지만, 이미 들어와 있는 화면은 그렇지 않았습니다.

만료 주기가 정책이면 발급일의 분포도 정책입니다. 같은 날 계정을 무더기로 만들면 같은 날 낮에 무더기로 만료됩니다. 그때 화면이 어떻게 되는지는 사실 계정을 발급하는 날 이미 정해집니다.


리다이렉트 가드 둘이 서로 다른 원천을 봤습니다

루프의 모양은 단순했습니다. 프론트는 428 을 받으면 비밀번호 변경 화면으로 보냅니다. 그 화면의 진입 훅은 브라우저 로컬 저장소에 있는 '변경 필요' 플래그를 확인합니다. 그런데 그 플래그는 로그인할 때 한 번만 기록되고, 토큰 회전 시점에는 갱신되지 않습니다.

그래서 변경 화면이 로컬 사본을 보고 '변경할 필요 없음'으로 판단하고 대시보드로 되돌립니다. 대시보드는 첫 API 에서 다시 428 을 받습니다. 그리고 변경 화면으로 갑니다. 보내는 쪽은 서버를 믿고 되돌리는 쪽은 로컬 사본을 믿습니다. 같은 질문에 답이 둘이라 그 사이를 왕복합니다.

428 응답과 로컬 플래그가 서로를 되돌려 리다이렉트 루프가 되는 구조도


고칠 자리는 세 군데였습니다

루프를 끊는 방법은 하나가 아니었습니다. 세 가지를 놓고 비교했습니다.

선택지

내용

판단

A. 응답 인터셉터

428 을 받는 순간 로컬 플래그를 true 로 세우고 나서 이동

채택 — 진실이 도착하는 자리에서 사본을 갱신

B. 변경 화면

로컬 사본을 보지 않고 서버에 다시 질의

요청이 하나 늘고, 세 앱을 모두 고쳐야 함

C. 토큰 회전 응답

회전 응답에 플래그를 실어 로컬을 갱신

서버 응답 규격 변경이 필요

A 를 골랐습니다. 이유는 두 가지입니다. 하나는 사본을 갱신할 자리가 서버가 새 상태를 말해 주는 그 응답이라는 점입니다. 무효화 경로가 한 곳으로 모입니다. 다른 하나는 뒤에 나올 이유인데, 같은 계보의 앱 하나에 이미 그 해법이 들어가 있었습니다.


운영 데이터를 건드리지 않고 재현했습니다

원인 가설은 섰지만 재현이 없으면 고쳤는지 알 수 없습니다. 문제는 재현하려면 계정 상태를 만료로 바꿔야 한다는 점이었습니다. 운영 데이터를 바꾸는 일이라 그대로 가면 안 됐습니다.

우회 경로는 이미 그 상태인 계정을 쓰는 것이었습니다. 마침 '변경 필요' 플래그가 걸려 있던 관리자 계정이 있었습니다. 브라우저 에이전트로 접속해 로그인하니 변경 화면에서 정상적으로 멈췄습니다. 여기서 브라우저 콘솔로 로컬 플래그만 false 로 바꿔 대시보드에 들어갔습니다.

6초 동안 URL 전환이 22회 일어났습니다. 서버 로그에는 같은 분에 428 이 469건 찍혔습니다. 화면에서 빠져나갈 방법은 주소창에 로그인 주소를 직접 입력하는 것뿐이었습니다. 로컬 사본이 원인인 버그는 콘솔에서 사본만 만지면 재현됩니다. 데이터베이스를 쓰지 않아도 됩니다.

이 과정에서 하나가 같이 잡혔습니다. 프로젝트 지시문에 적힌 그 관리자 계정의 상태 줄이 '변경 필요 아님'으로 틀려 있었습니다. 실제로는 '필요' 상태였고, 그래서 그 계정의 API 가 전부 428 로 답해 자체 검증이 막혀 있었습니다. 문서가 틀린 값을 들고 있으면 왜 안 되는지를 한 번 더 찾게 됩니다. 실측값으로 정정했습니다.


한 앱에서 고친 것은 나머지 앱에서 안 고친 것입니다

이 ERP 에는 같은 계보의 웹 앱이 셋 있습니다. 관리자 웹, 매니저 웹, 점주 웹입니다. 세 앱을 나란히 열어 보니 점주 웹에는 문제가 없었습니다. 428 을 받는 순간 로컬 플래그를 먼저 세우는 처리가 이미 들어가 있었습니다.

그 처리는 한 달 전에 넣은 것이었습니다. 관리자가 점주 비밀번호를 재발급했더니 점주 화면이 흰 화면으로 멈춘 사건이 있었고, 그때 같은 모양의 루프를 한 줄로 끝냈습니다. 그 한 줄이 점주 웹에만 들어갔습니다.

그래서 한 달 전에 끝냈다고 생각한 일이 형제 앱 둘에서 분당 1,747건으로 돌아왔습니다. 수정 당시 나머지 두 앱을 훑는 데 걸렸을 시간은 몇 분이었을 겁니다. 그 몇 분을 건너뛴 대가가 한 달 뒤의 장애였습니다.

같은 계보의 웹 앱 세 개 중 한 곳만 수정돼 있던 상태를 비교한 그림


수정과 재검증

관리자 웹과 매니저 웹에 점주 웹과 똑같은 처리를 넣었습니다. 428 을 받으면 로컬 플래그를 true 로 먼저 세우고 나서 변경 화면으로 보내는 순서입니다. 각각 커밋하고 같은 날 오후에 배포했습니다. 배포 여부는 빌드 식별자가 바뀌었는지와 프로세스가 새로 기동됐는지로 확인했습니다.

재검증은 재현과 똑같은 순서로 돌렸습니다. 로그인하고, 콘솔에서 로컬 플래그를 false 로 바꾸고, 대시보드에 들어갔습니다. 두 앱 모두 화면 전환 1회에서 변경 화면에 멈췄고 플래그는 true 로 다시 세워져 있었습니다.

수정 전 6초에 22회 전환하던 화면이 수정 후 1회 전환으로 멈춘 비교 그림


루프가 남긴 것

장애가 끝난 뒤에도 남는 것이 있습니다. 세 가지를 후속으로 잡았습니다.

만료 주기를 3개월에서 6개월로 늘리기로 고객이 결정했습니다. 정책 상수와 테스트를 함께 커밋했습니다.

428 이 WARN 레벨에 스택 113줄로 기록되고 있었습니다. 루프가 도는 동안 하루 로그가 127MB 였습니다. 예상된 응답이므로 한 줄짜리 INFO 로 낮춥니다.

사고 당일에 비밀번호를 바꾼 사람들은 다시 3개월짜리 만료일을 받았습니다. 정책을 늘려도 이미 저장된 만료일은 소급되지 않습니다. 석 달 뒤 같은 날에 그 사람들의 변경 화면이 다시 뜹니다.

두 번째는 따로 짚을 만합니다. 428 은 오류가 아니라 예상된 응답입니다. 예상된 응답이 스택 113줄로 찍히면 정상 흐름에서도 로그가 두껍고, 정작 폭주했을 때 그 사실이 눈에 띄지 않습니다. 정상 흐름의 응답은 한 줄이어야 합니다. 다만 로그 레벨을 낮추는 것과 건수를 세는 것은 다른 일입니다. 이번 사건을 재구성한 근거는 분당 건수였고, 그 관측은 레벨과 무관하게 유지돼야 합니다.


같은 모양을 피하려면

이번 건을 정리하면서 다음에 같은 자리에서 쓸 점검 항목을 뽑았습니다.

같은 코드 계보의 앱이 여럿이면, 한 곳을 고칠 때 나머지를 훑고 커밋 범위에 적는다

서버 상태를 로컬에 복사해 두는 곳이 있으면, 그 사본을 무효화하는 자리가 어디인지 먼저 정한다

리다이렉트 가드가 둘 이상이면 둘이 같은 원천을 보는지 확인한다

계정을 무더기로 발급할 때 만료가 몰리는 날을 같이 계산한다

정상 흐름에서 자주 나오는 응답 코드가 어떤 로그 레벨로 찍히는지 확인한다


마무리

신고 한 줄에서 출발해 응답 코드 한 자리에서 방향이 갈렸습니다. 401 이었다면 세션 이야기였고, 428 이었기 때문에 상태 동기화 이야기가 됐습니다. 로그가 없었으면 재구성이 안 됐을 사건입니다.

남은 것은 완전한 해법이 아닙니다. 탭을 여러 개 열어 두면 다른 탭이 옛 사본을 들고 대시보드에 들어갈 수 있습니다. 이번에는 문제가 되지 않았고, 그 탭도 첫 API 에서 428 을 받아 사본이 갱신됩니다. 더 근본적인 방식은 변경 화면이 서버에 다시 묻는 쪽이지만 요청이 하나 늘고 세 앱을 모두 바꿔야 해서 이번에는 가지 않았습니다.

제일 비싸게 배운 것은 범위 쪽입니다. 버그를 고칠 때 '고쳤다'가 어디까지인지를 묻는 일은, 같은 버그를 한 달 뒤에 다시 만나는 것보다 훨씬 쌉니다.

#HTTP428#리다이렉트루프#비밀번호만료#상태동기화#운영회고