Skip to content

Networking

JEONG edited this page Aug 24, 2026 · 3 revisions

Networking

Moya 기반 네트워크 레이어. Router 설계 규칙, Response DTO 디코딩 규칙, STOMP 실시간 연결을 다룹니다. 모든 코드는 Core/Network(CoreNetwork) + 각 Feature의 Data 타겟에 있습니다.


0. CoreNetwork 한눈에

영역 내용
Base/ BaseTargetType(공통 baseURL·헤더·.successCodes 검증), APIResponse, NetworkConfig
Client/ NetworkClient, MoyaNetworkAdapter, 토큰 갱신(TokenRefreshServiceImpl), TokenPair, TokenStoreProtocol
Auth/ Kakao / Google / Apple 로그인 매니저, KeychainTokenStore, KakaoPlusManager
Realtime/ StompConnection(actor), StompFrame — 커뮤니티 스레드 실시간
Member/, Authorization/, Storage/ 교차 Feature 공용 Repository·Router·DTO

Feature Router는 BaseTargetType을 채택하고 path / method / task 정의합니다.


1. Network Router (Moya)

API Router는 엔드포인트 메타데이터(Path / Method / Encoding)만 책임집니다. 파라미터 키 이름과 직렬화 규칙은 Request/Query DTO가 캡슐화합니다.

원칙

  1. Router의 task 안에 인라인 딕셔너리 금지 — key 문자열이 Router에 흩어지면 스펙 변경 시 모든 case를 뒤져야 한다.
  2. Body → Encodable DTO + .requestJSONEncodable(body)
  3. Query → DTO + toParameters + .requestParameters(..., encoding: URLEncoding.queryString) 직렬화 책임을 var toParameters: [String: Any] computed property로 캡슐화. 네이밍은 toParameters로 통일.
  4. Path 변수({id})는 case associated value로 받아 path에서만 사용 — 쿼리/바디와 섞지 않는다.
  5. 단일 path 변수만 받는 case는 DTO 불필요 — 파라미터 컬렉션이 생기는 순간 DTO 분리.

❌ 안티패턴

// Router가 파라미터 키 이름까지 알고 있음
case .getNoticeReadStatusList(_, let cursorId, let filterType, let organizationIds, let status):
    return .requestParameters(
        parameters: [
            "cursorId": cursorId,
            "filterType": filterType,
            "organizationIds": organizationIds,
            "status": status
        ],
        encoding: URLEncoding.queryString
    )

✅ 권장 패턴

// 1. Query/Body DTO 정의
struct NoticeReadStatusListQuery: Encodable {
    let cursorId: Int
    let filterType: String
    let organizationIds: [Int]
    let status: String

    var toParameters: [String: Any] {
        ["cursorId": cursorId, "filterType": filterType,
         "organizationIds": organizationIds, "status": status]
    }
}

// 2. Router는 DTO만 받아 위임
case .getNoticeReadStatusList(_, let query):
    return .requestParameters(parameters: query.toParameters, encoding: URLEncoding.queryString)

case .addLink(_, let body):
    return .requestJSONEncodable(body)

파일 위치

  • Router: Features/{Feature}/Data/Sources/Router/{Name}Router.swift
  • Body: Features/{Feature}/Data/Sources/DTOs/Request/{Name}RequestDTO.swift
  • Query: Features/{Feature}/Data/Sources/DTOs/Request/{Name}Query.swift
  • Response: Features/{Feature}/Data/Sources/DTOs/Response/{Name}ResponseDTO.swift

2. Response DTO 디코딩

서버는 응답으로 내려주는 모든 정수 값을 String으로 직렬화합니다. Int로 선언된 필드를 그대로 디코딩하면 런타임 DecodingError.typeMismatch가 발생합니다.

원칙

  1. Response DTO의 모든 Int 필드는 String 폴백을 보장한다
  2. synthesized Codable 금지Int 필드가 있으면 반드시 custom init(from:) + encode(to:) 작성
  3. decode(Int.self) / decodeIfPresent(Int.self) 직접 호출 금지 — Flexible 헬퍼 사용
  4. 폴백 순서: IntString("123")Double(123.0) → throw
  5. Request DTO(Encodable, 보내는 쪽)는 제외Int 그대로 OK

공용 헬퍼 — UMCFoundation

⚠️ 예전에는 DTO 파일마다 private extension KeyedDecodingContainer를 복붙했지만, 지금은 UMCFoundationpublic extension 하나로 통합되었습니다. DTO에서 import UMCFoundation 한 줄이면 전부 쓸 수 있습니다. 파일 안에 헬퍼를 다시 정의하지 마세요.

Core/Foundation/Sources/Extensions/KeyedDecodingContainer+FlexibleNumber.swift

네이밍 규약 — 숫자를 String으로 흡수할 때는 정수로 절삭합니다("1.0"이 아니라 "1").

대상 함수
식별자·커서 (서버가 정수 또는 문자열) decodeFlexibleString · …IfPresent · …OrNil · …OrEmpty · decodeFlexibleStringArray · decodeFirstNonEmptyString(forKeys:)
실제 수량 (개수·점수) decodeIntFlexible · decodeIntFlexibleIfPresent
실수 decodeDoubleFlexible · decodeDoubleFlexibleIfPresent
불리언 decodeBoolFlexibleIfPresent
  • …IfPresent: 키가 없거나 명시적 null이면 nil, 해석 불가 타입이면 throw
  • …OrNil: 실패를 조용히 흡수해 nil
  • …OrEmpty: 실패 시 빈 문자열

✅ 권장 패턴

import Foundation
import UMCFoundation

struct CurriculumDTO: Codable, Sendable, Equatable {
    let curriculumId: String     // 서버 식별자는 String (절대 규칙 #2)
    let title: String
    let weekCount: Int

    private enum CodingKeys: String, CodingKey { case curriculumId, title, weekCount }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        curriculumId = container.decodeFlexibleStringOrNil(forKey: .curriculumId) ?? ""
        title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
        weekCount = try container.decodeIntFlexibleIfPresent(forKey: .weekCount) ?? 0
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(curriculumId, forKey: .curriculumId)
        try container.encode(title, forKey: .title)
        try container.encode(weekCount, forKey: .weekCount)
    }
}

리뷰 체크리스트

  • Int / Int? / [Int] / Int64 등 정수 타입 필드가 있는가
  • 정수 필드가 있다면 custom init(from:) + encode(to:) 정의 (synthesized Codable 금지)
  • init(from:) 안에서 Flexible 헬퍼 사용 — decode(Int.self) 직접 호출 없음
  • 헬퍼를 파일에 다시 정의하지 않고 import UMCFoundation으로 공용 것을 쓰는가
  • 서버 식별자는 전 레이어 String인가 (절대 규칙 #2)
  • Request/Encodable DTO는 적용 제외 확인

3. 실시간 — STOMP over WebSocket

커뮤니티 스레드(채팅)는 Moya가 아니라 URLSessionWebSocketTask 위에 STOMP 프레임을 얹어 통신합니다. 구현은 CoreNetwork/Realtime/에 있습니다.

public actor StompConnection {
    public init(url: URL, tokenStore: TokenStore, session: URLSession = .shared)

    public static func webSocketURL(base: URL) -> URL
    public static func backoffSeconds(attempt: Int) -> Double

    public func events() -> AsyncStream<StompEvent>
    public func connect() async
    public func subscribe(destination: String) async
    public func send(destination: String, headers: [String: String], body: Data) async throws
    public func disconnect() async
}
  • actor — 프레임 송수신 상태를 격리합니다.
  • 수신은 AsyncStream<StompEvent> 하나로 노출하고, Feature 쪽에서 이벤트를 도메인 모델로 매핑합니다.
  • 재연결은 backoffSeconds(attempt:) 지수 백오프.
  • 프레임 파싱/직렬화는 StompFrame이 담당하며 Core/Network/Tests에 단위 테스트가 있습니다.

관련 문서: Coding Conventions · Error Handling · Stella · API 커버리지

Clone this wiki locally