1번 실험에서 아쉬웠던 부분은 이랬다.
- 왜 트랜잭션을 적용한 UPDATE의 지표가 더 양호했는가?
- DB 관련한 수치 측정의 부재
- 서버 재실행 시 DB 컨테이너의 지속적인 활성화로 인한 영향?
위 3가지 부분을 고려하지 못한 점에서 아쉬움을 느꼈고, 두 번째 실험을 설계해서 재측정해보았다.
기존에 세팅했던 환경에서는 이러한 문제들을 추가로 확인할 수 있었다.
(@Transactional 적용한 원자적 UPDATE 해결안의 RPS)
회차 1 2 3 4 5 6 7 RPS 324.1 435.1 582.2 630.8 580.4 549.1 598.1
회차당 7,000요청(워밍업 2,000 + 본 측정 5,000)이므로 정상 상태까지 약 14,000요청이 필요하다.
평탄 구간에 들어간 뒤에도 회차 간 편차가 ±9% 남는다(4회차 ~ 7회차). 따라서 20% 미만의 차이는 1회 측정으로 판별할 수 없다.
| 지표 | 출처 | 측정 의도 |
|---|---|---|
| 요청당 커밋 수 | pg_stat_database.xact_commit 델타 | 트랜잭션 경계가 실제로 몇 개인가 |
| 요청당 커넥션 획득 횟수·대기시간 | hikaricp_connections_acquire_seconds_* | 커넥션을 몇 번 얻고 얼마나 기다리는가 |
| 요청당 커넥션 점유시간 | hikaricp_connections_usage_seconds_* | 풀을 얼마나 오래 붙잡는가 |
| 요청당 DB 실행시간·문장 수 | pg_stat_statements | 지연이 DB 안에서 났는가 밖에서 났는가 |
| 획득 대기 큐 깊이 | hikaricp_connections_pending 250ms 샘플링 | 줄이 실제로 쌓이는가 |
위에서 작성했던 1, 2번의 부재로 이와 같은 지표를 추가로 측정하기로 했다.
기존에 JPA의 더티체킹으로 구현한 로직이다.
@Transactional
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
Club targetClub = clubRepository.findById(clubId)
.orElseThrow(ClubNotFoundException::new);
targetClub.updateViewCount();
return ClubDetailServiceResponse.from(
clubRepository.getClubIntroduction(clubId, username));
}
| 유실 | 실패율 | RPS | RPS 구간 | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|---|
| 89.9% | 0% | 400.5 | 385~411 | 244.1 | 298.9 | 332.7 | 455.1 |
DB 관련 지표
| 커밋/req | 커넥션획득/req | 점유ms/req | DB실행ms/req | SQL문장/req |
|---|---|---|---|---|
| 1.0 | 1 | 24.4 | 21.9 | 4.0 |
유실율은 여전히 90%대로, 대다수의 요청에서 조회수 업데이트가 누락되었다.
// ClubRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Club c WHERE c.id = :clubId")
Optional<Club> findByIdForUpdate(@Param("clubId") String clubId);
@Transactional
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
Club club = clubRepository.findByIdForUpdate(clubId) // 행 배타 락을 잡고 읽는다
.orElseThrow(ClubNotFoundException::new);
club.updateViewCount();
return ClubDetailServiceResponse.from(
clubRepository.getClubIntroduction(clubId, username));
}
| 유실 | 실패율 | RPS | RPS 구간 | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|---|
| 0 (0%) | 0% | 346.4 | 331~357 | 286.0 | 330.8 | 354.9 | 400.1 |
DB 관련 지표
| 커밋/req | 커넥션획득/req | 점유ms/req | DB실행ms/req | SQL문장/req |
|---|---|---|---|---|
| 1.0 | 1 | 28.3 | 26.0 | 4.0 |
이전과 동일하게 유실율은 없고, 현행에서 약간의 점유 시간과 DB 실행시간이 늘었다.
@Version + 재시도 3회)// Club.java — 스키마 변경 필요 (version 컬럼 추가)
@Version
private Long version;
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
for (int attempt = 1; attempt <= 3; attempt++) {
try {
// @Transactional 이 붙은 별도 빈
viewCountUpdater.increaseOnce(clubId);
break;
} catch (ObjectOptimisticLockingFailureException e) {
if (attempt == 3) throw e; // 3회 소진 → 500
}
}
return ClubDetailServiceResponse.from(
clubRepository.getClubIntroduction(clubId, username));
}
| 유실 | 실패율 | RPS | RPS 구간 | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|---|
| 0 (0%)* | 67.6% | 354.3 | 345~364 | 263.5 | 547.5 | 711.6 | 1085.0 |
DB 관련 지표
| 커밋/req | 커넥션획득/req | 점유ms/req | DB실행ms/req | SQL문장/req |
|---|---|---|---|---|
| 1.0 | 3.25 | 26.3 | 21.6 | 5.85 |
예상했지만 여전히 재시도 횟수 초과로 인한 실패율이 높아보였다.
요청으로 인한 지연 분포도 높았으며
무엇보다, 커넥션 획득 횟수나 실행되는 SQL 수도 기존보다 높아졌기에 역시 채택이 어려웠다.
@Transactional 적용)@Transactional
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
ClubDetailQueryResponse detail = clubRepository.getClubIntroduction(clubId, username);
if (detail == null) {
throw new ClubNotFoundException();
}
clubRepository.increaseViewCount(clubId);
return ClubDetailServiceResponse.from(detail);
}
| 유실 | 실패율 | RPS | RPS 구간 | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|---|
| 0 (0%) | 0% | 589.6 | 549~631 | 165.4 | 221.3 | 328.7 | 518.1 |
DB 관련 지표
| 커밋/req | 커넥션획득/req | 점유ms/req | DB실행ms/req | SQL문장/req |
|---|---|---|---|---|
| 1.0 | 1 | 16.4 | 14.2 | 3.0 |
의외로 비관적 락에 비해서 생각보다 지연 분포가 넓었다.
다만 점유 시간이나 DB 실행시간, SQL 실행의 수는 다른 대안들에 비하면 확연히 적음을 확인했다.
UPDATE (@Transactional 미적용)// @Transactional 없음
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
ClubDetailQueryResponse detail = clubRepository.getClubIntroduction(clubId, username);
if (detail == null) {
throw new ClubNotFoundException();
}
viewCountUpdater.increase(clubId); // @Transactional 이 붙은 별도 빈
return ClubDetailServiceResponse.from(detail);
}
| 유실 | 실패율 | RPS | RPS 구간 | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|---|
| 0 (0%) | 0% | 561.0 | 544~589 | 172.9 | 268.4 | 324.7 | 460.1 |
DB 관련 지표
| 커밋/req | 커넥션획득/req | 점유ms/req | DB실행ms/req | SQL문장/req |
|---|---|---|---|---|
| 3.0 | 3 | 16.3 | 15.0 | 3.0 |
생각보다 지표가 좋게 나와서 놀랐다.
트랜잭션 커밋이 3회, 커넥션 획득이 3회인 부분에 조금 의구심이 들어서 더 파헤쳐보았다.
OSIV = false
이 설정에 대해 간단하게 이해하자면, 트랜잭션이 끝나도 API 응답이 사용자에게 완료될 때까지 DB 커넥션을 반환하지 않는 설정인데, 트랜잭션 점유 시간으로 인한 성능 문제 때문에 이 설정을 꺼놓았다.
이 설정으로 인해, @Transactional을 미적용한 원자적 UPDATE는 사실상 쿼리를 한 번 호출할 때마다 커넥션을 받고 반납하게 된다.
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
// 리포지토리 내의 커넥션 획득 2회
ClubDetailQueryResponse detail = clubRepository.getClubIntroduction(clubId, username);
if (detail == null) {
throw new ClubNotFoundException();
}
// 커넥션 획득 3회째
viewCountUpdater.increase(clubId); // @Transactional 이 붙은 별도 빈
return ClubDetailServiceResponse.from(detail);
}
결론적으로는 커넥션이 사용 후 바로 바로 반납되기에 절대적인 커넥션 점유 시간은 당연히 줄어들게 된다.
다만 매 작업마다 커넥션 요청 및 반납 비용이나, 메서드 내의 일부 트랜잭션이 실패할 경우의 정합성 문제
(EX:디테일 조회는 null이 아닌 이상한 값인데, 조회 수는 다른 동아리가 증가함) 등을 고려했을 때 좋은 선택지는 아닌 것 같아, 결국 돌고 돌아 다시 @Transactional을 적용한 원자적 UPDATE를 채택하기로 했다.
@Transactional 을 넘어서 Redis INCR 이라거나 그 외의 방식도 알게됬지만 아직은 도입할 시기가 아닌 듯 싶어 보류했다.