-Today's Learning Content-

  • API로 가져온 데이터를 CollectionView에 적용하기

1. 아이디어 구상하기

📌 내용 정리

API를 통해 네트워크 통신을 연습하고 싶은데 어떤 UI를 만들어볼까...
UICollectionView도 연습해보고 싶은데, 둘을 같이 사용해볼까?

1) API 선정하기

이번에는 API를 이용해서 URL을 통해 온라인 이미지를 가져오고, 그 이미지를 컬렉션뷰에 넣어서 보여주는 UI를 구현해 보려고 한다.
이를 위해서는 우선 API를 골라야 하는데, 연습이니까 유료 API는 무리고...
인터넷 서치를 통해 무료로 고양이 사진을 주는 API를 발견했다.

랜덤으로 고양이 사진을 주는 무료 API 사이트

회원 등록을 하지 않고 사용하면 최대 10개까지 한 번에 받을 수 있고, 하나씩 요청하는건 제한이 없는 듯 했다.
그럼 이제 UI를 구현하러 가보자.

2) UI 디자인

코드를 작성하기 이전에 어떤 UI를 만들 것인지 고민했다.
그래서 FigJam을 이용해서 간단히 디자인을 구성해보았다.

대충 이렇게 만들었다. 버튼을 누르면 URLSession을 통해 이미지를 가져오고 그 이미지를 컬렉션뷰의 셀에 추가하는 방식이다.
디자인을 끝냈으니 이제 진짜 코드를 작성하자.

3) JSON 모델 파일 생성하기

인터넷에서 URL을 통해 이미지를 가져오기 위해서 디코딩을 해 줄 필요가 있는데, 이번에는 JSON 파일 형식을 이용해 보기로 했다.
먼저, API를 제공하는 사이트의 형식을 보자.

[
  {
    "id": "eao",
    "url": "https://cdn2.thecatapi.com/images/eao.jpg",
    "width": 600,
    "height": 600
  }
]

id, url, width, height 라는 4가지 정보를 받아올 수 있는데, 나는 사진만 받아오면 되기 때문에 이번에는 url 정보만 받기로 했다.

// JSON 데이터 모델
import UIKit

struct CatImageData: Codable {
    let url: String
}

4) UI 배치하기

이번 연습에서 필요한 UI는 2가지 뿐이다. UIButtonUICollectionView이다.
먼저 UICollectionView의 셀을 커스텀해서 만들어주자.

먼저 새로운 파일을 만들고 새로운 클래스를 만들어 UICollectionViewCell 클래스를 상속시킨다.

class MyCollectionViewCell: UICollectionViewCell {
}

다음은 컬렉션뷰에 identifier로 사용할 id와 생성자, 필수 생성자를 만들어준다.

static let id: "MyCollectionViewCell" // 고유한 셀 이름

override init(frame: CGRect) {
	super.init(frame: frame)

}
    
required init?(coder: NSCoder) {
	fatalError("init(coder:) has not been implemented")
}

컬렉션뷰의 셀에 들어갈 UI는 UIImageView 하나만 있으면 된다.
이미지 뷰를 만들어주고 UI 설정과 오토레이아웃을 설정한다.

private let imageView = UIImageView()

// 컬렉션뷰 셀 자체 UI 설정
private func configUI() {
	self.backgroundColor = .white
	self.layer.cornerRadius = 20
	self.layer.borderColor = UIColor.gray.withAlphaComponent(0.7).cgColor
	self.layer.borderWidth = 1
}
    
// 이미지뷰 UI 설정 및 오토레이아웃 설정
private func setupImageView() {
	imageView.clipsToBounds = true
	imageView.backgroundColor = .yellow
	imageView.contentMode = .scaleAspectFill
	imageView.layer.cornerRadius = 10
	self.addSubview(imageView)
        
	imageView.snp.makeConstraints {
		$0.center.equalToSuperview()
		$0.width.height.equalToSuperview().inset(10)
	}
}

그리고 이제 이미지뷰에 사진을 추가할 메소드를 생성한다.
이 메소드는 나중에 ViewController에서 컬렉션뷰 데이터소스 메소드 부분에서 호출하여 사용할 메소드이다.

func insertImage(_ image: UIImage) {
	self.imageView.image = image
	self.layoutIfNeeded()
}

커스텀 컬렉션뷰 셀에 대한 설정은 이걸로 끝이니 이제 ViewController에서 UI 설정을 하자

5) Button, CollectionView 생성

우선 버튼과 컬렉션뷰를 추가해주고 이에 대한 설정과 오토레이아웃을 잡아준다.

private let button = UIButton()

private let collectionView: UICollectionView!

여기서 컬렉션뷰만 초기화하지 않은 이유는 컬렉션뷰의 레이아웃 설정 때문이다.
컬렉션뷰는 초기화될 때 반드시 Layout에 대한 설정을 가져야 하는데, 여기서 초기화 하면 레이아웃을 가지고 있지 않기 때문에 크래시가 발생해 앱이 실행되지 않기 때문이다.

// UI 및 오토레이아웃 설정
func configUI() {
	[collectionView, button].forEach { view.addSubview($0) }
        
	setupButtonViewLayout()
	setupCollectionViewLayout()
        
	configButtonView()
	configCollectionView()
}
    
func configButtonView() {
	self.button.setTitle("Insert Column", for: .normal)
	self.button.titleLabel?.font = UIFont.systemFont(ofSize: 20, weight: .bold)
	self.button.titleLabel?.textColor = .white
	self.button.backgroundColor = .cyan
	self.button.layer.cornerRadius = 10
}
    
func setupButtonViewLayout() {
	self.button.snp.makeConstraints {
		$0.centerX.equalToSuperview()
		$0.bottom.equalTo(view.safeAreaLayoutGuide).inset(15)
		$0.width.equalTo(200)
		$0.height.equalTo(50)
	}
}
    
func configCollectionView() {
	self.collectionView.backgroundColor = .clear
	self.collectionView.register(MyCollectionViewCell.self, 
    							 forCellWithReuseIdentifier: MyCollectionViewCell.id)
	self.collectionView.showsVerticalScrollIndicator = false
	self.collectionView.showsHorizontalScrollIndicator = false
	self.collectionView.delegate = self
	self.collectionView.dataSource = self
}
    
func setupCollectionViewLayout() {
	self.collectionView.snp.makeConstraints {
		$0.trailing.leading.top.equalTo(view.safeAreaLayoutGuide).inset(20)
		$0.bottom.equalTo(button.snp.top).offset(-20)
	}
}

이제 ViewController의 생명주기 중 viewDidLoad에 메소드들을 추가하여 UI를 뷰에 추가해준다.
이 때, 여기서 컬렉션뷰에 레이아웃을 추가한다.

override func viewDidLoad() {
	super.viewDidLoad()
        
	view.backgroundColor = .white
    
    // 컬렉션뷰에 레이아웃을 추가하여 초기화
	let layout = UICollectionViewFlowLayout()
	layout.scrollDirection = .vertical
	layout.minimumInteritemSpacing = 10
	layout.sectionInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
	layout.itemSize = CGSize(width: 100, height: 100) // 셀의 초기 크기 값
        
	self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        
	configUI() // 버튼 및 컬렉션뷰 UI 설정 메소드
}

여기까지가 기본 세팅이다. 이제부터 API 통신을 통해 이미지를 받아오고 그것을 컬렉션뷰에 추가하는 작업을 진행하면 된다.

2. API 통신하기

📌 내용 정리

API를 통해 이미지를 불러오고, 해당 이미지를 컬렉션뷰의 셀에 적용하는 작업

1) 컬렉션뷰 데이터소스 설정

본격적으로 API를 연결하기 전, 위에서 만든 컬렉션뷰의 데이터소스에 대한 메소드를 작성해야 한다.

먼저 셀의 수를 표현하고, 이미지뷰의 이미지로 쓰일 데이터 소스를 선언해준다.

private let images: [UIImage] = [] // 초기값은 빈 값

그리고 데이터소스 메소드를 작성해준다.

extension ViewController: UICollectionViewDataSource {
    // 셀의 수
    // 데이터소스에 담긴 데이터의 수만큼 반환
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return self.images.count
    }
    
    // 셀의 설정
    // 커스텀 셀의 설정을 가져온다.
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = self.collectionView.dequeueReusableCell(withReuseIdentifier: MyCollectionViewCell.id, for: indexPath) as? MyCollectionViewCell else {
            return UICollectionViewCell()
        }
        
        // 셀의 이미지뷰에 이미지를 추가하는 메소드
        // 데이터소스의 indexPath.row 번째 데이터를 삽입한다.
        cell.insertImage(self.images[indexPath.row])
        
        return cell
    }
    
}

다음은 URLJSON형태로 변환하는 메소드를 만들 차례이다.

2) JSON Decoding

처음에는 Alamofire 라이브러리를 사용할까 고민했지만, 일단 기본부터 천천히 익히자는 생각으로 기본 URLSession을 사용하기로 했다.
먼저 함수를 선언해준다.

func fetchData<T: Decodable>(url: URL, completion: @escaping (T?) -> Void) {
	// ...
}

함수의 파라미터는 JSON 형태로 변환할 URL 타입과, 클로저를 받는다.
굳이 클로저를 받지 않고 메소드 하나에서 모두 끝낼 수도 있지만, 메소드의 역할 분리를 위해 이 메소드에서는 URLJSON 데이터로 변환하는 역할만 담당하도록 하였다.

이제 URLSession을 만들어 추가하고 입력받은 URLJSON 파일로 변환하기 위해 dataTask 메소드를 실행해준다.

func fetchData<T: Decodable>(url: URL, completion: @escaping (T?) -> Void) {
	
    let session = URLSession(configuration: .default)
    session.dataTask(with: url) { (data, response, error) in
    	// ...
    }
    
}

URLSession의 옵션에 대해서는 여기서 정리해뒀다.

dataTask 메소드를 활용하면 입력한 url에서 데이터를 요청하게 된다.
이 때, 되돌아온 데이터는 요청한 데이터 값이 있을 수도 있고 없을 수도 있다. 즉 Optional 타입이다.
또, Error가 같이 오는 경우도 있기 때문에 이를 확인하고 오류를 방지하기 위한 코드를 작성해준다.

guard let data, error == nil else {
	print("data 수신 오류")
	completion(nil)
	return
}

이렇게 하면 데이터의 값이 있는지 확인하고, error가 있는지 확인할 수 있다.
다음은 response를 이용해 네트워크 요청이 성공했는지 확인을 해 볼 차례이다.

let successRange = 200..<300
if let response = response as? HTTPURLResponse, 
				  successRange.contains(response.statusCode) {
	// ...
}

이 코드는 responseHTTPURLResponse로 캐스팅하여 네트워크의 응답이 http 응답인지 확인하고, statusCode를 검출하여 성공범위 내에 있는지 확인하는 코드다.
일반적으로 http 네트워크 요청의 성공범위는 200~299이기 때문에 successRange의 범위를 똑같이 설정하고, responsestatusCode가 범위 내에 있는지 검사한 것이다.

상태코드는 200: 요청 성공, 201: 리소스 생성됨, 204: 요청 성공, 내용 없음 등으로 나타난다.

네트워크 요청이 성공적으로 끝났다면 이제 받아온 데이터를 JSON 형식으로 디코딩 해준다.
이 때, 디코딩 메소드는 throws로 에러를 반환할 수 있기 때문에 꼭 try를 통해 시도해야 한다.

do {
	let decodeData = try JSONDecoder().decode(T.self, from: data)
	completion(decodeData)
} catch {
	print(error)
	return
}

나는 do-try-catch를 이용하여 디코딩을 진행하고, 디코딩에 성공하면 해당 값을 클로저에 전달하도록 했다.
이렇게 하면 이 메소드를 호출한 곳에서 클로저를 호출하여 디코딩된 값을 사용할 수 있게 된다.

전체 코드는 아래와 같다.

func fetchData<T: Decodable>(url: URL, completion: @escaping (T?) -> Void) {

	let session = URLSession(configuration: .default)
	session.dataTask(with: url) { (data, response, error) in
		guard let data, error == nil else {
			print("data 수신 오류")
			completion(nil)
			return
		}
            
		let successRange = 200..<300
		if let response = response as? HTTPURLResponse, successRange.contains(response.statusCode) {
                
			do {
				let decodeData = try JSONDecoder().decode(T.self, from: data)
				completion(decodeData)
			} catch {
				print(error)
				return
			}
		}
	}.resume() // 필수 선언
}

3) URL 검증 및 이미지 추가

이제 위에서 만든 메소드를 사용해서 URL을 검증하고, 이미지를 불러와 셀의 데이터소스에 추가하는 메소드를 작성한다.

먼저 메소드를 선언하고 URL을 검증하는 코드를 작성한다.
이 때, 이번 메소드는 버튼의 액션으로 사용할 예정이기 때문에 @objc로 지정해준다.

@objc func fetchCatData() {
	var component = URLComponents(string: "https://api.thecatapi.com/v1/images/search")
	guard let url = component?.url else {
		print("잘못된 URL")
		return
	}
    
    // ...
}

위의 코드를 통해 입력한 URL이 유효한지 검증하고, 유효하지 않을 경우 더이상 진행하지 못하도록 막을 수 있다.
URL의 검증을 마친 후에는 위에서 작성한 메소드를 호출한다.

self.fetchData(url: url) { [weak self] (result: CatImageData?) in
	guard let self, let result else {
		print("fetch 오류")
		return
	}
    
    // ...
}

이렇게 하면 위에서 검증한 URLfetchData 내부에서 JSON 형식으로 변환하여 클로저에 매개변수로 전달해준다.
여기서 클로저의 매개변수는 result이고, 타입은 제네릭 타입으로 지정했기 때문에 Decodable을 상송하는 타입을 선언해주면 된다. 나는 가장 처음에 CatImageData라는 JSON 형식을 받을 모델을 정의했었기 때문에 해당 모델을 타입으로 받았다.

그리고 클로저 내부에서 self(ViewController)의 값을 사용해야 하는데, 그냥 사용하면 순환참조가 발생하여 메모리 누수가 발생할 수 있기 때문에 weak를 사용하여 클로저 캡처를 하여 사용하였다.
다만 이렇게 하면 self가 옵셔널이 되기 때문에 guard문을 통해 옵셔널 바인딩을 해주었다.

이제 위에서 데이터에 문제가 없다면 result의 값을 상요할 수 있을 것이다.
그럼 그 값을 이용해서 컬렉션뷰의 데이터 소스인 images 배열에 UIImage를 추가하는 코드를 작성하면 된다.

// 이미지 URL은 클로저의 매개변수로 받아온 result(CatImageData)의 프로퍼티 url을 사용
if let imageURL = URL(string: result.url) {
	// response와 error는 사용하지 않기 때문에 와일드카드 패턴 사용
	URLSession.shared.dataTask(with: imageURL) { imageData, _, _ in
		if let imageData, let image = UIImage(data: imageData) {
        
        	// UI 업데이트는 항상 메인 스레드에서 진행
			DispatchQueue.main.async {
				self.images.append(image)
				self.collectionView.reloadData()
				print("이미지 불러오기 성공")
			}
		}
	}.resume() // 필수 선언
}

모든 메소드를 작성했으니 버튼에 액션을 추가하고 빌드를 해서 앱이 잘 작동하는지 확인한다.

self.button.addTarget(self, action: #selector(fetchCatData), for: .touchDown)

다행히 빌드는 잘 이루어졌다.
이제 버튼을 눌러서 셀이 잘 추가되는지 확인해보자.

응...? 왜 오류가...

일단 에러 메세지를 살펴보자

typeMismatch(Swift.Dictionary<Swift.String, Any>, 
Swift.DecodingError.Context(codingPath: [], 
debugDescription: "Expected to decode Dictionary<String, Any> but found an array instead.", underlyingError: nil))
미스매치(Swift.Dictionary<Swift.String, Any>, 
Swift.DecodingError.Context(codingPath: [, debugDescription: "사전<String, Any>를 해독할 것으로 예상되지만 
대신 배열을 찾았습니다.", 기본 오류: 0)

음... 무슨 소리인지 모르겠다.
일단 이런 에러메세지가 출력 됐다는 뜻은, print(error)를 작성했던 디코딩 부분에서 실패한 것 같다.
그럼 에러를 해결해보자.

JSON Decoding 에러 해결하기

📌 내용 정리

JSON 디코딩이 실패한 원인을 분석하고 해결하기

1) 에러 메시지 분석

위에서 본 에러메시지를 해석해보면,
내가 지정한 데이터 타입 모델(CatImageData)이 Dictionary 형식으로 받을 것처럼 보이지만 네트워크 요청으로 받아온 데이터는 배열 형식이었다... 라는 뜻인 것 같다.

다시 내가 만든 데이터 모델의 코드를 보자.

// JSON 데이터 모델
import UIKit

struct CatImageData: Codable {
    let url: String
}

음... 딱히 딕셔너리 형식은 아닌데...
혹시 배열 형식이 아니라서 그런걸까?
그래서 데이터 모델을 배열 형식으로 바꾸고 관련된 코드도 싹 수정해 주었다.

struct CatImageData: Codable {
    let data: [CatData]
}

struct CatData: Codable {
    let url: String
}

// 이미지 URL을 불러오는 코드 수정
if let imageURL = URL(string: result.data.first?.url ?? "") { ... }

그리고 다시 빌드를 해보면...

여전히 에러가 발생한다...
그럼 데이터 모델의 문제는 아닌 것 같으니 다시 원래대로 돌리고 디코딩을 구현하는 코드 쪽으로 가보자.

코드는 이렇게 구현이 되어 있는데 뭐가 문제일까...
코드를 다시 읽으며 문제점을 찾아보는데 한 가지 눈에 띄는 것이 있었는데,
바로 fetchData 메소드의 클로저 매개변수 부분이었다.
혹시 저 부분을 바꾸면 되나? 싶어서 매개변수를 수정하고 빌드를 해봤는데...

self.fetchData(url: url) { [weak self] (result: [CatImageData]?) in { ... }

됐다...!!!

설마 저 작은 변수가 이런 큰 문제를 만들 줄이야...
콘솔창에 뭔가 무수한 노란 에러가 발생하는데, 네트워크와 관련된 에러라고 한다.
작동에는 문제가 없으니 대충 넘어가기로...

2) 컬렉션뷰 셀 크기 동적 변경하기

이대로 완성할 수도 있었지만, 뭔가 아쉬웠다.
버튼을 누를 때마다 새로운 고양이 사진이 담긴 셀이 추가되는건 좋지만, 배열이 자동으로 변경되면 더 좋을 것 같았다.
그래서 수정해보기로 했다!!

컬렉션뷰의 셀의 크기를 동적으로 바꿔줄 수 있는 코드를 찾아보니
Delegate를 이용하는 방법도 있고, 메소드를 이용하는 방법도 있었는데, 나는 setCollectionViewLayout이라는 메소드를 이용하기로 했다.

우선 메소드를 하나 선언해주고 사이즈라는 변수를 추가한다.

func setupCollectionViewLayoutSize() {
	let size = view.frame.width
    let columns = self.images.count
}

여기서 size는 뷰 컨트롤러의 뷰가 가지는 width 사이즈이고, columns는 컬렉션뷰의 데이터 소스의 수이다.
이걸 선언한 이유는, 컬렉션뷰 데이터 소스의 수가 3개보다 많으면 배열을 2*n 으로 변경하고, 6개보다 많으면3*n 배열로 변경하기 위해서이다.

여기서 하나의 변수를 더 선언해줄건데 이 변수는 연산 프로퍼티를 이용하여 선언한다.

var grid: CGFloat {
	if columns <= 3 {
		return 1
	} else if columns <= 6 {
		return 2
	} else {
		return 3
	}
}

grid는 컬렉션뷰 데이터 소스의 아이템 수에 따라 리턴 값이 달라진다.
이 값을 이용해서 위에서 정의한 size의 값을 나누어주면 셀의 크기가 뷰의 크기에 맞춰 조정된다.

// 컬렉션뷰의 레이아웃 정의로 셀의 사이즈 설정
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: (size / grid) - 40, height: (size / grid) - 40)

// 컬렉션뷰의 레이아웃 재설정 -> 셀의 크기 변경
self.collectionView.setCollectionViewLayout(layout, animated: true)
// 셀이 추가될 때 추가된 셀의 위치로 자동 스크롤
self.collectionView.scrollToItem(at: IndexPath(row: columns - 1, section: 0), at: .centeredVertically, animated: true)
// 컬렉션뷰 reload
self.collectionView.reloadData()

이제 이 메소드를 어디에 호출하면 좋을까?
가장 적절한 타이밍은 셀이 추가된 이후일 것이라고 생각한다.
그렇기 때문에 URLSession 내부에서 컬렉션뷰의 데이터소스 아이템을 추가하는 코드 바로 아래에 호출해준다.

그럼 이제 빌드를 해서 잘 실행되는지 확인해보자.

한 번에 성공!!
(사실 굉장히 많은 우여곡절이 있었지만 패스...)

-Today's Lesson Review-

오늘은 UIKit에서 API를 활용한 예제를 만들어 보았다., 자주 쓰이는 컬렉션 뷰를 같이 활용하여 UI를 만드는 실력을 늘리고자 하였다.

API와 관련된 코드는 여전히 너무 어려운 것 같다... 클로저도...
아직 공부 해야할 내용이 무척이나 많은 것 같다.
profile
이유있는 코드를 쓰자!!

0개의 댓글