커낵션 풀사이즈 설정과 데드락 해결기
“이돈이면” 프로젝트를 진행하면서 발생한 데드락 해결 과정을 기록하고자 한다.
상황
서비스에 적절한 커넥션 풀 사이즈를 찾기위해 부하 테스트를 진행하고 있었다.
그러던 중 댓글 작성 API에서 커낵션 풀 사이즈가 5일 때 예외가 발생하는 걸 발견했다.
- 테스트에서 사용한 값
- Number of Thread(users): 112 (현재 사용자 수 * 2)
- Ramp-up period (seconds): 10(한 화면에 10초 정도 머문다고 가정)
위 사진에서 에러를 보면 88.39% 거의 모든 요청이 실패한 걸 알 수 있다.
문제 파악
서비스에서 게시글에 댓글이 달렸을 경우, 게시글 작성자에게 댓글이 달렸다는 알림이 간다.
위의 로그를 보면 5개의 커낵션이 특정 요청들에 의해 점유되고 있는걸 볼 수 있다.
서버에서 발생한 예외를 확인해보면 다음과 같다.
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:181)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:146)
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
즉, 요청을 완료하지 못하고 대기하고 있다가 30초 이후 타임아웃으로 종료된 것이다.
문제가 발생한 코드를 메소드 호출 순서를 간단하게 보면 다음과 같다.
@Transactional
public long createComment(final MemberId memberId, final Long postId, final CommentRequest commentRequest) {
//..댓글 생성 코드 생략
// 여기서 댓글을 생성한 후 댓글 생성 이벤트를 발행한다.
publisher.publishEvent(new SavedCommentEvent(comment));
return comment.getId();
}
// 댓글 생성 이벤트 처리 (기본 AFTER_COMMIT)
@TransactionalEventListener
public void sendCommentSavedNotification(SavedCommentEvent event) {
try {
notificationService.sendCommentNotificationToPostWriter(event.comment());
} catch (BusinessLogicException e) {
log.error(e.getMessage(), e);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendCommentNotificationToPostWriter(Comment comment) {
// .. 자세한 코드 생략
sendNotification(comment.getPostWriter(), ScreenType.POST, comment.findPostId(), COMMENT_NOTIFICATION_TITLE);
}
위와 같이 코드를 작성했을 때, 예상했던 흐름은 다음과 같았다.
- 커낵션을 가져와서 댓글을 생성하고, 데이터베이스에 저장한다.(커밋 또는 롤백 처리 후, 커낵션을 커낵션 풀에 반환)
- 트랜잭션이 커밋된 경우 이벤트를 발행한다.
- 커낵션을 가져와서 새로운 트랜잭션을 생성하고 알림을 전송한다. (이후, 커낵션 반환)
이렇게 예상대로 동작한다면 댓글을 생성하고 알림을 보내기까지의 전체과정에서 커낵션을 최대 1개 사용한다.
하지만, 댓글 작성과정에서 트랜잭션이 종료되고 커낵션을 반환할 것이라는 생각은 틀렸다.
그 이유는 자바에서 기본적으로 특정 스레드에서 예외가 발생하면 해당 스레드에서 콜 스택을 하나씩 타고 올라가서 예외가 전파되기 때문이다.
트랜잭션은 예외가 발생하면 롤백이 체크되기 때문에, 자식 트랜잭션에서 발생한 예외가 부모 트랜잭션까지 타고 올라가 롤백 처리된다.
위 과정을 처리하기 위해 댓글 생성에서 생성된 커낵션은 커낵션 풀에 반환되지 않고 대기하고 있다.
댓글 작성 메서드는 알림 발송 메서드가 종료될 때까지 기다려야한다.
즉, 위 동작에서는 커낵션을 최대 2개 사용하고 있다.
해결 방안
- 커낵션 풀 사이즈 조정
- 스레드 분리
1번의 경우 HikariCP wiki에 나와있는 데드락을 피하는 공식을 사용하면된다.
pool size = Tn x (Cm - 1) + 1
(Tn: 최대 스레드 수, Cm: 단일 스레드에서 사용하는 최대 커넥션 수)
공식을 설명하면 다음과 같다.
위와 같이 전체 스레드가 8개이고 단일 스레드에서 사용하는 커낵션이 2개라면
pool size = 8 x (2 - 1) + 1
= 9개를 설정하면 데드락이 발생하지 않는다는 것이다.
그 이유는 동시에 8개의 요청이 와도 하나의 커낵션이 각 요청 스레드에 할당되고
작업을 완료하기 위해 하나의 커낵션을 기다리고 있을 텐데, 커낵션 풀에 하나의 커낵션이 존재하기 때문에
하나의 요청은 커낵션을 받아서 작업을 종료할 수 있다.
그 후 종료된 스레드가 커낵션 두개를 반납하면 남은 요청 스레들도 요청을 완료할 수 있기 때문이다.
이는 최대 스레드 수(톰캣의 thread.max 기본은 200)에 따라 커낵션 풀 사이즈를 조정해야한다는 말과 같고 우리가 원하는 바가 아니었다.
우리가 원하는 바는 댓글 작성 메소드는 댓글 생성 이벤트를 발행하고 트랜잭션을 마치고 커낵션을 반환하는 것이다.
이를 위해서는 비동기로 스레드를 분리하여야한다.
적용 및 확인
비동기를 적용하기 위해서는 다음과 같이 구성 클래스에 @EnableAsync
어노테이션을 붙여주면 된다.
@EnableAsync
@SpringBootApplication
public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
}
이후 아까 사용했던 코드에
@Transactional
public long createComment(final MemberId memberId, final Long postId, final CommentRequest commentRequest) {
//..댓글 생성 코드 생략
// 여기서 댓글을 생성한 후 댓글 생성 이벤트를 발행한다.
publisher.publishEvent(new SavedCommentEvent(comment));
return comment.getId();
}
// 댓글 생성 이벤트 처리
@TransactionalEventListener
public void sendCommentSavedNotification(SavedCommentEvent event) {
try {
notificationService.sendCommentNotificationToPostWriter(event.comment());
} catch (BusinessLogicException e) {
log.error(e.getMessage(), e);
}
}
@Async
@Transactional
public void sendCommentNotificationToPostWriter(Comment comment) {
// .. 자세한 코드 생략
sendNotification(comment.getPostWriter(), ScreenType.POST, comment.findPostId(), COMMENT_NOTIFICATION_TITLE);
}
알림 발송 메소드에서 @Async
어노테이션을 적용해주면 된다.
(비동기로 처리하면서 기존 트랜잭션이 없기 때문에 REQUIRES_NEW
옵션은 빼주었다.)
이제 @Async
로 비동기 적용 후 다시 테스트를 해보면 아래와 같이 예외 없이 정상 작동하는 걸 알 수 있다
추가로 커낵션 풀 사이즈를 5로 정한 이유
스레드를 분리하여 Hikari CP에서 제공하는 데드락을 피하는 공식을 적용하면 다음과 같다.
pool size = 200(기본 값) x (1 - 1) + 1
= 1,
즉 이제 하나의 커낵션만 있어도 해당 메서드에는 데드락이 발생하지 않는다.
하지만, 데드락을 피하는 공식을 사용해서 나온 풀 사이즈는 효율적이지는 않다.
Hikari CP wiki를 보면 다음과 같은 효율적인 성능 개선을 위한 공식을 제공한다.
connections = ((core_count * 2) + effective_spindle_count
위의 공식을 기준으로 서버의 사양(EC2 t4g.small)을 대입해보면 다음과 같이 나온다.
connections = 2 * 2 + 0(서버에서 사용하는 스토리지는 EBS의 gp3로 SSD 기반이라 스핀들이 없다)
4개를 기준으로 하나씩 더하고 빼보면서 평균적으로 편차가 적은 5개를 사용하였다.
결론
자바 코드로 테스트를 작성하는 것도 중요하지만, API 설계 후 부하테스트도 진행해야한다.
댓글남기기