Skip to content
Published on

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

Authors

목차

  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 반환 타입의 메서드에서만 올바르게 동작합니다.