📋 목차
문제 발견: N+1 문제란?
@Service
public class AnnotationService {
public AnnotationListResponse loadAnnotations(Integer imageId) {
List<Annotation> annotations = annotationRepository.findByImageId(imageId);
return annotations.stream()
.map(AnnotationResponse::from) // 여기서 N+1 발생
.collect(toList());
}
}
실제 실행되는 쿼리들
-- 1개 쿼리: Annotation 조회
SELECT *
FROM annotations
WHERE image_id = ?
-- N개 쿼리: 각 Annotation마다 User 조회
SELECT *
FROM users
WHERE id = ? -- Annotation 1의 annotated_by
SELECT *
FROM users
WHERE id = ? -- Annotation 2의 annotated_by
SELECT *
FROM users
WHERE id = ? -- Annotation 3의 annotated_by
...
-- N*M개 쿼리: 각 Annotation마다 Shape들 조회
SELECT *
FROM shapes
WHERE annotation_id = ?
...
결과: Annotation 50개 → 2,551개 쿼리 실행
테스트 환경 구성
엔티티 관계도
Annotation (1) ─── (N) Shape (1) ─── (N) Coordinate
│
└── (N) ─── (1) User
└── (N) ─── (1) Image
테스트 데이터 시나리오
| 시나리오 | Annotation 수 | Shape/Annotation | Coordinate/Shape | 총 Coordinate |
|---|---|---|---|---|
| 기본 | 5개 | 2개 | 10개 | 100개 |
| Hard | 20개 | 5개 | 15개 | 1,500개 |
| Mad | 50개 | 10개 | 25개 | 12,500개 |
JMeter 부하 테스트 설정
- 동시 사용자: 10~50명
- 요청 수: 1,000회
- 측정 항목: 응답시간, TPS, 에러율
6가지 쿼리 최적화 방식 비교
1️⃣ N+1 방식 (기준점)
@Query("SELECT a FROM Annotation a WHERE a.image.id = :imageId")
List<Annotation> findByImageId(Integer imageId);
- 쿼리 수: 1 + N + N×M + N×M×L
- 특징: 가장 단순하지만 성능 최악
2️⃣ EntityGraph 방식
@EntityGraph(attributePaths = {"annotatedBy", "image"})
@Query("SELECT a FROM Annotation a WHERE a.image.id = :imageId")
List<Annotation> findByImageIdWithEntityGraph(Integer imageId);
- 쿼리 수: 1개 (JOIN 사용)
- 특징: 간단한 관계에 효과적
3️⃣ Fetch Join 방식
@Query("SELECT a FROM Annotation a " +
"JOIN FETCH a.annotatedBy " +
"JOIN FETCH a.image " +
"WHERE a.image.id = :imageId")
List<Annotation> findByImageIdWithFetchJoin(Integer imageId);
- 쿼리 수: 1개 (명시적 JOIN)
- 특징: EntityGraph보다 명확한 제어
4️⃣ 모든 관계 JOIN (위험)
@EntityGraph(attributePaths = {"annotatedBy", "image", "shapes", "shapes.coordinates"})
@Query("SELECT DISTINCT a FROM Annotation a WHERE a.image.id = :imageId")
List<Annotation> findByImageIdWithAllRelations(Integer imageId);
- 쿼리 수: 1개 (복잡한 JOIN)
- 특징: 데이터 폭발로 인한 성능 급락 위험
5️⃣ 배치 조회 방식
// 1. Annotation만 조회
List<Annotation> annotations = repository.findByImageId(imageId);
// 2. User들 배치 조회
List<Integer> annotationIds = annotations.stream().map(Annotation::getId).toList();
List<User> users = repository.findUsersByAnnotationIds(annotationIds);
Map<Integer, User> userMap = users.stream().collect(toMap(User::getId, identity()));
// 3. Shape들 배치 조회
List<Shape> shapes = shapeRepository.findByAnnotationIdIn(annotationIds);
// 4. Coordinate들 배치 조회
List<Integer> shapeIds = shapes.stream().map(Shape::getId).toList();
List<Coordinate> coordinates = coordinateRepository.findByShapeIdIn(shapeIds);
// 5. 메모리에서 관계 매핑
- 쿼리 수: 4개 (고정)
- 특징: 데이터 양과 무관하게 일정한 성능
성능 테스트 결과
응답시간 비교 (Average)
| 방식 | 기본 (5개) | Hard (20개) | Mad (50개) | 개선율 |
|---|---|---|---|---|
| N+1 방식 | 628ms | 2,624ms | 12,556ms | - |
| EntityGraph | 570ms | 7,145ms | 12,602ms | 9% |
| Fetch Join | 537ms | 2,187ms | 12,556ms | 14% |
| 배치 조회 | 3ms | 303ms | 2,199ms | 97.8% |
발견사항
1.
기본 데이터 (5개 Annotation):
✅ Fetch Join: 537ms (최적)
Mad 데이터 (50개 Annotation):
❌ Fetch Join: 12,556ms (최악)
✅ 배치 조회: 2,199ms (최적)
2. 데이터 크기별 성능 격차
- 기본 → Mad: Fetch Join은 23배 성능 저하
- 기본 → Mad: 배치 조회는 733배만 성능 저하
3. 처리량(TPS) 차이
- 배치 조회: 992 TPS vs N+1: 104 TPS
- 9.5배 처리량 향상
적용
1. 간단한 관계 (1-2 depth)
// ✅ Fetch Join 사용
@Query("SELECT a FROM Annotation a JOIN FETCH a.annotatedBy WHERE a.image.id = :imageId")
- 조건: 관계가 적고 데이터량이 예측 가능
- 장점: 간단하고 직관적
2. 복잡한 관계 (3+ depth)
// ✅ 배치 조회 사용
List<Entity> entities = repository.findByCondition();
Map<Integer, RelatedEntity> relatedMap = getRelatedEntities(entityIds);
- 조건: 관계가 복잡하거나 데이터량이 많음
- 장점: 예측 가능한 성능, 메모리 효율적
3. 읽기 전용 + 성능 크리티컬
// ✅ DTO Projection 사용
@Query("SELECT new com.example.AnnotationDto(a.id, u.name, i.url) " +
"FROM Annotation a JOIN a.annotatedBy u JOIN a.image i " +
"WHERE a.image.id = :imageId")
- 조건: 특정 필드만 필요하고 성능이 최우선
- 장점: 최소한의 데이터 전송, 최고 성능
📝 결론
-
“항상 JOIN이 좋은 건 아니다”
- 간단한 관계: JOIN 우수
- 복잡한 관계: 배치 조회 압승
-
데이터 크기에 따른 전략 수립
- 작은 데이터: 편의성 중심
- 큰 데이터: 성능 중심
-
실제 측정을 꼭 해보자