iOS 미디어 업로드 프로세스 개선 작업일기 — (2)
@TaskLocal과 Data 처리
iOS 미디어 업로드 프로세스 개선 작업일기 — (2)
Photo by Austin Distel on Unsplash
- 서론
이전 게시글로부터 많은 시간이 흘렀다. 그 사이, 이전 작업에서 미처 처리하지 못했던 취약점이 추가로 보고되었고 이를 수정하고 보완하느라 시간이 지났다. 발견하지 못했던 대부분의 에러는 정의되지 않은 분기로 인해 발생하거나, 작성된 코드 자체에 대한 이해도가 낮은 점에서 기인했다. 이러나 저러나 꼼꼼한 코드 독해가 중요하다는 사실을 다시 한 번 깨달을 수 있었다.
이번 게시글에서는 이전 게시글에서 예고한대로 Multipart Upload에서 발생하던 문제들을 어떻게 수정했는지 과정을 간단히 정리해보고자 한다.
2. 본론
Multipart로 데이터를 업로드하는 로직은 서비스와 기업에 따라 양상이 달라질 수 있겠으나, 우리 서비스 같은 경우에는 특정 사이즈 이상의 영상을 대상으로 Multipart 업로드를 진행하고 있다. Oracle, Filebase 등의 문서에 따르면 100mb 이상의 파일에 대해 Multipart 업로드를 지원하라는 가이드가 있다. 이번 수정 로직의 Multipart 업로드 순서는 다음과 같다. 모든 과정은 Amazon S3 Multipart 업로드 가이드를 참고하여 진행되었다.
- 데이터의 용량이 Multipart 업로드를 요구하는 기준치 이상인지 확인한다.
- 기준치 이상이라면, 해당 영상의 업로드를 시작하기 위해 Upload ID를 받아오는 통신을 진행한다. 이는 Amazon S3 Multipart 업로드의 고유 식별자를 의미한다. 업로드할 때 잘못된 Identifier를 지정하거나 중복된 Object Name이 전달된다면 에러가 발생하는 경우가 있었는데, 이 경우 새로운 Key와 Upload ID를 요청하는 로직(1)이 필요했다.
- Upload ID가 성공적으로 확인되었다면 해당 ID를 활용하여 각 파트를 업로드 한다. 업로드에 성공하면 그 응답으로 반환되는 ETag를 확인할 수 있다. 업로드 대상이 되는 각 파트들은 특정 사이즈로 분할된 청크로 구별(2)되어 업로드 된다.
- 데이터의 모든 청크가 업로드된 후에, Multipart 업로드를 종료한다. 각 파트의 번호와 ETag을 보관하는 딕셔너리와 함께 Upload ID를 쿼리하여 업로드를 완료한다.
이전 로직에서는 새로운 Key와 Upload ID를 요청하는 로직(1)이 구현되어 있지 않았고, 데이터의 청크를 나누는 로직(2)의 안정성이 떨어지는 문제가 있었다. (1)을 개선하기 위해 나는 Upload ID의 초기 값과 변경된 값을 구별하는 방법을 고민하고 새로운 Multipart Upload ID를 요청하는 로직을 구현했다. 문제의 해결만 놓고 본다면, ‘굳이’ 사용할 필요 없는 TaskLocal 이라는 API를 써가면서.
TaskLocal은 자기 자신의 Task와 child Task의 맥락 내에서만 사용되는 특정 값들을 보관하고 사용하는 로직이다. 즉, 다른 Task 맥락에 의해 아무런 영향을 받지 않고 영향을 주지 않음을 보장한다. 전역상태를 사용하지 않으면서 Task 간에 공유해야 할 상태를 안전하게 공유할 수 있다.
아래 애플 공식 문서의 예시를 보자. withValue의 클로져 내부에서만 지정해둔 값이 유지되고 있으며 .detached를 통해 현재 진행중인 Task의 맥락에서 벗어나면 값이 없다는 사실을 알 수 있다.
enum Example {
@TaskLocal
static var traceID: TraceID?
}
func read() -> String {
if let value = Self.traceID {
"\(value)"
} else {
"<no value>"
}
}
await Example.$traceID.withValue(1234) { // bind the value
print("traceID: \(Example.traceID)") // traceID: 1234
read() // traceID: 1234
async let id = read() // async let child task, traceID: 1234
await withTaskGroup(of: String.self) { group in
group.addTask { read() } // task group child task, traceID: 1234
return await group.next()!
}
Task { // unstructured tasks do inherit task locals by copying
read() // traceID: 1234
}
Task.detached { // detached tasks do not inherit task-local values
read() // traceID: nil
}
}
Multipart의 문제를 해결하기 위해 굳이 내가 TaskLocal을 써보면 좋겠다고 생각한 이유는 하나의 Task 에서 얻은 값이 독립적인 맥락을 갖는 Task에 대해 영향을 주지 않기 때문이다(지금 생각해보면 이 이유는 마땅히 타당하다는 생각은 들지 않는다. TaskLocal은 오히려 코드가 복잡해지는 데에 큰 역할을 했다). 이전의 Upload ID를 초기 TaskLocal 로 설정한 후, 재시도가 필요한 경우가 발생하여 새로운 Upload ID를 사용해야 하면 이를 새로운 TaskLocal을 정의하여 Task의 맥락 자체를 분리하려 한 것이다. 아래 코드 예시는 내가 구현한 코드를 간략히 표현한 것이다. 파라미터와 특정 값들은 제거하였고 구조만 확인할 수 있도록 정리했다(코드 스니펫을 다 정리하고 든 생각 = 다시는 이렇게 코드를 짜지 않으리라).
// 초기 상태의 Multipart Value 정의
extension MultipartOperation {
struct _MultipartKey: Sendable {
// code //
var key = ""
var uploadID = ""
}
enum MultipartKey {
@TaskLocal static var container = _MultipartKey()
}
}
// Multipart Upload 시작
func startMultipartUpload() async throws -> String {
let uploadID = try await startMultipartUpload(key: localKey)
// 받아온 uploadID로 새로운 withValue 클로저 생성
MultipartOperation.MultipartKey.$container.withValue(
MultipartOperation._MultipartKey(key: localKey, uploadID: uploadID)
) {
// 업로드를 위한 presignedURL 요청
let container = MultipartOperation.MultipartKey.container
let key = container.key
let uploadID = container.uploadID
// presignedURL의 결과로 반환된 data가 갖고 있는 key와
// Local에서 생성한 INIT_KEY를 비교확인하여 사용가능한지 체크
let data = try await getPresignedURL(key: key, uploadID: uploadID)
let needUpdateKeyAndUploadID = try await compareKeys(key, data.key)
if needUpdateKeyAndUploadID { // 만약 새로 가져와야 한다면 key와 uploadID 업데이트
try await MultipartOperation.MultipartKey.$container.withValue(
// 새로운 key로 업데이트하여 새로운 TaskLocal 클로저에서 Task 수행
MultipartOperation._MultipartKey(key: data.key, uploadID: uploadID)
) {
let container = MultipartOperation.MultipartKey.container
let newUploadID = try await restartMultipartUpload(key: container.key)
try await MultipartOperation.MultipartKey.$container.withValue(
MultipartOperation._MultipartKey(key: container.key, uploadID: newUploadID)
) {
// upload Multipart
}
}
} else {
// upload Multipart
}
}
}
의도한대로 key와 uploadID가 필요한 클로저 내에서만 사용되도록, 그리고 수정되어야 하는 key와 uploadID를 적절한 Task에 필요한 TaskLocal의 클로저에서 사용할 수 있도록 했으나 이렇게 할 필요는 없었다는 반성을 하게 하는 코드로 남았다. key와 uploadID를 받아올 때마다 새로운 TaskLocal 클로져를 생성하고, 클로저가 연속되다보니 이 값이 어떤 값인지 확인하고 이해하기가 난해한 코드가 되었다.
다음은 Data를 청크로 구분하는 로직을 정리해야 했다. 이 부분은 NSData의 .mappedIfSafe API를 활용했다.
final class MappedChunkProvider: ChunkBuilder {
private let mappedData: NSData
private let chunkSize: Int
private var currentOffset: Int = 0
init(filename: String, chunkSize: Int) throws {
do {
self.mappedData = try NSData(contentsOf: URL(fileURLWithPath: filename), options: .mappedIfSafe)
self.chunkSize = chunkSize
} catch {
// throw
}
}
init(data: Data, chunkSize: Int = 5 * 1024 * 1024) throws {
self.mappedData = NSData(data: data)
self.chunkSize = chunkSize
}
func nextChunk() async throws -> Data? {
guard currentOffset < mappedData.length else { return nil }
let length = min(chunkSize, mappedData.length - currentOffset)
let chunk = mappedData.subdata(with: NSRange(location: currentOffset, length: length))
currentOffset += length
return chunk
}
}
3. 결론
몇 달 만에 돌아와서 게시글을 썼는데, 그 사이 많은 일들이 있었다. 업로드 과정 중 발생했던 아주 많은 이슈의 원인은 다음에 다루게 될 CoreData 쪽이었다. 여러 번 수정을 거치고 검토를 했음에도 내부적으로 취약점이 발견되고 있기 때문에 아직 게시글로 정리하기엔 시기상조라는 생각이 들었다. CoreData 쪽 수정 작업은 Combine에 대한 내 이해도를 반성하게끔 했고, 상태 관리의 중요성을 더욱 체감하게 된 작업이었다.
2025.05.18(일)
메타데이터
- post_id
- 531435514f08
- slug
- ios-미디어-업로드-프로세스-개선-작업일기-2-531435514f08
- url
- https://medium.com/@celanlee/ios-%EB%AF%B8%EB%94%94%EC%96%B4-%EC%97%85%EB%A1%9C%EB%93%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EA%B0%9C%EC%84%A0-%EC%9E%91%EC%97%85%EC%9D%BC%EA%B8%B0-2-531435514f08
- canonical_url
- https://medium.com/@celanlee/ios-%EB%AF%B8%EB%94%94%EC%96%B4-%EC%97%85%EB%A1%9C%EB%93%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EA%B0%9C%EC%84%A0-%EC%9E%91%EC%97%85%EC%9D%BC%EA%B8%B0-2-531435514f08
- author_url
- https://medium.com/@celanlee
- status
- ok
- fetched_at
- 2026-07-19 20:47:15