숲 꾸미기 서비스 개요
아이템을 캐시로 구매 -> 보유 -> 실제 공간에 배치
ex. 싸이월드 미니룸


ERD 설계

Item (상점 아이템)
└─ MyItem (사용자가 보유한 아이템)
└─ UsingItem (공간에 배치된 아이템)
Item - 상점에 존재하는 아이템
- 상점에서 판매되는 원본 아이템
- 가격, 이미지, 카테고리 등 변하지 않는 정보 관리
주요 필드
Item
- itemId (PK)
- name
- price
- imageUri
- category
- createdAt
- updatedAt
특징
- 사용자와 직접적인 관계 X
- 여러 사용자가 동일한 Item을 여러 개 구매 가능
- 읽기 중심(Read-heavy) 엔티티
MyItem - 사용자가 소유한 아이템
- 특정 사용자가 얼마나 많은 아이템을 보유하고 있는지 표현
- Item과 User 사이의 소유 관계 엔티티
주요 필드
MyItem
- myItemId (PK)
- userId
- item (FK → Item)
- quantity
- createdAt
- updatedAt
특징
User 1 ─── N MyItem
Item 1 ─── N MyItem
- 동일 Item을 여러 번 구매하면 `quantity` 증가
- 실제 배치 여부와는 무관
- 소유 상태만 관리
UsingItem - 실제 공간에 배치된 아이템
- MyItem 중 실제 사용 중인 아이템
- 좌표(posX, posY)를 가지며 숲에 배치됨
주요 필드
UsingItem
- usingItemId (PK)
- myItem (FK → MyItem)
- posX
- posY
- createdAt
- updatedAt
특징
- Drag & Drop으로 위치가 자주 변경됨
- 빈번한 업데이트 발생
- 실시간 UI 동기화 대상
N+1 문제
부모 엔티티를 한 번 조회한 이후, 연관된 자식 엔티티를 N번 추가로 조회하게 되는 ORM 성능 문제
발생하는 이유
현재 연관관계 구조
UsingItem
└─ MyItem (LAZY)
└─ Item (LAZY)
현재 상황
숲 꾸미기 화면은 사용자가 접속할 때마다 자신이 배치한 모든 아이템을 한 번에 조회해야 함
아이템 개수 ↑ -> (UsingItem -> MyItem -> Item) 연관 조회 반복 => DB 쿼리 수 선형적으로 ↑
N+1 문제가 발생한 코드 위치
`UsingItemUserServiceImpl` 중 `getAllUsingItemsByUser` 메서드
@Override
public List<UsingItemOutput> getAllUsingItemsByUser() {
Long userId = userInfoProvider.getUserInfo().userId();
return usingItemRepository.findByUserId(userId)
.stream()
.map(UsingItemOutput::from)
.toList();
}
-> `usingItemRepository.findByUserId(userId)`는 `UsingItem`만 조회
`UsingItemOutput`
public record UsingItemOutput(
Long id,
Long myItemId,
Long userId,
String imageUri,
Double posX,
Double posY,
String category,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static UsingItemOutput from(UsingItem usingItem) {
return new UsingItemOutput(
usingItem.getUsingItemId(),
usingItem.getMyItem().getMyItemId(), // 🔥
usingItem.getMyItem().getUserId(), // 🔥
usingItem.getMyItem().getItem().getImageUri(), // 🔥
usingItem.getPosX(),
usingItem.getPosY(),
usingItem.getMyItem().getItem().getCategory().name(), // 🔥
usingItem.getCreatedAt(),
usingItem.getUpdatedAt()
);
}
}
실제 실행 순서
- UsingItem 목록 조회 (1번 쿼리)
- `SELECT * FROM using_item WHERE ...`
- `getMyItem()` 호출 -> MyItem 호출 (N번)
- `SELECT * FROM my_item WHERE my_item_id = ?`
- IF. UsingItem이 10개면 -> 10번 실행
- `getItem()` 호출 -> Item 조회 (N번)
- `SELECT * FROM item WHERE item_id = ?`
- 또 10번 실행
=> 총 1+2N
즉, UsingItem 조회 후 DTO 변환 과정에서 LAZY 연관 엔티티(MyItem, Item)에 접근하면서 발생
해결 방법: Fetch Join으로 연관 엔티티 한 번에 조회
`UsingItemRepository`
Before
@Query("SELECT u FROM UsingItem u WHERE u.myItem.userId = :userId")
List<UsingItem> findByUserId(Long userId);
After
@Query("""
SELECT u
FROM UsingItem u
JOIN FETCH u.myItem mi
JOIN FETCH mi.item i
WHERE mi.userId = :userId
""")
List<UsingItem> findByUserIdWithItem(Long userId);
`UsingItemUserServiceImpl` 중 `getAllUsingItemsByUser` 메서드
After
@Override
public List<UsingItemOutput> getAllUsingItemsByUser() {
Long userId = userInfoProvider.getUserInfo().userId();
return usingItemRepository.findByUserIdWithItem(userId)
.stream()
.map(UsingItemOutput::from)
.toList();
}
=> 서버–DB 간 반복적인 쿼리 I/O 제거
테스트 코드 작성 및 로그 확인
N+1 문제 재현 테스트
- 연관 엔티티를 지연 로딩 상태로 조회할 경우, 반복적인 추가 쿼리가 발생하는지(N+1 문제) 확인
시나리오: 부모 엔티티 1건 조회 / 해당 엔티티가 가진 자식 컬렉션 순회하며 접근
기대 결과: 부모 조회 쿼리 1회 / 자식 엔티티 조회 쿼리가 자식 개수만큼 추가로 발생
테스트 코드
@Test
void N_plus_1_발생_테스트_fetch_join_적용_전() {
// given
Long userId = 1L;
Item item = itemRepository.save(
Item.builder()
.name("tree")
.price(100L)
.imageUri("tree.png")
.category(Category.TREE)
.userId(999L)
.build()
);
for (int i = 0; i < 30; i++) {
MyItem myItem = myItemRepository.save(
MyItem.builder()
.userId(userId)
.item(item)
.quantity(1L)
.build()
);
usingItemRepository.save(
UsingItem.builder()
.myItem(myItem)
.posX((double) i)
.posY((double) i)
.build()
);
}
// INSERT 쿼리 정리
em.flush();
em.clear();
// when
List<UsingItemOutput> result =
usingItemUserService.getAllUsingItemsByUser();
// then
assertThat(result).hasSize(30);
}
로그 확인



=> UsingItem 조회 쿼리 1회 + MyItem 조회 쿼리 30회 + Item 조회 쿼리 30회 => 총 SELECT 쿼리 61회
Fetch Join 적용 테스트
- Fetch Join을 통해 N+1 문제가 실제로 해결되는지 검증
시나리오: Fetch Join이 적용된 JPQL로 주문과 주문 아이템을 함께 조회 / 동일하게 자식 컬렉션에 접근
기대 결과: 단일 쿼리 1회 실행 / 추가 쿼리 발생 X
테스트 코드
@Test
void N_plus_1_해결_테스트_fetch_join_적용_후() {
// given
Item item = itemRepository.save(
Item.builder()
.name("tree")
.price(100L)
.imageUri("tree.png")
.category(Category.TREE)
.userId(999L)
.build()
);
for (int i = 0; i < 30; i++) {
MyItem myItem = myItemRepository.save(
MyItem.builder()
.userId(1L)
.item(item)
.quantity(1L)
.build()
);
usingItemRepository.save(
UsingItem.builder()
.myItem(myItem)
.posX((double) i)
.posY((double) i)
.build()
);
}
em.flush();
em.clear();
var stats = emf.unwrap(org.hibernate.SessionFactory.class)
.getStatistics();
stats.clear();
// when
List<UsingItemOutput> result =
usingItemUserService.getAllUsingItemsByUser();
// then
assertThat(result).hasSize(30);
long selectCount = stats.getPrepareStatementCount();
assertThat(selectCount)
.as("Fetch Join 적용 시 SELECT는 1번만 발생해야 함")
.isEqualTo(1);
}
로그 확인

=> UsingItem + MyItem + Item을 한 번에 가져오는 단일 SELECT
깃허브
플랜티파이
플랜티파이 has 21 repositories available. Follow their code on GitHub.
github.com
GitHub - hk-plantify/item-service: [Java] 숲꾸미기 아이템 서비스(+ cash repo)
[Java] 숲꾸미기 아이템 서비스(+ cash repo). Contribute to hk-plantify/item-service development by creating an account on GitHub.
github.com