- Authors

- Name
- Youngju Kim
- @fjvbn20031
목차
- 의존성 설정
- DataSource 설정 (HikariCP)
- Entity 설계 패턴
- Repository 패턴
- QueryDSL 설정과 활용
- 트랜잭션 관리
- N+1 문제 해결
- 멀티 데이터소스 설정
- R2DBC 비동기 연결
- 슬로우 쿼리 로깅 및 모니터링
- 퀴즈
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에는 @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 연결을 구성합니다.
의존성
<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는 모든 데이터를 메모리에 가져온 후 자바 레벨에서 페이징을 처리합니다. 대신 @BatchSize나 default_batch_fetch_size 설정을 사용하면 IN 쿼리를 통해 N+1을 해결하면서 페이징도 정상적으로 동작합니다.
Q4. REQUIRES_NEW 트랜잭션 전파를 사용해야 하는 대표적인 사례는?
정답: 감사 로그(Audit Log)나 이메일 알림 발송처럼 상위 트랜잭션의 성공/실패에 관계없이 반드시 저장/실행되어야 하는 작업입니다.
설명: REQUIRES_NEW로 설정된 메서드는 부모 트랜잭션을 일시 정지하고 독립적인 새 트랜잭션을 시작합니다. 부모 트랜잭션이 롤백되더라도 REQUIRES_NEW 트랜잭션은 독립적으로 커밋됩니다. 예를 들어 주문 처리가 실패하더라도 "주문 시도 실패" 감사 로그는 반드시 DB에 저장되어야 하는 경우에 사용합니다.
Q5. R2DBC를 사용할 때 JPA(@Transactional)를 그대로 사용할 수 있나요?
정답: 아니요. R2DBC는 리액티브 스택이므로 Spring의 @Transactional 대신 ReactiveTransactionManager와 TransactionalOperator를 사용해야 합니다.
설명: R2DBC는 블로킹 JDBC와 달리 비동기/논블로킹 방식으로 동작합니다. 기존 @Transactional은 Thread-Local 기반으로 동작하므로 리액티브 스택에서는 올바르게 작동하지 않습니다. Spring Boot는 R2DBC 환경에서 자동으로 R2dbcTransactionManager를 빈으로 등록하며, @Transactional도 리액티브 컨텍스트에서 동작하도록 지원합니다. 단, Mono나 Flux 반환 타입의 메서드에서만 올바르게 동작합니다.