Search

깃허브 앱 만들기(with. RxSwift)

Decodable과 Encodable

Swift에서 데이터를 쉽게 처리할 수 있도록 DecodableEncodable 프로토콜이 제공됩니다. 이 두 프로토콜은 외부 데이터 형식과 Swift 데이터 타입 간의 변환을 단순화하며 Codable은 이 두 프로토콜을 모두 포함하는 타입 별칭입니다. 이번 글에서는 DecodableEncodable의 개념을 설명하고 왜 Codable을 사용하지 않고 Decodable을 사용했는지에 대해 예제 코드와 함께 살펴보겠습니다.

Decodable

JSON, XML 또는 다른 데이터 형식에서 Swift 타입으로 데이터를 변환하는 데 사용됩니다. 데이터를 받아와서 내부 모델로 변환하는 작업에서 활용됩니다.
예를 들어, 유튜브를 사용할 때 수많은 비디오 데이터를 서버로부터 불러와야 합니다. 유튜브 서버는 비디오 제목, 설명, 조회수 등의 정보를 JSON 형식으로 앱에 보냅니다. 이때, 앱은 이 JSON 데이터를 받아 내부에서 사용할 수 있는 데이터 모델로 변환해야 합니다. 이 과정에서 Decodable 프로토콜이 사용됩니다.
// 비디오 정보를 담는 구조체 struct YouTubeVideo: Decodable { var title: String var description: String var viewCount: Int } // 서버로부터 받은 JSON 데이터 let videoData = """ { "title": "깃허브 앱 만들기", "description": "RxSwift를 이용하여 MVVM 패턴으로 깃허브 앱을 만들었습니다.", "viewCount": 678,334,598 } """.data(using: .utf8)! // JSON 데이터를 YouTubeVideo 객체로 디코딩 let video = try? JSONDecoder().decode(YouTubeVideo.self, from: videoData)
Swift
복사

Encodable

Swift 타입을 JSON이나 XML 등의 외부 데이터 형식으로 변환하는 데 사용됩니다. 데이터를 외부에 전송하기 전에 필요한 형식으로 만드는 작업에서 활용됩니다.
예를 들어, 메모 앱에서 사용자가 메모를 작성하면 이 메모 데이터를 JSON 형식으로 인코딩하여 서버에 전송해야 합니다. 이 과정에서 Encodable 프로토콜이 사용됩니다.
// 메모 정보를 담는 구조체 struct Memo: Encodable { var content: String var dateCreated: Date } // 사용자가 작성한 메모 let newMemo = Memo(content: "Meeting notes", dateCreated: Date()) // 메모 객체를 JSON 데이터로 인코딩 if let encodedMemo = try? JSONEncoder().encode(newMemo), let jsonString = String(data: encodedMemo, encoding: .utf8) { // 이제 이 JSON 문자열을 서버에 전송할 수 있습니다. print(jsonString) }
Swift
복사

Codable은 언제 사용하나요?

Codable은 Decodable과 Encodable을 모두 합친 타입 별칭으로, 한 데이터 모델에 대해 동시에 디코딩과 인코딩을 모두 수행해야 할 때 사용됩니다. 그래서 디코딩만 필요하거나 인코딩만 필요한 경우에는 굳이 사용할 필요가 없습니다. 만약 디코딩 또는 인코딩만 필요한데 Codable을 사용한다면 코드의 목적을 저해시키고 코드의 가독성이 떨어지게 됩니다. 예를 들어, 설정 앱에서 설정을 변경하고 이를 서버에서 저장해야 할 때 사용됩니다.
// 사용자 설정을 저장하는 모델 struct UserSettings: Codable { var username: String var notificationsEnabled: Bool var theme: String } // 서버로부터 설정을 불러올 때 let jsonData = """ { "username": "tech rafa", "notificationsEnabled": true, "theme": "dark" } """.data(using: .utf8)! // JSON 데이터를 UserSettings 객체로 디코딩 let decoder = JSONDecoder() if let userSettings = try? decoder.decode(UserSettings.self, from: jsonData) { print("Username: \(userSettings.username)") } // 사용자가 설정을 변경한 후, 이를 서버에 저장할 때 var settings = UserSettings(username: "rafa", notificationsEnabled: false, theme: "light") let encoder = JSONEncoder() if let encodedSettings = try? encoder.encode(settings), let jsonString = String(data: encodedSettings, encoding: .utf8) { // JSON 문자열을 서버에 전송 print(jsonString) }
Swift
복사
따라서 이번 프로젝트에서 GitHub Api로부터 저장소를 불러오는 기능만 들어갔기 때문에 Decodable을 사용하였고 MVVM 패턴을 적용하였습니다. 모델 코드부터 보도록 하겠습니다.

Model

먼저, GitHub API 주소를 Postman에 전송하여 데이터가 정상적으로 불러와지는지 확인해보았습니다.
그리고 stargazers_count가 ‘snake_case’로 선언되어 있습니다. 저희는 이를 Swift에서 사용하는 ‘cameCase’로 바꾸기 위해 CodingKey를 사용하도록 하겠습니다.
더 자세한 내용은 다음 글에서 참고 바랍니다.
import Foundation struct Repository: Decodable { let id: Int // 저장소의 고유 식별자 let name: String // 저장소 이름 let description: String? // 저장소 설명. 없을 수도 있기 때문에 옵셔널 타입 let stargazersCount: Int // 저장소 별 갯수 let language: String? // 저장소 주요 사용 언어. 없을 수도 있기 때문에 옵셔널 타입 // JSON 키와 구조체 프로퍼티의 매핑을 위한 열거형 enum CodingKeys: String, CodingKey { case id, name, description, language // JSON의 'stargazers_count' 키를 'stargazersCount' 프로퍼티에 매핑 case stargazersCount = "stargazers_count" } }
Swift
복사

ViewModel

다음은, MVVM 중 VM에 해당하는 코드입니다.
import RxCocoa import RxSwift class RepositoryListViewModel { let repositories: BehaviorSubject<[Repository]> = BehaviorSubject(value: []) private let disposeBag = DisposeBag() func fetchRepositories(of organization: String) { // 1 Observable.from([organization]) // 2 .map { organization -> URL? in URL(string: "https://api.github.com/orgs/\(organization)/repos") } // nil을 제거하여 URL 인스턴스만 전달 .compactMap { $0 } // 3 .map { URLRequest(url: $0) } // 4 .flatMap { request -> Observable<(response: HTTPURLResponse, data: Data)> in URLSession.shared.rx.response(request: request) } // 5 .filter { response, _ in 200..<300 ~= response.statusCode } // 6 .map { _, data -> [Repository] in do { return try JSONDecoder().decode([Repository].self, from: data) } catch { print("Error decoding data: \(error)") return [] } } // 7 .bind(to: repositories) .disposed(by: disposeBag) } }
Swift
복사
위 코드는 RxSwift를 사용하여 GitHub API로부터 조직의 저장소 데이터를 비동기적으로 가져오고 이를 앱에서 사용할 수 있는 형태로 변환하는 과정입니다.

여기서 잠깐!

<BehaviorSubject이란?> 초기값을 가지고 시작하며 구독자가 구독을 시작하는 순간 가장 최근 값 또는 초기값을 받습니다. 이런 특성 덕분에 데이터 스트림이 항상 최소 한 개의 값으로 시작해야 하는 경우에 매우 유용합니다 예를 들어, 사용자 인터페이스가 최신의 데이터 상태를 반영해야 하거나 앱이 데이터의 현재 상태를 즉시 반영해야 할 때 BehaviorSubject를 사용하면 좋습니다. *스트림(Stream): 데이터가 마치 물처럼 연속적으로 흐르는 것을 뜻함. 데이터가 순차적으로 한 번에 하나씩 나타나고 처리되는 일련의 데이터 요소들을 의미. 예를 들어, 라이브 방송이 있음. 방송은 계속해서 시청자에게 전달되고 시청자는 실시간으로 내용을 볼 수 있음. 여기서 방송의 흐름이 바로 스트림.
<map과 flatMap 연산자의 차이점> map은 각 데이터 요소를 받아서 변환하는 데 사용됩니다. 간단히 말해, 하나의 값에서 다른 형태의 값으로 변환할 때 사용됩니다. 반면, flatMapmap과 비슷하게 동작하지만 반환된 각 아이템을 새로운 Observable로 변환하고 이를 하나의 Observable 스트림으로 병합합니다. 이를 통해 비동기 작업의 결과를 순차적이고 체계적으로 처리할 수 있습니다. 예제에서는 URLRequest를 처리하여 네트워크 응답을 스트림으로 받기 위해 flatMap을 사용하였습니다. 이는 각 요청이 비동기적으로 발생하고 각각의 응답을 하나의 스트림으로 합쳐야 하기 때문입니다.
<disposeBag이란?> 생성된 모든 구독을 관리하고 뷰 모델이 사라질 때 구독을 자동으로 해제해주는 역할을 합니다. 이는 메모리 누수를 방지하고 불필요한 네트워크 요청이나 데이터 처리가 계속되지 않도록 합니다. disposeBag 없이 구독을 해제하지 않으면 뷰 모델이 사라진 후에도 구독이 계속 살아있어 앱의 성능 저하나 예상치 못한 버그를 초래할 수 있습니다.

RepositoryListViewModel의 구조와 기능

RepositoryListViewModel은 깃허브 저장소 정보를 관리하는 핵심 클래스입니다. 이 클래스는 다음과 같은 프로퍼티와 메서드로 이루어져 있습니다.

프로퍼티

repositories
변화 가능한 저장소 목록을 관리하는 BehaviorSubject입니다. 이는 초기값으로 빈 배열을 가지며 저장소 데이터가 업데이트 될 때마다 구독자에게 새로운 데이터를 제공합니다.
disposeBag
RxSwift에서 생성된 구독을 안전하게 해제하고 메모리 누수를 방지하기 위해 사용됩니다.

메서드

fetchRepositories(of organization: String)
주어진 조직의 깃허브 저장소 목록을 비동기적으로 가져옵니다. 이 함수는 RxSwift의 연산자를 활용하여 네트워크 요청의 실행부터 데이터 처리까지의 모든 과정을 한 스트림에서 관리합니다.
fetchRepositories 메서드에 대해 순서대로 알아보도록 하겠습니다.
1.
먼저, Observable.from을 통해 조직(organization) 이름을 Observable로 변환합니다. 이는 비동기 작업 시작하기 위한 준비 단계입니다.
2.
map 연산자를 사용하여 조직 이름으로부터 요청할 깃허브 API의 URL을 생성합니다.
3.
생성된 URL로부터 URLRequest 객체를 생성합니다. 이 단계에서 실제 네트워크 요청을 준비합니다.
4.
flatMapURLSession.shared.rx.response를 사용하여 실제 네트워크 요청을 비동기적으로 실행하고 응답을 받습니다.
5.
성공적인 HTTP 응답만을 필터링하기 위해 filter 연산자를 사용합니다.
6.
map을 통해 받은 데이터를 Repository 배열로 디코딩합니다.
7.
디코딩된 데이터는 repositories(BehaviorSubject)에 바인딩되어 앱의 다른 부분에서 사용될 수 있습니다.

View

앞서 작성한 뷰 모델을 사용하기 위해 프로퍼티를 선언하고 메모리 누수를 방지하기 위해 disposeBag도 선언해줍니다.
private let viewModel = RepositoryListViewModel() private let disposeBag = DisposeBag()
Swift
복사
뷰 컨트롤러의 네비게이션 타이틀을 설정하고 테이블 뷰 셀을 등록합니다. 또한 행의 높이도 지정해줍니다.
private func setupUI() { title = "Repositories" tableView.register(RepositoryListViewCell.self, forCellReuseIdentifier: "RepositoryListViewCell") tableView.rowHeight = 140 }
Swift
복사
테이블 뷰에 새로고침 기능을 추가합니다. 사용자가 화면을 아래로 당기면 새로고침이 시작되고 데이터를 최신 상태로 업데이트하도록 합니다.
private func setupRefreshControl() { refreshControl = UIRefreshControl() refreshControl?.do { $0.backgroundColor = .white $0.tintColor = .darkGray $0.attributedTitle = NSAttributedString(string: "당겨서 새로고침") } }
Swift
복사
viewModel.repositories는 Observable로, 저장소 데이터의 변화를 감지하고 테이블 뷰에 자동으로 데이터를 업데이트합니다(따라서 tableView.reloadData()가 필요없음). 새로고침 컨트롤이 활성화될 때마다 뷰 모델에게 새 데이터를 불러오도록 요청하고 테이블 뷰를 새로고침합니다.
private func configure() { tableView.dataSource = nil tableView.delegate = nil viewModel.repositories.bind( to: tableView.rx.items( cellIdentifier: "RepositoryListViewCell", cellType: RepositoryListViewCell.self ) ) { _, repository, cell in cell.repository = repository }.disposed(by: disposeBag) refreshControl?.rx.controlEvent(.valueChanged) .bind { [weak self] _ in self?.viewModel.fetchRepositories(of: "Apple") self?.refreshControl?.endRefreshing() } .disposed(by: disposeBag) }
Swift
복사
위에서 주의할 점은 테이블 뷰의 dataSourcedelegatenil로 설정하는 것입니다. RxSwift를 사용하는 경우, dataSourcedelegate의 역할이 Rx의 Observable로 대체되기 때문에 이들을 nil로 설정합니다.

전체 코드

Model
View
ViewModel
GitHubRepoWithRxSwift
rafa-e1