조회 성능을 최적화 하는 과정에서 트랜잭션 오버헤드를 줄여서 성능을 향상시키는 방법을 몇 가지 제시받았는데, 트랜잭션 오버헤드가 정확히 뭔지 처음 들어봐서 한 번 알아보고자 한다.
트랜잭션 오버헤드가 뭔가요?
트랜잭션 오버헤드란 트랜잭션을 시작하고 유지하며 완료하기 위해 추가적으로 발생하는 비용을 의미한다.
이는 트랜잭션이 데이터의 무결성과 일관성을 보장하기 위해 필요한 여러 작업(락, 로그 기록, 복구 준비 등)에서 발생한다.
이 추가 비용은 CPU, 메모리, 네트워크, 디스크 I/O 등의 리소스를 소비하며 시스템 성능에 영향을 미칠 수 있다.
트랜잭션 오버헤드의 원인에는 뭐가 있나요.
1. 트랜잭션 시작 및 종료 작업
- 명령 처리 비용:
트랜잭션을 시작하면 데이터베이스는 begin, commit, rollback 같은 명령을 처리한다. 이 명령들은 데이터베이스 엔진이 내부적으로 처리해야 하는 추가적인 연산을 발생시킨다. - 네트워크 왕복 비용:
애플리케이션과 DB 사이에 트랜잭션 명령이 오갈 때 네트워크 지연이 발생한다. 각 트랜잭션이 독립적인 네트워크 요청을 수행할 경우, 잦은 트랜잭션은 큰 오버헤드를 초래한다.
2. 락(Lock) 관리
- 데이터 무결성 보장:
데이터의 일관성을 보장하기 위해 트랜잭션이 데이터에 락을 설정한다. 예를 들어, 한 사용자가 상품 재고를 수정하는 동안 다른 사용자는 해당 재고에 접근할 수 없다. - 락 경합(Deadlock)과 블로킹:
여러 트랜잭션이 같은 자원에 동시에 접근하려 하면 경합이 발생한다. 트랜잭션이 오래 지속될수록 다른 트랜잭션들이 대기하면서 성능 저하를 일으킨다. - 락의 종류:
- 행(row) 락: 특정 행에만 락을 걸어 경합을 줄인다.
- 테이블 락: 트랜잭션 단위가 커지면 테이블 전체에 락이 걸릴 수 있다.
3. 트랜잭션 로그 관리
- 데이터 변경 내역 저장:
모든 쓰기 작업(INSERT, UPDATE, DELETE)은 로그에 기록된다. 이는 장애 시 데이터 복구를 위한 필수 작업이다. - 디스크 I/O 비용:
로그를 디스크에 저장해야 하기 때문에, 많은 트랜잭션은 디스크 I/O 부하를 증가시킨다. SSD를 사용하거나 로그를 비동기적으로 저장하면 일부 개선할 수 있다. - Journaling 시스템:
데이터베이스는 트랜잭션의 커밋 이전에 변경 사항을 먼저 로그에 쓰는 방식(WAL: Write-Ahead Logging)을 사용한다.
4. 롤백 관리
- 변경 내용 보관:
트랜잭션이 실패하면 이전 상태로 되돌려야 하므로, 데이터의 변경 전 상태를 별도로 저장한다. 이러한 작업은 메모리나 임시 테이블에 보관될 수 있으며, 이로 인해 메모리 사용량이 늘어난다. - 복구 비용:
큰 트랜잭션이 실패할 경우, 롤백을 수행하는 데 추가적인 연산과 디스크 작업이 필요합니다. 특히, 긴 트랜잭션이 롤백되면 성능에 심각한 영향을 미칠 수 있다.
5. 분산 트랜잭션의 네트워크 오버헤드
- 2PC(2-Phase Commit)와 네트워크 비용:
여러 데이터베이스나 시스템에 걸친 트랜잭션에서는 2단계 커밋(2PC)이 사용된다.- Prepare 단계: 모든 참여 시스템이 준비되었는지 확인.
- Commit 단계: 모든 시스템이 커밋을 확정.
이러한 과정은 네트워크 지연을 유발하고 트랜잭션 완료까지 시간이 오래 걸릴 수 있다.
- 네트워크 장애에 대한 대응:
네트워크가 불안정하면 트랜잭션이 중단되고 복구 과정이 추가 오버헤드를 발생시킨다.
그럼 트랜잭션 오버헤드는 어떻게 줄일 수 있나요?
1. 트랜잭션 범위 최소화
- 트랜잭션 범위가 길어지면 락이 유지되며 경합이 발생할 가능성이 커진다. 따라서 필요한 최소 작업에만 트랜잭션을 적용해야 한다.
public void updateUserInfo(Long userId, String newName, String newEmail) {
// 1. 사용자 정보를 가져오는 부분
User user = userRepository.findById(userId);
// 2. 비즈니스 로직 수행
if (someCondition) {
// 추가적인 비즈니스 로직
}
// 3. 사용자 정보 업데이트 (트랜잭션 범위 최소화)
updateUserTransaction(user, newName, newEmail);
}
@Transactional
public void updateUserTransaction(User user, String newName, String newEmail) {
// 4. 사용자 정보 업데이트
user.setName(newName);
user.setEmail(newEmail);
userRepository.save(user);
// 트랜잭션 종료
}
단순 User 조회인 userRepository.findById(userId) 에는 트랜잭션을 걸어주지 않았다.
* 여기에는 User Entity 에 지연로딩 되어있는 연관관계가 없다는 가정이 필요하다.
2. Batch 처리 사용
- 여러 작업을 한 번의 트랜잭션에 묶어 네트워크 왕복 횟수와 트랜잭션 초기화를 줄인다.
예: 여러 레코드 삽입을 배치로 수행.
@Transactional
public void batchInsertUsers(List<User> users) {
userRepository.saveAll(users);
}
- Batch Update를 사용하면 트랜잭션 시작과 종료 작업을 줄일 수 있다.
3. 읽기 전용 트랜잭션 설정
- 읽기 작업에 대해서는 트랜잭션을 **readOnly = true**로 설정하면 오버헤드를 줄일 수 있다.
@Transactional(readOnly = true)
public List<User> getUsers() {
return userRepository.findAll();
}
- 읽기 전용 트랜잭션은 로그 기록과 락 설정을 피할 수 있어 성능을 높인다.
4. 트랜잭션 합치기 (Nested Transaction 지양)
- 필요 없는 중첩 트랜잭션을 피하고 가능한 한 단일 트랜잭션으로 합치기를 권장한다.
- 중첩된 트랜잭션은 각각 시작과 종료 비용을 발생시키므로 오버헤드가 커진다.
5. 비동기 처리 사용
- 트랜잭션 내에서 오래 걸리는 작업(예: 이메일 발송, 외부 API 호출)은 비동기 작업으로 처리한다.
@Async
public void sendNotification() {
// 이메일 발송 등 비동기 처리
}
- 트랜잭션을 짧게 유지하고 병렬 작업을 통해 성능을 개선할 수 있다.
6. Connection Pool 사용
- 트랜잭션 시작 시마다 새로운 DB 커넥션을 생성하면 비용이 많이 든다. 이를 줄이기 위해 커넥션 풀을 사용한다.
- HikariCP와 같은 커넥션 풀 라이브러리를 활용하면 효율적인 커넥션 관리를 통해 트랜잭션 오버헤드를 줄일 수 있다.
7. 격리 수준(Isolation Level) 최적화
- 불필요하게 높은 격리 수준을 사용하면 락과 대기 시간이 늘어난다. 트랜잭션의 특성에 맞게 격리 수준을 조정하자.
- 예: 조회 작업에 READ_COMMITTED 사용.
8. 트랜잭션 프로파일링 및 모니터링
- 느린 쿼리나 불필요한 트랜잭션 경계를 모니터링하여 최적화하자.
- Spring Actuator와 JPA Performance Analyzer 같은 도구를 사용해 성능 병목을 찾아 개선하자.
'컴퓨터 프로그래밍 > CS' 카테고리의 다른 글
[CS] CI 와 CD 란? (0) | 2024.10.27 |
---|---|
[CS] ACCESS 토큰과 REFRESH 토큰 (0) | 2024.10.23 |
[CS] DNS (0) | 2024.10.02 |
[CS] 싱글톤 패턴 (0) | 2024.09.18 |
[CS] 데이터베이스 정규화 (0) | 2024.09.05 |