실무에서 MySQL 데이터베이스를 활용하면서 커넥션, I/O 연산, 잠금에 대해 더 유심히 살펴보게 되었다. 요 세가지 자원이 중요하다고 배웠는데 실제로 사용해보면서 정말 그렇다는걸 느낄 수 있었다. 그렇기에 이를 통해 배운점들을 다시 상기해고자 한다.
커넥션
데이터베이스를 사용하면서 가장 중요한 자원을 꼽는다면 커넥션이다. 커넥션이 부족해지는 순간 쿼리를 날릴 수 없다는 의미이며, 이는 곧 장애로 이어진다. 그렇기에 데이터베이스 커넥션 개수를 원활하게 모니터링하고 관리하는 부분은 굉장히 중요하다.
커넥션이 부족해지게 만드는 요소로는 트래픽 증가로 서버의 스케일링으로 커넥션이 부족해질 때가 존재한다. 또, 트랜잭션이 길어져 해당 작업이 커넥션을 계속 잡고 있으면 db 커넥션이 부족해질 수 있다. 혹은 어플리케이션에서 커넥션을 제대로 반환하지 않는 경우도 존재할 수 있다.
항상 예상치 못한 상황이 발생하기에 db 커넥션 수를 잘 모니터링하고 제대로된 알람을 받는게 가장 중요하다. 이외에도 각 구성요소의 커넥션 풀과 timeout을 잘 설정해 db 커넥션이 부족하지 않도록 신경써야 한다. 부하테스트를 통해 선제적으로 방지하는 것도 방법이 될 수 있다.
I/O 최적화
커넥션을 적절히 확보한다면, I/O에 대해서 고민해볼 것 같다. DBMS는 결국 데이터를 HDD나 SSD에 읽기와 쓰기 에 대한 I/O를 진행해야 한다. 이 I/O는 사소한 차이에 성능이 천차만별 만큼 차이가 나기 때문에, I/O 연산이 정확한 수치까지는 아니더라도 대략적으로 얼마나 나오는 지 알아두는 건 굉장히 중요하다.
조회 I/O 최적화는 조인과 인덱스가 큰 영향을 준다. 조인을 잘못하면 테이블 간의 레코드 개수의 곱만큼의 순차 I/O가 발생할 수 있으며, 인덱스가 없는 컬럼의 테이블을 조회하면 레코드 수만큼의 순차 I/O가 발생할 수 있다. 그렇기에 인덱스를 활용해 대량의 순차 I/O를 소량의 랜덤 I/O로 변경하면 유의미하게 성능이 향상된다. 하지만 인덱스를 생성하는 I/O로 인해 insert, update, delete query의 성능을 저하시킬 수 있으며, 데이터 분포도가 고르지 않을 때 인덱스를 활용하면 조회 성능 마저 기존보다 느려질 수 있다.
또, 인덱스를 제대로 활용하지 않고 다량의 데이터를 조회하는 쿼리가 여러개가 작동하게 되면 CPU 사용량이 급증하게 된다. 이 역시 상황에 맞게 적절히 인덱스를 활용하고 조인문을 적절히 활용하는게 좋겠다. 실제로 나는 조인절 인덱스를 고려하지 않아 슬로 쿼리를 만들어 CPU를 높이는 실수한 경험이 있다..
커멘드 작업에도 I/O에 대해 고민해보는게 좋다. insert문과 update문을 사용할 때, 다량의 데이터를 넣을 때에는 단건의 쿼리를 고려해보는 것도 방법이다. 이는 서버와 db 간 네트워크 I/O와 인덱스 업데이트 과정의 Disk I/O를 줄일 수 있게된다. 실제로 mysql 공식 문서에서도 insert 속도 성능 향상을 위해 여러 단건 insert문보다 하나의 bulk insert문을 활용하라고 권장한다.
물론 bulk insert가 orm에서 기본적으로 제공하지 않을 수도 있다. 예를 들어 현재 회사에서 활용하는 spring jpa에서 컬렉션 타입에 대해 insert 혹은 update를 진행하게 되면 기본적으로 단건으로 여러 쿼리가 발생하는데, 수십~수백건에 대해서는 문제 없이 작동하지만 수천건이 넘어가는 순간부터 유의미하게 속도가 느려진다. 이 상황에서 jdbcTemplate 등을 활용해 raw query를 만드는 방법 등을 통해 bulk insert를 구현해 문제를 해결할 수도 있다.
하지만 모든 상황에서 이를 활용하는게 정답은 아니다. 저장해야할 데이터가 수천건이 넘어가 비즈니스 로직에 문제가 생긴다면 bulk insert를 구현해 성능을 최적화 할 수도 있을 것이고, 이 구현 역시 비용이 될 수 있으므로 단순히 spring data jpa의 saveAll(
)이나 변경 감지를 활용해 간단하게 처리할 수도 있다. 결국 상황에 맞게 적절히 활용해야 한다.
잠금(Lock)
I/O를 잘 진행하면 그다음 고려할 것은 잠금이다. MySQL은 MVCC를 제공하기에 여러 잠금 레벨을 제공하며, 잠금에 따라 성능이 천차만별이다. 잠금은 데이터 정합을 위한 장치이다. 따라서 성능과 데이터 정합은 반비례한다고 보면 된다. 정합을 중요시해서 잠금을 높은 수준(길게)으로 설정한다면 성능에 이슈가 발생할 수 있다. 반면 정합이 상황에 따라 조금씩 틀리더라도 잠금을 낮은 수준으로 설정하면 성능에 큰 이점을 가져올 수 있다.
실제로 잠금을 직접 건드릴 일이 크게 많지는 않았지만, 이로 인한 문제가 발생할 때가 종종 있엇고 어떤 잠금이 문제를 일으키는 지 확인하는 과정은 중요했던 것 같다. 작업을 하면서 고려해야 할 잠금은 여러 개가 존재하겠지만 몇몇 사례를 소개한다.
- 수천만건 데이터가 있는 테이블의 alter 문 실행, index 추가 및 제거 작업을 진행할 때 잠금
- 트랜잭션 격리 레벨에 따른 잠금
- 외래키의 잠금 전파
- 유니크 인덱스의 읽기/쓰기 잠금
- 어플리케이션의 낙관적/비관적 잠금
결국 쿼리든, 잠금이든 정답은 존재하지 않는다. 도메인과 풀어야 하는 문제 상황에 맞게 적절히 쿼리를 작성하고 잠금 수준을 고려하는게 중요하다. 결제 정보와 같이 정합이 중요하다면 잠금을 직접 걸거나 고려해볼 수도 있으며, 가볍게 여러번 조회하는 타임라인, 목록과 같은 정보의 경우 조회 결과가 조금씩 달라도 큰 문제가 없다면 잠금 수준을 낮게 설정하는 것도 방법이다. 이 상황에서 오히려 정합을 위해 잠금 수준을 높게 두어 속도가 느리다면 고객 경험 측면에서 더 손해일 수도 있다.
잠금이나 쿼리에 대해서 최적화를 할 때 꼭 RDBMS에만 의존해야할 필요가 없을 수도 있다. 조회를 위해 인덱스를 거는 대신에 key-value db, document db로 캐싱을 고려하는 것도 방법이며, 어떤 작업에 대한 잠금이 필요할 때 Redis로 분산 락을 거는 것도 방법이다. 결국 방법은 여러 개이기에 지금 상황의 문제에 맞게 팀원과 합의해 최선의 방법으로 해결하는게 가장 중요하다.