이 포스터에서는 회사에서 배치를 짜다가 만난 당황스러운 문제를 해결했던 경험을 공유한다.
우선 다음과 같은 테이블이 있다고 가정해보자.
create table test
(
id bigint primary key,
name varchar(255) not null,
CONSTRAINT test_name_unique_key UNIQUE (name)
);
insert into test value(1, '홍길동');
뻔한 테이블이다. 다만 특이한점이 하나 있다면 name에 unique 제약조건이 걸려있다는 것이다. 집중하자. 모든 문제는 이 UNIQUE 제약조건에서 출발한다.
이제 해당 테이블에 접근하는 서비스 로직을 작성해보자. 나는 '홍길동'이라는 이름의 레코드를 제거하고, 새로운 '홍길
동'이라는 이름을 가지는 레코드를 생성할 예정이다. 간단하다. 한번 로직을 작성해보자.
@Transactional
public void testServiceMethod() {
// TestEntity.name은 unique 제약조건이 걸려있다.
TestEntity testEntityList = new TestEntity(2L, "홍길동");
testRepository.deleteByName("홍길동");
testRepository.save(testEntityList);
}
그리고 이제 Controller와 Repository, Entity를 붙여서 이 코드를 실행해보자.
(글이 길어져서 좋을거 하나 없기 때문에 해당 내용은 생략한다)
이 코드를 보고 난 다음과 같은 절차 SQL이 실행되는 것을 기대했다.
1. delete * from test where name='홍길동' - [이름이 홍길동인 레코드 제거]
2. insert into test (name, id) values ('홍길동', 2) - [이름이 홍길동인 레코드 삽입]
그럼 부푼 마음으로 코드를 실행해보자!
그런데,,, 기대와 달리 코드는 에러를 뱉었다.
java.sql.SQLIntegrityConstraintViolationException: (conn=366) Duplicate entry '홍길동' for key 'test_name_unique_key'
아니! 내가 분명 홍길동인 레코드를 제거한 후에 홍길동인 레코드를 삽입 했는데! 데체 왜! 이런 오류가 발생하는걸까!
일단.. 너무나도 바쁘니까 두 쿼리 사이에 flush()를 넣어서 문제를 해결했다.
자.. 여기까지가 문제 상황이었다. 이제.. Spring 코드를 까보자.
Spring Data JPA에서는 지연 쿼리 저장소를 이용해서 Transaction이 Commit되는 시점에 쿼리들을 DB에 날린다. 그런데, 이 오류가 발생하는건 쿼리의 실행 순서가 내가 정의한 순서와 다르다는 뜻인걸까?
일단 단서를 찾기 위해 @Transaction어노테이션이 수행하는 AOP 코드를 찾아보자.
(진짜 하나 하나 코드가 수행되는 순서대로 디버그 모드로 읽어갔다. 코드 분석다는 다른 좋은 방법을 안다면 꼭 알려주길 바란다.)
코드를 모두 옮겨올 수 없기에, 간단한 흐름만 설명하자면 다음과 같다.
1. @Transaction이 걸려있는 Method는 TransactionInterceptor를 통해 AOP가 수행된다.
2. @Transaction이 걸려있는 우리의 서비스 메서드(testServiceMethod)가 수행되고 나면 TransactionAspectSupport클래스의 commitTransacitonAfterReturning Method가 실행된다.
3. 몇몇 작업을 하고,, 적절한 구현체를 찾아고는 과정을 수행한 후 JdbcCoordinatorImpl.beforeTransactionCompletion()을 호출한다.
4. 이 함수에서는 SessionImpl.beforeTransactionCompletion()을 호출하는데, 이 함수는 지연쿼리 저장소에 있던 모든 쿼리를 DB에 반영한다.
자 이제 진짜 문제가 나온다. SessionImpl에서 저장되어있던 모든 쿼리를 수행하는 방법을 살펴보자.
private void doFlush() {
this.pulseTransactionCoordinator();
this.checkTransactionNeededForUpdateOperation();
try {
if (this.persistenceContext.getCascadeLevel() > 0) {
throw new HibernateException("Flush during cascade is dangerous");
} else {
FlushEvent event = new FlushEvent(this);
this.fastSessionServices.eventListenerGroup_FLUSH.fireEventOnEachListener(event, FlushEventListener::onFlush);
this.delayedAfterCompletion();
}
} catch (RuntimeException var2) {
RuntimeException e = var2;
throw this.getExceptionConverter().convert(e);
}
}
우선 SessionImpl은 내부적으로 doFlush()를 호출하고, doFlush()에서는 FlushEventListener의 onFlush()라는 함수를 호출한다.
이제 DefaultFlushEventListner.onFlush() 메서드를 살펴보자.
public void onFlush(FlushEvent event) throws HibernateException {
EventSource source = event.getSession();
PersistenceContext persistenceContext = source.getPersistenceContextInternal();
if (persistenceContext.getNumberOfManagedEntities() <= 0 && persistenceContext.getCollectionEntriesSize() <= 0) {
if (source.getActionQueue().hasAnyQueuedActions()) {
this.performExecutions(source);
}
} else {
try {
source.getEventListenerManager().flushStart();
this.flushEverythingToExecutions(event);
this.performExecutions(source);
this.postFlush(source);
} finally {
source.getEventListenerManager().flushEnd(event.getNumberOfEntitiesProcessed(), event.getNumberOfCollectionsProcessed());
}
this.postPostFlush(source);
StatisticsImplementor statistics = source.getFactory().getStatistics();
if (statistics.isStatisticsEnabled()) {
statistics.flush();
}
}
}
# 생략...
protected void performExecutions(EventSource session) {
LOG.trace("Executing flush");
PersistenceContext persistenceContext = session.getPersistenceContextInternal();
JdbcCoordinator jdbcCoordinator = session.getJdbcCoordinator();
try {
jdbcCoordinator.flushBeginning();
persistenceContext.setFlushing(true);
ActionQueue actionQueue = session.getActionQueue();
actionQueue.prepareActions();
actionQueue.executeActions();
} finally {
persistenceContext.setFlushing(false);
jdbcCoordinator.flushEnding();
}
}
onFlush 함수를 살펴보면 실제로 DB에 요청을 날리는 것은 performExecutions 메서드라는 것을 알 수 있는데, 여기서 이상한 객체가 등장한다.
ActionQueue가 바로 그 이상한(?)놈 인데, 특히 actionQueue.executeActions()에서 Action을 직접 수행하고 있는 것 같다. 이놈이 의심스러우니 조금 더 살펴보자.
private static final OrderedActions[] ORDERED_OPERATIONS = ActionQueue.OrderedActions.values();
public void executeActions() throws HibernateException {
if (this.hasUnresolvedEntityInsertActions()) {
throw new IllegalStateException("About to execute actions, but there are unresolved entity insert actions.");
} else {
OrderedActions[] var1 = ORDERED_OPERATIONS;
int var2 = var1.length;
for(int var3 = 0; var3 < var2; ++var3) {
OrderedActions action = var1[var3];
this.executeActions(action.getActions(this));
}
}
}
private static enum OrderedActions { # OrderedActions는 많이 생략되었다. 궁금하면 찾아보자.
OrphanCollectionRemoveAction,
OrphanRemovalAction,
EntityInsertAction,
EntityUpdateAction,
QueuedOperationCollectionAction,
CollectionRemoveAction,
CollectionUpdateAction,
CollectionRecreateAction,
EntityDeleteAction;
}
잡았다. 지금 저장되어있는 모든 Action을 순서대로 수행하는게 아니라 OrderedActions Enum에 정의되어 있는 순서대로 DB에 Action을 수행하고 있던 것이다.
자세한 내용은 길어져서 생략했지만, ActionQueue는 OrderedAdctions에 정의되어 있는 값들을 List로 누적해서 가지고 있다. 간략하게 보여주면 다음과 같다.
public class ActionQueue {
...
private ExecutableList<AbstractEntityInsertAction> insertions;
private ExecutableList<EntityDeleteAction> deletions;
private ExecutableList<EntityUpdateAction> updates;
private ExecutableList<CollectionRecreateAction> collectionCreations;
private ExecutableList<CollectionUpdateAction> collectionUpdates;
private ExecutableList<QueuedOperationCollectionAction> collectionQueuedOps;
private ExecutableList<CollectionRemoveAction> collectionRemovals;
private ExecutableList<CollectionRemoveAction> orphanCollectionRemovals;
private ExecutableList<OrphanRemovalAction> orphanRemovals;
...
...
}
즉, 우리의 서비스 코드에서 작성한 쿼리 순서대로 Queue에 쌓이는게 아니라, 각 쿼리를 분류해서 List에 쌓고 있던 것이다!
그리고 분류된 각 명령어들은 순서대로 수행되는데, 이때 OrderedAdctions Enum에 변수가 정의된 순서대로 DB에 Query를 날리는 것이었다...!!
자.. 자.. 그래서 이런 오류를 만났는데, 가장 좋은 해결 방법은 뭐냐?
쿼리의 순서를 보장하고 싶으면, entityManager.flush()를 호출해라.
Query가 실행되는 순서를 명시적으로 지정해 둔 것은 Hibernate에서 자체적으로 성능을 최적화 하기 위함이라고 한다. 하지만,, 그 이외의 자세한 정보는 나오지 않는다.
(열심히 찾아보면 나올지도..)
'Spring' 카테고리의 다른 글
Spring Data Jpa에서 단일 쿼리로 조회하기 (1) | 2023.04.28 |
---|