모바일 앱 개발에 있어 가장 까다로운 부분 중 하나는 사용자 인증과 보안이라고 생각한다. 특히 이번 프로젝트를 진행하면서 가장 깊이 고민하고 많은 시간을 투자했던 부분이 바로 로그인 로직이었다. 서버와의 안전한 통신부터 민감한 사용자 정보를 저장하는 키체인 관리, 토큰 기반 인증의 구현, 그리고 자동 토큰 갱신까지 - 보안과 사용자 경험 사이에서 최적의 균형을 찾기 위해 고민했던 과정들을 이야기해보고자 한다.
전체적인 로그인 및 토큰 인증 흐름
내가 구현한 전체적인 로그인 및 토큰 인증 흐름을 단계별로 작성해보겠다.
1. 최초 로그인 과정
[클라이언트] ----애플 로그인 시도----> [애플 서버]
[애플 서버] ----인증 코드 반환----> [클라이언트]
[클라이언트] ----인증 코드와 함께 로그인 요청----> [파인뮤즈 서버]
[파인뮤즈 서버] ----액세스 토큰 + 리프레시 토큰 발급----> [클라이언트]
- 클라이언트는 받은 두 토큰(액세스, 리프레시)을 안전하게 저장 (키체인)
2. API 요청
[클라이언트] ----액세스 토큰을 헤더에 실어 API 요청---> [파인뮤즈 서버]
[파인뮤즈 서버] ----토큰 유효성 확인 후 응답----> [클라이언트]
- 모든 인증이 필요한 API 요청에 액세스 토큰을 헤더에 포함
- 서버는 토큰의 유효성을 검사하고 응답
3. 액세스 토큰 만료 시
[클라이언트] ----만료된 액세스 토큰으로 API 요청----> [파인뮤즈 서버]
[파인뮤즈 서버] ----401 Unauthorized 응답----> [클라이언트]
[클라이언트] ----리프레시 토큰으로 새 액세스 토큰 요청----> [파인뮤즈 서버]
[파인뮤즈 서버] ----새 액세스 토큰 발급----> [클라이언트]
[클라이언트] ----새 액세스 토큰으로 원래 API 다시 요청---->[파인뮤즈 서버]
4. 리프레시 토큰 만료 시
[클라이언트] ----리프레시 토큰으로 요청----> [파인뮤즈 서버]
[파인뮤즈 서버] ----401 Unauthorized 응답----> [클라이언트]
[클라이언트] ----로그아웃 처리 & 로그인 화면으로 이동---->
위의 로그인 및 토큰 갱신 흐름을 구현하기 위해 아래와 같은 컴포넌트를 구현했다.
1. TokenStorage
- 액세스 토큰과 리프레시 토큰을 키체인에 안전하게 저장
- 토큰 저장, 조회, 삭제 기능 제공
2. TokenPlugin
- 모든 API 요청 전에 자동으로 액세스 토큰을 헤더에 추가
- 401 에러 발생 시 자동으로 토큰 갱신 프로세스 시작
- 토큰 갱신 실패 시 로그아웃 처리
3. AuthService
- 로그인, 토큰 갱신 등 인증 관련 API 엔드포인트 정의
- 리프레시 토큰을 이용한 새 액세스 토큰 요청 처리
4. AuthRepository
- 실제 로그인 로직 구현
- 받은 토큰들을 TokenStorage를 통해 키체인에 저장
토큰 갱신
내가 가장 고민했던 부분은 토큰 갱신이다. 토큰 갱신을 위해서 여러 접근 방식을 검토했는데, 크게 세 가지 방법이 있었다. 첫째로 각 API 호출마다 직접 토큰 만료를 체크하고 갱신하는 방법, 둘째로 네트워크 레이어에서 인터셉터를 구현하는 방법, 마지막으로 Moya의 플러그인 시스템을 활용하는 방법이었다. 고민 끝에 나는 Moya에서 제공하는 플러그인을 사용하기로 했다. 플러그인을 사용하면 토큰 갱신 로직을 중앙화하여 관리할 수 있고, 코드의 재사용성도 높일 수 있다고 판단했기 때문이다.
✅TokenPlugin 구현
import Moya
import RxSwift
protocol AuthorizedTargetType: TargetType {
var requiresAuthentication: Bool { get }
}
class TokenPlugin: PluginType {
private let tokenStorage = TokenStorage.shared
private let disposeBag = DisposeBag()
private var isRefreshing = false
// 요청 전 액세스 토큰 추가
func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
var request = request
if let authTarget = target as? AuthService, case .refreshToken = authTarget {
if let accessToken = try? tokenStorage.loadToken(type: .access) {
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
return request
}
guard let authorizedTarget = target as? AuthorizedTargetType,
authorizedTarget.requiresAuthentication,
let accessToken = try? tokenStorage.loadToken(type: .access) else {
return request
}
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
return request
}
// 응답 처리 (401)
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
guard case let .failure(error) = result,
error.response?.statusCode == 401,
let authorizedTarget = target as? AuthorizedTargetType,
authorizedTarget.requiresAuthentication else {
return
}
print("토큰 만료")
handleTokenRefresh()
}
private func handleTokenRefresh() {
guard !isRefreshing else { return }
isRefreshing = true
let provider = MoyaProvider<AuthService>()
provider.rx.request(.refreshToken)
.subscribe(onSuccess: { [weak self] response in
guard let self = self else { return }
if let newAccessToken = response.response?.allHeaderFields["Authorization"] as? String {
try? self.tokenStorage.saveToken(newAccessToken, type: .access)
}
self.isRefreshing = false
}, onFailure: { [weak self] error in
guard let self = self else { return }
if let moyaError = error as? MoyaError,
moyaError.response?.statusCode == 401 {
self.handleLogout()
}
self.isRefreshing = false
})
.disposed(by: disposeBag)
}
private func handleLogout() {
try? tokenStorage.deleteAllTokens()
NotificationCenter.default.post(
name: NSNotification.Name("UserDidLogout"),
object: nil
)
}
}
TokenPlugin은 Moya에서 제공하는 플러그인 시스템을 활용하여 토큰 관리를 구현한 코드이다.
1. AuthorizedTargetType 프로토콜
- 인증이 필요한 API 엔드포인트를 구분하기 위한 프로토콜
- requiresAuthentication 속성으로 인증 필요 여부 판단
extension AuthService: AuthorizedTargetType {
var requiresAuthentication: Bool {
switch self {
case .signInWithApple, .refreshToken:
return false
case .fetchIsValidNickname, .registerNickname:
return true
}
}
}
2. prepare 메소드
- API 요청 전에 자동으로 호출되는 메소드
- 토큰 갱신 요청일 때는 만료된 액세스 토큰을 헤더에 추가
- 인증이 필요한 일반 요청에는 액세스 토큰을 Authorization 헤더에 추가
✅ prepare 메소드를 구현하기 전에는 각 API의 서비스의 headers 부분에서 인증 토큰을 직접 처리해야 했다. 이는 코드의 중복을 발생시키고, 토큰 관련 로직이 여러 곳에 분산되는 문제가 있었다. TokenPlugin의 prepare 메소드를 통해 이러한 인증 헤더 처리를 중앙화함으로써, 각 API 서비스에서는 더 이상 토큰 관련 코드를 작성할 필요가 없어졌다. 이는 코드의 중복을 제거하고 유지보수성을 크게 향상시켰을 뿐만 아니라, 새로운 API 엔드포인트를 추가할 때도 인증 처리를 걱정할 필요 없이 비즈니스 로직에만 집중할 수 있게 되었다.
extension AuthService: AuthorizedTargetType {
...
public var headers: [String : String]? {
switch self {
case .signInWithApple, .refreshToken:
return ["Content-Type" : "application/json"]
case .fetchIsValidNickname, .registerNickname:
let accessToken = try? TokenStorage.shared.loadToken(type: .access)
return [
"Content-Type" : "application/json",
"Authorization" : "Bearer \(accessToken ?? "")"
]
}
}
}
✅ 기존의 API 서비스는 headers 부분에서 인증 토큰을 직접 처리해야 했다.
extension AuthService: AuthorizedTargetType {
...
public var headers: [String : String]? {
return ["Content-Type" : "application/json"]
}
...
}
✅ TokenPlugin의 prepare 메소드를 통해 이러한 인증 헤더 처리를 중앙화함으로써, 각 API 서비스에서는 더 이상 토큰 관련 코드를 작성할 필요가 없어졌다.
3. didReceive 메소드
- 401에러(인증 만료)발생 시 호출
- 토큰 리프레시 프로세스(handleTokenRefresh)를 시작
4. handleTokenRefresh 메소드
- 리프레시 토큰과 만료된 액세스 토큰을 사용하여 새로운 액세스 토큰을 요청
- 동시 여러 번의 갱신 요청을 방지하기 위한 isRefreshing 플래그 사용
- 토큰 갱신 성공 시 새 토큰을 키체인에 저장
5. handleLogout 메소드
- 리프레시 토큰마저 만료된 경우 호출
- 저장된 토큰들을 삭제하고 로그아웃 처리
위 처럼 TokenPlugin을 구현함으로써, 사용자나 개발자가 토큰 만료를 신경 쓸 필요 없이, TokenPlugin이 백그라운드에서 자동으로 토큰을 갱신할 수 있게 되었다.
자동 로그인
자동 로그인을 구현하면서 많은 고민이 있었다. 초기 구상은 앱 실행 시 스플래시 화면을 보여주고, 저장된 토큰 유무를 체크한 후, 토큰이 있다면 사용자의 가입 상태를 확인하여 적절한 화면으로 라우팅 하는 것이었다. 구체적으로는 토큰이 없으면 로그인 화면으로, 토큰이 있는 경우 사용자 정보를 조회하여 가입 완료 상태(isLogin)에 따라 메인 화면 또는 가입 화면으로 이동하는 플로우를 계획했다. 또한 에러가 발생하면 토큰을 삭제하고 로그인 화면으로 보내도록 설계했다.
앱 실행
↓
스플래시 화면
↓
토큰 체크
├─ 토큰 없음 → 로그인 화면
└─ 토큰 있음 → 사용자 정보 조회
├─ isLogin true → 메인 화면
├─ isLogin false → 가입 화면(닉네임 입력부터)
└─ 에러 발생 → 토큰 삭제 후 로그인 화면
하지만 이 로직을 구현하기 위해서는 액세스 토큰으로 사용자의 가입 상태를 확인할 수 있는 API가 필요했다. 서버에 해당 API가 없어서 프론트엔드에서 대체 구현을 고민해보았지만, 이는 불필요한 리소스 낭비가 될 것이라 판단했다. 결국 백엔드 개발자와 논의하여 사용자 정보 조회 API를 새로 구현하기로 결정했다.
'우리 같이 협업하자' 카테고리의 다른 글
[우협하] 프로젝트 중단 (0) | 2025.01.01 |
---|---|
[우협하] 키체인 도입 (0) | 2024.11.05 |
[우협하] 16주차 회고 (1) | 2024.10.28 |
[우협하] 수평 컬렉션 뷰를 커스텀해보자. - 동적 배경과 페이징 효과 (0) | 2024.10.21 |
[우협하] 13~15주차 회고 (1) | 2024.10.21 |