[Feat] 청년 공고 목록/찜 화면 구현 및 서버 연동 - #94
Conversation
- 위경도 기반 청년 공고 조회, 찜한 공고 조회 API 연동 - 커서 페이지네이션, 찜 등록/해제(낙관적 업데이트) 구현 - 활동 기록 화면의 '더보기'에서 청년 공고 화면으로 진입 - stash 적용 중 발생한 충돌 및 유령 파일 정리 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Walkthrough청년 정책 조회와 북마크 기능을 추가했습니다. 도메인 계약과 API 연동을 구성했습니다. 커서 기반 페이지네이션과 낙관적 북마크 갱신을 구현했습니다. 탭, 목록, 배지, 빈 상태를 포함한 정책 화면을 추가했습니다. Changes청년 정책 기능
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-->>사용자: 정책 목록 표시
Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (4)
Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift (1)
116-119: 📐 Maintainability & Code Quality | 🔵 Trivial섹션 주석이 실제 내용과 일치하지 않습니다.
heartEmptyIcon과heartFilledIcon은 청년 공고 찜(북마크) 기능에 사용되는 아이콘입니다. 이 두 아이콘을 "// 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인데dday가nil이면init?이nil을 반환합니다. 이nil은YouthPolicyItem.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
⛔ Files ignored due to path filters (6)
Projects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon.pngis excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@2x.pngis excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/heart_empty_icon@3x.pngis excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon.pngis excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@2x.pngis excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/heart_filled_icon@3x.pngis excluded by!**/*.png
📒 Files selected for processing (26)
Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swiftProjects/DataSource/Sources/DTO/YouthPolicyDTO.swiftProjects/DataSource/Sources/Endpoint/YouthPolicyEndpoint.swiftProjects/DataSource/Sources/Repository/YouthPolicyRepository.swiftProjects/Domain/Sources/DomainDependencyAssembler.swiftProjects/Domain/Sources/Entity/Enum/YouthPolicyStatus.swiftProjects/Domain/Sources/Entity/YouthPolicyEntity.swiftProjects/Domain/Sources/Entity/YouthPolicyPageEntity.swiftProjects/Domain/Sources/Protocol/Repository/YouthPolicyRepositoryProtocol.swiftProjects/Domain/Sources/Protocol/UseCase/YouthPolicyUseCaseProtocol.swiftProjects/Domain/Sources/UseCase/YouthPolicy/YouthPolicyUseCase.swiftProjects/Presentation/Resources/Images.xcassets/Common/heart_empty_icon.imageset/Contents.jsonProjects/Presentation/Resources/Images.xcassets/Common/heart_filled_icon.imageset/Contents.jsonProjects/Presentation/Sources/ActivityHistory/View/ActivityHistoryViewController.swiftProjects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swiftProjects/Presentation/Sources/Common/PresentationDependencyAssembler.swiftProjects/Presentation/Sources/YouthPolicy/Model/YouthPolicyBadge.swiftProjects/Presentation/Sources/YouthPolicy/Model/YouthPolicyItem.swiftProjects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTab.swiftProjects/Presentation/Sources/YouthPolicy/Model/YouthPolicyTabItem.swiftProjects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyBadgeView.swiftProjects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyEmptyView.swiftProjects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTabCollectionViewCell.swiftProjects/Presentation/Sources/YouthPolicy/View/Component/YouthPolicyTableViewCell.swiftProjects/Presentation/Sources/YouthPolicy/View/YouthPolicyViewController.swiftProjects/Presentation/Sources/YouthPolicy/ViewModel/YouthPolicyViewModel.swift
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
알 수 없는 상태값이 있는 공고를 조용히 제거하면 개수 표시가 어긋납니다.
toYouthPolicyEntity()는 status가 YouthPolicyStatus에 없는 값이면 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.
| case .addBookmark(let policyNumber), .removeBookmark(let policyNumber): | ||
| return "\(baseURL)/\(policyNumber)/bookmark" | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 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="/?:@!$&'()*+,-.;=' <>[]"))
PYRepository: 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 경로에 삽입하기 전에 인코딩하십시오.
urlString을 URLComponents로 변환하지 않고 queryItems만 자동 설정합니다. ?, #, &, 공백이 포함된 policyNumber가 경로를 분할하거나 손상시킬 수 있으므로, addBookmark와 removeBookmark에서 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.
| 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) | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 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.swiftRepository: 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:
- 1: https://huypham85.hashnode.dev/scheduler-in-combine-managing-asynchronous-tasks-efficiently
- 2: https://holyswift.app/swift-and-combine-which-thread-runs-my-sink-closure/
- 3: https://apple-docs.everest.mt/docs/combine/publisher/receive(on:options:)/
- 4: https://trycombine.com/posts/subscribe-on-receive-on/
- 5: https://www.kodeco.com/books/combine-asynchronous-programming-with-swift/v4.0/chapters/17-schedulers
- 6: https://apple-docs.everest.mt/docs/swiftui/view/onreceive(_:perform:)/
MainActor 격리 없이 Task에서 ViewModel 상태를 갱신하지 마십시오.
YouthPolicyViewModel이 @MainActor로 격리되어 있지 않아 fetchFirstPage, fetchNextPage, toggleBookmark의 Task { ... }는 비주 스레드에서 실행됩니다. 이 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
| } 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 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"
fiRepository: 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
PYRepository: YAPP-Github/Bitnagil-iOS
Length of output: 8363
빈 목록 상태와 네트워크 오류를 분리해 방출하지 마십시오.
fetchFirstPage의 catch는 목록이 비어 있어도 state.hasLoadedOnce = true로 바꿉니다. sendPolicies는 state.items.isEmpty && state.hasLoadedOnce를 isEmptyPublisher에 보내므로, 첫 페이지 조회 실패 시 빈 목록 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.
| 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)) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ 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
left a comment
There was a problem hiding this comment.
대박 !! 하나두 간단하지 않은디요 !! 완존 고생하셨슴다 ㅠㅠ !!!! 👍🏻👍🏻👍🏻
낙관적 업데이트 넘 조아요 ~~~~
띵푸루부 ~~
| formatter.dateFormat = "yyyy-MM-dd" | ||
| formatter.locale = Locale(identifier: "en_US_POSIX") |
There was a problem hiding this comment.
요기 formatter.locale = Locale(identifier: "en_US_POSIX") 요거는 혹시 왜 필요한가용 !!
궁금 !! 그래서 Shared 모듈 Date+에 정의된 convertToDate를 사용하지 않은건가용 ??
| /// 외부 신청 페이지 URL. 없을 수 있습니다. | ||
| public let applyURL: String? |
There was a problem hiding this comment.
헙 .. 그러쿤요 !! 어쩐지 눌러도 외부 페이지도 이동하지 않는 공고들이 있어서 궁금했었는디 .. !!!!!
굿뜨 ~
🌁 Background
청년 공고 목록 화면과 찜한 공고 화면을 구현하고 서버 연동을 진행했어요 ~
📱 Screenshot
👩💻 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) 다시 받아오도록 했습니다.2. 찜 낙관적 업데이트(Optimistic Update)와 실패 롤백
찜 버튼을 누르면 서버 응답을 기다리지 않고 UI에 먼저 반영한 뒤,
요청이 실패하면 변경 전 상태로 되돌리도록 했어요.
이때 찜 등록은 찜 목록의 정렬 순서를 서버가 정하기 때문에 직접 목록에 끼워넣지 않고
isStale로만 표시한 뒤 다음 진입 시 다시 받아오고, 찜 해제는 순서와 무관하니 즉시 목록에서 제거했습니다.혹시 낙관적 업데이트보다 서버 응답 후 반영하는 게 낫다고 생각하시면 말씀해주세요 !!
Summary by CodeRabbit
새 기능
개선