FEW의 메인 페이지에는 위의 사진에서 보이는 것처럼 아티클이 모인 학습지 목록을 제공하고 있습니다.
그리고 해당 워크북의 기본 정렬 기준은 아래와 같습니다.
- 현재 학습 중인 학습지를 가장 먼저 보여준다.
- 학습을 완료한 학습지는 가장 나중에 보여준다.
- 학습하고 있지 않는 학습지의 경우 구독자가 많은 학습지를 우선하여 보여준다.
학습하고 있지 않는 학습지의 경우 구독자가 많은 학습지를 우선하여 보여준다
현재 DB 테이블의 경우 '학습지' 테이블과 '구독자' 테이블을 구분하여 관리하고 있습니다.
-- 학습지 테이블
create table WORKBOOK
(
id bigint auto_increment
primary key,
title varchar(255) not null,
main_image_url varchar(255) not null,
category_cd tinyint not null,
description varchar(255) not null,
);
-- 구독 테이블
create table SUBSCRIPTION
(
id bigint auto_increment
primary key,
member_id bigint not null,
target_workbook_id bigint null,
);
위의 정렬을 수행하기 위해서는 '학습지'에 관한 '구독자 수' 정보를 위해서는 'Join 쿼리'를 사용하여 한번에 정보를 조회하는 방법과 각각의 정보 조회를 위한 쿼리를 '여러 번' 사용하는 방법이 있을 것이라 생각하였습니다. (이하 Join 쿼리, 여러 번 쿼리)
저는 두 방법의 차이를 아래와 같이 생각하였습니다.
- 학습지와 구독자 수에 대한 매핑과 정렬을 'Join 쿼리'의 경우 DB에서, '여러 번 쿼리'의 경우 애플리케이션에서 수행한다.
- 'Join 쿼리'는 DB에 한 번의 쿼리를 '여러 번 쿼리'는 DB에 여러 번의 쿼리를 요청한다.
이때 매핑과 정렬의 경우 Join에서 발생하는 비용이 있지만 DB에서 수행하는 것이 애플리케이션에서 수행하는 것보다 성능이 좋다는 것을 알고 있었습니다.
하지만 하나의 유즈케이스에서 한 번의 쿼리를 요청을 수행하는 것과 여러 번의 쿼리 요청을 수행하는 것의 차이에 대해서는 알지 못하였고 이에 대해 확인한 내용을 공유하려 합니다.
하나의 유즈케이스에서 여러 번 쿼리를 요청하면?
조금 더 구체적으로는 여러 번의 쿼리 요청을 수행하기 위해 커넥션을 어떻게 사용하고 있는지 확인해보려 합니다.
(설명의 편의를 위해 프로젝트와는 조금 다른 환경에서 유사한 상황을 설정하여 진행하였습니다.)
개발환경
- spring boot 2.7.5
- spring boot data jpa
- hikari cp
hikari cp 설정
spring:
datasource:
url: jdbc:mysql://localhost:13306/study
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
pool-name: MAIN-POOL
minimum-idle: 4
maximum-pool-size: 16
connection-timeout: 30000 # 30 seconds
idle-timeout: 300000 # 5 minutes
max-lifetime: 1800000 # 30 minutes
connection-test-query: SELECT 1
엔티티
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class ConnectionStudyEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
유즈케이스
@Slf4j
@Service
@RequiredArgsConstructor
public class ConnectionStudyUseCase {
private final ConnectionStudyRepository connectionStudyRepository;
@Transactional
public void execute(String name) {
ConnectionStudyEntity newEntity = new ConnectionStudyEntity();
// 1. name과 일치하는 엔티티를 조회한다.
Optional<ConnectionStudyEntity> entity1 = connectionStudyRepository.findByName(name);
if (entity1.isPresent()) {
log.info("findByName: {}", entity1.get());
// 2. 아이디로 엔티티를 조회한다.
ConnectionStudyEntity entity2 =
connectionStudyRepository.findById(entity1.get().getId()).orElseThrow();
log.info("findById: {}", entity2);
newEntity.setName(name + "new");
} else {
newEntity.setName(name);
}
// 3. 새로운 엔티티를 저장한다.
connectionStudyRepository.save(newEntity);
log.info("save: {}", newEntity);
}
}
1. 트랜잭션 시작과 커넥션 요청
ConnectionStudyUseCase#execute 메서드는 첫 @Transactional 어노테이션이 존재하기에 트랜잭션의 시작점(startTransaction)이 됩니다.
// AbstractPlatformTransactionManager line 347 ~379 getTransaction 메서드 내부
// Use defaults if no transaction definition given.
TransactionDefinition def = (definition != null ? definition : TransactionDefinition.withDefaults());
Object transaction = doGetTransaction();
boolean debugEnabled = logger.isDebugEnabled();
if (isExistingTransaction(transaction)) {
// Existing transaction found -> check propagation behavior to find out how to behave.
return handleExistingTransaction(def, transaction, debugEnabled);
}
// Check definition settings for new transaction.
if (def.getTimeout() < TransactionDefinition.TIMEOUT_DEFAULT) {
throw new InvalidTimeoutException("Invalid transaction timeout", def.getTimeout());
}
// No existing transaction found -> check propagation behavior to find out how to proceed.
if (def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_MANDATORY) {
throw new IllegalTransactionStateException(
"No existing transaction found for transaction marked with propagation 'mandatory'");
}
else if (def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRED ||
def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW ||
def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED) {
SuspendedResourcesHolder suspendedResources = suspend(null);
if (debugEnabled) {
logger.debug("Creating new transaction with name [" + def.getName() + "]: " + def);
}
try {
return startTransaction(def, transaction, debugEnabled, suspendedResources);
}
catch (RuntimeException | Error ex) {
resume(null, suspendedResources);
throw ex;
}
}
트랜잭션이 시작하며 AbstractPlatformTransactionManager를 상속한 JpaTransactionManager는 doBegin 메서드에서 transactionData를 생성하기 위해 HikariPool에 커넥션을 요청합니다.
( AbstractPlatformTransactionManager를 상속한 구체 클래스에서 트랜잭션 시작과 커넥션 요청이 일어난다.)
// JpaTransactionManager line 419 ~ 424, doBegin 메서드 내부
// Delegate to JpaDialect for actual transaction begin.
int timeoutToUse = determineTimeout(definition);
Object transactionData = getJpaDialect().beginTransaction(em,
new JpaTransactionDefinition(definition, timeoutToUse, txObject.isNewEntityManagerHolder()));
txObject.setTransactionData(transactionData);
txObject.setReadOnly(definition.isReadOnly());
// getJpaDialect().beginTransaction에 의해 실행된
// HibernateJpaDialect line 164, beginTransaction 메서드 내부
// Standard JPA transaction begin call for full JPA context setup...
entityManager.getTransaction().begin();
...
// 트랜잭션 시작을 위한 커넥션 요청에 의해 실행된
// HikariPool line 180, getConnection 메서드 내부
PoolEntry poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
이렇게 HikariPool에서 커넥션을 획득하여 transactionData를 생성한 이후 transactionData는 txObject에 저장합니다.
커넥션 획득 이후에는 DataSource를 확인하고 커넥션을 ConnectionHodler 객체로 TransactionSynchronizationManager에 DataSource와 함께 저장합니다.
// JpaTransactionManager line 431 ~ 451, doBegin 메서드 내부
// Register the JPA EntityManager's JDBC Connection for the DataSource, if set.
if (getDataSource() != null) {
ConnectionHandle conHandle = getJpaDialect().getJdbcConnection(em, definition.isReadOnly());
if (conHandle != null) {
ConnectionHolder conHolder = new ConnectionHolder(conHandle);
if (timeoutToUse != TransactionDefinition.TIMEOUT_DEFAULT) {
conHolder.setTimeoutInSeconds(timeoutToUse);
}
if (logger.isDebugEnabled()) {
logger.debug("Exposing JPA transaction as JDBC [" + conHandle + "]");
}
TransactionSynchronizationManager.bindResource(getDataSource(), conHolder);
txObject.setConnectionHolder(conHolder);
}
else {
if (logger.isDebugEnabled()) {
logger.debug("Not exposing JPA transaction [" + em + "] as JDBC transaction because " +
"JpaDialect [" + getJpaDialect() + "] does not support JDBC Connection retrieval");
}
}
}
2. Repository 클래스의 메서드로 쿼리 요청을 진행한다.
Repository 클래스의 메서드로 쿼리 요청을 진행할 때는 위의 과정을 거쳤기에 이미 트랜잭션이 생성된 상태입니다.
이에 새로운 트랜잭션을 생성하는 것이 아닌 생성된 트랜잭션(handleExistingTransaction)을 사용합니다.
생성된 트랜잭션에는 이미 커넥션이 존재하기에 다시 HikariPool에서 커넥션을 획득하는 과정이 반복되지는 않습니다.
// AbstractPlatformTransactionManager line 347 ~ 353 getTransaction 메서드 내부
Object transaction = doGetTransaction();
boolean debugEnabled = logger.isDebugEnabled();
if (isExistingTransaction(transaction)) {
// Existing transaction found -> check propagation behavior to find out how to behave.
return handleExistingTransaction(def, transaction, debugEnabled);
}
// JpaTransactionManager lin 366 ~ 388 doGetTransaction 메서드
@Override
protected Object doGetTransaction() {
JpaTransactionObject txObject = new JpaTransactionObject();
txObject.setSavepointAllowed(isNestedTransactionAllowed());
EntityManagerHolder emHolder = (EntityManagerHolder)
TransactionSynchronizationManager.getResource(obtainEntityManagerFactory());
if (emHolder != null) {
if (logger.isDebugEnabled()) {
logger.debug("Found thread-bound EntityManager [" + emHolder.getEntityManager() +
"] for JPA transaction");
}
txObject.setEntityManagerHolder(emHolder, false);
}
if (getDataSource() != null) {
ConnectionHolder conHolder = (ConnectionHolder)
TransactionSynchronizationManager.getResource(getDataSource());
txObject.setConnectionHolder(conHolder);
}
return txObject;
}
* 그럼 트랜잭션 내부에서 새로운 트랜잭션이 생성되면 커넥션도 새로 요청할까?
그렇진 않을 것이라 생각합니다.
트랜잭션 내부에서 새로운 트랜잭션이 생성되는 과정 역시 AbstractPlatformTransactionManager#handleExistingTransaction 메서드에서 수행되기 때문에 transaction에 존재하는 커넥션을 사용할 것이라 생각합니다.
3. 결론
여러 번 쿼리를 수행하여도 트랜잭션 매니저가 트랜잭션 시작 시 최초 한번 커넥션 풀에 커넥션을 요청할 뿐 이후 추가적인 커넥션 요청은 없다.
Join 쿼리 vs 여러 번 쿼리 선택
하나의 커넥션으로 여러 번 쿼리를 수행한 다는 것을 위를 통해 알 수 있었지만 저의 선택은 'Join 쿼리'였습니다.
제가 'Join 쿼리'를 선택한 이유는 조인으로 인해 조회 결과가 증가하는 쿼리가 아니었기 때문입니다.
'Join 쿼리'의 결과 역시 '여러 번 쿼리'를 위해 쿼리를 분리하였을 때와 그 수가 동일하였습니다.
그렇다면 DB에서 보다 빠르게 매핑과 정렬을 할 수 있는 'Join 쿼리'가 '여러 번 쿼리'보다 속도에서 강점을 가질 수 있다 판단하였고 이는 빠른 속도가 요구되는 메인 페이지 기능에서 선택의 중요한 기준이었습니다.
소감
해당 기능을 구현하며 평소에는 그냥 넘어가기도 하였던 것들을 확인할 수 있어서 보람찬 구현이었던 것 같습니다.
작업 PR: https://github.com/YAPP-Github/24th-Web-Team-1-BE/pull/261
'개발' 카테고리의 다른 글
Pinpoint 도입 이모저모 (0) | 2024.08.22 |
---|---|
유연한 코드를 작성하기 위한 고민 (0) | 2024.08.20 |
캐싱을 도입하며 (0) | 2024.07.30 |
jOOQ 사용기 (2) | 2024.07.23 |
[FEW] MVP 기능을 구현하며 (0) | 2024.07.14 |