Spring Boot - Pool config, jOOQ!
Pool config
Spring Boot 2.x 이상은 기본 커넥션 풀로 HikariCP 를 사용하며 아래 설정 가능하다.
- maximumPoolSize: 풀에서 유지할 최대 커넥션 수(기본값 10).
- minimumIdle: 유휴 상태로 유지할 최소 커넥션 수(기본값 maximumPoolSize).
- maxLifetime: 커넥션의 최대 생존 시간(기본 30분).
- connectionTimeout: 커넥션을 얻기 위한 최대 대기 시간(기본 30초).
- idleTimeout: 유휴 커넥션이 풀에 유지되는 시간(기본 10분).
AWS 4 vCPU, 16GB 자원에서 Tomcat max-threads 를 500 정도를 유지할 경우 아래와 같은 설정을 추천한다.
spring:
datasource:
hikari:
maximum-pool-size: 50 # 스레드 수(500)의 10~20% 수준으로 설정.
minimum-idle: 10 # 코어 수(4) × 2~3배 정도로 설정
max-lifetime: 1800000 # 30분(기본값)
connection-timeout: 30000 # 30초(기본값)
idle-timeout: 600000 # 10분(기본값)
MySQL 서버 설정도 커넥션 개수를 기본값 151 에서 500 으로 확장
보통 4vCPU 16GB 정도 리소스에서 500개 정도는 커버 가능하다.
SHOW VARIABLES LIKE 'max_connections'; -- 151
SET GLOBAL max_connections = 500;
커넥션 수가 부족하다 느껴진다면 read, write 인스턴스 분리, 샤딩 과 같은 물리적인 증가방법을 사용하거나 MongoDB 와 같이 커넥션 수 제한이 없는 NoSQL DB 사용을 권장한다.
jOOQ
jOOQ(Java Object Oriented Querying)
타입 안전 SQL 빌더, 코드생성도구.
데이터베이스 스키마 혹은 DDL(데이터 정의어)쿼리를 기반으로 Java 클래스를 자동 생성하여 타입 안전한 쿼리를 작성할 수 있다.
Spring Boot 공식 지원 스타터 패키지 존재.
dependencies {
// JOOQ + Spring Data JDBC 함께 사용
implementation 'org.springframework.boot:spring-boot-starter-jooq'
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' // Spring Data JDBC 같이 테스트
implementation 'com.h2database:h2'
}
configurations {
jooqCodegen {
extendsFrom implementation
}
}
dependencies {
// JOOQ code generation (DDLDatabase 사용)
// jooq-codegen은 Spring Boot가 관리하므로 버전 생략 가능
// jooq-meta-extensions는 Spring Boot가 관리하지 않으므로 버전 명시 필요
jooqCodegen 'org.jooq:jooq-codegen' // Spring Boot가 관리하는 버전 사용
jooqCodegen 'org.jooq:jooq-meta-extensions:3.18.13' // DDLDatabase 지원 (버전 명시 필요)
jooqCodegen 'com.h2database:h2'
}
장점:
- 타입 안전성: 컴파일 타임에 쿼리 오류를 발견할 수 있다.
- SQL 친화적: SQL에 가까운 형태로 쿼리를 작성할 수 있다.
- 복잡한 쿼리: 복잡한 SQL 쿼리를 쉽게 작성할 수 있다.
- 동적 쿼리: 조건에 따라 동적으로 쿼리를 생성할 수 있다.
- 성능: 생성된 쿼리가 최적화되어 있다.
단점:
- 코드 생성: 스키마 변경 시 코드 재생성이 필요하다.
- 학습 곡선: jOOQ 문법을 익혀야 한다.
- 의존성: 데이터베이스 스키마에 의존적이다.
코드생성
데이터베이스 연결 혹은 지정한 DDL 파일로부터 코드를 생성한다.
아래는 src/main/resources/jooq-codegen.xml 을 통해 코드생성하는 과정.
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<configuration xmlns="http://www.jooq.org/xsd/jooq-codegen-3.18.0.xsd">
<generator>
<database>
<name>org.jooq.meta.extensions.ddl.DDLDatabase</name>
<properties>
<property>
<key>scripts</key>
<value>src/main/resources/init.sql</value>
</property>
<property>
<key>defaultNameCase</key>
<value>lower</value>
</property>
</properties>
<!-- TIMESTAMP 타입을 Instant로 강제 매핑 -->
<forcedTypes>
<forcedType>
<name>INSTANT</name>
<types>TIMESTAMP</types>
</forcedType>
</forcedTypes>
</database>
<target>
<packageName>com.example.jooq.generated</packageName>
<directory>src/main/java</directory>
</target>
<generate>
<pojos>true</pojos>
<pojosEqualsAndHashCode>true</pojosEqualsAndHashCode>
<pojosToString>true</pojosToString>
<javaTimeTypes>true</javaTimeTypes>
</generate>
</generator>
</configuration>
init.sql 테이블 DDL 작성.
-- 사용자(Author) 테이블 생성
CREATE TABLE IF NOT EXISTS author (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 게시판 테이블 생성 (author_id 추가)
CREATE TABLE IF NOT EXISTS board (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
author_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
view_count INT DEFAULT 0,
FOREIGN KEY (author_id) REFERENCES author(id) ON DELETE CASCADE
);
-- 댓글 테이블 생성
CREATE TABLE IF NOT EXISTS comment (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
board_id BIGINT NOT NULL,
content TEXT NOT NULL,
author VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (board_id) REFERENCES board(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_board_id ON comment(board_id);
CREATE INDEX IF NOT EXISTS idx_board_created_at ON board(created_at);
CREATE INDEX IF NOT EXISTS idx_board_author_id ON board(author_id);
CREATE INDEX IF NOT EXISTS idx_author_email ON author(email);
Gradle에서 코드 생성 태스크를 추가한다.
// JOOQ code generation 태스크 (DDLDatabase 방식: init.sql에서 직접 생성)
task jooqCodegen(type: JavaExec) {
classpath = configurations.jooqCodegen
mainClass = 'org.jooq.codegen.GenerationTool'
args = ['src/main/resources/jooq-codegen.xml']
}
// JOOQ code generation을 compileJava 전에 실행 (필요시 주석 해제)
tasks.named('compileJava') {
dependsOn 'jooqCodegen'
}
코드 생성 실행하여 jooq-codegen.xml 파일에 지정한 com.example.jooq.generated 패키지 위치에 코드생성 확인.
./gradlew jooqCodegen
실제 데이터베이스에 연결하여 스키마를 읽어들일경우 아래 xml 참고.
<configuration>
<jdbc>
<driver>com.mysql.cj.jdbc.Driver</driver>
<url>jdbc:mysql://localhost:3306/demo</url>
<user>root</user>
<password>root</password>
</jdbc>
<generator>
<database>
<name>org.jooq.meta.mysql.MySQLDatabase</name>
<inputSchema>demo</inputSchema>
</database>
<target>
<packageName>com.example.jooq</packageName>
<directory>src/main/java</directory>
</target>
</generator>
</configuration>
기본 사용법
생성된 클래스를 사용하여 타입 안전한 쿼리를 작성할 수 있다.
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository; // Spring Data JDBC 인터페이스
private final DSLContext dsl; // JOOQ DSL (다이나믹 쿼리용)
// 게시판 + 작성자 조회 (DTO 사용) - Spring Data JDBC 사용
public List<BoardWithAuthorDto> findAllWithAuthor() {
return boardRepository.findAllWithAuthor();
}
// 게시판 ID로 조회: 게시판 + 작성자 (JOOQ JOIN 사용)
public BoardAuthorDto findByIdWithAuthor(Long boardId) {
Record record = dsl.select()
.from(BOARD)
.join(AUTHOR).on(BOARD.AUTHOR_ID.eq(AUTHOR.ID))
.where(BOARD.ID.eq(boardId))
.fetchOne();
if (record == null) return null;
Board board = record.into(BOARD).into(Board.class);
Author author = record.into(AUTHOR).into(Author.class);
BoardAuthorVo vo = new BoardAuthorVo(board, author, null);
return convertToDto(vo);
}
// INSERT
@Transactional
public BoardDto createBoard(BoardCreateRequest request) {
Instant now = Instant.now();
// INSERT 실행 후 생성된 ID 반환 (returning() 사용)
// 주의: MySQL/H2는 RETURNING 절을 지원하지 않을 수 있음
Record1<Long> boardRecord = dsl.insertInto(BOARD)
.set(BOARD.TITLE, request.getTitle())
.set(BOARD.CONTENT, request.getContent())
.set(BOARD.AUTHOR_ID, request.getAuthorId())
.set(BOARD.CREATED_AT, now)
.set(BOARD.UPDATED_AT, now)
.set(BOARD.VIEW_COUNT, 0)
.returningResult(BOARD.ID) // 자동으로 MySQL/H2 fallback: LAST_INSERT_ID() 사용
.fetchOne();
Long boardId = boardRecord.getValue(BOARD.ID);
Board board = findById(boardId);
return convertToDto(board);
}
// UPDATE
@Transactional
public BoardDto updateBoard(Long boardId, BoardUpdateRequest request) {
int updatedRows = dsl.update(BOARD)
.set(BOARD.TITLE, request.getTitle())
.set(BOARD.CONTENT, request.getContent())
.set(BOARD.UPDATED_AT, Instant.now())
.where(BOARD.ID.eq(boardId))
.execute();
if (updatedRows == 0) {
return null; // 게시판이 존재하지 않음
}
Board board = findById(boardId);
return convertToDto(board);
}
// DELETE
@Transactional
public boolean deleteBoard(Long boardId) {
int deletedRows = dsl.deleteFrom(BOARD)
.where(BOARD.ID.eq(boardId))
.execute();
return deletedRows > 0;
}
// JOOQ로 단일 게시판 조회 (내부 메서드)
private Board findById(Long boardId) {
BoardRecord record = dsl.selectFrom(BOARD)
.where(BOARD.ID.eq(boardId))
.fetchOne();
return record != null ? record.into(Board.class) : null;
}
}
동적 쿼리
조건에 따라 동적으로 쿼리를 생성할 수 있다.
// 게시판 검색 (다이나믹 파라미터): 게시판 + 작성자 (JOOQ 사용)
public List<BoardAuthorDto> searchBoards(BoardSearchRequest request) {
Condition condition = null;
if (request.getTitle() != null && !request.getTitle().isEmpty()) {
condition = BOARD.TITLE.like("%" + request.getTitle() + "%");
}
if (request.getContent() != null && !request.getContent().isEmpty()) {
condition = condition == null
? BOARD.CONTENT.like("%" + request.getContent() + "%")
: condition.and(BOARD.CONTENT.like("%" + request.getContent() + "%"));
}
SelectJoinStep<Record> selectStep = dsl.select()
.from(BOARD)
.join(AUTHOR).on(BOARD.AUTHOR_ID.eq(AUTHOR.ID));
if (condition != null) {
return selectStep.where(condition)
.orderBy(BOARD.ID.desc())
.fetch()
.stream()
.map(record -> {
Board board = record.into(BOARD).into(Board.class);
Author author = record.into(AUTHOR).into(Author.class);
BoardAuthorVo vo = new BoardAuthorVo(board, author, null);
return convertToDto(vo);
})
.collect(Collectors.toList());
} else {
return selectStep.orderBy(BOARD.ID.desc())
.fetch()
.stream()
.map(record -> {
Board board = record.into(BOARD).into(Board.class);
Author author = record.into(AUTHOR).into(Author.class);
BoardAuthorVo vo = new BoardAuthorVo(board, author, null);
return convertToDto(vo);
})
.collect(Collectors.toList());
}
}
트랜잭션
@Transactional 어노테이션을 사용하여 트랜잭션을 관리할 수 있다. jOOQ는 Spring의 트랜잭션 관리와 완벽하게 통합된다.
@Transactional
public void createUserWithOrder(String name, String email) {
// 여러 쿼리를 하나의 트랜잭션으로 묶을 수 있다
dsl.insertInto(AUTHOR)
.set(AUTHOR.NAME, name)
.set(AUTHOR.EMAIL, email)
.execute();
dsl.insertInto(BOARD)
.set(BOARD.TITLE, "제목")
.set(BOARD.CONTENT, "내용")
.set(BOARD.AUTHOR_ID, 1L)
.execute();
}
Spring Data JDBC와 함께 사용
단순 조회는 spring-boot-starter-data-jdbc 를 사용하고, 복잡한 동적 쿼리는 jOOQ를 사용하는 것도 추천.
@Repository
public interface BoardRepository extends CrudRepository<Board, Long> {
// 생성자 기반 자동 매핑 시도 (컬럼 순서와 생성자 매개변수 순서 일치 필요)
@Query("""
SELECT b.id, b.title, b.content, b.author_id, b.created_at, b.updated_at, b.view_count,
a.id AS author_id_value, a.name AS author_name,
a.email AS author_email, a.created_at AS author_created_at,
a.updated_at AS author_updated_at
FROM board b
INNER JOIN author a ON b.author_id = a.id
ORDER BY b.id DESC
""")
List<BoardWithAuthorDto> findAllWithAuthor();
}
@Repository
public interface CommentRepository extends CrudRepository<Comment, Long> {
List<Comment> findByBoardId(Long boardId);
List<Comment> findByBoardIdIn(List<Long> boardIds);
}
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository; // Spring Data JDBC
private final DSLContext dsl; // JOOQ (동적 쿼리용)
// 단순 조회는 Spring Data JDBC 사용
public List<BoardWithAuthorDto> findAllWithAuthor() {
return boardRepository.findAllWithAuthor();
}
// 동적 쿼리는 jOOQ 사용
public List<BoardAuthorDto> searchBoards(BoardSearchRequest request) {
// ... jOOQ 동적 쿼리
}
}
데모코드
https://github.com/Kouzie/spring-boot-demo/tree/main/jooq-demo