좋아요 기능의 동시성 문제를 낙관적 락(Optimistic Lock)과 Retry를 통해 해결하고 개선하는 과정을 정리해 보았다.

 

현재 진행 중인 프로젝트에는 방명록(Post)에 좋아요를 누를 수 있고 Post 목록 조회 시 좋아요 개수를 같이 보여주고 있다.

Post 목록 조회 시마다 방명록 좋아요 테이블을 join해서 좋아요 개수를 계산하는 것은 비효율적이라고 판단해서 반정규화로 Post에 별도의 likeCount 칼럼을 추가했다.

 

하지만 여러 사용자가 동시에 좋아요를 누를 경우 likeCount를 update 하면서 lost update 문제가 발생할 수 있다. 예를 들어 방명록의 좋아요가 5개에서 두 유저가 좋아요를 누르면 7개가 되어야 한다. 그런데 두 유저가 동시에 좋아요를 누르고 각각 조회 시점에 좋아요 개수가 5개로 보이면 둘 다 6으로 업데이트를 하게 된다. (업데이트를 어떻게 하냐에 따라 다르지만..)

 

이러한 문제를 해결하기 위해 낙관적 락(Optimistic Lock), 비관적 락(Pessimistic Lock)을 사용해 볼 수 있다.

 

낙관적 락은 충돌이 많이 발생하지 않는다고 가정하고 애플리케이션 레벨에서 락을 거는 방식이다.

비관적 락은 충돌이 많이 발생한다고 가정하고 DB에서 락을 거는 방식이다.

 

DB에서 락을 거는 방식은 레코드를 SELECT로 읽으면서 FOR SHARE, FOR UPDATE를 사용하면 직접 잠금을 걸 수 있다.

(select .. from .. where .. for share)

  • FOR SHARE는 읽기 잠금(공유 잠금, Shared lock)으로 다른 세션에서 해당 레코드를 읽는 것은 가능하고 변경할 수 없게 한다.
  • FOR UPDATE는 쓰기 잠금(배타 잠금, Exclusive lock)으로 다른 트랜잭션에서 그 레코드를 변경하는 것뿐만 아니라 읽기(FOR SHARE 절을 사용하는 SELECT 쿼리)도 사용할 수 없다.

참고로 InnoDB 스토리지 엔진의 경우 MVCC(Multi Version Concurrency Control)를 통해 잠금 없는 읽기(Non Locking Consistent Read)를 지원하기 때문에 단순 SELECT문은 아무런 대기 없이 실행된다.

 

비관적 락은 높은 데이터 무결성을 보장하고 충돌이 많이 발생하는 경우 낙관적 락보다 효율적일 수 있지만 일반적으로 DB에 락을 걸기 때문에 동시 처리 성능이 떨어지고 데드락 위험이 있다. 좋아요 기능에 비관적 락을 거는 것은 성능면에서도 적절하지 않은 것 같고 현재 상황에서 충돌이 거의 발생하지 않는다고 판단해서 낙관적 락을 사용하기로 했다.

 

낙관적 락은 애플리케이션에서 락을 거는 방식으로 JPA는 @Version 애노테이션으로 version 칼럼을 통해 버전을 관리할 수 있다.

version 자료형으로는 Integer(int), Long(long) , Short(short) , timestamp가 있다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long likeCount;

    @Version
    private Integer version;
}

 

방명록 좋아요 로직은 아래와 같이 작성했다. 방명록의 likeCount는 JPA의 변경 감지(dirty checking)으로 update 한다.

 

@Transactional
public void likePost(Long postId, Long userId) {
    final User user = findLoginUser(userId);
    final Post post = findPost(postId);
    if (postQueryRepository.existsPostLike(post, user)) {
        throw new ApiException(PostErrorCode.ALREADY_LIKE);
    }
    final PostLike postLike = post.likeFrom(user);
    postLikeRepository.save(postLike);
}

 

락 모드는 LockModeType로 설정할 수 있는데 version이 있는 Entity는 락 옵션을 적용하지 않아도 기본적으로 낙관적 락을 사용한다.

  • NONE
    • 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경(삭제)되지 않아야 한다. 엔티티를 수정할 때 version을 체크하면서 version을 증가시킨다. 만약 version 값이 다르면 예외가 발생한다.
    • update에 성공한 thread(pool-4-thread-2)은 추가 쿼리가 발생하지 않았다. 반면 update에 실패한 thread(pool-4-thread-1)은 select 쿼리를 한번 더 날리는 것으로 봐서 updated row가 없으면 select 쿼리로 조회해보고 예외를 던지는 것 같다.

  • OPTIMISTIC
    • 엔티티를 조회만 해도 버전을 체크해서 한 번 조회한 엔티티는 트랜잭션을 종료할 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
    • 트랜잭션을 커밋할 때 update와 상관없이 version을 조회해서 현재 엔티티의 버전과 같은지 체크한다.
    • 만약 version이 바뀌어 update에 실패하면 NONE인 경우랑 마찬가지로 version 조회 대신 엔티티를 조회해 보고 예외를 던진다.

 

  • OPTIMISTIC_FORCE_INCREMENT
    • 엔티티를 수정하지 않아도 version을 증가시키고, 엔티티를 수정하면 version이 2번 나타날 수 있다.

 

Optimistic Lock 예외 상황을 테스트해보았다. CountDownLatch를 사용해서 첫 번째 Thread를 작업 중간에 wait 시키고, 두번째 Thread가 version을 update 시킨다. 그러면 첫번째 Thread는 바뀐 version으로 인해 update에 실패하고 예외가 발생한다.

 

var waitingLatch = new CountDownLatch(1);
// 낙관적 락 version이 업데이트가 될때까지 대기
var waitWorker = CompletableFuture.runAsync(() -> waitPostLikeAndNoRetry(post, likeFailUser, waitingLatch));
// 낙관적 락 version을 업데이트하고 countDown
CompletableFuture.runAsync(() -> postService.likePost(post.getId(), likeSuccessUser.getId()))
    .thenRun(waitingLatch::countDown);
// 두번째 작업의 완료로 인해 version이 변경 되어 OptimisticLockingFailureException 예외 발생
assertThatThrownBy(waitWorker::join)
    .extracting("cause")
    .isInstanceOf(OptimisticLockingFailureException.class);

 

Hibernate에서 발생한 StaleObjectStateException 예외를 스프링에서 OptimisticLockingFailureException으로 감싸서 던진다.

 

 

Optimistic Lock 예외를 클라이언트한테 에러로 응답해 주면 클라이언트는 매우 불편할 것이다. 그래서 Optimistic 예외 발생 시 재시도를 위해 스프링 AOP로 Retry를 추가해 줬다.

 

AOP란?

  • 애플리케이션 로직은 크게 핵심 기능과 부가 기능으로 나눌 수 있는데 부가 기능은 핵심 기능을 보조하기 위해 제공되는 기능이다.
  • 여기서 핵심 기능은 좋아요 기능이고 부가 기능은 재시도 기능이 되겠다. 좋아요를 처리하는 로직 외에 예외를 try-catch 해서 재시도하는 로직이 들어가면 가독성도 떨어지고 다른 기능에서도 재시도가 필요한 경우 거의 동일한 로직을 사용한다.
  • AOP(Aspect-Oriented Programming)은 관점 지향 프로그래밍으로, Aspect는 부가 기능과 해당 부과 기능을 어디에 적용할지 선택하는 기능을 하나의 모듈로 만든 것이며 핵심 기능에서 부가 기능을 분리하기 위해 나온 것이다.
  • 애플리케이션을 바라보는 관점을 하나하나의 기능에서 횡단 관심사(cross-cutting-concerns) 관점으로 보면 여러 모듈에서 공통적으로 사용되는 부가 기능들이 있을 것이다.

 

스프링 AOP는 프록시를 통해 AOP를 적용하는데 메서드에만 적용할 수 있는 등의 한계와 주의점들이 있지만 비교적 간단하고 유연하게 AOP를 적용할 수 있다.

 

AOP 간단 용어

  • 조인 포인트(Join point)
    • 추상적인 개념으로 AOP를 적용할 수 있는 지점
    • 스프링 AOP는 프록시 방식의 한계로 조인 포인트가 메소드 지점으로 제한된다.

 

 

  • 포인트컷(Pointcut)
    • 어디에 부가 기능을 적용할지, 적용하지 않을지 판단하는 필터링 로직이다.
    • 주로 AspectJ 표현식, 애노테이션으로 지정
  • 어드바이스(Advice)
    • 프록시가 호출하는 부가 기능, 즉 프록시 로직
  • 애스펙트(Aspect)
    • Advice + Pointcut를 모듈화 한 것
    • 여러 Advice와 Pointcut이 함께 존재
  • 어드바이저(Advisor)
    • 하나의 Advice와 Pointcut으로 구성
    • 스프링 AOP에서만 사용되는 용어

스프링 AOP 라이브러리를 추가하면 AnnotationAwareAspectJAutoProxyCreator 라는 빈 후처리기(BeanPostProcessor)가 스프링 빈에 자동으로 등록된다. (BeanPostProcessor는 스프링이 객체를 생성하여 Bean 저장소에 등록하기 전에 조작하기 위해 사용한다.)

AnnotationAwareAspectJAutoProxyCreator는 스프링 빈으로 등록된 Advisor들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용, @AspectJ 와 관련된 AOP 기능도 자동으로 찾아서 처리한다.

스프링이 bean 대상이 되는 객체를 생성해서 AnnotationAwareAspectJAutoProxyCreator에 전달하면 스프링 컨테이너에서 모든 Advisor를 조회해서 Pointcut을 통해 프록시 적용 대상인지를 판단하고 프록시 적용 대상이라면 프록시를 생성하고 반환해서 프록시를 스프링 빈으로 등록한다. @Aspect 애노테이션을 찾아서 Advisor로 변환해서 저장하는 기능도 한다.

 

BeanFactoryAspectJAdvisorBuilder는 내부 저장소에 @Aspect 정보를 기반으로 생성된 Advisor를 캐시 한다. 캐시에 어드바이저가 이미 만들어져 있는 경우 캐시에 저장된 어드바이저 반환한다.

 

Advice 종류

  • @Around : 메서드 호출 전후 수행, JoinPoint 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능
  • @Before: JoinPoint 실행 이전에 실행
  • @AfterReturning : JoinPoint 정상 완료 후 실행
  • @AfterThrowing : 메서드가 예외를 던지는 경우 실행
  • @After : JoinPoint가 정상 또는 예외에 관계없이 실행

Retry 로직은 다음과 같이 추가했다. Advisor는 기본적으로 순서를 보장하지 않아서 필요시 Order 애노테이션으로 순서를 적용해줘야 한다. (클래스 단위로 적용)

@Transactional은 @EnableTransactionManagement를 보면 Order가 가장 낮은 순위로 되어있다.

 

 

Order를 지정해주지 않으면 Transactional이 Retry보다 먼저 실행되는 경우 변경 감지가 커밋 시점에 발생하기 때문에 변경 감지 update 쿼리가 나가기 전에 retry 로직이 성공으로 return 되고OptimisticLockingFaulureExceptuon을 catch 할 수 없다.

 

Retry를 통해 정상적으로 좋아요가 처리되는 테스트를 해보았다.

 

//given
final ExecutorService executorService = Executors.newFixedThreadPool(postLikeCount);
final CountDownLatch completeLatch = new CountDownLatch(postLikeCount);
//when
users.forEach(user -> executorService.submit(() -> {
    postService.likePost(post.getId(), user.getId());
    completeLatch.countDown();
}));
completeLatch.await();
//then
assertThat(postRepository.findById(post.getId()))
    .isPresent().get()
    .satisfies(updatedPost -> assertThat(updatedPost.getLikeCount()).isEqualTo(postLikeCount));

 

추가로 낙관적 락을 사용할 때 외래키가 있으면 데드락(DeadLock)으로 인해 CannotAcquireLockException 예외가 발생할 수 있다.

show engine innodb status; 로 데드락 체크를 해보면 아래와 같다.(최대한 생략시킨 결과)

 

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 1068786, ACTIVE 0 sec starting index read

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 36000 page no 4 n bits 72
index PRIMARY of table `photospot`.`post`
trx id 1068786 lock mode S locks rec but not gap

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 36000 page no 4 n bits 72
index PRIMARY of table `photospot`.`post` trx id 1068786
lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 1068787, ACTIVE 0 sec starting index read

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 36000 page no 4 n bits 72
index PRIMARY of table `photospot`.`post`
trx id 1068787 lock mode S locks rec but not gap

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 36000 page no 4 n bits 72
index PRIMARY of table `photospot`.`post` trx id 1068787
lock_mode X locks rec but not gap waiting

*** WE ROLL BACK TRANSACTION (2)

 

(참고로 InnoDB는 인덱스의 레코드에 대해 락을 건다.)

트랜잭션 1, 2가 post 테이블의 primary index에 대한 S-Lock을 점유하고 있는 상태에서 X-lock을 대기하면서 결국 하나의 트랜잭션이 롤백되었다.

PostLike 테이블은 Post의 PK를 FK로 가지고 있다. 그래서 좋아요를 insert 할 때 FK 제약 조건을 체크하기 위해 S-Lock을 건다. (S-Lock은 여러 트랜잭션에서 걸 수 있다.)

그다음 Post의 좋아요 개수를 Update 할 때 X-Lock을 획득하는데 트랜잭션 1,2가 각각 S-Lock을 점유하고 있어서 서로 X-Lock을 획득하지 못하고 대기한다. (X-Lock은 읽기 잠금도 제한하기 때문에 이미 읽기 잠금이 걸려있으면 대기한다.)

 

PostLike의 FK를 제거하니 CannotAcquireLockException 예외가 발생하지 않았다.

 

사실 update는 한 번에 하나의 트랜잭션만 레코드를 변경하기 때문에 Atomic 하게 동작한다. 그래서 이런 단순 count를 증가시키는 쿼리는 직접 likeCount = likeCount + 1로 update 하면 동시성 문제를 해결할 수 있다.

 

UPDATE post SET like_count = like_count + 1 where id = 1;

 

JPA의 변경 감지 기능에 너무 의존하다 보니 이런 부분을 놓친 것 같다. 그래도 낙관적 락 사용하고 테스트해보면서 배운 게 많아서 나름 만족스럽다.

 

 

[참고]

 

트랜잭션 동시성 문제 해결- Optimistic Lock과 AOP활용

0. 배경 서로 다른 API요청이 거의 동시에 들어올 경우 동시성 문제가 발생할 수 있는 기능이 있어서 이를 해결하기 위한 부분을 고민하게 되었습니다. 0-1. 기능 설명 해당 프로젝트는 사람들을

velog.io

 

좋아요 개수 조회 최적화하기

속닥속닥 프로젝트(https://github.com/woowacourse-teams/2022-sokdak…

tecoble.techcourse.co.kr

 

Optimistic Lock in JPA

<br /><br />

junhyunny.github.io

 

MySQL 낙관적 락과 데드락(dead lock) With JPA Hibernate

프로젝트에서 모임 가입 기능을 구현하면서, 동시성 문제와 데드락까지 경험한 내용 그리고 어떻게 해결하였는지 고민과정과 해결방법을 정리하려고 합니다. 프로젝트 버전 SpringBoot 2.7.8 MySQL 8.

0soo.tistory.com

 

 

스프링 핵심 원리 - 고급편 강의 - 인프런

스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기 📢 수강

www.inflearn.com

 

자바 ORM 표준 JPA 프로그래밍 | 김영한 - 교보문고

자바 ORM 표준 JPA 프로그래밍 | 자바 ORM 표준 JPA는 SQL 작성 없이 객체를 데이터베이스에 직접 저장할 수 있게 도와주고, 객체와 관계형 데이터베이스의 차이도 중간에서 해결해준다. 이 책은 JPA

product.kyobobook.co.kr

RealMySQL8.0

 

단일 비교를 하는 경우 IN()절로 작성한 SQL을 재사용해도 좋을지 궁금해서 찾아봤는데 괜찮아보이는 글이 보였다. 대체로 차이가 없다는 의견이지만 공식 문서는 없는 것 같아서 간단하게 테스트를 해봤다.

 

 

Performance differences between equal (=) and IN with one literal value

How does SQL engines differ when we use equal sign and IN operator have same value? Does execution time changes? 1st one using equality check operator WHERE column_value = 'All' 2nd one using IN

stackoverflow.com

 

spot_id IN(1), spot_id = 1 모두 동등 비교를 하고 실행 계획 type도 ref로 나왔다. ref는 동등 조건으로 검색할 때의 접근 방법을 나타낸다.

 

IN 절에 여러 id가 들어가는 경우에는 id = 1 or id = 2.. 형태로 된다.

 

 

데이터를 안전하게 저장, 관리하고 효율적으로 조회하려면 옵티마이저가 어떻게 동작할지, 쿼리의 실행 계획에 대해 알아야한다. 

 

실행 계획은 쿼리를 실행할때 EXPLAIN 키워드를 붙여서 실행하면 된다. 실행 계획도 그때그때 찾아보는 것이 좋을 것 같은데 자주 보이고 알면 좋은 부분만 정리해봤다.

 

실행 계획 결과는 기본적으로 테이블(json, tree 같은 포맷도 있다)로 여러 정보를 알려준다.

 

1. id

- 단위 select 쿼리(select 키워드 단위로 구분한 것)의 식별자로 테이블을 조인하면 조인되는 테이블의 개수만큼 실행 계획 레코드가 생기지만 같은 id 값을 가진다. (id 값이 같으면 조인된 경우)

- id가 테이블의 접근 순서를 의미하지는 않는다.

 

2. select_type

- 단위 select 쿼리가 어떤 타입의 쿼리인지 나타낸다.

- 서브쿼리를 사용하지 않는 단순 쿼리를 나타내는 SIMPLE, 서브 쿼리를 가지는 select 쿼리의 가장 바깥쪽에 있는 쿼리를 나타내는 PRIMARY 등의 키워드가 있다.

- DERIVED는 단위 SELECT 쿼리의 실행 결과로 메모리나 디스크에 임시 테이블(파생 테이블)을 생성하는 것을 의미하는데 주로 from절 서브쿼리를 사용하는 경우를 나타낸다. (성능상 주의깊게 볼 부분)

 

3. type

- MySQL 서버가 각 테이블을 어떤 방식으로 읽을지를 나타낸다.

- 주로 쓰이는 타입으로는 all < index < range < index_subquery < unique_subquery < ref < eq_ref < const 의 순서대로 빠른 순서를 보인다.

- const(Unique index scan)는 where절로 PK 또는 유니크 키로 1건만 조회하는 쿼리로 많이 사용하는 방식이다.

- eq_ref는 join에서 처음 읽은 테이블의 컬럼값을, 그 다음 읽어야 할 테이블의 PK나 유니크 키 컬럼의 검색 조건에 사용할 때 나타낸다.

- ref는 인덱스 종류와 관계없이 동등 조건으로 검색할 때를 나타낸다.

- index_subquery, unique_subquery는 where 조건절에서 사용되는 in(subquery) 형태의 쿼리를 나타낸다. MySQL8.0 버전은 in subquery 형태의 세미 조인의 최적화된 기능이 많이 도입 되어서 실제 테스트 해보니 ref로 실행 된다거나 하는 경우가 많았다. (책에서도 최적화 옵션을 비활성화 하고 테스트를 하고 있다)

- range는 인덱스 레인지 스캔 형태의 접근 방법으로 인덱스를 범위로 검색하는 경우 (<, >, IS NULL, BETWEEN, IN, LIKE)등의 연산자로 검색시 사용된다. 속도는 생각보다 낮지만 range로 조회만 하더라도 어느정도 최적의 성능이 보장된다고 한다.

- index는 인덱스를 처음부터 끝까지 읽는 인덱스 풀 스캔방식이다

- all은 풀 테이블 스캔 방식으로 InnoDB는 풀 스캔시 대량의 디스크 I/O를 유발하는 작업을 위해 한번에 많은 페이지를 읽어 들이는 기능(Read AHead)을 제공한다. 대용량 레코드 처리시 잘못 튜닝된 인덱스 쿼리보다는 풀 테이블 스캔이 더 나은 경우도 있다.

 

4. keys

- 실행 계획에서 사용하는 인덱스를 의미하며 쿼리를 튜닝할 때는 key 컬럼에 의도한 인덱스가 표시되는지 확인하는 것이 중요하다.

 

5. rows

- 얼마나 많은 레코드를 읽고 체크해야 하는지 예측한 레코드 건수를 의미한다.

- 스토리지 엔진이 가지고 있는 통계 정보를 참조해서 MySQL 옵티마이저가 산출해 낸 예상값이라서 정확하지는 않다.

 

6. filtered

- 필터링되고 남은 레코드의 비율을 의미한다.

 

7. extra

- 성능에 중요한 부분, 처리 방식등을 보여준다.

- Using where MySQL 엔진에서 스토리지 엔진으로부터 받은 데이터를 별도의 가공을 해서 필터링 작업을 처리한 경우에 나타난다. (검색 조건에 따라 스토리지 엔진에서 필터링을 할 수도, MySQL 엔진에서 필터링을 할 수도 있다.)

- Using index(커버링 인덱스)는 인덱스만 읽어서 쿼리를 처리할 수 있는 경우 나타난다. 인덱스를 이용해 처리하는 쿼리에서 가장 큰 부하는 인덱스 검색에서 일치하는 키 값들의 레코드를 읽기 위해 데이터 파일을 검색하는 작업이다. InnoDB의 세컨더리 인덱스는 데이터 레코드를 찾아가기 위한 논리 주소로 사용하기 위해 PK를 같이 저장하는데 이는 추가 컬럼을 하나 더 가지는 인덱스 효과도 얻을 수 있다. 하지만 너무 과도하게 인덱스 컬럼이 많아지면 메모리 낭비가 심해지고 레코드 저장, 변경 작업이 매우 느려질 수 있어서 주의해야한다.

-Using index condition는 (MySQL 5.6 버전부터) 인덱스를 범위 제한 조건으로 사용하지 못한다고 하더라도 인덱스에 포함된 컬럼의 조건이 있다면 모두 같이 모아서 스토리지 엔진으로 전달하는 것을 말한다. 만약 스토리지 엔진에서 범위 제한 조건으로 10000건을 조회해서 MySQL엔진으로 전달했는데 MySQL 엔진에서 체크 조건으로 1건만 필터링이 된다면 매우 비효율적이다.

- Using filesort는 order  by를 처리하기 위해 적절한 인덱스를 이용하지 못해서 조회된 레코드를 메모리 버퍼에 복사해 다시 한번 정렬하는 경우를 말한다. (성능상 주의해야 될 부분)

 

 

 

explain analyze로 검색하면 쿼리의 실행 계획과 단계별 소요된 시간 정보를 확인할 수 있다.

- 들여쓰기가 같은 레벨에서는 상단 라인이 먼저 실행

- 들여쓰기가 다른 레벨에서는 가장 안쪽 라인이 먼저 실행

- actual time=0.007..0.009 : 첫번째 레코드를 가져오는 데 걸린 평균 시간(ms), 두번째는 마지막 레코드를 가져오는 데 걸린 평균 시간

- rows 처리한 레코드 건수

- loops 반복 횟수

 

 

실행 계획과 더불어 쿼리 시간을 측정하는 경우 주의할 부분이 있다.

MySQL 서버는 운영체제의 파일 시스템 관련 기능(시스템 콜)을 이용해 데이터 파일을 읽어온다. 그런데 일반적으로 대부분의 운영체제는 한 번 읽은 데이터는 운영체제가 관리하는 별도의 캐시 영역에 보관해 뒀다가 다시 해당 데이터가 요청되면 디스크를 읽지 않고 캐시의 내용을 바로 MySQL 서버로 반환한다. InnoDB 스토리지 엔진은 일반적으로 파일 시스템의 캐시나 버퍼를 거치지 않는 Direct I/O를 사용하므로 운영체제의 캐시가 큰 영향을 미치지 않는다.

 

하지만 MySQL 서버에서도 데이터 파일의 내용을 페이지 단위로 캐시하는 기능을 제공한다. InnoDB 스토리지 엔진이 관리하는 캐시를 버퍼 풀이라고 하는데 이는 인덱스 페이지는 물론이고 데이터 페이지까지 캐시하며, 쓰기 작업을 위한 버퍼링 작업까지 겸해서 처리한다.

MySQL 서버가 한번 시작되면 InnoDB의 버퍼 풀을 강제로 삭제할 수 있는 방법이 없다.

InnoDB 버퍼 풀은 MySQL 서버가 종료될 때 자동으로 덤프됐다가 다시 시작될 때 자동으로 적재된다.

  • SET GLObAL innodb_buffer_pool_dump_at_shutdown=OFF
  • SET GLObAL innodb_buffer_pool_load_at_startup=OFF

그래서 실제 쿼리의 성능 테스트를 MySQL 서버의 상태가 워밍업된 상태(캐시나 버퍼등의 데이터가 준비된 상태)로 할지 아니면 콜드 상태(캐시나 버퍼가 모두 초기화된 상태)에서 진행할지도 고려해야 한다. 일반적으로 쿼리의 성능 테스트는 워밍업된 상태를 가정하고 테스트한다고 한다.

MySQL의 버퍼 풀은 크기가 제한적이라서 쿼리에서 필요로 하는 데이터나 인덱스 페이지보다 크기가 작으면 플러시 작업과 캐시 작업이 반복해서 발생하므로 쿼리를 6~7번 정도 반복해서 실행한 후, 처음 한두 번의 결과는 버리고 나머지 결과의 평균값을 기준으로 비교하는 것이 좋다.

 

 

[참고]

RealMySQL 8.0

 

개념

- 디스크 읽기 방식

- 인덱스란?

- B-Tree 인덱스

- 클러스터링 인덱스

- 유니크 인덱스

- 외래키

 

 

디스크 읽기 방식


 

디스크를 읽는 방식에는 랜덤(Random) I/O, 순차(Sequential) I/O가 있다. 이전에 가상 메모리, 페이징을 공부하면서 디스크 I/O를 줄이는 것이 중요하다는 것을 배웠는데 일반적으로 데이터베이스도 디스크에 데이터를 읽고 저장하기 때문에 디스크 I/O가 성능에 큰 영향을 끼친다.

 

순차 I/O는 디스크 헤더를 움직이지 않고 한번에 많은 데이터를 읽는 작업으로 비중이 크지 않다.

랜덤 I/O는 부분적으로 작은 데이터를 읽고 쓰는 작업으로 대부분의 작업이 이에 해당한다.

 

디스크 원판을 가지지 않는 SSD는 HDD에 비해 랜덤 I/O 속도가 훨씬 빠른데 그래도 순차 I/O에 비하면 전체 스루풋(Throughput)이 떨어진다고 한다.

 

일반적으로 쿼리를 튜닝하는 것은 랜덤 I/O 자체를 줄여주는 것이 목적으로 쿼리를 처리하는 데 꼭 필요한 데이터만 읽도록 쿼리를 개선하는 것을 의미한다.

 

 

인덱스란?


 

인덱스에 대해 찾아보면 대부분 책 뒤에 있는 색인으로 비유를 하는데 "ㄱ", "ㄴ", "ㄷ" 와 같은 순서로 정렬을 해서 원하는 데이터를 빠르게 찾을 수 있도록 도와주는 역할을 한다. 데이터의 저장(INSERT, UPDATE, DELETE) 성능을 희생하고 읽기 속도를 향상 시키는 것이다.

 

인덱스는 유니크(Unique)한 인덱스와 유니크 하지 않은 인덱스로 구분할 수 있는데 이는 실제 쿼리를 실행해야 하는 옵티마이저에게 상당히 중요한 문제이다. 유니크 인덱스에 대해 동등 조건('=')으로 검색한다는 것은 항상 1건의 코드만 찾으면 더 찾지 않아도 된다는 것을 옵티마이저에게 알려주는 효과를 낸다.

 

 

B-Tree 인덱스


 

인덱싱 알고리즘으로 가장 일반적으로 사용되는 B-Tree(Balanced-Tree)는 다음과 같은 구조로 되어 있다.

 

- 최상위 루트 노드(Root node)

- 중간 자식 노드 (Branch node)

- 최하위 자식 노드 (Leaf node)

 

데이터베이스에서 인덱스와 실제 데이터가 저장된 데이터는 따로 관리되는데 일반적으로 인덱스의 리프 노드는 실제 데이터 레코드를 찾아가기 위한 주솟값을 가지고 있다.

 

데이터 파일은 일반적으로 정렬이 되지 않은 상태로 저장이 되는데 DELETE 된 공간이 다시 재활용 되기 때문에 순서가 섞인다.

 

InnoDB 테이블

- 레코드가 클러스터 되어 디스크에 저장 되기 때문에 기본적으로 프라이머리 키 순서로 정렬이 되어 저장

- 리프 노드가 실제 주솟값이 아닌 프라이머리 키를 논리적인 주소로 사용 (세컨더리 인덱스 검색에서 프라이머리 키를 저장하고 있는 B-Tree를 다시 한번 검색)

 

인덱스 키를 추가할때 리프 노드가 꽉차면 분리가 되면서 상위 브랜치 노드까지 처리의 범위가 넓어지는데 이러한 이유로 B-Tree는 상대적으로 쓰기 작업에 비용이 많이 든다. (이 비용의 대부분이 메모리와 CPU에서 처리하는 시간이 아니라 디스크로부터 인덱스 페이지를 읽고 쓰기를 해야 해서 걸리는 시간)

 

아무튼 이런 비용을 감당하면서 인덱스를 사용하는 이유는 빠른 검색을 위해서이다. DB을 잘 사용하려면 얻는 것과 잃는 것에 대해 많은 경험, 고민이 필요한 것 같다.

 

인덱스를 이용한 검색에서 주의할 점은 인덱스의 키 값에 변형이 가해진 후 비교되는 경우 B-Tree의 빠른 검색 기능을 사용할 수 없다는 것인데 함수나 연산을 수행한 결과로 정렬, 검색을 하는 작업은 B-Tree의 장점을 이용할 수 없으므로 주의해야 한다.

 

InnoDB 스토리지 엔진은 디스크에 데이터를 저장하는 가장 기본 단위를 페이지 또는 블록이라고 하며 디스크의 모든 읽기 및 쓰기 작업의 최소 작업 단위가 된다. 위의 루트, 브랜치, 리프 노드를 구분한 기준도 페이지 단위이다.

하나의 인덱스 페이지는 4KB ~ 64KB(기본값 16KB)의 크기를 가지는데 인덱스 키 값이 커지면 그만큼 하나의 페이지에 담을 수 있는 키가 줄어들고 페이지 깊이(depth)가 증가하게 된다. 이는 디스크로부터 읽어야 하는 횟수가 늘어나고 그만큼 느려질 수 있다는 것을 의미한다.

 

인덱스를 통해 테이블의 레코드를 일는 것은 인덱스를 거치지 않고 바로 테이블의 레코드를 읽는 것보다 높은 비용이 드는 작업인데 인덱스를 통해 읽어야 할 레코드의 건수가 많다면(대략 전체 테이블 레코드의 20~25% 기준) 인덱스를 사용하지 않고 테이블을 모두 읽어서 필터링 하는 방식이 효율적이다. (MySQL의 옵티마이저는 이런 경우 예상을 해서 효율적인 방식으로 처리한다)

 

 

클러스터링 인덱스


 

MySQL 서버에서 클러스터링은 테이블의 레코드를 비슷한 것(프라이머리 키)들끼리 묶어서 저장하는 형태로 구현되는데 InnoDB는 프라이머리 키를 클러스터링 인덱스로 사용한다. 중요한 것은 프라이머리 키 값에 의해 레코드의 저장 위치가 결정된다는 것이다.

 

프라이머리 키가 세컨더리 인덱스에 미치는 영향

 

InnoDB 테이블의 모든 세컨더리 인덱스는 해당 레코드가 저장된 주소가 아니라 프라이머리 키(클러스터링 인덱스) 값을 저장하도록 구현이 되어 있다. 

 

 

세컨더리 인덱스가 프라이머리 키 값을 포함하고 있기 때문에 프라이머리 키의 크기가 커질 경우 세컨더리 인덱스도 자동으로 크기가 커지게 되서 위에서 말한대로 디스크 I/O가 증가하게 된다. 그래서 프라이머리 키의 크기는 가능하면 작게 사용하는 것이 효율적이다.

하지만 InnoDB의 프라이머리 키는 클러스터링 키로 사용되며 검색에 큰 이점이 있어서 해당 칼럼의 크기가 크더라도 업무적으로 해당 레코드를 대표할 수 있다면 그 칼럼을 프라이머리 키로 설정하는 것도 좋다고 한다.

 

 

유니크 인덱스


 

유니크 인덱스는 고유한 값으로 검색 시 1건만 찾으면 되기 때문에 유니크하지 않은 인덱스에 비해 훨씬 빠를 것 같지만 유니크하지 않은 인덱스의 경우 CPU에서 칼럼값을 비교하는 작업이 추가되는 것이라서 성능상 큰 차이는 없다고 한다.

오히려 유니크 인덱스는 주의해야 될 점이 있는데 유니크 인덱스 쓰기 작업의 경우 별도로 중복된 값이 있는지 없는지 체크를 한다. MySQL에서는 중복된 값을 체크할 때 읽기 잠금을 사용하고, 쓰기를 할 때는 쓰기 잠금을 사용하는데 이 과정에서 데드락이 빈번히 발생한다. 그리고 InnoDB 스토리지 엔진은 인덱스 키의 저장을 체인지 버퍼(Change Buffer)를 사용해서 버퍼링을 하기 때문에 인덱스의 쓰기 작업을 빨리 처리할 수 있는데 유니크 인덱스의 경우는 중복 체크를 해야 하기 때문에 작업 자체를 버퍼링하지 못한다. (= 꼭 필요할때 사용)

 

 

외래키


 

InnoDB의 외래키 관리

 

- 테이블의 변경(쓰기 잠금)이 발생하는 경우에만 잠금 경합(잠금 대기)이 발생한다.

- 외래키와 연관되지 않은 칼럼의 변경은 최대한 잠금 경합(잠금 대기)를 발생시키지 않는다.

 

자식 테이블의 외래 키 칼럼의 변경은 부모 테이블의 확인이 필요한데, 이 상태에서 부모 테이블의 해당 레코드가 쓰기 잠금이 걸려 있으면 해당 쓰기 잠금이 해제될 때까지 기다리게 된다. 자식 테이블의 외래키가 아닌 칼럼의 변경은 외래키로 인한 잠금 확장이 발생하지 않는다.

 

데이터베이스에서 외래 키를 물리적으로 생성하려면 이러한 잠금 경합을 고려해 모델링을 해야한다. 물리적으로 외래키를 생성하면 자식 테이블에 레코드가 추가되는 경우 해당 참조키가 부모 테이블에 있는지 확인하는데 이때 연관 테이블에 읽기 잠금을 걸어서 이런 잠금 확장으로 인한 영항을 신경 써야한다.

 

 

 

[참고]

Real MySQL 책

+ Recent posts