iOS/Swift

iOS 앱 개발자를 위한 RESTful API 실전 적용과 WebSocket

hyunjicraft 2025. 1. 6. 21:34

1. RESTful API 설계와 특징

RESTful API는 서버와 클라이언트 간의 통신을 효율적으로 설계하기 위해 널리 사용되는 방식이다.

RESTful API 설계의 주요 원칙

  • 리소스를 나타내는 명사형 URI:
    • GET /users: 유저 목록 읽기
    • POST /users: 새로운 유저 생성
  • Stateless 통신:
    클라이언트는 매 요청 시 필요한 모든 정보를 포함하여 서버에 전달한다. 서버는 이전 요청 상태를 기억하지 않는다.

RESTful하지 않은 API 란

  • 모든 요청을 POST로 처리
  • URI에 리소스 대신 동작을 나타내는 동사가 포함된 경우 (예: /students/update)

2. RESTful API를 위한 서버 - 클라이언트 협업

앱 개발에서 가장 기본이 되는 RESTful API는 화면의 데이터 제공이다. 특히 일회성 데이터의 경우 특별한 이견이 없다면 화면 기준으로 설계된다. 만약 백엔드 파트에서 특정한 정보를 빼는데 리소스가 많이 필요하다고 하면 그 부분은 Codable 객체에서 버리는 방식으로 설계한다. 또는 백엔드 파트에서는 앱뿐만아니라 웹 클라이언트를 고려해야 해서 중첩된 형태의 JSON을 제공하기도 한다.

일회성 데이터가 아니라 앱 전체에 걸쳐서 사용해야 하는 공통 객체를 교환하는 경우, 앱 파트에서는 최소한의 DTO를 설계할 수 있도록 노력한다.

 

API 요청 방식

  • GET: 화면 진입 시 필요한 데이터를 가져올 때
  • POST/PUT: 유저 데이터를 삭제하거나 수정할 때

Codable 사용

예시: Codable 객체 설계

백엔드 파트와 논의하여 최소한의 JSON 형태를 협의한다.
클라이언트는 최소한의 DTO를 사용할 수 있어야 하고, 서버는 API를 위해 너무 불필요한 연산을 하지 않을 수 있어야 한다.

// 상속을 활용한 Codable 객체

class PetBase : Codable {
    var id: Int
    var name: String
    var gender: Gender
    var breed: Breed
    var birthday: Double
    var weight: Float
    var profileImageURL: String?
}

class Pet : PetBase {
    var registerNumber: String?
    var isNeutered: Bool
    var isMissionPet: Bool
    var profile: PetProfile
    var character: Figure
    var family: [PetFamily]
}

Key 충돌 처리

레거시 코드가 남은 경우, 같은 데이터의 JSON 키가 다를 수 있다. 이 경우 extension을 통해 커스텀 디코딩 로직을 사용한다.

extension KeyedDecodingContainer {
    enum ParsingError: Error {
        case noKeyFound
    }

    /// 여러 CodingKey 중 맞는 게 있으면 리턴
    func decode<T>(_ type:T.Type, forKeys keys:[K]) throws -> T where T: Decodable {
        for key in keys {
            if let val = try? self.decode(type, forKey: key) {
                return val
            }
        }
        throw ParsingError.noKeyFound
    }
}
struct MyModel: Decodable {
    let name: String

    enum CodingKeys: String, CodingKey {
        case name1
        case name2
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.name = try container.decode(String.self, forKeys: [.name1, .name2])
    }
    
//	기본은 아래와 같다.    
//    init(from decoder: Decoder) throws {
//        let container = try decoder.container(keyedBy: CodingKeys.self)
//        // 두 키 중 하나라도 매칭되면 해당 값을 디코딩
//        if let name = try? container.decode(String.self, forKey: .name1) {
//            self.name = name
//        } else if let name = try? container.decode(String.self, forKey: .name2) {
//            self.name = name
//        } else {
//            throw DecodingError.dataCorruptedError(forKey: .name1, in: container, debugDescription: "Neither key is present")
//        }
//    }   
}

3. Alamofire와 네트워크 통신 최적화

Alamofire 사용 이유

Alamofire는 코드 가독성과 협업 효율성을 높이는 라이브러리이다. RESTful API 호출을 간결하게 작성할 수 있다.

나는 라이브러리를 최소한으로 사용하는 것을 좋아하지만, Objective-C의 AFNetworking이나 SnapKit, Kingfisher 등은 거의 대부분의 iOS 개발자가 인지하고 있기 때문에 필수적으로 설치하는 편이다. 특히 Alamofire는 네트워크에 필요한 많은 부분들을 획일화된 코딩 스타일로 제공해주기 때문에 코드가 간결해지고 가독성이 좋아진다.

 

나는 주로 서비스에 맞는 enum을 정의해서 래핑한다.

- 200 번대 성공 케이스는 Info 구조체를 가지고 있고 title과 message를 팝업 형태로 보여준다. 예를 들어 "반려견 등록: 반려견이 등록되었습니다." 같은 메세지를 띄운다.

- 401 에러는 로그인 만료인데, 다른 기기에서 동시 로그인 시 앱이 강제로그아웃된다. 이 때 앱을 첫번째 뎁스로 되돌리고 로그인 화면을 띄우도록 한다.

 

API Error Enum 예시

enum APIError : Error {
    case other(Info)    // 200번대
    case expired    // 401
    case noPet      // 사용 안함
    case undecodable    // 규약 에러
    case network(AFError)   // 통신 실패
    case server // 500
    case fixing // 502
    case canceled   // 취소
    case `default`
    
    struct Info : Decodable {
        var code: String
        var title: String
        var message: String
    }
    ...
}

 

싱글톤 API Manager 구현

class APIManager {
    static let shared = APIManager()
    typealias completionHandler = ((Result<Data, APIError>) -> Void)
    private let sessionManager: Session = {
        let configuration = URLSessionConfiguration.default
        configuration.timeoutIntervalForRequest = 30
        configuration.httpAdditionalHeaders = ["User-Agent" : "\(Device().appVersion); \(Device().deviceInfo); iOS \(Device().osVersion)"]
        let sessionManager = Session(configuration: configuration, interceptor: APIInterceptor())
        return sessionManager
    }()
    
    func request<T: Decodable>(url: String, params: Parameters? = nil, method: HTTPMethod, header: Header) async throws -> T {
        if case .bearer = header, UserInfo.shared.isLogined == false {
            throw APIError.canceled
        }
        let request = sessionManager.request(url,
                                             method: method,
                                             parameters: params,
                                             encoding: JSONEncoding.default,
                                             headers: header.value)
            .validate(statusCode: 200..<300)
        let response: DataResponse<ResponseData<T>, AFError> = await request.serializingDecodable(ResponseData<T>.self).response

        switch response.result {
        case .success(let value):
            if let data = value.data {  // 성공
                return data
            } else if let error = value.error { // 200번대 응답이지만 실패 케이스인 경우
                throw APIError.other(error)
            } else {    // 알 수 없는 오류
                throw APIError.undecodable
            }
        case .failure(let error):
        // 크래시리틱스 로그 전송
            sendCrashlyticsUserInfo(url: url, param: params, description: error.localizedDescription)
            throw handleFailure(status: response.response?.statusCode, error: error)
        }
    }
}

 

request() 함수는 Decodable 객체를 반환하고, Interactor에서 그 Decodable 객체를 비동기로 로드 후 핸들링한다. 

아래 예시 코드는 클린 아키텍쳐를 사용하고 있다. worker는 API 데이터를 비동기로 처리하는 함수를 갖는다.

// RouteInteractor
Task {
    do {
        let result = try await worker.loadRoute(petID: petID, walkID: walkID)
        // Decodable result 객체 핸들링
    } catch let error as APIError {
    	// 예외 처리
    }
}

 

지도의 POI 데이터는 refresh가 가능하고, 너무 많이 로드하면 클러스터링으로 인해 서버에 부담이 될 수 있어서 횟수에 제한을 두었다.

최대 3번만 시도하도록 RequestIntercepter 클래스를 상속받아서 retry 메소드를 오버라이드한다.

// APIIntercepter
public func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
    if let status = request.response?.statusCode {
        if status == 401 {
            completion(.doNotRetry)
        }
    }
    if request.retryCount < retryLimit {
        let timeDelay: TimeInterval = 0.2
        completion(.retryWithDelay(timeDelay))
    } else {
        completion(.doNotRetry)
    }
}

페이징 처리

테이블 뷰나 콜렉션 뷰 등 무한 스크롤을 사용하는 경우 최적화를 위해서 페이징을 사용할 수 있다.

RESTful API를 설계할 때 현재 페이지에 대한 정보와 다음 페이지에 대한 정보를 함께 전달하도록 협의한다.

struct PagingInfoResponseDTO : Decodable, Equatable {
    var pageNumber: Int
    var numOfRows: Int
    var totalListSize: Int
    var isLastPage: Bool
    
    enum CodingKeys: String, CodingKey {
        case pageNumber = "pageNo"
        case numOfRows = "numOfRows"
        case totalListSize = "listSize"
        case isLastPage
    }
    
    func toDomain() -> PagingInfo {
        return PagingInfo(pageNo: pageNumber,
                          listSize: totalListSize,
                          numOfRows: pageOfRows,
                          isLastPage: isLastPage)
    }
}

4. WebSocket 활용

WebSocket이란?

RESTful API는 서버와 클라이언트 간 요청과 응답이 필요한 정적인 데이터 교환에 적합하다. RESTful API로는 실시간 데이터 업데이트가 불가능하다.

WebSocket은 실시간 양방향 데이터 전송에 적합한 통신 방식이다. RESTful API와 다르게 서버와 클라이언트 간 연결을 유지하며 데이터를 주고 받는다.

사용 사례

  • 채팅 애플리케이션
  • 실시간 주식 거래 데이터
  • 게임 상태 업데이트

WebSocket과 REST API 비교

특징 REST API WebSocket
연결 방식 요청-응답 기반 실시간 연결 유지
사용 사례 정적 데이터 실시간 데이터 업데이트
통신 방향 클라이언트 -> 서버 양방향 (서버 ↔ 클라이언트)

WebSocket 구현 전략

  • 최초 연결 요청은 REST API로 처리
  • 이후 WebSocket으로 실시간 데이터 주고받기
  • Ping 메시지를 활용해 연결 상태를 주기적으로 확인