기어가더라도 제대로

[에러] 오래 응답이 없는 화면 - 1 본문

만난 에러들

[에러] 오래 응답이 없는 화면 - 1

Damagucci-juice 2024. 12. 11. 19:06

 

게시글 리스트 요청에 40초 소요

무한 로딩

네이버 밴드에서 사용자들의 커뮤니티를 밴드라고 하는데 저희 땡기지 앱에서는 ‘캠프’라고 부릅니다.

이 캠프에 진입하면 그와 동시에 게시글의 리스트를 불러옵니다.

게시글의 내용이 빼곡하게 담겨있는 요청의 경우에 40초가 걸리는 문제가 있었습니다.

한 번의 요청에 10개의 게시글의 배열을 가지고 오는데, 솔직히 이게 그렇게 오래 걸릴일은 아닙니다.

배열을 요청하는 주소를 웹 브라우저에서 검색하니 눈 깜짝할 새에 응답이 왔습니다.

하지만 iOS 앱에서는 너무 오래걸렸어요. 심지어 그 기다리는 시간 동안 Hang(일시정지) 상태였습니다. 도저히 묵과할 수 없는 에러였습니다. 그래서 저는 얼마나 시간이 걸리는지 정확하게 측정하기 위해 Instrument라는 Xcode 내부에 있는 검사 도구를 사용했습니다.

Instrument 로 진단하기

45초만에 자율권

그림에서 빨간색이 Severe Hang(심각한 정지, 이하 행)을 뜻합니다.

검사 도구를 사용해보니까 캠프에 입장에서 처음 유저가 자율권을 얻을 때까지 40초가 소요됐습니다.

그 이후로 행은 다음 페이지를 요청하는 것입니다. 요청마다 약 10초 정도가 걸리는 모습입니다. 이런 수치를 얻은 저는 이 문제의 원인을 MVC 구조에서 찾았습니다. Alamofire를 이용한 요청과 응답을 처리하는 것이 ViewController에서 있으니 기분이 편치 않았기 때문입니다.

그래서 게시글 리스트를 불러오는 로직을 Repository Pattern으로 리펙토링을 하기로 했습니다.

험난한 과정과 도움의 손길

코드 품질에 대한 고민 없이 10년을 서비스한 프로젝트를 당장 리펙토링하는 것은 머리에 쥐가나는 경험입니다. 어디서부터 바로 세워야할지, 이 곳을 수정했을 때 어느 부분에서 문제가 발생할지 등 수정과 그로 인한 반응에 대한 인과관계를 유추하기가 어렵기 때문입니다. 일의 순서를 결정하는 것부터 쉽지 않았습니다.

요청에 응답할 때 받아주는 DTO 객체들의 모든 프로퍼티가 옵셔널로 선언되어있어서, 어느 프로퍼티가 값이 있고, 어느 프로퍼티는 선택적인지 전혀 분간할 수 없었습니다. 그래서 처음에 이런 DTO 객체들부터 새로 만들었습니다. 4시간쯤 끙끙 대다가 팀장님이 보시더니, “아 이거 쿼리를 바꿔 볼게요. 요청을 만든지 오래되서 지금 설정에 맞지 않는 부분이 있어요.”

한 시간쯤 지나 팀장님이 쿼리를 손봤다고 하시더니 한 2초 정도로 줄었습니다. 한번의 리스트 요청마다 2초라니, 절로 존경심이 들더군요. 하지만 2초의 행도 사용자가 불편을 느끼기에 충분한 시간이라고 생각했습니다.

 

2초를 0.5초 안쪽으로 줄이기

2초가 걸리는 상황은 캠프의 매니저가 아닐 때 2초가 걸리고 캠프의 매니저인 경우 여전히 7초가 걸렸습니다. 처음의 결정대로 디버깅도 어렵고 해서 포스트를 읽어오는 흐름을 Repository Pattern으로 수정을 하기로 결정했습니다. 이 때 참조한 코드는 UIKit의 Clean Architecture로 유명한 ExampleMVVM 프로젝트를 참조했습니다.

여기서 알게 된 사실은 크게 두가지입니다.

레포지토리 패턴을 구현하기 위해서 많은 Codable 객체를 만들어야한다는 점을 배웠고,

Decodable 타입의 한 변수는 여러 타입을 갖을 수도 있다는 사실을 코드에 적용시켜야했습니다.

내부 DB(Realm)을 이용하기 위해 Entity를 설계하기

이것을 위해 Entity도 한 4개를 만들어야 했습니다. DTO, Entity, 그들간의 변환을 또 신경쓰는데 그림을 그리는게 큰 도움이 됐습니다. 추가적으로 알게 된 사실은 캐시 저장을 위해서 PostsResponseEntity 를 저장해야겠다고 생각을 했었는데, 실제로 구현하면서 느낀 것은 PostsRequestEntity 를 중점적으로 fetch, store 하는 객체로 삼는다는 것이였습니다.

PostsResponseEntityPostsRequestEntity 에 관계로써 찾는 것이 이상적이라는 상황을 알게 되었습니다. 왜냐하면 게시글의 리스트를 요청할 때 PostsRequestDTO로 요청을 하는데 이것으로 찾을 수 있는 객체는 당연히 PostsRequestEntity 이기 때문입니다. 덜렁 PostsResponseEntity 를 단독으로 저장했을 때는 이 객체를 찾을 방법이 없습니다.

import Foundation
import Combine

protocol PostsRepository {
    /// 포스트의 배열을 가져오는 함수
    ///
    /// 레포지토리에 값이 없어도 Error를 반환하진 않습니다.
    /// 빈 posts 값이 옵니다.
    /// - Returns: 헤더값, 포스트DTO의 배열, 광고DTO의 배열, 전체 갯수
    /// - Throws: httpStatus 200~299을 벗어난다면 에러
    /// - Parameters:
    ///    - requestDto: 네트워크 요청에 파라미터를 encoding하는 객체
    ///    - endpoint: path, baseurl 등을 기반으로 AF.DataRequest를 반환하는 객체
    func fetchPosts(
        with requestDto: PostsRequestDTO,
        at endpoint: APIEndpoint
    ) -> AnyPublisher<PostsResponseDTO, Error>
}

// MARK: - 구현부, DefaultPostsRepository.swift
func fetchPosts(
    with requestDto: PostsRequestDTO,
    at endpoint: APIEndpoint
) -> AnyPublisher<PostsResponseDTO, Error> {
    // check cache
    // 캐시 확인
    if let response = storage.getResponse(for: requestDto) {
        return Just(response)
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }

    // make API Request
    // API 요청 생성
    let request = endpoint.asRequest(requestDto)

    return Future
    { promise in
        request
        // 이하 생략 
}
import Foundation
import RealmSwift

final class PostsRequestEntity: Object {
    @Persisted var cafeNumber: String
    @Persisted var page: Int
    @Persisted var category: String
    @Persisted var postType: String
    @Persisted var postsResponse: PostsResponseEntity?    // 이 객체를 가져와서 이 변수를 참조
    @Persisted var createdAt = Date()

    var hasHourPassed: Bool {
        let currentDate = Date()
        let timeInterval = currentDate.timeIntervalSince(createdAt)
        return timeInterval >= 3600
    }
}

final class PostsResponseEntity: Object {
    @Persisted var advertisements: List<AdvertisementEntity>
    @Persisted var posts: List<PostEntity>
    @Persisted var totalCount: Int
}

한 프로퍼티가 여러 타입을 갖을 경우

Decodable 객체의 한 프로퍼티가 어떤 타입을 가질지 명확하게 정의하지 못하는 경우가 있습니다. 저희 프로젝트에선 광고 관련 DTO의 시청시간 프로퍼티가 그랬습니다. 그래서 LandingValue라는 타입을 만들어주고 어느 타입으로 디코딩할지를 결정하게끔 했습니다.

struct AdvertisementDTO: Decodable {
    let advertisementId: String
    let creationDate: String
    let adDescription: String
    let minimumAge: String
    let watchedTime: LandingValue   // String(or Int)로 오는 변수
    let heartCount: String
}

enum LandingValue: Decodable {
        case stringValue(String)
        case intValue(Int)

        init(from decoder: Decoder) throws {
            let container = try decoder.singleValueContainer()
            if let value = try? container.decode(String.self) {
                self = .stringValue(value)
                return
            }

            if let value = try? container.decode(Int.self) {
                self = .intValue(value)
                return
            }

            throw DecodingError.typeMismatch(
                LandingValue.self,
                DecodingError.Context(codingPath: decoder.codingPath,
                                      debugDescription: "Type is not matched",
                                      underlyingError: nil)
            )
        }

        var targetS: String {
            switch self {
            case .stringValue(let string):
                return string
            case .intValue(let int):
                return "\(int)"
            }
        }

        var targetI: Int {
            switch self {
            case .stringValue(_):
                return 0
            case .intValue(let int):
                return int
            }
        }
    }

    ---

    // MARK: - AdvertisementEntity.swift
    convenience init(from dto: AdvertisementDTO) {
            // 생략
        self.watchedTime = dto.watchedTime.targetI
        // 생략
    }

DTO에서 Entity로 전환하기 위한 코드 부분에서 이런 식으로 작성하여 Entity 저장은 하나의 타입으로 하게끔 설정했습니다. 이렇게 하니 서버에서 어떤 타입이 올지 모르는 경우에도 대처가 가능해졌습니다.

오버엔지니어링과 클린코드 그 사이 어딘가

패턴이 없던 코드에 패턴을 적용하는 것은 너무 힘이 드는 경험입니다. 또한 제가 작성한 코드들도 추후에 누군가가 본다면 레거시로 보이는 것도 당연하구요. 그래도 게시글 리스트를 불러오는 흐름은 핵심적이여서 누군가 이 흐름을 본다면, 화성에서 방치된 오퍼튜니티호를 만난 것 과 같은 반가움을 느껴주진 않을까요? 그런 마음으로 작업했습니다.

그렇게 많은 시간을 쓰고 나서 다시 time profiler를 이용해서 검사를 해봤습니다. 아쉽게도 시간의 변화가 없었습니다.

2초로 줄어든 소요시간

유지보수는 쉽게 했을지 몰라도 문제는 고치지 못했구나하는 아쉬움을 사수분께 토로했더니 Instrument를 더 공부해보고 어떤 함수가 얼마나 시간을 쓰는지 알아보라고 해주셨습니다. 사실 이게 당연한건데 충분한 고민 없이 ViewController에 있는 네트워크 요청 로직이 눈에 거슬리니까 Repository 패턴을 적용한게 문제였습니다. 이미 돌아가는 코드는 문제가 발생하지 않는한 안 건드리는게 나을 수도 있다는 사실을 이번에 배웠습니다.

Instrument 공부를 통해 문제를 어떻게 해결했는지는 문서가 길어지니 2편에서 마저 다루도록 하겠습니다.

참고

https://velog.io/@juyoung999/Decoding-JSON-With-Multiple-Types-Key

다음글

2024.12.11 - [만난 에러들] - [에러] 오래 응답이 없는 화면 - 2

 

Comments