Skip to content

네트워크 레이어

Moya + Alamofire 기반 네트워크 구조

truloop iOS의 네트워크 레이어는 Moya 라이브러리를 기반으로 구축되어 있으며, Alamofire 위에서 동작합니다.

구성 요소

API Target 패턴

각 API 엔드포인트 그룹은 enum으로 정의되며, Moya의 TargetType 프로토콜을 채택합니다.

TargetType 기본 설정

모든 API Target은 공통 TargetType extension을 공유합니다:

swift
public extension TargetType {
    var baseURL: URL {
        URL(string: Constants.serverURLString)!
    }

    var commonHeaders: [String: String] {
        return [
            "X-Platform": "iOS",
            "X-App-Version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
            "X-Local-Timezone": TimeZone.current.identifier,
        ]
    }
}

API Target 목록

Target역할API 버전
AuthAPI인증, 회원가입, 전화번호 인증v1
HomeAPI룹 CRUD, 미디어, 리캡(내부명: Story), 미션, 약속, 커버, 차단 등v1 / v3
ContactAPI연락처 동기화v1
FriendAPI친구 관리v1
DeviceAPI디바이스 등록 (FCM), 채팅 세션 토큰, 앱 버전 체크v1
LooptAPIAI 비서 에이전트 연동 (service: loopt)v1
NotificationAPI알림 조회, 읽음 처리v1
LiveActivityAPILive Activity 토큰v1

API Target 구현 예시

swift
public enum AuthAPI {
    case requestVerificationCode(phoneNumber: String)
    case verify(phoneNumber: String, code: String)
    case login(phoneNumber: String, verificationToken: String)
    case refreshToken(refreshToken: String)
    case logout(refreshToken: String)
    case me
    case signUp(userName: String, phoneNumber: String, name: String,
                verificationToken: String, referralCode: String?, locale: String?)
    case usernameCheck(userName: String)
    case deleteAccount
    case profileUploadURL(fileName: String, fileType: String)
}

extension AuthAPI: TargetType {
    public var service: String { "core" }
    public var apiVersion: String { "api/v1" }

    public var path: String {
        let path: String
        switch self {
        case .login: path = "auth/login"
        case .refreshToken: path = "auth/refresh"
        // ...
        }
        return "\(service)/\(apiVersion)/\(path)"
    }
}

정보

API 경로는 {service}/{apiVersion}/{path} 형식으로 구성됩니다. 대부분의 API Target은 service가 core이며, LooptAPI만 service가 loopt입니다. HomeAPI의 룹 관련 엔드포인트 대부분은 v3, 나머지는 v1을 사용합니다.

Request / Response 처리

Networking 프로토콜

swift
public protocol Networking {
    func request<T: Decodable>(target: TargetType) async throws -> T
    func request(target: TargetType) async throws
}

두 가지 메서드를 제공합니다:

  • 응답이 있는 요청: request<T: Decodable>(target:) -- JSON 응답을 T 타입으로 디코딩
  • 응답 없는 요청: request(target:) -- 성공 여부만 확인 (DELETE 등)

NetworkingImpl

MoyaProvider<MultiTarget>을 내부적으로 사용하며, 모든 요청을 async/await으로 래핑합니다. 초기화 시 ValidatingSessionNetworkLoggerPlugin을 설정합니다:

swift
final class NetworkingImpl: Networking {
    private let provider: MoyaProvider<MultiTarget>
    private let networkingErrorRouter: NetworkingErrorRouter

    init(
        authInterceptor: AuthInterceptor,
        networkingErrorRouter: NetworkingErrorRouter
    ) {
        provider = MoyaProvider<MultiTarget>(
            session: ValidatingSession(interceptor: authInterceptor),
            plugins: [
                NetworkLoggerPlugin(configuration: .init(logOptions: .verbose))
            ]
        )
        self.networkingErrorRouter = networkingErrorRouter
    }

    func request<T: Decodable>(target: TargetType) async throws -> T {
        do {
            return try await provider.request(MultiTarget(target))
                .filterSuccessfulStatusCodes()
                .map(T.self)
        } catch {
            throw handleError(error)
        }
    }
}

MoyaProvider async 확장

Moya의 콜백 기반 API를 async/await으로 변환하는 확장이 포함되어 있습니다:

swift
public extension MoyaProvider {
    func request(_ target: Target, ...) async throws -> Response {
        try await withCheckedThrowingContinuation { continuation in
            self.request(target) { result in
                switch result {
                case .success(let response): continuation.resume(returning: response)
                case .failure(let error): continuation.resume(throwing: error)
                }
            }
        }
    }
}

에러 처리

에러 타입 계층

서버 에러 응답은 TruloopError 구조체로 파싱됩니다:

swift
public struct TruloopError: LocalizedError, Decodable {
    let rawType: String          // "ERROR_TYPE_NOT_FOUND" 등
    public let displayType: DisplayType  // toast, dialog, none 등
    public let message: Message  // locale, text

    public var type: TruloopErrorType {
        return TruloopErrorType(rawValue: self.rawType)
    }
}

TruloopErrorTyperawType 문자열을 Swift enum으로 변환합니다:

  • .notFound -- ERROR_TYPE_NOT_FOUND
  • .userNotFound -- ERROR_TYPE_USER_NOT_FOUND
  • .invalidVerificationCode -- ERROR_TYPE_INVALID_VERIFICATION
  • .internalServerError -- ERROR_TYPE_INTERNAL_SERVER_ERROR
  • .unspecified(String) -- 그 외 모든 에러 타입

에러 파싱 흐름

NetworkingError.parseError에서 Moya 에러를 분석하여 적절한 NetworkingError 케이스로 변환합니다:

  • MoyaError.statusCode -- 응답 body를 TruloopError로 디코딩 시도 → .serverError 또는 .objectMapping
  • MoyaError.underlying -- underlying 응답에서 TruloopError 디코딩 시도
  • MoyaError.objectMapping -- .objectMapping
  • 그 외 -- .unknown

에러 표시 방식

서버에서 display_type 필드를 통해 클라이언트에서 에러를 어떻게 표시할지 지정합니다. NetworkingImplhandleError에서 NetworkingErrorRouter를 통해 에러를 자동으로 표시합니다:

NetworkingError 케이스DisplayType표시 방식
.serverError.dialogAlert Popup
.serverError.toastToast
.serverError그 외표시하지 않음
.objectMapping--Toast (파싱 실패 메시지)
.unknown--Alert Popup (에러 메시지)

Authentication Interceptor

AuthInterceptor

Alamofire의 RequestInterceptor 프로토콜을 채택하여 인증 토큰 관리를 담당합니다. 프로토콜은 Networking 모듈에 선언되고, 구현체 AuthInterceptorImpl은 Repository 모듈에 위치합니다.

swift
// Networking 모듈
public protocol AuthInterceptor: RequestInterceptor { }

adapt -- 토큰 주입

  • KeychainStore에서 AuthToken을 로드
  • Access Token을 AES-256 복호화 후 Authorization: Bearer {token} 헤더에 추가
  • 토큰이 없는 경우 (비로그인 상태) 헤더 없이 요청 진행

retry -- 토큰 갱신

  • 401 응답 시에만 동작
  • URL별 재시도 횟수 추적 (최대 2회)
  • 동시 다발 요청 시 하나의 refresh 요청만 실행하고, 나머지는 대기열에 추가
  • Refresh Token을 AES-256 복호화 후 AuthAPI.refreshToken으로 재인증
  • 성공: 새 토큰을 AES-256 암호화 후 Keychain에 저장, 대기 중인 모든 요청 재시도
  • 실패: 대기 중인 모든 요청 취소, RootSceneRouteUseCase를 통해 Splash 화면으로 이동

ValidatingSession

Alamofire Session을 상속한 커스텀 세션으로, 200-299 범위 외의 상태 코드를 자동으로 검증합니다:

swift
final class ValidatingSession: Session, @unchecked Sendable {
    override func request(
        _ convertible: any URLRequestConvertible,
        interceptor: (any RequestInterceptor)? = nil
    ) -> DataRequest {
        let dataRequest = super.request(convertible, interceptor: interceptor)
        return dataRequest.validate(statusCode: 200..<300)
    }
}

공통 헤더

모든 API 요청에 다음 헤더가 포함됩니다:

헤더
X-Platform"iOS"
X-App-Version앱 버전 (CFBundleShortVersionString)
X-Local-Timezone디바이스 타임존 (예: Asia/Seoul)
AuthorizationBearer {accessToken} (AuthInterceptor가 주입)

변경 이력

날짜내용
2026-03-10소스 코드 기반 검증: TruloopError 타입명 수정, NetworkLoggerPlugin 추가, 에러 파싱 흐름 상세화, AuthInterceptor 토큰 갱신 로직 상세화, LooptAPI 역할 명확화, ValidatingSession Sendable 반영