성능 테스트 결과
좋아요 기능만 성능 테스트를 해봤습니다.
Redis를 사용해 구현한 경우에는 평균 TPS가 2,454가 나왔습니다.
DB만을 사용해 구현한 경우에는 평균 TPS가 1,317가 나왔습니다. TPS만 보면 성능 자체는 대략 86%정도 향상되었다고 볼 수 있겠네요.
테스트 도중 발견한 문제점
Hikari Connection Pool
이상하게 DB 테스트 도중 중간에 계속 Dead lock에 빠지는 현상이 발생했습니다. Redis에서는 멀쩡한 것을 보니 DB로직에 문제가 있을 거라고 생각했습니다.
결론은 HikariCP에 문제가 있었어요. HikariCP는 미리 DB connection들을 pool로 만들어두어서 DB와 연결하는 오버헤드를 줄여주는 라이브러리입니다. 그런데 문제는 이 HikariCP가 만들 수 있는 최대 connection수가 정해져있다는 점입니다.
1
spring.datasource.hikari.maximum-pool-size=48
이렇게 최대 connection수를 바꿔줄 수 있습니다.
물론 이렇게 널널하게 늘려주면 문제는 해결되지만, 어느 정도로 정해야할지 기준 정도는 잡아야겠죠? 그리고 발생 원인도 알아내야 나중에 비슷한 문제가 발생했을 때 이 문제를 떠올리면서 해결책을 찾아볼 수 있겠죠.
@GeneratedValue(strategy = GenerationType.AUTO)
의 문제
이 GeneratedValue는 Sequence전략을 사용합니다. 그런데 저는 mysql을 사용하고 있었고, mysql은 hibernate_sequence 테이블을 만들어 사용합니다. 그러면 자연스럽게 save할 때 id 생성이 필요할 것이고, 이때 총 2개의 connection이 사용됩니다.
이상하죠? @Transactional
은 기본적으로 중첩해서 쓰면 부모 transaction에서 같이 수행되니까 하나의 transaction내에서 실행되어야 할 것 같은데요.
특이하게도 @GeneratedValue
는 직접 connection을 obtain해서 가져와 사용합니다. 그래서 실질적으로는 2개의 connection이 사용되는 것이지요. 참고 링크에 걸어둔 필자의 생각으로는
상위 Transaction이 끝나기 전 까지 다른 thread에서 ID 채번을 할 수 없기 때문
인 것 같다고 하네요.
Connection이 2개여서 생기는 문제
그래서 Connection이 2개 생기는데 왜 dead lock이 발생할까요? 아래는 dead lock 시나리오입니다.
- 저희는 hikari pool connection 제한을 10개로 정했다고 합시다.
- 동시에 16개의 thread에서 connection을 요청합니다.
- 그럼 6개의 thread는 다른 thread가 connection을 다 쓸 때까지 대기하고 있겠죠?
- 하지만 남은 10개의 thread도 connection이 하나 더 필요합니다. 왜냐하면 위에서 설명한대로 @GeneratedValue때문에 그렇습니다.
- 기다리는 thread만 있고, 반납하는 thread없으니 dead lock에 걸립니다.
- timeout에 의해 일부 thread의 connection이 반납되겠지만, 반납되더라도 결국 위와 같은 상황이 반복됩니다.
그러면 몇 개의 connection이 적절할까?
\[pool size=t_n \times (c_m - 1) + 1\]$t_n$은 thread 개수, $c_m$은 한 thread에서 사용하는 최대 connection 개수입니다. 위의 예시에서는 $t_n=16, c_m=2$입니다. 그래서
\[pool size=16 \times (2 - 1) + 1 = 17개\]최소 17개만 있어도 dead lock을 피할 수 있다는 이야기입니다.
이 공식의 포인트는 뒤에 있는 $+1$인데요. 한번 17개로 설정했을 때의 시나리오를 보겠습니다.
- 16개 thread가 모두 connection을 가지고 있다.
- 하지만 thread별로 각 2개가 필요하다.
- 16개중 가장 먼저 요청한 thread가 connection을 요청하고, 받아 사용한다.
- 그러면 그 thread는 lock에 걸리지않고 정상처리된다.
- 처리된 이후 connection 2개가 반납되고, 다른 thread도 정상처리되어 dead lock에 걸리지 않는다.
물론 dead lock에 걸리지는 않지만 성능은 느려질 수 있습니다. 그러므로 17개보다 더 널널하게 설정해두는 편이 좋습니다. 17개는 ‘최소’ connection 개수입니다.
자세한 사항은 여기에 잘 설명되어있습니다.
최대 thread 개수를 정하는 방법
위에서는 thread개수를 16개라고 가정했습니다. 하지만 성능 테스트를 해보니 tomcat은 thread를 200개까지 생성하네요. 그러면 connection수를 201개로 맞춰주면 될 것 같네요.
하지만 200이라는 수치는 바뀔 수 있는 수치입니다. tomcat 업데이트에 따라 다른 값으로 바뀔 수도 있겠죠. 그리고 매번 테스트해서 값을 가져오기도 어렵습니다. 이 값을 고정해서 사용하면 좋을 것 같네요.
1
server.tomcat.threads.max=160
최대 160개로 설정했습니다. 이러면 hikariCP의 maximum-pool-size
는 161개 이상으로 설정해주어야합니다.
Mysql Max Connection
Mysql의 Max Connection이 문제가 될 수도 있습니다. 저는 hikariCP의 최대 pool size를 161개로 지정했는데요. Mysql의 기본 max connection은 151개였습니다. 그래서 중간에 병목이 발생했었어요.
이 값은 DB를 다른 서버에서도 같이 사용한다면 그 모든 connection까지 고려해서 설정해야합니다. 이 max connection은 mysql console에서 확인할 수 있습니다.
1
2
3
4
5
# max_connections 보기
show variables like 'max_connections';
# max_connections 바로 설정
set global max_connections=3000;
위에서의 방식대로 max_connections를 설정해도 db가 재시작될 때 초기화됩니다. 테스트할 용도나 급한 경우에만 사용하시면 될 것 같아요.
만약 영구적으로 설정하고 싶으시면 mysql의 cnf파일을 수정해야합니다. ubuntu기준 /etc/my.cnf
에 있습니다. 이 cnf파일에 아래 설정을 적용해주세요.
1
2
[mysqld] # 이 [mysqld] 아래에 max_connections 표기
max_connections = 500
DB 동시성 문제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
public void like(Long postId, Long userId) {
boolean isAlreadyLiked = persistenceRepository.findBy(postId, userId).isPresent();
if (!isAlreadyLiked) {
persistenceRepository.save(~~~생략~~~);
}
}
@Transactional
public void cancelLike(Long postId, Long userId) {
boolean isAlreadyLiked = persistenceRepository.findBy(postId, userId).isPresent();
if (isAlreadyLiked) {
persistenceRepository.deleteBy(postId, userId);
}
}
위의 로직은 동시성 문제가 발생한 좋아요 로직입니다.
like나 cancelLike를 할 때 우선 유저가 이전에 like를 한 적이 있는지 확인합니다. 만약 모든 transactional 요청이 sequencial하게 실행되었다면 이 로직은 문제가 없습니다.
하지만 동시에 like를 3번한다고 가정해볼게요.
- 동시에 요청이 들어왔으므로, 3개 요청 모두 동시에 findBy로 like를 한 적이 있는지 확인한다.
- 3개 요청 모두 like를 한 적이 없다고 나올 것이다.
- 3개 요청은 모두 save를 한다. (결국 중복 데이터 3개 발생)
이 문제는 여러 방법으로 해결할 수 있습니다.
- DB column에 unique제약 조건을 걸어서 error가 발생하면 적절히 처리해준다. (controller advise등 )
- Transactional에 더 높은 레벨의 isolation을 걸어준다. (성능이 하락될 수 있습니다.)
- Redis를 사용한다. (대신 한 번에 한 개 명령어만 사용해야합니다.)
저는 Redis를 통해 해결했습니다. Redis를 그냥 막 사용하면 똑같이 동시성 이슈가 발생합니다. Redis는 명령을 Single-thread로 처리하긴 합니다. 하지만 redis로 보내는 요청 자체는 multi-thread이므로 사용하는 명령어가 2개 이상이면 이 명령어 사이에 다른 명령어가 queue될 수도 있습니다.
Redis에는 lock이 있습니다. 이 NX를 사용해서 Spin lock을 걸거나, Redisson에서 제공하는 분산 lock을 사용하면 동시성 문제를 해결할 수 있습니다.
참고
- https://techblog.woowahan.com/2663/