일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- Stack
- 혼자 공부하는 C언어
- Serialization
- 윤성우 열혈자료구조
- s
- C 언어 코딩 도장
- Graph
- Selection Sorting
- coding test
- stream
- 알기쉬운 알고리즘
- 이스케이프 문자
- JSON
- 이것이 자바다
- 윤성우의 열혈 자료구조
- insertion sort
- buffer
- Algorithm
- list 컬렉션
- R
- C programming
- datastructure
- 메모리구조
- Today
- Total
Engineering Note
[SW Engineering] 이커머스 장바구니 조회 기능 구현: 복잡한 연관관계를 단일 쿼리로 해결하고, 복합 인덱스로 쿼리 최적화 본문
[SW Engineering] 이커머스 장바구니 조회 기능 구현: 복잡한 연관관계를 단일 쿼리로 해결하고, 복합 인덱스로 쿼리 최적화
Software Engineer Kim 2025. 9. 24. 18:55이커머스 프로젝트에서 장바구니 조회 기능을 구현하면서, 여러 테이블에 분산된 데이터를 효율적으로 조회하는 방법을 고민했습니다. N+1문제를 피하고 성능을 최적화하면서도 필요한 모든 정보를 한 번에 가져오는 과정을 공유합니다.
요구사항 분석
사용자 스토리:
로그인한 사용자가 장바구니 페이지에 접속하면, 담아둔 상품들의 이름, 가격, 수량, 대표 이미지를 확인할 수 있어야 한다.
기숙적 요구사항
- 한 번의 쿼리로 모든 필요한 정보 조회 (N+1 문제 방지)
- 상품의 대표 이미지만 표시 (상품당 여러 이미지 중 하나만 표시)
- 장바구니가 비어있는 경우 예외 처리
데이터 모델 설계
프로젝트의 핵심 Entity들과 연관관계입니다.
// 장바구니 (회원당 1개)
@Entity
@Table(name = "cart")
@Getter @Setter
@ToString
public class Cart extends BaseEntity {
@Id
@Column(name = "cart_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
public static Cart createCart(Member member) {
Cart cart = new Cart();
cart.setMember(member);
return cart;
}
}
// 장바구니 상품 (장바구니, 상품의 릴레이션 테이블)
@Entity
@Table(name = "cart_item")
@Getter
@Setter
public class CartItem extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "cart_item_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id")
private Cart cart;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
private int count;
public static CartItem createCartItem(Cart cart, Item item, int count) {
CartItem cartItem = new CartItem();
cartItem.setCart(cart);
cartItem.setItem(item);
cartItem.setCount(count);
return cartItem;
}
public void addCount(int count) {
this.count += count;
}
public void updateCount(int count){
this.count = count;
}
}
// 상품
@Entity
@Table(name="item")
@Getter
@Setter
@ToString
public class Item extends BaseEntity {
@Id
@Column(name="item_id")
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String itemNm;
@Column(name="price", nullable = false)
private int price;
@Column(nullable = false)
private int stockNumber;
@Lob
@Column(nullable = false)
private String itemDetail;
@Enumerated(EnumType.STRING)
private ItemSellStatus itemSellStatus;
public void updateItem(ItemFormDto itemFormDto) {
this.itemNm = itemFormDto.getItemNm();
this.price = itemFormDto.getPrice();
this.stockNumber = itemFormDto.getStockNumber();
this.itemDetail = itemFormDto.getItemDetail();
this.itemSellStatus = itemFormDto.getItemSellStatus();
}
public void removeStock(int stockNumber) {
int restStock = this.stockNumber - stockNumber;
if(restStock < 0) {
throw new OutOfStockException("상품 재고가 부족합니다. (현재 재고 수량: " + this.stockNumber + ")");
}
this.stockNumber = restStock;
}
public void addStock(int stockNumber) {
this.stockNumber += stockNumber;
}
}
// 상품 이미지 (상품당 여러개, 그 중 대표이미지 1개)
@Entity
@Table(name = "item_img")
@Getter @Setter
public class ItemImg extends BaseEntity {
@Id
@Column(name = "item_img_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String imgName; //이미지 파일명
private String oriImgName; //원본 이미지 파일명
private String imgUrl; //이미지 경로
private String repImgYn; //대표 이미지 여부
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
public void updateItemImg(String oriImgName, String imgName, String imgUrl) {
this.oriImgName = oriImgName;
this.imgName = imgName;
this.imgUrl = imgUrl;
}
}
ERD 관점에서 보면:
- Member(1) ↔ Cart(1) : 일대일
- Cart(1) ↔ CartItem(N) : 일대다
- Item(1) ↔ CartItem(N) : 일대다
- Item(1) ↔ ItemImg(N) : 일대다
서비스 레이어 구현
사용자 인증 정보에서 시작해서 장바구니 데이터를 조회하는 전체 흐름입니다.
@Service
@Transactional(readOnly = true)
public class CartService {
public List<CartDetailDto> getCartList(String email) {
// 1. 이메일로 회원 조회
Member member = memberRepository.findByEmail(email);
if (member == null) {
return new ArrayList<>();
}
// 2. 회원의 장바구니 조회
Cart cart = cartRepository.findByMemberId(member.getId());
if (cart == null) {
return new ArrayList<>(); // 장바구니가 없으면 빈 리스트
}
// 3. 장바구니 상세 정보 조회 (핵심 쿼리)
return cartItemRepository.findCartDetailDtoList(cart.getId());
}
}
설계 포인트:
- @Transactional(readOnly = true):조회 전용 최적화
- Early Return 패턴으로 예외 상황 처리
- 비즈니스 로직과 데이터 베이스 조회 로직 분리(서비스 레이어, 레포지토리 레이어 분리)
쿼리 설계 과정
여기서부터가 핵심입니다. 4개 테이블에서 필요한 데이터를 효율적으로 가져오는 쿼리를 단계별로 만들어보겠습니다.
1단계: 기본 장바구니 상품 조회
SELECT *
FROM CART_ITEM
WHERE cart_id = 1;
이 쿼리로는 cart_id, item_id, count만 얻을 수 있습니다. 상품 정보가 필요합니다.
2단계: 상품 정보 조인
SELECT ci.cart_id, ci.item_id, ci.count, i.item_nm, i.price
FROM CART_ITEM ci
LEFT JOIN ITEM i ON ci.item_id = i.item_id
WHERE ci.cart_id = 1;
2단계: 상품 정보 조인
SELECT ci.cart_id, ci.item_id, ci.count, i.item_nm, i.price
FROM CART_ITEM ci
LEFT JOIN ITEM i ON ci.item_id = i.item_id
WHERE ci.cart_id = 1;
LEFT JOIN을 선택한 이유:
- 상품이 삭제되어도 장바구니 항목은 표시되어야 함
- 데이터 정합성 문제 상황에서도 안전
3단계: 대표 이미지 추가
SELECT ci.cart_id, ci.item_id, ci.count,
i.item_nm, i.price, ii.img_url
FROM CART_ITEM ci
LEFT JOIN ITEM i ON ci.item_id = i.item_id
LEFT JOIN ITEM_IMG ii ON ii.item_id = i.item_id
WHERE ci.cart_id = 1 AND ii.rep_img_yn = 'Y';
주의사항:
- rep_img_yn = 'Y' 조건으로 대표 이미지만 선택
- 이미지가 없는 상품도 표시되도록 LEFT JOIN 사용
실제 Repository 구현
JPA 환경에서 네이티브 쿼리로 구현한 예시입니다.
@Repository
public interface CartItemRepository extends JpaRepository<CartItem, Long> {
@Query(value = """
SELECT new com.shop.dto.CartDetailDto(
ci.id, i.itemNm, i.price, ci.count, ii.imgUrl
)
FROM CartItem ci
LEFT JOIN ci.item i
LEFT JOIN ItemImg ii ON ii.item.id = i.id
WHERE ci.cart.id = :cartId
AND ii.repImgYn = 'Y'
ORDER BY ci.createdTime DESC
""")
List<CartDetailDto> findCartDetailDtoList(@Param("cartId") Long cartId);
}
JPQL 사용 이유:
- Entity 기반 쿼리로 타입 안정성 확보
- 생성자 프로젝션으로 DTO 직접 매핑
- 최신 등록순 정렬로 사용자 경험 개선
DTO 설계
@Getter
public class CartDetailDto {
private Long cartItemId;
private String itemNm;
private int price;
private int count;
private String imgUrl;
public CartDetailDto(Long cartItemId, String itemNm, int price,
int count, String imgUrl) {
this.cartItemId = cartItemId;
this.itemNm = itemNm;
this.price = price;
this.count = count;
this.imgUrl = imgUrl;
}
// 총 가격 계산 메서드
public int getTotalPrice() {
return price * count;
}
}
성능 최적화 포인트
1. 단일 쿼리 전략
- 4개 테이블 JOIN으로 한 번에 조회
- N+1 문제 완전 방지
2. 인덱스 설계
인덱스가 필요한 이유
최종 쿼리를 다시 보면:
SELECT ci.cart_id, ci.item_id, ci.count, i.item_nm, i.price, ii.img_url
FROM cart_item ci
LEFT JOIN item i ON ci.item_id = i.item_id
LEFT JOIN item_img ii ON ii.item_id = i.item_id
WHERE ci.cart_id = 1 AND ii.rep_img_yn = 'Y';
이 쿼리에서 item_img 테이블에 대한 조건은 다음과 같습니다:
- ii.item_id = i.item_id (JOIN 조건)
- ii.rep_img_yn = 'Y' (WHERE 조건)
단일 인덱스 vs 복합 인덱스 성능 비교
단일 인덱스만 있는 경우 (복합 인덱스 적용 전 상태)
-- JPA가 자동 생성한 FK 인덱스
CREATE INDEX FK_item_id ON item_img(item_id);
실행 과정:
- item_id 인덱스로 특정 상품의 이미지들 조회 (예: 5개 발견)
- 5개 레코드를 실제 테이블에서 읽어서 rep_img_yn = 'Y' 조건 확인
- 최종 1개 결과 반환
문제점: 불필요한 테이블 접근이 발생 (디스크 I/O 증가)
복합 인덱스 적용
CREATE INDEX idx_item_img_item_rep ON item_img(item_id, rep_img_yn);
실행 과정:
- 복합 인덱스에서 (item_id, rep_img_yn = 'Y') 조건으로 한 번에 1개 발견
- 해당 레코드만 테이블에서 읽어서 img_url 가져오기
개선점: 불필요한 테이블 접근 제거, 디스크 I/O 최소화
복합 인덱스 컬럼 순서 선택 이유
-- 올바른 순서
CREATE INDEX idx_item_img_item_rep ON item_img(item_id, rep_img_yn);
-- 잘못된 순서
CREATE INDEX idx_wrong ON item_img(rep_img_yn, item_id);
카디널리티(Cardinality) 분석:
- item_id: 높은 카디널리티 (1, 2, 3, 4... 수천 개의 서로 다른 값)
- rep_img_yn: 낮은 카디널리티 ('Y', 'N' 단 2개 값만)
인덱스 효율성:
- item_id로 먼저 대부분의 데이터 필터링 (1000개 → 5개)
- rep_img_yn으로 세밀한 필터링 (5개 → 1개)
만약 순서를 바꾸면 rep_img_yn으로만 절반 정도 필터링되어 비효율적입니다.
3. 페이징 고려사항
장바구니 항목이 많은 경우를 대비한 페이징 처리:
방법 1: @Query 없이 메서드명 기반
public interface CartItemRepository extends JpaRepository<CartItem, Long> {
@Query("SELECT new com.shop.dto.CartDetailDto(ci.id, i.itemNm, i.price, ci.count, ii.imgUrl) " +
"FROM CartItem ci " +
"LEFT JOIN ci.item i " +
"LEFT JOIN ItemImg ii ON ii.item.id = i.id " +
"WHERE ci.cart.id = :cartId AND ii.repImgYn = 'Y'")
Page<CartDetailDto> findCartDetailDtoList(@Param("cartId") Long cartId, Pageable pageable);
}
Spring Data JPA가 자동으로:
- ORDER BY와 LIMIT/OFFSET 추가
- count 쿼리 자동 생성
사용 예시:
// 서비스에서 호출
Pageable pageable = PageRequest.of(2, 10, Sort.by("createdTime").descending());
Page<CartDetailDto> result = cartItemRepository.findCartDetailDtoList(cartId, pageable);
실제 실행되는 SQL:
-- Spring Data JPA가 자동으로 변환
SELECT ... FROM cart_item ci WHERE ci.cart_id = ?
ORDER BY ci.created_time DESC -- Pageable의 Sort 정보
LIMIT 10 OFFSET 20 -- Pageable의 페이지 정보
방법 2: countQuery가 필요한 경우
@Query(value = "SELECT new com.shop.dto.CartDetailDto(...) " +
"FROM CartItem ci " +
"LEFT JOIN ci.item i " +
"LEFT JOIN ItemImg ii ON ii.item.id = i.id " +
"WHERE ci.cart.id = :cartId AND ii.repImgYn = 'Y'",
countQuery = "SELECT count(ci) " +
"FROM CartItem ci " +
"LEFT JOIN ItemImg ii ON ii.item.id = ci.item.id " +
"WHERE ci.cart.id = :cartId AND ii.repImgYn = 'Y'")
Page<CartDetailDto> findCartDetailDtoList(@Param("cartId") Long cartId, Pageable pageable);
배운 점과 개선 방향
성공 요인
- 단일 쿼리 전략: 복잡한 연관관계를 한 번에 해결
- LEFT JOIN 활용: 데이터 누락 방지
- 생성자 프로젝션: 타입 안전한 DTO 매핑
마무리
이커머스에서 장바구니는 핵심 기능 중 하나입니다. 단순해 보이지만 여러 테이블의 데이터를 효율적으로 조합해야 하는 복잡한 쿼리가 필요합니다.
이번 구현을 통해 복잡한 연관관계를 단일 쿼리로 해결하는 접근법과 예외 상황까지 고려한 안전한 설계의 중요성을 다시 한번 깨달았습니다. 비슷한 기능을 구현하시는 분들에게 도움이 되기를 바랍니다.
'SW Engineering' 카테고리의 다른 글
[SW Engineering] 구체적인 사례로 보는 JavaScript Stack Trace 읽는 법 (0) | 2025.09.27 |
---|---|
[SW Engineering] AJAX 응답 타입 불일치로 인한 TypeError 해결기 (0) | 2025.09.27 |
[SW Engineering] JPA @Transactional 어노테이션의 동작방법과 JPA 영속성 컨테스트 (0) | 2025.09.24 |
[SW Engineering] JPQL에서 FETCH JOIN과 DTO 생성자 패턴 충돌 해결하기 (0) | 2025.09.23 |
[SW Engineering] JPQL join, fetch join (0) | 2025.09.23 |