앱을 개발하다 보면 외부 API를 직접 만들고 관리하고 싶을 때가 있다.
특히 “명언”처럼 변화가 거의 없고 정적 데이터라면 백엔드를 별도로 만들 필요 없이 GitHub Gist만으로 API처럼 활용할 수 있다.
이번 글에서는 내가 직접 만든 커스텀 명언 API(CustomQuote) 를
iOS 앱에서 Gist Raw URL로 불러오고, Combine 기반으로 관리하는 방식을 정리해본다.
📌 전체 구조 요약
이번 구현에서 내가 만든 흐름은 다음과 같다.
- GitHub Gist에 quotes.json 올리기
- Gist Raw URL을 이용해 외부 API처럼 사용
- CustomQuote 데이터 모델 생성
- QuoteService(Singleton) 작성 — 네트워크 호출 + 디코딩
- QuoteViewModel 생성 —
- 전체 명언 목록 fetch
- 랜덤 선택 기능 분리(중복 fetch 방지)
- 에러/로딩 상태 관리
- MainHomeViewModel에서 QuoteViewModel을 가져다 씀
1️⃣ GitHub Gist로 나만의 JSON API 만들기
GitHub Gist는 특정 파일을 웹에서 “Raw” 형태로 접근할 수 있기 때문에
사실상 정적 JSON API 서버의 역할을 할 수 있다.
📌 1) Gist 생성 후 quotes.json 파일 추가
[
{
"id": 1,
"category": "life",
"text": "삶이 있는 한 희망은 있다.",
"source": "키케로"
},
{
"id": 2,
"category": "inspire",
"text": "행동이 말보다 더 큰 소리를 낸다.",
"source": "어니스트 허밍웨이"
}
]
📌 2) “Raw” 버튼을 눌러 URL 가져오기
👉 이 URL이 바로 iOS 앱에서 호출할 API 엔드포인트가 된다.

2️⃣ CustomQuote 데이터 모델 생성
서버에서 내려받을 JSON 구조에 맞춰 모델을 만들었다.
struct CustomQuote: Decodable, Identifiable, Hashable, Sendable {
let id: Int
let category: String
let text: String
let source: String
}
- Decodable: JSON decode 가능
- Identifiable: SwiftUI/UICollectionView에서 바로 바인딩 가능
- Hashable: DiffableDataSource 등에서 필요
- Sendable: 병렬 처리 대비
3️⃣ QuoteService — Gist Raw URL을 비동기로 호출하는 서비스
명언 데이터를 호출하는 로직은 ViewModel에 절대 넣지 않는다.
→ 재사용성과 테스트 가능성을 위해 반드시 Service Layer로 분리.
아래는 내가 만든 QuoteService의 핵심 구조다:
// MARK: ✅ Gist 기반 명언 서비스 구현
// GitHub Gist Raw URL을 통해 모든 명언 데이터를 비동기적으로 가져오는 서비스
final class QuoteService: QuoteServiceProviding {
// Constants - URL 주소
// 직접 제공한 Gist Raw URL 주소
private let gistRawURLString = "Raw 주소"
// Singleton
static let shared = QuoteService()
private init() { }
// Method
// Gist로부터 전체 명언을 가져옵니다.
func fetchAllQuotes() -> AnyPublisher<[CustomQuote], any Error> {
// 🔹 URL 생성
guard let url = URL(string: gistRawURLString) else {
LogManager.print(.error, "잘못된 URL")
return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
}
LogManager.print(.info, "요청 시작: \(url.absoluteString)")
return URLSession.shared.dataTaskPublisher(for: url)
// 🔹 HTTP 상태 코드 검증 및 Data 추출
.tryMap { output -> Data in
if let httpResponse = output.response as? HTTPURLResponse {
LogManager.print(.info, "상태 코드: \(httpResponse.statusCode)")
guard (200..<300).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
}
return output.data
}
// 🔹 CustomQoute 배열 ([CustomQuote])로 디코딩
.decode(type: [CustomQuote].self, decoder: JSONDecoder())
// 🔹 메인 스레드에서 수신 (UI 업데이트)
.receive(on: DispatchQueue.main)
// 🔹 이벤트 로그 처리 및 디버깅
.handleEvents(
receiveOutput: { quote in
LogManager.print(.success, "명언 데이터 수신 성공: \(quote)")
},
receiveCompletion: { completion in
switch completion {
case .failure(let error):
LogManager.print(.error, "API 호출 실패: \(error.localizedDescription)")
case .finished:
LogManager.print(.success, "API 호출 완료")
}
}
)
// Publisher 타입 통합 및 반환
.eraseToAnyPublisher()
}
}
✔️ 서비스에서 한 일 정리
- 잘못된 URL 검사
- HTTP 상태 코드 체크
- [CustomQuote] 형태로 디코딩
- Combine publisher로 반환
- 메인 스레드에서 receive
→ ViewModel은 네트워크 세부사항 신경 쓸 필요 없음.
4️⃣ QuoteViewModel — 데이터 로드 + 캐싱 + 랜덤 선택을 담당
핵심 포인트는 두 가지 함수로 명확히 분리했다는 점이다.
| loadQuotes() | 최초 1회 전체 데이터를 불러옴 |
| selectRandomQuote() | 이미 불러온 배열에서 랜덤으로 선택 |
왜 분리했을까?
→ selectRandomQuote() 호출할 때마다 API를 다시 부르면 데이터 낭비 + 느림
→ 따라서 최초 1회만 데이터를 요청하고(mem caching)
→ 이후 랜덤은 캐싱된 배열에서 처리하도록 설계
💡 ViewModel 구조
import Foundation
import Combine
// MARK: ViewModel
final class QuoteViewModel: ObservableObject {
// MARK: ✅ Published Properties (View에 바인딩)
@Published var todayQuote: CustomQuote? // View에 표시될 오늘의 명언
@Published var isLoading: Bool = false // 로딩 상태 표시
@Published var errorMessage: String? // 오류 메시지
// MARK: ✅ Private Properties (데이터 캐싱 및 Combine 관리)
private var quoteService: QuoteServiceProviding // 메모리에 전체 명언 목록 저장
private var allQuotes: [CustomQuote] = []
private var cancellables = Set<AnyCancellable>() // Combine 구독 관리
init(quoteService: QuoteServiceProviding = QuoteService.shared) {
self.quoteService = quoteService
}
// MARK: ✅ Main Method
// Gist에서 전체 명언 데이터를 로드, 로드 성공 시 랜덤 명언을 선택합니다.
func loadQuotes() {
// 이미 데이터가 메모리에 있다면 (재실행이 아닌 단순 갱신이라면) 바로 랜덤 선택
if !allQuotes.isEmpty {
selectRandomQuote()
return
}
isLoading = true
errorMessage = nil
quoteService.fetchAllQuotes()
.sink { [weak self] completion in
guard let self = self else { return }
self.isLoading = false
switch completion {
case .failure(let error):
// 네트워크 실패 시 오류 메시지 업데이트
LogManager.print(.error, "[ViewModel] 데이터 로드 실패: \(error.localizedDescription)")
self.errorMessage = "명언을 불러오는 데 실패햇습니다. 네트워크를 확인해주세요."
case .finished:
LogManager.print(.success, "[ViewModel] 데이터 로드 완료 및 구독 종료")
}
} receiveValue: { [weak self] quotes in
guard let self = self else { return }
// 받은 전체 목록을 메모리에 저장
self.allQuotes = quotes
// 랜덤으로 명언을 선택하여 View에 게시
self.selectRandomQuote()
}
.store(in: &cancellables) // 구독 관리
}
// MARK: ✅ Business Logic
// 메모리에 저장된 전체 명언 목록에서 랜덤으로 하나를 선택하여 todayQuote를 업데이트합니다.
func selectRandomQuote() {
guard !allQuotes.isEmpty else {
LogManager.print(.error, "[ViewModel] 명언 목록이 비어있어 랜덤 선택 불가")
self.errorMessage = "표시할 명언 데이터가 없습니다."
return
}
// 전체 목록 중 하나를 랜덤으로 선택
let randomQuote = allQuotes.randomElement()
// todayQuote를 업데이트하고 View에 알림
self.todayQuote = randomQuote
LogManager.print(.success, "[ViewModel] 새로운 랜덤 명언 선택됨: \(randomQuote?.text ?? "")")
}
// MARK: ✅ Public Interface (View에서 버튼 클릭시 호출)
// 사용자 요청에 의해 새로운 랜덤 명언을 표시할 떄 사용합니다.
func refreshQuotes() {
// 메모리 데이터가 있다면 네트워크 호출 없이 즉시 갱신
if !allQuotes.isEmpty {
selectRandomQuote()
} else {
// 메모리에 데이터가 없다면 로드부터 다시 시도
loadQuotes()
}
}
}
5️⃣ MainHomeViewModel — 홈 화면에서 QuoteViewModel 사용
홈 화면에서는 복잡한 네트워크 로직을 몰라도 된다.
왜냐면 MainHomeViewModel에서 “QuoteViewModel을 들고 있기 때문”.
final class MainHomeViewModel: ObservableObject {
@Published var quoteVM: QuoteViewModel
@Published var calendarVM: CalendarViewModel
init(
quoteViewModel: QuoteViewModel = QuoteViewModel(),
calendarViewModel: CalendarViewModel = CalendarViewModel()
) {
self.quoteVM = quoteViewModel
self.calendarVM = calendarViewModel
quoteVM.loadQuotes() // 초기 로드
}
}
결과
- 홈 화면에 진입하면 자동으로 명언 한 개가 표시됨
- 버튼을 누르면 API 호출 없이 즉시 랜덤 변경
- 앱 성능 향상
- 데이터 사용 최소화
🔥 최종 정리 — 이 로직은 왜 좋은가?
✔️ 1) 백엔드 없이도 API 구축 가능
GitHub Gist = 읽기 전용 JSON 서버
→ 운영 비용 0원
✔️ 2) Service Layer 분리로 엄청 깔끔한 구조
ViewModel이 네트워크를 전혀 모르게 설계 → 유지보수 쉬움
✔️ 3) Combine으로 선언적 데이터 흐름 구축
- 로딩
- 에러
- 값 업데이트
한 번에 처리 가능
✔️ 4) 캐싱 + 랜덤 선택 분리로 성능 최적화
- 최초 1회만 fetch
- 이후에는 로컬 메모리에서 즉시 랜덤 제공
✔️ 5) 메인 홈 화면의 구성 요소(MainHomeViewModel)와 자연스럽게 연결
📝 “나중에 다시 만들 수 있도록” 핵심 체크리스트
- Gist에 JSON 업로드 후 Raw URL 복사
- CustomQuote 모델 생성
- QuoteService(Singleton) 작성
- Combine 기반 fetchAllQuotes() 구현
- QuoteViewModel에서
- loadQuotes() — 1회 fetch
- selectRandomQuote() — 캐싱된 배열에서 random
- MainHomeViewModel에서 quoteVM.loadQuotes() 호출
- UI에서 todayQuote 바인딩하여 표시