본문 바로가기
감정일기(가칭)

🍋 iOS에서 외부 API 호출하기 – HappinessService 완전 해부

by 밤새는 탐험가89 2025. 10. 25.
728x90
SMALL

iOS 앱에서 외부 API로부터 데이터를 가져오려면,
URLSession을 사용해서 네트워크 요청을 보내고, 응답을 받아 JSON 데이터를 디코딩하는 과정이 필요합니다.

 

이번 글에서는 Combine과 함께 사용하는
실전형 네트워크 서비스 클래스 HappinessService를 단계별로 해부해봅니다 🧩


https://github.com/WAI-laboratory/happiness-backend?tab=readme-ov-file

 

GitHub - WAI-laboratory/happiness-backend: 한국어 행복 명언을 제공하는 무료 API입니다. API를 통해 무작위

한국어 행복 명언을 제공하는 무료 API입니다. API를 통해 무작위로 행복 명언과 그에 대한 영어 번역을 얻을 수 있습니다. - WAI-laboratory/happiness-backend

github.com

 

"GET https://api.sobabear.com/happiness/random-quote" 라는 문장은
이 주소로 GET 요청을 보내면 데이터를 받을 수 있다”는 뜻이에요.

 

🔍 1️⃣ 이 문장의 의미 정리

GET https://api.sobabear.com/happiness/random-quote

 

이건 HTTP 요청(HTTP Request) 형식의 설명이에요.


즉,

1. HTTP Method: GET
→ 서버에 “데이터를 요청”할 때 사용하는 방식
(반대로 데이터를 보낼 땐 POST, 수정은 PUT/PATCH, 삭제는 DELETE)

 

2. URL: https://api.sobabear.com/happiness/random-quote
→ 요청을 보낼 API 서버의 주소(엔드포인트)

그래서 의미를 풀어쓰면 👇

“이 URL에 GET 요청을 보내면 랜덤 명언(JSON 데이터)을 응답으로 받을 수 있다.”

 

🔍 2️⃣ 실제 응답 예시

보통 이 API는 JSON 형식으로 명언 데이터를 내려줘요.
예를 들어 아래와 같은 구조일 수 있어요 👇

즉,
👉 statusCode는 서버 응답 상태
👉 data 안에는 실제로 우리가 사용할 명언 내용(quote, author)이 들어 있습니다.

 

🔍 3️⃣ iOS에서 요청할 때는?

그렇기 때문에 Swift에서는 단순히 “이 주소를 GET 방식으로 호출”만 하면 돼요.

예를 들어 가장 간단한 형태는 아래처럼 됩니다 👇

let url = URL(string: "https://api.sobabear.com/happiness/random-quote")!
URLSession.shared.dataTask(with: url) { data, response, error in
    if let data = data {
        print(String(data: data, encoding: .utf8) ?? "Invalid Data")
    }
}.resume()

 

🔍 4️⃣ HappinessService에서는 이렇게 확장한 것

HappinessService는 바로 이 요청을

 

- Combine 기반 비동기 흐름으로 감싸고,

- JSON을 자동으로 디코딩하고,

- 로그와 에러를 정리해서

깔끔하게 하나의 함수(fetchRandomQuote())로 묶은 겁니다 👇

func fetchRandomQuote() -> AnyPublisher<HappinessQuote, Error>

 

즉, 내부적으로는 결국 같은 일을 합니다.
👉 “이 URL에 GET 요청을 보내서 JSON을 받아오는 것.”


🎯 전체 코드

import Foundation
import Combine

// MARK: - 🍋 행복 명언 서비스 (HappinessService)
final class HappinessService {
    
    // MARK: ✅ Singleton (싱글톤 패턴)
    static let shared = HappinessService()
    private init() {}
    
    
    // MARK: ✅ 명언 가져오기 (Fetch Random Quote)
    func fetchRandomQuote() -> AnyPublisher<HappinessQuote, Error> {
        
        // MARK: 🔹 1. URL 생성
        guard let url = URL(string: "https://api.sobabear.com/happiness/random-quote") else {
            LogManager.print(.error, "잘못된 URL")
            return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
        }
        
        LogManager.print(.info, "요청 시작: \(url.absoluteString)")
        
        
        // MARK: 🔹 2. 네트워크 요청 (URLSession)
        return URLSession.shared.dataTaskPublisher(for: url)
            
            // MARK: 🔹 3. 응답 처리 (상태 코드 확인)
            .tryMap { output -> Data in
                if let httpResponse = output.response as? HTTPURLResponse {
                    LogManager.print(.info, "상태 코드: \(httpResponse.statusCode)")
                }
                return output.data
            }
            
            // MARK: 🔹 4. JSON 디코딩
            .decode(type: HappinessResponse.self, decoder: JSONDecoder())
            
            // MARK: 🔹 5. 상태 코드 검증 + 데이터 추출
            .tryMap { response in
                guard response.statusCode == 200 else {
                    throw URLError(.badServerResponse)
                }
                LogManager.print(.success, "명언 데이터 수신 성공: \(response.data.author)")
                return response.data
            }
            
            // MARK: 🔹 6. 메인 스레드에서 수신
            .receive(on: DispatchQueue.main)
            
            // MARK: 🔹 7. 이벤트 로그 처리
            .handleEvents(
                receiveCompletion: { completion in
                    switch completion {
                    case .failure(let error):
                        LogManager.print(.error, "API 호출 실패: \(error.localizedDescription)")
                    case .finished:
                        LogManager.print(.success, "API 호출 완료")
                    }
                }
            )
            
            // MARK: 🔹 8. Publisher 타입 통합
            .eraseToAnyPublisher()
    }
}

💡 구조 요약

구분 역할 주요 포인트
Singleton 패턴 앱 전역에서 하나의 인스턴스만 사용 static let shared
네트워크 요청 URLSession.dataTaskPublisher 로 API 호출 Combine 기반 비동기
데이터 검증 HTTP 상태 코드 / JSON 응답 확인 tryMap + decode
결과 전달 AnyPublisher<HappinessQuote, Error> 뷰모델에서 구독 가능
스레드 관리 UI 업데이트를 위해 메인 스레드로 전달 .receive(on:)
에러 처리 / 로깅 커스텀 LogManager로 상태 출력 handleEvents

🧩 단계별로 이해하기

1️⃣ URL 생성

guard let url = URL(string: "...") else { ... }

 

 

- 문자열을 실제 URL 객체로 변환합니다.

- 잘못된 URL일 경우 바로 실패(URLError.badURL) 처리합니다.

 

2️⃣ 네트워크 요청

URLSession.shared.dataTaskPublisher(for: url)

 

 

- Combine의 Publisher 형태로 데이터를 스트림처럼 받아옵니다.

- 네트워크 호출 후 (data, response) 쌍을 발행합니다.

 

3️⃣ 응답 상태 코드 확인

.tryMap { output -> Data in ... }

 

- HTTP 응답의 상태 코드를 확인하고 로그로 출력합니다.

 

4️⃣ JSON 디코딩

.decode(type: HappinessResponse.self, decoder: JSONDecoder())

 

- API 응답(JSON)을 HappinessResponse 타입으로 자동 변환합니다.
(이 타입은 Decodable 프로토콜을 따르는 구조체여야 합니다.)

 

5️⃣ 상태 코드 검증 및 데이터 추출

.tryMap { response in
    guard response.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    return response.data
}

 

 

- statusCode가 200(정상)이 아닐 경우 에러를 던집니다.

- 정상이라면 response.data만 뽑아서 반환합니다.

 

6️⃣ 메인 스레드로 전달

.receive(on: DispatchQueue.main)

 

 

- UI 업데이트가 필요한 경우를 대비해 메인 스레드에서 결과를 받습니다.

 

7️⃣ 이벤트 로그 관리

.handleEvents(receiveCompletion: { ... })

 

 

- API 호출 성공/실패 여부를 LogManager로 출력합니다.

- 디버깅할 때 매우 유용합니다.

 

8️⃣ Publisher 타입 정리

.eraseToAnyPublisher()

 

 

- 내부 구현을 감추고, 외부에서는 단순히 AnyPublisher로만 보이게 합니다.
→ ViewModel에서 테스트나 교체가 쉬워집니다.


🚀 이렇게 사용합니다

@MainActor
final class HappinessViewModel: ObservableObject {
    
    
    // MARK: ✅ Published Properties
    @Published var quote: String = ""
    @Published var author: String = ""
    
    private let service: HappinessService
    private var cancellables = Set<AnyCancellable>()
    
    
    // MARK: ✅ Init
    init(service: HappinessService = .shared) {
        self.service = service
    }
    
    
    // MARK: ✅ Method
    func loadQuote() {
        HappinessService.shared.fetchRandomQuote()
            .sink(receiveCompletion: { completion in
                if case .failure(let error) = completion {
                    LogManager.print(.error, "명언 불러오기 실패: \(error.localizedDescription)")
                }
            }, receiveValue: { [weak self] quoteData in
                self?.quote = quoteData.content
                self?.author = quoteData.author
                LogManager.print(.success, "명언 업데이트 완료")
            })
            .store(in: &cancellables)
    }
}
728x90
LIST