SwiftUI 팀 프로젝트 혼자 리팩토링하기 #6 : API 연결

·2024년 7월 11일
0

리팩토링 일지

목록 보기
7/13

요약

오늘은 새로운 이슈를 파고 새로운 브랜치에서 작업햇당
프로젝트 구경하기

  • 학교 설정 open API 연결 리팩토링
  • 프로필 설정 API 연결 리팩토링
  • 설정 완료되면 자동 로그인 되도록 구현

학교 설정 open API 연결 리팩토링

해당 프로젝트는 청소년들을 위한 프로젝트이기 때문에,
전체 학교, 본인 학교 내의 투표 서비스를 둘다 이용한다.
따라서 학교 정보를 입력받는다. (따로 인증을 받도록 기획하진 않았다.)

커리어넷의 학교 정보 openAPI와 연결하여 textField로 입력받은 학교 이름을 검색하여 보여준다.


리팩토링 전 코드

final class SchoolSearchViewModel: ObservableObject {
    @Published var schools = [SchoolInfoModel]()
    @Published var isFetching = false
    private let baseURL = "http://www.career.go.kr/cnet/openapi/getOpenApi"

    var apiKey: String {
        guard let key = Bundle.main.object(forInfoDictionaryKey: "SCHOOL_API_KEY") as? String else {
            fatalError("SCHOOL_API_KEY error")
        }
        return key
    }

    @MainActor
    func setSchoolData(searchWord: String) async throws {
        schools.removeAll()
        isFetching = true

        let highSchoolValues: HighSchoolResponse = try await fetchSchoolData(schoolType: .highSchool, searchWord: searchWord)
        let middleSchoolValues: MiddleSchoolResponse = try await fetchSchoolData(schoolType: .middleSchool, searchWord: searchWord)
        let highSchoolSchools = highSchoolValues.dataSearch.content.map { $0.convertToSchoolInfoModel() }
        let middleSchoolSchools = middleSchoolValues.dataSearch.content.map { $0.convertToSchoolInfoModel() }

        schools.append(contentsOf: highSchoolSchools + middleSchoolSchools)
        isFetching = false
    }

    private func fetchSchoolData<T: Decodable>(schoolType: SchoolDataType, searchWord: String) async throws -> T {
        guard let encodedSearchWord = searchWord.addingPercentEncoding(withAllowedCharacters: .afURLQueryAllowed) else {
            throw URLError(.badURL)
        }

        let url = baseURL +
            """
            ?apiKey=\(apiKey)&svcType=api&svcCode=SCHOOL&contentType=json&gubun=\(schoolType.schoolParam)&searchSchulNm=\(encodedSearchWord)
            """

        return try await AF.request(url, method: .get)
                        .validate()
                        .serializingDecodable(T.self)
                        .value
    }
}

그동안 리팩한 부분에서 내 파트는 별로 없었는데... 이건 예전에 내가 구현했었다. 이때 aync await 공부를 했어서 바로 적용했던 기억이... 그리고 viewModel에서 모조리 호출해 버리기....

리팩토링 후 코드

ViewModel

final class SchoolSearchViewModel: ObservableObject {

    enum Action {
        case submit(_ searchSchoolText: String)
    }

    @Published var schools: [SchoolInfoModel] = [SchoolInfoModel]()
    @Published var isLoading: Bool = false
    @Published var searchSchoolText: String = ""
    @Published var textFieldState: SearchTextFieldState = .inactive

    private let userUseCase: UserUseCaseType

    private var cancellables = Set<AnyCancellable>()

    init(userUseCase: UserUseCaseType) {
        self.userUseCase = userUseCase
    }

    func send(action: Action) {
        switch action {
        case let .submit(searchSchoolText):
            isLoading = true

            userUseCase.searchSchool(searchSchoolText)
                .sink { [weak self] _ in
                    self?.isLoading = false
                } receiveValue: { [weak self] schools in
                    self?.textFieldState = .submitted
                    self?.isLoading = false

                    self?.schools = schools
                }
                .store(in: &cancellables)
        }
    }
}

useCase에 접근하여, 학교 정보를 받으면 로딩을 중지하고 @Publihsed로 선언된 schools에 할당하여 뷰에 보여준다.

UseCase

protocol UserUseCaseType {
	// ...
    func setProfile(_ profile: ProfileSettingModel) -> AnyPublisher<Void, WoteError>
}

final class UserUseCase: UserUseCaseType {
    
    private let userRepository: UserRepositoryType

    init(userRepository: UserRepositoryType) {
        self.userRepository = userRepository
    }

	// ...

    func setProfile(_ profile: ProfileSettingModel) -> AnyPublisher<Void, WoteError> {
        userRepository.setProfile(profile)
    }
}

usecase에서는 repository에 접근한다.

Repository & DataSource

  • repository
    func getSchoolsData(_ query: String) -> AnyPublisher<[SchoolInfoModel], WoteError> {
        Publishers.Zip(userDataSource.getHighSchoolData(query), userDataSource.getMiddleSchoolData(query))
            .map { highSchoolObject, middleSchoolObject in
                var schoolResult: [SchoolInfoModel] = .init()

                schoolResult.append(contentsOf: highSchoolObject.dataSearch.content.map { $0.convertToSchoolInfoModel() })
                schoolResult.append(contentsOf: middleSchoolObject.dataSearch.content.map { $0.convertToSchoolInfoModel() })

                return schoolResult
            }
            .mapError { WoteError.error($0) }
            .eraseToAnyPublisher()
    }

    func setProfile(_ profile: ProfileSettingModel) -> AnyPublisher<Void, WoteError> {
        userDataSource.setProfile(profile.toObject())
            .mapError { WoteError.error($0) }
            .eraseToAnyPublisher()
    }
  • data source
    func getHighSchoolData(_ searchText: String) -> AnyPublisher<HighSchoolResponseObject, APIError> {
        provider.requestPublisher(.getSchoolData(searchText, .highSchool))
            .tryMap { try JSONDecoder().decode(HighSchoolResponseObject.self, from: $0.data) }
            .mapError { APIError.error($0) }
            .eraseToAnyPublisher()
    }

    func getMiddleSchoolData(_ searchText: String) -> AnyPublisher<MiddleSchoolResponseObject, APIError> {
        provider.requestPublisher(.getSchoolData(searchText, .middleSchool))
            .tryMap { try JSONDecoder().decode(MiddleSchoolResponseObject.self, from: $0.data) }
            .mapError { APIError.error($0) }
            .eraseToAnyPublisher()
    }

고등학교, 중학교 정보가 함께 응답으로 오지 않기 때문에,
요청을 보내고, 두 정보를 zip하여 하나의 공통된 모델로 매핑했다.


프로필 설정 API 연결 리팩토링

multipart form data

profileSetting도 비슷하게 진행했지만, 다른 점은 사진을 넣어야 해서 multipart form data로 보내야 한다는 점

struct MultipartFormDataHelper {
    
    static func createMultipartFormData(from profileRequest: ProfileRequestObject) -> [MultipartFormData] {
        var formData = [MultipartFormData]()

        if let imageFile = profileRequest.imageFile {
            let imageMultipart = MultipartFormData(
                provider: .data(imageFile),
                name: "imageFile",
                fileName: "\(profileRequest.nickname).jpg",
                mimeType: "image/jpeg"
            )

            formData.append(imageMultipart)
        }

        var profileData: [String: Any] = [
             "nickname": profileRequest.nickname
         ]

        if profileRequest.school != nil {
            profileData["school"] = [
                "schoolName": profileRequest.school?.schoolName,
                "schoolRegion": profileRequest.school?.schoolRegion
             ]
        }

        do {
            let jsonData = try JSONSerialization.data(withJSONObject: profileData)
            let jsonString = String(data: jsonData, encoding: .utf8)!
            let stringData = MultipartFormData(provider: .data(jsonString.data(using: String.Encoding.utf8)!),
                                               name: "profileRequest",
                                               mimeType: "application/json")
            formData.append(stringData)
        } catch {
            print("error")
        }

        return formData
    }
}

우선 multipart form data로 변환하는 과정을 따로 빼서 진행해 줬다.

이전에는

  var task: Moya.Task {
        switch self {
        case .postProfileSetting(let profile):
            var formData: [MultipartFormData] = []

            if let data = UIImage(data: profile.imageFile ?? Data())?
                                            .jpegData(compressionQuality: 0.3) {
                let imageData = MultipartFormData(provider: .data(data),
                                                  name: "imageFile",
                                                  fileName: "\(profile.nickname).jpg",
                                                  mimeType: "image/jpeg")
                formData.append(imageData)
            }
            var profileData: [String: Any] = [
                 "nickname": profile.nickname
             ]
            if profile.school != nil {
                profileData["school"] = [
                    "schoolName": profile.school?.schoolName,
                    "schoolRegion": profile.school?.schoolRegion
                 ]
            }
            do {
                let jsonData = try JSONSerialization.data(withJSONObject: profileData)
                let jsonString = String(data: jsonData, encoding: .utf8)!
                let stringData = MultipartFormData(provider: .data(jsonString.data(using: String.Encoding.utf8)!),
                                                   name: "profileRequest",
                                                   mimeType: "application/json")
                formData.append(stringData)
            } catch {
                print("error")
            }
            return .uploadMultipart(formData)
	// ...

위와 같이 moya task 내에서 진행했다.



// Moya target type
        case let .postProfile(requestObject):
            let formData = MultipartFormDataHelper.createMultipartFormData(from: requestObject)
            return .uploadMultipart(formData)

그리고 이렇게 변경할 수 있었당.
프로필 세팅이 완료됐을 때는 자동 로그인 여부를 설정해 줬다.
자동 로그인 로직은 끗!

다른 건 저번 포스팅 내용들과 비슷해서 생략하겠다.


오늘의 일지 끗

0개의 댓글

관련 채용 정보