Skip to content

bookmarkfeature 모듈을 패키지로 구성합니다.#335

Merged
dongglehada merged 6 commits into
devfrom
feat/#333-bookmarkfeature
Jun 6, 2026
Merged

bookmarkfeature 모듈을 패키지로 구성합니다.#335
dongglehada merged 6 commits into
devfrom
feat/#333-bookmarkfeature

Conversation

@dongglehada

Copy link
Copy Markdown
Member

📌 이슈

✅ 작업 사항

1. MLSBookmarkFeature SPM 패키지 생성

북마크 관련 코드를 독립 SPM 패키지로 분리하고 4개의 타깃으로 구성했습니다.

MLSBookmarkFeatureInterface  ← 외부 공개 계약 (프로토콜, 엔티티)
MLSBookmarkFeature           ← Presentation + Domain + Data 구현체
MLSBookmarkFeatureTesting    ← Mock / Stub 객체 모음
MLSBookmarkFeatureTests      ← 단위 테스트

2. 모듈 구조 — Interface 타깃으로 계약 통합

외부 소비자(본앱 등)가 알아야 하는 모든 것을 MLSBookmarkFeatureInterface로 통합했습니다.

MLSBookmarkFeatureInterface
├── Entities/       (BookmarkResponse, CollectionResponse, DictionaryType, SortType ...)
├── Repositories/   (BookmarkRepository, CollectionRepository, BookmarkAuthRepository ...)
├── UseCases/       (ParseItemFilterResultUseCase)
└── Factories/      (BookmarkMainFactory, BookmarkListFactory, CollectionListFactory ...)

3. Presentation — 전체 화면 구성

화면 설명
BookmarkMain 북마크 탭 진입점 — 온보딩 / 북마크 리스트 / 컬렉션 리스트 탭 구성
BookmarkList 전체 / 아이템 / 몬스터 탭 별 북마크 목록
CollectionList 컬렉션 목록
CollectionDetail 컬렉션 내 북마크 목록
CollectionEdit 컬렉션 내 북마크 편집
CollectionSetting 컬렉션 이름 변경 / 삭제 바텀시트
BookmarkModal 북마크 추가 / 컬렉션 담기 바텀시트
AddCollection 컬렉션 생성 바텀시트
BookmarkOnBoarding 첫 진입 온보딩 화면

4. Data 레이어 — API 연동 구조 세팅

엔드포인트 및 Repository 구현체를 패키지 내부에 배치했습니다. 본앱 연동 시 실제 NetworkProvider를 주입받아 동작합니다.

엔드포인트 설명
BookmarkEndPoint 북마크 조회 / 추가 / 삭제
CollectionEndPoint 컬렉션 CRUD

5. MLSBookmarkFeatureTesting — Mock / Stub 모듈화

테스트 및 Example 앱에서 재사용 가능하도록 모든 Mock과 Stub을 MLSBookmarkFeatureTesting 타깃으로 통합했습니다.

타입 역할
MockBookmarkRepository 북마크 조회 / 추가 / 삭제 결과 주입
MockCollectionRepository 컬렉션 CRUD 결과 주입
MockBookmarkAuthRepository 로그인 상태 주입
MockBookmarkUserDefaultsRepository 온보딩 완료 여부 주입
MockParseItemFilterResultUseCase 필터 파싱 결과 주입
StubDictionaryDetailFactory 외부 도감 상세 화면 스텁
StubSortedBottomSheetFactory 정렬 바텀시트 스텁
StubItemFilterBottomSheetFactory 아이템 필터 스텁
StubMonsterFilterBottomSheetFactory 몬스터 필터 스텁
StubLoginFactory 로그인 화면 스텁

6. 단위 테스트 작성

비즈니스 로직이 있는 Reactor 레이어에 집중해 테스트를 작성했습니다.

테스트 파일 주요 케이스
BookmarkListReactorTests 로그인 상태에서 목록 조회, 비로그인 시 빈 목록, 북마크 토글 시 삭제 이벤트 방출

7. MLSCore — DictionaryTabControllable 추가

BookmarkMain에서 탭바 탭 전환을 제어하기 위해 MLSCore에 프로토콜과 레지스트리를 추가했습니다.

public protocol DictionaryTabControllable: AnyObject {
    func changeTab(index: Int)
}
public enum DictionaryTabRegistry {
    public static func register(controller: DictionaryTabControllable) { ... }
    public static func changeTab(index: Int) { ... }
}

8. MLSBookmarkFeatureExample 앱 구성

개별 화면을 독립적으로 확인할 수 있도록 Example 앱을 구성했습니다.

  • SceneDelegate에서 DIContainer를 통해 모든 Factory 등록 (Mock 주입)
  • MenuViewController — 9개 화면으로 바로 진입 가능한 메뉴 리스트
  • LaunchScreen.storyboard 추가 — 풀스크린 적용
메뉴 항목 비고
BookmarkMain bottomInset: 0으로 풀스크린
BookmarkList (전체 / 아이템 / 몬스터) 탭 별 개별 진입
CollectionList -
CollectionDetail Stub 컬렉션 데이터 주입
BookmarkModal Sheet 방식 표시
AddCollection -
BookmarkOnBoarding -

스크린샷

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-05-29.at.14.59.12.mov

@dongglehada dongglehada requested a review from pinocchio22 May 29, 2026 05:59
@dongglehada dongglehada self-assigned this May 29, 2026
@dongglehada dongglehada added refactor 프로덕션 코드 리팩토링, 파일 삭제, 네이밍 수정 및 폴더링 test 테스트 코드 추가 labels May 29, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the MLSBookmarkFeature module, implementing comprehensive bookmarking and collection management capabilities, including DTOs, endpoints, repositories, reactors, and view controllers. The code review identifies several critical issues and areas for improvement: potential data races and background thread UI updates during image loading, a lack of error handling in RxSwift streams that could lead to unexpected stream termination, a side effect in the onboarding query repository, and architectural improvements regarding navigation logic and hardcoded indices.

Comment on lines +14 to +32
func isLoggedIn() -> Observable<Bool> {
switch tokenRepository.fetchToken(type: .refreshToken) {
case .success(let token):
guard !token.isEmpty else { return .just(false) }
return authAPIRepository.reissueToken(refreshToken: token)
.map { [weak self] response -> Bool in
guard let self else { return false }
let accessResult = self.tokenRepository.saveToken(type: .accessToken, value: response.accessToken)
let refreshResult = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken)
switch (accessResult, refreshResult) {
case (.success, .success): return true
default: return false
}
}
.catch { _ in .just(false) }
case .failure:
return .just(false)
}
}

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

isLoggedIn() 메서드가 호출될 때마다 매번 reissueToken 네트워크 요청을 보내고 토큰을 갱신하고 있습니다. 이 메서드는 viewWillAppear 등 화면이 나타날 때마다 빈번하게 호출되는데, 매번 네트워크 요청을 보내는 것은 서버 부하를 가중시키고 불필요한 리소스를 소모하게 됩니다.

일반적으로 isLoggedIn()은 로컬에 유효한 토큰(또는 만료되지 않은 토큰)이 존재하는지만 가볍게 확인하고, 토큰 만료 시 재발급(Reissue)하는 로직은 API 요청 시 TokenInterceptor 등에서 공통으로 처리하도록 분리하는 것이 효율적입니다.

    func isLoggedIn() -> Observable<Bool> {
        switch tokenRepository.fetchToken(type: .accessToken) {
        case .success(let token):
            return .just(!token.isEmpty)
        case .failure:
            return .just(false)
        }
    }

Comment on lines +183 to +211
switch currentState.type {
case .total:
return bookmarkRepository.fetchBookmark(sort: resolvedSort).map { .setItems($0) }
case .monster:
return bookmarkRepository.fetchMonsterBookmark(
minLevel: currentState.startLevel ?? 1,
maxLevel: currentState.endLevel ?? 200,
sort: resolvedSort
).map { .setItems($0) }
case .item:
return bookmarkRepository.fetchItemBookmark(
jobId: nil,
minLevel: currentState.startLevel,
maxLevel: currentState.endLevel,
categoryIds: nil,
sort: resolvedSort
).map { .setItems($0) }
case .npc:
return bookmarkRepository.fetchNPCBookmark(sort: resolvedSort).map { .setItems($0) }
case .quest:
return bookmarkRepository.fetchQuestBookmark(sort: resolvedSort).map { .setItems($0) }
case .map:
return bookmarkRepository.fetchMapBookmark(sort: resolvedSort).map { .setItems($0) }
default:
return .empty()
}
}

func handleToggle(id: Int) -> Observable<Mutation> {

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

RxSwift에서 API 요청 등의 에러가 발생했을 때 적절히 처리하지 않으면, 에러가 스트림을 타고 올라가 전체 Reactor의 구독(Subscription)이 해제(Dispose)되어 화면이 더 이상 동작하지 않게 됩니다. fetchList() 메서드 내의 각 API 요청 Observable에 .catch 또는 .catchAndReturn 등을 추가하여 에러 발생 시에도 스트림이 유지되도록 에러 핸들링을 추가해야 합니다.

    func fetchList(sort: SortType? = nil) -> Observable<Mutation> {
        let resolvedSort = (sort ?? currentState.sort)?.sortParameter
        let fetchObservable: Observable<[BookmarkResponse]>
        switch currentState.type {
        case .total:
            fetchObservable = bookmarkRepository.fetchBookmark(sort: resolvedSort)
        case .monster:
            fetchObservable = bookmarkRepository.fetchMonsterBookmark(
                minLevel: currentState.startLevel ?? 1,
                maxLevel: currentState.endLevel ?? 200,
                sort: resolvedSort
            )
        case .item:
            fetchObservable = bookmarkRepository.fetchItemBookmark(
                jobId: nil,
                minLevel: currentState.startLevel,
                maxLevel: currentState.endLevel,
                categoryIds: nil,
                sort: resolvedSort
            )
        case .npc:
            fetchObservable = bookmarkRepository.fetchNPCBookmark(sort: resolvedSort)
        case .quest:
            fetchObservable = bookmarkRepository.fetchQuestBookmark(sort: resolvedSort)
        case .map:
            fetchObservable = bookmarkRepository.fetchMapBookmark(sort: resolvedSort)
        default:
            return .empty()
        }
        return fetchObservable
            .map { Mutation.setItems($0) }
            .catch { _ in .just(.navigateTo(.bookmarkError)) }
    }

Comment on lines +37 to +50
private func loadImages(from urls: [String?], completion: @escaping ([UIImage?]) -> Void) {
var results = [UIImage?](repeating: nil, count: urls.count)
let group = DispatchGroup()
for (index, urlString) in urls.enumerated() {
group.enter()
ImageLoader.shared.loadImage(stringURL: urlString) { image in
results[index] = image
group.leave()
}
}
group.notify(queue: .main) {
completion(results)
}
}

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

loadImages 메서드에서 비동기 클로저 내부의 results[index] = image 코드는 데이터 레이스(Data Race)를 유발할 수 있습니다. ImageLoader가 이미지를 백그라운드 스레드에서 로드하고 컴플리션을 호출하는 경우, 여러 스레드에서 동시에 results 배열의 서로 다른 인덱스에 접근하여 쓰기 작업을 수행하게 되므로 크래시나 메모리 오염이 발생할 수 있습니다.

이를 방지하기 위해 배열에 값을 쓰는 작업을 메인 스레드(DispatchQueue.main.async)에서 수행하도록 동기화해야 합니다.

Suggested change
private func loadImages(from urls: [String?], completion: @escaping ([UIImage?]) -> Void) {
var results = [UIImage?](repeating: nil, count: urls.count)
let group = DispatchGroup()
for (index, urlString) in urls.enumerated() {
group.enter()
ImageLoader.shared.loadImage(stringURL: urlString) { image in
results[index] = image
group.leave()
}
}
group.notify(queue: .main) {
completion(results)
}
}
private func loadImages(from urls: [String?], completion: @escaping ([UIImage?]) -> Void) {
var results = [UIImage?](repeating: nil, count: urls.count)
let group = DispatchGroup()
for (index, urlString) in urls.enumerated() {
group.enter()
ImageLoader.shared.loadImage(stringURL: urlString) { image in
DispatchQueue.main.async {
results[index] = image
group.leave()
}
}
}
group.notify(queue: .main) {
completion(results)
}
}

Comment on lines +53 to +64
if let url = URL(string: input.imageUrl) {
ImageLoader.shared.loadImage(url: url) { [weak self] image in
guard let self else { return }
if let currentIndex = collectionView.indexPath(for: self), currentIndex == indexPath {
if isMap {
self.cellView.setMapImage(image: image ?? UIImage(), backgroundColor: input.type.backgroundColor)
} else {
self.cellView.setImage(image: image ?? UIImage(), backgroundColor: input.type.backgroundColor)
}
}
}
}

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

ImageLoader.shared.loadImage는 컴플리션 핸들러를 메인 스레드에서 호출하는 것을 보장하지 않을 수 있습니다. 실제로 다른 코드에서는 DispatchQueue.main.async로 감싸서 UI를 업데이트하고 있는 반면, 여기서는 백그라운드 스레드에서 직접 UIKit 컴포넌트(cellView)를 업데이트할 위험이 있습니다.

UI 업데이트 및 collectionView.indexPath(for:) 호출을 DispatchQueue.main.async 블록 내부에서 수행하도록 수정해야 합니다.

Suggested change
if let url = URL(string: input.imageUrl) {
ImageLoader.shared.loadImage(url: url) { [weak self] image in
guard let self else { return }
if let currentIndex = collectionView.indexPath(for: self), currentIndex == indexPath {
if isMap {
self.cellView.setMapImage(image: image ?? UIImage(), backgroundColor: input.type.backgroundColor)
} else {
self.cellView.setImage(image: image ?? UIImage(), backgroundColor: input.type.backgroundColor)
}
}
}
}
if let url = URL(string: input.imageUrl) {
ImageLoader.shared.loadImage(url: url) { [weak self] image in
DispatchQueue.main.async {
guard let self else { return }
if let currentIndex = collectionView.indexPath(for: self), currentIndex == indexPath {
if isMap {
self.cellView.setMapImage(image: image ?? UIImage(), backgroundColor: input.type.backgroundColor)
} else {
self.cellView.setImage(image: image ?? UIImage(), backgroundColor: input.type.backgroundColor)
}
}
}
}
}

Comment on lines +8 to +15
func hasVisitedOnboarding() -> Observable<Bool> {
let hasVisited = UserDefaults.standard.bool(forKey: key)
if !hasVisited {
UserDefaults.standard.set(true, forKey: key)
return .just(false)
}
return .just(true)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

hasVisitedOnboarding() 메서드 내부에서 값을 조회함과 동시에 UserDefaults에 true를 저장하는 부작용(Side Effect)이 발생하고 있습니다. 이로 인해 사용자가 온보딩 화면을 완전히 확인하지 않고 앱을 강제 종료하더라도 다음 진입 시 온보딩이 다시 표시되지 않는 문제가 발생할 수 있습니다.

조회(Query)와 명령(Command)을 분리하여, hasVisitedOnboarding()은 단순히 값만 반환하고, 실제 온보딩을 완료하거나 건너뛰었을 때 markOnboardingVisited()를 호출하여 값을 저장하도록 수정하는 것이 안전합니다.

    func hasVisitedOnboarding() -> Observable<Bool> {
        let hasVisited = UserDefaults.standard.bool(forKey: key)
        return .just(hasVisited)
    }

Comment on lines +43 to +45
case .viewWillAppear:
return collectionRepository.fetchCollectionList(sort: currentState.selectedSort?.sortParameter)
.map { .setListData($0) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

collectionRepository.fetchCollectionList API 요청 시 에러가 발생하면 스트림이 종료되어 화면이 먹통이 될 수 있습니다. .catch 블록을 추가하여 에러 발생 시에도 스트림이 유지되도록 방어 코드를 작성하는 것이 좋습니다.

Suggested change
case .viewWillAppear:
return collectionRepository.fetchCollectionList(sort: currentState.selectedSort?.sortParameter)
.map { .setListData($0) }
case .viewWillAppear:
return collectionRepository.fetchCollectionList(sort: currentState.selectedSort?.sortParameter)
.map { .setListData($0) }
.catch { _ in .empty() }

Comment on lines +53 to +55
case .viewWillAppear:
return collectionRepository.fetchCollection(id: currentState.collection.collectionId)
.map { .setItems($0) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

collectionRepository.fetchCollection API 요청 시 에러가 발생하면 스트림이 종료되어 화면이 먹통이 될 수 있습니다. .catch 블록을 추가하여 에러 발생 시에도 스트림이 유지되도록 방어 코드를 작성하는 것이 좋습니다.

Suggested change
case .viewWillAppear:
return collectionRepository.fetchCollection(id: currentState.collection.collectionId)
.map { .setItems($0) }
case .viewWillAppear:
return collectionRepository.fetchCollection(id: currentState.collection.collectionId)
.map { .setItems($0) }
.catch { _ in .empty() }

Comment on lines +44 to +55
reactor.pulse(\.$route)
.observe(on: MainScheduler.instance)
.bind(onNext: { [weak viewController] route in
switch route {
case .toMain:
onMoveToMain?()
viewController?.navigationController?.popToRootViewController(animated: true)
default:
break
}
})
.disposed(by: viewController.disposeBag)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

현재 .toMain 경로로의 화면 전환 로직이 CollectionDetailFactoryImpl 내부에서 처리되고 있으며, 다른 경로(.dismiss, .edit 등)는 CollectionDetailViewController 내부에서 처리되고 있어 내비게이션 로직이 분산되어 있습니다.

일관성 있는 유지보수를 위해 모든 화면 전환 로직을 CollectionDetailViewController 내부로 통합하거나, Coordinator 패턴 등을 도입하여 한 곳에서 관리하는 것이 좋습니다. 만약 팩토리에서 클로저를 전달받아야 한다면, CollectionDetailViewController에 클로저를 주입하여 컨트롤러 내부에서 호출하도록 수정하는 것을 권장합니다.

Comment on lines +41 to +47
self.viewControllers = type.pageTabList.enumerated().map { index, tabType in
if index == 1 {
return collectionListFactory.make()
} else {
return bookmarkListFactory.make(type: tabType, listType: type)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

index == 1과 같이 하드코딩된 인덱스를 기준으로 CollectionList 뷰 컨트롤러를 생성하고 있습니다. 만약 향후 탭의 순서가 변경되거나 새로운 탭이 추가되는 경우 버그가 발생하기 쉽습니다.

index 대신 tabType == .collection과 같이 실제 탭 타입을 기준으로 분기 처리하는 것이 훨씬 안전하고 직관적입니다.

Suggested change
self.viewControllers = type.pageTabList.enumerated().map { index, tabType in
if index == 1 {
return collectionListFactory.make()
} else {
return bookmarkListFactory.make(type: tabType, listType: type)
}
}
self.viewControllers = type.pageTabList.map { tabType in
if tabType == .collection {
return collectionListFactory.make()
} else {
return bookmarkListFactory.make(type: tabType, listType: type)
}
}

@pinocchio22

Copy link
Copy Markdown
Contributor

고생하셨습니다!! 제미나이 리뷰 한번 쫙 확인해주시고 DictionaryTabControllable 관련하여 제 모듈 분리 작업과 중복 사항 있을 수 있어보입니다. 해당 부분만 병합 후에 하나로 통일 시키면 좋을 것 같아요 :)
제미나이 리뷰 중 필요한 부분은 수정 부탁드리고 해당없는 부분은 resolve만 한 뒤 병합해도 좋을 것 같습니다!!

@dongglehada dongglehada merged commit 998e697 into dev Jun 6, 2026
2 checks passed
@dongglehada dongglehada deleted the feat/#333-bookmarkfeature branch June 6, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor 프로덕션 코드 리팩토링, 파일 삭제, 네이밍 수정 및 폴더링 test 테스트 코드 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants