-
Notifications
You must be signed in to change notification settings - Fork 0
Networking
Moya 기반 네트워크 레이어. Router 설계 규칙, Response DTO 디코딩 규칙, STOMP 실시간 연결을 다룹니다. 모든 코드는
Core/Network(CoreNetwork) + 각 Feature의Data타겟에 있습니다.
| 영역 | 내용 |
|---|---|
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만 정의합니다.
API Router는 엔드포인트 메타데이터(Path / Method / Encoding)만 책임집니다. 파라미터 키 이름과 직렬화 규칙은 Request/Query DTO가 캡슐화합니다.
-
Router의
task안에 인라인 딕셔너리 금지 — key 문자열이 Router에 흩어지면 스펙 변경 시 모든 case를 뒤져야 한다. - Body →
EncodableDTO +.requestJSONEncodable(body) -
Query → DTO +
toParameters+.requestParameters(..., encoding: URLEncoding.queryString)직렬화 책임을var toParameters: [String: Any]computed property로 캡슐화. 네이밍은toParameters로 통일. -
Path 변수(
{id})는 case associated value로 받아path에서만 사용 — 쿼리/바디와 섞지 않는다. - 단일 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
서버는 응답으로 내려주는 모든 정수 값을 String으로 직렬화합니다.
Int로 선언된 필드를 그대로 디코딩하면 런타임 DecodingError.typeMismatch가 발생합니다.
- Response DTO의 모든
Int필드는 String 폴백을 보장한다 -
synthesized
Codable금지 —Int필드가 있으면 반드시 custominit(from:)+encode(to:)작성 -
decode(Int.self)/decodeIfPresent(Int.self)직접 호출 금지 — Flexible 헬퍼 사용 -
폴백 순서:
Int→String("123")→Double(123.0)→ throw -
Request DTO(Encodable, 보내는 쪽)는 제외 —
Int그대로 OK
⚠️ 예전에는 DTO 파일마다private extension KeyedDecodingContainer를 복붙했지만, 지금은UMCFoundation의public 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는 적용 제외 확인
커뮤니티 스레드(채팅)는 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 커버리지