Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

목차

1. [의존성 설정](#1-의존성-설정)

2. [DataSource 설정 (HikariCP)](#2-datasource-설정-hikaricp)

3. [Entity 설계 패턴](#3-entity-설계-패턴)

4. [Repository 패턴](#4-repository-패턴)

5. [QueryDSL 설정과 활용](#5-querydsl-설정과-활용)

6. [트랜잭션 관리](#6-트랜잭션-관리)

7. [N+1 문제 해결](#7-n1-문제-해결)

8. [멀티 데이터소스 설정](#8-멀티-데이터소스-설정)

9. [R2DBC 비동기 연결](#9-r2dbc-비동기-연결)

10. [슬로우 쿼리 로깅 및 모니터링](#10-슬로우-쿼리-로깅-및-모니터링)

11. [퀴즈](#11-퀴즈)

1. 의존성 설정

Maven 의존성

<!-- JPA/Hibernate -->

<!-- PostgreSQL 드라이버 -->

<!-- MySQL 드라이버 (선택) -->

<!-- QueryDSL -->

<!-- Flyway 마이그레이션 -->

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 설정

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에는 `@QueryHints`의 `HibernateHints.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 연결을 구성합니다.

의존성

설정

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 설정

`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. 퀴즈

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

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

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

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

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

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

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

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

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

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

현재 단락 (1/410)

1. [의존성 설정](#1-의존성-설정)

작성 글자: 0원문 글자: 13,363작성 단락: 0/410