부하테스트, 낙관적 병행 제어 실패 예외 디버깅

이희수·2025년 5월 26일

Cafeboo

목록 보기
1/5

cafeboo 서비스 v1 배포가 끝났다.

v1 배포는 단일 인스턴스 기반의 간단한 클라우드 아키텍처를 가지고 있다.

단일 인스턴스 안에 FE, BE, AI, DB가 모두 들어가 있기 때문에 부하테스트에 좋지 못한 성능을 보일것으로 예상된다.

부하테스트 도구로는 Jmeter를 사용했다.

Thread 그룹 생성

Test Plan -> Add -> Threads(Users) -> Thread Group

  • Number of Threads(Users): 스레드 수 (유저 수)
  • Ramp-up period (seconds): 지정된 유저가 모두 로딩될 시간
  • Loop Count: 반복 횟수

1초에 50번의 부하를 100번, 총 5000건의 요청을 발생시켰다.

Sampler 생성

Test Plan -> Add -> Sampler -> HTTP Request

POST 요청시 추가 설정

Post로 요청시에는 HTTP Header 설정을 추가로 해주어야 한다.
Sampler -> Add -> Config Element -> HTTP Header Manager

Add를 클릭하여 Content-type: application/json 값을 넣어준다.

로그인 이후 로직이기에 Bearer Token 도 같이 넣어준다.

Listner 추가

Thread Group -> Add -> Listner -> 원하는 형식 추가

우선 결과물을 그래프 형태로 시각화해서 보여주는 Aggregate Graph 와 통계 테이블을 제공해주는 Summary Report 를 리스너로 추가해주었다.

테스트

v1에 테스트 가능한 api들을 구성한 후, 각 api 마다 1번씩 요청을 해보았다.

부하테스트 결과는 아래와 같다.

평균 응답 시간이 144ms로 아직은 1건씩의 요청이라 응답이 매우 빠름을 확인할 수 있다.

api별 응답은 카페인 섭취 등록이 576 ms 로 가장 느렸고, 단순 조회 api는 100ms 이하로 나타남

이번엔 5명의 유저가 5회 요청을 보내도록 해보았다.

이번에도 단순 조회요청은 100ms 이하로 나타났지만, 등록과 같은 요청은 1000ms 이상으로 1건의 요청보다 응답시간이 늘어났다.

등록 요청의 편차도 늘어난 것을 확인해 볼 수 있다.

이번엔 Thread를 50으로 늘려보았다.

전체 평균 응답시간이 1626ms, 성능 표준편차가 800~1600ms 수준으로 발생했으며 유저 카페인정보 수정 api는 18.4% 의 에러율을 보여줬다.

로그를 확인해 보니 아래와 같았다.

org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.ktb.cafeboo.domain.user.model.UserFavoriteDrinkType#1081]
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:329)
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:256)
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244)
	at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:566)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:795)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:758)
	at 
 ... 이하 생략

해당 예외는 동일한 엔티티(같은 ID)를 두 개 이상의 트랜잭션이 동시에 수정하려고 할 때 발생한다.

유저 카페인 정보 수정 api는 아래의 메서드를 호출한다.

// domain/user/model/User.java

public void setFavoriteDrinks(List<UserFavoriteDrinkType> favorites) {
        this.favoriteDrinks.clear(); // 기존 관계 제거
        this.favoriteDrinks.addAll(favorites);
    }

겉보기에는 단순히 list를 clear 하고 추가하는 update와 동일한 로직처럼 보인다.

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserFavoriteDrinkType> favoriteDrinks = new ArrayList<>();

하지만 favoriteDrinkscascade 옵션이 붙어있어서, 실제로는 아래와 같은 쿼리문을 실행한다.

DELETE FROM user_favorite_drink_types WHERE user_id = ?;

즉, 단순 컬렉션 clear가 아니라 엔티티 제거로 해석된다.

정리하자면 낙관적 병행제어 실패(ObjectOptimisticLockingFailureException)가 발생하는 흐름은 아래와 같다.

  1. 카페인 정보 수정 api가 호출되어 clear() 호출
  2. clear() 는 해당 row에 대해 DELETE 쿼리 실행 예약
  3. 동시에 다른 트랜잭션이 동일 user_id로 clear() 호출
  4. 처음 트랜잭션이 먼저 commit 하여 해당 row를 삭제
  5. 두번째 트랜잭션이 clear()로 인해 DELETE FROM user_favorite_drink_types WHERE id = 123 같은 쿼리를 실행
  6. 그러나 이 row는 삭제되었기에, Hibernate는 데이터가 예상 상태와 다르다 판단하고 낙관적 병행제어 실패 예외를 발생시킴

현재 상태에서 요청 과부화를 더 높여보자.

Thread 200


  • 총 요청 수: 14,000 건
  • 전체 API 평균 응답 시간: 8017ms
  • 총 처리량 23.9 req/sec

Thread 500

  • 총 70,138 건의 요청 처리
  • 전체 평균 응답 시간: 14.05 초
  • 전체 에러율: 21.92%
  • 총 처리량 25.7 req/sec

API 에서 발생한 에러로 인해 다른 API 에서도 에러율이 증가하고, 응답 시간이 크게 늘었다.

트랜잭션이 오래 걸림으로 인해, 커넥션 풀 점유 시간이 증가하고 같은 시점에 많은 요청이 들어오면 DB 커넥션 풀 고갈 -> 다른 API도 DB 접근을 못해서 느려짐 or 500 에러 발생

tomcat 스레드가 장기간 점유되면서 정상 API 요청도 스레드를 배정받기 못해서 timeout 발생

💡커넥션 풀이란?

DB에 접근하려면 애플리케이션은 데이터베이스와의 연결(Connection)이 필요하다.
하지만 매번 연결을 만들고 끊는 것은 매우 비싸고 시간도 오래걸리기에 "미리 DB 연결을 여러 개 만들어 놓고" 요청이 들어올 때마다 그 중 하나를 빌려주는 구조로 진행된다.

  • Spring Boot 에서는 일반적으로 HikariCP를 통해 커넥션 풀을 관리한다.

🎯커넥션 풀의 동작 구조

  1. 서버 시작 시 DB와 연결된 커넥션 N개 생성
  2. 사용자가 API 요청 (ex /user/update)
  3. HikariCP가 커넥션 풀에서 남는 커넥션 1개를 빌려줌
  4. 트랜잭션 완료 시 -> 커넥션 반환
  5. 다시 다른 요청에서 그 커넥션을 재사용

🚨문제 상황: 낙관적 병행제어 실패 예외 & 커넥션 고갈

시나리오
1. user.setFavoriteDrinks() 내에서 삭제, 삽입 대량 연산 수행
2. 동시에 수백~수천 개의 부하 테스트 요청
3. 각 요청마다 DB 트랜잭션이 끝나지 않고 커넥션을 점유
4. 커넥션 풀이 가득 참 -> 다른 요청은 대기하거나 실패
5. 다른 Get API 도 DB 연결 못해서 응답 지연, 심하면 500 에러 발생

💀해결 시도

1. 명시적 flush

clear() 후 flush 를 통해 DB에 즉시 반영, 그 후 새로운 값 추가

// domain/user/model/User.java
public void setFavoriteDrinks(List<UserFavoriteDrinkType> favorites, Runnable flushHandler) {
        this.favoriteDrinks.clear(); // 기존 관계 제거
        flushHandler.run();
        this.favoriteDrinks.addAll(favorites);
    }
// domain/user/service/UserCaffeineInfoService.update

if (!favoriteDrinkTypes.isEmpty()) {
                user.setFavoriteDrinks(favoriteDrinkTypes, () -> userRepository.saveAndFlush(user));
            }

여전히 오류 발생..

2. 차집합 기반 삭제 + 덧붙이기

// domain/user/service/UserCaffeineInfoService.java

List<String> newNames = Optional.ofNullable(request.userFavoriteDrinks())
        .orElse(Collections.emptyList())
        .stream()
        .filter(StringUtils::hasText)
        .distinct()
        .toList();

List<UserFavoriteDrinkType> existing = user.getFavoriteDrinks();

// 삭제: 기존 중 새 리스트에 없는 엔티티만 제거
Iterator<UserFavoriteDrinkType> it = existing.iterator();
while (it.hasNext()) {
    UserFavoriteDrinkType fav = it.next();
    if (!newNames.contains(fav.getDrinkType().getName())) {
        it.remove();  // orphanRemoval에 의해 DELETE
    }
}

// 추가: 새 리스트 중 기존에 없는 이름만 INSERT
for (String name : newNames) {
    boolean alreadyExists = existing.stream()
            .anyMatch(fav -> fav.getDrinkType().getName().equals(name));
    if (!alreadyExists) {
        DrinkType dt = drinkTypeRepository.findByName(name)
                .orElseGet(() -> drinkTypeRepository.save(new DrinkType(name)));

        UserFavoriteDrinkType fav = new UserFavoriteDrinkType();
        fav.setUser(user);
        fav.setDrinkType(dt);
        existing.add(fav);  // Cascade.ALL로 자동 persist
    }
}

기존에는 전부 삭제하고, 다시 생성했다면, 차집합 기반의 일치하지 않는 항목만 삭제하고 새로운 항목을 추가하는 방식으로 불필요한 삭제를 방지하였다.

에러율이 18.4% 였던 Thread 50 환경에서 에러율 0%을 보여준다.

요청을 더 높혀보자

Thread 200

  • 에러율 21.92% -> 0.03%
  • 평균 응답시간 14.05초 -> 4.44초
profile
백엔드 개발자로 살아남기

1개의 댓글

comment-user-thumbnail
2025년 5월 27일

낙관적 병행제어 실패.. 좋은 개념 알아갑니다

답글 달기