Loading the catalog…
Loading the catalog…
관리자 대시보드에 서버 로그아웃 API를 붙여 머지한 날, 브라우저 점검 스크립트로 로그인 흐름을 다시 돌렸습니다. A 탭에서 로그인하고, 그 뒤에 연 B 탭에서 로그아웃한 다음, A 탭에서 다시 로그인하는 순서였어요. 로그인 버튼을 눌렀는데 화면은 로그인 화면 그대로였고 에러 창이 하나 떴습니다. POST /auth/login 400 로그아웃된 토큰입니다. 토큰을 받으러 가는 요청이 토큰 때문에 거절당한 거죠. 같은 버튼을 한 번 더 누르니 그때는 로그인이 됐습니다. 결론부터 말하면 A 탭의 axios instance.defaults 에 남아 있던 옛 토큰이 로그인 요청에 실려 나갔고, 요청 인터셉터 한 줄로 막았습니다. 전날 같은 파일에서 고친 에러 창 버그도 instance.defaults 에서 나온 것이라 함께 적습니다. 로그인 요청 헤더에 옛 토큰이 있었다 처음엔 스크립트가 폼을 제대로 못 채운 줄 알았습니다. 폼 상태를 찍어 보니 두 칸 모두 값이 들어 있었고 검증 에러도 없었어요. 같은 계정으로 터미널에서 토큰 없이 curl 로그인을 보내니 200이 왔고요. 그래서 브라우저가 보낸 요청을 봤습니다. XMLHttpRequest.prototype.setRequestHeader 를 가로채 찍어 보니 로그인 요청에 Authorization: Bearer ... 가 붙어 있었습니다. 개발 서버에서 API 모듈을 동적 import 해 instance.defaults.headers.common.Authorization 도 찍어 봤더니, B 탭에서 방금 로그아웃한 바로 그 토큰이 들어 있더라고요. 토큰 없이 보낸 curl은 200, 옛 토큰을 실은 브라우저 요청은 400이었습니다. 게이트웨이는 로그인 요청이라도 헤더에 토큰이 있으면 그것부터 검사한다는 뜻이죠. localStorage는 탭끼리 나누고 defaults는 나누지 않는다 로그인과 요청 인터셉터를 줄여 옮기면 이렇습니다. // api.js (수정 전) export const instance = axios.create({ baseURL: '/api' }) export function onLoginSuccess(token) { localStorage.setItem('token', token) instance.defaults.headers.common.Authorization = `Bearer ${token}` // 이 탭의 메모리에만 남는다 } export function clearSession() { localStorage.removeItem('token') delete instance.defaults.headers.common.Authorization // 부른 탭의 기본값만 지운다 } instance.interceptors.request.use((config) => { if (config.url === '/auth/login' || config.url === '/auth/refresh') { // 토큰을 싣지 않는 요청 // delete config.headers.Authorization return config } const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) localStorage 는 같은 출처의 탭끼리 공유하고, instance.defaults 는 그 탭의 메모리에만 있습니다. 인터셉터는 요청마다 localStorage 에서 토큰을 읽어 싣고, 로그인과 토큰 갱신 요청만 건너뜁니다. B 탭에서 로그아웃하면 A 탭의 instance.defaults 는 어떻게 될까요? 그대로입니다. B 탭의 로그아웃 버튼이 부르는 clearSession() 은 B 탭의 기본값만 지워요. 인터셉터는 로그인 요청에 헤더를 새로 붙이지 않을 뿐, 이미 붙어 있던 기본값을 걷어 내지는 않았고요. 걷어 내는 줄은 있었습니다. 2024년 6월부터 주석인 채로요. 탭 둘을 iframe으로 띄우고 같은 가짜 게이트웨이를 보게 해서 이 순서를 재현했습니다. 번호는 요청 순서입니다. A 탭 위쪽 칸을 보면 저장소는 비었는데 defaults에만 token-1이 남아 있어요. B 탭의 defaults는 비어 있습니다. B 탭은 로그인을 직접 하지 않고 저장소의 토큰만 읽어 썼으니까요. 실제 앱에서도 B 탭은 다음 요청을 토큰 없이 보내 "헤더가 없다"는 다른 에러를 받았고, 이 400은 처음 로그인한 탭에서만 났습니다. 그날 머지한 서버 로그아웃이 버그를 꺼냈다 서버 로그아웃 API를 붙인 까닭은 화면의 토큰만 지우면 서버 세션이 남아, 로그아웃한 토큰으로 API가 계속 통했기 때문입니다. 그 시절에는 옛 토큰이 로그인 요청에 실려 가도 서버에서 유효한 토큰이니 검사를 통과했을 거예요. 그날 머지한 변경이 옛 토큰을 정말로 무효로 만들면서 이 줄이 처음 문제가 됐습니다. 두 번째 시도에 로그인이 된 이유는 응답 인터셉터에 있었습니다. // 응답 인터셉터 (줄임) if (error.response?.data?.code === LOGGED_OUT_TOKEN) { clearSession() // 첫 실패 때 A 탭의 기본값도 여기서 지워진다 } 첫 실패가 다음 시도의 길을 치워 준 셈입니다. 로그인 요청에서 헤더를 걷어 내기 if (config.url === '/auth/login' || config.url === '/auth/refresh') { - // 토큰을 싣지 않는 요청 - // delete config.headers.Authorization + // 다른 탭의 로그아웃으로 무효가 된 토큰이 기본값에 남아 있을 수 있다 + config.headers.delete('Authorization') return config } axios 1.7.2는 요청 인터셉터를 돌리기 전에 defaults.headers.common 을 그 요청의 config.headers 로 합쳐 둡니다( lib/core/Axios.js 의 Flatten headers 부분). 그래서 인터셉터에서 지우면 실제로 나가는 요청에서도 빠집니다. onLoginSuccess 에서 defaults에 넣는 줄을 지우는 편이 더 근본적이긴 합니다. 그런데 defaults에 토큰을 넣는 곳이 로그인, 토큰 갱신, 래퍼 옵션까지 세 군데라서 이번에는 나가는 길목 한 곳에서 막았어요. 사실 이 코드에서는 주석만 풀어도 고쳐집니다. 그래도 delete 연산자 대신 headers.delete() 메서드를 쓴 건, 누가 헤더 이름을 소문자로 넣어도 지워지게 하려고서입니다. // 기본값을 소문자 이름으로 넣었다면 instance.defaults.headers.common.authorization = 'Bearer old-token' // 요청 인터셉터 안에서 delete config.headers.Authorization // 나간 요청에 'Bearer old-token'이 남는다 config.headers.delete('Authorization') // 나간 요청에 헤더가 없다 config.headers 는 AxiosHeaders 인스턴스라서 메서드는 이름을 대소문자 구분 없이 찾습니다. 0.x에서는 이렇게 쓰면 안 돼요. 0.27.2로 돌려 보니 인터셉터 안의 config.headers.delete 가 DELETE 요청용 헤더 객체여서 TypeError: config.headers.delete is not a function 이 났고, 토큰도 config.headers.common 밑에 있었습니다. 4 로그인 요청의 Authorization 줄만 보면 됩니다. 나머지 순서는 수정 전과 같아요. 시험은 axios의 adapter 를 바꿔 끼워 실제로 나가려던 config를 붙잡는 식으로 썼습니다. 고치기 전 코드로 돌리면 실패하는 것까지 봤어요. it('로그인 요청에는 기본값에 남은 옛 토큰을 싣지 않는다', async () => { const sent = [] instance.defaults.adapter = async (config) => { sent.push(config) return { data: {}, status: 200, statusText: 'OK', headers: {}, config } } instance.defaults.headers.common.Authorization = 'Bearer stale-token' localStorage.removeItem('token') // 다른 탭이 로그아웃한 상태 await instance.post('/auth/login', { id: 'tester', password: 'pw' }) expect(sent[0].headers.Authorization).toBeUndefined() }) 응답을 기다리는 사이 바뀐 옵션 전날 고친 버그는 탭 사이가 아니라 요청 사이에서 값이 섞였습니다. 저희 API 래퍼는 부를 때마다 받은 옵션을 instance.defaults 에 써 넣어요. 그중 하나가 "에러 안내는 화면이 직접 하니 공통 에러 창은 띄우지 마라"는 옵션입니다. // api.js (수정 전) export const api = (options = {}) => { instance.defaults.silentError = options.silentError ?? false return { getItems: () => instance.get('/items'), getStats: () => instance.get('/stats') } } instance.interceptors.response.use( (response) => response, (error) => { if (!instance.defaults.silentError) showErrorDialog(error) // 응답이 도착한 때의 값을 읽는다 return Promise.reject(error) } ) // 목록 화면 api({ silentError: true }).getItems().catch(() => toast('목록을 불러오지 못했습니다.')) api().getStats() 목록 요청이 응답을 기다리는 사이 통계 요청이 api() 를 부르면 값이 false 로 덮입니다. 왼쪽 5번이 응답 인터셉터가 읽은 값입니다. 목록 요청은 true 로 보냈는데 false 를 읽어 토스트 위에 공통 에러 창을 겹쳐 띄웠어요. 반대도 됩니다. 통계 요청이 실패 응답을 기다리는 사이 목록 화면이 api({ silentError: true }) 를 부르면, 공통 창이 떠야 할 통계 실패가 안내 0건으로 묻힙니다. 같은 axios로 돌려서 두 경우 모두 확인했습니다. axios는 요청을 보낼 때 instance.defaults 의 키를 그 요청의 config로 복사해 둡니다. 직접 만든 키도 복사되는지 확인해 봤어요. instance.defaults.silentError = true // api({ silentError: true }) const pending = instance.get('/slow') // 400 으로 끝날 요청 instance.defaults.silentError = false // 기다리는 사이 api() // 응답 인터셉터에서 찍은 값 // instance.defaults.silentError: false // error.config.silentError: true 그래서 요청을 보낸 때의 값을 읽도록 고쳤습니다. 요청 인터셉터에서 던진 에러처럼 config 가 없는 경우도 있어 ?. 를 붙였어요. - if (!instance.defaults.silentError) showErrorDialog(error) + if (!error.config?.silentError) showErrorDialog(error) blob 다운로드에도 같은 꼴이 남았다 파일을 내려받는 api({ responseType: 'blob' }) 도 instance.defaults.responseType 을 바꾸고, blob 응답이 성공했을 때만 'json' 으로 되돌립니다. api({ responseType: 'blob' }).downloadFile(id) // defaults.responseType = 'blob' api().getItems() // 다운로드 응답 전에 나가면 이 요청도 blob 으로 받는다 다운로드를 기다리는 사이 나간 요청이 blob으로 나가는 건 axios 1.7.2로 확인했습니다. 다운로드가 실패하면 되돌리는 줄이 아예 돌지 않으니 그 뒤 요청도 blob으로 나갈 텐데, 이건 코드로만 읽었고 화면에서 재현하지는 않았어요. 이번 수정은 응답 인터셉터가 읽는 쪽만 바꿨기 때문에 이 건은 그대로입니다. 래퍼가 defaults를 고치지 않고 옵션을 요청 config로 넘기게 바꾸면 둘 다 풀리는데, 그러면 옵션을 넘기는 서른한 곳의 요청이 나가는 방식이 한꺼번에 바뀝니다. 그 화면들을 다시 시험할 시간을 따로 잡으려고 합니다. 탭 사이 로그아웃 동기화도 남았습니다. 지금은 B 탭에서 로그아웃해도 A 탭은 메뉴를 누르거나 다음 요청이 실패하기 전까지 로그인된 화면에 머물러요. storage 이벤트로 토큰이 지워진 걸 듣고 A 탭도 함께 정리하는 게 다음 할 일입니다. 혹시 같은 꼴이 있는지 grep -rn "defaults\." src 로 찾아보세요. 앱이 시작할 때 한 번 넣는 baseURL 같은 값은 괜찮습니다. 로그인할 때 넣는 토큰이나 호출할 때마다 바꾸는 옵션이 나오면, 그 줄이 이 글의 두 버그가 난 자리와 같은 모양입니다.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
다른 탭에서 로그아웃했더니 이 탭은 로그인이 안 됐다. 관리자 대시보드에 서버 로그아웃 API를 붙여 머지한 날, 브라우저 점검 스크립트로 로그인 흐름을 다시 돌렸습니다. A 탭에서 로그인하고, 그 뒤에 연 B 탭에서 로그아웃한 다음, A 탭에서 다시 로그인하는 순서였어요. 로그인 버튼을 눌렀는데 화면은 로그인 화면 그대로였고 에러 창이 하나 떴습니다. POST /auth/login 400 로그아웃된 토큰입니다. 토큰을 받으러 가는 요청이 토큰 때문에 거절당한 거죠. 같은 버튼을 한 번 더 누르니 그때는 로그인이 됐습니다. 결론부터 말하면 A 탭의 axios instance.defaults 에 남아 있던 옛 토큰이 로그인 요청에 실려 나갔고, 요청 인터셉터 한 줄로 막았습니다. 전날 같은 파일에서 고친…
Open source