서버에서 데이터 가져오기

SteadySlower·2021년 12월 23일
0
post-thumbnail

계획

컴동쌤의 앱이 드디어 서버를 가졌습니다. 저번 포스팅에 서버에 2개의 API를 만들었는데요. 이 API를 미리 구현한 기능에 연결하는 작업을 해보도록 하겠습니다.

API 연결

1번 API

1번 API는 학년별 반 갯수, 반별 학생 수를 알려주는 API 였습니다. 이 API를 사용해서 학생부장님이 사용하시는 학생 조회 화면 (FaceCheck)에 사용할 예정입니다. 임의로 나오도록 했던 버튼의 갯수를 DB에 반영해서 정확한 데이터를 반영할 수 있을 것 같습니다.

2번 API

2번 API는 학년-반-번호를 받아서 학생을 조회하는 API입니다. 마찬가지로 학생부장님의 학생 조회 화면에서 사용할 API입니다.

Alamofire와 KingFisher

swift로 개발할 때 빠져서는 안될 두 라이브러리입니다. 대부분의 iOS 앱에서 사용되고 있을 것 같은데요. alamofire는 네트워크 통신을 편리하게 해주는 라이브러리입니다. swift로 Http 통신을 사용할 때 사용하는 URLSession을 한단계 감싸서 만든 라이브러리입니다. KingFisher는 이미지를 다운로드하는데 특화되어 있습니다. 비동기 다운로드 처리와 캐싱을 자동으로 해줍니다. 이번 포스팅은 두 라이브러리를 사용해서 개발해보겠습니다.

구현 (1번 API 연결)

StudentService 만들기

싱글톤 객체

싱글톤 객체는 한 앱에 해당 객체의 인스턴스가 1개만 존재하도록 하는 디자인 패턴입니다. 네트워크를 담당하는 객체에 싱글톤 패턴을 적용하는 이유는 다음과 같습니다.

  1. 메모리를 절약할 수 있고 (필요한 객체마다 인스턴스를 생성하지 않기 때문에) 속도도 빠릅니다. (이미 생성된 인스턴스를 사용하기 때문에)
  2. 데이터를 일관되게 가지고 있을 수 있습니다. 싱글톤 해당 작업에 대한 모든 데이터를 가지고 있기 때문에 여러 다른 클래스에서 참고할 때 같은 데이터를 반환할 수 있습니다.

StudentService에서 싱글톤을 사용하는 이유는 1번 API와 관련이 있습니다. 1번 API가 주는 데이터의 경우 처음에 한번만 받아와서 캐싱해서 사용하는 것이 효율적입니다. 따라서 싱글톤 객체가 해당 데이터를 가지고 있도록 하겠습니다.

struct StudentService {
    static let shared = StudentService()
}

응답 구조체 만들기

서버에서 보내는 응답은 아래와 같은 json 입니다.

동일한 구조를 가진 Response 객체를 하나 만들도록 하겠습니다. 하지만 result 부분은 어떤 API를 사용하느냐에 따라 다르므로 그때그때 Response객체를 따로 만들기 보다는 제네럴을 활용하도록 하겠습니다.

Codable 프로토콜을 채택하면 JSON 형식으로 쉽게 인코딩하고 디코딩할 수 있습니다.

struct Response<T: Codable>: Codable {
    let isSuccess: Bool
    let code: Int
    let message: String
    let result: T
}

학교 현황 구조체 만들기

학교의 학년별 반 갯수, 반별 학생 수를 나타내는 구조체를 만들어보도록 하겠습니다. 방금 만든 응답 구조체의 T자리에 들어갈 구조체입니다.

struct SchoolStatus: Codable {
    let numOfClasses: [Int: Int]
    let numOfStudents: [Int: [Int: Int]]
}

fetch 해오는 함수 만들기

alamorfire 라이브러리를 이용해서 만들었습니다. alamofire는 json으로 자동으로 디코딩하는 메소드도 제공합니다. 그 메소드를 통해 응답을 바로 디코딩해서 후행 클로저의 인자로 전달합니다.

아래 함수는 비동기적으로 실행이 되므로 completionHandler를 인자로 받습니다. completionHandler는 네트워크 작업을 비동기적으로 실행하고 나서 실행할 작업을 정의합니다. 해당 함수는 현재 함수에서 실행되지 않고 현재 함수가 리턴된 다음에 실행되기 때문에 @escaping을 붙여서 escaping closure임을 표시합니다.

guard문을 두 가지 이용해서 최소한의 에러 핸들링 처리를 해주었습니다. 첫 번째 guard 문은 network 자체가 실패해서 데이터 자체가 없을 때를 대비했고 두 번째 guard문은 서버에서 실패를 응답으로 보냈을 때를 대비합니다.

또한 StudentService 객체에 SchoolStatus를 캐싱해두고 캐싱한 데이터가 있을 때는 해당 데이터를 반환하도록 했습니다.

( 🤫 사실 이 앱에서 위 API는 FaceCheckViewController, FaceCheckViewModel을 init하는데 한번 사용되고 다시 사용되는 일은 없습니다. FaceCheckViewController는 앱이 실행되는 동안 deinit되지 않기 때문입니다. 하지만 캐싱을 구현한 이유는 앱의 확장성과 싱글톤 사용의 취지를 살리기 위함임을 감안해주세요.🙏)

struct StudentService {
    static let shared = StudentService()
    
    // SchoolStatus 캐싱용 property
    private var schoolStatus: SchoolStatus?
    
    func fetchSchoolStatus(completionHandler: @escaping((SchoolStatus) -> Void)) {
    
        // 캐싱한 데이터 사용
        if let schoolStatus = schoolStatus {
            completionHandler(schoolStatus)
            print("캐싱한 데이터 사용됨")
            return
        }
        
        // 캐싱한 데이터가 없을 경우에 서버에서 데이터 가져오기
        AF.request("http://localhost:8080/students/totalNumber").responseDecodable(of: Response<SchoolStatus>.self) { data in
            guard let response = data.value else { return }
            guard response.isSuccess == true else { return }
            completionHandler(response.result)
        }
    }
}

VM에서 해당 함수 활용하기

기존의 더미데이터 대신에 실제 서버에서 받은 값을 활용하도록 합시다. 일단 서버에서 받은 값을 저장할_numOfClasses와 _numOfStudents를 선언합시다. 해당 값은 fetchNumberOfCells 함수를 통해 세팅됩니다. 그리고 나서 VC에서 참조할 값을 numOfClassCells와 numOfStudentCells라는 계산 프로퍼티로 선언해둡니다.

fetchNumberOfCells에 보시면 completionHandler로 전달되는 후행 클로저에 [weak self]로 선언한 것을 보실 수 있습니다. 이렇게 선언한 이유는 네트워크를 담당하는 클래스에서 현재 ViewModel을 강한 참조하지 않게 하기 위해서 입니다. 강한 참조를 하게 되면 ViewModel이 deinit 되어야 하는 타이밍에 네트워크 클래스에서 참조하고 있다면 deinit이 되지 않을 수 있습니다. 최악의 경우에 네트워크 클래스의 메소드가 리턴되지 않는 경우 (socketIO나 firebase에 observe를 사용하는 경우)에는 deinit이 영원히 안될 수도 있습니다. ARC에 대해서 공부해보시면 이유를 명확히 아실 수 있을 겁니다.

아직 fetchNumberOfCells이 한번도 실행되지 않았습니다. ViewModel의 init을 구현해서 처음에 ViewModel의 인스턴스가 생성될 때 한번 실행하도록 하겠습니다. 한번만 실행하면 내부 변수에 세팅이 되어서 더 이상 호출할 필요가 없습니다.

// MARK: - cellNumberData

private var _numOfClasses: [Int: Int]?

private var _numOfStudents: [Int: [Int: Int]]?

private func fetchNumberOfCells() {
    StudentService.shared.fetchSchoolStatus { [weak self] status in
        self?._numOfClasses = status.numOfClasses
        self?._numOfStudents = status.numOfStudents
    }
}

var numOfClassCells: Int {
    guard let _numOfClasses = _numOfClasses else { return 0 }
    guard let grade = grade else { return 0 }
    return _numOfClasses[grade]!
}

var numOfStudentCells: Int {
    guard let _numOfStudents = _numOfStudents else { return 0 }
    guard let grade = grade else { return 0 }
    guard let classNumber = classNumber else { return 0 }
    return _numOfStudents[grade]![classNumber]!
}
// MARK: - LifeCycle

init() {
    self.fetchNumberOfCells()
}

VC에서 활용하기

VC에서 바뀐 점은 거의 없습니다. 기존과 동일하기 VM에서 필요한 cell 갯수를 받아오면 됩니다.

extension FaceCheckViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        var numOfCells: Int
        switch currentInputCellType {
        case .grade: numOfCells = 3
        case .classNumber: numOfCells = viewModel.numOfClassCells
        case .number: numOfCells = viewModel.numOfStudentCells
        }
        return numOfCells
    }
}

결과

하나 더!) 에러 처리하기

네트워크와 관련된 작업은 항상 에러가 발생할 여지가 있습니다. 네트워크에서 에러가 발생했을 때 VM과 VC에서 적절한 에러 처리를 해봅시다.

문제가 생기면 nil을 반환하도록!

numOfClassCells, numOfStudentCells를 위에서는 에러가 나면 0을 반환합니다. 기존 코드와의 호환성을 위해서 0을 반환하도록 하였습니다.

만약 네트워크에 장애가 생긴다면 사용자 입장에서는 아무 피드백 없이 빈 화면을 볼 것입니다. 좋지 않은 UX입니다.

논리적으로는 서버에서 값을 받아오지 못했다면 nil을 반환해야 합니다. nil을 반환하게 하고 VC에서 nil이면 예외처리하도록 합니다. (에러를 던지고 try catch를 통해서 하는 것이 정석이라고 생각됩니다만 일단 nil을 활용하도록 하겠습니다.)

var numOfClassCells: Int? {
    guard let _numOfClasses = _numOfClasses else { return nil }
    guard let grade = grade else { return nil }
    return _numOfClasses[grade]!
}

var numOfStudentCells: Int? {
    guard let _numOfStudents = _numOfStudents else { return nil }
    guard let grade = grade else { return nil }
    guard let classNumber = classNumber else { return nil }
    return _numOfStudents[grade]![classNumber]!
}

numOfCells가 nil일 때 alert 띄우기

numOfCells가 nil일 때 alert를 띄우는 코드를 추가합니다. 현재 메소드에서 반환값이 없을 경우 에러가 발생하므로 일단 0을 반환합시다.

그리고 alert를 띄우고 나면 fetch를 다시 실행해서 네트워크 통신을 다시 시도합니다.

extension FaceCheckViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        var numOfCells: Int?
        switch currentInputCellType {
        case .grade: numOfCells = 3
        case .classNumber: numOfCells = viewModel.numOfClassCells
        case .number: numOfCells = viewModel.numOfStudentCells
        }
        guard let numOfCells = numOfCells else {
            present(networkErrorAlert, animated: true) {
                self.viewModel.fetchNumberOfCells()
            }
            return 0
        }
        return numOfCells
    }
}

cell 갯수가 nil일 때 띄울 alert controller 만들기

사용자에게 네트워크가 발생했음을 알리는 메시지를 띄우고 재시도를 통해서 alert를 띄울 때 다시 시도했던 네트워크 통신의 결과를 reloadData를 통해 받아오도록 합시다.

lazy var networkErrorAlert: UIAlertController = {
    let alert = UIAlertController(title: "네트워크 에러", message: "네트워크 상태에 문제가 있어 서버에 접속할 수 없습니다.", preferredStyle: .alert)
    
    let retry = UIAlertAction(title: "재시도", style: .default) { [weak self] _ in
        self?.inputCollectionView.reloadData()
    }
    
    alert.addAction(retry)
    return alert
}()

테스트

뷰 모델의 init 부분을 주석처리하고 테스트를 해봅시다. 이렇게 하면 viewModel에 _numOfClasses와 _numOfStudents가 아직 로드되기 전이므로 처음에는 무조건 네트워크 에러가 나게 됩니다.

테스트 결과 원하는대로 alert를 띄우고 나서 다시 네트워크 통신을 시도해 서버에서 데이터를 가져오는 것을 보실 수 있습니다.

// MARK: - LifeCycle

init() {
//        self.fetchNumberOfCells()
}

구현 (2번 API 연결)

이제 두 번째 API를 연결을 해봅시다. 두 번째 API를 id로 학생을 조회하는 API입니다.

Model 수정

JSONDecoder로 디코딩하기 위해서는 property의 이름과 JSON의 key가 정확히 일치해야 합니다. 서버에서 온 JSON을 보고 정확히 일치시킵시다.

struct Student: Codable {
    let id: Int
    let grade: Int
    let classNumber: Int
    let number: Int
    let name: String
    let profileImageURL: String?
}

Service에 fetchStudent 메소드 만들기

utilities - id를 만드는 함수 만들기

Student의 id는 학년도 + 학년 + 반 + 번호로 만들 수 있습니다. id를 만드는 함수를 utilities 클래스에 별도로 만들어 둡시다.

class Utilities {
    // 학년, 반, 번호로 ID 만드는 메소드
    func makeStudentID(grade: Int, classNumber: Int, number: Int) -> String {
        let classNumberString = String(classNumber).count == 1 ? "0\(classNumber)" : "\(classNumber)"
        let numberString = String(number).count == 1 ? "0\(number)" : "\(number)"
        return "2021\(grade)\(classNumberString)\(numberString)"
    }
}

fetchStudent 메소드 만들기

fetchSchoolStatus와 거의 유사합니다. 다만 차이점은 query string으로 parameter를 전달한다는 것입니다.

func fetchSchoolStatus(grade: Int, classNumber: Int, number: Int, completionHandler: @escaping((Student) -> Void)) {
    let id = Utilities().makeStudentID(grade: grade, classNumber: classNumber, number: number)
    let parameter = ["id": id]

    AF.request("http://localhost:8080/students/search", parameters: parameter).responseDecodable(of: Response<Student>.self) { data in
        guard let response = data.value else { return }
        guard response.isSuccess == true else { return }
        completionHandler(response.result!)
    }
}

faceCheckViewModel에서 사용하기

student 객체를 만들어서 StudentProfileVC에 전달해야 하므로 해당 메소드는 StudentProfileVM이 아니라 그 전 단계인 FaceCheckViewModel에 있어야 합니다.

현재 VM에 정의된 grade, classNumber, number를 가지고 API를 호출합시다.

completionHandler를 받아야 하는 이유는 이 메소드를 호출하는 함수는 최종적으로 VC이기 때문입니다. VC에서 Student 객체를 받아서 UI 업데이트 작업을 할 수 있도록 completionHandler를 인자로 받아줍니다.

// MARK: - 서버에서 Student 객체 가져오기
func fetchStudent(completionHandler: @escaping (Student) -> Void) {
    guard let grade = grade else { return }
    guard let classNumber = classNumber else { return }
    guard let number = number else { return }
    
    StudentService.shared.fetchStudent(grade: grade, classNumber: classNumber, number: number) { student in
        completionHandler(student)
    }
}

faceCheckVC에서 사용하기

기억이 벌써 가물가물하지만 Student의 정보를 가지고 있는 StudentProfileViewController은 마지막 번호를 입력하면 init되도록 프로그래밍했습니다.

다만 예전에 더미데이터를 썼을 때와 다른 점은 이제는 비동기적 네트워크 작업을 수반하므로 completionHandler 안에서 Student 객체를 활용해서 VC를 만들고 navigationController에 push 해야 합니다.

extension FaceCheckViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        switch currentInputCellType {
        case .grade:
            viewModel.grade = indexPath.row + 1
            studentInfoLabel.text = viewModel.studentInfoLabelText
            currentInputCellType = .classNumber
        case .classNumber:
            viewModel.classNumber = indexPath.row + 1
            studentInfoLabel.text = viewModel.studentInfoLabelText
            currentInputCellType = .number
        case .number:
            viewModel.number = indexPath.row + 1
            viewModel.fetchStudent { [weak self] student in
                let vc = StudentProfileViewController(student: student)
                self?.navigationController?.pushViewController(vc, animated: true)
            }
        }
    }
}

StudentProfileVC에 적용하기

StudentProfileVC 쪽에 수정할 것은 거의 없습니다. ImageView 관련해서만 약간 수정해보겠습니다.

디폴트 이미지 상수 선언하기

Student의 이미지 url은 옵셔널입니다. 학생의 프로필 이미지가 서버에 없을 수도 있기 때문입니다. 이 경우에 사용할 디폴트 이미지를 전역에 선언해두겠습니다.

앱 전체에서 반복되서 사용되는 함수는 별도의 파일에 모아서 선언해두는 것이 좋습니다.

let NO_PROFILE_IMAGE = UIImage(systemName: "person.fill.xmark")

UIActivityIndicatorView 만들기

네트워크에서 가져오는 이미지는 디스크나 메모리에서 가져오는 것보다 가져오는데 오래 걸립니다. 네트워크 상태와 이미지의 크기에 따라 1초 이상 걸릴 수도 있습니다. 이런 경우에는 사용자에게 이미지 다운로드가 진행 중이라는 신호를 줄 필요가 있습니다.

VC에 property로 indicator를 선언해둡시다. 인디케이터의 위치는 이미지 뷰의 정중앙입니다.

let imageLoadingIndicator: UIActivityIndicatorView = {
    let indicator = UIActivityIndicatorView()
    indicator.hidesWhenStopped = true
    indicator.style = .large
    return indicator
}()
view.addSubview(imageLoadingIndicator)
imageLoadingIndicator.translatesAutoresizingMaskIntoConstraints = false
imageLoadingIndicator.centerXAnchor.constraint(equalTo: profileImageView.centerXAnchor).isActive = true
imageLoadingIndicator.centerYAnchor.constraint(equalTo: profileImageView.centerYAnchor).isActive = true

imageView에 이미지 세팅하는 부분 수정

student 객체이 url이 없다면 기본 이미지를 보여줍니다. 아니라면 KingFisher를 활용해서 url에 있는 이미지를 받아서 세팅해줍시다.

KingFisher는 자동으로 캐싱을 처리 해줍니다. 아래 코드에서 메모리에만 캐싱하고 캐싱 기간을 0초로 한 이유는 모든 학생들의 이미지 url이 동일하기 때문입니다. (해당 url은 랜덤 이미지를 보내주는 url인데 캐싱을 하게 되면 해당 url로 처음에 온 이미지를 캐싱하기 때문에 모든 학생들이 동일한 이미지가 보이기 때문입니다.)

또한 KingFisher는 이미지 다운로드를 비동기로 처리하고 다운로드가 끝난 후 실행할 CompletionHandler를 인자로 받습니다. 다운로드를 하기 전에 인디케이터를 움직이게 하고 completionHandler에 인디케이터를 멈추는 코드를 넣으면 다운로드 되는 동안 인디케이터가 동작하는 것을 볼 수 있습니다.

func configure() {
    infoLabel.text = viewModel.infoLabelText
    nameLabel.text = viewModel.nameLabelText
    guard let urlString = viewModel.student.profileImageURL else {
        profileImageView.image = NO_PROFILE_IMAGE
        return
    }
    imageLoadingIndicator.startAnimating()
    let url = URL(string: urlString)
    profileImageView.kf.setImage(with: url, options: [.memoryCacheExpiration(.seconds(0)), .cacheMemoryOnly]) { [weak self] _ in
        self?.imageLoadingIndicator.stopAnimating()
    }
}

결과

  1. 임의의 더미 데이터가 아니라 실제 DB에 있는 이름을 볼 수 있습니다.
  2. 이미지 또한 실제 DB에 있는 것이고 다운로드 되는 동안 인디케이터가 동작하는 것도 볼 수 있습니다.

마치며...

  1. 서버가 실수로 잘못 입력한 id를 보내도 결과를 success로 보내는군요. 유효성 검사와 err를 담은 response가 필요한 시점입니다.
  2. alamofire와 kingfisher는 다양한 옵션을 제공하는 것 같습니다. 또한 query string말고 path parameter나 body를 통해서도 요청을 보내보고 싶습니다.
profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.

0개의 댓글