Skip to content

[3단계 - 상태 관리 | 4단계 - HTTP Client] 하로 미션 제출합니다 #174

Open
noeyhoj wants to merge 18 commits into
woowacourse:noeyhojfrom
noeyhoj:step3
Open

[3단계 - 상태 관리 | 4단계 - HTTP Client] 하로 미션 제출합니다 #174
noeyhoj wants to merge 18 commits into
woowacourse:noeyhojfrom
noeyhoj:step3

Conversation

@noeyhoj
Copy link
Copy Markdown

@noeyhoj noeyhoj commented May 11, 2026

셀프 체크리스트

  • 프로그램이 정상적으로 작동하는가?
  • 모든 테스트가 통과하는가?
  • 이전에 받은 피드백을 모두 반영하였는가?
  • 코딩 스타일 가이드를 준수하였는가?
    • IDE 코드 자동 정렬을 적용하였는가?
    • 린트 검사를 통과하였는가?
  • 프로그래밍 요구 사항을 준수하였는가?
  • README.md에 기능 목록을 정리하고 명확히 기술하였는가?

기능 구현 목록

  1. 상품 목록 화면에서 장바구니에 담을 수 있다.
  2. 상품 목록 화면에서 장바구니에 담을 상품의 개수를 조절할 수 있다.
  3. 상품 목록 화면의 상단에 최근 본 상품 목록이 나타나도록 했다.
  4. 상품 상세 화면에서 가장 최근 본 상품에 접근할 수 있도록 했다.
  5. 장바구니에서 들어있는 상품의 개수를 조절할 수 있다.
  6. room을 적용하여 앱을 종료한 후 다시 실행해도 최근 본 상품 목록과 장바구니 목록이 지워지지 않는다.
  7. okHttp를 적용하여 상품 목록을 MockWebServer로부터 불러올 수 있게끔 하였다.
  8. 네트워크의 흐름이 끊겼을 때 UI에 Toast 메세지가 나타나게끔 구현했다.

스크린샷

상품 목록 화면

기본 화면 개수 추가 시 화면 최근 본 상품 목록 변경
image image image

상품 상세 화면

기본 화면 개수 추가 시 화면 장바구니 담기 후 화면
image image image

장바구니 화면

기본 화면
image

네트워크 끊겼을 시

토스트 메세지 발생
image

영상

상품 추가 및 장바구니 시나리오 최근 본 상품 시나리오
KakaoTalk_Video_2026-05-11-10-42-27.mp4
2.mp4

리뷰어에게

안녕하세요 크롱! 하로입니다. 🐲

배운 내용을 이해하며 적용해보다보니 살짝 늦어진 것 같습니다... 😅
또한 이전 리뷰도 반영하여 코멘트를 달아놨습니다!

이번 리뷰도 잘 부탁드립니다~~!!

  • 이번 미션에서 stateHolder를 viewModel로 다시 수정하였습니다. stateHolder를 유지하고 싶었지만, 이번 수업에서 배운 내용이기도 하고 비동기처리를 위한 viewModelScope를 사용하고자 채택하였습니다.
  • 의존성 분리를 위해 uiState를 사용했습니다. 또한 uiModel을 만들어 ui와 도메인 사이의 연결을 끊고자 했습니다.

noeyhoj added 18 commits May 7, 2026 13:01
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

Review Change Stack

Walkthrough

이 PR은 쇼핑 카트 애플리케이션을 단계 3-4로 진행하여 상태 관리 및 HTTP 클라이언트 기능을 구현합니다.

핵심 변경사항:

  1. 도메인 모델: CartItemQuantity 값 클래스를 도입하고 CartItem을 ID 기반에서 상품/수량 기반 모델로 재설계
  2. 저장소 계층: 모든 저장소 인터페이스를 suspend 함수로 전환하고 원격(OkHttp/MockWebServer)과 로컬(Room) 구현체 추가
  3. ViewModel 도입: ProductListViewModel, ProductDetailViewModel, CartViewModel으로 세 개 화면의 상태 관리 통합
  4. 네트워크 & 저장소: NetworkMonitor로 연결 상태 추적, Room 데이터베이스로 로컬 저장소 구현
  5. UI 리팩토링: 모든 화면을 ViewModel 기반 상태 주입으로 변경하고 최근 본 상품 기능 추가

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailScreen.kt (1)

310-319: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preview에서 기본 viewModel() 호출 시 동작 확인 필요

ProductDetailScreenviewModel 파라미터가 기본값 viewModel()을 가지는데, ProductDetailViewModelParcelProduct/CartRepository/RecentProductRepository를 요구합니다. Preview에서 별도 Factory 없이 호출되면 인스턴스화에 실패할 가능성이 큽니다.

검토해 보실 점:

  • Preview에서 실제 IDE Preview가 렌더링되나요? 아니면 빌드만 통과하는 상태인가요?
  • ViewModel을 인자로 받는 화면을 Preview하기 위한 일반적인 패턴(예: Stateless한 내부 컴포저블만 Preview하거나, fake/test factory를 주입하는 방법)을 적용할 수 있을지 고려해 보세요.

ProductDetailContent처럼 상태/콜백을 받는 stateless 컴포저블을 Preview 전용으로 분리하면 어떤 이점이 있을지 한 번 정리해 보시는 것을 추천드립니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailScreen.kt`
around lines 310 - 319, The ProductDetailScreen's Preview fails because its
default parameter uses viewModel() (ProductDetailViewModel) which requires real
dependencies; update the Preview to avoid instantiating the real ViewModel by
either (1) creating and calling a stateless composable (e.g.,
ProductDetailContent) with mocked state and no real VM/callbacks, or (2)
injecting a fake/test ProductDetailViewModel instance into ProductDetailScreen
from the Preview (avoid calling viewModel() there); locate references to
ProductDetailScreen(viewModel()...), ProductDetailViewModel, and
ProductDetailContent and implement one of these approaches so the Preview
renders without needing the real repositories or ParcelProduct.
🟠 Major comments (22)
app/src/main/java/woowacourse/shopping/domain/cart/model/CartItem.kt-11-13 (1)

11-13: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

수량 증감 API에 입력 경계 검증이 필요합니다.

Line 11, Line 13에서 targetQuantity가 0/음수일 때도 연산이 진행되면 증감 의미가 뒤집히거나, 감소 후 비정상 수량을 만들 수 있습니다.
도메인 계약을 명확히 하려면 “증감 입력은 양수만 허용”과 “감소 후 최소 수량 보장”을 먼저 검증하는 방향을 고려해보면 어떨까요?

🤖 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 `@app/src/main/java/woowacourse/shopping/domain/cart/model/CartItem.kt` around
lines 11 - 13, The increaseQuantity and decreaseQuantity methods must validate
targetQuantity is positive and prevent invalid post-change quantities: in
increaseQuantity(targetQuantity: Int) check targetQuantity > 0 and throw an
IllegalArgumentException (or domain-specific exception) if not, then perform
copy(quantity = CartItemQuantity(quantity.value + targetQuantity)); in
decreaseQuantity(targetQuantity: Int) check targetQuantity > 0 and compute
newQty = quantity.value - targetQuantity and ensure newQty >= 1 (or the domain
minimum) before creating CartItemQuantity(newQty), otherwise throw an
IllegalArgumentException (or domain-specific exception) with a clear message;
update tests accordingly to assert these validations.
app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/product/ProductRepositoryMockImpl.kt-10-10 (1)

10-10: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

!!로 인한 비정상 종료 가능성이 있습니다.

Line [10]에서 id 미존재 시 NPE로 바로 종료됩니다. Mock 구현이어도 호출부가 처리 가능한 명시적 예외로 바꾸는 편이 안정적입니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/product/ProductRepositoryMockImpl.kt`
at line 10, The mock implementation of getProduct in ProductRepositoryMockImpl
uses the non-null assertion (!!) which will throw an NPE if the id is missing;
change it to explicitly handle the not-found case by replacing the !! with a
safe lookup and throwing a clear, handled exception (e.g.,
NoSuchElementException or a domain ProductNotFoundException) with a descriptive
message including the id; update the implementation that calls
MockData.products.find { it.id == id } to use the elvis operator (?:) to throw
the chosen exception so callers receive an explicit, meaningful error instead of
an NPE.
app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/product/ProductRepositoryMockImpl.kt-14-17 (1)

14-17: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

페이지 범위 초과 시 subList 예외가 발생할 수 있습니다.

Line [17]은 page * pageSize가 리스트 크기를 넘으면 IndexOutOfBoundsException이 납니다. 시작/끝 인덱스를 먼저 검증하고 범위 밖 요청은 빈 리스트로 처리해 주세요.

🤖 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
`@app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/product/ProductRepositoryMockImpl.kt`
around lines 14 - 17, In getPagedProducts, avoid IndexOutOfBoundsException from
MockData.products.subList by validating and clamping the start/end indices:
compute start = page * pageSize and if start >= MockData.products.size or start
< 0 return an empty list; otherwise compute end = minOf(MockData.products.size,
(page + 1) * pageSize) and return MockData.products.subList(start, end). Ensure
you reference getPagedProducts and MockData.products when making the change.
app/src/main/java/woowacourse/shopping/domain/cart/model/CartItems.kt-12-14 (1)

12-14: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

신규 상품 추가 분기에서 수량이 과증가할 수 있습니다.

Line [13]은 신규 항목일 때도 cartItem의 현재 수량에 targetQuantity를 다시 더합니다. 입력 cartItem.quantity가 이미 선택 수량이라면 최종 수량이 과하게 커질 수 있으니, “증가량”과 “목표수량” 의미를 한 가지로 통일해 주세요.

🤖 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 `@app/src/main/java/woowacourse/shopping/domain/cart/model/CartItems.kt` around
lines 12 - 14, The new-item branch in CartItems.add (using searchCartItem and
CartItems(_value = value + cartItem.increaseQuantity(targetQuantity))) wrongly
treats the incoming cartItem.quantity as a base and adds targetQuantity again,
causing over-increment; change the logic so that when searchCartItem(cartItem)
is false you append a cart item whose quantity is set to the targetQuantity
(e.g. create/clone the cartItem with quantity = targetQuantity or call a
setQuantity/withQuantity helper) instead of calling increaseQuantity; continue
to use increaseQuantity only in the existing-item branch (the else that handles
updating an existing CartItem).
app/src/main/java/woowacourse/shopping/ShoppingApplication.kt-7-10 (1)

7-10: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mock 서버가 모든 빌드에서 실행됩니다.

확인된 문제: ShoppingApplication.onCreate()에서 ShoppingMockServer.start()를 무조건 호출하고 있으며, build.gradle에서 mockwebserver3 의존성이 implementation()으로 설정되어 있어 릴리스 빌드에도 포함됩니다. 결과적으로 프로덕션 빌드에서도 Mock 서버가 실행되어 실제 데이터가 아닌 테스트 데이터를 사용하게 됩니다.

이를 해결하기 위해 다음을 고려해보세요:

  • mockwebserver3 의존성을 debugImplementation()으로 변경하면 디버그 빌드에만 포함됩니다
  • ShoppingApplication.onCreate()에서 조건부로 Mock 서버를 시작하려면 빌드 타입을 어떻게 구분할 수 있을까요? (BuildConfig.DEBUG 활용)
  • DataProvider.kt에서 ShoppingMockServer.baseUrl을 무조건 사용하고 있는데, 환경에 따라 실제 서버 URL을 동적으로 선택하는 방식을 생각해보세요
🤖 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 `@app/src/main/java/woowacourse/shopping/ShoppingApplication.kt` around lines 7
- 10, ShoppingMockServer is started unconditionally in
ShoppingApplication.onCreate() and mockwebserver3 is currently included for all
builds; change the Gradle dependency from implementation(...) to
debugImplementation(...) so MockWebServer is only packaged in debug builds, then
guard the call to ShoppingMockServer.start() in ShoppingApplication.onCreate()
with a BuildConfig.DEBUG check (only start when true). Also update DataProvider
(where ShoppingMockServer.baseUrl is used) to select the real remote base URL
when BuildConfig.DEBUG is false (e.g., use ShoppingMockServer.baseUrl only when
BuildConfig.DEBUG is true) so production builds use the real endpoint.
app/src/main/java/woowacourse/shopping/domain/cart/model/Cart.kt-26-33 (1)

26-33: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

페이지 범위 검증이 불완전합니다.

isPageValid(page)는 페이지가 0 이상인지만 확인하지만, fromIndex가 실제 리스트 크기를 초과하는 경우는 검증하지 않습니다.

CartItems.subList를 살펴보니 상한(toIndex)은 minOf(toIndex, value.size)로 처리하지만, 하한(fromIndex)은 보호되지 않습니다. 따라서 cartItems.subList(fromIndex, fromIndex + pageSize)를 호출할 때 fromIndex >= value.size인 경우 IndexOutOfBoundsException이 발생합니다.

이 경우를 어떻게 처리해야 할지 고민해보세요.

힌트:

  • cartItems.size()와 요청된 페이지 범위를 비교해보세요
  • 페이지 크기를 초과하는 경우 어떻게 처리할지 결정해야 합니다 (예외 발생 vs. 빈 리스트 반환 vs. 가능한 만큼만 반환)
🤖 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 `@app/src/main/java/woowacourse/shopping/domain/cart/model/Cart.kt` around
lines 26 - 33, The getPage function currently only checks isPageValid(page)
(non-negative) but must also ensure the computed fromIndex = page * pageSize
does not exceed cartItems.size; update getPage to compute fromIndex and if
fromIndex >= cartItems.size return an empty List<CartItem> (or throw a clear
exception if you prefer consistent failure), otherwise compute toIndex =
minOf(fromIndex + pageSize, cartItems.size) and return
cartItems.subList(fromIndex, toIndex); keep references to getPage, isPageValid,
cartItems, fromIndex, and pageSize so reviewers can find the change.
app/src/main/java/woowacourse/shopping/data/source/remote/ProductRemoteDataSource.kt-24-30 (1)

24-30: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

실패 응답을 emptyList()로 흡수하면 상위 계층이 장애를 구분할 수 없습니다.

현재는 실패/본문 누락/정상 빈 목록이 모두 같은 결과라서, UI 에러 처리(토스트 등)와 캐시 정책이 왜곡되기 쉽습니다. 실패 신호를 별도로 전달하는 계약(예: 예외/Result 분리)로 정리하는 방향을 고려해 주세요.

🤖 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
`@app/src/main/java/woowacourse/shopping/data/source/remote/ProductRemoteDataSource.kt`
around lines 24 - 30, ProductRemoteDataSource currently swallows failure cases
by returning emptyList() for response.isSuccessful == false and when
response.body is null; change this to surface distinct failure signals instead
of an empty list by throwing a descriptive exception (e.g., IOException or a
custom RemoteDataException) when !response.isSuccessful (include response.code()
and/or response.message()) and when response.body == null, while keeping the
successful branch that uses
Json.decodeFromString(ListSerializer(ProductEntity.serializer()), responseBody);
update callers (or change the method to return Result<List<ProductEntity>>) to
handle these errors accordingly.
app/src/main/java/woowacourse/shopping/data/source/remote/ProductRepositoryRemoteImpl.kt-11-15 (1)

11-15: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

첫 조회 실패 결과까지 캐시되어 복구가 막힐 수 있습니다.

fetchAllProducts()가 첫 결과를 무조건 캐시해서, 일시적인 네트워크 실패로 빈 리스트를 받으면 이후에도 계속 빈 데이터만 반환할 가능성이 큽니다. 실패와 “정상 빈 데이터”를 구분해 캐시 전략을 분리해보는 게 좋겠습니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/data/source/remote/ProductRepositoryRemoteImpl.kt`
around lines 11 - 15, fetchAllProducts currently unconditionally caches the
first fetch into cachedProducts which can lock in a bad empty result; change
fetchAllProducts to call remoteDataSource.getProducts() inside a try/catch, only
assign cachedProducts = it when the call succeeds and the returned list is
non-empty (or otherwise decide a success criterion), return the fetched list
even if empty but do not overwrite cachedProducts on exceptions or on
transient-empty responses, and leave cachedProducts untouched so future
successful fetches can replace it; reference fetchAllProducts(), cachedProducts
and remoteDataSource.getProducts() when making the change.
app/src/main/java/woowacourse/shopping/data/NetworkMonitor.kt-26-32 (1)

26-32: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

네트워크 손실 시 실제 연결 상태를 재확인해야 합니다.

onLost() 콜백이 호출되었다고 해서 모든 네트워크 연결이 끊긴 것은 아닙니다. 여러 개의 네트워크가 동시에 활성화될 수 있는 Android 환경에서, WiFi가 끊겨도 모바일 데이터는 여전히 연결되어 있을 수 있습니다.

현재처럼 onLost()에서 바로 false를 emit하면, 실제로는 인터넷이 연결되어 있는데도 오프라인으로 표시되는 오탐이 발생할 수 있습니다. 대신 onLost() 콜백 내에서도 isCurrentlyConnected()를 호출해서 현재 실제 연결 상태를 재평가한 후 emit하는 방식을 고려해보세요.

🤖 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 `@app/src/main/java/woowacourse/shopping/data/NetworkMonitor.kt` around lines
26 - 32, onLost currently emits false unconditionally which can falsely mark the
app offline; inside the NetworkMonitor's onLost(network: Network) callback call
the existing isCurrentlyConnected() check and emit its boolean result via
trySend(...) instead of trySend(false), so the emitted state reflects actual
connectivity (use the same helper used elsewhere to reevaluate overall network
status).
app/src/main/AndroidManifest.xml-10-10 (1)

10-10: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

usesCleartextTraffic="true"를 전체 앱에 상시 적용하는 것은 보안 범위가 큽니다.

Line 10 설정은 디버그 빌드뿐 아니라 릴리스 빌드에서도 모든 HTTP 평문 트래픽을 허용합니다. MockWebServer 테스트 목적이라면 네트워크 보안 설정(network security config) 파일로 디버그 환경에만 제한하는 것이 안전합니다. 현재 구성에서는 프로덕션 환경까지 평문 통신을 허용하므로 즉시 개선이 필요합니다.

🤖 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 `@app/src/main/AndroidManifest.xml` at line 10, 현재 AndroidManifest의
usesCleartextTraffic="true"을 전체 앱에 적용하고 있으므로 이를 제거하고 평문 트래픽 허용을 디버그 빌드로 한정하세요:
manifest에서 usesCleartextTraffic 속성을 제거하고 대신 네트워크 보안 구성
파일(network_security_config.xml)을 만들어 디버그 전용으로 cleartext를 허용하도록 설정한 뒤, 이 구성은 디버그
전용 AndroidManifest(또는 debug 소스셋의 매니페스트 오버레이)에서만 android:networkSecurityConfig로
참조되게 하세요; 관련 식별자: usesCleartextTraffic, network_security_config.xml, debug
AndroidManifest (debug build type 오버레이).
app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/recentProduct/RecentProductRepositoryRoomImpl.kt-20-24 (1)

20-24: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

catch (Exception)이 코루틴 취소까지 삼켜서 취소 전파가 깨질 수 있습니다.

현재 구현은 CancellationException까지 흡수할 수 있어 취소 협력이 깨질 위험이 있습니다. 예상 가능한 예외만 좁혀 잡고, 취소 예외는 반드시 재전파하는 형태로 정리해 주세요.

이 문제는 20-24번 줄과 31-35번 줄 모두에 해당합니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/recentProduct/RecentProductRepositoryRoomImpl.kt`
around lines 20 - 24, The current catch-all (catch (e: Exception)) around calls
to productRepository.getProduct(entity.productId) swallows CancellationException
and breaks coroutine cancellation; change these catches to rethrow cancellation:
either use catch (e: CancellationException) { throw e } before handling other
exceptions, or replace with catch (e: Exception) { if (e is
CancellationException) throw e else /* existing fallback (return null or log) */
}; apply the same fix for both occurrences in RecentProductRepositoryRoomImpl
where getProduct(entity.productId) is invoked.
app/src/main/java/woowacourse/shopping/data/source/remote/ShoppingMockServer.kt-14-39 (1)

14-39: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

백그라운드 스레드에서 서버가 시작되므로 초기 요청 타이밍 레이스 가능성이 있습니다.

start() 메서드가 즉시 반환되지만, server.start()는 백그라운드 스레드에서 비동기로 실행됩니다. 앱이 빠르게 시작되는 경우, DataProviderremoteDataSource가 초기화되어 ShoppingMockServer.baseUrl에 접근할 때 서버가 아직 준비되지 않은 상태일 수 있습니다.

서버 시작을 완료한 후 호출자에게 제어를 반환하도록 개선할 방법을 생각해 보세요. (예: 서버 준비를 알리는 메커니즘 추가)

🤖 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
`@app/src/main/java/woowacourse/shopping/data/source/remote/ShoppingMockServer.kt`
around lines 14 - 39, start() currently spawns a background Thread and returns
immediately, causing a race where ShoppingMockServer.baseUrl may be read before
server.start() completes; make start() synchronous or add a readiness signal so
callers block until the mock server is ready (e.g., remove the background Thread
and call server.start() directly or use a CountDownLatch / CompletableFuture
that the Dispatcher or thread sets after server.start() and have start() await
it), update ShoppingMockServer.start() to wait for that readiness before
returning and ensure any callers of ShoppingMockServer.baseUrl depend on that
readiness mechanism.
app/src/main/java/woowacourse/shopping/data/source/local/CartDatabase.kt-33-33 (1)

33-33: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

fallbackToDestructiveMigration()은 향후 스키마 업그레이드 시 사용자 데이터 손실을 초래할 수 있습니다.

현재는 새로운 데이터베이스 생성이므로 즉각적인 문제가 없지만, 버전 2에서 3으로 업그레이드될 때 장바구니 및 최근 본 상품 데이터가 삭제될 것입니다. 지금부터 명시적 Migration 클래스로 스키마 변경을 관리하는 것이 향후 데이터 손실을 방지하는 방법입니다. Room의 마이그레이션 패턴을 참고하여 어떻게 구성해야 할지 생각해보세요.

🤖 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 `@app/src/main/java/woowacourse/shopping/data/source/local/CartDatabase.kt` at
line 33, Replace fallbackToDestructiveMigration() with explicit Room Migration
objects and register them via addMigrations(...) on the Room.databaseBuilder
used in CartDatabase; create Migration instances (e.g., val MIGRATION_2_3 =
object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { /*
schema changes: ALTER TABLE / CREATE TABLE / COPY data as needed */ } }) in the
CartDatabase companion or a dedicated migrations file, implement the SQL needed
to preserve cart and recently viewed data, and pass those migrations into
databaseBuilder.addMigrations(MIGRATION_2_3, /* other migrations */) so upgrades
don't destructively delete user data.
app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/cart/CartRepositoryRoomImpl.kt-22-26 (1)

22-26: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

예외를 삼키면 장바구니 누락 원인을 추적하기 어려워집니다.

Line 24에서 Exception을 그대로 삼키고 null로 치환하면, 실제 장애(파싱/네트워크/데이터 손상)를 구분할 수 없습니다. 최소한 예외를 기록하거나, 복구 가능한 예외만 선별 처리하는 쪽이 안전합니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/cart/CartRepositoryRoomImpl.kt`
around lines 22 - 26, In CartRepositoryRoomImpl replace the blanket catch around
productRepository.getProduct(...) that swallows Exception with targeted
handling: catch only the expected "product not found" exception (e.g.,
NotFoundException or whatever your productRepository throws) and return null for
that case, but for other exceptions log the error via the repository/logger (or
rethrow) so parsing/network/data corruption errors are visible; ensure
references to productRepository.getProduct and CartRepositoryRoomImpl are
updated accordingly.
app/src/main/java/woowacourse/shopping/data/DataProvider.kt-16-38 (1)

16-38: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

싱글톤 캐시 경계에서 Activity 컨텍스트가 보관될 수 있습니다.

Line 16, 30, 36처럼 외부 context를 그대로 넘기면 호출자가 Activity일 때 누수 위험이 생깁니다. DataProvider 진입점에서 applicationContext로 정규화해두는 쪽을 고려해보실까요?

🤖 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 `@app/src/main/java/woowacourse/shopping/data/DataProvider.kt` around lines 16
- 38, The DataProvider currently stores objects created with the raw Context
passed into getNetworkMonitor, getCartRepository, and getRecentProductRepository
which can leak an Activity; update these factory methods to normalize the
incoming Context to applicationContext (e.g., use context.applicationContext)
before constructing NetworkMonitor, calling CartDatabase.getDatabase, or passing
into any long-lived singletons (networkMonitor, CartRepositoryRoomImpl,
RecentProductRepositoryRoomImpl) so only the Application context is retained.
app/src/main/java/woowacourse/shopping/features/productList/ProductListViewModel.kt-42-44 (1)

42-44: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

totalProductCount 초기화 시점 때문에 첫 페이지에서 isLastPage가 잘못 계산될 수 있습니다.

init에서 Line 43으로 moreProducts()가 먼저 실행되는데, Line 96 계산 시점의 totalProductCount는 아직 0입니다. 이러면 isLastPage=true로 고정되어 더보기 버튼/페이징이 조기에 막힐 수 있습니다. 카운트 초기화 책임을 moreProducts() 경로로 옮길지 한번 점검해보실래요?

Also applies to: 46-49, 68-97

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productList/ProductListViewModel.kt`
around lines 42 - 44, The init block calls moreProducts() before
totalProductCount is initialized which causes isLastPage to be computed
incorrectly; update the flow so totalProductCount is set before isLastPage is
evaluated by either (a) initializing totalProductCount in init (set a sensible
initial value or load the count first) and then calling moreProducts(), or (b)
move the totalProductCount assignment and isLastPage calculation into the
moreProducts() response handling so that after fetching the first page you
assign totalProductCount and then compute isLastPage; adjust the logic in
ProductListViewModel.kt (init, moreProducts(), and the isLastPage calculation
site) so isLastPage is always computed only after totalProductCount is
populated.
app/src/main/java/woowacourse/shopping/features/cart/CartScreen.kt-62-62 (1)

62-62: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

CartViewModel의 생성자 의존성으로 인해 기본 viewModel() 경로가 작동하지 않습니다.

CartViewModel은 CartRepository를 생성자에서 요구하는데(16-18줄), Line 62의 기본 매개변수 viewModel()은 이 의존성을 제공할 수 없습니다. 따라서 Line 340-348의 Preview는 명시적인 viewModel 인자를 제공하지 않으면서 기본값에 의존하게 되어 런타임에 실패합니다.

이를 해결하기 위해 다음을 고려해보세요:

  • CartScreen의 viewModel 매개변수에서 기본값을 제거하고 호출처에서 명시적으로 주입하는 방식
  • 또는 Preview에서 테스트용 mock CartViewModel을 생성하여 전달하는 방식
🤖 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 `@app/src/main/java/woowacourse/shopping/features/cart/CartScreen.kt` at line
62, The CartScreen's default parameter viewModel: CartViewModel = viewModel()
fails because CartViewModel requires a CartRepository in its constructor; remove
the default so callers must pass an explicit CartViewModel or provide a
test/mock in previews. Specifically, update CartScreen to require viewModel:
CartViewModel (no default) and modify callers (including the Preview at the
region around lines 340-348) to supply either the real injected viewModel or a
mock/test instance, or alternatively create a mock CartViewModel inside the
Preview and pass it into CartScreen to avoid runtime injection failures.
app/src/main/java/woowacourse/shopping/features/cart/CartViewModel.kt-35-60 (1)

35-60: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

loadCartPage()의 코루틴 실행 구조를 다시 살펴봐주세요.

removeCartItem(), increaseCartItem(), decreaseCartItem()에서 각각 viewModelScope.launch로 감싼 후 loadCartPage()를 호출하고 있습니다. loadCartPage() 자체도 내부에서 다시 viewModelScope.launch를 사용합니다.

몇 가지 생각해볼 점들입니다:

  • 리포지토리 작업이 완료되고 loadCartPage()를 호출할 때, 이 호출이 즉시 반환되는데 내부의 launch 블록은 언제 실행되나요?
  • 여러 액션이 빠르게 연속으로 호출될 때(예: 수량 +/- 빠른 연타), _uiState.update 호출들이 어떤 순서로 실행될까요?
  • 현재 구조에서 UI 상태 갱신의 순서를 보장할 수 있을까요?

코루틴 스코프와 suspend 함수의 역할을 생각해보면서, 같은 코루틴 컨텍스트에서 순차적으로 실행되게 구조화하는 방법을 찾아보세요.

🤖 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 `@app/src/main/java/woowacourse/shopping/features/cart/CartViewModel.kt` around
lines 35 - 60, loadCartPage() currently starts its own viewModelScope.launch
while removeCartItem(), increaseCartItem(), and decreaseCartItem() also launch
before calling it, causing nested asynchronous launches and unpredictable UI
update ordering; change loadCartPage() to be a suspend function (remove its
internal viewModelScope.launch) so callers can call it from their existing
viewModelScope.launch blocks, ensuring repository calls and the subsequent
_uiState.update run sequentially in the same coroutine context and preserve
ordering.
app/src/main/java/woowacourse/shopping/features/productList/ProductListScreen.kt-70-70 (1)

70-70: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

ProductListScreen의 기본 viewModel() 호출은 의존성을 충족하지 못해 Preview와 기본값 경로에서 런타임 실패 위험이 있습니다.

Line 70의 기본값 viewModel()을 보면, ProductListViewModel이 필요로 하는 4가지 의존성(NetworkMonitor, ProductRepository, CartRepository, RecentProductRepository)을 전달할 방법이 없습니다. Activity에서는 ProductListViewModelFactory로 안전하게 주입되지만, Line 468~476 Preview는 기본값을 사용하므로 호출 시 런타임 오류가 발생합니다.

해결 방향:

  • Composable의 기본값 파라미터를 제거하거나,
  • Preview와 테스트를 위해 mock/stub ViewModel을 제공하는 별도의 팩토리나 파라미터를 고려해보세요.

또한 이 패턴이 프로젝트의 다른 Composable에서도 반복되는지 확인하는 것도 좋은 습관입니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productList/ProductListScreen.kt`
at line 70, The ProductListScreen's default parameter viewModel:
ProductListViewModel = viewModel() will fail in Preview because its dependencies
(NetworkMonitor, ProductRepository, CartRepository, RecentProductRepository)
cannot be provided; update ProductListScreen so it no longer calls viewModel()
as a default: either remove the default and make the viewModel parameter
required, or add an explicit optional preview/test injection such as a
previewViewModel factory parameter (e.g., accept a ProductListViewModelFactory
or a PreviewProductListViewModel singleton) and use that for composable
previews; ensure references to ProductListScreen and ProductListViewModel (and
the existing ProductListViewModelFactory) are updated so Activity injection
still works and previews/tests can supply a lightweight/mock view model.
app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt-22-32 (1)

22-32: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

ViewModel 내부의 상태 중복 보관 검토

_uiState(MutableStateFlow)와는 별개로 name, imageUrl, price, quantity, minusEnabled, isLatestProduct, latestProduct를 각각 private var로 두고 양쪽을 동시에 갱신하고 있습니다. 이렇게 되면 “원천(single source of truth)”이 둘로 쪼개져서, 한쪽만 갱신했을 때 UI와 내부 도메인 상태가 어긋날 위험이 있습니다.

스스로 점검해 보실 만한 질문:

  • 어떤 정보가 “UI에 표시하기 위한 상태”이고, 어떤 정보가 “비즈니스 로직에만 필요한 상태”인가요? 후자가 정말로 별도 필드여야 하나요?
  • _uiState.value로부터 동일한 값을 즉시 읽을 수 있다면, 별도 var 백업이 굳이 필요할까요?
  • 단가/총가/수량처럼 파생 가능한 값은 저장하는 대신 계산해서 노출할 수 있지 않을까요?

상태가 한 곳에 모이면 increase/decrease, changeProduct 등의 메서드도 단일 update {} 호출로 단순화되어 버그 가능성이 줄어듭니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt`
around lines 22 - 32, The ViewModel currently duplicates state between _uiState
(MutableStateFlow<ProductDetailUiState>) and private vars (name, imageUrl,
price, quantity, minusEnabled, isLatestProduct, latestProduct); consolidate to a
single source of truth by removing those separate private vars and always
reading/writing from _uiState (use uiState.value for reads) and update state via
_uiState.update { ... } in methods like increase, decrease, and changeProduct;
compute derived values (e.g., total price, minusEnabled) on-demand in
ProductDetailUiState or in getters rather than storing them as separate fields,
and keep only domain-only data that truly cannot be exposed to UI (if any) as
distinct properties.
app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailScreen.kt-80-89 (1)

80-89: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

!! 강제 언래핑과 상태 필드 이름 불일치 문제

라인 84의 onLatestProductClick(uiState.latestProduct!!)는 NPE 위험이 있습니다. 호출 시점에 !isLatestProduct 조건이 만족되더라도 람다 캡처 시점과 실제 클릭 시점 사이에 상태가 바뀔 수 있고, 무엇보다 “isLastProduct == falselatestProduct != null”이라는 불변식은 ViewModel과의 암묵적 결합에만 의존합니다.

또한 라인 83에서 uiState.isLastProduct를 로컬에서는 isLatestProduct로 받고 있는데, 이는 ViewModel과 UiState의 isLastProduct / isLatestProduct 명명 혼란(파일 2 참고)을 화면 코드까지 끌고 옵니다.

스스로 점검해 보실 만한 질문 몇 가지를 남깁니다:

  • UI 모델 단계에서 “최근 본 상품 카드”가 의미적으로 단일 옵셔널 값(null이면 카드 없음)으로 표현되면, if (!isLatestProduct) + !! 패턴을 제거할 수 있지 않을까요?
  • isLastProduct라는 이름이 “마지막(최후)의 상품”인지 “가장 최근에 본 상품인지 여부”인지 모호하지는 않나요? 어느 쪽 의미가 의도였는지 한 단어로 표현할 수 있을지 고민해 보세요.
🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailScreen.kt`
around lines 80 - 89, 현재 코드의 주요 문제는 uiState.latestProduct!!로 강제 언래핑해 NPE 위험을 만드는
점과 isLatestProduct/isLastProduct라는 불일치한 이름으로 의미가 혼란스러운 점입니다; 해결 방법은
ProductDetailScreen에서 uiState.latestProduct를 로컬 nullable 변수(e.g. val
latestProduct = uiState.latestProduct)로 안전하게 캡처하고 onLatestProductClick에는
nullable을 허용하거나 클릭 람다 내부에서 latestProduct?.let { onLatestProductClick(it) }처럼
null 체크를 해 !!를 제거하고, UI/UiState 전체에서 isLastProduct 또는 isLatestProduct 중 하나의 명확한
이름으로 통일하여 ViewModel과 화면이 같은 의미를 사용하도록 변경하세요 (참고 심볼: uiState.latestProduct,
onLatestProductClick, uiState.isLastProduct/isLatestProduct).
app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt-60-81 (1)

60-81: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

changeProduct의 강제 언래핑과 상태 의미 점검

라인 61의 _uiState.value.latestProduct!!는 호출 시점에 latestProduct가 null이면 즉시 크래시로 이어집니다. 또한 다음 의미적인 점도 함께 점검해 보시기 바랍니다.

  • UI에서는 isLastProduct == false일 때만 카드가 보이고 그때만 이 함수가 호출되도록 막혀있지만, ViewModel은 호출자(UI)의 가정에 의존하는 형태입니다. 만약 다른 경로에서 이 메서드가 호출된다면 어떻게 방어할 수 있을까요?
  • price = product.price.value 한 줄은 “현재 표시 가격”을 단가로 리셋합니다. 그런데 increaseCartItem/decreaseCartItem에서는 price를 총액(누적가)처럼 다룹니다. 같은 필드가 시점에 따라 단가/총액으로 의미가 바뀌는 셈인데, 이 이중 의미는 어떻게 정리하면 좋을지 고민해 보세요(예: 단가는 상수처럼 두고, 총액은 quantity * unitPrice로 매번 파생).

NPE 회피는 옵셔널을 안전하게 처리하거나(?.let { ... }) UI 모델 자체에서 “선택 가능한 최근 상품”을 표현하는 방향을 고려해 보세요.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt`
around lines 60 - 81, changeProduct currently force-unwraps
_uiState.value.latestProduct with !! which can NPE if called unexpectedly;
update changeProduct to safely guard against null (e.g.,
_uiState.value.latestProduct?.let { latest -> ... } or early return if
latestProduct == null) and operate on that non-null local; also stop conflating
unit price and aggregate price by keeping product.price as unitPrice (e.g.,
unitPrice = latest.price.value) and derive totalPrice as quantity * unitPrice
when updating _uiState and when used in increaseCartItem/decreaseCartItem so
price semantics are consistent across ProductDetailViewModel (refer to
changeProduct, latestProduct, product.price, increaseCartItem/decreaseCartItem).
🟡 Minor comments (4)
README.md-74-75 (1)

74-75: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

StateHolder 언급을 ViewModel/UDF 기준으로 최신화해주세요.

PR 설명에는 ViewModel 전환이 반영되어 있는데, README에는 여전히 StateHolder가 남아 있어 현재 구조를 오해할 수 있습니다.

🤖 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 `@README.md` around lines 74 - 75, The README still refers to "StateHolder"
which is outdated; update those references to "ViewModel" (or "UDF" where
appropriate) and adjust the wording so it matches the PR's architecture: e.g.,
replace "StateHolder가 장바구니 repository를 알고 있어야 한다" with "ViewModel/UDF가 장바구니
repository에 접근해야 한다" and replace "각 상품이 장바구니에 몇 개가 담겨있는지 알고 있어야 한다" with
"ViewModel/UDF가 각 상품의 장바구니 수량을 관리/노출해야 한다" so the README matches the
ViewModel/UDF-based design described in the PR.
README.md-47-47 (1)

47-47: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

문서 오탈자/맞춤법을 정리해주세요.

예: 메세지메시지, 변경되야변경되어야, 알고있다알고 있다, 어짜피어차피.
README 신뢰도와 가독성에 바로 영향이 있습니다.

Also applies to: 65-65, 76-76, 80-80

🤖 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 `@README.md` at line 47, Fix Korean spelling/spacing typos in the README:
replace occurrences of "메세지" with "메시지", "변경되야" with "변경되어야", "알고있다" with "알고
있다", and "어짜피" with "어차피" (and apply the same corrections to the other affected
occurrences referenced in the review); update the sentence "네트워크가 끊길 경우 Toast
메세지가 나타나게끔 기능을 구현했다." to use "메시지" and adjust spacing where needed so phrases
like "알고 있다" are two words.
app/src/test/java/woowacourse/shopping/cart/CartItemQuantityTest.kt-20-21 (1)

20-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

테스트 이름과 검증 대상이 어긋나 있습니다

Line 20의 테스트명은 “예외가 발생하지 않는다”인데, Line 21은 생성자 호출 없이 isValueValid만 확인하고 있습니다. 생성 성공(예외 미발생)을 직접 검증하도록 맞추는 편이 의도를 더 정확히 보장합니다.

🤖 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 `@app/src/test/java/woowacourse/shopping/cart/CartItemQuantityTest.kt` around
lines 20 - 21, The test name says "no exception is thrown" but it only calls
CartItemQuantity.isValueValid(value); update the test to actually construct a
CartItemQuantity with the provided value (e.g., call the CartItemQuantity
constructor or factory used in production) to assert that creation succeeds
without throwing, or alternatively rename the test to reflect that it only
validates the value; target the CartItemQuantity constructor/newInstance and the
isValueValid method so the test either constructs CartItemQuantity(value) and
asserts no exception, or changes the test name to match the current behavior.
app/src/main/java/woowacourse/shopping/data/source/remote/ProductEntity.kt-11-11 (1)

11-11: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

ProductEntity의 id 필드에 빈 문자열 기본값이 적절한지 검토하세요

Line 11의 id: String = "" 기본값을 고려할 때, API 응답에서 id 필드가 누락되면 어떻게 되는지 생각해보세요. 현재 설정에서는 toDomain() 메서드를 통해 빈 문자열이 그대로 Product 도메인 객체의 id로 전달됩니다.

다음 질문들을 자문해보세요:

  • id는 정말 선택 사항입니까?
  • 빈 문자열 id로 getProduct(id: String), 최근 본 상품 매핑, 장바구니 조회 등의 작업이 제대로 작동할까요?
  • ProductName처럼 id도 유효성 검사가 필요하지 않을까요?

ProductName의 검증 방식(비어있으면 예외)을 참고하여, id 필드가 API 계약에서 필수라면 이를 반영하도록 수정하는 방법을 생각해보세요. 필드를 선택 사항으로 유지할지, 기본값을 제거할지, 아니면 도메인 계층에서 유효성 검사를 추가할지 결정해야 합니다.

🤖 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 `@app/src/main/java/woowacourse/shopping/data/source/remote/ProductEntity.kt`
at line 11, ProductEntity currently gives id a default empty string which lets
missing API ids silently become invalid domain ids; update ProductEntity.id
handling so missing ids are caught: either remove the default so id is required
in the DTO (make it non-optional/no default) or make id nullable (String?) and
add a validation in ProductEntity.toDomain() that throws a clear exception if id
is null/blank (similar to ProductName validation), then map a validated id into
the Product domain; locate ProductEntity and its toDomain() to apply the chosen
change and ensure callers/tests reflect the stricter contract.
🧹 Nitpick comments (10)
README.md (1)

76-80: ⚡ Quick win

질문형 메모를 확정된 요구사항 문장으로 정리하는 것을 권장합니다.

현재 README에 회의 메모 톤(질문형/말줄임표/빈 bullet)이 섞여 있어, 스펙 문서로 읽기 어렵습니다. 결정된 규칙(예: 비교 키, 최근 본 상품 10개 유지 정책)을 단정형으로 고정해 주세요.

Also applies to: 129-130

🤖 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 `@README.md` around lines 76 - 80, Convert the question-style bullets in the
README (the entries starting with “상품 목록 화면에서도 장바구니에 담겨있는 상품들을 알고있다..?” and the
related bullets about comparing ids) into definitive requirement statements:
state that product listing must reflect cart membership, specify the comparison
key as product.id (remove ambiguity about storing duplicate ids in cart items),
and add a concrete policy for recently viewed items (e.g., “최근 본 상품은 최대 10개를
유지”). Update all similar question-form bullets (including the other instances
mentioned) to the same declarative style so the README reads as explicit specs
rather than meeting notes.
app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailUiState.kt (1)

11-11: ⚡ Quick win

UI 상태에 도메인 모델이 직접 노출되어 계층 분리가 약해집니다

Line 11의 latestProduct: Product?는 UI 계층이 도메인 타입에 직접 의존하게 만듭니다. 이번 PR 목표(UIView 모델 분리)에 맞추려면 화면 전용 모델로 경계를 유지하는 쪽이 더 일관적입니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailUiState.kt`
at line 11, ProductDetailUiState currently exposes the domain Product via the
latestProduct: Product? property which breaks layering; change latestProduct to
a UI-specific model (e.g., LatestProductUiModel or ProductViewState?) and map
the domain Product to that UI model before populating ProductDetailUiState.
Update places that construct ProductDetailUiState (search for
ProductDetailUiState usage) to perform the mapping from domain Product -> the
new UI model and remove direct Product references from UI layer.
app/src/test/java/woowacourse/shopping/cart/CartItemTest.kt (1)

37-55: ⚡ Quick win

경계 입력 테스트를 한두 케이스 더 추가하면 안정성이 크게 올라갑니다.

지금은 양수 delta 중심이라, 0/음수 delta현재 수량보다 큰 감소 요청 같은 케이스에서 기대 동작(예외/거부)을 명시해두면 도메인 규칙 회귀를 더 잘 막을 수 있습니다.

🤖 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 `@app/src/test/java/woowacourse/shopping/cart/CartItemTest.kt` around lines 37
- 55, Add boundary tests around CartItem operations: add cases using cartItem1
that call increaseQuantity(0) and increaseQuantity(-1) and assert the domain's
expected behavior (e.g., throw an exception such as IllegalArgumentException or
leave quantity unchanged), and add a test that calls decreaseQuantity with a
delta greater than the current quantity (and assert it throws the expected
exception or is rejected). Use the existing helpers/fixtures (cartItem1) and the
methods increaseQuantity and decreaseQuantity so these new tests live alongside
the existing ones and explicitly verify invalid/edge deltas and over-decrement
behavior.
app/src/test/java/woowacourse/shopping/cart/CartItemsTest.kt (1)

17-29: ⚡ Quick win

수량 변경 핵심 시나리오에 직접적인 검증을 추가해 주세요.

현재는 존재 여부/총합 위주라서 수량 계산 회귀를 놓치기 쉽습니다. addCartItem/minusCartItem 이후 getQuantity(targetCartItem)를 함께 검증하면 의도한 동작을 더 정확히 보호할 수 있습니다.

Also applies to: 69-82

🤖 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 `@app/src/test/java/woowacourse/shopping/cart/CartItemsTest.kt` around lines 17
- 29, Add an explicit quantity assertion after addCartItem/minusCartItem calls:
when testing CartItems.addCartItem and CartItems.minusCartItem (in
CartItemsTest), after obtaining addedCartItems/updatedCartItems assert that
addedCartItems.getQuantity(targetCartItem) (or getQuantity on the returned
instance) equals the expected CartItemQuantity (e.g.
CartItemQuantity(targetQuantity or decremented value)) in addition to the
existing searchCartItem/total checks; reference CartItems, addCartItem,
minusCartItem, getQuantity, CartItemQuantity and searchCartItem to locate where
to add the assertions.
app/src/main/java/woowacourse/shopping/features/productList/ProductListActivity.kt (1)

65-68: ⚡ Quick win

존재 여부 검증 흐름을 한 번에 판단 가능한 형태로 정리해보면 어떨까요?

지금 패턴은 “메서드 호출(부수효과) → 별도 플래그 확인”이라 타이밍/상태 동기화 이슈가 숨어들기 쉽습니다. 클릭 시점에 즉시 판단 가능한 반환값(or 단일 상태 이벤트)으로 합치면 흐름이 더 안전해집니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productList/ProductListActivity.kt`
around lines 65 - 68, Current flow calls viewModel.isHasProductId(productUi.id)
for side effects and then checks viewModel.isHasProductId flag, which risks
timing/state sync issues; change the API so the check is atomic: modify the
method isHasProductId(...) to return a Boolean (or provide a single single-shot
state/event like hasProductIdEvent) and use that returned value directly in the
click handler (e.g., val exists = viewModel.isHasProductId(productUi.id); if
(!exists) { Toast... }). Update any callers and the ViewModel implementation
(isHasProductId function and any backing property) to reflect the synchronous
boolean-return contract or emit a single state event.
app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailActivity.kt (1)

25-27: ⚡ Quick win

setContent 블록 내에서의 finish() 호출을 Activity 초기화 단계로 분리해 보세요.

현재 코드에서 parcelProduct가 null일 때 setContent 내부에서 finish()를 호출하면, Compose 재구성 시점에 예측하기 어려운 동작이 발생할 수 있습니다.

onCreate에서 데이터 검증을 먼저 수행한 후, 필요한 경우 finish()를 호출하고 setContent를 진행하는 흐름으로 개선하면 어떨까요? 이렇게 하면 Activity의 생명주기와 Compose의 UI 구성 영역이 더 명확하게 분리됩니다. 왜 이런 분리가 중요한지, 그리고 어떻게 구조화할 수 있을지 생각해 보시기 바랍니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailActivity.kt`
around lines 25 - 27, 현재 parcelProduct null 체크와 finish() 호출이 setContent 블록 내부에
있어 Compose 재구성 시 불안정한 동작을 유발할 수 있으니, onCreate 진입부에서 parcelProduct를 먼저 검증하고
null이면 finish() 후 즉시 반환하도록 이동하세요; 즉, 참조되는 식별자(parcelProduct, setContent,
onCreate, finish())를 찾아 setContent를 호출하기 전에 null 체크를 수행하고, null일 경우 finish() 호출
후 더 이상 setContent를 실행하지 않도록 리팩터링하세요.
app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt (3)

125-137: 💤 Low value

ViewModelProvider.Factory의 최신 권장 형태 검토(선택)

현재 구현은 create(modelClass: Class<T>): T 시그니처를 오버라이드하는 전통적 방식입니다. 동작에 문제는 없지만, AndroidX Lifecycle 2.5+ 이후로는 viewModelFactory { initializer<ProductDetailViewModel> { ... } } DSL을 권장합니다. 보일러플레이트와 안전하지 않은 캐스트(as T)를 줄일 수 있으니, 시간이 되시면 적용해 보시고 차이를 비교해 보세요.

Lifecycle ViewModel — Factory APIs 문서를 참고하시면 됩니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt`
around lines 125 - 137, The current ProductDetailViewModelFactory uses the old
create(modelClass: Class<T>) override and an unsafe cast; replace it with the
AndroidX viewModelFactory DSL to remove boilerplate and the unchecked cast: stop
implementing ProductDetailViewModelFactory as a ViewModelProvider.Factory with
create(), and instead provide a factory via viewModelFactory {
initializer<ProductDetailViewModel> { ProductDetailViewModel(product,
cartRepository, recentProductRepository) } } (or equivalent registration) so
consumers obtain the instance without using as T and without manual class
checks; reference ProductDetailViewModelFactory, create, and
ProductDetailViewModel when locating the code to change.

96-122: ⚡ Quick win

누적 합산 방식의 가격 계산과 경계값 처리 점검

increaseCartItem/decreaseCartItemprice± product.price.value로 누적/차감합니다. 이 접근법에는 몇 가지 위험이 있습니다.

  • 누적 방식은 호출 누락이나 잘못된 상태 전이가 한 번이라도 일어나면 pricequantity * unitPrice와 어긋난 채로 표류합니다. 단가는 변하지 않으므로 매번 quantity * unitPrice로 파생 계산하면 어떻게 될지 비교해 보세요.
  • decreaseCartItem에는 하한 검사가 없습니다. UI에서 minusEnabled = quantity > 1로 비활성화하더라도, ViewModel 메서드 단독으로는 quantity = 0 혹은 음수까지 내려갈 수 있는 구조입니다. 도메인 모델(CartItemQuantity)에 최소값 제약이 있는지, ViewModel에서도 가드를 둘지 고려해 보세요.
  • 상한도 마찬가지로 생각해 보세요. 사용자가 수량을 임의로 무한 증가시키는 시나리오는 어떻게 다루어야 할지요?

“표시 가격은 항상 단가의 함수”라는 불변식을 ViewModel 안에 강제하면 이 두 메서드는 단순히 quantity 변경만 다루도록 줄어듭니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt`
around lines 96 - 122, The current increaseCartItem/decreaseCartItem mutate a
running price and allow quantity to go out of bounds; change both methods to
derive price from quantity * unitPrice instead of incrementing/decrementing
price directly, enforce a minimum quantity of 1 (and an optional maximum if your
domain requires one) inside the ViewModel (or delegate to CartItemQuantity if
present) before applying the change, and then update quantity, minusEnabled =
quantity > 1, and _uiState with productPrice = quantity * product.price.value so
the displayed price is always a pure function of quantity and unit price; ensure
decreaseCartItem prevents going below the minimum and increaseCartItem
optionally respects a configured max.

44-54: ⚡ Quick win

isLatestProduct / isLastProduct 명명 일관성과 의미 명확화

라인 44에서 isLatestProduct = (latestProduct?.id ?: product.id) == product.id로 계산하고, 라인 54에서는 UiState의 isLastProduct 필드로 매핑합니다. 다음을 검토해 보세요.

  • latestProduct가 null이면 isLatestProduct = true”라는 결론은 의도된 것인가요? 의미상 “최근 본 상품이 아직 없다”와 “지금 보고 있는 상품이 곧 최근 본 상품이다”는 다른 상태일 수 있습니다.
  • 필드 이름 isLastProduct는 영어 사용자에게는 “마지막 상품(목록 끝)”으로 읽힐 여지가 있습니다. UI 컴포넌트의 의도가 “직전에 본 상품 카드를 숨길지 여부”라면, 그에 부합하는 이름(예: hasPreviousProduct, shouldShowRecentCard 등)을 고려해 보세요.
  • ViewModel 내부 변수명(isLatestProduct)과 UiState 필드명(isLastProduct)이 다른 이유는 무엇인가요? 통일하면 화면 측의 매핑(isLatestProduct = uiState.isLastProduct)도 사라집니다.
🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt`
around lines 44 - 54, The boolean logic and naming are inconsistent: you compute
isLatestProduct = (latestProduct?.id ?: product.id) == product.id which treats
null latestProduct as true, then map it into ProductDetailUiState's
isLastProduct; fix by choosing a single clear name (e.g., hasPreviousProduct or
shouldShowRecentCard) used both in the ViewModel and ProductDetailUiState,
change the calculation to reflect intent (for example set hasPreviousProduct =
latestProduct != null && latestProduct.id == product.id if you mean "this
product is the previously viewed one", or hasPreviousProduct = latestProduct !=
null if you mean "there is a previous product"), and update the _uiState.update
call to pass the unified field name (replace isLatestProduct/isLastProduct with
the chosen identifier) so the ViewModel and UI state are consistent.
app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailScreen.kt (1)

180-209: ⚡ Quick win

“마지막으로 본 상품” 카드의 Modifier 순서 점검 필요

현재 체인 순서가 clip → padding(18.dp) → fillMaxWidth → height(80.dp) → border → clickable로 구성되어 있습니다. Compose에서 Modifier 순서는 시각/입력 동작에 직접적인 영향을 주는데, 다음 사항들을 한 번 검토해 보시면 좋겠습니다.

  • padding이 사이즈 지정(fillMaxWidth, height) 앞에 위치하면, 사이즈가 패딩으로 줄어든 영역 안에서 결정됩니다. 부모로부터 받은 영역 전체에 80dp 높이를 적용하고 싶었던 의도와 일치하나요?
  • clipborder보다 앞에 있으면 보더 자체가 클립되지 않습니다. 둥근 모서리에 보더를 깔끔히 맞추려면 어떤 순서가 적절할까요?
  • clickablepadding 뒤에 있어 ripple/포커스 영역이 패딩 안쪽으로만 한정됩니다. 카드 외곽 전체가 클릭 가능 영역이 되도록 하고 싶다면 어떤 순서가 좋을지 함께 고려해 보세요.

공식 문서의 Modifier order 절을 참고하시면 의도한 시각·인터랙션과 일치하는 순서를 정하시는 데 도움이 될 것입니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailScreen.kt`
around lines 180 - 209, The Modifier chain on the Column should be reordered so
visual size is determined before internal padding and the clickable/ripple and
border align with the clipped shape: move clickable to the front, then apply
clip and border, then size modifiers (fillMaxWidth, height) and finally padding;
update the Column's Modifier (the modifier passed to Column, and the
RoundedCornerShape usage) accordingly so the ripple covers the whole card, the
border matches the rounded corners, and the 80.dp height applies to the card's
outer size rather than being reduced by padding.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 094bfcbb-c6f3-4073-b479-5cd70dffafbb

📥 Commits

Reviewing files that changed from the base of the PR and between 3eff494 and 8abf755.

📒 Files selected for processing (56)
  • README.md
  • app/build.gradle.kts
  • app/src/main/AndroidManifest.xml
  • app/src/main/java/woowacourse/shopping/ShoppingApplication.kt
  • app/src/main/java/woowacourse/shopping/data/DataProvider.kt
  • app/src/main/java/woowacourse/shopping/data/MockData.kt
  • app/src/main/java/woowacourse/shopping/data/NetworkMonitor.kt
  • app/src/main/java/woowacourse/shopping/data/cart/CartRepositoryMockImpl.kt
  • app/src/main/java/woowacourse/shopping/data/product/ProductRepositoryMockImpl.kt
  • app/src/main/java/woowacourse/shopping/data/source/local/CartDatabase.kt
  • app/src/main/java/woowacourse/shopping/data/source/local/dao/CartDao.kt
  • app/src/main/java/woowacourse/shopping/data/source/local/dao/RecentProductDao.kt
  • app/src/main/java/woowacourse/shopping/data/source/local/entity/CartEntity.kt
  • app/src/main/java/woowacourse/shopping/data/source/local/entity/RecentProductEntity.kt
  • app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/cart/CartRepositoryMockImpl.kt
  • app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/cart/CartRepositoryRoomImpl.kt
  • app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/product/ProductRepositoryMockImpl.kt
  • app/src/main/java/woowacourse/shopping/data/source/local/repositoryImpl/recentProduct/RecentProductRepositoryRoomImpl.kt
  • app/src/main/java/woowacourse/shopping/data/source/remote/ProductEntity.kt
  • app/src/main/java/woowacourse/shopping/data/source/remote/ProductJsonData.kt
  • app/src/main/java/woowacourse/shopping/data/source/remote/ProductRemoteDataSource.kt
  • app/src/main/java/woowacourse/shopping/data/source/remote/ProductRepositoryRemoteImpl.kt
  • app/src/main/java/woowacourse/shopping/data/source/remote/ShoppingMockServer.kt
  • app/src/main/java/woowacourse/shopping/domain/RecentProductRepository.kt
  • app/src/main/java/woowacourse/shopping/domain/cart/model/Cart.kt
  • app/src/main/java/woowacourse/shopping/domain/cart/model/CartItem.kt
  • app/src/main/java/woowacourse/shopping/domain/cart/model/CartItemQuantity.kt
  • app/src/main/java/woowacourse/shopping/domain/cart/model/CartItems.kt
  • app/src/main/java/woowacourse/shopping/domain/cart/repository/CartRepository.kt
  • app/src/main/java/woowacourse/shopping/domain/product/repository/ProductRepository.kt
  • app/src/main/java/woowacourse/shopping/features/cart/CartActivity.kt
  • app/src/main/java/woowacourse/shopping/features/cart/CartScreen.kt
  • app/src/main/java/woowacourse/shopping/features/cart/CartStateHolder.kt
  • app/src/main/java/woowacourse/shopping/features/cart/CartUiModel.kt
  • app/src/main/java/woowacourse/shopping/features/cart/CartUiState.kt
  • app/src/main/java/woowacourse/shopping/features/cart/CartViewModel.kt
  • app/src/main/java/woowacourse/shopping/features/generalComponent/QuantityControlRow.kt
  • app/src/main/java/woowacourse/shopping/features/productDetail/ParcelProduct.kt
  • app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailActivity.kt
  • app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailScreen.kt
  • app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailStateHolder.kt
  • app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailUiState.kt
  • app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt
  • app/src/main/java/woowacourse/shopping/features/productList/ProductListActivity.kt
  • app/src/main/java/woowacourse/shopping/features/productList/ProductListScreen.kt
  • app/src/main/java/woowacourse/shopping/features/productList/ProductListStateHolder.kt
  • app/src/main/java/woowacourse/shopping/features/productList/ProductListUiState.kt
  • app/src/main/java/woowacourse/shopping/features/productList/ProductListViewModel.kt
  • app/src/main/java/woowacourse/shopping/features/productList/ProductUiModel.kt
  • app/src/test/java/woowacourse/shopping/cart/CartItemQuantityTest.kt
  • app/src/test/java/woowacourse/shopping/cart/CartItemTest.kt
  • app/src/test/java/woowacourse/shopping/cart/CartItemsTest.kt
  • app/src/test/java/woowacourse/shopping/cart/CartTest.kt
  • app/src/test/java/woowacourse/shopping/data/cart/CartRepositoryMockImplTest.kt
  • app/src/test/java/woowacourse/shopping/data/product/ProductRepositoryMockImplTest.kt
  • gradle/libs.versions.toml
💤 Files with no reviewable changes (5)
  • app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailStateHolder.kt
  • app/src/main/java/woowacourse/shopping/data/cart/CartRepositoryMockImpl.kt
  • app/src/main/java/woowacourse/shopping/features/cart/CartStateHolder.kt
  • app/src/main/java/woowacourse/shopping/data/product/ProductRepositoryMockImpl.kt
  • app/src/main/java/woowacourse/shopping/features/productList/ProductListStateHolder.kt

Comment thread app/build.gradle.kts
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
kotlin("plugin.serialization") version "2.1.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.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Kotlin Gradle plugin 2.3.0과 org.jetbrains.kotlin.plugin.serialization 2.1.0을 함께 사용할 수 있나요? 공식 호환성 문서 링크와 권장 버전 매칭 규칙을 알려주세요.

💡 Result:

아니요, Kotlin Gradle plugin 2.3.0과 org.jetbrains.kotlin.plugin.serialization 2.1.0을 함께 사용할 수 없습니다. serialization 플러그인은 Kotlin 컴파일러 버전(즉, Kotlin Gradle plugin 버전)과 정확히 일치해야 합니다.[1][2][3][4] 공식 문서와 예시에서 항상 동일한 버전을 사용하도록 명시되어 있습니다: - https://kotlinlang.org/docs/serialization.html (공식 Kotlin 문서, plugins { kotlin("jvm") version "2.3.21" kotlin("plugin.serialization") version "2.3.21" } [2]) - https://github.com/Kotlin/kotlinx.serialization (공식 GitHub README, kotlin("plugin.serialization") version "2.3.0" with kotlin("jvm") version "2.3.0" [1][3]) 호환성 정책: serialization 플러그인은 Kotlin 버전과 tandem으로 릴리스되며, 버전 불일치 시 컴파일 오류나 런타임 문제가 발생합니다. 예를 들어, 과거 이슈에서 Kotlin 1.9.10과 serialization 1.9.20 불일치가 문제를 일으켰습니다.[4][5] 권장 버전 매칭 규칙: Kotlin Gradle plugin (kotlin("jvm") 등) 버전과 org.jetbrains.kotlin.plugin.serialization 버전을 항상 동일하게 맞추세요. 예: plugins { kotlin("jvm") version "2.3.0" kotlin("plugin.serialization") version "2.3.0" }[1][2][6]

Citations:


Kotlin Serialization 플러그인 버전을 Kotlin 플러그인과 맞춰야 합니다.

Line 6의 kotlin("plugin.serialization") version "2.1.0"을 프로젝트의 Kotlin 버전 2.3.0으로 통일하세요. Kotlin Serialization 플러그인은 Kotlin 컴파일러 버전과 정확히 일치해야 하며, 공식 Kotlin 문서(kotlinlang.org/docs/serialization.html)에서도 동일 버전 사용을 명시하고 있습니다. 버전 불일치 시 컴파일 오류가 발생합니다.

🤖 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 `@app/build.gradle.kts` at line 6, The kotlin serialization plugin version is
mismatched; update the declaration kotlin("plugin.serialization") version
"2.1.0" to use the project's Kotlin version (2.3.0) so the serialization plugin
matches the Kotlin compiler version; locate the kotlin("plugin.serialization")
line in build.gradle.kts and change its version string to "2.3.0".

@Query("SELECT * FROM cart_table")
suspend fun getAll(): List<CartEntity>

@Insert(onConflict = OnConflictStrategy.Companion.REPLACE)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Android Room의 @insert(onConflict=...)에서 Kotlin 표기는 OnConflictStrategy.REPLACE가 맞나요? OnConflictStrategy.Companion.REPLACE 사용 가능 여부를 공식 문서 기준으로 확인해줘.

💡 Result:

결론: Room의 @Insert(onConflict=...)에서 Kotlin 표기는 OnConflictStrategy.REPLACE가 맞습니다. 공식 문서에서도 @Insert(onConflict = OnConflictStrategy.REPLACE)처럼 사용 예시와 REPLACE 상수가 명시돼 있습니다. [1][2] OnConflictStrategy.Companion.REPLACE 사용 가능 여부: 공식 문서(= API reference)에서 OnConflictStrategy는 “REPLACE 상수”로 제공되며, 사용 예시는 OnConflictStrategy.REPLACE 형태입니다. API reference에는 Companion.REPLACE 같은 형태를 사용하도록 안내/명시되어 있지 않습니다. 따라서 공식 문서 기준으로는 OnConflictStrategy.Companion.REPLACE를 써야 한다고 보기는 어렵고, 표준/권장 사용은 OnConflictStrategy.REPLACE입니다. [3][4][1] 참고로, 공식 문서에서 OnConflictStrategy.REPLACE의 의미는 “기존 행을 대체하고 트랜잭션을 계속”하는 전략입니다. [3][1]

Citations:


OnConflictStrategy.Companion.REPLACE 표기는 Room 공식 API 문서에 없습니다.

Android 공식 문서(androidx.room.OnConflictStrategy API reference)를 보면, @Insert(onConflict = OnConflictStrategy.REPLACE) 형태가 표준입니다. 현재 Companion을 거쳐서 접근하는 방식은 공식 권장과 맞지 않으므로 컴파일 오류가 발생할 가능성이 높습니다.

Line 15에서 Kotlin의 표준 상수 접근 방식으로 어떻게 수정할 수 있을지 한번 생각해보셨나요?

🤖 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 `@app/src/main/java/woowacourse/shopping/data/source/local/dao/CartDao.kt` at
line 15, Replace the non-standard usage of the Room conflict constant by
changing the `@Insert` annotation to use the enum constant directly: replace
OnConflictStrategy.Companion.REPLACE with OnConflictStrategy.REPLACE in the
CartDao (the `@Insert` annotation on the DAO method) so it follows the Room API
and Kotlin constant access conventions.

Comment on lines +34 to +58
init {
viewModelScope.launch {
addRecentProducts(product.id)

name = product.name.value
imageUrl = product.imageUrl.value
price = product.price.value
quantity = 1
minusEnabled = false
latestProduct = recentProductRepository.getMostRecentProduct()
isLatestProduct = (latestProduct?.id ?: product.id) == product.id

_uiState.update {
ProductDetailUiState(
productName = name,
productImageUrl = imageUrl,
productPrice = price,
quantity = quantity,
minusEnabled = minusEnabled,
latestProduct = latestProduct,
isLastProduct = isLatestProduct,
)
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

init에서 최근 본 상품 기록과 조회 사이의 경합 조건 가능성

addRecentProducts(product.id)는 내부에서 viewModelScope.launch { ... }로 별도 코루틴을 띄우기 때문에, 이 함수는 await하지 않고 즉시 반환됩니다. 그 직후 recentProductRepository.getMostRecentProduct()를 호출하면 다음 두 시나리오가 비결정적으로 발생합니다.

  1. write가 read보다 먼저 완료된 경우: 가장 최근 상품 = 방금 본 상품 ⇒ isLatestProduct = true, 카드 숨김
  2. write가 read보다 늦게 완료된 경우: 가장 최근 상품 = 직전에 본 다른 상품 ⇒ 카드 표시

기능 명세상 “마지막으로 본 상품” 카드는 어느 쪽이 정답인가요? 또한 다음을 확인해 보세요.

  • addRecentProduct는 suspend 함수인가요? 그렇다면 굳이 내부에서 launch로 감싸야 하는 이유는 무엇인가요? 호출 측 코루틴에서 await하도록 두는 편이 순서 보장에 유리하지 않을까요?
  • 만약 fire-and-forget이 필요한 자리가 따로 있다면, init처럼 “기록 → 조회”의 순서가 중요한 자리에서는 다른 동기화 방법을 쓸 수 있는지 검토해 보세요.
🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt`
around lines 34 - 58, The init block currently races because
addRecentProducts(product.id) launches a separate coroutine and returns
immediately before recentProductRepository.getMostRecentProduct() runs; change
the flow so the write completes before the read: make addRecentProducts a
suspend function (or expose a suspend helper) and call it directly from the init
coroutine (instead of having addRecentProducts launch its own coroutine), or
have addRecentProducts return a Job and call .join() before calling
recentProductRepository.getMostRecentProduct(); update
ProductDetailViewModel.init to await the write, then read latestProduct and
compute isLatestProduct, and then update _uiState so the "last viewed product"
decision is deterministic.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

코드래빗의 코멘트를 잘 읽어보시고 현재 어떤 문제가 왜 발생하는지 확인해보시면 좋겠네요.

Comment on lines +89 to +94
fun addToCart() {
viewModelScope.launch {
val cartItem = CartItem(product = product, quantity = CartItemQuantity(1))
cartRepository.addCartItem(cartItem, quantity)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

addToCart가 사용자가 선택한 수량을 무시할 가능성

val cartItem = CartItem(product = product, quantity = CartItemQuantity(1))
cartRepository.addCartItem(cartItem, quantity)

CartItem에 들어가는 수량은 1로 하드코딩되어 있고, 별도로 quantity가 두 번째 인자로 전달되고 있습니다. 다음을 확인해 보세요.

  • CartRepository.addCartItem(cartItem, quantity)의 시그니처에서 두 번째 quantity 인자가 무엇을 의미하나요? cartItem.quantity와의 관계는 어떻게 정의되어 있나요?
  • 사용자가 상세 화면에서 수량을 3으로 올린 뒤 “장바구니 담기”를 눌렀을 때, 실제로 장바구니에 3이 반영되는지 동작/테스트로 확인되어 있나요?
  • 만약 두 번째 인자가 “증가량”을 의미한다면, CartItem의 수량을 1로 고정하는 의도는 무엇인가요?

명확한 단일 API(예: addCartItem(CartItem(product, CartItemQuantity(quantity))))로 모으는 편이 호출 측 실수와 의미 충돌을 줄여줍니다.

🤖 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
`@app/src/main/java/woowacourse/shopping/features/productDetail/ProductDetailViewModel.kt`
around lines 89 - 94, The addToCart function builds CartItem with a hardcoded
CartItemQuantity(1) while also passing a separate quantity to
cartRepository.addCartItem, which risks ignoring the user's selected quantity;
change addToCart so the CartItem is constructed using the selected quantity (use
CartItem(product = product, quantity = CartItemQuantity(quantity))) and then
call cartRepository.addCartItem with a single, unambiguous API (either pass the
CartItem only or ensure the second parameter semantics match and remove the
hardcoded 1) to eliminate the discrepancy between CartItem.quantity and the
repository call.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

현재 CartItem이 수량을 나타내는 Quantity프로퍼티를 갖고 있지만, 실제로 DB에 값을 추가하는 로직은 어떻게 되나요?

id를 기반으로 저장되어 있는 수량을 가져온 뒤에 targetQuantity를 더해서 다시 저장을 합니다.

Quantity를 가지고 있는 의미가 있는 것일까요?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

이 리뷰에 대한 답변입니다.

이 어노테이션에서 나타내는 경고는 Parcelize 어노테이션의 패키지가 변경되었다는 경고입니다. 그렇기에 kotlinx.android.parcel에서 kotlinx.parcelize로 import 경로를 변경하였습니다.

Comment on lines -20 to +22
val product = intent.getParcelableExtra<ParcelProduct>("PRODUCT")!!
val stateHolder =
ProductDetailStateHolder(
cartRepository = DataProvider.cartRepository,
)
val parcelProduct = intent.getParcelableExtra<ParcelProduct>("PRODUCT")
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

이 리뷰에 대한 답변입니다!

Product가 넘어왔을 때 잘못되는 상황에 대해서 찾아봤습니다.

  • key가 불일치할 때
  • 타입이 불일치할 때
  • Intent에 아무것도 넣지 않고 보냈을 때

이런 상황들이 언박싱했을 때 null을 반환하는 상황이였습니다. 즉, 대개 잘못된 값을 언박싱했을 때는 null이 반환되는 사실을 알았습니다.

그러면 크롱이 남겨주신 리뷰의 의도를 생각해봤을 때, !!로 강제로 언박싱했을 때 잘못된 값을 풀면 생기는 문제를 해결하면 될 것입니다.

잘못된 값을 언박싱했을 때 null이 나오게되니, parcelProduct == null일 경우를 처리하면 된다고 생각했습니다. 그리고 null일 경우에는 finish()를 사용해 엑티비티를 끝내서, 상세 화면으로 넘어가지 못하게 막을 수 있습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

그리고 현재 잘 보면 컴파일러가 무언가를 알려주고 있을거에요.

어떻게 대응해볼 수 있을지 고민해보셔도 좋겠습니다.

Comment on lines +165 to +166
items = pageCartItems,
key = { item -> item.id },
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

이 리뷰에 대한 답변입니다.

compose가 id기반으로 리컴포지션을 감지하기 때문에 key를 적용해놓으면 변경된 아이템만 추적해서 불필요한 리컴포지션을 줄일 수 있습니다!

그래서 LazyColumn에 상품의 id를 이용해 고유한 값을 key로 적용했습니다.

Comment on lines +22 to +32
private val _uiState = MutableStateFlow(ProductDetailUiState())
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()

private var product: Product = parcelProduct.toProduct()
private var name = ""
private var imageUrl = ""
private var price = 0
private var quantity = 0
private var minusEnabled = false
private var isLatestProduct = true
private var latestProduct: Product? = null
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

이 리뷰이 리뷰에 대한 답변입니다.

두 부분이 비슷하다고 느껴져 한 코멘트로 답변드리겠습니다!

이 부분도 가시성을 private로 바꿔야하죠.

하지만 이렇게 상태를 각각 관리한다면 각각 변수를 관리해야되기에 유지보수에도 힘들고, 놓칠 수도 있다는 문제점이 있다는 것을 알았습니다. uiState라는 상태를 묶어서 UI에 보여지는 상태를 관리하는 방법이 있다는 것을 배웠습니다.

uiState로 묶는다면 viewModel에서 관리할 때는 아래와 같은 하나의 상태 묶음만 mutableStateOf로 추적하고, ui에 그릴 때도 uiState에서 관리되는 상태들을 호출하여 그릴 수 있습니다.

또한 ui에만 그려지는 도메인 상태들을 묶은 uiModel이라는 것을 만들었습니다. 이 uiModel이라는 것을 만든 이유는 uiModel이 없었을 경우 도메인 객체를 그대로 ui에 가져와서 그리게 된다는 점이 있었습니다. 이를 해결하려면 ui와 domain의 연결을 끊어야하기에 중간에 무언가가 필요했습니다.

그래서 ui 그리기에 필요한 도메인 상태들만을 묶은 uiModel이라는 것을 만들게 되었습니다. 이런 방식이면 viewModel에서만 도메인과 ui의 상호작용이 이루어지고, ui와 domain 사이에는 간섭이 없기에 의존성이 분리됩니다!

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

UI와 Domain의 연결을 끊어야 하는 이유는 뭐라고 생각하시나요?

Comment on lines +17 to +23
fun ProductUiModel.toProduct(): Product =
Product(
id = id,
name = ProductName(name),
price = Price(price),
imageUrl = ImageUrl(imageUrl),
)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

이 리뷰에 대한 답변입니다.

이 리뷰를 받고 사실 이해가 잘 되지 않았었습니다. 그래서 몇몇 크루들은 직렬화를 어떻게 했고, 어디에 구현했는지를 살짝, 아주 살짝 참고해봤습니다.

대체로 크롱의 말처럼 변환 함수가 그 클래스와 같은 위치에 존재했습니다. 위치보다 더 큰 깨달음을 얻은 것은 확장함수 개념을 사용한 것이였습니다.

저는 싱글톤을 사용해 전역에서 변환 함수를 불러왔었습니다. 이런 방식은 싱글톤이 선언된 클래스를 앞에 붙여야하며, 함수의 인자로 그 객체를 넣어줘야 했습니다. 하지만 확장 함수를 선언한다면, 그 객체 뒤에 확장함수로 선언한 함수를 붙여주면 쉽게 변환할 수 있었습니다.

그래서 해당 클래스로 변환하는 곳에 확장함수를 선언하면서 싱글톤을 지향하고 더 깔끔하게 변환을 할 수 있는 방법으로 수정하였습니다!


fun loadCartPage() {
viewModelScope.launch {
if (!Cart.isPageValid(currentPage)) currentPage = 0
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

이 리뷰에 대한 답변입니다!

하나를 고쳤다 생각하면 다른 곳에서 또 구멍이 나고…. 리뷰를 받을 때마다 계속 무언가를 놓치게 되는 것 같네요…

확실히 UI에서 원천적으로 들어오는 것은 막았지만, 도메인 자체에서는 getPage()에 들어가는 인자를 검증할 수 없기에 getPage(-1, 0)같은 식으로 도메인을 따로 호출한다면 예외가 발생해서 앱이 터질 것입니다.

그래서 기존 아이디어와 이번에 반영한 아이디어를 섞어서 해결해보고자 했습니다.

도메인에서 page의 값을 검증하는 isPageValid()라는 함수를 싱글톤으로 만들어 getPage()에서 require을 사용해 page 값이 조건과 맞지 않는다면 예외를 던집니다. 이것만 사용했을 때의 문제점이 UI에서 getPage()를 호출하며 page가 조건과 일치하지 않는다면 앱이 터진다는 것이였습니다.

이 문제를 해결하기 위해 싱글톤으로 도메인과 같은 조건을 UI에서 먼저 currentPage가 isPageValid를 만족하는지를 검증해보고, 조건과 일치하지 않는다면 현재 페이지를 0으로 만드는 방식으로 문제를 해결했습니다.

즉, 도메인과 UI에서 같은 page 검증 조건을 사용하는 것이죠. 그렇게 한다면 도메인 스스로도 검증할 수 있으면서 UI에서 도메인과 동일한 검증 조건으로 먼저 검증하고 처리하기 때문에 앱이 터질 일도 없다고 생각했습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

해당 리뷰를 반영하면서 도메인이 왜 자신만의 규칙을 가지고 있어야 하는지, UI에서 방어로직을 만들면 되는 것이 아닌가 하는 부분에 대해 좀 더 명확히 이해하신 것 같아 좋습니다.

하나를 고쳤다 생각하면 다른 곳에서 또 구멍이 나고…. 리뷰를 받을 때마다 계속 무언가를 놓치게 되는 것 같네요…

지금 배워가는 단계이고, 이러한 이유 때문에 코드리뷰가 필요한 것이 아닐까요? 😄

Copy link
Copy Markdown

@krrong krrong left a comment

Choose a reason for hiding this comment

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

안녕하세요 하로,
이번 미션 구현하느라 고생 많으셨습니다.
전반적으로 잘 구현해주신 것 같은데요, 이전 미션에서 받은 코멘트들도 잊지 않고 잘 반영해주셨네요 👍

이전 미션에서 되돌렸던 것들을 이번 미션에서 다시 적용해보셨을텐데, 이번에는 어느정도의 이해를 기반으로 구현해보는 경험이었으면 좋겠습니다.

고민해볼만한 부분들에 코멘트를 남겨두었으니, 코드래빗의 코멘트와 함께 확인해주세요 🙇

Comment on lines +34 to +58
init {
viewModelScope.launch {
addRecentProducts(product.id)

name = product.name.value
imageUrl = product.imageUrl.value
price = product.price.value
quantity = 1
minusEnabled = false
latestProduct = recentProductRepository.getMostRecentProduct()
isLatestProduct = (latestProduct?.id ?: product.id) == product.id

_uiState.update {
ProductDetailUiState(
productName = name,
productImageUrl = imageUrl,
productPrice = price,
quantity = quantity,
minusEnabled = minusEnabled,
latestProduct = latestProduct,
isLastProduct = isLatestProduct,
)
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

코드래빗의 코멘트를 잘 읽어보시고 현재 어떤 문제가 왜 발생하는지 확인해보시면 좋겠네요.

Comment on lines +89 to +94
fun addToCart() {
viewModelScope.launch {
val cartItem = CartItem(product = product, quantity = CartItemQuantity(1))
cartRepository.addCartItem(cartItem, quantity)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

현재 CartItem이 수량을 나타내는 Quantity프로퍼티를 갖고 있지만, 실제로 DB에 값을 추가하는 로직은 어떻게 되나요?

id를 기반으로 저장되어 있는 수량을 가져온 뒤에 targetQuantity를 더해서 다시 저장을 합니다.

Quantity를 가지고 있는 의미가 있는 것일까요?

Comment on lines +22 to +32
private val _uiState = MutableStateFlow(ProductDetailUiState())
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()

private var product: Product = parcelProduct.toProduct()
private var name = ""
private var imageUrl = ""
private var price = 0
private var quantity = 0
private var minusEnabled = false
private var isLatestProduct = true
private var latestProduct: Product? = null
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

UI와 Domain의 연결을 끊어야 하는 이유는 뭐라고 생각하시나요?


fun loadCartPage() {
viewModelScope.launch {
if (!Cart.isPageValid(currentPage)) currentPage = 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.

해당 리뷰를 반영하면서 도메인이 왜 자신만의 규칙을 가지고 있어야 하는지, UI에서 방어로직을 만들면 되는 것이 아닌가 하는 부분에 대해 좀 더 명확히 이해하신 것 같아 좋습니다.

하나를 고쳤다 생각하면 다른 곳에서 또 구멍이 나고…. 리뷰를 받을 때마다 계속 무언가를 놓치게 되는 것 같네요…

지금 배워가는 단계이고, 이러한 이유 때문에 코드리뷰가 필요한 것이 아닐까요? 😄

Comment on lines +74 to +93
ProductUiModel(
id = product.id,
name = product.name.value,
price = product.price.value,
imageUrl = product.imageUrl.value,
quantity =
cartRepository.getQuantity(
CartItem(
product = product,
quantity = CartItemQuantity(1),
),
),
isExistProductToCart =
cartRepository.isCartItemExist(
CartItem(
product = product,
quantity = CartItemQuantity(1),
),
),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ProductUiModel.toProduct 확장함수를 만들어주신 것처럼 Product.toUiModel이라는 확장함수를 만들어줄 수도 있습니다.

그리고 이러한 역할을 하는 친구들을 일반적으로는 Mapper라고 부르기도 해요 😄

import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged

class NetworkMonitor(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

NetworkMonitor는 무엇을 하는 녀석인지 설명해주시고, 현재 단계에서 필요한 녀석인지 한 번 고민해볼까요?

cartItem: CartItem,
targetQuantity: Int,
) {
var currentQuantity = getQuantity(cartItem)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

val vs var?

Comment on lines -20 to +22
val product = intent.getParcelableExtra<ParcelProduct>("PRODUCT")!!
val stateHolder =
ProductDetailStateHolder(
cartRepository = DataProvider.cartRepository,
)
val parcelProduct = intent.getParcelableExtra<ParcelProduct>("PRODUCT")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

그리고 현재 잘 보면 컴파일러가 무언가를 알려주고 있을거에요.

어떻게 대응해볼 수 있을지 고민해보셔도 좋겠습니다.

abstract fun recentProductDao(): RecentProductDao

companion object {
@Volatile
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Volatile 어노테이션은 어떤 역할을 하며, 무엇을 위해 추가해주셨을까요?

Comment on lines +6 to +7
class ProductRepositoryRemoteImpl(
private val remoteDataSource: ProductRemoteDataSource,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ProductRepositoryRemoteImpl는 단순히 ProductRemoteDataSource를 생성자로 주입받고 있습니다.

그리고 항상 API를 통해 외부의 값을 받아오는 역할을 받아오기만 해요.

현재 시점에서 RepositoryDataSource가 분리되어 얻는 이점은 무엇이라고 생각하는지 하로의 의견이 궁금합니다.

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