동아리 상세 정보 조회 기능을 리팩토링하며 AI와 대화하던 중 받은 피드백이었다.
조회 수 필드 같은 경우에는 인기 점수 로직과도 관련이 있었기에, 다수의 트래픽이 몰리게 될 경우에는, 인기 동아리 표시에 신빙성이 떨어질 수도 있겠다는 생각이 들었다.
리팩토링 진행하는 김에, 타당한 방향으로 개선해보고자 실험을 진행했다.
실험 시작 전에는 혼자 생각했을 때는 아래 방법들을 고려했었다.
- 비관적 락
- 낙관적 락
- 원자적 업데이트 (
update쿼리)
이 조건에서, 원자적 업데이트 조건에 @Transactional 어노테이션 채택 여부를 분기하여 총 4가지 방식으로 k6 부하테스트를 진행해보기로 결정했다.
| 항목 | 값 |
|---|---|
| 부하 도구 | k6 (grafana/k6:latest), shared-iterations 실행기 |
| 부하 | 100 VU, 5,000 요청, think time 없음 |
| 경로 | k6 → nginx → Spring Boot app → PostgreSQL (모두 Docker 컨테이너) |
| 리소스 | app 2 vCPU / 2GB, db 2 vCPU / 1GB |
| 커넥션 풀 | HikariCP maximum-pool-size = 10 |
| 워밍업 | 매 측정 전 2,000 요청 (결과 폐기) |
처음에는 VU와 요청 수가 과하다고 생각했으나, 인프라에 지장이 가지 않으며 조회 수가 올라가지 않는 동시성 이슈를 확실하게 재현하기 위한 스모크 테스트 의도에 맞다고 판단되어 이 수치 그대로 변경 없이 진행했다.
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));
}
// Club.java
public void updateViewCount() {
viewCount += 1; // JVM 메모리 위에서의 증가
}
view_count = 100 인 상태에서 두 요청이 동시에 들어오면:
| 시각 | 트랜잭션 A | 트랜잭션 B | DB 상태 |
|---|---|---|---|
| t1 | SELECT → 100 읽음 | 100 | |
| t2 | SELECT → 100 읽음 | 100 | |
| t3 | 메모리에서 101 계산 | 100 | |
| t4 | 메모리에서 101 계산 | 100 | |
| t5 | UPDATE SET view_count = 101 | 101 | |
| t6 | UPDATE SET view_count = 101 | 101 |
위 표와 같이 club 엔티티의 viewCount라는 하나의 자원에 두 트랜잭션이 동시 접근했을때, 의도대로라면 viewCount는 102가 되어야 하지만, 둘 다 100일 때 접근했기에, 여기서 updateViewCount() 메서드를 거쳐서 101이 되는 문제가 있었다.
viewCount 필드는 별도로 API 응답에 노출되는 정보가 아니었기에, 아래와 같은 플로우를 거쳐 실제 증가량을 측정했다.
① 대상 행 시드 (전용 UUID, mock 데이터와 겹치지 않게)
② 워밍업 2,000 요청 (결과 폐기)
③ UPDATE clubs SET view_count = 0 WHERE id = '...' ← 리셋
④ k6 본 측정 5,000 요청
⑤ SELECT view_count FROM clubs WHERE id = '...' ← 실제 증가분
⑥ 유실 = (2xx 응답 수) - (실제 증가분)
개선 전 지표
| 유실 | 실패율 | RPS | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|
| 4,507 / 5,000 (90.14%) | 0% | 311.7 | 300.1 | 481.5 | 622.4 | 1091.1 |
실패하는 요청은 없었으나, 누락되는 증가분이 매우 많았다.
아래와 같은 방식으로 적용하여 지표를 측정했다.
// ClubRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Club c WHERE c.id = :clubId")
Optional<Club> findByIdForUpdate(@Param("clubId") String clubId);
| 유실 | 실패율 | RPS | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|
| 0 (0%) | 0% | 245.9 | 401.1 | 480.7 | 503.6 | 517.4 |
확실히 이전보다 유실이 확 줄어든게 눈에 띈다.
다만 RPS가 좀 줄어들었다는 점이 아쉬웠다.
version 컬럼과 재시도 로직(3회)을 추가하여 지표를 측정했다.
// Club.java (version 컬럼 추가)
@Version
private Long version;
// 재시도 횟수
private static final int MAX_RETRY = 3;
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
return transactionTemplate.execute(status -> {
Club club = clubRepository.findById(clubId)
.orElseThrow(ClubNotFoundException::new);
club.updateViewCount(); // 커밋 시 version 을 함께 검사·증가
return ClubDetailServiceResponse.from(
clubRepository.getClubIntroduction(clubId, username));
});
} catch (ObjectOptimisticLockingFailureException e) {
log.warn("optimistic lock conflict attempt={}", attempt);
}
}
throw new IllegalStateException("조회수 증가 재시도 초과");
}
| 유실 | 실패율 | RPS | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|
| 0 (0%) | 67.36% | 150.8 | 699.6 | 1011.5 | 1205.1 | 1779.1 |
예상했지만, 충돌이 잦았기에 재시도 3번에도 실패한 요청이 매우 많았다.
특이한 점은 유실이 0 이었다는건데, 성공한 요청 기준으로는 정상작동하지만,
성능 지표도 그렇고 사용자 입장에서는 3번의 요청 중 2번이 실패한다는 점에서는 채택할 이유가 없었다.
우선 먼저, @Transactional을 적용한 원자적 업데이트 먼저 지표를 측정했다.
// ClubRepository.java
@Modifying
@Query("UPDATE Club c SET c.viewCount = c.viewCount + 1 WHERE c.id = :clubId")
int increaseViewCount(@Param("clubId") String clubId);
트랜잭션(@Transactioal) 적용
@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 | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|
| 0 (0%) | 0% | 601.4 | 157.5 | 244.8 | 325.0 | 479.1 |
트랜잭션(@Transactioal) 미적용
| 유실 | 실패율 | RPS | p50 | p95 | p99 | max |
|---|---|---|---|---|---|---|
| 0 (0%) | 0% | 319.4 | 299.2 | 472.5 | 586.0 | 823.8 |
이상하게도 트랜잭션을 미적용했을 때보다 적용했을 때 훨씬 성능이 좋았다.
| 변형 | 유실 | 실패율 | RPS | p50 | p95 | p99 | max | 스키마 변경 |
|---|---|---|---|---|---|---|---|---|
| 현행 | 90.14% | 0% | 311.7 | 300.1 | 481.5 | 622.4 | 1091.1 | – |
| 원자적 UPDATE - 트랜잭션 사용 | 0% | 0% | 601.4 | 157.5 | 244.8 | 325.0 | 479.1 | 불필요 |
| 원자적 UPDATE — 트랜잭션 미사용 | 0% | 0% | 319.4 | 299.2 | 472.5 | 586.0 | 823.8 | 불필요 |
| 낙관락 (@Version, 재시도 3회) | 0% | 67.36% | 150.8 | 699.6 | 1011.5 | 1205.1 | 1779.1 | 필요 (version 컬럼) |
| 비관락 (SELECT … FOR UPDATE) | 0% | 0% | 245.9 | 401.1 | 480.7 | 503.6 | 517.4 | 불필요 |
우선 당장의 판단으로는, @Transactional 을 채택한 원자적 UPDATE가 최선으로 보였다.
실험 진행 후, 복기하며 궁금한 점을 몇 가지 정리해보았다.
- 왜 트랜잭션을 적용한 원자적 UPDATE의 지표가 더 양호했는가?
-> 우선 이 프로젝트의OSIV설정이false로 되어 있는 것과 관련 있을 수 있다는 생각이 들었다.- DB 관련한 수치는 어떻게 나올까?
-> 현재 측정했던 수치는 단순하게 웹 - 애플리케이션 계층에서의 응답 속도였기에, DB에 가해지는 부하 등을 고려하지 못했다.- 서버 재실행 시, DB 컨테이너는 그대로 떠있었다. 이로 인한 영향은?
-> DB 캐시가 적용되는 등의 변수를 고려하지 못했다.
의문점 부분들을 고려하고 나니, 살짝 디테일이 부족한 실험이라는 생각이 들었다.
다음엔 이 부분들을 고려하여, 재실험을 진행해보려한다.
물론 빠르게 해결방안을 선택하는 것은 좋지만, 단순히 빠르게 선택한다는 건 나는 잘못됬다고 생각한다.
차라리 타당하게 고민하고 선택하는 방향이 훨 났다고 느낀다.
(물론 빠르고 타당하다면 좋겠지만..아직은 스스로가 미숙한 것 같다.)