Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 0 additions & 1 deletion WhereAreYou/WhereAreYou/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
firestoreSettings.cacheSettings = MemoryCacheSettings()
Firestore.firestore().settings = firestoreSettings
Database.database().useEmulator(withHost: host, port: 9000)
Comment thread
sangYuLv marked this conversation as resolved.
Storage.storage().useEmulator(withHost: host, port: 9199)
Comment thread
sangYuLv marked this conversation as resolved.
#endif
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ extension DIContainer {
}
))

// 프로필 이미지
register(ProfileImageRepository.self, instance: StorageProfileImageRepository())

// 위치
let coreLocationRepository = CoreLocationRepository()
register(LocationRepository.self, instance: coreLocationRepository)
Expand Down
4 changes: 1 addition & 3 deletions WhereAreYou/WhereAreYou/Data/FirestoreUserRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ final class FirestoreUserRepository: UserRepository {

let userData: [String: Any] = [
"nickname": nickname,
// TODO: Firebase Storage 전환 시 실제 URL로 교체
"profileImage": "basic",
"defaultTransportMode": "TRANSIT",
"locationSharingScope": "ONLY_DURING_APPOINTMENT",
Expand All @@ -47,8 +46,7 @@ final class FirestoreUserRepository: UserRepository {
return User(
id: userID,
nickname: nickname,
// TODO: Firebase Storage 전환 시 실제 URL로 교체
profileImage: URL(string: "basic")!,
profileImage: "basic",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand Down
16 changes: 6 additions & 10 deletions WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,6 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository {
private static let currentUserID = "user_me"
private static let mockCode = "MOCK1234"

private static func profileImageURL(_ assetName: String) -> URL {
URL(string: "asset://\(assetName)")!
}

private static func makeMockAppointment(appointmentID: String) -> Appointment {
let place = Place(
id: "place_starbucks_gangnam",
Expand All @@ -55,7 +51,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository {
User(
id: currentUserID,
nickname: "나",
profileImage: profileImageURL("shark"),
profileImage: "shark",
defaultTransportMode: .car,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -64,7 +60,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository {
User(
id: "user_minji",
nickname: "김민지",
profileImage: profileImageURL("turtle"),
profileImage: "turtle",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -73,7 +69,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository {
User(
id: "user_seoyeon",
nickname: "이서연",
profileImage: profileImageURL("shark"),
profileImage: "shark",
defaultTransportMode: .car,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -82,7 +78,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository {
User(
id: "user_junwoo",
nickname: "박준우",
profileImage: profileImageURL("turtle"),
profileImage: "turtle",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -91,7 +87,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository {
User(
id: "user_gildong",
nickname: "최길동",
profileImage: profileImageURL("shark"),
profileImage: "shark",
defaultTransportMode: .walk,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -100,7 +96,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository {
User(
id: "user_subin",
nickname: "정수빈",
profileImage: profileImageURL("turtle"),
profileImage: "turtle",
defaultTransportMode: .car,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ final class MockAppointmentInfoRepository: AppointmentInfoRepository {
private static let currentUser = User(
id: "me",
nickname: "나",
profileImage: URL(string: "otter")!,
profileImage: "otter",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -66,7 +66,7 @@ final class MockAppointmentInfoRepository: AppointmentInfoRepository {
private static let user2 = User(
id: "user2",
nickname: "김길동",
profileImage: URL(string: "shark")!,
profileImage: "shark",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -76,7 +76,7 @@ final class MockAppointmentInfoRepository: AppointmentInfoRepository {
private static let user3 = User(
id: "user3",
nickname: "홍길동",
profileImage: URL(string: "turtle")!,
profileImage: "turtle",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand Down
6 changes: 3 additions & 3 deletions WhereAreYou/WhereAreYou/Data/MockChatRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ final class MockChatRepository: ChatRepository {
private static let currentUser = User(
id: currentUserID,
nickname: "나",
profileImage: URL(string: "https://placeholder")!,
profileImage: "otter",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -76,7 +76,7 @@ final class MockChatRepository: ChatRepository {
private static let user2 = User(
id: "user2",
nickname: "김길동",
profileImage: URL(string: "shark")!,
profileImage: "shark",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -86,7 +86,7 @@ final class MockChatRepository: ChatRepository {
private static let user3 = User(
id: "user3",
nickname: "홍길동",
profileImage: URL(string: "turtle")!,
profileImage: "turtle",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand Down
6 changes: 3 additions & 3 deletions WhereAreYou/WhereAreYou/Data/MockSharedPlaceRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ final class MockSharedPlaceRepository: SharedPlaceRepository {
private static let currentUser = User(
id: currentUserID,
nickname: "나",
profileImage: URL(string: "https://placeholder")!,
profileImage: "otter",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -27,7 +27,7 @@ final class MockSharedPlaceRepository: SharedPlaceRepository {
private static let user2 = User(
id: "user2",
nickname: "김길동",
profileImage: URL(string: "shark")!,
profileImage: "shark",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand All @@ -37,7 +37,7 @@ final class MockSharedPlaceRepository: SharedPlaceRepository {
private static let user3 = User(
id: "user3",
nickname: "홍길동",
profileImage: URL(string: "turtle")!,
profileImage: "turtle",
defaultTransportMode: .transit,
locationSharingScope: .onlyDuringAppointment,
isNotificationEnabled: true,
Expand Down
62 changes: 62 additions & 0 deletions WhereAreYou/WhereAreYou/Data/StorageProfileImageRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// StorageProfileImageRepository.swift
// WhereAreYou
//
// Created by 이상유 on 2026-09-14.
//

import Foundation
import FirebaseStorage

/// Firebase Storage 기반 프로필 이미지 저장소
actor StorageProfileImageRepository: ProfileImageRepository {

private let storage = Storage.storage()
private let maxImageSize: Int64 = 1 * 1024 * 1024

private var cachedNames: [String]?
private var namesFetchTask: Task<[String], Error>?
private let dataCache = NSCache<NSString, NSData>()

func fetchAvailableImageNames() async throws -> [String] {
if let cached = cachedNames {
return cached
}

if let existing = namesFetchTask {
return try await existing.value
}

let storage = self.storage
let task = Task<[String], Error> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[HIGH] actor 내부에서 Task { } 를 직접 생성하면 해당 Task는 actor context 바깥의 unstructured task로 생성된다. 즉, namesFetchTask에 할당 직후 다른 caller가 fetchAvailableImageNames()를 동시에 호출하면, namesFetchTask가 아직 nil인 타이밍과 할당 완료 타이밍 사이에 여러 Task가 중복 생성될 수 있다. actor는 메서드 단위로 직렬화되므로 await 없이 연속 호출되는 경우 문제가 없지만, Task { } 내부 본문은 actor 격리 없이 실행되어 namesFetchTask, cachedNames 접근 시 actor 격리 보장이 실제로 유지되는지 재검토가 필요하다. 특히 task.value를 await하는 시점에 actor lock이 해제되므로, 그 사이에 다른 caller가 진입해 namesFetchTask가 nil인 상태를 볼 수 있다 — 이 경우 중복 네트워크 요청이 발생한다.

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.

Task 클로저 본문에서는 로컬 변수 storage만 캡처해 Firebase API 호출만 수행하며, cachedNames, namesFetchTask에 접근하지 않습니다. 해당 프로퍼티 변경은 Task 바깥의 본문에서 수행됩니다.

또한 Task 생성과 namesFetchTask = task 할당 사이에 suspension point가 없으므로, actor 직렬화에 의해 원자적으로 실행됩니다. 첫 suspension은 await task.value(40행)인데, 그 시점에는 이미 namesFetchTask가 설정되어 있어 후속 caller는 if let existing = namesFetchTask 분기를 타고 동일 Task를 공유합니다. 현재 구조에서 중복 네트워크 요청은 발생하지 않습니다.

    func fetchAvailableImageNames() async throws -> [String] {
        if let cached = cachedNames {
            return cached
        }

        if let existing = namesFetchTask {
            return try await existing.value
        }

        let storage = self.storage
        let task = Task<[String], Error> {
            let result = try await storage.reference().child("avatars").listAll()
            return result.items
                .map { ($0.name as NSString).deletingPathExtension }
                .sorted()
        }
        namesFetchTask = task

        do {
            let names = try await task.value
            cachedNames = names
            namesFetchTask = nil
            return names
        } catch {
            namesFetchTask = nil
            throw error
        }
    }

Comment thread
sangYuLv marked this conversation as resolved.
let result = try await storage.reference().child("avatars").listAll()
return result.items
.map { ($0.name as NSString).deletingPathExtension }
.sorted()
}
namesFetchTask = task

do {
let names = try await task.value
cachedNames = names
namesFetchTask = nil
return names
} catch {
namesFetchTask = nil
throw error
}
}

func downloadImageData(identifier: String) async throws -> Data {
let key = identifier as NSString
if let cached = dataCache.object(forKey: key) {
return cached as Data
}

let ref = storage.reference().child("avatars/\(identifier).png")
let data = try await ref.data(maxSize: maxImageSize)
dataCache.setObject(data as NSData, forKey: key)
return data
}

}
3 changes: 1 addition & 2 deletions WhereAreYou/WhereAreYou/Data/UserDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ struct UserDTO {
return User(
id: id,
nickname: nickname,
// TODO: Firebase Storage 전환 시 실제 URL로 교체
profileImage: URL(string: profileImage)!,
profileImage: profileImage,
defaultTransportMode: transportMode,
locationSharingScope: sharingScope,
isNotificationEnabled: isNotificationEnabled,
Expand Down
2 changes: 1 addition & 1 deletion WhereAreYou/WhereAreYou/Domain/Entity/User.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct User: Hashable {

let id: String
let nickname: String
let profileImage: URL
let profileImage: String
let defaultTransportMode: TransportType
let locationSharingScope: LocationSharingScope
let isNotificationEnabled: Bool
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// ProfileImageRepository.swift
// WhereAreYou
//
// Created by 이상유 on 2026-09-14.
//

import Foundation

/// 프로필 이미지 저장소 — 사용 가능한 이미지 목록 조회 및 이미지 데이터 다운로드
protocol ProfileImageRepository {

func fetchAvailableImageNames() async throws -> [String]

func downloadImageData(identifier: String) async throws -> Data

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ final class ParticipantBox: UIView {
init(member: Participant) {
super.init(frame: .zero)

let avatar = UIImageView(image: UIImage(named: member.profileImage))
let avatar = UIImageView()
avatar.setProfileImage(member.profileImage)
avatar.backgroundColor = .pointBackground
avatar.layer.cornerRadius = Self.avatarSize / 2
avatar.clipsToBounds = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ private extension ChatViewModel {
id: message.id,
senderID: message.sender.id,
senderNickname: message.sender.nickname,
senderProfileImage: message.sender.profileImageName,
senderProfileImage: message.sender.profileImage,
content: bubbleContent,
timeText: timeText,
sentAt: message.sentAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ final class SharedPlaceRow: UIView {

private func setUpVoterImages(_ images: [String]) {
for imageName in images {
let imageView = UIImageView(image: UIImage(named: imageName))
let imageView = UIImageView()
imageView.setProfileImage(imageName)
imageView.contentMode = .scaleAspectFit
imageView.layer.cornerRadius = Self.profileSize / 2
imageView.clipsToBounds = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ final class OtherChatBubble: ChatBubbleBase {
}

private func makeAvatarView(imageName: String) -> UIImageView {
let imageView = UIImageView(image: UIImage(named: imageName))
let imageView = UIImageView()
imageView.setProfileImage(imageName)
imageView.contentMode = .scaleAspectFit
imageView.layer.cornerRadius = Self.avatarSize / 2
imageView.clipsToBounds = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// ProfileImageLoader.swift
// WhereAreYou
//
// Created by 이상유 on 2026-09-13.
//

import UIKit

/// 프로필 이미지를 로드해 UIImage로 변환하는 싱글턴 로더
final class ProfileImageLoader {

static let shared = ProfileImageLoader()

private let repository: ProfileImageRepository

private init() {
repository = DIContainer.shared.resolve(ProfileImageRepository.self)
}

func fetchAvailableImageNames() async throws -> [String] {
try await repository.fetchAvailableImageNames()
}

func load(identifier: String) async -> UIImage {
do {
let data = try await repository.downloadImageData(identifier: identifier)
return UIImage(data: data) ?? Self.failureImage
} catch {
return Self.failureImage
}
}

// MARK: - 실패 이미지

static let failureImage: UIImage = {
let config = UIImage.SymbolConfiguration(pointSize: 40, weight: .light)
let symbol = UIImage(systemName: "questionmark.circle", withConfiguration: config)
return symbol?.withTintColor(.secondaryLabel, renderingMode: .alwaysOriginal)
?? UIImage()
}()

}
Loading
Loading