Skip to content

[Feat] 청년 공고 목록/찜 화면 구현 및 서버 연동 - #94

Merged
taipaise merged 1 commit into
developfrom
feat/notice
Aug 2, 2026
Merged

[Feat] 청년 공고 목록/찜 화면 구현 및 서버 연동#94
taipaise merged 1 commit into
developfrom
feat/notice

Conversation

@taipaise

@taipaise taipaise commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

🌁 Background

청년 공고 목록 화면과 찜한 공고 화면을 구현하고 서버 연동을 진행했어요 ~

  • 현위치 기반 청년 공고 목록 조회
  • 찜한 공고 목록 조회
  • 공고 찜 등록 / 해제

📱 Screenshot

iPhone 13 mini iPhone 16 iPhone 16 Pro
13mini 16 16pro

👩‍💻 Contents

  • 청년 공고 화면 YouthPolicyViewController / YouthPolicyViewModel 구현
  • 전체 / 찜한 공고 탭 구성 (YouthPolicyTab) 및 전체 탭 공고 개수 노출
  • 공고 목록 셀 YouthPolicyTableViewCell, 마감 상태 뱃지 YouthPolicyBadgeView 구현
  • 공고가 없을 때의 빈 화면 YouthPolicyEmptyView 구현
  • 커서 기반 페이지네이션 및 찜 등록/해제 서버 연동
  • 청년 공고 도메인/데이터 레이어 구현 (YouthPolicyUseCase, YouthPolicyRepository, YouthPolicyEndpoint, DTO/Entity)
  • 찜 아이콘 에셋 추가 (heart_empty_icon, heart_filled_icon)

📝 Review Note

1. 탭별 상태 분리와 탭 간 동기화 (isStale)

전체, 찜한 공고 두 탭을 각각 TabState로 분리해서 목록/커서/로딩 상태를 따로 들고 있어요.
탭을 옮길 때마다 API를 다시 부르지 않도록, 한 번 받아온 탭은 캐시된 상태를 그대로 보여줍니다.

다만 한 탭에서 찜을 바꾸면 다른 탭의 목록이 최신화되지 않는 문제가 있어서, isStale 플래그로 표시해두고
해당 탭에 다시 진입할 때 (!hasLoadedOnce || isStale) 다시 받아오도록 했습니다.

if !state.hasLoadedOnce || state.isStale {
    fetchFirstPage(tab: tab)
}

2. 찜 낙관적 업데이트(Optimistic Update)와 실패 롤백

찜 버튼을 누르면 서버 응답을 기다리지 않고 UI에 먼저 반영한 뒤,
요청이 실패하면 변경 전 상태로 되돌리도록 했어요.

이때 찜 등록은 찜 목록의 정렬 순서를 서버가 정하기 때문에 직접 목록에 끼워넣지 않고
isStale로만 표시한 뒤 다음 진입 시 다시 받아오고, 찜 해제는 순서와 무관하니 즉시 목록에서 제거했습니다.

if isBookmarked {
    // 찜 목록의 정렬 순서는 서버가 정하므로 직접 끼워넣지 않고 다음 진입 시 다시 받아옵니다.
    bookmarkedState.isStale = true
} else {
    bookmarkedState.items.removeAll { $0.policyNumber == policyNumber }
    bookmarkedState.totalCount = max(0, bookmarkedState.totalCount - 1)
}

혹시 낙관적 업데이트보다 서버 응답 후 반영하는 게 낫다고 생각하시면 말씀해주세요 !!

Summary by CodeRabbit

  • 새 기능

    • 청년 정책 목록과 찜한 정책 목록을 탭으로 확인할 수 있습니다.
    • 지역 기반 정책 조회와 페이지 추가 로딩을 지원합니다.
    • 정책의 상태, 신청 기간, D-day, 신청 링크를 확인할 수 있습니다.
    • 정책을 찜하거나 찜을 해제할 수 있습니다.
    • 정책이 없을 때 안내 화면을 제공합니다.
    • 활동 내역에서 청년 정책 화면으로 이동할 수 있습니다.
  • 개선

    • 네트워크 오류 발생 시 재시도할 수 있습니다.
    • 찜 변경 실패 시 기존 상태로 복원됩니다.

- 위경도 기반 청년 공고 조회, 찜한 공고 조회 API 연동
- 커서 페이지네이션, 찜 등록/해제(낙관적 업데이트) 구현
- 활동 기록 화면의 '더보기'에서 청년 공고 화면으로 진입
- stash 적용 중 발생한 충돌 및 유령 파일 정리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

청년 정책 조회와 북마크 기능을 추가했습니다. 도메인 계약과 API 연동을 구성했습니다. 커서 기반 페이지네이션과 낙관적 북마크 갱신을 구현했습니다. 탭, 목록, 배지, 빈 상태를 포함한 정책 화면을 추가했습니다.

Changes

청년 정책 기능

Layer / File(s) Summary
도메인 계약과 유스케이스
Projects/Domain/Sources/Entity/*, Projects/Domain/Sources/Protocol/Repository/*, Projects/Domain/Sources/Protocol/UseCase/*, Projects/Domain/Sources/UseCase/*, Projects/Domain/Sources/DomainDependencyAssembler.swift
정책 엔티티와 페이지 엔티티를 추가했습니다. 저장소와 유스케이스 계약을 정의했습니다. 위치 기반 조회와 북마크 상태 변경을 구현했습니다.
정책 API와 저장소 구현
Projects/DataSource/Sources/DTO/YouthPolicyDTO.swift, Projects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swift, Projects/DataSource/Sources/Repository/YouthPolicyRepository.swift, Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift
API 요청과 응답 DTO를 추가했습니다. 날짜와 상태를 변환합니다. 네트워크 오류를 도메인 오류로 매핑합니다.
정책 상태와 화면 모델
Projects/Presentation/Sources/YouthPolicy/Model/*, Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift
정책 배지, 기간, 탭 모델을 추가했습니다. 탭별 목록과 커서를 관리합니다. 페이지 추가 조회와 북마크 낙관적 갱신 및 복구를 처리합니다.
정책 화면과 진입 경로
Projects/Presentation/Sources/YouthPolicy/View/*, Projects/Presentation/Sources/YouthPolicy/View/Component/*, Projects/Presentation/Sources/ActivityHistory/View/ActivityHistoryViewController.swift, Projects/Presentation/Sources/Common/*, Projects/Presentation/Resources/Images.xcassets/Common/*
탭, 정책 목록, 빈 상태, 배지, 북마크 셀을 구성했습니다. 신청 URL 열기와 다음 페이지 요청을 연결했습니다. 활동 내역 화면에서 정책 화면으로 이동하도록 구성했습니다.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  actor 사용자
  participant ActivityHistoryViewController
  participant YouthPolicyViewController
  participant YouthPolicyViewModel
  participant YouthPolicyUseCase
  사용자->>ActivityHistoryViewController: 청년 정책 버튼 탭
  ActivityHistoryViewController->>YouthPolicyViewController: 화면 푸시
  YouthPolicyViewController->>YouthPolicyViewModel: 정책 조회 요청
  YouthPolicyViewModel->>YouthPolicyUseCase: 위치 기반 정책 조회
  YouthPolicyUseCase-->>YouthPolicyViewModel: 정책 페이지 반환
  YouthPolicyViewModel-->>YouthPolicyViewController: 정책 목록 발행
  YouthPolicyViewController-->>사용자: 정책 목록 표시
Loading

Possibly related issues

Possibly related PRs

  • YAPP-Github/Bitnagil-iOS#66: DataSourceDependencyAssembler.swift의 저장소 DI 등록과 LocationRepository 의존성 연결이 관련됩니다.

Poem

깡충 뛰는 토끼가 새 정책을 보네
커서 따라 목록이 차곡차곡 오네
빈 하트는 톡, 찬 하트는 반짝
탭마다 공고가 또렷이 깜빡
당근처럼 단정한 화면이 열렸네 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 청년 공고 목록과 찜 화면 구현 및 서버 연동이라는 PR의 주요 변경 사항을 명확하게 요약합니다.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/notice

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🧹 Nitpick comments (4)
Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift (1)

116-119: 📐 Maintainability & Code Quality | 🔵 Trivial

섹션 주석이 실제 내용과 일치하지 않습니다.

heartEmptyIconheartFilledIcon은 청년 공고 찜(북마크) 기능에 사용되는 아이콘입니다. 이 두 아이콘을 "// MARK: - Notice" 섹션 아래에 추가했습니다. 이 이름은 알림 기능을 가리키는 것으로 읽힙니다. 섹션 이름을 실제 용도(예: "// MARK: - Youth Policy" 또는 "// MARK: - Bookmark")에 맞게 바꾸십시오.

✏️ 제안하는 수정
-    // MARK: - Notice
+    // MARK: - Youth Policy
     static let heartEmptyIcon = UIImage(named: "heart_empty_icon", in: bundle, with: nil)
     static let heartFilledIcon = UIImage(named: "heart_filled_icon", in: bundle, with: nil)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift` around
lines 116 - 119, Rename the section marker above heartEmptyIcon and
heartFilledIcon from “Notice” to a label that reflects their youth-policy
bookmark purpose, such as “Bookmark” or “Youth Policy,” without changing the
icon declarations.
Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyBadge.swift (1)

20-31: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

서버 명세 위반 시 항목이 조용히 사라짐.

status.open인데 ddaynil이면 init?nil을 반환합니다. 이 nilYouthPolicyItem.init?(entity:)(YouthPolicyItem.swift, 라인 21-23)로 전파되어, 해당 공고 항목이 목록에서 아무 흔적 없이 사라집니다. 주석에 서버 명세상 항상 dday가 내려온다고 명시되어 있지만, 명세가 깨졌을 때 디버깅이 어려워집니다.

nil 반환 전에 로그를 남기거나 assertionFailure를 추가해, 명세 위반이 발생했을 때 조기에 파악할 수 있게 하는 것을 권장합니다.

♻️ 제안하는 수정
         case .open:
             // 서버 명세상 status가 open이면 dday는 항상 내려옵니다.
-            guard let dday else { return nil }
+            guard let dday else {
+                assertionFailure("open 상태의 정책에 dday가 없습니다. 서버 명세를 확인하세요.")
+                return nil
+            }
             self = .dday(dday)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyBadge.swift`
around lines 20 - 31, Update the .open branch of
YouthPolicyBadge.init(status:dday:) to record an assertionFailure or diagnostic
log before returning nil when dday is missing, while preserving the existing
optional-initializer behavior and .dday path for valid values.
Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTableViewCell.swift (1)

70-75: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

북마크 버튼에 접근성 라벨이 없습니다.

bookmarkButton은 이미지만 표시하는 버튼입니다. accessibilityLabel이 없으면 VoiceOver 사용자가 버튼의 용도를 알 수 없습니다. 북마크 상태에 따라 라벨을 갱신하는 것을 권장합니다.

♻️ 제안하는 수정
         bookmarkButton.setImage(
             item.isBookmarked ? BitnagilIcon.heartFilledIcon : BitnagilIcon.heartEmptyIcon,
             for: .normal)
+        bookmarkButton.accessibilityLabel = item.isBookmarked ? "찜 해제" : "찜하기"

Also applies to: 144-157

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTableViewCell.swift`
around lines 70 - 75, Set an accessibility label for bookmarkButton in the cell
configuration and update it whenever the bookmark state changes, including the
corresponding logic near the bookmark action and the additional state-update
block. Use labels that clearly describe the button’s bookmark purpose and
current state for VoiceOver users.
Projects/Presentation/Sources/YouthPolicy/View/YouthPolicyViewController.swift (1)

197-212: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

사용하지 않는 [weak self] 캡처.

바깥쪽 클로저(라인 197)에 [weak self]가 선언되어 있지만, 본문에서 self를 직접 사용하지 않습니다. cell.configure에 전달하는 내부 클로저(라인 203-205)가 별도로 [weak self]를 캡처합니다. 바깥쪽 캡처를 제거해도 동작에 변화가 없습니다.

♻️ 제안하는 수정
-        policyDataSource = UITableViewDiffableDataSource<PolicySection, YouthPolicyItem>(tableView: policyTableView) { [weak self] tableView, indexPath, item in
+        policyDataSource = UITableViewDiffableDataSource<PolicySection, YouthPolicyItem>(tableView: policyTableView) { tableView, indexPath, item in
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Projects/Presentation/Sources/YouthPolicy/View/YouthPolicyViewController.swift`
around lines 197 - 212, Remove the unused [weak self] capture from the
UITableViewDiffableDataSource cell-provider closure initializing
policyDataSource; keep the inner cell.configure callback’s [weak self] capture
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Projects/DataSource/Sources/DTO/YouthPolicyDTO.swift`:
- Around line 31-61: Update toYouthPolicyPageEntity so policies removed by
toYouthPolicyEntity for unknown statuses are not silently ignored: either log
each dropped DTO or adjust totalCount by the number of discarded items, ensuring
the displayed count and pagination metadata remain consistent with rendered
policies.

In `@Projects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swift`:
- Around line 24-27: Update the addBookmark/removeBookmark URL construction in
the endpoint’s request-path switch to percent-encode policyNumber with an
appropriate URL path-safe character set before interpolating it into the path,
preserving the existing bookmark endpoint structure.

In
`@Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift`:
- Around line 187-213: Update toggleBookmark so failed bookmark requests roll
back only the targeted policy item's isBookmarked value instead of restoring
previousEntireState and previousBookmarkedState snapshots. Preserve state
changes from concurrent fetchFirstPage or fetchNextPage operations, then publish
the corrected item state through the existing sendTabs and sendPolicies flow.
- Around line 121-130: Update the fetchFirstPage error path around
networkRetryHandler so a failed first-page request does not set
state.hasLoadedOnce = true; only mark hasLoadedOnce after a successful response,
preserving networkErrorPublisher retry behavior without emitting
isEmptyPublisher for the failed empty list.
- Around line 104-132: Update the Task closures in fetchFirstPage,
fetchNextPage, and toggleBookmark to use MainActor isolation while retaining the
existing Task-based flow. This ensures reads and writes to
entireState/bookmarkedState and calls to tabsSubject/policiesSubject.send()
execute on the main actor.

---

Nitpick comments:
In `@Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift`:
- Around line 116-119: Rename the section marker above heartEmptyIcon and
heartFilledIcon from “Notice” to a label that reflects their youth-policy
bookmark purpose, such as “Bookmark” or “Youth Policy,” without changing the
icon declarations.

In `@Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyBadge.swift`:
- Around line 20-31: Update the .open branch of
YouthPolicyBadge.init(status:dday:) to record an assertionFailure or diagnostic
log before returning nil when dday is missing, while preserving the existing
optional-initializer behavior and .dday path for valid values.

In
`@Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTableViewCell.swift`:
- Around line 70-75: Set an accessibility label for bookmarkButton in the cell
configuration and update it whenever the bookmark state changes, including the
corresponding logic near the bookmark action and the additional state-update
block. Use labels that clearly describe the button’s bookmark purpose and
current state for VoiceOver users.

In
`@Projects/Presentation/Sources/YouthPolicy/View/YouthPolicyViewController.swift`:
- Around line 197-212: Remove the unused [weak self] capture from the
UITableViewDiffableDataSource cell-provider closure initializing
policyDataSource; keep the inner cell.configure callback’s [weak self] capture
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5bf3608e-c5ba-4c33-9dc5-99e05b9c00b9

📥 Commits

Reviewing files that changed from the base of the PR and between 2d72a75 and 889a4aa.

⛔ Files ignored due to path filters (6)
  • Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon.png is excluded by !**/*.png
  • Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@2x.png is excluded by !**/*.png
  • Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@3x.png is excluded by !**/*.png
  • Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon.png is excluded by !**/*.png
  • Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@2x.png is excluded by !**/*.png
  • Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@3x.png is excluded by !**/*.png
📒 Files selected for processing (26)
  • Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift
  • Projects/DataSource/Sources/DTO/YouthPolicyDTO.swift
  • Projects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swift
  • Projects/DataSource/Sources/Repository/YouthPolicyRepository.swift
  • Projects/Domain/Sources/DomainDependencyAssembler.swift
  • Projects/Domain/Sources/Entity/Enum/YouthPolicyStatus.swift
  • Projects/Domain/Sources/Entity/YouthPolicyEntity.swift
  • Projects/Domain/Sources/Entity/YouthPolicyPageEntity.swift
  • Projects/Domain/Sources/Protocol/Repository/YouthPolicyRepositoryProtocol.swift
  • Projects/Domain/Sources/Protocol/UseCase/YouthPolicyUseCaseProtocol.swift
  • Projects/Domain/Sources/UseCase/YouthPolicy/YouthPolicyUseCase.swift
  • Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/Contents.json
  • Projects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/Contents.json
  • Projects/Presentation/Sources/ActivityHistory/View/ActivityHistoryViewController.swift
  • Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift
  • Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift
  • Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyBadge.swift
  • Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyItem.swift
  • Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTab.swift
  • Projects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTabItem.swift
  • Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyBadgeView.swift
  • Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyEmptyView.swift
  • Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTabCollectionViewCell.swift
  • Projects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTableViewCell.swift
  • Projects/Presentation/Sources/YouthPolicy/View/YouthPolicyViewController.swift
  • Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift

Comment on lines +31 to +61
func toYouthPolicyEntity() -> YouthPolicyEntity? {
guard let status = YouthPolicyStatus(rawValue: status) else { return nil }

return YouthPolicyEntity(
policyNumber: plcyNo,
title: title,
category: category,
thumbnailURL: thumbnailUrl,
status: status,
startDate: startDate.flatMap { Self.dateFormatter.date(from: $0) },
endDate: endDate.flatMap { Self.dateFormatter.date(from: $0) },
dday: dday,
applyURL: applyUrl,
isBookmarked: bookmarked)
}
}

struct YouthPolicyPageDTO: Decodable {
let totalCount: Int
let hasNext: Bool
let nextCursor: String?
let items: [YouthPolicyDTO]

func toYouthPolicyPageEntity() -> YouthPolicyPageEntity {
return YouthPolicyPageEntity(
policies: items.compactMap { $0.toYouthPolicyEntity() },
totalCount: totalCount,
hasNext: hasNext,
nextCursor: nextCursor)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

알 수 없는 상태값이 있는 공고를 조용히 제거하면 개수 표시가 어긋납니다.

toYouthPolicyEntity()statusYouthPolicyStatus에 없는 값이면 nil을 반환합니다. toYouthPolicyPageEntity()compactMap으로 이 nil 항목을 목록에서 제거하지만, totalCount는 서버 응답값을 그대로 사용합니다.

서버가 새 상태값을 추가하거나 매핑되지 않은 값을 반환하면, 화면에 표시되는 "전체 공고 개수"(totalCount)가 실제로 렌더링되는 항목 수보다 커집니다. 페이지네이션의 hasNext/nextCursor도 드롭된 항목을 포함한 전체 기준이므로, 사용자에게는 페이지마다 항목이 누락되는 것처럼 보일 수 있습니다.

알 수 없는 상태를 만나면 최소한 로그를 남기거나, 드롭된 항목 수만큼 totalCount를 보정하는 방안을 검토하십시오.

🐛 드롭된 항목 수만큼 totalCount를 보정하는 예시
     func toYouthPolicyPageEntity() -> YouthPolicyPageEntity {
+        let policies = items.compactMap { $0.toYouthPolicyEntity() }
         return YouthPolicyPageEntity(
-            policies: items.compactMap { $0.toYouthPolicyEntity() },
-            totalCount: totalCount,
+            policies: policies,
+            totalCount: totalCount - (items.count - policies.count),
             hasNext: hasNext,
             nextCursor: nextCursor)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DataSource/Sources/DTO/YouthPolicyDTO.swift` around lines 31 - 61,
Update toYouthPolicyPageEntity so policies removed by toYouthPolicyEntity for
unknown statuses are not silently ignored: either log each dropped DTO or adjust
totalCount by the number of discarded items, ensuring the displayed count and
pagination metadata remain consistent with rendered policies.

Comment on lines +24 to +27
case .addBookmark(let policyNumber), .removeBookmark(let policyNumber):
return "\(baseURL)/\(policyNumber)/bookmark"
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# NetworkService 구현 파일을 찾아 URL 구성 로직을 확인합니다.
fd -i 'NetworkService.swift' --exec cat -n {}

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 4116


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate Endpoint files =="
fd -i 'Endpoint' --exec sh -c 'echo "--- {}"; wc -l "{}"; ast-grep outline "{}" || true' \;

echo "== YouthPolicyEndpoint.swift =="
fd -i 'YouthPolicyEndpoint.swift' --exec cat -n {}

echo "== Endpoint protocol/extension files =="
for f in $(fd -i 'Endpoint.*\.swift'); do
  echo "--- $f"
  rg -n "protocol Endpoint|makeURLRequest|path|URLRequest|baseURL|queryItems|method|body" "$f" -A20 -B5 || true
done

echo "== Search for path encoding helpers =="
rg -n "addingPercentEncoding|percent-encoded|URLComponents|URLQueryItem|queryItems|baseURL|path" -S .

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 35032


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== URLRequest.swift =="
fd -i 'URLRequest\.swift' --exec cat -n {}

echo "== QueryParameter encoding helpers =="
rg -n "URLQueryItem|encodedQuery|percent|URLComponents|queryParameters|makeQuery" Projects/DataSource -S

echo "== Deterministic URLComponents/path behavior probe =="
python3 - <<'PY'
from urllib.parse import quote
cases = [" 1 ", "a?b", "a#b", "a&b", "a b"]
for value in cases:
    path = f"https://example.com/api/v1/youth-policies/{value}/bookmark"
    # Mirrors URLComponents behavior: query needs percent encoding; path has different allowed chars.
    percent = quote(value, safe="/?:@!$&'()*+,-.;=\x3C>[")
    print(value, "=> path:", path, "=> percent-encoding-like safe:", percent)
PY

echo "== Deterministic URLComponents encoding evidence from Python urlparse =="
python3 - <<'PY'
from urllib.parse import quote, urlsplit
value = "a?b"
url = f"https://example.com/api/v1/youth-policies/{value}/bookmark"
parts = urlsplit(url)
print("original path:", parts.path)
print("quoted value:", quote(value, safe="/?:@!$&'()*+,-.;=' <>[]"))
PY

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 3090


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== URLRequest+.swift =="
fd -i 'URLRequest\+\.swift' --exec cat -n {}

echo "== Header/body extension files =="
for f in $(fd -i '*Header*.swift' -p Projects/DataSource/Sources/NetworkService -o -i '*Body*.swift' -p Projects/DataSource/Sources/NetworkService); do
  echo "--- $f"
  wc -l "$f"
  cat -n "$f"
done

echo "== URLRequest extension search =="
fd -i 'URLRequest.*\.swift' --exec sh -c 'echo "--- $1"; cat -n "$1"' sh {}

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 2926


policyNumber를 URL 경로에 삽입하기 전에 인코딩하십시오.

urlStringURLComponents로 변환하지 않고 queryItems만 자동 설정합니다. ?, #, &, 공백이 포함된 policyNumber가 경로를 분할하거나 손상시킬 수 있으므로, addBookmarkremoveBookmark에서 URLQueryAllowedCharacters 같은 허용 문자 집합으로 인코딩한 뒤 경로에 삽입하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swift` around lines
24 - 27, Update the addBookmark/removeBookmark URL construction in the
endpoint’s request-path switch to percent-encode policyNumber with an
appropriate URL path-safe character set before interpolating it into the path,
preserving the existing bookmark endpoint structure.

Comment on lines +104 to +132
Task { [weak self] in
guard let self else { return }

do {
let page = try await self.fetchPage(tab: tab, cursor: nil)

var state = self.state(of: tab)
state.items = page?.policies.compactMap { YouthPolicyItem(entity: $0) } ?? []
state.totalCount = page?.totalCount ?? 0
state.nextCursor = page?.nextCursor
state.hasNext = page?.hasNext ?? false
state.isLoading = false
state.isStale = false
state.hasLoadedOnce = true
self.updateState(state, of: tab)

self.networkRetryHandler.clearRetryState()
} catch {
var state = self.state(of: tab)
state.isLoading = false
state.hasLoadedOnce = true
self.updateState(state, of: tab)

self.networkRetryHandler.handleNetworkError(error) { [weak self] in
self?.fetchFirstPage(tab: tab)
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: ViewModel 프로토콜이 MainActor로 격리되어 있는지 확인합니다.
fd -e swift | xargs rg -n -B2 -A8 '^\s*(public )?protocol ViewModel\b'

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 794


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target file outline =="
ast-grep outline Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift || true

echo
echo "== target file relevant lines =="
sed -n '1,240p' Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift | nl -ba | sed -n '1,240p'

echo
echo "== ViewModel protocol and conformers mentioning YouthPolicyViewModel =="
rg -n 'protocol ViewModel|class YouthPolicyViewModel|struct YouthPolicyViewModel|extension YouthPolicyViewModel|YouthPolicyViewModel:.*ViewModel' Projects -g '*.swift'

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 573


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file header and relevant Task blocks =="
sed -n '1,220p' Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift

echo
echo "== all Task blocks in target file =="
grep -n -A 45 'Task {' Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 13155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ViewModel.swift =="
cat Projects/Presentation/Sources/Common/Protocol/ViewModel.swift

echo
echo "== YouthPolicyViewModel methods update, state, send, binding =="
sed -n '240,340p' Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift
rg -n 'YouthPolicyViewController|YouthPolicyViewModel|youth.*ViewModel|ViewModel' Projects/Presentation/Sources/YouthPolicy -g '*.swift' || true

echo
echo "== CurrentValueSubject.send usage in same file =="
rg -n 'send\\(|\\.receive\\(' Projects/Presentation/Sources/YouthPolicy -g '*.swift'

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 2245


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== YouthPolicyViewController lifecycle/binding section =="
sed -n '1,230p' Projects/Presentation/Sources/YouthPolicy/View/YouthPolicyViewController.swift

echo
echo "== UISubject/updatePublisher/Combine sinks =="
rg -n 'CurrentValueSubject|publish|updatePublisher|\\.receive|\\.sink|\\.receive\\(' Projects/Presentation/Sources/YouthPolicy -g '*.swift'

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 8963


🌐 Web query:

Apple SwiftUI Publisher.receive documentation "Publisher sends values on background thread from publisher"

💡 Result:

In Swift Combine, publishers do not have a fixed thread on which they send values [1]. By default, a publisher emits values on the same thread or execution context from which the value was generated or triggered [2][1]. If a publisher performs work on a background thread, it will send its values on that background thread [1]. To control which thread or scheduler your subscriber code runs on, use the receive(on:options:) operator [3][4]. Key points regarding receive(on:): - Execution Context: It specifies the scheduler on which the downstream subscriber receives elements [3][4]. All operators placed after receive(on:) in the publisher chain will execute on that specified scheduler [2][5]. - UI Updates: Because SwiftUI view updates must occur on the main thread, it is common practice to use.receive(on: DispatchQueue.main) before attaching a subscriber or using.onReceive in a SwiftUI view [3][2][5]. - vs subscribe(on:): While receive(on:) changes the execution context for downstream messages, subscribe(on:) affects the scheduler used for upstream tasks such as subscription, cancellation, and requests [3][4]. For SwiftUI specifically, the.onReceive(_:perform:) view modifier is the standard way to subscribe to a publisher [6]. While.onReceive receives values on the main thread if the publisher emits them there, you should ensure your publisher chain uses.receive(on: DispatchQueue.main) if there is any risk of background-thread emissions, as performing UI updates from a background thread is not safe [3][2][5].

Citations:


MainActor 격리 없이 Task에서 ViewModel 상태를 갱신하지 마십시오.

YouthPolicyViewModel@MainActor로 격리되어 있지 않아 fetchFirstPage, fetchNextPage, toggleBookmarkTask { ... }는 비주 스레드에서 실행됩니다. 이 Task 블록에서 entireState/bookmarkedState를 직접 읽고 쓰고 tabsSubject/policiesSubject.send()를 호출하면 UI 구독에 안전하지 않은 동시성 문제가 생길 수 있습니다. Task를 유지하면서 Task { @mainactor [weak self] in ... }로 변경하십시오.

Also applies to: 146-176, 198-213

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift`
around lines 104 - 132, Update the Task closures in fetchFirstPage,
fetchNextPage, and toggleBookmark to use MainActor isolation while retaining the
existing Task-based flow. This ensures reads and writes to
entireState/bookmarkedState and calls to tabsSubject/policiesSubject.send()
execute on the main actor.

Source: Learnings

Comment on lines +121 to +130
} catch {
var state = self.state(of: tab)
state.isLoading = false
state.hasLoadedOnce = true
self.updateState(state, of: tab)

self.networkRetryHandler.handleNetworkError(error) { [weak self] in
self?.fetchFirstPage(tab: tab)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: YouthPolicyViewController가 isEmptyPublisher와 networkErrorPublisher를 어떻게 함께 처리하는지 확인합니다.
fd YouthPolicyViewController.swift | xargs rg -n -A5 'isEmptyPublisher|networkErrorPublisher'

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 705


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the ViewModel and ViewController around the reported identifiers.
if [ -f "Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift" ]; then
  wc -l "Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift"
  sed -n '1,180p' "Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift"
  sed -n '230,290p' "Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift"
fi

if [ -f "Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift" ]; then
  rg -n 'enum State|struct State|state(of|of:|updateState|isEmptySubject|networkErrorPublisher|sendPolicies|hasLoadedOnce|isLoading' "Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift"
fi

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 7475


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the controller around publisher subscriptions and empty-view bindings.
if [ -f "Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift" ]; then
  rg -n 'isEmptyPublisher|networkErrorPublisher' "Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift"
fi

if [ -f "Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift" ]; then
  rg -n 'isEmptyPublisher|networkErrorPublisher' "Projects/Presentation/Sources/YouthPolicy"
fi

fd YouthPolicyViewController.swift Projects/Presentation/Sources | while read -r f; do
  echo "===== ${f#"$PWD/"} ====="
  wc -l "$f"
  sed -n '1,180p' "$f"
done

python3 - <<'PY'
from pathlib import Path
p=Path("Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift")
text=p.read_text()
checks={
    "fetchFirstPage catch hasLoadedOnce": "state.hasLoadedOnce = true" in text[text.find("catch {"):text.find("func fetchNextPage", text.find("catch {"))],
    "sendPolicies emptiness uses hasLoadedOnce": "isEmptySubject.send(state.items.isEmpty && state.hasLoadedOnce)" in text,
    "controller subscribes isEmptyPublisher": "viewModel.output.isEmptyPublisher" in (Path("Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift").parent.parent / "YouthPolicyViewController.swift").read_text() if Path("Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift").parent.parent / "YouthPolicyViewController.swift" else False,
}
print(checks)

# A static probe of the exact conditional text shape without executing repo code.
assert "isEmptySubject.send(state.items.isEmpty && state.hasLoadedOnce)" in text
PY

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 8363


빈 목록 상태와 네트워크 오류를 분리해 방출하지 마십시오.

fetchFirstPagecatch는 목록이 비어 있어도 state.hasLoadedOnce = true로 바꿉니다. sendPoliciesstate.items.isEmpty && state.hasLoadedOnceisEmptyPublisher에 보내므로, 첫 페이지 조회 실패 시 빈 목록 UI가 켜집니다. 화면은 networkErrorPublisher의 retry 액션과 함께 빈 화면을 보여 운영자에게 잘못된 상태가 됩니다. hasLoadedOnce는 성공 응답에서 설정하거나, 빈 상태와 오류 상태를 별도로 전달하여 함께 방출되지 않게 하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift`
around lines 121 - 130, Update the fetchFirstPage error path around
networkRetryHandler so a failed first-page request does not set
state.hasLoadedOnce = true; only mark hasLoadedOnce after a successful response,
preserving networkErrorPublisher retry behavior without emitting
isEmptyPublisher for the failed empty list.

Comment on lines +187 to +213
private func toggleBookmark(policyNumber: String) {
guard let currentItem = state(of: selectedTab).items.first(where: { $0.policyNumber == policyNumber })
else { return }

let targetIsBookmarked = !currentItem.isBookmarked

let previousEntireState = entireState
let previousBookmarkedState = bookmarkedState

applyBookmarkChange(policyNumber: policyNumber, isBookmarked: targetIsBookmarked)

Task { [weak self] in
guard let self else { return }

do {
try await self.youthPolicyUseCase.updateBookmark(
policyNumber: policyNumber,
isBookmarked: targetIsBookmarked)
} catch {
// 실패하면 낙관적으로 반영했던 변경을 되돌립니다.
self.entireState = previousEntireState
self.bookmarkedState = previousBookmarkedState
self.sendTabs()
self.sendPolicies(state: self.state(of: self.selectedTab))
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

낙관적 찜 갱신 롤백이 동시 페이지 로드 결과를 덮어쓸 수 있습니다.

toggleBookmark는 193-194행에서 previousEntireState/previousBookmarkedState를 스냅샷으로 저장합니다. 202-204행의 await 구간에서 fetchFirstPage 또는 fetchNextPage가 완료되어 entireState/bookmarkedState가 새로운 페이지 데이터로 갱신되면, 이후 찜 등록/해제 요청이 실패했을 때(205-211행) 스냅샷으로 되돌리는 과정에서 새로 불러온 페이지 데이터가 유실됩니다.

찜 등록/해제가 진행 중일 때 다른 페이지 로드가 완료되는 상황을 고려해, 전체 상태를 스냅샷으로 되돌리는 대신 롤백 대상 항목의 isBookmarked 값만 되돌리는 방식으로 변경하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Projects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift`
around lines 187 - 213, Update toggleBookmark so failed bookmark requests roll
back only the targeted policy item's isBookmarked value instead of restoring
previousEntireState and previousBookmarkedState snapshots. Preserve state
changes from concurrent fetchFirstPage or fetchNextPage operations, then publish
the corrected item state through the existing sendTabs and sendPolicies flow.

@choijungp choijungp left a comment

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.

대박 !! 하나두 간단하지 않은디요 !! 완존 고생하셨슴다 ㅠㅠ !!!! 👍🏻👍🏻👍🏻

낙관적 업데이트 넘 조아요 ~~~~
띵푸루부 ~~

Comment on lines +25 to +26
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")

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.

요기 formatter.locale = Locale(identifier: "en_US_POSIX") 요거는 혹시 왜 필요한가용 !!

궁금 !! 그래서 Shared 모듈 Date+에 정의된 convertToDate를 사용하지 않은건가용 ??

Comment on lines +23 to +24
/// 외부 신청 페이지 URL. 없을 수 있습니다.
public let applyURL: 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.

헙 .. 그러쿤요 !! 어쩐지 눌러도 외부 페이지도 이동하지 않는 공고들이 있어서 궁금했었는디 .. !!!!!
굿뜨 ~

@taipaise
taipaise merged commit f78eb11 into develop Aug 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants