Skip to content

[feat/#288] 제보하기 api 연동#304

Merged
SeungWon1125 merged 28 commits intodevelopfrom
feat/#288-reports-api
Oct 30, 2025
Merged

[feat/#288] 제보하기 api 연동#304
SeungWon1125 merged 28 commits intodevelopfrom
feat/#288-reports-api

Conversation

@SeungWon1125
Copy link
Copy Markdown
Collaborator

@SeungWon1125 SeungWon1125 commented Oct 28, 2025

📄 작업 내용

  • 제보하기 api를 연동했어요
구현 내용 iPhone 13 mini 진짜 됐을까요? - 네!
사진 O
사진 X

💻 주요 코드 설명

truncated 함수 오버로딩

extension String {
    /// 문자열을 지정한 길이를 기준으로 잘라낸 새로운 문자열을 반환합니다.
    /// - Parameter length: 남기고자 하는 최대 글자 수. 문자열이 이 값을 초과하면 잘라냅니다.
    /// - Returns: 지정한 길이로 잘라낸 문자열. 잘림이 발생하면 문자열 끝에 ".."가 붙습니다.
    func truncated(length: Int) -> Self {
        if self.count > length {
            return "\(self.prefix(length)).."
        }
        
        return self
    }
    
    /// 문자열을 지정한 문자열 이전까지 잘라낸 새로운 문자열을 반환합니다.
    /// - Parameter excludeEndRange: 이 문자열 직전까지만 남기고 잘라낼 기준 문자열.
    /// - Returns: 기준 문자열 직전까지의 부분 문자열.
    func truncated(excludeEndRange: String) -> String {
        guard let endRange = self.range(of: excludeEndRange) else { return self }

        return String(self[..<endRange.lowerBound])
    }
    
    /// 문자열을 지정한 시작 문자열 이후부터 끝 문자열 직전까지 잘라낸 새로운 문자열을 반환합니다.
    /// - Parameters:
    ///   - includeStartRange: 시작 기준 문자열. 이 문자열 직후부터 포함됩니다.
    ///   - excludeEndRange: 끝 기준 문자열. 이 문자열 직전까지만 포함됩니다.
    /// - Returns: 시작과 끝 기준 문자열 사이의 부분 문자열.
    func truncated(includeStartRange: String, excludeEndRange: String) -> String {
        guard let startRange = self.range(of: includeStartRange),
              let endRange = self.range(of: excludeEndRange) else { return self }
        
        return String(self[startRange.lowerBound..<endRange.lowerBound])
    }
}

기존에는 문자열의 길이 또는 특정 문자열을 받아서 자르는 함수가 따로 존재했는데
이번에 하나 더 필요해서 매개변수만 다르게 받아 처리하도록 수정했어요

S3 업로드

일단 S3 업로드 과정은 다음과 같은데요

1. 클라 -> 서버 : 업로드 요청

  • 사용자가 이미지를 선택함
  • 클라에서 "이 이미지를 S3에 올릴 presigned URL을 주세요” 라고 서버에 요청함

2. 클라 -> S3 : 직접 업로드

  • 서버에게 받은 presigned URL를 통해 실제 이미지를 S3에 업로드함

3. 클라 -> 서버 : 업로드 완료 알림

  • 클라에서 서버한테 "이 파일(사진)을 S3에 올렸어요"라고 알려줌
  • 우리는 제보하기 API에 같이 담아서 호출할 예정

위 과정을 Store & Effect에서 처리했어요
제보하기 요청과 동시에 로띠를 돌리고 돌리는 중에 이미지를 업로드 & 제보하기 API 호출하도록 했습니다
(PresignedUrl 요청 -> S3 업로드 -> 제보하기 API 호출과정을 순차적으로 처리)
사진 업로드가 오래 걸릴수도 있기 때문에 제보하기 API가 성공했을 때 로띠를 멈추도록 했고, 최소 2초는 로띠가 돌아가도록 했어요

1. 클라 -> 서버 : 업로드 요청

// ReportsStore.swift

case .changeReportsStep(let reportsStep):
    if let selectedReportsType = state.selectedReportsType, reportsStep == .reportsComplete {
        if state.attachedImageData.isEmpty { // 사용자가 사진 선택을 안 했을 때
            dispatch(
                .submitReports( // 바로 제보하기 API 호출하는 action
                    placeId: state.placeId,
                    request: ReportsRequestDTO(
                        reportType: selectedReportsType.rawValue,
                        content: state.reportsContent,
                        imageKeys: nil
                    )
                )
            )
        } else { // 사용자가 사진을 한 장이라도 선택했을 때
            dispatch(
                .submitPresignedUrlRequest( // presigned Url 요청하는 action부터 차근차근~
                    request: PresignedUrlRequestDTO(
                        files: state.attachedImageData.map { fileName, _ in
                            File(fileName: fileName)
                        }
                    )
                )
            )
        }
    }

사진 선택은 필수가 아니기 때문에 선택된 사진이 없다면 DTOimageKeysnil을 담고
제보하기 API를 호출하는 submitReports actiondispatch합니다.

선택된 사진이 하나라도 있으면 S3 업로드 과정으로 돌입합니다.
그러니 presigned Url요청을 먼저 해야겠죠?
(submitPresignedUrlRequest dispatch)

// ReportsStore.swift

case .submitPresignedUrlRequest(let request):
    Task {
        let result = await effect.submitPresignedUrlRequest(request: request)
        self.dispatch(result) // `presigned URL`를 받으면 presignedUrlReqeustSubmitted를 dispatch
    }

서버에게 이미지를 S3에 올릴 presigned URL을 요청합니다.
서버로부터 성공적으로 presigned URL를 받으면 사진을 직접 업로드를 해야합니다.

2. 클라 -> S3 : 직접 업로드

presigned URL를 통해 사진을 S3에 업로드 해요

// ReportsStore.swift

case .presignedUrlReqeustSubmitted(let response):
    let presignedInformation = response.presignedGetUrlInfos
    let imageDatas = state.attachedImageData
    // 서버에서 주는 데이터를 [URL: Data] 형태로 가공하는 과정입니다.
    var presignedDictionary: [URL: Data] = [:]
    for (info, data) in zip(presignedInformation, imageDatas) {
        if let url = URL(string: info.presignedUrl) {
            presignedDictionary[url] = data.1
        }
    }
    
    Task {
        let result = await effect.uploadImages(dictionary: presignedDictionary)
        self.dispatch(result)
    }

effect에서는 uploadPhotosService를 통해 업로드를 하는데요
업로드할 사진과 URL을 [URL: Data] 형태의 딕셔너리로 가공해서 effect로 넘겨요
effect에서는 이 딕셔너리를 통해 service의 API 요청 함수를 호출하겠죠

// UploadPhotosService.swift

final class UploadPhotosService { }

extension UploadPhotosService: UploadPhotosAPI {
    func uploadImages(_ dictionary: [URL: Data]) async throws -> [URL] {
        var uploadedUrls: [URL] = []
        print("📷 [UploadPhotosService] S3 사진 업로드 시작")
        try await withThrowingTaskGroup(of: URL.self) { group in
            for (presignedUrl, data) in dictionary {
                group.addTask { // 사진 S3업로드 병렬 처리
                    try await self.uploadToS3(url: presignedUrl, data: data)
                    return presignedUrl // 함수 리턴하는 거 아님!! ❌, 각 비동기 작업의 리턴값입니닷 ✅
                }
            }
            
            for try await url in group { // 위 리턴값을 여기서 받아요
                uploadedUrls.append(url)
            }
        }
        print("📸 [UploadPhotosService] S3 사진 업로드 완료")
        return uploadedUrls
    }
    
    private func uploadToS3(url: URL, data: Data) async throws {
        var request = URLRequest(url: url)
        request.httpMethod = "PUT"
        request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")

        let (_, response) = try await URLSession.shared.upload(for: request, from: data)
        
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.responseError
        }

        guard 200..<300 ~= httpResponse.statusCode else {
            throw NetworkError.apiError(message: "S3 업로드에 실패했습니다.")
        }
    }
}

effect에서는 위 UploadPhotosService의 func uploadImages()함수를 호출하여 업로드를 진행합니다.
사진 여러 개를 순차적으로 처리하면 오래 걸리기 때문에 withThrowingTaskGroup를 통해 병렬처리했어요
group.addTask { ... } 코드는 백그라운드에서 동시에 처리합니다.
S3로 업로드하는 과정은 URLSession을 사용했어요

3. 클라 -> 서버 : 업로드 완료 알림

S3 업로드 후 업로드 한 key를 제보하기 API에 같이 담아서 보내줘요
아까 사진을 선택하지 않았을 때 imageKeys를 nil로 담아서 보내줬죠?
이제는 S3에서 응답으로 받은 imagekey들을 [String]형태로 전환하여 DTO에 잘 담아서 보내주면 됩니다.

// ReportsStore.swift

case .photoUploadSuccess(let imageKeys):
    guard let reportsType = state.selectedReportsType, state.placeId > 0 else {
        return
    }
    
    var imageKeyStrings: [String]
    
    imageKeyStrings = imageKeys.map { imageKey in
        imageKey.absoluteString.truncated(includeStartRange: "dev", excludeEndRange: "?")
    }
    
    let request = ReportsRequestDTO(
        reportType: reportsType.rawValue,
        content: state.reportsContent,
        imageKeys: imageKeyStrings // String으로 변환해서 넘겨줍니다.
    )
    
    self.dispatch(.submitReports(placeId: state.placeId, request: request))
// ReportsStore.swift

case .submitReports(let placeId, let request):
    Task { // 제보하기 API 호출
        let result = await effect.submitReports(placeId: placeId, request: request)
        self.dispatch(result) // 성공하면 reportsSubmitted action dispatch함
    }

이렇게 DTO로 잘 변환 후 제보하기 API를 호출합니다.
제보하기에 성공하면 로띠 재생을 끝내요

로띠 최소 2초 재생

제보하기 버튼을 누르면 로띠를 재생함과 동시에 PresignedUrl 요청 & S3 업로드 & 제보하기 API 호출을 순차적으로 진행하기 때문에
이 과정이 다 끝나면 로띠 재생을 멈추도록 구현했어요

그래서 우리 최소 로띠 재생시간인 2초보다 덜 걸릴수도, 더 걸릴수도 있기 때문에 최소 2초를 보장하고자
아래와 같이 제보하기 API가 끝나도 최소 2초는 기다리도록 구현했어요
=> 그래서 gif보면, 사진 첨부하면 2초보다 조금 더 걸려서 로띠 사이클이 1회보다 조금 더 돌아요, 사진 없으면 로띠 1사이클 돌고 바로 끝남
(아직 제보하기 에러처리에 대한 뷰가 없어서 제보를 성공했을 때만 최소 2초 돌아가도록 했습니다.)

// ReportsEffect.swift

extension ReportsEffect {
    func submitReports(placeId: Int, request: ReportsRequestDTO) async -> ReportsAction {
        do {
            let response = try await placeService.submitReports(placeId: placeId, request: request)
            _ = try await Task.sleep(nanoseconds: 2_000_000_000) // 얘가 끝날 때까지 기다림! ㅋㅋ
            
            guard let _ = response.data else {
                return .errorOccured(error: .responseDecodingError)
            }
            
            return .reportsSubmitted
            
        } catch let error as NetworkError {
            return .reportsFailed(error: error)
        } catch {
            return .reportsFailed(error: .unknownError)
        }
    }
}

🔗 연결된 이슈

👀 기타 더 이야기해볼 점

  • 궁금한 점 있으면 편하게 리뷰 남겨주세요
  • 기명세에서는 못 봤는데 서버에서는 동일한 장소 오류 제보를 하루에 하나씩만 할 수 있게 해두셨더라고요(error로 반환) 이 부분에 대한 피그마가 없어서 처리하지 않았습니다(제보 실패하면 로띠가 일찍 끝남. 이때 Toast띄우거나 하면 될듯)
  • 기디쌤들이랑 논의 후 따로 이슈 파서 추가할게요

@SeungWon1125 SeungWon1125 requested a review from a team October 28, 2025 12:15
@SeungWon1125 SeungWon1125 self-assigned this Oct 28, 2025
@SeungWon1125 SeungWon1125 added the 🦒 seungwon 승원이가함! label Oct 28, 2025
@SeungWon1125 SeungWon1125 linked an issue Oct 28, 2025 that may be closed by this pull request
1 task
@SeungWon1125 SeungWon1125 added the 🛠️ feat 새로운 기능 구현 시 사용 label Oct 28, 2025
Copy link
Copy Markdown
Contributor

@dudwntjs dudwntjs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

데단하십니댜....

S3 업로드 병렬 처리 부분 컹커렁시?... 머싯네요

오타 수정은 해주시구여...
옵셔널 부분은 -1 쓰는 게 갠적으로 구리구리라 생각해서 추천드린 거고, 바쁘면 패수하고 머지하셔도 댐

@@ -18,11 +18,11 @@ struct SolplyPhotosPicker: View {
@State private var selectedImages: [UIImage] = []

private let maxSelectionCount: Int = 3
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 언제 쓰이나용?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

최대 사진 첨부 개수가 3개라 상수로 선언하고 사용합니다!

/// 문자열을 지정한 문자열 이전까지 잘라낸 새로운 문자열을 반환합니다.
/// - Parameter excludeEndRange: 이 문자열 직전까지만 남기고 잘라낼 기준 문자열.
/// - Returns: 기준 문자열 직전까지의 부분 문자열.
func truncated(excludeEndRange: String) -> String {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반환 타입을 Self 대신 String으로 명시한 거 좋네유
Self는 프로토콜 확장에서 타입을 제너릭하게 유지하려고 쓰는 건디... String 확장이니까 글케 바꾼거조

import Foundation

struct ReportsState {
var placeId: Int = -1
Copy link
Copy Markdown
Contributor

@dudwntjs dudwntjs Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

placeId: Int?로 두고 유효성은 guard let/if let같은 옵셔널로 처리하는 건 어떠신지...ㅎㅎ(셈나 과제라 아는 척 해볾)

현재도 Store에서 > 0 가드가 있어 의도는 잘 보입니다만, 의미를 타입으로 표현해브앙

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReportsView에서 onAppear과 동시에 초기 placeIdstate에 저장하기 때문에 nil일 상황이 없어서 Optional로 처리하지 않았는데요, 그래도 혹시 모를 상황이 생길 수 있으니 Optional로 처리하는 게 의미상 더 맞는 거 같네요. 수정할게요!

Comment on lines +63 to +67
for (info, data) in zip(presignedInformation, imageDatas) {
if let url = URL(string: info.presignedUrl) {
presignedDictionary[url] = data.1
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zip으로 presigned 응답로컬 이미지 데이터를 1:1 매칭해서 딕셔너리 만들거군뇨

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

궁금했는데 답변 고맙수

self.dispatch(result)
}

case .presignedUrlReqeustSubmitted(let response):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reqeust → Request 오타 발견 ㅋ

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗차차

Comment on lines +26 to +48
if let selectedReportsType = state.selectedReportsType, reportsStep == .reportsComplete {
if state.attachedImageData.isEmpty {
dispatch(
.submitReports(
placeId: state.placeId,
request: ReportsRequestDTO(
reportType: selectedReportsType.rawValue,
content: state.reportsContent,
imageKeys: nil
)
)
)
} else {
dispatch(
.submitPresignedUrlRequest(
request: PresignedUrlRequestDTO(
files: state.attachedImageData.map { fileName, _ in
File(fileName: fileName)
}
)
)
)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미지 없으면 바로 제보, 있으면 presigned → 업로드 → 제보 맞저

func uploadImages(_ dictionary: [URL: Data]) async throws -> [URL] {
var uploadedUrls: [URL] = []
print("📷 [UploadPhotosService] S3 사진 업로드 시작")
try await withThrowingTaskGroup(of: URL.self) { group in
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 좋네요—withThrowingTaskGroup으로 병렬 업로드 가넝학겟슨
콩코롱시... 이거 배우고 갑니다 ㅋ

Copy link
Copy Markdown
Contributor

@pedro0527 pedro0527 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아빠 너무 어려워요 ㅜㅡㅜ 회사 점심시간 이제 끝남....나머지 이따 볼게...ㅎ

Comment on lines +13 to +19
func convertToJPGFile(compressionQuality: CGFloat = 0.8) -> (String, Data)? {
guard let data = self.jpegData(compressionQuality: compressionQuality) else { return nil }
let uuid = UUID().uuidString.prefix(8)
let fileName = "place\(uuid).jpg"
return (fileName, data)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8은 무조건 고정값인가여

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

임의의 이미지 이름을 정하기 위한 값입니다! 8 글자면 충분히 이미지들끼리 이름이 겹치지 않을 거 같아서 정했어염

Comment on lines +13 to +30
func uploadImages(_ dictionary: [URL: Data]) async throws -> [URL] {
var uploadedUrls: [URL] = []
print("📷 [UploadPhotosService] S3 사진 업로드 시작")
try await withThrowingTaskGroup(of: URL.self) { group in
for (presignedUrl, data) in dictionary {
group.addTask {
try await self.uploadToS3(url: presignedUrl, data: data)
return presignedUrl
}
}

for try await url in group {
uploadedUrls.append(url)
}
}
print("📸 [UploadPhotosService] S3 사진 업로드 완료")
return uploadedUrls
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR을 보면 리턴한는거 아니고 각 비동기 작업의 리턴값이라고 하고
그 밑에서 리턴값을 받아서 최종적으로 리턴하는거 같은데
왜 그래여...? 모르게써

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 저 group에 묶여있는 Task들은 비동기적으로 병렬처리되고, 그 저리된 작업을 아래 for try await 구문에서 받아 배열에 append 해요 그리고 업로드 성공한 url을 반환하는 형식입니다.

우선 S3에 업로드는 한번에 한 사진만 가능합니다. 그렇기 때문에 사진 하나 끝날 때까지 기다렸다가 다음 사진 업로드 하고.. 이러면 너무 오래걸리기 때문에 병렬로 처리하는 거고, 그 사진마다 용량이 다를 것이기 때문에 항상 동시에 끝남을 보장받을 수 없어요. 그래서 비동기적으로 업로드합니다. 마지막으로 이 3장의 사진이 모두 업로드됐다고 하면(비동기 작업의 리턴값들이 모두 모이면) [URL]을 반환하는 거죠

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

S3에 업로드하는 과정은 URLSession 사용해서 여기서는 안쓰는거죠ㅕ

Comment on lines +63 to +67
for (info, data) in zip(presignedInformation, imageDatas) {
if let url = URL(string: info.presignedUrl) {
presignedDictionary[url] = data.1
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

궁금했는데 답변 고맙수

@SeungWon1125 SeungWon1125 merged commit 56b2f72 into develop Oct 30, 2025
@SeungWon1125 SeungWon1125 deleted the feat/#288-reports-api branch October 30, 2025 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🛠️ feat 새로운 기능 구현 시 사용 🦒 seungwon 승원이가함!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 잘못된 정보 제보 API 연동

3 participants