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)
}
}'감정일기(가칭)' 카테고리의 다른 글
| 📘 단위 테스트 vs 통합 테스트– HappinessViewModel 사례로 배우는 Combine 테스트 구조 (0) | 2025.10.27 |
|---|---|
| ✅ ViewModel을 만들기 전에, Service 계층이 제대로 동작하는지 단위 테스트로 검증하자 (0) | 2025.10.27 |
| “테스트 실행 타이밍”과 “ViewModel 초기화 타이밍”이 안 맞는 상황 해결 (0) | 2025.10.24 |
| 초보자도 쉽게 이해할 수 있는 단위 테스트의 기본 구조 - HomeViewModelTests (0) | 2025.10.24 |
| 처음 보는 사람도 다음엔 혼자 Mock을 만들 수 있게...설계가이드 (0) | 2025.10.24 |