"JWT + OAuth가 만나면"
"그냥 아이디/비밀번호 체크하면 되는 거 아니야?"
11년차 QA 엔지니어로서 수많은 로그인 시스템을 테스트해왔습니다. 매번 똑같은 테스트 케이스를 작성하면서 "이게 뭐가 그렇게 어렵다고 개발자들이 시간을 이렇게 오래 잡지?" 하고 생각했었죠.
그런데 직접 구현해보니... 아, 이건 정말 다른 세상이었습니다. 특히 JWT와 OAuth를 함께 사용하면서 환경별로 다르게 동작하는 것을 보고 머리가 아팠어요. 로컬에서는 잘 되는데 Vercel에 올리면 안 되고, 개발 환경에서는 되는데 운영에서는 또 안 되고...
이번 편에서는 제가 인증/인가 시스템을 구현하면서 겪은 시행착오와 깨달음을 공유하려고 합니다. 정말이지, 개발자들에게 미안한 마음이 들 정도로 복잡한 영역이더라고요.
처음엔 JWT가 뭔지도 몰랐습니다. "그냥 암호화된 문자열 아니야?" 정도로만 생각했죠. 구글링해서 나온 예제 코드 복사해서 붙여넣고 "오, 되네!" 하면서 좋아했던 기억이 납니다.
그런데 문제는 그 다음부터였어요. 개발 환경에 배포하고 나니 갑자기 "Invalid token" 에러가 막 뜨는 겁니다. 로컬에서는 분명히 잘 됐는데 말이죠. 알고 보니 제가 모든 환경에서 같은 시크릿 키를 사용하고 있었던 거예요. 그것도 "my-secret-key" 같은 아주 단순한 문자열로...
처음엔 정말 무지했습니다. 모든 환경에서 같은 JWT_SECRET_KEY를 사용했거든요. 심지어 그 키가 "test123" 이었다는... 지금 생각하면 정말 아찔합니다.
어느 날 보안 관련 아티클을 읽다가 충격을 받았어요. JWT 시크릿 키는 최소 256비트(32바이트) 이상의 랜덤한 값이어야 한다는 거였습니다. 그리고 각 환경마다 완전히 다른 키를 사용해야 한다고 하더라고요.
"아니, 그럼 개발 환경에서 발급한 토큰이 운영 환경에서 안 먹히는 거 아니야?" 라고 생각했는데, 그게 바로 포인트였습니다. 당연히 안 먹혀야 하는 거였어요! 보안상 각 환경은 완전히 독립적이어야 하니까요.
그래서 Python으로 시크릿 키 생성 스크립트를 만들고, 각 환경별로 완전히 다른 키를 생성해서 사용하기 시작했습니다. Vercel Dashboard에 환경 변수 설정하면서 "아, 이래서 환경 변수 관리가 중요하구나" 하고 또 한 번 깨달았죠.
OAuth 설정의 환경별 차이
OAuth 연동은 정말... 제 인내심을 시험하는 과정이었습니다. 특히 리다이렉트 URL 설정이 제일 힘들었어요.
처음에는 "그냥 로그인 성공하면 메인 페이지로 보내면 되는 거 아니야?" 라고 생각했는데, OAuth의 세계는 그렇게 단순하지 않더라고요. Google Cloud Console에 들어가서 OAuth 2.0 클라이언트를 만들 때, "승인된 리디렉션 URI"라는 항목이 있었습니다.
여기에 뭘 넣어야 하나 고민하다가 일단 http://localhost:8000/auth/google/callback을 넣었죠. 로컬에서는 잘 됐습니다! 그런데...
Vercel에 배포하고 나니 "redirect_uri_mismatch" 에러가 뜨는 거예요. 아, 맞다. Vercel은 URL이 다르지! 그래서 다시 Google Cloud Console에 들어가서 https://api-jamescompany-git-develop.vercel.app/auth/google/callback을 추가했습니다.
그런데 여기서 또 문제가... Vercel은 브랜치별로 URL이 달라지는 거였어요. feature 브랜치를 만들 때마다 새로운 URL이 생성되는데, 그때마다 Google Cloud Console에 가서 URL을 추가할 수는 없잖아요?
Google OAuth 설정의 함정
Google OAuth를 설정하면서 느낀 건, Google이 정말 보안에 엄격하다는 거였습니다.
처음에는 테스트 모드로 앱을 만들었는데, 이게 또 함정이었어요. 테스트 모드에서는 최대 100명까지만 사용할 수 있고, 그마저도 직접 테스트 사용자로 등록해야 한다는 거였습니다.
"아니, 나 혼자 테스트하는 건데 왜 이렇게 복잡해?" 하면서 투덜거렸는데, 나중에 운영 환경으로 넘어가려고 하니 Google의 앱 검증 프로세스를 거쳐야 한다는 걸 알게 됐어요. 개인정보처리방침 URL도 필요하고, 서비스 약관도 필요하고...
그때 깨달았죠. "아, 그래서 많은 서비스들이 '간편 로그인' 기능을 나중에 추가하는구나." 처음부터 OAuth를 넣으려면 준비할 게 정말 많더라고요.
JWT만으로는 부족했던 이유
JWT의 가장 큰 장점은 stateless라는 거잖아요? 서버에 세션을 저장할 필요가 없으니 확장성이 좋다고 들었습니다. 그래서 "오, 그럼 Redis 같은 거 필요 없겠네!" 하고 좋아했었죠.
그런데 막상 구현하다 보니 문제가 하나둘 나타나기 시작했습니다.
첫 번째 문제는 로그아웃이었어요. 사용자가 로그아웃 버튼을 눌렀을 때, 프론트엔드에서 토큰을 삭제하면 된다고 생각했는데... 만약 토큰이 탈취당했다면? 그 토큰은 만료 시간까지 계속 유효한 거잖아요.
두 번째 문제는 강제 로그아웃이었습니다. 예를 들어 사용자의 권한이 변경되었거나, 비밀번호를 변경했을 때 기존 세션을 모두 무효화하고 싶은데, JWT만으로는 이게 불가능하더라고요.
그래서 결국 Redis를 도입하게 됐습니다. "아니, 그럼 stateless의 의미가 없는 거 아니야?" 라고 생각하실 수도 있는데, 완전한 stateless는 아니지만 그래도 세션 정보를 모두 저장하는 것보다는 훨씬 가볍게 구현할 수 있었어요.
토큰 블랙리스트 구현
토큰 블랙리스트를 구현하면서 재미있는 경험을 했습니다.
처음에는 블랙리스트에 토큰을 영구적으로 저장하려고 했어요. 그런데 생각해보니, JWT 토큰은 어차피 만료 시간이 있잖아요? 만료된 토큰은 어차피 사용할 수 없으니, 블랙리스트에도 만료 시간을 설정하면 되는 거였습니다.
Redis의 SETEX 명령어를 사용해서 토큰의 남은 유효 시간만큼만 블랙리스트에 보관하도록 했더니, 메모리도 절약되고 관리도 편해졌어요. "아, 이래서 Redis를 쓰는구나!" 하는 순간이었죠.
로컬에서는 Docker로 Redis를 띄우고, 개발/운영 환경에서는 Upstash Redis를 사용했는데, 이것도 나름의 도전이었습니다. Upstash는 Serverless Redis라서 연결 방식이 조금 달랐거든요. SSL 설정하는 것도 처음엔 헤맸고요.
구글링을 해보니 의견이 분분하더라고요. localStorage vs sessionStorage vs Cookie... 각각 장단점이 있다는데, 뭘 선택해야 할지 막막했습니다.
결국 선택한 건 httpOnly 쿠키였어요. XSS 공격으로부터 상대적으로 안전하다는 이유에서였죠. 그런데 이 선택이 또 다른 도전의 시작이었습니다.
로컬 환경에서는 http://localhost:5137 (프론트엔드)과 http://localhost:8000 (백엔드)를 사용하는데, 이게 브라우저 입장에서는 다른 도메인이에요. 그래서 쿠키가 전달이 안 되는 거였습니다!
처음엔 "왜 안 되지? 분명히 Set-Cookie 헤더는 보이는데..." 하면서 한참을 헤맸어요. Chrome 개발자 도구의 Network 탭과 Application 탭을 수도 없이 왔다갔다 하면서 확인했죠.
SameSite 속성의 미묘한 차이
쿠키의 SameSite 속성을 이해하는 데도 시간이 꽤 걸렸습니다.
Strict: 가장 엄격한 설정. 다른 사이트에서 오는 모든 요청에 쿠키가 전송되지 않음
Lax: 일부 크로스 사이트 요청(안전한 HTTP 메서드)에는 쿠키 전송
None: 모든 크로스 사이트 요청에 쿠키 전송 (단, Secure 속성 필수)
처음엔 "그냥 다 None으로 하면 되는 거 아니야?"라고 생각했는데, 이게 또 보안상 위험할 수 있다는 거였어요. CSRF 공격에 취약해질 수 있다고 하더라고요.
그래서 환경별로 다르게 설정했습니다.
로컬: Lax (같은 컴퓨터니까 상대적으로 안전)
개발: None (Vercel과 통신해야 하니까)
운영: Strict (보안 최우선!)
이렇게 설정하고 나니, 각 환경에서 미묘하게 다른 동작을 보였어요. 특히 OAuth 리다이렉트 후에 쿠키가 설정되는 타이밍이 달라서, 프론트엔드에서 추가 처리가 필요했습니다.
React에서 인증 상태를 관리하는 것도 쉽지 않았습니다.
처음엔 각 컴포넌트에서 필요할 때마다 API를 호출해서 인증 상태를 확인했는데, 이게 비효율적이더라고요. 매번 서버에 요청을 보내니까 느리기도 하고, 코드도 중복되고...
그래서 Context API를 사용해서 전역으로 인증 상태를 관리하기로 했습니다. 처음엔 "이게 뭐가 어렵다고, 그냥 상태 공유하는 거잖아" 했는데, 막상 구현하려니 고려할 게 많았어요.
앱이 처음 로드될 때 인증 상태를 어떻게 확인할 것인가?
토큰이 만료되었을 때 어떻게 처리할 것인가?
로그인/로그아웃 시 모든 컴포넌트에 어떻게 알릴 것인가?
특히 httpOnly 쿠키를 사용하니까, JavaScript에서 직접 토큰을 읽을 수 없어서 더 복잡했습니다. 결국 서버에 /api/auth/me 같은 엔드포인트를 만들어서 현재 사용자 정보를 가져오도록 했어요.
withCredentials의 중요성
axios를 사용하면서 가장 많이 실수한 부분이 withCredentials: true 설정이었습니다.
처음엔 이게 뭔지도 몰랐어요. 그냥 API 호출하면 쿠키가 자동으로 전송되는 줄 알았거든요. 그런데 크로스 도메인 환경에서는 명시적으로 설정해줘야 한다는 걸 나중에 알았습니다.
개발하면서 "왜 로그인은 되는데 다음 API 호출에서는 인증이 안 되지?" 하고 몇 시간을 헤맸는데, 알고 보니 withCredentials 설정을 빼먹은 거였어요.
이런 실수를 여러 번 하다 보니, 결국 axios 인스턴스를 만들어서 기본 설정으로 넣어두게 됐습니다. "아, 이래서 다들 axios 인스턴스를 만들어 쓰는구나!"
QA 관점에서 본 인증 시스템 테스트
11년 동안 QA를 하면서 인증 관련 테스트를 수없이 해왔지만, 직접 구현해보니 제가 놓치고 있던 부분이 정말 많았습니다.
예전에는 그냥 "로그인 되나? 로그아웃 되나? 권한별로 접근 제한 되나?" 정도만 테스트했어요. 하지만 이제는 훨씬 더 깊이 있는 테스트를 할 수 있게 됐습니다.
환경별 테스트의 중요성을 깨달았어요.
로컬에서 완벽하게 동작하는 기능이 Vercel 개발 환경에서는 전혀 다른 문제를 일으킬 수 있다는 걸 몸소 체험했습니다. 쿠키 설정 하나 때문에 몇 시간을 디버깅한 적도 있고, Redis 연결이 끊어져서 모든 사용자가 로그아웃되는 대참사(?)도 겪어봤죠.
보안 테스트의 관점도 완전히 달라졌습니다.
JWT 토큰을 조작해서 다른 사용자인 척 할 수 있는지
만료된 토큰으로 API 호출했을 때 제대로 거부되는지
OAuth 콜백 URL에 악의적인 파라미터를 넣었을 때 어떻게 처리되는지
동시에 여러 디바이스에서 로그인했을 때 세션 관리가 제대로 되는지
이런 시나리오들을 실제로 구현하면서 겪어보니, 왜 이런 테스트가 필요한지 뼈저리게 느낄 수 있었어요.
성능 관점의 테스트도 추가됐습니다:
Redis 조회가 추가되면서 인증 확인에 걸리는 시간이 늘어났거든요. 특히 Upstash Redis를 사용하는 개발/운영 환경에서는 네트워크 지연도 고려해야 했습니다.
"로그인 버튼 클릭 후 2초 이내에 완료되어야 한다"는 단순한 기준에서, "JWT 검증 + Redis 조회 + 사용자 정보 로드까지 총 500ms 이내"라는 구체적인 성능 기준을 세울 수 있게 됐어요.
개발자와의 소통 개선
이제 개발자들과 인증 관련 이슈를 논의할 때 완전히 다른 레벨의 대화가 가능해졌습니다.
예전에는
"로그인이 안 돼요. 확인 부탁드립니다."
이제는
이렇게 구체적이고 기술적인 커뮤니케이션이 가능해지니, 문제 해결 속도도 훨씬 빨라졌습니다. 개발자들도 "오, 정확하게 짚어주셨네요"라며 놀라워하더라고요.
인증 시스템의 복잡성에 대한 존경심
솔직히 말하면, 인증 시스템을 직접 구현하기 전에는 "그까짓 로그인 기능이 뭐가 어렵다고" 하는 마음이 있었습니다.
하지만 이제는 다릅니다. Auth0, Firebase Auth, AWS Cognito 같은 서비스들이 왜 존재하는지, 왜 많은 회사들이 돈을 내고 이런 서비스를 사용하는지 완전히 이해하게 됐어요.
JWT 하나만 해도 고려할 게 산더미고, OAuth는 각 제공자마다 미묘하게 다른 구현 방식을 가지고 있고, 쿠키 설정은 브라우저마다 다르게 동작하고... 정말 끝이 없더라고요.
특히 보안 관련해서는 더욱 조심스러워졌습니다. 제가 만든 시스템에 보안 취약점이 있으면 사용자들의 개인정보가 유출될 수 있다는 생각에, 매번 코드를 작성할 때마다 "이게 정말 안전한가?"를 수십 번 확인하게 됐어요.
인증 시스템을 직접 구현해보니, 제가 QA 엔지니어로서 얼마나 피상적으로 테스트를 해왔는지 깨달았습니다.
"로그인 되나/안 되나"의 단순한 체크리스트에서 벗어나, 각 환경별 쿠키 정책, 토큰 생명주기, 세션 관리 전략, 보안 취약점 등 정말 다양한 관점에서 테스트를 설계할 수 있게 됐어요.
무엇보다 가장 큰 수확은, 개발자들이 "인증 쪽 버그가 있는 것 같아요"라고 할 때 "아... 그거 진짜 복잡하죠. 어떤 부분인지 같이 한번 봐요"라고 공감하며 대화할 수 있게 됐다는 점입니다.
그리고 이제는 "쿠키가 안 먹어요"라는 개발자의 농담(?)을 들으면 진심으로 웃을 수 있게 됐네요. 정말로 쿠키가 안 먹히는 상황을 수도 없이 겪어봤거든요.
다음 편에서는 파일 업로드와 CDN 연동에서 겪은 시행착오를 공유하겠습니다. S3 버킷 권한 때문에 며칠을 고생한 이야기, CORS 에러와의 끝없는 전쟁, 그리고 Cloudflare CDN 설정하다가 사이트 전체가 먹통이 됐던 이야기... 정말 눈물 없이는 들을 수 없는 이야기들이 기다리고 있습니다. 기대해주세요!