감정일기(가칭)

✨ iOS에서 GitHub Gist를 이용해 나만의 명언 API 만들기 — JSON 데이터 생성부터 ViewModel 적용까지 전 과정 정리

밤새는 탐험가89 2025. 12. 4. 13:45
728x90
SMALL

앱을 개발하다 보면 외부 API를 직접 만들고 관리하고 싶을 때가 있다.
특히 “명언”처럼 변화가 거의 없고 정적 데이터라면 백엔드를 별도로 만들 필요 없이 GitHub Gist만으로 API처럼 활용할 수 있다.

이번 글에서는 내가 직접 만든 커스텀 명언 API(CustomQuote)
iOS 앱에서 Gist Raw URL로 불러오고, Combine 기반으로 관리하는 방식을 정리해본다.


📌 전체 구조 요약

이번 구현에서 내가 만든 흐름은 다음과 같다.

  1. GitHub Gist에 quotes.json 올리기
  2. Gist Raw URL을 이용해 외부 API처럼 사용
  3. CustomQuote 데이터 모델 생성
  4. QuoteService(Singleton) 작성 — 네트워크 호출 + 디코딩
  5. QuoteViewModel 생성 —
    • 전체 명언 목록 fetch
    • 랜덤 선택 기능 분리(중복 fetch 방지)
    • 에러/로딩 상태 관리
  6. 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 바인딩하여 표시
728x90
LIST