글을 작성하게 된 계기
비동기로 TRUNCATE 쿼리를 사용 하면서 겪은 문제와 해결과정을 정리하기 위해 글을 작성하게 되었습니다.
1. 문제 상황
회사에서 통합 테스트를 작성할 때, 테스트 격리(Test Isolation) 를 위해 TRUNCATE 쿼리를 사용하고 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
@Profile(value = "test")
public class DatabaseInitializer {
......
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void initData() {
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_FALSE);
executeQuery();
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_TRUE);
}
private void executeQuery() {
for (final String tableName : tableNames) {
jdbcTemplate.execute("TRUNCATE TABLE " + tableName);
}
}
}
그런데 테이블이 수백 개 라서 TRUNCATE 쿼리를 실행하는 데 시간이 오래 걸렸고, 이를 개선하기 위해 CompletableFuture를 사용해 비동기 TRUNCATE 쿼리를 실행 했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
@Profile(value = "test")
public class DatabaseInitializer {
......
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void initData() {
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_FALSE);
executeQuery();
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_TRUE);
}
private void executeQuery() {
final List<CompletableFuture<Void>> result = new ArrayList<>();
for (final String tableName : tableNames) {
result.add(CompletableFuture.runAsync(() -> jdbcTemplate.execute("TRUNCATE TABLE " + tableName)));
}
final CompletableFuture<Void> allResult = allOf(result.toArray(new CompletableFuture[0]));
allResult.join();
}
}
그랬더니 다음과 같은 외래키 제약조건에 대한 오류가 발생했습니다. 해당 테이블에 외래키 제약조건이 있어서요.
1
Caused by: java.sql.SQLSyntaxErrorException: Cannot truncate a table referenced in a foreign key constraint
분명 외래키 체크 조건을 OFF 했는데, 왜 이런 문제가 발생했을까요? 발생했던 문제를 요약해보고, 어떻게 이를 해결했는지 살펴보도록 하겠습니다.
- 수 백 개의 테이블에 TRUNCATE 쿼리를 날리니 시간이 오래 걸린다.
- CompletableFuture를 사용해 비동기로 TRUNCATE 쿼리를 실행하니 외래키 제약조건에 대한 오류가 발생한다.
2. TRUNCATE
MySQL의 TRUNCATE 쿼리는 암묵적인 커밋(Implicit Commit) 을 하는데, 한 번 실행한 TRUNCATE 쿼리는 자동으로 커밋 되기 때문에 더 이상 추가적인 작업이 불가능합니다.
Truncate operations cause an implicit commit, and so cannot be rolled back.
기존에는 하나의 트랜잭션 내부에서 SET_FOREIGN_KEY_CHECKS_FALSE 명령어를 실행한 후, 동기적으로 여러 번의 TRUNCATE 쿼리를 실행했습니다. 즉, 외래키 제약조건을 OFF 한 후, 모든 TRUNCATE 쿼리를 한 트랜잭션에서 처리했기 때문에 별다른 문제가 없었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
@Profile(value = "test")
public class DatabaseInitializer {
......
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void initData() {
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_FALSE);
executeQuery();
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_TRUE);
}
private void executeQuery() {
for (final String tableName : tableNames) {
jdbcTemplate.execute("TRUNCATE TABLE " + tableName);
}
}
}
그런데 문제가 된 코드를 보면 하나의 큰 트랜잭션 내부 에서 SET_FOREIGN_KEY_CHECKS_FALSE 명령어를 실행한 후, 비동기로 여러 번의 TRUNCATE 쿼리를 실행 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
@Profile(value = "test")
public class DatabaseInitializer {
......
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void initData() {
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_FALSE);
executeQuery();
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_TRUE);
}
private void executeQuery() {
final List<CompletableFuture<Void>> result = new ArrayList<>();
for (final String tableName : tableNames) {
result.add(CompletableFuture.runAsync(() -> jdbcTemplate.execute("TRUNCATE TABLE " + tableName)));
}
final CompletableFuture<Void> allResult = allOf(result.toArray(new CompletableFuture[0]));
allResult.join();
}
}
이는 TRUNCATE 명령어의 특징과 비동기 환경에서 트랜잭션의 성질 때문인데, 먼저 MySQL에서 TRUNCATE 명령어는 트랜잭션의 영향을 받지 않고 커밋 됩니다. 이는 CompletableFuture로 인해 별도의 쓰레드에서 실행되며, 기존 문맥과 다른 트랜잭션으로 실행 됩니다. 즉, 부모 트랜잭션에서 선언한 외래키 제약 조건 체크 OFF가 적용되지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@Profile(value = "test")
public class DatabaseInitializer {
......
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void initData() {
// 1. 최초 트랜잭션
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_FALSE);
// 2. 내부에서는 각 TRUNCATE 명령어가 새로운 쓰레드에서 실행되며, 이는 기존 부모 트랜잭션과 다른 트랜잭션으로 취급
executeQuery();
// 3. 부모 트랜잭션과 동일 문맥. 여기서 외래키 제약 조건 체크 ON. 2번에서는 이를 적용받지 않음.
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_TRUE);
}
......
}
참고로 비동기 환경에서 별도의 쓰레드에서 진행되는 작업은 트랜잭션 컨텍스트를 상속받지 않습니다. 따라서 부모 트랜잭션에서 선언한 외래키 체크 해제가 자식 쓰레드에 적용되지 않게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class ParentService {
@Autowired
private EntityManager entityManager;
@Autowired
private Executor asyncExecutor;
@Transactional
public void parentMethod() {
asyncExecutor.execute(() -> {
try {
insertChild(); // 다른 쓰레드에서 Insert. 부모 트랜잭션 문맥 공유 안 함.
} catch (Exception ex) {
......
}
});
}
public void childMethod() {
entityManager.persist(new ChildEntity(999L));
}
}
3. 문제 해결
문제는 다음과 같이 해결했는데요, Statement를 사용해 한 번의 커넥션 으로 세 개의 작업을 처리 하며, 각 쓰레드마다 TRUNCATE 쿼리 실행 전, 외래키 체크를 해제하도록 했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Component
@Profile(value = "test")
public class DatabaseInitializer {
......
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void initData() {
executeQuery();
jdbcTemplate.execute(SET_FOREIGN_KEY_CHECKS_TRUE);
}
private void executeQuery() {
final List<CompletableFuture<Void>> futures = tableNames.stream()
.map(this::truncateAsync)
.toList();
allOf(futures.toArray(new CompletableFuture[0])).join();
}
private CompletableFuture<Void> truncateAsync(final String table) {
return runAsync(() -> {
try (
final Connection conn = dataSource.getConnection();
final Statement stmt = conn.createStatement()
) {
stmt.execute(SET_FOREIGN_KEY_CHECKS_FALSE);
stmt.execute("TRUNCATE TABLE " + table);
stmt.execute(SET_FOREIGN_KEY_CHECKS_TRUE);
} catch (Exception ex) {
log.error("Failed to truncate table: {}", table, ex);
}
}, taskExecutor);
}
}
각 쓰레드마다 외래키 제약조건을 해제하니 기존 오류가 발생하지 않았고, 수 백 개의 테이블에 TRUNCATE 쿼리를 동기적으로 실행하는 것 보다 훨씬 빠르게 작업이 완료 되었습니다. 이를 통해 기존에 5분 이상 걸리던 작업을 2분 이내로 단축할 수 있었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@Profile(value = "test")
public class DatabaseInitializer {
......
private CompletableFuture<Void> truncateAsync(final String table) {
return runAsync(() -> {
try (
final Connection conn = dataSource.getConnection();
final Statement stmt = conn.createStatement()
) {
stmt.execute(SET_FOREIGN_KEY_CHECKS_FALSE);
stmt.execute("TRUNCATE TABLE " + table);
stmt.execute(SET_FOREIGN_KEY_CHECKS_TRUE);
} catch (Exception ex) {
log.error("Failed to truncate table: {}", table, ex);
}
}, taskExecutor);
}
}
4. 정리
스프링에서 비동기를 사용하면 각 쓰레드는 별도의 트랜잭션을 가지게 됩니다. 즉, 트랜잭션 컨텍스트를 상속받지 못 하기 때문 에 한 번 선언된 외래키 체크 해제가 원하는 대로 동작하지 않았던 것이죠. 쓰레드 관련 이슈는 언제나 어렵고 복잡한데요, 그래도 재밌네요.