Skip to content

Split View: Spring Boot DB 연결 완전 가이드: JPA, HikariCP, 트랜잭션, QueryDSL, 멀티 DB

|

Spring Boot DB 연결 완전 가이드: JPA, HikariCP, 트랜잭션, QueryDSL, 멀티 DB

목차

  1. 의존성 설정
  2. DataSource 설정 (HikariCP)
  3. Entity 설계 패턴
  4. Repository 패턴
  5. QueryDSL 설정과 활용
  6. 트랜잭션 관리
  7. N+1 문제 해결
  8. 멀티 데이터소스 설정
  9. R2DBC 비동기 연결
  10. 슬로우 쿼리 로깅 및 모니터링
  11. 퀴즈

1. 의존성 설정

Maven 의존성

<!-- JPA/Hibernate -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- PostgreSQL 드라이버 -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- MySQL 드라이버 (선택) -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- QueryDSL -->
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <classifier>jakarta</classifier>
    <scope>provided</scope>
</dependency>

<!-- Flyway 마이그레이션 -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>

2. DataSource 설정 (HikariCP)

Spring Boot 2.x 이상에서는 HikariCP가 기본 커넥션 풀로 사용됩니다.

전체 설정 예시

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: user
    password: secret
    driver-class-name: org.postgresql.Driver
    hikari:
      pool-name: HikariCP-Main
      maximum-pool-size: 10 # 최대 커넥션 수
      minimum-idle: 5 # 최소 유휴 커넥션
      connection-timeout: 30000 # 커넥션 획득 타임아웃 (30초)
      idle-timeout: 600000 # 유휴 커넥션 유지 시간 (10분)
      max-lifetime: 1800000 # 커넥션 최대 수명 (30분)
      connection-test-query: SELECT 1
      auto-commit: true
      leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초)
  jpa:
    hibernate:
      ddl-auto: validate # prod: validate, dev: update/create-drop
    show-sql: false
    open-in-view: false # OSIV 비활성화 (권장)
    properties:
      hibernate:
        format_sql: true
        use_sql_comments: true
        default_batch_fetch_size: 100
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
        dialect: org.hibernate.dialect.PostgreSQLDialect

HikariCP 풀 크기 결정 공식

최적 풀 크기 = Tn × (Cm - 1) + 1
  • Tn: 최대 동시 스레드 수
  • Cm: 단일 작업에서 필요한 최대 커넥션 수

실무에서는 다음 공식도 많이 사용됩니다:

커넥션 수 = (CPU 코어 수 × 2) + 유효 스핀들 수

HikariCP 문서에서는 10개의 커넥션으로 많은 경우를 충족할 수 있다고 권장합니다.

OSIV (Open Session In View) 비활성화

spring:
  jpa:
    open-in-view: false

OSIV를 활성화하면 HTTP 응답 완료 시까지 DB 커넥션을 유지하여 커넥션 풀이 소진될 수 있습니다. 서비스 계층에서 트랜잭션을 제어하는 것이 더 좋은 패턴입니다.


3. Entity 설계 패턴

기본 Entity 구조

@Entity
@Table(name = "users", indexes = {
    @Index(name = "idx_user_email", columnList = "email"),
    @Index(name = "idx_user_created_at", columnList = "created_at")
})
@EntityListeners(AuditingEntityListener.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Column(nullable = false, length = 50)
    private String name;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private UserStatus status = UserStatus.ACTIVE;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @Version
    private Long version;  // Optimistic Locking

    @Builder
    public User(String email, String name) {
        this.email = email;
        this.name = name;
    }
}

JPA Auditing 활성화

@Configuration
@EnableJpaAuditing
public class JpaConfig {
    // 필요 시 AuditorAware 구현 추가
}

BaseEntity 추상 클래스

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

4. Repository 패턴

JpaRepository 기본 메서드 활용

public interface UserRepository extends JpaRepository<User, Long> {

    // 메서드 이름 기반 쿼리
    Optional<User> findByEmail(String email);
    List<User> findByStatusAndCreatedAtAfter(UserStatus status, LocalDateTime date);
    boolean existsByEmail(String email);
    long countByStatus(UserStatus status);

    // @Query로 JPQL
    @Query("SELECT u FROM User u WHERE u.email = :email AND u.status = 'ACTIVE'")
    Optional<User> findActiveUserByEmail(@Param("email") String email);

    // Native Query
    @Query(value = "SELECT * FROM users WHERE created_at > :date LIMIT :limit",
           nativeQuery = true)
    List<User> findRecentUsersNative(@Param("date") LocalDateTime date,
                                     @Param("limit") int limit);

    // Projection - 인터페이스
    @Query("SELECT u.id AS id, u.email AS email FROM User u WHERE u.status = :status")
    List<UserSummary> findUserSummariesByStatus(@Param("status") UserStatus status);

    // Modifying Query
    @Modifying(clearAutomatically = true)
    @Transactional
    @Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
    int updateStatusByIds(@Param("ids") List<Long> ids, @Param("status") UserStatus status);
}

Projection 인터페이스

public interface UserSummary {
    Long getId();
    String getEmail();
}

Specification API (동적 쿼리)

public class UserSpecification {

    public static Specification<User> hasStatus(UserStatus status) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.equal(root.get("status"), status);
    }

    public static Specification<User> emailContains(String keyword) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.like(root.get("email"), "%" + keyword + "%");
    }

    public static Specification<User> createdAfter(LocalDateTime date) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.greaterThan(root.get("createdAt"), date);
    }
}

// 사용 예시
List<User> users = userRepository.findAll(
    UserSpecification.hasStatus(UserStatus.ACTIVE)
        .and(UserSpecification.emailContains("example.com"))
);

5. QueryDSL 설정과 활용

QueryDSL Maven Plugin 설정

<plugin>
    <groupId>com.mysema.maven</groupId>
    <artifactId>apt-maven-plugin</artifactId>
    <version>1.1.3</version>
    <executions>
        <execution>
            <goals>
                <goal>process</goal>
            </goals>
            <configuration>
                <outputDirectory>target/generated-sources/java</outputDirectory>
                <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
            </configuration>
        </execution>
    </executions>
</plugin>

JPAQueryFactory 빈 등록

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

QueryDSL Repository 구현

@Repository
@RequiredArgsConstructor
public class UserQueryRepository {

    private final JPAQueryFactory queryFactory;

    public Page<User> findActiveUsers(UserSearchCondition condition, Pageable pageable) {
        List<User> content = queryFactory
            .selectFrom(user)
            .where(
                statusEq(condition.getStatus()),
                emailContains(condition.getEmail()),
                createdAtAfter(condition.getFromDate())
            )
            .orderBy(getOrderSpecifier(pageable))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        JPAQuery<Long> countQuery = queryFactory
            .select(user.count())
            .from(user)
            .where(
                statusEq(condition.getStatus()),
                emailContains(condition.getEmail())
            );

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    private BooleanExpression statusEq(UserStatus status) {
        return status != null ? user.status.eq(status) : null;
    }

    private BooleanExpression emailContains(String email) {
        return StringUtils.hasText(email) ? user.email.containsIgnoreCase(email) : null;
    }

    private BooleanExpression createdAtAfter(LocalDateTime date) {
        return date != null ? user.createdAt.after(date) : null;
    }

    private OrderSpecifier<?> getOrderSpecifier(Pageable pageable) {
        if (pageable.getSort().isEmpty()) {
            return user.createdAt.desc();
        }
        Sort.Order order = pageable.getSort().iterator().next();
        return order.isAscending()
            ? user.createdAt.asc()
            : user.createdAt.desc();
    }
}

6. 트랜잭션 관리

@Transactional 주요 옵션

@Service
@Transactional(readOnly = true)  // 클래스 레벨: 기본적으로 읽기 전용
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    // 읽기 전용 (기본값 readOnly=true 상속)
    public UserResponse getUser(Long id) {
        return userRepository.findById(id)
            .map(UserResponse::from)
            .orElseThrow(() -> new EntityNotFoundException("User not found: " + id));
    }

    // 쓰기 작업은 readOnly=false로 명시
    @Transactional
    public UserResponse createUser(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateEmailException(request.getEmail());
        }
        User user = User.builder()
            .email(request.getEmail())
            .name(request.getName())
            .build();
        return UserResponse.from(userRepository.save(user));
    }

    // 특정 예외만 롤백
    @Transactional(rollbackFor = Exception.class)
    public void processPayment(PaymentRequest request) {
        // 결제 처리 로직
    }

    // 독립 트랜잭션으로 실행 (부모 트랜잭션과 무관하게 커밋/롤백)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAuditLog(String message) {
        // 감사 로그는 항상 저장
    }

    // 트랜잭션 없이 실행
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void callExternalApi() {
        // DB 작업 없는 외부 API 호출
    }
}

트랜잭션 전파 레벨

전파 레벨설명
REQUIRED기존 트랜잭션 사용, 없으면 새로 생성 (기본값)
REQUIRES_NEW항상 새 트랜잭션 생성, 기존 트랜잭션 일시 정지
SUPPORTS기존 트랜잭션 사용, 없으면 트랜잭션 없이 실행
NOT_SUPPORTED트랜잭션 없이 실행, 기존 트랜잭션 일시 정지
MANDATORY기존 트랜잭션 필수, 없으면 예외 발생
NEVER트랜잭션 없이 실행, 기존 트랜잭션이 있으면 예외
NESTED중첩 트랜잭션 (Savepoint 활용)

격리 수준 (Isolation Level)

// 팬텀 리드 방지가 필요한 경우
@Transactional(isolation = Isolation.SERIALIZABLE)

// 반복 가능한 읽기 보장
@Transactional(isolation = Isolation.REPEATABLE_READ)

// 더티 리드 방지 (대부분의 DB 기본값)
@Transactional(isolation = Isolation.READ_COMMITTED)

7. N+1 문제 해결

N+1 문제는 1번의 쿼리로 N개의 결과를 가져온 후, 각 결과에 대해 N번의 추가 쿼리가 발생하는 성능 문제입니다.

문제 상황

// N+1 발생 예시
List<Order> orders = orderRepository.findAll();
orders.forEach(order -> {
    System.out.println(order.getUser().getName()); // 매 Order마다 User 쿼리 발생
});

해결 방법 1: @EntityGraph

@EntityGraph(attributePaths = {"user", "orderItems"})
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusWithUserAndItems(@Param("status") OrderStatus status);

해결 방법 2: Fetch Join

@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.user JOIN FETCH o.orderItems WHERE o.status = :status")
List<Order> findByStatusWithFetch(@Param("status") OrderStatus status);

주의: Fetch Join과 페이징을 함께 사용하면 메모리에서 페이징이 처리되어 성능 문제가 발생할 수 있습니다. 컬렉션 Fetch Join에는 @QueryHintsHibernateHints.HINT_PASS_DISTINCT_THROUGH를 함께 사용하세요.

해결 방법 3: BatchSize

@Entity
public class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 100)   // 한 번에 100개씩 IN 쿼리로 조회
    private List<Order> orders;
}

또는 전역 설정:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

8. 멀티 데이터소스 설정

두 개의 DataSource 설정

@Configuration
@EnableTransactionManagement
public class DataSourceConfig {

    @Primary
    @Bean(name = "primaryDataSource")
    @ConfigurationProperties("spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean(name = "secondaryDataSource")
    @ConfigurationProperties("spring.datasource.secondary")
    public DataSource secondaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean(name = "primaryEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("primaryDataSource") DataSource dataSource) {
        return builder
            .dataSource(dataSource)
            .packages("com.example.domain.primary")
            .persistenceUnit("primary")
            .build();
    }

    @Primary
    @Bean(name = "primaryTransactionManager")
    public PlatformTransactionManager primaryTransactionManager(
            @Qualifier("primaryEntityManagerFactory") EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

application.yml 멀티 데이터소스 설정

spring:
  datasource:
    primary:
      url: jdbc:postgresql://primary-db:5432/maindb
      username: ${DB_PRIMARY_USERNAME}
      password: ${DB_PRIMARY_PASSWORD}
      hikari:
        maximum-pool-size: 10
    secondary:
      url: jdbc:postgresql://secondary-db:5432/analyticsdb
      username: ${DB_SECONDARY_USERNAME}
      password: ${DB_SECONDARY_PASSWORD}
      hikari:
        maximum-pool-size: 5

Read/Write 분리 (AbstractRoutingDataSource)

public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? DataSourceType.READ
            : DataSourceType.WRITE;
    }
}

public enum DataSourceType {
    READ, WRITE
}

9. R2DBC 비동기 연결

리액티브 스택(Spring WebFlux)을 사용하는 경우 R2DBC로 비동기 DB 연결을 구성합니다.

의존성

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>r2dbc-postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

설정

spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/mydb
    username: user
    password: secret
    pool:
      initial-size: 5
      max-size: 10
      max-idle-time: 30m
      validation-query: SELECT 1

R2DBC Repository

public interface UserReactiveRepository extends ReactiveCrudRepository<User, Long> {
    Mono<User> findByEmail(String email);
    Flux<User> findByStatus(UserStatus status);
}

@Service
@RequiredArgsConstructor
public class UserReactiveService {

    private final UserReactiveRepository userRepository;

    public Mono<UserResponse> getUser(Long id) {
        return userRepository.findById(id)
            .map(UserResponse::from)
            .switchIfEmpty(Mono.error(new EntityNotFoundException("User not found: " + id)));
    }

    public Flux<UserResponse> getAllActiveUsers() {
        return userRepository.findByStatus(UserStatus.ACTIVE)
            .map(UserResponse::from);
    }
}

10. 슬로우 쿼리 로깅 및 모니터링

p6spy 설정

<dependency>
    <groupId>com.github.gavlyukovskiy</groupId>
    <artifactId>p6spy-spring-boot-starter</artifactId>
    <version>1.9.0</version>
</dependency>

src/main/resources/spy.properties:

appender=com.p6spy.engine.spy.appender.Slf4JLogger
logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat
customLogMessageFormat=%(currentTime)|%(executionTime)ms|%(category)|connection%(connectionId)|%(sqlSingleLine)
filter=true
execution=true

Hibernate Statistics 활성화

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
        session:
          events:
            log:
              LOG_QUERIES_SLOWER_THAN_MS: 100 # 100ms 이상 걸리는 쿼리 로깅

Micrometer + Prometheus 모니터링

management:
  endpoints:
    web:
      exposure:
        include: metrics,health,prometheus
  metrics:
    export:
      prometheus:
        enabled: true

주요 모니터링 지표:

  • jdbc.connections.active: 현재 활성 커넥션 수
  • jdbc.connections.max: 최대 커넥션 풀 크기
  • hibernate.query.executions.total: 전체 쿼리 실행 횟수
  • hibernate.second.level.cache.hits: 2차 캐시 히트율

Actuator 엔드포인트

GET /actuator/metrics/jdbc.connections.active
GET /actuator/metrics/jdbc.connections.pending
GET /actuator/metrics/hikaricp.connections.timeout

11. 퀴즈

Q1. HikariCP의 maximum-pool-size를 너무 크게 설정하면 어떤 문제가 발생하나요?

정답: DB 서버의 최대 커넥션 수를 초과하거나, 과도한 컨텍스트 스위칭으로 인한 성능 저하가 발생할 수 있습니다.

설명: 커넥션이 많다고 성능이 좋아지지 않습니다. 각 커넥션은 DB 서버 측에서도 메모리와 CPU 리소스를 소모합니다. HikariCP 문서에서는 많은 경우 10개 이하의 커넥션으로 충분하다고 설명합니다. CPU 코어가 8개인 서버에 200개의 커넥션을 열어도 CPU는 8개의 작업만 동시에 처리할 수 있으므로, 나머지 커넥션은 대기 상태가 됩니다.

Q2. @Transactional(readOnly=true)가 성능을 향상시키는 이유는?

정답: DB에 읽기 전용 힌트를 전달하여 불필요한 dirty checking(변경 감지)을 비활성화하고, 스냅샷 생성을 최적화하기 때문입니다.

설명: readOnly=true로 설정하면 Hibernate는 1차 캐시(Persistence Context)에서 dirty checking을 수행하지 않아 메모리 사용량과 CPU 오버헤드가 줄어듭니다. 또한 일부 DB 드라이버는 이 힌트를 받아 읽기 전용 복제본(Read Replica)으로 라우팅하거나 최적화된 실행 계획을 사용합니다. PostgreSQL에서는 트랜잭션 시작 시 스냅샷 생성을 건너뛰는 최적화도 가능합니다.

Q3. N+1 문제를 Fetch Join으로 해결할 때 페이징과 함께 사용하면 안 되는 이유는?

정답: 컬렉션(일대다) Fetch Join과 페이징을 함께 사용하면 Hibernate가 전체 데이터를 메모리에 로드한 후 페이징을 적용하는 HHH90003004 경고가 발생하고, 메모리 부족(OutOfMemory) 오류가 발생할 수 있습니다.

설명: 컬렉션 Fetch Join은 카테시안 곱(Cartesian product)을 만들어 결과 행 수가 증가합니다. DB 레벨에서 LIMIT/OFFSET을 적용하면 예상치 못한 결과가 나올 수 있으므로 Hibernate는 모든 데이터를 메모리에 가져온 후 자바 레벨에서 페이징을 처리합니다. 대신 @BatchSizedefault_batch_fetch_size 설정을 사용하면 IN 쿼리를 통해 N+1을 해결하면서 페이징도 정상적으로 동작합니다.

Q4. REQUIRES_NEW 트랜잭션 전파를 사용해야 하는 대표적인 사례는?

정답: 감사 로그(Audit Log)나 이메일 알림 발송처럼 상위 트랜잭션의 성공/실패에 관계없이 반드시 저장/실행되어야 하는 작업입니다.

설명: REQUIRES_NEW로 설정된 메서드는 부모 트랜잭션을 일시 정지하고 독립적인 새 트랜잭션을 시작합니다. 부모 트랜잭션이 롤백되더라도 REQUIRES_NEW 트랜잭션은 독립적으로 커밋됩니다. 예를 들어 주문 처리가 실패하더라도 "주문 시도 실패" 감사 로그는 반드시 DB에 저장되어야 하는 경우에 사용합니다.

Q5. R2DBC를 사용할 때 JPA(@Transactional)를 그대로 사용할 수 있나요?

정답: 아니요. R2DBC는 리액티브 스택이므로 Spring의 @Transactional 대신 ReactiveTransactionManagerTransactionalOperator를 사용해야 합니다.

설명: R2DBC는 블로킹 JDBC와 달리 비동기/논블로킹 방식으로 동작합니다. 기존 @Transactional은 Thread-Local 기반으로 동작하므로 리액티브 스택에서는 올바르게 작동하지 않습니다. Spring Boot는 R2DBC 환경에서 자동으로 R2dbcTransactionManager를 빈으로 등록하며, @Transactional도 리액티브 컨텍스트에서 동작하도록 지원합니다. 단, MonoFlux 반환 타입의 메서드에서만 올바르게 동작합니다.

Spring Boot Database Connection Complete Guide: JPA, HikariCP, Transactions, QueryDSL, Multi-DB

Table of Contents

  1. Dependency Configuration
  2. DataSource Configuration (HikariCP)
  3. Entity Design Patterns
  4. Repository Patterns
  5. QueryDSL Setup and Usage
  6. Transaction Management
  7. Solving the N+1 Problem
  8. Multi-DataSource Configuration
  9. R2DBC Async Connection
  10. Slow Query Logging and Monitoring
  11. Quiz

1. Dependency Configuration

Maven Dependencies

<!-- JPA/Hibernate -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- PostgreSQL driver -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- MySQL driver (optional) -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- QueryDSL -->
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <classifier>jakarta</classifier>
    <scope>provided</scope>
</dependency>

<!-- Flyway migration -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>

2. DataSource Configuration (HikariCP)

Spring Boot 2.x+ uses HikariCP as the default connection pool.

Complete Configuration Example

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: user
    password: secret
    driver-class-name: org.postgresql.Driver
    hikari:
      pool-name: HikariCP-Main
      maximum-pool-size: 10 # Maximum connections
      minimum-idle: 5 # Minimum idle connections
      connection-timeout: 30000 # Connection acquisition timeout (30 seconds)
      idle-timeout: 600000 # Idle connection retention time (10 minutes)
      max-lifetime: 1800000 # Maximum connection lifetime (30 minutes)
      connection-test-query: SELECT 1
      auto-commit: true
      leak-detection-threshold: 60000 # Connection leak detection (60 seconds)
  jpa:
    hibernate:
      ddl-auto: validate # prod: validate, dev: update/create-drop
    show-sql: false
    open-in-view: false # Disable OSIV (recommended)
    properties:
      hibernate:
        format_sql: true
        use_sql_comments: true
        default_batch_fetch_size: 100
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
        dialect: org.hibernate.dialect.PostgreSQLDialect

HikariCP Pool Size Formula

Optimal pool size = Tn x (Cm - 1) + 1
  • Tn: Maximum number of concurrent threads
  • Cm: Maximum connections needed per single transaction

A commonly used practical formula:

Connections = (CPU cores x 2) + effective spindle count

The HikariCP documentation notes that a pool of 10 connections satisfies most workloads.

Disabling OSIV (Open Session In View)

spring:
  jpa:
    open-in-view: false

Enabling OSIV holds a DB connection open for the entire HTTP response lifecycle, which can exhaust the connection pool. Managing transactions explicitly in the service layer is the recommended pattern.


3. Entity Design Patterns

Basic Entity Structure

@Entity
@Table(name = "users", indexes = {
    @Index(name = "idx_user_email", columnList = "email"),
    @Index(name = "idx_user_created_at", columnList = "created_at")
})
@EntityListeners(AuditingEntityListener.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Column(nullable = false, length = 50)
    private String name;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private UserStatus status = UserStatus.ACTIVE;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @Version
    private Long version;  // Optimistic Locking

    @Builder
    public User(String email, String name) {
        this.email = email;
        this.name = name;
    }
}

Enabling JPA Auditing

@Configuration
@EnableJpaAuditing
public class JpaConfig {
    // Add AuditorAware implementation if needed
}

BaseEntity Abstract Class

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

4. Repository Patterns

Using JpaRepository Built-in Methods

public interface UserRepository extends JpaRepository<User, Long> {

    // Method name-based query
    Optional<User> findByEmail(String email);
    List<User> findByStatusAndCreatedAtAfter(UserStatus status, LocalDateTime date);
    boolean existsByEmail(String email);
    long countByStatus(UserStatus status);

    // @Query with JPQL
    @Query("SELECT u FROM User u WHERE u.email = :email AND u.status = 'ACTIVE'")
    Optional<User> findActiveUserByEmail(@Param("email") String email);

    // Native Query
    @Query(value = "SELECT * FROM users WHERE created_at > :date LIMIT :limit",
           nativeQuery = true)
    List<User> findRecentUsersNative(@Param("date") LocalDateTime date,
                                     @Param("limit") int limit);

    // Interface-based Projection
    @Query("SELECT u.id AS id, u.email AS email FROM User u WHERE u.status = :status")
    List<UserSummary> findUserSummariesByStatus(@Param("status") UserStatus status);

    // Modifying Query
    @Modifying(clearAutomatically = true)
    @Transactional
    @Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
    int updateStatusByIds(@Param("ids") List<Long> ids, @Param("status") UserStatus status);
}

Projection Interface

public interface UserSummary {
    Long getId();
    String getEmail();
}

Specification API (Dynamic Queries)

public class UserSpecification {

    public static Specification<User> hasStatus(UserStatus status) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.equal(root.get("status"), status);
    }

    public static Specification<User> emailContains(String keyword) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.like(root.get("email"), "%" + keyword + "%");
    }

    public static Specification<User> createdAfter(LocalDateTime date) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.greaterThan(root.get("createdAt"), date);
    }
}

// Usage
List<User> users = userRepository.findAll(
    UserSpecification.hasStatus(UserStatus.ACTIVE)
        .and(UserSpecification.emailContains("example.com"))
);

5. QueryDSL Setup and Usage

QueryDSL Maven Plugin Configuration

<plugin>
    <groupId>com.mysema.maven</groupId>
    <artifactId>apt-maven-plugin</artifactId>
    <version>1.1.3</version>
    <executions>
        <execution>
            <goals>
                <goal>process</goal>
            </goals>
            <configuration>
                <outputDirectory>target/generated-sources/java</outputDirectory>
                <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
            </configuration>
        </execution>
    </executions>
</plugin>

Registering JPAQueryFactory Bean

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

QueryDSL Repository Implementation

@Repository
@RequiredArgsConstructor
public class UserQueryRepository {

    private final JPAQueryFactory queryFactory;

    public Page<User> findActiveUsers(UserSearchCondition condition, Pageable pageable) {
        List<User> content = queryFactory
            .selectFrom(user)
            .where(
                statusEq(condition.getStatus()),
                emailContains(condition.getEmail()),
                createdAtAfter(condition.getFromDate())
            )
            .orderBy(getOrderSpecifier(pageable))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        JPAQuery<Long> countQuery = queryFactory
            .select(user.count())
            .from(user)
            .where(
                statusEq(condition.getStatus()),
                emailContains(condition.getEmail())
            );

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    private BooleanExpression statusEq(UserStatus status) {
        return status != null ? user.status.eq(status) : null;
    }

    private BooleanExpression emailContains(String email) {
        return StringUtils.hasText(email) ? user.email.containsIgnoreCase(email) : null;
    }

    private BooleanExpression createdAtAfter(LocalDateTime date) {
        return date != null ? user.createdAt.after(date) : null;
    }
}

6. Transaction Management

Key @Transactional Options

@Service
@Transactional(readOnly = true)  // Class-level default: read-only
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    // Read-only (inherits class-level readOnly=true)
    public UserResponse getUser(Long id) {
        return userRepository.findById(id)
            .map(UserResponse::from)
            .orElseThrow(() -> new EntityNotFoundException("User not found: " + id));
    }

    // Write operation: override with readOnly=false
    @Transactional
    public UserResponse createUser(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateEmailException(request.getEmail());
        }
        User user = User.builder()
            .email(request.getEmail())
            .name(request.getName())
            .build();
        return UserResponse.from(userRepository.save(user));
    }

    // Roll back on all exceptions
    @Transactional(rollbackFor = Exception.class)
    public void processPayment(PaymentRequest request) {
        // Payment processing logic
    }

    // Independent transaction (commits/rolls back independently of parent)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAuditLog(String message) {
        // Audit log must always be saved
    }

    // Run without a transaction
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void callExternalApi() {
        // External API call with no DB operations
    }
}

Transaction Propagation Levels

PropagationDescription
REQUIREDUse existing transaction; create new one if none exists (default)
REQUIRES_NEWAlways create a new transaction; suspend the existing one
SUPPORTSUse existing transaction if available; proceed without one otherwise
NOT_SUPPORTEDExecute without a transaction; suspend any existing one
MANDATORYRequires existing transaction; throw exception if none exists
NEVERExecute without a transaction; throw exception if one exists
NESTEDExecute within a nested transaction using a Savepoint

Isolation Levels

// Prevent phantom reads
@Transactional(isolation = Isolation.SERIALIZABLE)

// Guarantee repeatable reads
@Transactional(isolation = Isolation.REPEATABLE_READ)

// Prevent dirty reads (most DB default)
@Transactional(isolation = Isolation.READ_COMMITTED)

7. Solving the N+1 Problem

The N+1 problem occurs when one query retrieves N records, then N additional queries fire to load related data for each record.

Problem Scenario

// N+1 example
List<Order> orders = orderRepository.findAll();
orders.forEach(order -> {
    System.out.println(order.getUser().getName()); // One User query per Order
});

Solution 1: @EntityGraph

@EntityGraph(attributePaths = {"user", "orderItems"})
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusWithUserAndItems(@Param("status") OrderStatus status);

Solution 2: Fetch Join

@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.user JOIN FETCH o.orderItems WHERE o.status = :status")
List<Order> findByStatusWithFetch(@Param("status") OrderStatus status);

Warning: Combining collection Fetch Join with pagination causes Hibernate to load all data into memory before applying the page (HHH90003004 warning), risking OutOfMemoryError. Use @BatchSize instead when pagination is required.

Solution 3: BatchSize

@Entity
public class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 100)   // Load 100 at a time using IN queries
    private List<Order> orders;
}

Or configure globally:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

8. Multi-DataSource Configuration

Configuring Two DataSources

@Configuration
@EnableTransactionManagement
public class DataSourceConfig {

    @Primary
    @Bean(name = "primaryDataSource")
    @ConfigurationProperties("spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean(name = "secondaryDataSource")
    @ConfigurationProperties("spring.datasource.secondary")
    public DataSource secondaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean(name = "primaryEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("primaryDataSource") DataSource dataSource) {
        return builder
            .dataSource(dataSource)
            .packages("com.example.domain.primary")
            .persistenceUnit("primary")
            .build();
    }

    @Primary
    @Bean(name = "primaryTransactionManager")
    public PlatformTransactionManager primaryTransactionManager(
            @Qualifier("primaryEntityManagerFactory") EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

application.yml Multi-DataSource Configuration

spring:
  datasource:
    primary:
      url: jdbc:postgresql://primary-db:5432/maindb
      username: ${DB_PRIMARY_USERNAME}
      password: ${DB_PRIMARY_PASSWORD}
      hikari:
        maximum-pool-size: 10
    secondary:
      url: jdbc:postgresql://secondary-db:5432/analyticsdb
      username: ${DB_SECONDARY_USERNAME}
      password: ${DB_SECONDARY_PASSWORD}
      hikari:
        maximum-pool-size: 5

Read/Write Separation (AbstractRoutingDataSource)

public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? DataSourceType.READ
            : DataSourceType.WRITE;
    }
}

public enum DataSourceType {
    READ, WRITE
}

9. R2DBC Async Connection

When using the reactive stack (Spring WebFlux), configure asynchronous DB connections with R2DBC.

Dependencies

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>r2dbc-postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

Configuration

spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/mydb
    username: user
    password: secret
    pool:
      initial-size: 5
      max-size: 10
      max-idle-time: 30m
      validation-query: SELECT 1

R2DBC Repository

public interface UserReactiveRepository extends ReactiveCrudRepository<User, Long> {
    Mono<User> findByEmail(String email);
    Flux<User> findByStatus(UserStatus status);
}

@Service
@RequiredArgsConstructor
public class UserReactiveService {

    private final UserReactiveRepository userRepository;

    public Mono<UserResponse> getUser(Long id) {
        return userRepository.findById(id)
            .map(UserResponse::from)
            .switchIfEmpty(Mono.error(new EntityNotFoundException("User not found: " + id)));
    }

    public Flux<UserResponse> getAllActiveUsers() {
        return userRepository.findByStatus(UserStatus.ACTIVE)
            .map(UserResponse::from);
    }
}

10. Slow Query Logging and Monitoring

p6spy Configuration

<dependency>
    <groupId>com.github.gavlyukovskiy</groupId>
    <artifactId>p6spy-spring-boot-starter</artifactId>
    <version>1.9.0</version>
</dependency>

src/main/resources/spy.properties:

appender=com.p6spy.engine.spy.appender.Slf4JLogger
logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat
customLogMessageFormat=%(currentTime)|%(executionTime)ms|%(category)|connection%(connectionId)|%(sqlSingleLine)
filter=true
execution=true

Enabling Hibernate Statistics

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
        session:
          events:
            log:
              LOG_QUERIES_SLOWER_THAN_MS: 100 # Log queries taking over 100ms

Micrometer + Prometheus Monitoring

management:
  endpoints:
    web:
      exposure:
        include: metrics,health,prometheus
  metrics:
    export:
      prometheus:
        enabled: true

Key monitoring metrics:

  • jdbc.connections.active: Currently active connection count
  • jdbc.connections.max: Maximum connection pool size
  • hibernate.query.executions.total: Total query execution count
  • hibernate.second.level.cache.hits: Second-level cache hit rate

Actuator Endpoints

GET /actuator/metrics/jdbc.connections.active
GET /actuator/metrics/jdbc.connections.pending
GET /actuator/metrics/hikaricp.connections.timeout

11. Quiz

Q1. What problems arise from setting HikariCP's maximum-pool-size too high?

Answer: Exceeding the DB server's maximum connection limit, or performance degradation due to excessive context switching.

Explanation: More connections do not automatically mean better performance. Each connection consumes memory and CPU resources on the DB server side. The HikariCP documentation states that a pool of fewer than 10 connections satisfies most workloads. Even if you open 200 connections on an 8-core server, the CPU can only process 8 tasks simultaneously, leaving the remaining connections idle. The optimal pool size should be determined through load testing.

Q2. Why does @Transactional(readOnly=true) improve performance?

Answer: It sends a read-only hint to the database and disables unnecessary dirty checking, reducing memory usage and CPU overhead.

Explanation: With readOnly=true, Hibernate skips dirty checking in the first-level cache (Persistence Context), reducing memory consumption and CPU overhead. Some DB drivers use this hint to route queries to read replicas or to use optimized execution plans. PostgreSQL can also skip snapshot creation at transaction start as an additional optimization.

Q3. Why should you avoid using Fetch Join on collections together with pagination?

Answer: Combining collection (one-to-many) Fetch Join with pagination causes Hibernate to load all data into memory before applying the page limit (HHH90003004 warning), risking OutOfMemoryError.

Explanation: Collection Fetch Joins produce a Cartesian product, increasing the number of result rows. Applying LIMIT/OFFSET at the DB level would produce unexpected results, so Hibernate loads all data into memory and performs pagination in Java. Use @BatchSize or the default_batch_fetch_size setting instead — they resolve N+1 problems using IN queries while allowing pagination to work correctly.

Q4. What is a representative use case for REQUIRES_NEW transaction propagation?

Answer: Operations like audit logging or email notification that must be saved/executed regardless of the parent transaction's success or failure.

Explanation: A method with REQUIRES_NEW suspends the parent transaction and starts an independent new one. Even if the parent transaction rolls back, the REQUIRES_NEW transaction commits independently. For example, even when order processing fails, the "order attempt failed" audit log must still be persisted to the database.

Q5. Can you use standard JPA @Transactional when using R2DBC?

Answer: Not directly. R2DBC is a reactive stack, so it requires ReactiveTransactionManager and TransactionalOperator instead of traditional Thread-Local-based transaction management.

Explanation: Unlike blocking JDBC, R2DBC operates asynchronously and non-blockingly. The standard @Transactional relies on Thread-Local storage and does not work correctly in reactive pipelines. Spring Boot automatically registers an R2dbcTransactionManager in R2DBC environments. @Transactional is also supported in reactive contexts, but only works correctly on methods that return Mono or Flux.