스프링 사전과제 가이드 4편: Performance & Optimization — Spring Boot 4 · Kotlin 2.3 N+1·페이지네이션·캐싱·QueryDSL

스프링 사전과제 가이드 4편: Performance & Optimization — Spring Boot 4 · Kotlin 2.3 N+1·페이지네이션·캐싱·QueryDSL


서론

“성능 최적화, 어디서 시작해야 가점이 될까?”

사전과제에서 Performance 영역은 두 가지로 갈린다. LAZY를 설정하고 @BatchSize를 전역으로 잡은 것과, EAGER가 그대로 남은 것. 이 차이가 리뷰어의 평가를 가른다.

1편은 4계층, 2편은 Database & Testing, 3편은 Documentation & AOP를 다뤘다. 4편은 그 위에서 실제 쿼리 비용을 줄이는 영역이다. 주요 내용은 세 가지다.

  • N+1 해결 도구 3종의 트레이드오프
  • 페이지네이션 세 가지 타입의 분기 기준
  • 캐시 선택 논리, 동적 쿼리와 Projection 적용 기준

대상 독자는 기능은 돌아가는데 성능 최적화가 막막한 주니어 백엔드 개발자다. 다 읽으면 도구별 선택 기준이 생긴다.

이전 글에서 Documentation & AOP를 먼저 다뤘다.


TL;DR

  • LAZY가 기본, 예외가 EAGER다@ManyToOne·@OneToOne 기본값이 EAGER이므로 명시적으로 LAZY로 바꿔야 한다. EAGER를 그대로 두면 JPQL에서도 N+1이 발생한다.
  • Fetch Join은 페이징 불가, @BatchSize는 페이징 가능 — 컬렉션 Fetch Join 후 페이징하면 메모리 페이징이 발생한다. 페이징이 필요하면 @BatchSize가 정답이다.
  • Page vs Cursor 분기점은 “전체 개수가 필요한가” — 전체 개수가 필요하면 Page(Offset), 무한 스크롤이면 Cursor, 그 중간이면 Slice다.
  • 캐시 선택은 서버 수로 시작한다 — 단일 서버면 Caffeine으로 충분하다. 멀티 서버거나 강한 일관성이 필요하면 Redis로 간다.
  • Projection은 목록 조회의 기본 옵션이다 — 전체 Entity를 반환할 이유가 없는 목록 조회에서 DTO Projection으로 바꾸면 쿼리 컬럼 수가 줄고 성능이 오른다.

1. N+1 문제 — 왜 LAZY가 정답이고 어디서 새는가

1.1 N+1 발생 흐름

참고: Spring Boot 4 + Kotlin 2.3 프로젝트 셋업(kotlin-spring·kotlin-jpa plugin 등) 자체는 1편 1.1절에서 다뤘다. 4편은 그 위에서 도는 Performance 영역에 집중한다. Kotlin 2.x 시리즈는 백워드 호환이라 같은 코드가 2.0~2.3 모두 작동한다.

N+1 문제는 1번의 쿼리로 N개의 레코드를 가져온 뒤, 각 레코드의 연관 데이터를 조회하기 위해 N번의 추가 쿼리가 발생하는 현상이다.

“쿼리 1번으로 주문 목록을 가져왔는데, OrderItem을 접근할 때마다 SELECT가 터진다”는 게 N+1의 실체다.

sequenceDiagram
    participant App as Application
    participant DB as Database

    App->>DB: SELECT * FROM orders (1번)
    Note over DB: 10개의 Order 반환

    loop N번 반복 (order_id 1~10)
        App->>DB: SELECT * FROM order_item WHERE order_id = ?
        DB-->>App: OrderItem 목록
    end

    Note over App,DB: 1번 + 10번 = 11번 쿼리 (N+1)

10개의 주문이면 1 + 10 = 11번 쿼리다. 100개면 101번. 연관이 중첩되면 기하급수로 늘어난다.

// Order : OrderItem = 1 : N 관계
val orders = orderRepository.findAll() // 1번 쿼리

for (order in orders) {
    // 각 Order마다 OrderItem 조회 쿼리 발생 (N번)
    val items = order.orderItems
    items.forEach { println(it.productName) }
}

1.2 LAZY가 기본 — 그래도 새는 두 시나리오

모든 연관관계는 FetchType.LAZY로 설정하는 것이 기본 원칙이다. LAZY는 연관 데이터를 실제 접근 시점까지 쿼리를 미룬다. EAGER는 부모 로딩과 동시에 연관 데이터를 불러온다.

kotlin-jpa 플러그인이 JPA Entity에 no-arg 생성자를 자동으로 합성하므로, 직접 추가할 필요가 없다(1편 1.1절 참고).

그런데 LAZY로 설정해도 N+1이 터지는 시나리오가 두 가지 있다.

시나리오 1 — @ManyToOne·@OneToOne 기본값이 EAGER

JPA 스펙에서 @ManyToOne, @OneToOne의 기본 fetch 전략은 EAGER다. 명시적으로 LAZY를 선언하지 않으면 부모 로딩 시 연관 Entity가 항상 따라온다.

@Entity
class Order(
    // 기본값 EAGER — 반드시 LAZY로 바꿔야 한다
    @ManyToOne(fetch = FetchType.LAZY)
    val member: Member,

    // @OneToMany는 기본값이 LAZY라 그나마 낫다
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    val orderItems: MutableList<OrderItem> = mutableListOf()
)

시나리오 2 — JPQL에서는 EAGER도 N+1

연관관계가 EAGER라도 JPQL이 JOIN을 자동으로 넣지 않는다. JPQL은 쿼리를 그대로 실행한 뒤 EAGER 설정을 보고 추가 쿼리를 발사한다. 결국 1 + N이다.

// JPQL로 조회해도 EAGER 연관관계는 추가 쿼리가 발생한다
@Query("SELECT o FROM Order o")
fun findAll(): List<Order>
// → SELECT * FROM orders
// → SELECT * FROM member WHERE id = ? (EAGER라서 N번 추가)

2. N+1 해결 도구 3종 — Fetch Join · @EntityGraph · @BatchSize

2.1 Fetch Join — 페이징 못 쓰는 이유

Fetch Join은 JPQL에서 연관 Entity를 한 번의 JOIN 쿼리로 함께 가져오는 방법이다.

N+1을 단 1번의 쿼리로 해결하는 가장 직접적인 방법이다. 그러나 치명적인 한계가 있다.

interface OrderRepository : JpaRepository<Order, Long> {

    @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems")
    fun findAllWithOrderItems(): List<Order>
}

주의: 컬렉션 Fetch Join + 페이징은 Hibernate가 경고를 남긴다. 전체 데이터를 메모리에 올린 뒤 페이징하므로 OOM 위험이 있다.

왜 뻥튀기가 되는가? Order 1개에 OrderItem 3개면 JOIN 결과는 3행이다. 여기서 페이징하면 “Order 기준 10개”가 아니라 “행 기준 10개”가 잘린다.

2.2 @EntityGraph — 선언적 fetch

@EntityGraph는 JPQL 없이 어노테이션으로 연관관계의 fetch 전략을 메서드 단위로 오버라이드하는 방법이다.

Fetch Join과 동일한 JOIN 전략을 사용하지만, JPQL을 직접 작성하지 않아도 된다. 중첩 연관관계도 attributePaths 배열로 선언한다.

interface OrderRepository : JpaRepository<Order, Long> {

    // 1단계 연관관계: Order → OrderItems
    @EntityGraph(attributePaths = ["orderItems"])
    @Query("SELECT o FROM Order o")
    fun findAllWithOrderItemsGraph(): List<Order>

    // 2단계 연관관계: Order → OrderItems → Product
    @EntityGraph(attributePaths = ["orderItems", "orderItems.product"])
    fun findByStatus(status: OrderStatus): List<Order>

    // 3단계 연관관계: Order → OrderItems → Product → Category
    @EntityGraph(attributePaths = [
        "orderItems",
        "orderItems.product",
        "orderItems.product.category"
    ])
    fun findWithFullDetailsById(id: Long): Optional<Order>
}

그런데 @EntityGraph도 Fetch Join과 동일하게 컬렉션을 포함하면 페이징이 메모리 처리로 넘어간다. 이 한계는 두 방법이 공유한다.

2.3 @BatchSize — 페이징과 양립

@BatchSize는 지연 로딩 시 발생하는 N번의 쿼리를 IN 조건 하나로 묶어주는 방법이다.

Fetch Join이 “미리 JOIN해서 가져오는” 전략이라면, @BatchSize는 “나중에 한꺼번에 가져오는” 전략이다. 쿼리가 1 + 1로 줄고, 페이징과 함께 써도 안전하다.

# application.yml — 전역 설정 (권장)
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

전역 설정 대신 Entity 필드에 직접 붙일 수도 있다.

@Entity
class Order(
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    val orderItems: MutableList<OrderItem> = mutableListOf()
)

적용 전후 쿼리 차이를 보면 차이가 명확하다.

-- 적용 전: 주문 10개면 10번 쿼리
SELECT * FROM order_item WHERE order_id = 1;
SELECT * FROM order_item WHERE order_id = 2;
...

-- 적용 후: IN 쿼리 1번으로 처리
SELECT * FROM order_item WHERE order_id IN (1, 2, 3, ..., 10);

2.4 셋 중 무엇을 언제

결국 세 방법 모두 N+1을 줄이지만, 페이징 여부와 컬렉션 수에 따라 선택이 달라진다.

방법쿼리 수페이징주요 제약권장 시나리오
Fetch Join1번컬렉션 포함 시 불가카테시안 곱, 컬렉션 2개 이상 MultipleBagFetchException페이징 없는 단건·소량 조회
@EntityGraph1번컬렉션 포함 시 불가Fetch Join과 동일특정 쿼리 메서드에만 즉시 로딩 필요
@BatchSize1 + 1가능추가 쿼리 1회페이징 필요 / 컬렉션 여러 개

핵심: 과제에서는 default_batch_fetch_size: 100을 전역으로 설정한다. 페이징 없는 단건 상세 조회에만 Fetch Join을 선택적으로 쓰는 것이 가장 안전한 조합이다.


3. 페이지네이션 — Page · Slice · Cursor

3.1 Pageable과 Page 응답 형식

Pageable은 Spring Data JPA가 페이지 번호·크기·정렬을 추상화한 인터페이스다. Repository 메서드 파라미터로 받으면 자동으로 LIMIT/OFFSET SQL로 변환해준다.

응답을 어떤 타입으로 내보낼지는 상황에 따라 세 가지 선택지가 있다.

방식특징과제 권장 여부
Page<T> 직접 반환Spring 표준, sort·pageable 메타 포함충분
CommonResponse<Page<T>>일관된 응답 형식1편에서 CommonResponse를 쓴다면 적합
커스텀 PageResponse필요한 필드만 선택선택 사항

Kotlin은 Lombok을 쓰지 않는다 — 주입은 primary constructor의 val 파라미터로 처리한다. 아래는 Service와 Controller에서의 기본 구조다.

@Service
class ProductService(
    private val productRepository: ProductRepository
) {
    fun getProducts(pageable: Pageable): Page<ProductResponse> =
        productRepository.findAll(pageable).map { ProductResponse.from(it) }
}
@RestController
@RequestMapping("/api/v1/products")
class ProductController(
    private val productService: ProductService
) {
    @GetMapping
    fun getProducts(
        @PageableDefault(size = 20, sort = ["createdAt"], direction = Sort.Direction.DESC)
        pageable: Pageable
    ): Page<ProductResponse> = productService.getProducts(pageable)
}
커스텀 PageResponse 예시 (선택)
data class PageResponse<T>(
    val content: List<T>,
    val page: Int,
    val size: Int,
    val totalElements: Long,
    val totalPages: Int,
    val hasNext: Boolean
) {
    companion object {
        fun <T> from(page: Page<T>): PageResponse<T> = PageResponse(
            content = page.content,
            page = page.number,
            size = page.size,
            totalElements = page.totalElements,
            totalPages = page.totalPages,
            hasNext = page.hasNext()
        )
    }
}

3.2 Page vs Slice

Page와 Slice의 차이는 COUNT 쿼리 실행 여부다.

타입전체 개수실행 쿼리사용 패턴
Page포함 (totalElements)SELECT + COUNT관리자 목록, 전체 페이지 수 표시
Slice미포함 (hasNext만)SELECT (size + 1)무한 스크롤 초기, 다음 여부만 필요
// Page - 전체 개수가 필요한 경우 (일반적인 페이지네이션)
fun findByCategory(category: Category, pageable: Pageable): Page<Product>

// Slice - 무한 스크롤 등 전체 개수가 불필요한 경우
fun findByCategory(category: Category, pageable: Pageable): Slice<Product>

// List - 페이징 정보 없이 데이터만 필요한 경우
fun findByCategory(category: Category, pageable: Pageable): List<Product>

3.3 Offset vs Cursor — 대용량의 분기

Offset 기반 페이징의 약점은 OFFSET이 커질수록 DB가 앞 행을 전부 스캔해야 한다는 것이다. 수백만 row 테이블에서 page=500 요청은 500 × size개의 행을 건너뛰는 비용이 든다.

Cursor 기반 페이징은 마지막으로 조회한 ID를 기준으로 다음 데이터를 가져와, 앞 행 스캔 비용을 완전히 제거한다.

아래 결정 트리로 어떤 방식을 고를지 판단할 수 있다.

flowchart TD
    A([페이지네이션 시작]) --> B{전체 개수가\n필요한가?}
    B -->|Yes| C[Page\nSELECT + COUNT]
    B -->|No| D{무한 스크롤 /\nSNS 피드인가?}
    D -->|Yes| E{데이터 규모가\n수백만 row 이상?}
    D -->|No| F[Slice\nhasNext만 확인]
    E -->|Yes| G[Cursor 기반\nID < :cursor]
    E -->|No| F

Cursor 구현의 핵심은 “마지막 항목의 ID를 다음 요청의 기준으로 사용”하는 것이다.

interface ProductRepository : JpaRepository<Product, Long> {

    // ID 기반 커서 페이지네이션
    @Query("SELECT p FROM Product p WHERE p.id < :cursor ORDER BY p.id DESC")
    fun findByIdLessThan(@Param("cursor") cursor: Long, pageable: Pageable): List<Product>
}
@Service
class ProductService(
    private val productRepository: ProductRepository
) {
    fun getProductsWithCursor(cursor: Long?, size: Int): CursorResponse<ProductResponse> {
        val pageable = PageRequest.of(0, size + 1) // 다음 페이지 확인용 +1

        var products = if (cursor == null) {
            productRepository.findAll(
                PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "id"))
            ).content
        } else {
            productRepository.findByIdLessThan(cursor, pageable)
        }

        val hasNext = products.size > size
        if (hasNext) {
            products = products.subList(0, size)
        }

        val nextCursor = if (hasNext) products.last().id else null

        return CursorResponse(
            content = products.map { ProductResponse.from(it) },
            nextCursor = nextCursor,
            hasNext = hasNext
        )
    }
}
CursorResponse 클래스
data class CursorResponse<T>(
    val content: List<T>,
    val nextCursor: Long?,
    val hasNext: Boolean
)

과제 권장: 기본적으로 Offset(Page)을 사용한다. README에 “대용량이면 Cursor 방식으로 전환 가능하며, 트레이드오프는 ~“를 한 문단 써두면 가산점이 된다.

3.4 참고: COUNT 쿼리 최적화

Page를 쓸 때 COUNT 쿼리도 함께 실행된다. JOIN이 많은 복잡한 조회에서는 COUNT 쿼리도 느려진다. 이때는 countQuery를 분리한다.

@Query(
    value = "SELECT p FROM Product p JOIN FETCH p.category WHERE p.status = :status",
    countQuery = "SELECT COUNT(p) FROM Product p WHERE p.status = :status"
)
fun findByStatus(@Param("status") status: ProductStatus, pageable: Pageable): Page<Product>

참고: 과제 규모에서는 이 수준의 최적화가 필요한 경우가 드물다. 구조 자체를 알고 코드에 적용해두면 “의도적으로 설계했다”는 신호가 된다.


4. 캐싱 — Spring Cache · Caffeine · Redis

4.1 Spring Cache 추상화 (@Cacheable·@CachePut·@CacheEvict)

Spring Cache 추상화는 캐시 구현체에 상관없이 어노테이션만으로 캐싱 로직을 선언하는 방법이다.

캐시 구현체를 Caffeine으로 바꾸든 Redis로 바꾸든, 서비스 코드의 어노테이션은 변경 없이 동작한다. 세 가지 핵심 어노테이션의 역할은 다음과 같다.

어노테이션동작사용 시점
@Cacheable캐시에 있으면 반환, 없으면 실행 후 저장조회 메서드
@CachePut항상 실행 후 캐시 갱신수정 메서드
@CacheEvict캐시에서 해당 키 제거삭제 메서드
@Service
class ProductService(
    private val productRepository: ProductRepository
) {
    @Cacheable(value = "product", key = "#productId")
    fun getProductDetail(productId: Long): ProductDetailResponse {
        val product = productRepository.findById(productId)
            ?: throw ProductNotFoundException(productId)
        return ProductDetailResponse.from(product)
    }

    @CachePut(value = "product", key = "#productId")
    fun updateProduct(productId: Long, command: ProductUpdateCommand): ProductDetailResponse {
        val product = productRepository.findById(productId)
            ?: throw ProductNotFoundException(productId)
        product.update(command.name, command.price)
        return ProductDetailResponse.from(product)
    }

    @CacheEvict(value = "product", key = "#productId")
    fun deleteProduct(productId: Long) {
        productRepository.deleteById(productId)
    }

    @CacheEvict(value = "product", allEntries = true)
    fun clearProductCache() {
        // 캐시 전체 제거만 수행
    }
}

4.2 Caffeine — 단일 서버 표준

Caffeine은 JVM 메모리 내에서 동작하는 고성능 로컬 캐시 라이브러리다.

네트워크 통신 없이 메모리 직접 접근이라 속도가 가장 빠르다. 단일 서버 과제에서 기본 선택지다.

// settings.gradle.kts
dependencyResolutionManagement {
    versionCatalogs {
        create("libs") {
            library("caffeine", "com.github.ben-manes.caffeine", "caffeine").withoutVersion()
            library("spring-boot-starter-cache", "org.springframework.boot", "spring-boot-starter-cache").withoutVersion()
        }
    }
}
// build.gradle.kts
dependencies {
    implementation(libs.caffeine)
    implementation(libs.spring.boot.starter.cache)
}

기본 설정은 전체 캐시에 동일한 정책을 적용한다.

@Configuration
@EnableCaching
class CacheConfig {

    @Bean
    fun cacheManager(): CacheManager {
        val cacheManager = CaffeineCacheManager()
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .recordStats())
        return cacheManager
    }
}

캐시별로 TTL과 용량을 다르게 설정할 때는 SimpleCacheManager를 쓴다.

@Configuration
@EnableCaching
class CacheConfig {

    @Bean
    fun cacheManager(): CacheManager {
        val cacheManager = SimpleCacheManager()
        cacheManager.setCaches(listOf(
            buildCache("product", 500, 30, TimeUnit.MINUTES),
            buildCache("category", 100, 1, TimeUnit.HOURS),
            buildCache("config", 50, 24, TimeUnit.HOURS)
        ))
        return cacheManager
    }

    private fun buildCache(name: String, maxSize: Long, duration: Long, unit: TimeUnit): CaffeineCache =
        CaffeineCache(name, Caffeine.newBuilder()
            .maximumSize(maxSize)
            .expireAfterWrite(duration, unit)
            .recordStats()
            .build())
}

4.3 Redis — 분산 캐시

Redis는 외부 서버에서 동작하는 분산 캐시로, 여러 애플리케이션 인스턴스 간에 캐시 데이터를 공유한다.

멀티 서버 환경에서 각 인스턴스가 로컬 캐시를 따로 유지하면 데이터 불일치가 생긴다. Redis는 이 문제를 해결하는 공유 저장소 역할을 한다.

// settings.gradle.kts
dependencyResolutionManagement {
    versionCatalogs {
        create("libs") {
            library("spring-boot-starter-data-redis", "org.springframework.boot", "spring-boot-starter-data-redis").withoutVersion()
            library("spring-boot-starter-cache", "org.springframework.boot", "spring-boot-starter-cache").withoutVersion()
        }
    }
}
// build.gradle.kts
dependencies {
    implementation(libs.spring.boot.starter.data.redis)
    implementation(libs.spring.boot.starter.cache)
}
spring:
  redis:
    host: localhost
    port: 6379
  cache:
    type: redis
    redis:
      time-to-live: 600000
      cache-null-values: false
@Configuration
@EnableCaching
class RedisCacheConfig {

    @Bean
    fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
        val defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(GenericJackson2JsonRedisSerializer()))

        val cacheConfigurations = mapOf(
            "product" to defaultConfig.entryTtl(Duration.ofMinutes(30)),
            "category" to defaultConfig.entryTtl(Duration.ofHours(1))
        )

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigurations)
            .build()
    }
}

4.4 로컬 vs 분산 결정

어떤 캐시를 선택할지는 서버 수와 일관성 요구사항으로 결정한다.

flowchart TD
    A([캐시 선택 시작]) --> B{서버 인스턴스가\n2개 이상?}
    B -->|No| C[Caffeine\n단일 서버로 충분]
    B -->|Yes| D{서버 간\n강한 일관성 필요?}
    D -->|Yes| E[Redis\n분산 캐시]
    D -->|No| F{캐시 데이터\n용량이 매우 큼?}
    F -->|Yes| E
    F -->|No| G[Caffeine + TTL 조정\n로컬 캐시로 충분]
구분Caffeine (로컬)Redis (분산)
속도매우 빠름 (메모리 직접)상대적으로 느림 (네트워크)
일관성서버 간 불일치 가능공유 저장소로 일관성 보장
용량서버 JVM 메모리 제한별도 서버로 확장 가능
복잡도간단Redis 인프라 필요

과제 권장: 단일 서버라면 Caffeine이 충분하다. Docker Compose에 Redis 컨테이너를 추가해서 Redis 캐시를 연결하면 “분산 환경도 고려했다”는 가산점이 된다.

4.5 참고: 캐시 무효화 전략

캐시를 잘 쓰려면 무효화 전략도 함께 설계해야 한다. 자주 쓰는 두 패턴은 다음과 같다.

Cache-Aside (Lazy Loading) — Spring Cache 기본 동작 방식

  1. 조회 시 캐시에서 먼저 확인
  2. 캐시 미스 → DB 조회 후 캐시에 저장
  3. 수정/삭제 시 @CacheEvict로 해당 키 제거

Write-Through — 쓰기 시 캐시와 DB 동시 갱신

  1. @CachePut으로 항상 실행 후 캐시 갱신
  2. 조회는 캐시에서 바로 반환

주의사항이 두 가지 있다. 첫째, 목록 조회 캐시는 항목 하나가 변경될 때 전체 무효화(allEntries = true)가 필요하다. 둘째, 캐시 키에 prefix를 붙여 키 충돌을 막는다.


5. 쿼리 최적화 — Projection · QueryDSL · 인덱스

5.1 Projection — Entity 대신 필드만

Projection은 전체 Entity 대신 필요한 필드만 선택해서 조회하는 방법이다.

목록 조회에서 전체 Entity를 반환하면 사용하지 않는 컬럼도 SELECT에 포함된다. Projection을 쓰면 DB 전송량과 메모리 사용이 줄어든다.

두 가지 방식이 있다. Interface Projection은 인터페이스를 정의하면 Hibernate가 프록시를 생성한다.

interface ProductSummary {
    val id: Long
    val name: String
    val price: Int
}

interface ProductRepository : JpaRepository<Product, Long> {
    fun findByCategory(category: Category): List<ProductSummary>
}

DTO Projection은 data class로 직접 생성하므로 프록시 오버헤드가 없어 성능이 더 낫다.

data class ProductSummaryDto(
    val id: Long,
    val name: String,
    val price: Int
)

interface ProductRepository : JpaRepository<Product, Long> {

    @Query("SELECT new com.example.dto.ProductSummaryDto(p.id, p.name, p.price) " +
           "FROM Product p WHERE p.category = :category")
    fun findSummaryByCategory(@Param("category") category: Category): List<ProductSummaryDto>
}

성능 순서는 DTO Projection > Interface Projection > Entity 전체 조회다. 단, 조회 후 Entity를 수정해야 하면 Entity로 조회해야 한다.

Projection 성능 비교 코드
// 1. Entity 전체 조회 - 모든 컬럼 + 연관 Entity
val products: List<Product> = productRepository.findAll()

// 2. Interface Projection - 필요한 컬럼만 (Proxy 생성)
val summaries: List<ProductSummary> = productRepository.findAllProjectedBy()

// 3. DTO Projection - 필요한 컬럼만 (직접 생성, 가장 빠름)
val dtos: List<ProductSummaryDto> = productRepository.findAllSummary()

5.2 QueryDSL — 동적 쿼리

QueryDSL은 타입 안전한 Kotlin/Java 코드로 JPQL을 생성하는 프레임워크다.

JPQL은 문자열이라 컴파일 타임에 오류를 잡지 못하고, 동적 조건을 붙이려면 문자열 조작이 필요하다. QueryDSL은 이 두 문제를 해결한다.

Kotlin에서는 annotationProcessor 대신 kapt(Kotlin Annotation Processing Tool)로 Q 클래스를 생성한다.

// build.gradle.kts — QueryDSL + kapt 설정
plugins {
    kotlin("kapt") version "2.3"
}

dependencies {
    implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")
    kapt("com.querydsl:querydsl-apt:5.0.0:jakarta")
    kapt("jakarta.annotation:jakarta.annotation-api")
    kapt("jakarta.persistence:jakarta.persistence-api")
}

동적 검색 조건을 BooleanExpression으로 구성한 전형적인 패턴은 다음과 같다.

@Repository
class ProductQueryRepository(
    private val queryFactory: JPAQueryFactory
) {
    fun searchProducts(condition: ProductSearchCondition): List<Product> {
        return queryFactory
            .selectFrom(product)
            .where(
                categoryEq(condition.categoryId),
                priceGoe(condition.minPrice),
                priceLoe(condition.maxPrice),
                nameContains(condition.keyword)
            )
            .orderBy(product.createdAt.desc())
            .offset(condition.offset)
            .limit(condition.limit)
            .fetch()
    }

    private fun categoryEq(categoryId: Long?) =
        categoryId?.let { product.category.id.eq(it) }

    private fun priceGoe(minPrice: Int?) =
        minPrice?.let { product.price.goe(it) }

    private fun priceLoe(maxPrice: Int?) =
        maxPrice?.let { product.price.loe(it) }

    private fun nameContains(keyword: String?) =
        keyword?.takeIf { it.isNotBlank() }?.let { product.name.contains(it) }
}

조건 메서드가 null을 반환하면 QueryDSL이 해당 WHERE 절을 자동으로 제외한다. 이게 “동적 쿼리가 쉽다”는 이유다.

QueryDSL 도입이 맞는 시점은 “검색 조건이 2개 이상이고, 선택적으로 적용된다”이다. 단순 CRUD는 Spring Data JPA가 더 간결하다.

방식타입 안전동적 쿼리사용 시점
JPQLX (문자열)어려움단순 정적 쿼리
QueryDSLO쉬움복잡한 동적 검색
Native QueryX어려움DB 특화 기능, 복잡한 통계

5.3 인덱스 설계 — Entity 어노테이션으로

인덱스는 WHERE·JOIN·ORDER BY에 자주 쓰이는 컬럼에 대한 DB 검색 최적화 구조다.

JPA에서는 Entity @Table 어노테이션에 @Index를 선언하면 DDL 자동 생성 시 인덱스가 포함된다. “이 컬럼에 인덱스가 필요하다”는 의도를 코드 레벨에서 드러내는 방식이다.

@Entity
@Table(name = "product", indexes = [
    Index(name = "idx_product_category", columnList = "category_id"),
    Index(name = "idx_product_status_created", columnList = "status, created_at"),
    Index(name = "idx_product_name", columnList = "name")
])
class Product {
    // ...
}
인덱스 설계 기준

인덱스가 필요한 경우:

  • WHERE 절에 자주 사용되는 컬럼
  • JOIN 조건에 사용되는 컬럼 (FK)
  • ORDER BY에 사용되는 컬럼
  • 카디널리티가 높은 컬럼 (고유값이 많은)

인덱스 주의사항:

  • INSERT/UPDATE/DELETE 성능을 저하시킨다 (인덱스 갱신 비용)
  • 복합 인덱스는 컬럼 순서가 중요하다 (왼쪽부터 사용)
  • 과도한 인덱스는 오히려 성능 저하 원인이 된다

정리

  • LAZY 전역 + @BatchSize 100 — 모든 연관관계를 LAZY로 선언하고 default_batch_fetch_size: 100을 전역 설정하면 N+1을 안전하게 잡는 기본 방어선이 된다.
  • 페이징 분기는 전체 개수 필요 여부로 — 전체 개수가 필요하면 Page, 다음 존재 여부만 필요하면 Slice, 대용량 무한 스크롤이면 Cursor다.
  • 캐시는 서버 수로 시작한다 — 단일 서버면 Caffeine, 멀티 서버면 Redis. 과제에서 Docker Compose에 Redis를 추가하면 가산점이다.
  • 목록 조회에는 Projection을 기본으로 쓴다 — 수정이 필요 없는 목록에서 Entity 대신 DTO Projection을 쓰면 쿼리 컬럼 수와 메모리 사용이 줄어든다.
  • 복잡한 동적 검색에만 QueryDSL을 쓴다 — 단순 CRUD는 Spring Data JPA가 더 간결하다. 선택적 검색 조건이 2개 이상일 때 QueryDSL이 명확한 이점을 준다.

체크리스트

항목확인
모든 연관관계가 FetchType.LAZY로 설정되어 있는가?
@ManyToOne·@OneToOne에 명시적 LAZY 선언이 있는가?
default_batch_fetch_size 전역 설정이 적용되어 있는가?
페이지네이션이 필요한 API에 Pageable이 적용되어 있는가?
자주 조회되는 데이터에 캐싱이 적용되어 있는가?
목록 조회 시 필요한 필드만 Projection으로 가져오는가?
복잡한 동적 쿼리에 QueryDSL이 사용되었는가?

5편 Security & Authentication에서는 Spring Security 필터 체인, JWT 발급·검증, 비밀번호 암호화를 다룬다. 인증·인가 분기 설계와 감점 패턴 4종도 함께 정리한다.


부록

흔한 실수 5종

과제에서 자주 보이는 성능 실수 목록
  1. EAGER 로딩 그대로 사용@ManyToOne, @OneToOne 기본값이 EAGER다. 명시적으로 LAZY를 선언하지 않으면 모든 조회에서 연관 데이터가 따라온다.

  2. 무분별한 컬렉션 Fetch Join — 컬렉션을 Fetch Join한 상태에서 페이징하면 메모리 페이징이 발생한다. 컬렉션이 2개 이상이면 MultipleBagFetchException이 터진다.

  3. COUNT 쿼리 무시 — Page를 쓰면 COUNT 쿼리도 실행된다. JOIN이 복잡한 조회라면 COUNT 쿼리도 같이 느려진다. countQuery 분리 또는 Slice 전환을 고려한다.

  4. 캐시 키 충돌 — 서로 다른 메서드에서 같은 캐시명 + 같은 키를 쓰면 의도하지 않은 데이터가 반환된다. 메서드별로 고유한 캐시명이나 키 prefix를 설계한다.

  5. 목록 조회에서 Entity 반환 — 전체 Entity 대신 DTO Projection으로 바꾸면 SELECT 컬럼 수와 DB 전송량이 줄어든다. 수정이 필요 없는 조회에서는 Projection이 기본 옵션이다.

외부 참조

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.