회원 데이터가 Master DB 에 INSERT
되었고 Slave DB 에서도 SELECT
하면 조회되는데, Master DB 의 다른 Session 으로 SELECT
하면 데이터가 조회되지 않는 이슈가 있었어요.
회원마케팅서비스 개발팀에서 이슈의 원인을 파악하고, 해결한 과정을 공유드리려고 합니다.
간헐적으로 존재하는 회원 데이터가 조회되지 않는 에러가 발생했어요. 이상한 점은 에러 발생 후에 다시 요청하면 데이터가 조회되고 있어요.
✔️ 사용하고 있는 데이터베이스 엔진 및 격리수준은 다음과 같아요.
REPEATABLE READ
✔️ 이슈에 영향을 준 서비스의 설정은 다음과 같아요.
hikari.auto-commit
설정: false (기본 설정은 true 이에요)jpa.open-in-view
설정: true (기본 설정은 true 이에요)✔️ 이슈가 발생한 코드는 다음과 같아요.
public Membership outerMethod(long memberNo) {
var member = memberRepository.findByMemberNo(memberNo)
.orElseThrow(() -> new RuntimeException("요청한 리소스를 찾을 수 없습니다.");
var innerResult = service.innerMethod(memberNo);
return ...;
}
outerMethod()
에는 @Transactional
설정이 없어요.innerMethod()
에 @Transactional(readOnly = true)
설정이 적용되어있어요.INSERT
완료SELECT
시, 정상 조회SELECT
시, 조회 되지 않음데이터 생성 시기와 조회 시기의 차이가 최대 1분이내였기때문에, Master DB 에 데이터가 적재 되었지만 Replica Lag 으로 인해 Slave DB 에 데이터가 늦게 복제되는 경우를 의심했어요.
멤버십 서비스에서 Slave DB 를 통해 데이터를 조회한다면 Replica Lag 현상으로 데이터가 조회되지 않을 수 있거든요. 다시 요청하면 데이터가 조회되는 이유도 Master DB 에서 Slave DB 로 데이터 복제가 완료되어 데이터가 조회될 수 있는 거죠!
하지만 Replica Lag 이 원인은 아니었어요. 멤버십 서비스에서 조회 쿼리가 실행되는 Connection 디버깅 시에 Slave DB 가 아닌 Master DB 에서 실행되고 있었기 때문이에요.
Replica Lag
Slave DB 가 Master DB 에서 발생하는 업데이트를 따라잡을 수 없을 때 발생해요. 적용되지 않은 변경 사항은 Slave DB 의 릴레이 로그에 누적되며, Slave DB 와 Master DB 의 데이터베이스 버전 차이는 점점 커져요.
즉, Slave DB 의 Master DB 복제가 지연되는 것이에요.
INSERT
완료SELECT
시, 정상 조회SELECT
시, 조회 되지 않음COMMIT
이 실행되지 않았기 때문이에요.public Membership outerMethod(long memberNo) {
var member = memberRepository.findByMemberNo(memberNo)
.orElseThrow(() -> new RuntimeException("요청한 리소스를 찾을 수 없습니다.");
var innerResult = service.innerMethod(memberNo);
return ...;
}
autocommit
설정이 false 이기 때문에 쿼리 종료시점에 수동으로 COMMIT
이 실행되어야해요. 개발자가 명시적으로 실행하거나, @Transactional
을 사용할 수도 있어요. @Transactional
은 메서드 종료 시점에 정상 종료 시 COMMIT
, 예외 발생 시 ROLLBACK
을 실행시켜요.
COMMIT
이 실행되지 않으면 어떤 일이 벌어지나요?COMMIT
이 실행되지 않으면, Consistent Nonlocking Reads 로 인해 Snapshot 이 갱신되지 않아 최초 쿼리 실행 시점 이후에 실행된 트랜잭션의 변경사항을 보지 못해요.
InnoDB 가 특정 시점의 데이터베이스 Snapshot 을 쿼리에 제공하기 위해 MVCC(다중 버전 동시성 제어)를 사용하는 것을 의미해요. 쿼리는 그 시점 이전에 커밋된 트랜잭션의 변경 사항은 보지만, 그 이후의 변경 사항이나 커밋되지 않은 트랜잭션의 변경 사항은 보지 않아요. 단, 같은 트랜잭션 내에서 이전에 실행된 명령문에 의해 변경된 사항은 예외에요.
트랜잭션 격리 수준이 기본값인 REPEATABLE READ
일 경우, 같은 트랜잭션 내의 모든 일관된 읽기는 그 트랜잭션에서 처음 읽기를 수행한 시점의 Snapshot 을 읽어요. 현재 트랜잭션을 커밋한 후 새로운 쿼리를 실행하면 더 최신의 Snapshot 을 얻을 수 있어요.
용어도 어렵고, 설명도 어려우니 코드로 보기로 해요.
time Session A Session B
|
| SET autocommit=0; SET autocommit=0;
|
| SELECT * FROM t; -- Snapshot #1
| 데이터 없음
| INSERT INTO t VALUES (1, 2);
|
| SELECT * FROM t; -- Snapshot #1
| 데이터 없음
| COMMIT;
|
| // ✨ Session B 추가한 데이터를 보지 못해요.
| SELECT * FROM t; -- Snapshot #1
| 데이터 없음
|
| // ✨ 다음 쿼리 실행시점에 Snapshot #2 생성
| COMMIT;
|
| SELECT * FROM t; -- Snapshot #2
| ---------------------
| | 1 | 2 |
v ---------------------
SET autocommit=0;
설정이 된 경우, Session B 에서 데이터를 INSERT
후 COMMIT
이 실행되어도 Session A 에서 COMMIT
이 실행되기 전까지 Snapshot #1
을 가지고 있어 Session B 에서 추가한 데이터를 보지 못하는 현상이 발생해요.
COMMIT
, ROLLBACK
, BEGIN
모두 동일하게 Snapshot 을 갱신 해주는 역할을 해요.
Snapshot #1
이 생성Snapshot #1
에서는 신규 회원 데이터를 볼 수 없으므로 ROLLBACK
실행ROLLBACK
으로 인해 신규 회원 데이터 INSERT
변경내역이 반영된 Snapshot #4
가 생성되고, 신규 회원 데이터 조회 성공기존 회원 데이터 조회는 왜 일어나는 걸까요?
멤버십 서비스의 회원정보 조회 API 는 회원가입 직후 외에도 회원의 멤버십 정보가 필요한 시점마다 호출되는 API 에요. 그래서 기존 회원 데이터 조회도 발생하고 있어요.
간헐적으로 존재하는 회원 데이터가 조회되지 않는 에러가 발생했어요. 이상한 점은 에러 발생 후에 다시 요청하면 데이터가 조회되고 있어요.
간헐적으로 신규 데이터가 적재되기 전 Snapshot 을 가지고 있던 Session 에서 데이터가 조회되지 않았어요. 데이터가 조회되지 않으면 ROLLBACK
이 발생하고, 그 다음 데이터 요청에서 Snapshot 이 갱신되기 때문에 다시 요청하면 데이터가 조회되고 있어요.
READ COMMITTED
격리수준으로 낮추기READ COMMITTED
격리 수준에서는 각 SELECT
명령어가 별도의 Snapshot 을 사용해요.
저희 팀에서는 MySQL 의 기본 트랜잭션 격리 수준과 동일한 REPEATABLE READ
를 사용하고 있는데, 격리수준을 낮추는 경우에 Phantom Read 이슈가 발생하여 기존 비즈니스 로직에 영향을 주는 등의 사이드 이펙트를 고려하여 다른 해결방법을 찾아보기로 해요.
Phantom Read
한 트랜잭션에서 두 번의 읽기 작업을 수행할 때, 서로 다른 결과를 반환하는 현상을 말해요. 이는 다른 트랜잭션이 새로운 행을 삽입하거나 기존 행을 삭제하여 발생해요.
fyi; phantom rows
SELECT * FROM t LOCK IN SHARE MODE;
잠금 읽기(locking read)를 사용하면 InnoDB 는 Snapshot 이 아니라 테이블의 최신 데이터를 읽어요. 이는 데이터의 일관성을 유지하기 위함이에요.
하지만 잠금 읽기를 사용하면 SELECT
명령어가 실행될 때 해당 행을 잠그고, 다른 트랜잭션이 그 행을 수정하거나 삭제하지 못하게 되어 락 경합 및 데드락 발생 가능성 증가 이슈가 발생할 수 있어요. 다른 해결방법을 찾아보기로 해요.
@Transactional(readOnly = true)
설정 추가하기저희 팀은 @Transactional(readOnly = true)
설정을 추가하여, 메서드가 종료될 때 실행되는 COMMIT
을 통해 Snapshot 을 갱신하는 방법을 사용하기로 했어요.
@Transactional(readOnly = true)
public Membership outerMethod(long memberNo) {
var member = memberRepository.findByMemberNo(memberNo)
.orElseThrow(() -> new RuntimeException("요청한 리소스를 찾을 수 없습니다.");
var innerResult = service.innerMethod(memberNo);
return ...;
}
이제 매 요청마다 테이블의 최신 데이터가 포함된 Snapshot 기준으로 데이터를 조회하여, 존재하는 데이터를 정상적으로 조회할 수 있을거에요.
@Transactional
적용이 되어있지 않아요.SimpleJpaRepository 정의된 Spring Data JPA의 기본 메서드에는 @Transactional
이 적용됩니다.
findById
, findAll
등): @Transactional(readOnly = true)
save
, delete
등): @Transactional
하지만 findByMemberNo
처럼 JpaRepository 인터페이스에 정의한 쿼리 메서드는 @Transactional
이 적용되지 않아 트랜잭션에 의한 COMMIT
이 실행되지 않아요.
그래서 트랜잭션을 적용하려면 개발자가 명시적으로 @Transactional
을 추가해야 합니다.
@Nullable
private Object doInvoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
// 🌟🌟🌟 쿼리 메서드인지 확인
if (hasQueryFor(method)) {
RepositoryMethodInvoker invocationMetadata = invocationMetadataCache.get(method);
if (invocationMetadata == null) {
invocationMetadata = RepositoryMethodInvoker.forRepositoryQuery(method, queries.get(method));
invocationMetadataCache.put(method, invocationMetadata);
}
return invocationMetadata.invoke(repositoryInformation.getRepositoryInterface(), invocationMulticaster,
invocation.getArguments());
}
// 쿼리 메서드가 아니라면 기본 CRUD 메서드로 위임
return invocation.proceed();
}
private boolean hasQueryFor(Method method) {
return queries.containsKey(method);
}
// application.yml 형식을 사용한다면 아래와 같이 로깅 레벨을 설정해주세요.
logging:
level:
org.springframework.orm.jpa: TRACE
org.springframework.transaction: TRACE
// application.properties 형식을 사용한다면 아래와 같이 로깅 레벨을 설정해주세요.
logging.level.org.springframework.orm.jpa=TRACE
logging.level.org.springframework.transaction=TRACE
o.s.t.i.TransactionInterceptor : No need to create transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findByMemberNo]: This method is not transactional. // @Transactional 적용되지 않아요
...
o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly // @Transactional 이 적용되어요.
o.s.t.i.TransactionInterceptor : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]
o.s.t.i.TransactionInterceptor : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]
COMMIT
여부를 체크해요.HikariCP 는 JDBC Connection 관리를 위해 ProxyConnection 클래스를 사용하여 실제 JDBC Connection 객체를 관리하고 래핑합니다. Connection 종료 시점에 COMMIT
여부를 확인하여, COMMIT
이 실행되지 않았다면 ROLLBACK
을 실행시켜요.
ProxyConnection.close() 에서 COMMIT
이 실행되지 않았고(isCommitStateDirty = true
) autocommit
설정이 false 라면 ROLLBACK
을 실행시켜요.
public final void close() throws SQLException {
try {
// ✨✨✨ COMMIT이 없고, autocommit 설정이 false 라면 ROLLBACK
if (this.isCommitStateDirty && !this.isAutoCommit) {
this.delegate.rollback();
LOGGER.debug("{} - Executed rollback on connection {} due to dirty commit state on close().", this.poolEntry.getPoolName(), this.delegate);
}
...
}
위에서 언급한 JPA 쿼리 메서드나 QueryDSL 은 @Transactional
설정이 적용되어있지 않아 COMMIT
이 실행되지 않기 때문에 autocommit
설정이 false 라면, 쿼리가 정상적으로 실행되었더라도 ROLLBACK
이 실행됩니다. 물론 ROLLBACK
이 실행된다고 해서 서비스에서 예외가 반환되는 것은 아니에요.
public void rollbackTest(long memberNo) {
var member = memberRepository.findByMemberNo(memberNo);
memberRepisitory.findByName(member.getIdentification());
}
서비스는 정상 응답을 하지만, JPA 쿼리 메서드가 종료되는 시점마다 ROLLBACK 이 발생하는 것을 알 수 있어요.
Log 를 통해서 ROLLBACK 실행 여부 확인이 가능해요!// application.yml 형식을 사용한다면 아래와 같이 로깅 레벨을 설정해주세요.
logging:
level:
com.zaxxer.hikari.pool.ProxyConnection: TRACE
// application.properties 형식을 사용한다면 아래와 같이 로깅 레벨을 설정해주세요.
logging.level.com.zaxxer.hikari.pool.ProxyConnection=TRACE
com.zaxxer.hikari.pool.ProxyConnection : HikariPool-1 - Executed rollback on connection org.mariadb.jdbc.MariaDbConnection@321302bc due to dirty commit state on close().
이번 이슈에서 데이터가 조회되지 않으면 ROLLBACK
이 발생하는 이유도 JPA 쿼리 메서드로 조회 후에 데이터가 없으면 Exception 을 반환하는데, ProxyConnection 종료 시점에서 해당 Connection 에 COMMIT
이 실행되지 않아 ROLLBACK
을 시켜 버린 것이었습니다.
@Transactional
설정 또는 설정이 없는 경우는 Master DB 로 요청이 가고, @Transactional(readOnly=true)
설정인 경우 Slave DB 로 요청이 갑니다.
MariaDB Connector/J 드라이버는 v3.0.3 부터 aurora 모드를 지원하지 않으니 주의해주세요!
관련 내용이 궁금하다면 aurora 모드를 지원하지 않는다고? 블록을 참고해주세요.
fyi; about-mariadb-connector-j
public Membership outerMethod(long memberNo) {
var member = memberRepository.findByMemberNo(memberNo)
.orElseThrow(() -> new RuntimeException("요청한 리소스를 찾을 수 없습니다.");
var innerResult = service.innerMethod(memberNo);
return ...;
}
outerMethod()
에는 @Transactional
설정이 없어요.innerMethod()
에 @Transactional(readOnly = true)
설정이 적용되어있어요.findByMemberNo()
메서드 실행 후 데이터가 조회되더라도 ROLLBACK
이 실행되어 Snapshot 이 갱신되었을텐데, 왜 과거 Snapshot 을 가지고 있었나요?@Transactional
이 적용되지 않아요. 그래서 Connection 을 닫을 때, COMMIT
이 없어 ROLLBACK
이 실행되고 Snapshot 이 갱신되는게 맞아요.그렇다면 매 요청마다 최신 스냅샷으로 갱신되어 데이터를 조회할 수 있어야 하는데, 왜 조회되지않았을까요? 이건 open-in-view 설정과 관련이 있어요.
open-in-view 설정이 false 라면 트랜잭션 단위로 Connection 이 시작 및 종료돼요. 그래서 findByMemberNo
실행 후 Connection 종료되고, 이때 ROLLBACK
이 실행되어 매 요청에서 최신 Snapshot 을 가지고 있었을 거에요. 최신 Snapshot 을 가지고 있었다면 존재하는 회원 데이터는 조회돼요.
하지만, 위 프로젝트 설정은 open-in-view 설정이 true 였어요. Connection 시작 시점부터 API 종료시점까지 Connection 이 유지되고, API 종료시점에 Connection 의 COMMIT
여부를 체크하게 되는데 하위에 존재하는 @Transactional(readOnly = true)
설정으로 인해 Connection 에 COMMIT
이 실행되어 ROLLBACK
이 실행되지 않았고, Snapshot도 갱신되지 않았어요😢
innerMethod()
에 @Transactional(readOnly = true)
가 있는데, Slave Session 의 Snapshot 은 매번 갱신되나요?public Membership outerMethod(long memberNo) {
var member = memberRepository.findByMemberNo(memberNo); // 🙅♀️ 데이터 없음
var innerResult = service.innerMethod(memberNo);
return ...;
}
@Transactional(readOnly = true)
public InnerResult innerMethod(long memberNo) {
var member = memberRepository.findByMemberNo(memberNo); // 🙆♀️ 데이터 있음
...
}
outerMethod()
에는 @Transactional
이 적용되어있지 않아 Master Session 에서 실행돼요.outerMethod()
에서 회원의 데이터가 없으면 에러를 반환하지만, innerMethod()
메서드 호출을 위해 제거해볼게요.innerMethod()
에 @Transactional(readOnly = true)
이 적용되어있기 때문에 Slave Session 에서 실행돼요.저희 팀은 MariaDB Connector/J 의 aurora 모드를 사용하고 있어요. 따라서 @Transactional(readOnly = true)
가 존재하는 innerMethod()
는 Slave Session 에서 실행되고, 메서드 종료시점에 COMMIT
이 발생하여, 매 요청마다 Snapshot 이 갱신됩니다. Master Session 은 Snapshot 이 갱신되지 않았는데 말이죠.
그래서 Master Session 에서 조회되지 않던 데이터가 Slave Session 에서 조회되는 이상한 현상이 발생해요.
innerMethod()
에 (readOnly=true) 옵션이 없었다면, Snapshot 이 매번 갱신되어 데이터가 정상 조회되었을까요?innerMethod()
에 (readOnly=true) 옵션을 제외한 @Transactional
설정이 적용되었다면 데이터는 정상 조회돼요.Master Session 에서 COMMIT
이 발생하여 Snapshot 이 갱신되었을 것이기 때문에 위 이슈는 발생하지 않았어요.
하지만 조회 쿼리이기때문에 (readOnly=true) 옵션을 제외하여 Master DB 에서 조회 쿼리가 실행되는 것보다는 outerMethod()
에 @Transactional(readOnly = true)
설정을 추가해서 전체 쿼리가 Slave DB 에서 실행되도록 변경하는게 더 좋을 것 같아요.
hikari.auto-commit
설정이 기본값인 true 로 되어있으면 개발자가 COMMIT
을 신경쓰지 않아도 되는거 아닌가요? false 로 설정한 이유가 있나요?hikari.auto-commit
설정이 기본값인 true 라면 각 SQL 문은 자체적으로 단일 트랜잭션을 형성하여 오류가 발생하지 않은 쿼리에 대해 COMMIT
을 수행하기 때문에 COMMIT
여부를 신경쓰지 않아도 돼요.하지만, @Transactional
전후로 추가적인 쿼리가 발생하게 돼요. autocommit 설정이 true 라면 트랜잭션으로 묶어서 작업을 수행해도 즉시 반영 되기때문에, 이를 방지하기 위해 Hibernate 는 트랜잭션 전후로 setAutoCommit(false)
, setAutoCommit(true)
를 수행하여 트랜잭션 관리와 데이터 일관성을 보장해요.
fyi; innodb-autocommit-commit-rollback
setAutoCommit(false), setAutoCommit(true) 쿼리를 수행하는 코드는 여기있어요.AbstractLogicalConnectionImplementor.begin & resetConnection 에서 수행돼요.
@Override
public void begin() {
try {
if ( !doConnectionsFromProviderHaveAutoCommitDisabled() ) {
log.trace( "Preparing to begin transaction via JDBC Connection.setAutoCommit(false)" );
getConnectionForTransactionManagement().setAutoCommit( false ); // 🌟🌟🌟 트랜잭션 시작 전에 setAutoCommit( false )
log.trace( "Transaction begun via JDBC Connection.setAutoCommit(false)" );
}
status = TransactionStatus.ACTIVE;
}
catch( SQLException e ) {
throw new TransactionException( "JDBC begin transaction failed: ", e );
}
}
protected void resetConnection(boolean initiallyAutoCommit) {
try {
if ( initiallyAutoCommit ) {
log.trace( "re-enabling auto-commit on JDBC Connection after completion of JDBC-based transaction" );
getConnectionForTransactionManagement().setAutoCommit( true ); // 🌟🌟🌟 트랜잭션 종료 후에 setAutoCommit( true )
status = TransactionStatus.NOT_ACTIVE;
}
}
catch ( Exception e ) {
log.debug(
"Could not re-enable auto-commit on JDBC Connection after completion of JDBC-based transaction : " + e
);
}
}
autocommit
설정을 false 로 하여 성능 개선을 할 수 있었어요.@Transactional
설정이 있는 쿼리 전후로 set autocommit = 0
, set autocommit = 1
이 수행되는 것을 확인할 수 있어요.
Propagation 이
REQUIRES_NEW
인 경우는 부모 트랜잭션이 있더라도set autocommit
쿼리가 전후로 발생하고,REQUIRED
는 부모 트랜잭션이 없어 새로운 트랜잭션을 생성할 때만 쿼리가 전후로 발생해요. 부모 트랜잭션이 있다면 쿼리는 추가로 발생하지 않아요.
autocommit
설정을 false 로 변경하여 트랜잭션 전후로 발생하는 set autocommit
쿼리를 호출하지 않도록 한 결과, API 응답시간을 1.5 ms 줄여, 응답속도를 40프로 향상시킬 수 있었어요.
// application.yml 형식을 사용한다면 아래와 같이 Hikari autocommit 설정을 해주세요.
spring:
datasource:
hikari:
auto-commit: false
// application.properties 형식을 사용한다면 아래와 같이 Hikari autocommit 설정을 해주세요.
spring.datasource.hikari.auto-commit: false
만약 이 설정을 적용한다면 쿼리 종료시점에 COMMIT
이 실행되고 있는지에 대한 확인이 필요합니다.
이번 이슈의 주요 원인은 hikari.auto-commit: false
설정으로 인해 메서드 종료시점에 COMMIT
이 발생하지 않았기 때문이에요. 거기에 더불어 jpa.open-in-view
설정과 하위 메서드에서 실행되는 COMMIT
으로 인해 ROLLBACK
되어야하는 쿼리가 실행되지 않아 스냅샷 갱신이 이루어지지 않는 결과를 만들었어요.
복합적인 원인으로 인해 전달에 필요한 내용이 많아서, 준비한 자체 Q & A 세션에서 공유된 부분 외에도 궁금한 부분이 생길 수 있을 것 같아요. 이 부분은 댓글을 통해 함께 고민할 수 있는 시간을 가지면 좋을 것 같아요. 🤗
함께 이 문제를 고민하고 해결해주신 도성혁님, 이민수님
set autocomit 설정을 통해 성능을 개선해주신 김광용님
디테일한 이슈 원인과 😳 해결해 나아가는 과정이 너무 재밌게 보았습니다. (+@ 의 내용까지도!!!)
많이 배웁니다. 🙇♂️ (디버깅은 @Hocaron 님 처럼..)