이번 글에선 실제 인증 흐름과 디버깅을 다뤄볼 예정입니다.
인증 디버깅은 단순히 토큰의 유무 를 보는 것으로 끝나지 않습니다. 실제 요청에 어떤 헤더가 실려 나갔는지,
서버가 어떤 상태 코드를 내려줬는지, 앱이 어떤 저장소를 정리했는지까지 이어서 확인이 필요합니다.
1. 인증 디버깅의 기본 흐름
인증 API 디버깅의 핵심 흐름은 다음과 같습니다.
- access token 으로 API 요청
- 서버가 401 Unauthorized 응답
- Keychain 에서 refresh token 을 꺼냄
- refresh API 호출
- 새 access token 저장
- 실패했던 요청을 한 번만 재시도
- refresh token 도 만료됐다면 로그아웃 처리
여기서 중요한 점은 한 번만 재시도 입니다. refresh 가 실패했는데 계속 재시도하면 무한 루프가 생길 수 있습니다.
2. Authorization 헤더 방식과 Cookie 방식
토큰을 서버로 보내는 방식은 크게 두 가지입니다.
Authorization 헤더 방식
GET /users/me HTTP/1.1
Authorization: Bearer access-token
/users/me ← 현재 로그인한 사용자 정보 조회 API 를 뜻하는 예시 경로입니다.
실제 서비스에서는 /me, /api/me, /v1/member/profile, /profile 처럼 다르게 정할 수 있습니다.
iOS 에서는 보통 이렇게 붙여 사용합니다.
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
이렇게 사용할 경우
- 앱이 어떤 요청에 토큰을 붙이는지 직접 제어할 수 있습니다.
- 모바일 API 에서 다루기 쉽습니다.
- 브라우저의 CSRF 모델과 덜 얽힙니다. (CSRF: 사용자가 로그인된 상태를 악용해 다른 사이트에서 원치 않는 요청을 보내게 만드는 공격을 의미합니다.)
위와 같은 장점이 있지만 단점으로는 앱이 토큰 저장, 갱신, 만료 처리를 직접 책임져야 합니다.
Cookie 방식
GET /users/me HTTP/1.1
Cookie: accessToken=abc123
장점은 서버가 Set-Cookie 로 내려준 값을 클라이언트가 자동으로 보낼 수 있다는 점 입니다.
하지만 iOS 에서는 자동 처리 때문에 오히려 헷갈리는 경우도 있습니다.
- 어떤 쿠키가 저장되어 있는지 눈에 잘 보이지 않습니다
- 로그아웃 후 쿠키가 남아 있을 수 있습니다.
- Domain, Path 가 달라 같은 이름의 쿠키가 여러 개 생길 수 있습니다.
- URLSessionConfiguration.ephemeral 을 쓰면 기대한 지속성이 사라질 수 있습니다.
이러한 이유로 모바일 앱에서는 서버와 인증 방식을 먼저 합의하는 것이 중요합니다.
- Authorization 헤더 기반인지 / Cookie 기반인지
- refresh token 은 어디로 내려오는지
- 로그아웃 시 서버와 앱이 각각 무엇을 삭제하는지
위 항목들이 정리되지 않으면 인증 버그가 반복됩니다..!
3. 401 응답 후 refresh token 재발급
먼저 refresh API 응답 모델을 둡니다.
struct RefreshResponse: Decodable {
let accessToken: String
}
그리고 access token 재발급 함수를 만듭니다.
func refreshAccessToken() async throws {
guard let refreshToken = try tokenStore.currentRefreshToken() else {
throw APIError.unauthorized
}
var request = URLRequest(url: refreshURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"refreshToken": refreshToken
])
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
try tokenStore.clear()
throw APIError.unauthorized
}
let refreshResponse = try JSONDecoder().decode(RefreshResponse.self, from: data)
tokenStore.saveAccessToken(refreshResponse.accessToken)
}
이제 일반 API 요청에서 401 이 내려오면 refresh 후 재시도를 하게 됩니다.
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard httpResponse.statusCode == 401 else {
return (data, httpResponse)
}
try await refreshAccessToken()
var retryRequest = request
if let accessToken = tokenStore.currentAccessToken() {
retryRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
let (retryData, retryResponse) = try await session.data(for: retryRequest)
guard let retryHTTPResponse = retryResponse as? HTTPURLResponse else {
throw APIError.invalidResponse
}
return (retryData, retryHTTPResponse)
}
실제 프로젝트에서는 동시에 여러 API 가 401 을 받을 수 있습니다. 이때 모든 요청이 refresh API 를 동시에 호출하면 문제가 생길 수 있습니다. 그렇기에 운영 코드에서는 보통 다음 처리가 추가로 필요합니다.
- refresh 요청은 한 번만 수행되도록 동기화
- refresh 중 들어온 요청은 대기
- refresh 성공 후 대기 중인 요청 재시도
- refresh 실패 시 전체 로그아웃 처리
- 같은 요청이 무한히 재시도 되지 않도록 제한
4. 쿠키 옵션과 중복 쿠키 문제
쿠키에는 여러 옵션이 붙을 수 있습니다.
Set-Cookie: refreshToken=xyz;
HttpOnly;
Secure;
SameSite=Lax;
Path=/;
Max-Age=1209600
주요 옵션은 다음과 같습니다.
HttpOnly
브라우저 JavaScript에서 document.cookie로 접근할 수 없게 합니다.
WKWebView에서도 JS 접근 제한과 관련이 있습니다.
Secure
HTTPS 요청에서만 쿠키를 전송합니다.
SameSite
다른 사이트에서 시작된 요청에 쿠키를 보낼지 정합니다.
Path
쿠키가 포함될 요청 경로를 제한합니다.
Domain
쿠키가 적용될 도메인을 정합니다.
Max-Age / Expires
쿠키 만료 시간을 정합니다.
- Domain 과 Path 가 다르면 같은 이름의 쿠키도 서로 다른 쿠키로 존재할 수 있습니다. 로그아웃 시 쿠키를 삭제했는데 계속 인증되는 문제는 이 지점에서 자주 발생합니다.
- 서버가 쿠키를 삭제할 때도 생성할 때와 같은 조건을 맞춰야 합니다.
Set-Cookie: accessToken=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax
- 앱에서 직접 삭제할 때도 어떤 도메인과 경로의 쿠키를 지우는지 확인해야 합니다.
5. CORS: URLSession 과 WKWebView 는 다르게 본다
API 에 대해 검색하다보면 CORS 라는 단어를 종종 보게 되는데요.
CORS 는 Cross-Origin Resource Sharing 의 약자입니다. 브라우저가 "다른 출처의 리소스를 읽어도 되는지" 서버 응답 헤더를 보고 판단하는 보안 정책입니다.
여기서 출처(origin)는 보통 다음 세 가지 조합으로 결정됩니다.
scheme + host + port
https://app.example.com
https://api.example.com
위 두 URL 은 둘 다 HTTPS 지만 host 가 다르므로 서로 다른 출처 입니다.
브라우저에는 기본적으로 Same-Origin Policy 가 있습니다. 웹 페이지가 아무 API 나 마음대로 읽을 수 있으면, 사용자가 로그인해 둔 다른 서비스의 정보를 악성 페이지가 가져갈 수 있기 때문입니다. 그래서 브라우저는 다른 출처로 요청할 때 서버가 명시적으로 허용했는지 확인합니다.
서버가 허용하려면 보통 이런 응답 헤더를 내려줍니다.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin : 어떤 웹 출처를 허용할지 나타냅니다.
Access-Control-Allow-Credentials : 쿠키나 인증 정보를 포함한 요청을 허용할지 나타냅니다.
iOS 네이티브의 URLSession 요청은 브라우저 CORS 정책의 대상이 아닙니다.
즉, iOS 앱에서 URLSession 으로 API 를 호출하는데 CORS 에러가 난다고 표현한다면, 실제로는 다른 문제일 가능성이 높습니다.
- 인증 헤더 유무
- 쿠키가 전송되지 않았는지
- TLS 인증서 문제
- 서버가 모바일 앱 요청을 막았는지
- API Gateway 나 프록시에서 Origin 관련 정책을 잘못 적용
반면, WKWebView 안에서 웹 페이지가 fetch, XMLHttpRequest, axios 같은 브라우저 API 로 서버를 호출한다면 CORS 가 영향을
줍니다. 같은 앱 안이라도 URLSession 인지 WKWebView 인지에 따라 문제를 다르게 봐야 합니다.
WKWebView 와 iOS 네이티브 API 호출의 차이
같은 iOS 앱 안에서도 API 를 호출하는 주체가 다르면 동작이 달라집니다.
iOS 네이티브에서 URLSession 으로 호출하는 경우
URLSession 은 앱 코드가 직접 HTTP 요청을 만드는 방식입니다.
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
이 방식에서는 앱이 헤더, 바디, 캐시 정책, 인증 갱신, 쿠키 저장소를 직접 제어합니다.
- 특징은 다음과 같습니다.
- 브라우저 CORS 정책을 적용받지 않습니다.
- Authorization 헤더를 앱이 직접 붙입니다.
- 쿠키는 HTTPCookieStorage 와 URLSessionConfiguration 설정에 영향을 받습니다.
- Keychain 에 저장한 토큰을 읽어 요청에 반영하기 쉽습니다.
- TLS, ATS, 인증서 핀닝 같은 iOS 네트워크 보안 정책의 영향을 받습니다
따라서 네이티브 API 호출이 실패할 때는 CORS 보다 실제 요청 헤더, 상태 코드, 토큰 저장소, 쿠키 저장소, TLS 에러를 먼저 봐야합니다.
WKWebView 안의 웹 페이지에서 호출하는 경우
WKWebView 는 앱 안에 들어 있는 브라우저 환경에 가깝습니다.
웹 페이지의 JavaScript 가 API 를 호출하면 브라우저와 비슷한 제약을 받습니다.
fetch("https://api.example.com/users/me", {
credentials: "include",
headers: {
Authorization: `Bearer ${accessToken}`
}
})
이 방식에서는 다음을 확인해야 합니다.
- 웹 페이지 출처와 API 출처가 다르면 CORS 설정이 필요합니다.
- 쿠키를 보내려면 `credentials: "include"`와 서버의 `Access-Control-Allow-Credentials: true` 가 함께 맞아야 합니다.
- 쿠키는 WKHTTPCookieStore 의 영향을 받습니다.
- HttpOnly 쿠키는 JavaScript 에서 읽을 수 없습니다.
- 네이티브의 HTTPCookieStorage 와 WKWebView 의 쿠키 저장소는 자동으로 항상 같은 상태라고 가정하면 안 됩니다.
그래서 WKWebView 기반 화면에서 로그인은 되어 있는데 API 만 실패한다면 다음 질문을 해야 합니다.
- 이 요청은 URLSession 이 보냈는지 웹 페이지 JavaScript 가 보냈는지?
- 쿠키가 위치하는 곳은 HTTPCookieStorage 혹은 WKHTTPCookieStore 에 있는지
- 서버 CORS 응답에 Origin, Credentials, Headers 설정이 맞는지
앱이 네이티브 화면과 웹뷰 화면을 함께 사용한다면 인증 저장소도 명확히 나눠야합니다.
네이티브는 Keychain 과 URLSession 중심이고, 웹뷰는 쿠키와 웹 스토리지, WKHTTPCookieStore 중심으로 움직입니다.
6. TLS/ ATS 문제는 인증 실패처럼 보일 수 있다
TLS 는 HTTPS 통신에서 서버와 클라이언트 사이의 연결을 암호화 하고, 앱이 접속한 서버가 신뢰할 수 있는 서버인지 확인하는 계층입니다. HTTPS 요청이 시작되면 앱은 서버 인증서를 검증하게 되는데 보통 다음을 확인하게 됩니다.
- 인증서가 신뢰할 수 있는 인증기관에서 발급됐는지
- 인증서 체인이 올바른지
- 인증서가 만료되진 않았는지
- 접속한 도메인과 인증서의 도메인이 일치하는지
- iOS 의 ATS(App Transport Security) 정책을 만족하는지
이 중 하나라도 실패하면 요청은 서버의 API 로직까지 도달하지 못할 수 있습니다.
이 경우 서버가 401 Unauthorized 를 내려준 것이 아니라, HTTPS 연결 자체가 실패한 것입니다.
자주 보는 원인은 다음과 같습니다.
- 개발 서버에서 self-signed 인증서를 사용합니다.
- 인증서가 만료됐습니다.
- `api.example.com` 으로 접속했는데 인증서는 `www.example.com` 에만 발급됐습니다.
- 중간 인증서 체인이 누락됐습니다.
- TLS 버전이나 암호화 스위트가 iOS 에서 허용되지 않습니다.
- 인증서 핀닝을 사용하는 앱에서 서버 인증서가 교체됐지만 앱의 핀 정보가 갱신되지 않았습니다.
iOS 에서는 이런 경우 URLSession 이 HTTP 상태 코드 없이 네트워크 에러를 던질 수 있습니다.
NSURLErrorServerCertificateUntrusted
NSURLErrorSecureConnectionFailed
NSURLErrorAppTransportSecurityRequiresSecureConnection
이 문제를 인증 API 문제로 착각하면 디버깅이 길어집니다.
구분 기준은 간단합니다.
401, 403 같은 HTTP 상태 코드가 있습니다
서버 API까지 도달했고, 인증 또는 권한 처리에서 실패한 것입니다.
HTTPURLResponse 자체가 없습니다
DNS, 네트워크 연결, TLS, ATS 같은 전송 계층 문제일 수 있습니다.
개발 환경에서 임시로 ATS 예외를 넣을 수는 있지만, 운영 앱에서는 신뢰 가능한 인증서와 올바른 HTTPS 설정을 갖추는 것이 기본입니다.
특히 인증서 핀닝을 사용한다면 인증서 교체 시점에 앱 업데이트 또는 핀 로테이션 전략까지 같이 준비해야합니다.
7. Cache-Control 과 오래된 응답
API 응답이 예상과 다르게 오래된 데이터처럼 보일 때는 캐시 헤더를 확인해야 합니다.
민감한 사용자 정보나 인증 응답에는 보통 이런 설정을 사용합니다.
Cache-Control: no-store
예를들어 토큰 응답이 캐시되면 곤란합니다.
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"accessToken": "abc123"
}
반대로 거의 변하지 않는 데이터는 캐시를 활용할 수 있습니다.
Cache-Control: public, max-age=3600
iOS 에선 `URLRequest.CachePolicy` 도 함께 봐야 합니다.
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
항상 최신 데이터를 받아야하는 API 라면 서버의 캐시 헤더와 앱의 캐시 정책을 함께 확인해야 합니다.
실전예제: 쿠키 중복으로 인한 인증 실패
로그인 후 현재 사용자 정보 조회 API 인 `/users/me` 를 호출했는데 서버가 401 Unauthorized 를 내려줍니다.
앱에서는 새 access token 을 받은 줄 알았지만 실제 요청 헤더는 이런 상태입니다.
GET /users/me HTTP/1.1
Cookie: accessToken=old-token; accessToken=new-token
서버가 첫 번째 `accessToken` 을 읽는다면 만료된 토큰으로 인증을 시도하게 되는데, 응답은 이렇게 옵니다.
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"message": "invalid token"
}
이 문제를 해결하려면 다음을 확인해야 합니다.
- 로그인 성공 시 기존 쿠키를 덮어씁니까?
- 로그아웃 시 쿠키가 확실히 삭제됩니까?
- 같은 이름의 쿠키가 서로 다른 `Domain` 이나 `Path` 로 존재하지 않습니까?
- 서버는 중복 쿠키를 어떤 규칙으로 파싱합니까?
- 앱은 `Authorization` 헤더와 쿠키 인증을 동시에 섞어 쓰고 있지 않습니까?
중요한 건 "토큰을 새로 받았습니다" 가 끝이 아니라는 점 입니다. 실제 요청에 어떤 헤더가 실려 나가는지 확인해야 합니다.
8. 실제 디버깅 체크리스트 ⭐
- 실제 요청 URL 이 맞습니까?
- Status Code 가 무엇입니까? → 400, 401, 403, 431, 500 등을 구분합니다.
- Request Header 에 Authorization 또는 Cookie 가 어떻게 실려 있습니까?
- Response Header 에 Set-Cookie 가 정상적으로 내려옵니까?
- 앱의 HTTPCookieStorage 에 같은 이름의 쿠키가 중복되어 있지 않습니까?
- 쿠키의 Domain, Path, Secure, Expires 조건이 의도와 맞습니까?
- access token 과 refresh token 저장 위치가 명확합니까?
- 로그아웃 시 Keychain, 메모리 토큰, 쿠키를 모두 정리합니까?
- Content-Type 이 앱이 기대하는 형식과 일치합니까?
- URLSession 요청인지 WKWebView 요청인지 구분했습니까?
- WKWebView 요청이라면 CORS preflight 와 WKHTTPCookieStore 상태가 맞습니까?
- HTTPURLResponse 가 없는 실패라면 TLS, ATS, DNS, 네트워크 연결 문제는 아닙니까?
- 서버 로그에는 어떤 에러가 찍힙니까?
9. 정리
API 인증 문제는 Body 만 보면 해결하기 어렵습니다. 이전의 글에서 다룬 것들까지 전체 정리를 해보면
- Status Code, Header, Content-Type 을 함께 봐야 합니다.
- Cookie, Set-Cookie, HTTPCookieStorage, Keychain 의 역할을 구분해야합니다.
- 401 처리, refresh token 재발급, 캐시, CORS, WKWebView 와 네이티브 API 호출 차이, TLS 인증서, 쿠키 중복 문제를 실제 요청 기준으로 디버깅
API 응답을 확인할 때 JSON Body만 보는 것이 아니라 HTTPURLResponse 전체를 함께 보는 습관이 중요합니다.
Status Code, Header, Cookie, Cache, TLS 까지 함께 확인하면 인증 문제와 네트워크 디버깅 시간을 크게 줄일 수 있습니다.