Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
20d2fde
[Fix] PresignedURL API 변경사항 반영 (#272)
yurim830 Sep 19, 2025
40ddc80
[Refactor] 인덱스 오류 가능성 있는 코드 개선 (#272)
yurim830 Sep 19, 2025
e865c03
[Add,Feat] PhotoManager 구현 (#272)
yurim830 Sep 21, 2025
6325672
[Refactor] 사진 -> 데이터 변환 메소드 수정: 허용 포맷으로 변환하도록 (#272)
yurim830 Sep 21, 2025
7367163
[Chore] ErrorResponse: Error 채택 (#272)
yurim830 Sep 21, 2025
206ebff
[Fix] postPresignedURL API 수정 (#272)
yurim830 Sep 21, 2025
03573f9
[Add] PhotoModel 생성 (#272)
yurim830 Sep 21, 2025
82231e5
[Refactor] GCD, 콜백 중심 메소드 -> Swift Concurrency로 리팩토링 (#272)
yurim830 Sep 21, 2025
f84aff3
[Chore] Model 변경사항 반영 (#272)
yurim830 Sep 21, 2025
55053a3
[Feat] 서버 오류로 401 뜰 때 재시도 루프에 빠지는 문제 방지 (#272)
yurim830 Sep 21, 2025
5cd3aeb
[Chore] AlertType에 프로필 수정 실패 케이스 추가 (#272)
yurim830 Sep 21, 2025
2f41453
[Refactor] 프로필편집 - presignedURL 로직 수정 (#272)
yurim830 Sep 21, 2025
e2acd5f
[Chore] PhotoManager 에러 핸들링 default 사용 (#272)
yurim830 Sep 22, 2025
16f9d0c
[Fix] PUT PresignedURL API 수정: Content-Type 하드코딩을 mimeType으로 변경 (#272)
yurim830 Sep 22, 2025
19ba1cc
[Refactor] PhotoManagerError 세분화 (#272)
yurim830 Sep 22, 2025
17bccd6
[Fix] Profile patch error 세분화 (#272)
yurim830 Sep 22, 2025
ab3a394
[Refactor] patchProfile을 async throws로 변경 (#272)
yurim830 Sep 22, 2025
8e2edd5
[Refactor] postSpot을 async throws로 변경 (#272)
yurim830 Sep 22, 2025
38fa8b5
[Chore] 브랜치 최신화 및 컨플릭 해결 (#272)
yurim830 Sep 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions ACON-iOS/ACON-iOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
1502D71D2DE9D44100A21D81 /* RegionErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1502D71C2DE9D44100A21D81 /* RegionErrorView.swift */; };
1503DBEB2DFD9CBB001FC3E5 /* GetMenuboardImageListResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1503DBEA2DFD9CBB001FC3E5 /* GetMenuboardImageListResponse.swift */; };
150A105C2DA28F2600B0BC9A /* ACTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 150A105B2DA28F2600B0BC9A /* ACTextField.swift */; };
150B4B2A2E7EE4BB00C34C76 /* PhotoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 150B4B292E7EE4BB00C34C76 /* PhotoManager.swift */; };
150B4C8C2E805EAF00C34C76 /* PhotoModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 150B4C8B2E805EAF00C34C76 /* PhotoModel.swift */; };
150BA57A2E56F3720058A06B /* TutorialContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 150BA5792E56F3720058A06B /* TutorialContainerViewController.swift */; };
150BA57F2E56F8170058A06B /* ReviewTutorialViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 150BA57E2E56F8170058A06B /* ReviewTutorialViewController.swift */; };
150BA5812E56F8580058A06B /* LimitedSpotsTutorialViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 150BA5802E56F8580058A06B /* LimitedSpotsTutorialViewController.swift */; };
Expand Down Expand Up @@ -298,9 +300,9 @@
74C914D12D64DBBB00BC13E1 /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74C914D02D64DBAF00BC13E1 /* ImageType.swift */; };
74C914D32D64E01C00BC13E1 /* PutImageToPresignedURLRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74C914D22D64E01000BC13E1 /* PutImageToPresignedURLRequest.swift */; };
74CDCE562D310B1600E3A21A /* String+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74CDCE552D310B1300E3A21A /* String+.swift */; };
74D297F22D63436F00DDEE31 /* GetPresignedURLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D297F12D63435A00DDEE31 /* GetPresignedURLResponse.swift */; };
74D297F22D63436F00DDEE31 /* PostPresignedURLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D297F12D63435A00DDEE31 /* PostPresignedURLResponse.swift */; };
74D297F42D6343E300DDEE31 /* ImageTargetType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D297F32D6343D400DDEE31 /* ImageTargetType.swift */; };
74D297F62D63456400DDEE31 /* GetPresignedURLRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D297F52D63455D00DDEE31 /* GetPresignedURLRequest.swift */; };
74D297F62D63456400DDEE31 /* PostPresignedURLRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D297F52D63455D00DDEE31 /* PostPresignedURLRequest.swift */; };
74D297F82D63467900DDEE31 /* ImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D297F72D63467200DDEE31 /* ImageService.swift */; };
74D31C712D5832D000B4B2B4 /* AlbumViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D31C702D5832CC00B4B2B4 /* AlbumViewModel.swift */; };
74D31C7A2D5841EA00B4B2B4 /* PhotoCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D31C792D5841D100B4B2B4 /* PhotoCollectionViewController.swift */; };
Expand Down Expand Up @@ -336,6 +338,8 @@
1502D71C2DE9D44100A21D81 /* RegionErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionErrorView.swift; sourceTree = "<group>"; };
1503DBEA2DFD9CBB001FC3E5 /* GetMenuboardImageListResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetMenuboardImageListResponse.swift; sourceTree = "<group>"; };
150A105B2DA28F2600B0BC9A /* ACTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACTextField.swift; sourceTree = "<group>"; };
150B4B292E7EE4BB00C34C76 /* PhotoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoManager.swift; sourceTree = "<group>"; };
150B4C8B2E805EAF00C34C76 /* PhotoModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoModel.swift; sourceTree = "<group>"; };
150BA5792E56F3720058A06B /* TutorialContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialContainerViewController.swift; sourceTree = "<group>"; };
150BA57E2E56F8170058A06B /* ReviewTutorialViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewTutorialViewController.swift; sourceTree = "<group>"; };
150BA5802E56F8580058A06B /* LimitedSpotsTutorialViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitedSpotsTutorialViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -616,9 +620,9 @@
74C914D02D64DBAF00BC13E1 /* ImageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageType.swift; sourceTree = "<group>"; };
74C914D22D64E01000BC13E1 /* PutImageToPresignedURLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PutImageToPresignedURLRequest.swift; sourceTree = "<group>"; };
74CDCE552D310B1300E3A21A /* String+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+.swift"; sourceTree = "<group>"; };
74D297F12D63435A00DDEE31 /* GetPresignedURLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPresignedURLResponse.swift; sourceTree = "<group>"; };
74D297F12D63435A00DDEE31 /* PostPresignedURLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostPresignedURLResponse.swift; sourceTree = "<group>"; };
74D297F32D6343D400DDEE31 /* ImageTargetType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTargetType.swift; sourceTree = "<group>"; };
74D297F52D63455D00DDEE31 /* GetPresignedURLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPresignedURLRequest.swift; sourceTree = "<group>"; };
74D297F52D63455D00DDEE31 /* PostPresignedURLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostPresignedURLRequest.swift; sourceTree = "<group>"; };
74D297F72D63467200DDEE31 /* ImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageService.swift; sourceTree = "<group>"; };
74D31C702D5832CC00B4B2B4 /* AlbumViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumViewModel.swift; sourceTree = "<group>"; };
74D31C792D5841D100B4B2B4 /* PhotoCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCollectionViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1337,6 +1341,7 @@
7462D0242D4262EF00580464 /* AuthManager.swift */,
1515CE0C2E00823B00A559A2 /* DeepLinkManager.swift */,
153B78242E74090700B772F9 /* UserDefaultsManager.swift */,
150B4B292E7EE4BB00C34C76 /* PhotoManager.swift */,
);
path = Service;
sourceTree = "<group>";
Expand Down Expand Up @@ -1854,8 +1859,8 @@
isa = PBXGroup;
children = (
74C914D22D64E01000BC13E1 /* PutImageToPresignedURLRequest.swift */,
74D297F52D63455D00DDEE31 /* GetPresignedURLRequest.swift */,
74D297F12D63435A00DDEE31 /* GetPresignedURLResponse.swift */,
74D297F52D63455D00DDEE31 /* PostPresignedURLRequest.swift */,
74D297F12D63435A00DDEE31 /* PostPresignedURLResponse.swift */,
);
path = DTO;
sourceTree = "<group>";
Expand Down Expand Up @@ -1894,6 +1899,7 @@
isa = PBXGroup;
children = (
74D31C7B2D5BF8FD00B4B2B4 /* AlbumModel.swift */,
150B4C8B2E805EAF00C34C76 /* PhotoModel.swift */,
);
path = Model;
sourceTree = "<group>";
Expand Down Expand Up @@ -2265,14 +2271,15 @@
74D31C712D5832D000B4B2B4 /* AlbumViewModel.swift in Sources */,
74721B4B2E2A07D100F0ACB9 /* SemiShortModalView.swift in Sources */,
1503DBEB2DFD9CBB001FC3E5 /* GetMenuboardImageListResponse.swift in Sources */,
150B4C8C2E805EAF00C34C76 /* PhotoModel.swift in Sources */,
746A13B82E00549E0097DA25 /* PreferenceTargetType.swift in Sources */,
1547A6F22D33AD4500E96616 /* SpotListModel.swift in Sources */,
746261732D3EA78700A4E84F /* GetReiviewVerificationRequest.swift in Sources */,
743900912DC50926002F91B1 /* UIVisualEffectView+.swift in Sources */,
741A07592D355F1400778219 /* ReviewFinishedViewController.swift in Sources */,
15A3F6AC2D37AB7B00577E16 /* SpotListCollectionViewHeader.swift in Sources */,
1502D71D2DE9D44100A21D81 /* RegionErrorView.swift in Sources */,
74D297F62D63456400DDEE31 /* GetPresignedURLRequest.swift in Sources */,
74D297F62D63456400DDEE31 /* PostPresignedURLRequest.swift in Sources */,
748D6F892D2BD294007690B4 /* UIViewController+.swift in Sources */,
741429672D5D550C00B69528 /* AppVersionManager.swift in Sources */,
151AB6C32DD384C100D01DE8 /* SpotDetailImageCollectionViewCell.swift in Sources */,
Expand Down Expand Up @@ -2391,8 +2398,9 @@
7462618B2D3FA4A800A4E84F /* SpotDetailService.swift in Sources */,
15E71E422DF5A44B0020689D /* OpeningTimeView.swift in Sources */,
150BA5832E56F87E0058A06B /* StartNowViewController.swift in Sources */,
74D297F22D63436F00DDEE31 /* GetPresignedURLResponse.swift in Sources */,
74D297F22D63436F00DDEE31 /* PostPresignedURLResponse.swift in Sources */,
1547A88B2D3596B600E96616 /* SpotFilterType.swift in Sources */,
150B4B2A2E7EE4BB00C34C76 /* PhotoManager.swift in Sources */,
15304FBE2E33B7E400EFCDEF /* MenuRecommendationViewController.swift in Sources */,
74BF92172D393B4A00B923E3 /* Int+.swift in Sources */,
747BB6CE2DCA66F000352874 /* GlassButtonState.swift in Sources */,
Expand Down
195 changes: 195 additions & 0 deletions ACON-iOS/ACON-iOS/Global/Service/PhotoManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
//
// PhotoManager.swift
// ACON-iOS
//
// Created by 김유림 on 9/20/25.
//

import UIKit
import Photos

// MARK: - PhotoManager Errors

enum PhotoManagerError: LocalizedError {

case imageDataConversionFailed
case missingFileName
case tokenExpired
case requestError(Error) // 4xx
case serverError // 5xx
case networkError
case decodingError
case otherError

var errorDescription: String? {
switch self {
case .imageDataConversionFailed: return "🎞️ Failed to retrieve image data."
case .missingFileName: return "🎞️ Missing the filename of the photo."
case .tokenExpired: return "🎞️ Authentication token has expired."
case .requestError(let error): return "🎞️ A client error occurred: \(error.localizedDescription)"
case .serverError: return "🎞️ The server is currently unavailable. Please try again later."
case .networkError: return "🎞️ Please check your internet connection."
case .decodingError: return "🎞️ Failed to process the response from the server."
case .otherError: return "🎞️ Other error occurred."
}
}

}


// MARK: - PhotoManager

class PhotoManager {

private let imageService: ImageServiceProtocol
private let imageType: ImageType

init(imageType: ImageType, imageService: ImageServiceProtocol = ACService.shared.imageService) {
self.imageType = imageType
self.imageService = imageService
}

// NOTE: 이미지 1개 업로드
func uploadImage(asset: PHAsset) async throws -> String {
guard let fileName = getFileName(for: asset) else {
throw PhotoManagerError.missingFileName
}

let imageData = try await requestImageData(for: asset)
let presignedURLResponse = try await requestPresignedUrl(fileName: fileName)
try await uploadToS3(data: imageData, to: presignedURLResponse.preSignedUrl, fileName: fileName)

return presignedURLResponse.fileUrl
}

// NOTE: 이미지 여러장 업로드
func uploadImages(assets: [PHAsset]) async throws -> [String] {
return try await withThrowingTaskGroup(of: String.self) { group in // NOTE: 병렬 작업
var uploadedFileUrls: [String] = []
uploadedFileUrls.reserveCapacity(assets.count) // array allocation

for asset in assets {
group.addTask {
return try await self.uploadImage(asset: asset)
}
}

for try await fileUrl in group {
uploadedFileUrls.append(fileUrl)
}

return uploadedFileUrls
}
}

}


// MARK: - Helper

private extension PhotoManager {

/// NOTE: 사진 fileName 가져오기
func getFileName(for asset: PHAsset) -> String? {
return PHAssetResource.assetResources(for: asset).first?.originalFilename
}

/// NOTE: 사진을 데이터 타입으로 변환
/// - `SPOT`: jpg, jpeg, webp, heic
/// - `PROFILE`, `MENUBOARD`: jpg, jpeg, png, webp, heic
private func requestImageData(for asset: PHAsset) async throws -> Data {
let options = PHImageRequestOptions()
options.isNetworkAccessAllowed = true

return try await withCheckedThrowingContinuation { continuation in
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { [weak self] data, dataUTI, _, info in
guard let self = self else {
continuation.resume(throwing: PhotoManagerError.imageDataConversionFailed)
return
}

if let error = info?[PHImageErrorKey] as? Error {
continuation.resume(throwing: error)
return
}

guard let imageData = data,
let utiString = dataUTI as String? else {
continuation.resume(throwing: PhotoManagerError.imageDataConversionFailed)
return
}

// NOTE: 허용하지 않는 포맷 -> jpeg로 변환
let allowedFormats = self.imageType.allowedUTIs
if !allowedFormats.contains(utiString) {
print("🎞️ Format '\(utiString)' is not allowed. Convert to JPEG")

guard let image = UIImage(data: imageData),
let jpegData = image.jpegData(compressionQuality: self.imageType.compressionQuality) else {
continuation.resume(throwing: PhotoManagerError.imageDataConversionFailed)
return
}
continuation.resume(returning: jpegData)
} else {
// NOTE: 허용하는 포맷 -> 원본 데이터 반환
continuation.resume(returning: imageData)
}
}
}
}

/// NOTE: PresignedURL 생성
func requestPresignedUrl(fileName: String) async throws -> PostPresignedURLResponse {
try await withCheckedThrowingContinuation { continuation in
imageService.getPresignedURL(
parameter: PostPresignedURLRequest(imageType: imageType.rawValue, originalFileName: fileName)
) { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .reIssueJWT:
continuation.resume(throwing: PhotoManagerError.tokenExpired)
case .requestErr(let errorResponse):
continuation.resume(throwing: PhotoManagerError.requestError(errorResponse))
case .serverErr:
continuation.resume(throwing: PhotoManagerError.serverError)
case .networkFail:
continuation.resume(throwing: PhotoManagerError.networkError)
case .decodedErr:
continuation.resume(throwing: PhotoManagerError.decodingError)
default:
continuation.resume(throwing: PhotoManagerError.otherError)
}
}
}
}

/// NOTE: S3에 사진 업로드
func uploadToS3(data: Data, to urlString: String, fileName: String) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
imageService.putImageToPresignedURL(
requestBody: PutImageToPresignedURLRequest(presignedURL: urlString,
imageData: data,
fileName: fileName)
) { result in
switch result {
case .success:
continuation.resume(returning: ())
case .reIssueJWT:
continuation.resume(throwing: PhotoManagerError.tokenExpired)
case .requestErr(let errorResponse):
continuation.resume(throwing: PhotoManagerError.requestError(errorResponse))
case .serverErr:
continuation.resume(throwing: PhotoManagerError.serverError)
case .networkFail:
continuation.resume(throwing: PhotoManagerError.networkError)
case .decodedErr:
continuation.resume(throwing: PhotoManagerError.decodingError)
default:
continuation.resume(throwing: PhotoManagerError.otherError)
}
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ enum ACAlertType: CaseIterable {

case libraryAccessDenied // NOTE: 사진 권한 X
case changeNotSaved // NOTE: 프로필 변경사항 저장 X
case profilePatchFail // NOTE: 프로필 수정 실패

case changeVerifiedArea // NOTE: 지역인증 변경 (지역 1개)
case timeoutFromVerification // NOTE: 지역인증 변경 (인증 1주일 - 3개월)
Expand Down Expand Up @@ -68,14 +69,16 @@ enum ACAlertType: CaseIterable {

case .spotUploadFail:
return "장소 업로드 실패"
case .profilePatchFail:
return "프로필 수정 실패"
}
}

var description: String? {
switch self {
case .locationAccessDenied:
return "설정에서 위치접근 권한을 허용해주세요."
case .locationAccessFail, .spotUploadFail:
case .locationAccessFail, .spotUploadFail, .profilePatchFail:
return "문제가 발생했습니다.\n나중에 다시 시도해주세요."
case .reviewLocationFail:
return "현재 위치와 리뷰 등록 장소가\n오차범위 밖에 있습니다.\n좀 더 가까이 이동해보세요."
Expand Down Expand Up @@ -107,7 +110,7 @@ enum ACAlertType: CaseIterable {

var leftButtonTitle: String? {
switch self {
case .plainUpdate, .libraryAccessDenied, .changeVerifiedArea, .logout, .deletePhoto, .quitSpotUpload:
case .plainUpdate, .libraryAccessDenied, .changeVerifiedArea, .logout, .deletePhoto, .quitSpotUpload, .spotUploadFail, .profilePatchFail:
return "취소"
case .naverAPILimitExceeded:
return "끝내기"
Expand All @@ -132,7 +135,7 @@ enum ACAlertType: CaseIterable {
return "나가기"
case .changeVerifiedArea:
return "변경하기"
case .quitPreference, .quitSpotUpload:
case .quitPreference, .quitSpotUpload, .spotUploadFail, .profilePatchFail:
return "그만두기"
case .logout:
return "로그아웃"
Expand Down
4 changes: 2 additions & 2 deletions ACON-iOS/ACON-iOS/Global/Utils/Enums/HeaderType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ enum HeaderType {

static let basicHeader = ["Content-Type" : "application/json"]

static func imageHeader(imageData: Data) -> [String: String] {
return ["Content-Type" : "image/jpeg", "Content-Length": "\(imageData.count)" ]
static func imageHeader(contentType: String) -> [String: String] {
return ["Content-Type" : contentType]
}

static func headerWithToken() -> [String: String] {
Expand Down
Loading