새벽의 기록

[iOS] Clean Architecture 기반 이미지 업로드 파이프라인 구축기 (feat. AWS S3/TCA) 본문

[iOS]

[iOS] Clean Architecture 기반 이미지 업로드 파이프라인 구축기 (feat. AWS S3/TCA)

OneTen 2026. 2. 4. 16:02

 

YAPP 27기 iOS 파트원으로써 참여한 Neki 프로젝트의 1차 스프린트에서 앱의 핵심 기능 중 하나인 이미지 업로드 기능을 구현했습니다.

단순히 API를 호출하는 것을 넘어, AWS S3의 Presigned URL 방식을 도입하고, TCA(The Composable Architecture) Clean Architecture 원칙을 준수하며 데이터 흐름을 설계하는 데 집중했습니다.

 

특히 다중 이미지 업로드 시의 동시성 제어 네트워크 계층의 유연성을 확보하기 위해 고민했던 과정을 기록합니다.

 

https://github.com/YAPP-Github/27th-App-Team-2-iOS/pull/57

 

[Feat] #53 - S3 이미지 업로드 파이프라인 Core 모듈 통합 및 재사용성 개선 by OneTen19 · Pull Request #57

🌴 작업한 브랜치 feat#53-image-upload ✅ 작업한 내용 [Core] ImageUpload/Data Layer PresignedURLRequestDTO, PresignedURLResponseDTO PresignedURL 요청 시에 필요한 DTO들입니다. 이 파일들과 별개로 BaseResponseDTO의 status 필

github.com


🏗️ Architecture Overview

이번 기능 구현의 핵심 목표는 Domain 로직과 외부 데이터 소스(S3, Server)의 철저한 분리였습니다.

전체적인 파이프라인은 다음과 같습니다.

  1. Presentation: 사용자가 앨범에서 이미지를 선택 (NekiImagePicker)
  2. Domain: 업로드 의도 전달 (ImageUploadClient)
  3. Data:
    • API 서버에 Presigned URL 요청 (PresignedEndpoint)
    • 발급받은 URL로 AWS S3에 직접 업로드 (UploadImageEndpoint)
    • 업로드된 ID 반환

[Core] ImageUpload/Data Layer

 

 

🔐 단일 Endpoint로 관리하는 업로드 프로세스

보안을 위해 S3 업로드 시 서버를 거치지 않고, 서버로부터 발급받은 Presigned URL을 사용해 클라이언트가 S3로 직접 업로드하는 방식을 택했습니다. 이 과정에서 '서버 통신'과 'S3 통신'이라는 서로 다른 성격의 요청을 처리해야 했습니다. 

이를 각각 별도의 파일로 나누기보다, ImageUploadEndpoint라는 하나의 열거형(Enum)으로 응집도 있게 관리하는 방식을 택했습니다.

 

이 Endpoint는 case에 따라 BaseURL, 인증 방식, Content-Type을 동적으로 전환하며 두 가지 역할을 모두 수행합니다.

// ImageUploadEndpoint.swift (Core)

public enum ImageUploadEndpoint: Endpoint {
    case getPresignedURL(request: PresignedURLRequestDTO)            // 1. 서버 요청
    case uploadToS3(presignedURL: String, data: Data, contentType: String) // 2. S3 업로드
}

 

BaseURL의 동적 전환

보통 Endpoint는 앱의 기본 서버 URL(BASE_URL)을 공유하지만, S3 업로드는 서버가 아닌 AWS로 직접 요청을 보내야 합니다. 이를 위해 switch self를 통해 .uploadToS3 케이스일 때는 파라미터로 받은 presignedURL 자체를 BaseURL로 리턴하도록 구현했습니다.

 

인증(Authorization) 및 Body 처리의 유연함

두 요청은 요구하는 스펙이 완전히 다릅니다. 이를 Endpoint 프로토콜 구현부에서 명확히 분기 처리했습니다.

  • getPresignedURL (서버 요청):
    • 인증: Bearer 토큰 필요 (로그인 유저 검증)
    • Body: 메타데이터가 담긴 DTO (JSON 인코딩)
    • Content-Type: application/json
  • uploadToS3 (S3 직접 업로드):
    • 인증: .none (URL 자체에 서명이 포함되어 있으므로 헤더 토큰 제외)
    • Body: 이미지 바이너리(Data) 자체
    • Content-Type: .custom(String) (이미지 확장자에 맞는 MIME Type 지정)

이러한 구조 덕분에 네트워크 로직이 흩어지지 않고, "이미지 업로드"라는 하나의 도메인 지식을 한 파일 안에서 파악할 수 있게 되었습니다.


⚡️ 효율적인 업로드 시퀀스

이미지 업로드 로직은 크게 두 단계로 나뉩니다.

  1. URL 발급: 서버에게 이미지 메타데이터를 보내고, Presigned URL과 Media ID를 미리 발급받습니다.
  2. 실제 업로드: 발급받은 URL을 이용해 AWS S3에 이미지를 병렬로 업로드합니다.

여기서 핵심은 Media ID를 S3 업로드 후가 아니라, URL 발급 단계에서 미리 획득한다는 점입니다. 덕분에 S3 업로드 시에는 결과값을 모아서 정렬할 필요 없이, "모든 작업이 에러 없이 끝났는가?"만 체크하면 됩니다.

 

TaskGroup을 활용한 병렬 처리

사용자가 여러 장의 사진을 올릴 때, 순차적으로 처리하면 시간이 너무 오래 걸립니다. 이를 해결하기 위해 Swift Concurrency의 withThrowingTaskGroup을 사용했습니다.

// DefaultImageUploadRepository.swift

public func upload(items: [ImageUploadEntity], mediaType: ImageMediaType) async throws -> [Int] {
        Logger.network.debug("🚀 이미지 업로드 시작 (총 \(items.count)장, 타입: \(mediaType.rawValue))")
        
        // MARK: - Presigned URL 일괄요청
        
        let requestItems = items.map { item in
            PresignedURLRequestData(
                filename: UUID().uuidString,
                contentType: item.contentType,
                mediaType: mediaType.rawValue
            )
        }
        let requestDTO = PresignedURLRequestDTO(items: requestItems)
        let presignedEndpoint = ImageUploadEndpoint.getPresignedURL(request: requestDTO)
        
        let response: PresignedURLResponseDTO
        do {
            Logger.network.debug("📡 Presigned URL 요청 중...")
            response = try await networkProvider.request(endpoint: presignedEndpoint)
        } catch {
            Logger.network.error("❌ Presigned URL 요청 실패: \(error.localizedDescription)")
            throw error
        }
        
        guard let responseItems = response.data?.items, responseItems.count == items.count else {
            Logger.network.error("❌ 응답 데이터가 누락되었거나 요청한 개수와 일치하지 않습니다.")
            throw UploadError.presignedUrlFailed
        }
        
        // 결과로 반환할 mediaID들
        let finalMediaIDs = responseItems.map { $0.mediaID }
        
        
        // MARK: - S3 병렬 업로드
        
        let uploadTasks = Array(zip(items, responseItems))
        
        try await withThrowingTaskGroup(of: Void.self) { group in
            for (index, taskInfo) in uploadTasks.enumerated() {
                let entity = taskInfo.0
                let responseItem = taskInfo.1
                
                group.addTask {
                    let uploadEndpoint = ImageUploadEndpoint.uploadToS3(
                        presignedURL: responseItem.uploadTicket,
                        data: entity.data,
                        contentType: responseItem.contentType
                    )
                    
                    do {
                        _ = try await networkProvider.requestVoid(endpoint: uploadEndpoint)
                        Logger.network.debug("[\(index+1)] ✅ S3 업로드 성공 (Media ID: \(responseItem.mediaID))")
                    } catch {
                        Logger.network.error("[\(index+1)] ❌ S3 업로드 실패")
                        throw error // 하나라도 실패하면 전체 에러 발생
                    }
                }
            }
            
            try await group.waitForAll()
        }
        
        Logger.network.debug("✨ 모든 이미지 업로드 완료! (결과 ID: \(finalMediaIDs))")
        
        return finalMediaIDs
    }
  • zip 활용: 원본 이미지 데이터와 서버 응답(URL)을 1:1로 안전하게 매핑했습니다.
  • waitForAll(): 모든 이미지가 성공적으로 올라갈 때까지 대기하며, 중간에 하나라도 실패(throw)하면 즉시 중단되어 불필요한 리소스 낭비를 막습니다.
  • Logging: Logger를 활용해 각 단계(URL 요청, 개별 업로드 성공/실패)를 추적 가능하게 했습니다.

 

TCA Dependency 등록

구현된 Repository는 TCA의 Dependency 시스템에 등록하여 앱 전역에서 주입받을 수 있도록 설정했습니다. Repository 내부에서도 @Dependency(\.networkProvider)를 사용하여 네트워크 모듈을 주입받아 사용합니다.

private enum ImageUploadRepositoryKey: DependencyKey {
    static let liveValue: any ImageUploadRepository = DefaultImageUploadRepository()
}

extension DependencyValues {
    var imageUploadRepository: any ImageUploadRepository {
        get { self[ImageUploadRepositoryKey.self] }
        set { self[ImageUploadRepositoryKey.self] = newValue }
    }
}

 

이로써 Presentation Layer에 있는 Feature 리듀서(ViewModel 역할)는 Data Layer에 설계되어 있는 구체적인 구현 내용(DefaultImageUploadRepository)을 전혀 모르더라도 Domain Layer에 있는 인터페이스만(ImageUploadRepository)으로 기능을 수행할 수 있게 되었습니다.

 

처음에는 의존성을 스스로 주입받아서 사용하는 느낌이라 굉장히 어색했습니다.
기존 프로젝트에서는 최상위 레이어인 App Layer 에서 DIContainer를 만들어 의존성을 주입해 뿌려주는 방식만 경험했었는데,
TCA는 이 역할을 자체적으로 Dependency를 통해 해준다고 하더라구요? 굉장히 강력한 기능이라는 생각이 들었습니다.


[Core] ImageUpload/Domain Layer

Clean Architecture의 핵심은 "세부 구현 사항(S3 여부 등)을 모르게 하는 것"입니다.

 

📦 Entity & Repository

  • ImageUploadEntity: 네트워크 전송에 필요한 메타데이터를 담은 불변 구조체입니다.
  • ImageMediaType: 이미지가 사용될 도메인(프로필, 네컷사진 등)을 구분하여 S3 버킷 경로를 결정짓는 기준이 됩니다.
  • ImageUploadRepository: "이미지를 주면 ID 리스트를 돌려준다"는 추상화된 인터페이스만 정의하여 테스트 용이성을 확보했습니다.

🌉 ImageUploadClient 

TCA 환경에서 Feature가 Repository에 직접 접근하는 대신, Client라는 연결 다리를 두었습니다. DependencyValues 확장을 통해 앱 어디서든 liveValue로 구현체(DefaultImageUploadRepository)를 주입받아 사용할 수 있습니다.

 

다만, 아직 TCA 활용법에 대해 미숙하고 클린 아키텍처와 레포지토리의 역할등에 대해서도 부족하기에 적절한 구현방향인지는 모르겠습니다. 정확히는 Client와 Usecase의 차이를 아직 이해하지 못 한 상태로 단순히 TCA니까 Client라고 네이밍하자! 하고 사용하는 느낌입니다. 따라서 이 부분은 지속해서 공부해나갈 예정이며 현재 작성된 이 글과 추후의 개발 방식은 많이 차이가 날 수 있습니다.


[Core] ImageUpload/Presentation Layer

 

🖼️ NekiImagePicker & Feature

SwiftUI의 PhotosPicker를 래핑하여 NekiImagePicker 컴포넌트를 만들었습니다.

 

NekiImagePicker

SwiftUI PhotosPicker 래핑하여 시스템 앨범에서 이미지를 다중 선택할 수 있는 NekiImagePicker 컴포넌트를 구현했습니다.
기획 측의 요구사항대로 최대 업로드 이미지 개수는 10개 상한으로 잡아뒀고, ViewBuilder를 사용해 해당 컴포넌트를 사용하는 곳에서 자유롭게 UI를 구성할 수 있게 구현했습니다.

 

ImagePickerFeature

이미지 선택부터 업로드 완료까지의 상태를 관리하는리듀서입니다.
isLoading 변수를 만들어둬서 이미지 업로드 중에 로딩인디케이터 처리 등의 분기처리에 활용할 수 있습니다.

 

[주요 Action 흐름]

  • pickerItemsChanged: 앨범에서 사진 선택 시 호출됨. PhotosPickerItem을 UIImage로 로드합니다.
  • processImages: 로드된 UIImage를 ImageUploadEntity로 변환합니다.
  • requestUpload: 변환된 데이터를 imageUploadClient를 통해 서버로 전송합니다.
  • uploadCompleted: 업로드가 완료되면 결과(ID 리스트)를 반환하고, 부모 Feature에게 알립니다.

[사용 예시]

Feature (예: ArchiveFeature)에서 연결하기

// 1. State에 ImagePickerFeature.State 추가
struct State: Equatable {
    var imagePicker = ImagePickerFeature.State(
        maxCount: 10,
        mediaType: .archive // 업로드 타입 지정
    )
    // ...
}

// 2. Action에 연결
enum Action: BindableAction {
    case imagePicker(ImagePickerFeature.Action)
    // ...
}

// 3. Reducer에서 연결 및 결과 처리
var body: some ReducerOf<Self> {
    Scope(state: \.imagePicker, action: \.imagePicker) {
        ImagePickerFeature()
    }
    
    Reduce { state, action in
        switch action {
        // ...
        
        // 업로드 완료 신호 받기
        case let .imagePicker(.uploadCompleted(ids)):
            print("업로드 성공! ID 목록: \(ids)")
            // 이후 로직 처리 (예: 앨범 선택 팝업 띄우기)
            return .none
            
        case .imagePicker(.uploadFailed):
            print("업로드 실패")
            return .none
            
        // ...
        }
    }
}

 

 

View에서 사용하기

NekiImagePicker(store: store.scope(state: \.imagePicker, action: \.imagePicker)) {
    // 버튼 커스텀 UI
    HStack {
        Image(systemName: "photo.on.rectangle")
        Text("갤러리에서 추가")
    }
    .padding()
    .background(Color.gray.opacity(0.1))
    .cornerRadius(8)
}

 

Feature는 ImageUploadClient만 알고 있습니다. Client는 Repository Interface만 바라봅니다.
실제 동작은 Data Layer의 구현체가 수행합니다.

이 구조를 통해 S3 업로드 방식이 바뀌거나 라이브러리를 교체하더라도
Domain 레이어와 Feature 코드는 전혀 수정할 필요가 없으며 클린 아키텍처의 철칙을 실현하고자 의도했습니다.


Comments