Skip to content

동기화 딜레이 시간 개선 과정

SeungJae Son edited this page Jan 15, 2025 · 2 revisions

🛠 Issue

게임 진행에서 발생하는 모든 동기화 상황에 발생하는 시간 지연을 개선합니다.

진행 과정

세부 시나리오

  1. 사용자의 게임 <방> 생성 후 로비로 진입하기까지의 시간
  2. 게임 시작 후 음악 선택 화면으로 넘어가기까지의 시간 (약 1-3초)
  3. 게임 진행 중 음악 선택 후 제출한 정보가 다른 사용자에게 반영되기까지의 시간 (약 1-3초)
  4. 모든 사용자가 음악 선택 완료 시 허밍 화면으로 넘어가기까지의 시간
  5. 허밍 제출한 정보가 다른 사용자에게 반영되기까지의 시간 (약 1-9초)
  6. 모든 사용자가 허밍 제출 완료 시 리허밍 화면으로 넘어가기까지의 시간
  7. 리허밍 제출한 정보가 다른 사용자에게 반영되기까지의 시간 (약 1-5초)
  8. 모든 사용자가 리허밍 제출 완료 시 정답 제출 화면으로 넘어가기까지의 시간 (약 1-3초)
  9. 정답 제출한 정보가 다른 사용자에게 반영되기까지의 시간 (약 1-3초)
  10. 모든 사용자가 정답 제출 완료 시 결과 화면으로 넘어가기까지의 시간 (약 1-4초)
  11. 모든 결과 출력 후 다시 로비로 돌아가기까지의 시간 (약 1-4초)

화면 전환 케이스 (2, 6, 8, 10, 11)

화면 전환은 크게 두개의 ViewController에서 이루어진다.

  • GameNavigationController
  • GuideViewController

GameNavigation에서 setConfiguration으로 현재 GameState를 반영하고 이에 따라 updateViewControllers가 호출된다. 호출된 메서드에서 알맞은 View로의 전환을 담당하는데 이때 해당 View 이전에 GuideViewController를 우선 호출하게 한다.

GuideViewController에서 다음과 같이 지정된 5초 후에 필요한 View로 전환되는데, 이때 5초라는 시간에 대한 근거가 필요하다.

    private func startAnimation() {
        imageContainerView?.animateBounces()
        DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
            self.completion?()
        }
    }

만약 메인 스레드가 다른 작업으로 바쁘다면 5초 예약된 작업은 대기 상태로 들어가고, 메인 스레드가 비워질 때 까지 실행되지 않을 수 있다. 이는 즉 스레드의 작업이 완벽히 관리가 되지 않은 상태에서 (5초 + a)의 시간이 걸릴 수 있는 것이다.

해결 방법으로는

  1. 근거와 함께 예약 시간 조정
  2. DispatchQueue의 QoS(Quality of Service)를 높여 우선순위를 조정하면, 더 빠르고 안정적인 실행이 가능하다.

image

다만 global은 concurrent이므로 안에 main으로 다시 serial 하게 하면 되지 않을까 싶다.

DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + 5.0) {
    DispatchQueue.main.async {
        self.completion?()
    }
}
  1. Timer는 지연 시간이 발생할 수 있지만, 보다 더 정확한 타이밍에 가능하다.

1차로 판단한 사항으로는 2번 QoS를 지정하는 방법이었다. 화면 전환 사이에 가이드를 위한 화면이 존재하고 충분히 읽고 이해를 위해 5초 딜레이를 설정했기에 5초라는 시간 지연을 수정할 필요를 느끼지 못했다. 하지만 여기서 또 다른 에로사항이 생긴다.

알쏭달쏭에서는 GCD(Grand Central Dispatch)와 Swift Concurrency가 혼용되어있다. 여기서 주로 Swift Concurrency로 수정하면 좋지 않을까? -> 혼합 사용은 서로 호환 가능하다. 하지만, 잘못된 사용은 유지보수성과 성능 문제를 초래할 수 있다. -> 잘못 사용되고 있다는걸 어떻게 판단하는가..?

일단 Presentation 단에서는 repository와의 연결을 제외하면 모두 Swift Concurrency를 사용하고 있고, 나머지는 GCD를 사용하고 있다. 이때 다음의 상황에서만 Presentation 단에서 GCD를 사용하고 있는데, 일단 해당 부분을 일관성을 위해 Swift Concurrency로 바꾼다.

  • 기존 GCD
    private func startAnimation() {
        imageContainerView?.animateBounces()
        DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
            self.completion?()
        }
    }
  • Swift Concurrency
Task {
    try? await Task.sleep(nanoseconds: 5_000_000_000) // 5초
    await MainActor.run {
        self.completion?()
    }
}

이제 Presentation의 Swift Concurrency와 나머지 레이어의 GCD가 올바르게 사용되고 있는가를 확인해야한다. 여기서 문제가 발생한다.

  • Thread의 올바른 관리 여부를 어떻게 판단할 수 있는가?
  • Thread가 정말 동기화 딜레이의 원인이 되는가?

결론은 판단하기 어렵고, 원인이라 하기 어렵다는 것이다. 따라서 해당 접근을 끝내고 다음 케이스로 넘어가기로 했다.

네트워크 지연 케이스 (3, 5, 7, 9)

네트워크는 주로 POST 요청 즉, 파일의 Upload, Download에서 지연 시간이 발생한다. 이때 전송하는 데이터는 크게 두가지 종류로 구분할 수 있는데, 하나는 json이고 다른 하나는 multipart이다.

json 데이터는 다음 상황에서 전송된다.

  1. 음악 선택 제출 시
  2. 로컬에 저장된 허밍 파일을 가져올 때
  3. 방 생성 및 로비에서의 작업 시
  4. 정답 제출 시
  5. 게임 시작 시
  6. 게임 참가 시

multipart 데이터는 다음 상황에서 전송된다.

  1. 허밍(레코드) 전송

이를 통해 알 수 있는 사실은 json 데이터는 평균적으로 최대 3초(Post + Server 처리)의 시간이 소요되고 multipart의 경우는 최대 9초의 시간이 걸린다는 점이다. 따라서 우선 multipart를 개선할 수 있는지 알아보고자 한다.

MainRepository의 postRecording 메서드가 호출되면 Firebase를 endpoint로 하여 networkManager에 request를 보낸다. networkManager의 sendRequest에서 data를 보내고, cacheManager를 통해 cache에 저장한다.

그렇다면 sendRequest에서 Log를 찍어 어느 부분에서 가장 시간이 오래걸리는지 파악을 해보자.

    @discardableResult
    public func sendRequest(
        to endpoint: any Endpoint,
        type: HTTPContentType,
        body: Data? = nil,
        option: CacheOption = .both
    ) async throws -> Data {
        NSLog("sendRequest 시작")
        guard let url = endpoint.url else { throw ASNetworkErrors.urlError }
        NSLog("캐시에 데이터가 있는지 확인")
        if let cache = try await loadCache(from: url, option: option) { return cache }
        NSLog("endpoint 업데이트")
        let updatedEndpoint = updateEndpoint(type: type, endpoint: endpoint, body: body)
        NSLog("requestBuiler 생성")
        let request = try urlRequest(for: updatedEndpoint)
        NSLog("urlSession 요청")
        let (data, response) = try await urlSession.data(for: request)
        NSLog("validation 검사")
        try validate(response: response)
        NSLog("캐시에 저장")
        saveCache(from: url, with: data, option: option)
        NSLog("sendRequest 종료")
        return data
    }

18:12:53초에 허밍한 녹음 파일 제출을 요청했을 때의 로그는 다음과 같다.

기본	18:12:53.644000+0900	alsongDalsong	sendRequest 시작
기본	18:12:53.644026+0900	alsongDalsong	캐시에 데이터가 있는지 확인
기본	18:12:53.644052+0900	alsongDalsong	endpoint 업데이트
기본	18:12:53.644201+0900	alsongDalsong	requestBuiler 생성
기본	18:12:53.644253+0900	alsongDalsong	urlSession 요청
기본	18:12:59.870522+0900	alsongDalsong	validation 검사
기본	18:12:59.870665+0900	alsongDalsong	캐시에 저장
기본	18:12:59.870712+0900	alsongDalsong	sendRequest 종료

이를 보면 서버에서 처리하는 시간이 약 6초 정도 걸리는 것을 확인할 수 있다. 추가로 json은 거의 일정하게 3초가 걸리는걸 확인했다. 즉, urlSession.data(for: request)에서 시간 지연이 발생한다는 것이다.

시간지연은 다시 Post 요청 시간 + 서버 처리 시간으로 구분할 수 있는데, 이는 각각 3초가 소요된다. 여기서 요청 시간을 줄이기 위해 고려할만한 방법으로는

  1. 서버 측 성능 최적화
  2. 압축 사용
  3. HTTP/2: 병렬처리 사용
  • 압축 사용

측정 결과 각 허밍 레코드는 약 30Kb의 공간을 차지한다. 이는 충분히 작은 크기이며 이미 m4a 한번 압축된 형식이기에 큰 의미가 없다 판단했다. 그나마 Base64의 경우 33% 정도 크기가 증가하지만 서버에 String으로 전달하기에 서버 측에서 처리하기 편하다?는 장점이 있지만 이 또한 백엔드가 없는 상황에서 불가능하다 판단했다.

  • HTTP/2

현재 URLSession을 사용하고 있는데, URLSession은 이미 HTTP/1과 HTTP/2 프로토콜을 지원하고 있다. 다만, 서버에서 이를 지원하는지는 확인할 필요가 있다.

  • 서버 측 성능 최적화

정확하게 서버에서 어떤 작업이 오래걸리는 지 확인하기 위해 실제 Log에 업로드하는데 속도를 측정하였습니다.

사용자 측에서 허밍한 녹음 파일을 서버에 전송하면, 서버에서는 크게 두 가지 작업을 수행합니다.

  • Firebase Storage에 녹음 파일 업로드
  • Firebase Firestore(데이터베이스)에 관련 정보 저장
Friebase Stroge 업로드 처리 속도 Firebase 데이터베이스 쓰기 속도
3.639s 0.469s

이때 실제 시간을 측정한 결과, Firestore 쓰기 시간은 0.469초로 용인 가능한 수준이었지만, Storage 업로드가 약 3.639초나 소요되어 서비스 전체 응답 시간을 지연시키는 병목 구간임을 알 수 있었습니다.

  • 성능 개선 방안

이를 해결하기 위해 다음과 같은 방법들을 적용하였습니다.

  • 기존 파일 업로드 방식 변경
    • (fileStream.pipe(writeStream)) 대신, Google Cloud Storage에서 제공하는 upload() 혹은 save() 메서드가 내부적으로 최적화된 코드를 경유하기 때문에 실제 업로드 소요 시간이 단축되었습니다.
  • resumable 옵션 비활성화
    • resumble 옵션은 대용량 파일을 업로드할 때 파일을 여러 조각으로 나누어 처리하는 기능을 false로 하여 단일 요청으로 만들었습니다. (파일이 작은 경우 오히려 왕복으로 통신해야하는 횟수가 늘어남)
  • 결과
Friebase Stroge 업로드 처리 속도 Firebase 데이터베이스 쓰기 속도
2.221s 0.268s
Clone this wiki locally