[Claude Code] Claude Code로 서점 웹 만들기 - 화면 설계서로 상세페이지 만들기
목차
지난 글에서 만든 storeApp에 상품 상세페이지를 추가했다.
이번엔 코드만 짜달라고 한 게 아니라, 대충 그린 화면 설계서와 시나리오 문서를 던져주+고 그대로 구현이 되는지 실험해봤다.
이번에 하고 싶었던 것
기존 storeApp에는 메인 화면(상품 목록)과 장바구니 화면만 있었고, 상품 하나를 눌러서 들어가는 상세페이지가 없었다.
작업 전에 아래 두 가지를 먼저 준비했다.
1. 화면 설계서 — PPT로 대충 그린 목업. 왼쪽에 이미지, 오른쪽에 제목/저자·출판사/가격/수량 조절/버튼 2개, 그 아래 설명.

2. 시나리오 텍스트
<조회>
1. 메인화면에서 책 한권의 이미지or제목 영역을 클릭하면 상세페이지로 넘어옴
2. 초기화면은 사진과 같고 각각 내용이 뜨면됨
<구매 버튼 클릭>
1. 구매 버튼 클릭시 상품 수량만큼 담아서 구매페이지로 넘어감
<장바구니 버튼 클릭>
1. 상품 수량만큼 장바구니 추가
이 두 개만 주고 나머지는 Claude Code가 알아서 판단하게 했다.
Claude Code로 한 작업들
1. 목업을 실제 화면으로
목업 이미지를 그대로 보여주고 “화면 그림은 첨부한 사진대로 가주면 됨”이라고만 전달했는데, 레이아웃(좌측 이미지 / 우측 정보 / 하단 설명), 수량 증감 버튼(↓ ↑), 버튼 2개 배치까지 거의 그대로 맞춰서 detail.html을 만들어줬다.

<div class="qty-stepper">
<button class="qty-btn" onclick="changeQty(-1)">↓</button>
<input class="qty-value" id="qtyInput" type="number" value="1" min="1" />
<button class="qty-btn" onclick="changeQty(1)">↑</button>
</div>
<div class="detail-actions">
<button class="btn btn-primary" onclick="addToCart()">장바구니</button>
<button class="btn btn-primary" onclick="goPurchase()">구매</button>
</div>
메인 화면 상품 카드도 “이미지or제목 영역 클릭 시 이동”이라는 시나리오에 맞춰서, 담기 버튼만 event.stopPropagation()으로 분리하고 카드 전체를 클릭 가능하게 바꿔줬다. 이 부분은 내가 구체적으로 지시하지 않았는데 시나리오 문맥만으로 알아서 이렇게 구현했다.
2. 장바구니 상품 수량으로 인한 구조 변경
시나리오에 “상품 수량만큼 장바구니 추가”라고 적어뒀는데, 정작 기존 장바구니 테이블은 User-Product 다대다 관계라 수량 개념 자체가 없었다. 같은 책은 1권만 담기고 중복 담으면 에러가 나는 구조였다.
이 부분은 Claude Code가 먼저 짚어줬다. “지금 구조로는 수량을 못 담는데, cart 테이블에 quantity 컬럼을 추가하는 구조 변경이 필요하다”고 먼저 물어보고, 승인하니 아래처럼 바꿔줬다.
Cart엔티티 신설 (User,Product,quantity)User의@ManyToMany products필드 제거CartService/CartServiceImpl/CartController전면 수정 — 같은 상품 재담기 시 수량 누적
Optional<Cart> existing = cartRepository.findByUserIdAndProductId(userId, productId);
if (existing.isPresent()) {
Cart cart = existing.get();
cart.setQuantity(cart.getQuantity() + quantity); // 누적
cartRepository.save(cart);
} else {
cartRepository.save(Cart.builder()
.user(userOpt.get())
.product(productOpt.get())
.quantity(quantity)
.build());
}
기존에는 이미 담긴 상품을 또 담으면 409 에러였는데, 이젠 수량이 더해지는 방식으로 동작이 바뀐 것. 화면 하나 추가하려던 게 백엔드 데이터 모델 변경까지 이어진 케이스였다.
3. 실제로 브라우저 켜서 검증하다 나온 버그
기능 다 만들고 브라우저로 직접 눌러보라고 시켰는데, 장바구니 조회에서 500 에러가 났다.
No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor
Cart.product가 지연 로딩(LAZY)이라 프록시 객체가 그대로 JSON으로 직렬화되면서 터진 에러였다. 로그를 스스로 읽고 원인(LAZY → EAGER)까지 짚어서 바로 고쳤다.
LAZY와 EAGER는 뭐가 다른가
JPA에서 @ManyToOne, @OneToOne 같은 연관관계에는 fetch 옵션을 줄 수 있는데, 이게 연관된 엔티티를 언제 조회할지를 결정한다.
@ManyToOne(fetch = FetchType.LAZY) // 실제로 접근하는 시점에 조회
@JoinColumn(name = "bookID")
private Product product;
@ManyToOne(fetch = FetchType.EAGER) // Cart를 조회하는 즉시 함께 조회
@JoinColumn(name = "bookID")
private Product product;
- EAGER:
Cart를 조회하면 연관된Product도 그 자리에서 바로 JOIN해서 함께 가져온다. 실제Product객체가 채워진다. - LAZY:
Cart를 조회하는 시점엔Product를 아직 안 가져온다. 대신 하이버네이트가 만든 프록시 객체(진짜 Product를 흉내 내는 가짜 객체)를 채워두고,getName()처럼 실제 필드에 접근하는 순간에야 DB를 다시 조회해서 값을 채운다.
불필요한 연관 데이터까지 매번 다 긁어오면 성능에 안 좋으니, LAZY가 기본값이고 일반적으로 권장되는 방식이다.
왜 LAZY 때문에 500 에러가 났나
문제는 Cart.product가 LAZY일 때 cart.getProduct()가 반환하는 게 진짜 Product 객체가 아니라, 하이버네이트가 그 자리에서 만들어낸 프록시 객체(Product를 상속한 Product$HibernateProxy...라는 별도 클래스)라는 점이었다.
List<CartItemResponse> items = cartService.getCartItems(userId).stream()
.map(cart -> new CartItemResponse(
cart.getProduct().getId(),
cart.getProduct().getName(),
...
이 코드에서 getName() 같은 필드에 접근하면 그 순간 하이버네이트가 실제 DB를 조회해서 값을 채워주긴 하지만, 그렇다고 객체 자체가 진짜 Product로 바뀌는 건 아니다. 여전히 껍데기는 프록시 클래스 그대로다. 이 프록시 클래스는 내부적으로 hibernateLazyInitializer 같은 하이버네이트 전용 필드를 갖고 있는데, Jackson(Spring이 JSON 변환에 쓰는 라이브러리)이 이 객체를 통째로 직렬화하려다가 그 필드를 어떻게 JSON으로 바꿔야 할지 몰라서 아래 에러를 던진 것이다.
No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor
정리하면, 문제의 본질은 “초기화가 안 됐다”가 아니라 “Jackson이 다루는 객체의 실제 클래스가 Product가 아니라 하이버네이트 프록시 클래스였다”는 것이다. fetch = FetchType.EAGER로 바꾸면 Cart를 조회하는 순간 Product를 곧바로 JOIN해서 실제 엔티티로 채워주기 때문에, 애초에 프록시가 만들어지지 않아 문제가 사라진다.
4. 장바구니 화면에 수량이 안 보이던 것
상세페이지에서 수량을 담게 만들었는데, 정작 장바구니 화면(cart.html)에는 수량이 표시되지 않고 있었다. API 응답 자체에 quantity가 없었던 것.
“장바구니 화면에서 수량이 적용이 안되어있어. 수량만큼 표시되고 금액도 맞게 수정되도록 변경해줘” 한 줄로 요청했더니, API 응답 DTO에 quantity를 추가하고 화면에도 가격 × 수량 = 소계, 하단 합계까지 정확히 반영해줬다.
32,000원 × 2권 = 64,000원
...
총 3종 | 합계 116,000원
Claude Code 사용 소감
1. 화면 설계서 기반으로 구현이 되는지 확인해봤는데, 잘 됐다
정교한 피그마 목업이 아니라 PPT로 대충 그린 그림 한 장이었는데도 레이아웃, 요소 배치, 수량 스테퍼 모양까지 거의 그대로 구현됐다. “이 그림대로 만들어줘” 수준의 지시만으로도 충분했다.
2. 테스트 코드까지 알아서 짜주니 편한데, 예외 케이스는 내가 더 꼼꼼히 알고 있어야겠다
Cart 구조가 바뀔 때마다 테스트 코드도 같이 고쳐줬고 통과 여부까지 확인해줬다. 편하긴 한데, 반대로 “수량 누적이 맞는지”, “존재하지 않는 유저/상품이면 어떻게 되는지” 같은 예외 케이스를 내가 명확히 정의해주지 않으면 테스트도 그 수준에서 멈춘다는 걸 느꼈다. 실제로 500 에러는 유닛테스트가 아니라 브라우저로 직접 눌러봐서 발견한 것이었다. 테스트를 대신 짜주는 것과, 어떤 케이스를 테스트해야 하는지 아는 것은 별개라는 생각이 들었다.
3. 화면 시나리오가 머릿속에 명확하면 알아서 잘 되는 것 같다
“조회 → 구매 버튼 → 장바구니 버튼” 세 시나리오를 텍스트로 던져줬을 뿐인데, 그 안에서 구조적으로 필요한 것(수량 컬럼, DTO 변경, 이벤트 버블링 처리)까지 알아서 짚어냈다. 반대로 생각하면, 내가 시나리오를 애매하게 갖고 있었다면 결과물도 애매했을 것 같다. 잘 쓰려면 결국 내가 뭘 만들고 싶은지부터 명확해야 한다.
결론
이번엔 “코드를 짜달라”가 아니라 “이 화면대로, 이 시나리오대로 만들어달라”는 식으로 요청해봤는데, 오히려 이 방식이 더 잘 맞았다. 대신 화면 설계와 시나리오, 그리고 완성된 결과물을 실제로 눌러보며 검증하는 몫은 여전히 내 쪽에 있다는 걸 다시 확인한 작업이었다.