1. 배경
현재 저는 'SOPT' 라는 동아리에서 재밌고 편리한 동아리 활동을 위해 관련 서버를 운영하고 있습니다. 서버를 운영하면서 사용자 경험을 높이기 위해선 문제 상황을 빠르게 인지&대처를 하는 것이 정말 중요하다고 생각합니다. 그래서 pinpoint 를 활용한 모니터링 서버를 구축하여 서버의 전반적인 상태에 대한 관측 가능성(observability)을 높였습니다.
제가 담당하고 있는 서비스에는 위와 같이 스파크성 트래픽이 있습니다. 특정 행사의 선착순 신청을 하기 위한 트래픽인데요.
대학생인 저에겐 부하테스트를 제외하고 이러한 트래픽은 처음 겪어봤습니다. 보통 부하테스트의 경우엔 상황을 설정하고 진행하기에 원인이 단순합니다. 하지만 실제 트래픽은 많은 사용자가 다양한 액션을 하기에 정말 다양한 문제를 겪을 수 있었습니다.
특히 스파크성 트래픽이 있을 때는 API 응답시간이 1000ms가 넘어가는 문제가 빈번히 발생했습니다. 로컬이나 개발서버에서는 스파크 트래픽보다 훨씬 더 많은 vUser를 설정하여 부하테스트를 진행해도 1000ms를 초과하지 않았습니다.
하지만 프로덕션 환경에서는 아래와 같은 중요한 변수가 있었습니다.
- 비용 문제로 인해 가장 기본 성능 DB 1개를 5개 팀에서 사용하고 있기 때문에 훨씬 더 많은 부하가 발생
- DB에 쌓여있는 데이터가 몇 만건이기에 쿼리 실행속도가 훨씬 느려짐
이를 개선하기 위해 많은 시도 및 개선을 하였습니다. 이를 여러 포스팅으로 나눠 단계적으로 다뤄보고자 합니다.
오늘은 db connection 과 관련하여 LazyConnectionDataSourceProxy 에 한정해서 얘기해보고자 합니다.
이후 포스팅에서 더 드라마틱한 개선 사항들을 다뤄보겠습니다.
2. 문제 인식
위 사진과 같이 병목 지점을 잘 보면, 커넥션을 얻는 메서드 getConnection() 에서 많은 시간이 소요된 것을 확인할 수 있습니다.
이를 해결하기 위해 비즈니스 로직 최적화, 쿼리 최적화, 캐싱 등 많은 해결 방법이 있습니다. 하지만 앞서 말씀드린 것 같이 커넥션에만 집중해 보겠습니다.
가장 먼저 떠오릴 수 있는 방법은 커넥션을 늘리는 것입니다. 하지만 이미 하나의 DB를 많은 팀이 사용하고 있기에 커넥션을 늘려도 드라마틱한 결과를 얻을 수는 없었습니다.
두 번째로 생각한 것은 아래와 같습니다.
커넥션이 꼭 필요한 시점에 Lazy하게 요청을 하면 성능이 나아지지 않을까?
스프링에서는 @Transactional 어노테이션이 있을 경우, 해당 레이어에 진입하기 전에 프록시를 통해 커넥션을 얻는 작업을 진행합니다.
물론 DB에 접근하기 위해선 커넥션은 꼭 필요합니다. 하지만 그 커넥션을 얻는 시점이 꼭 "서비스 레이어에 들어갈 때" 일 필요는 없다고 생각했습니다.
그래서 비슷한 개념을 찾아보니 역시나 이미 구현된 LazyConnectionDataSourceProxy 가 있었습니다.
3. LazyConnectionDataSourceProxy 이란
LazyConnectionDataSourceProxy은 DB 커넥션이 실제로 필요할 때, 다시 말해 쿼리가 실행될 때 커넥션을 얻어옵니다.
@Transactional
@RequiredArgsConstructor
public class TestService {
private final LongTimeProcess longTimeProcess;
private final UserRepository userRepository;
public void processUser(Long id) {
longTimeProcess.do() // 평균 3s 작업이라 가정
User user = userRepository.findById(id);
// ..
}
}
위 코드처럼 실제 쿼리가 실행되기 전에 DB 커넥션과 관련없는 작업을 하게 되면, 불필요한 리소스를 낭비하게 됩니다.
그리고 트래픽이 몰릴 때는 해당 쓰레드가 커넥션을 점유한 채로 작업을 하기 때문에 다른 쓰레드들도 3s 이상 대기하게 되어 TPS 가 떨어지게 됩니다.
하지만 LazyConnectionDataSourceProxy 은 Statement 가 처음 생성될 때까지 실제 커넥션을 불러오는 것을 지연(Lazy) 시킵니다. read-only, isolation 과 같은 커넥션 초기 세팅은 저장하고 있다가 실제 커넥션을 불러올 때 적용됩니다.
Statement : 쿼리를 실행시킬 때 사용하는 객체
3-1. 장점
- 효율적인 리소스 사용 : 커넥션 풀의 부하를 줄이고, 진짜 DB 작업이 수행될 때만 커넥션을 사용하므로 효율적인 리소스 사용이 가능합니다.
- 트랜잭션 없는 상황에서 불필요한 커넥션 방지
- 기본적으로 @Transactional이 없는 메서드에서도 DataSource가 getConnection()을 호출하면 커넥션을 즉시 가져옵니다.
- LazyConnectionDataSourceProxy는 실제로 데이터베이스에 접근해야 할 때 커넥션을 가져오기 때문에, 단순히 빈이 로드되거나 사용되지 않는 상황에서는 커넥션을 점유하지 않습니다.
3-2. 단점
단점에 대해 계속 생각해봤지만 큰 단점이 없다고 생각합니다. 하나의 단점이라면, 시스템의 복잡도가 조금 높아진다지만 적용 방법이 단순하기 때문에 큰 문제라고 생각하지 않았습니다.
4. LazyConnectionDataSourceProxy 동작 방식
4-1. Transactional 동작
먼저 @Transactional 어노테이션의 동작을 명확히 알 필요가 있습니다.
@Transactional 어노테이션이 있는 클래스는 해당 클래스의 메서드를 호출할 때, TransactionInterceptor 에서 해당 요청을 가로채 invoke() 메서드를 실행시킵니다. 해당 호출 내부에서 트랜잭션을 만들게 되는데 JpaTransactionManager 내부의 beginTransaction() 에서 트랜잭션을 시작하면서 DataSource로부터 getConnection() 을 호출합니다.
4-2. LazyConnectionDataSourceProxy 동작
1. ConnectionProxy 반환
방금 전 예시와 비슷하게 동작하지만 위 그림과 같이 getConnection() 를 호출할 때 반환값에 차이가 있습니다.
DataSource를 상속받은 LazyConnectionDataSourceProxy 에서 getConnection() 를 처리하게 됩니다.
LazyConnectionDataSourceProxy 는 커넥션이 null 값인 ConnectionProxy 커넥션이 반환됩니다.
반환된 ConnectionProxy 는 실제 커넥션에게 보내는 모든 호출을 가로챕니다.
커넥션 없이도 응답이 가능한 것들은 정상적으로 응답하며, rollback() 이나 commit() 과 같은 커넥션이 필요한 메서드는 무시합니다.
아까 설명했듯이 커넥션을 불러오는 기준은 Statement 생성의 유무였습니다. Statement를 생성하는 prepareStatement() 메서드 외에는 커넥션을 생성하지 않는 것을 코드를 통해 확인할 수 있습니다.
2. 실제 커넥션이 필요할 경우
그 후 prepareStatement() 를 호출하여 실제 DB 호출이 필요할 때, getTargetConnection()를 통해 실제 커넥션을 가져옵니다. 커넥션을 통해 일반적인 경우와 동일하게 쿼리를 실행시킵니다.
3. 커넥션 반납 시점
한 번 커넥션을 얻으면 일반적인 방법과 똑같이 트랜잭션 끝날 때까지 커넥션을 가지고 있습니다.
일반적인 경우와 동일하게 close() 메서드 호출할 때 커넥션을 반납하게 됩니다.
4. 최초 커넥션 획득 후에도 계속 커넥션을 Lazy하게 가져올 수 없나?
결국 해당 기술은 "맨 처음에만 커넥션을 Lazy하게 가져오는 것" 이고, 그 후의 동작은 일반적인 동작과 동일합니다.
그러다보니 아래와 같은 생각을 했습니다.
최초 커넥션 획득 후에도 계속 Lazy하게 가져오면 성능이 더 좋아지지 않을까?
하지만 이 생각은 정말 위험한 방법이라는 것을 금방 알 수 있었습니다.
보통 하나의 트랜잭션이 보장되기 위해선 하나의 커넥션만을 사용해야 합니다. 트랜잭션은 커밋 또는 롤백 시 모든 작업이 일관되게 적용되거나, 모두 되돌려져야 하기 때문입니다. 만약, 서로 다른 커넥션을 사용하면 트랜잭션의 커밋/롤백이 각각의 커넥션에서 독립적으로 처리되어 트랜잭션의 원자성과 독립성이 깨지게 됩니다.
5. LazyConnectionDataSourceProxy 성능 테스트
LazyConnectionDataSourceProxy 를 적용하는 방법은 다른 블로그에도 잘 나와있고, 크게 어렵지 않기 때문에 생략하겠습니다.
성능 테스트를 진행하기 전에 현재 서비스 코드를 보며 결과를 예측해봤습니다. 병목현상이 발생하는 API는 주로 I/O Bound 작업이기에 드라마틱한 결과를 얻긴 힘들 것 같았습니다. 그리고 미리 스포하자면, 실제로도 10%미만의 아주 작은 성능 개선이었습니다.
5-1. LazyConnectionDataSourceProxy 적용 테스트
a. LazyConnectionDataSourceProxy 적용 X
평균 응답 시간 : 1275ms
TPS : 261
b. LazyConnectionDataSourceProxy 적용 O
평균 응답 시간 : 1075ms
TPS : 289
10% 정도 성능이 나아진 것 같지만, 실제로 여러 번 부하테스트를 진행해보면 항상 10% 이상의 차이가 나진 않습니다.
심지어는 적용하지 않았을 때 더 성능이 좋아진 경우도 있습니다.
예상대로 드라마틱한 성능 개선을 하진 못했습니다.
5-2. 오래 걸리는 작업이 있는 경우
하지만 만약에 DB를 호출하기 전에 시간이 오래 걸리는 작업이 있는 경우에는 확연한 차이가 있을 것이라고 생각했습니다.
public MeetingV2GetMeetingByIdResponseDto getMeetingById(Integer meetingId, Integer userId) {
try {
Thread.sleep(3000L);
} catch (Exception e) {
System.out.println("err");
}
User user = userRepository.findByIdOrThrow(userId);
// ...
}
위와 같이 sleep() 를 사용해 해당 상황을 가정해봤습니다. 조금 더 극단적인 결과를 얻기 위해 3s 의 지연을 줬습니다다. 결과는 유의미한 차이가 있었습니다.
a. LazyConnectionDataSourceProxy 적용 X
( 너무 오랜 시간이 걸리기도 하고 명확한 차이를 보여주고 있다고 생각하여 중간에 부하를 멈췄습니다)
평균 응답 시간 : 114069ms
TPS : 6.5
b. LazyConnectionDataSourceProxy 적용 O
평균 응답 시간 : 15020ms
TPS : 64
위 결과 같이 LazyConnectionDataSourceProxy를 적용했을 때, 압도적인 성능 차이가 있었습니다.
6. 결론
현재 서비스에서는 큰 성능 개선을 이뤄내지 못했습니다. 하지만 추후에 어떤 서비스가 도입될지 모르기도 할 뿐더러 해당 설정을 함으로써 손해볼 것이 없다고 생각해서 도입해볼 예정입니다.
뿐만 아니라 특정 메서드내에서도 코드의 위치에 따라 성능이 크게 좌우될 수 있다는 것도 느낄 수 있었습니다.
7. 마무리
이번 포스팅은 성능 개선보다는 @Transactional과 LazyConnectionDataSourceProxy의 동작 방식을 정확히 이해하는 데 초점을 맞추었습니다. 더불어, 스프링에서 강조하는 AOP를 프록시를 통해 어떻게 구현하는지에 대해 깊이 탐구하는 시간이었습니다.
저는 이제 단순히 기술을 사용하는 개발자에 머무르지 않고, 시스템의 내부 동작을 명확히 이해하는 개발자로 성장하고자 합니다. 이번에 동작 원리를 알아본 경험은 그 목표를 이루기 위한 좋은 초석이 되었다고 생각합니다.
다음 포스팅부터는 실제 성능 개선을 위해 진행했던 작업들에 대해 다룰 예정입니다.
출처
'BE' 카테고리의 다른 글
[Database] 실수하기 어려운 환경 만들기 (1) | 2024.09.16 |
---|---|
우아한객체지향을 보고 남기는 액기스 (0) | 2024.06.18 |
댓글