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

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

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

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


1초에 50번의 부하를 100번, 총 5000건의 요청을 발생시켰다.
Test Plan -> Add -> Sampler -> HTTP Request


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

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

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

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<>();
하지만 favoriteDrinks는 cascade 옵션이 붙어있어서, 실제로는 아래와 같은 쿼리문을 실행한다.
DELETE FROM user_favorite_drink_types WHERE user_id = ?;
즉, 단순 컬렉션 clear가 아니라 엔티티 제거로 해석된다.
정리하자면 낙관적 병행제어 실패(ObjectOptimisticLockingFailureException)가 발생하는 흐름은 아래와 같다.
DELETE FROM user_favorite_drink_types WHERE id = 123 같은 쿼리를 실행현재 상태에서 요청 과부화를 더 높여보자.
Thread 200


Thread 500



API 에서 발생한 에러로 인해 다른 API 에서도 에러율이 증가하고, 응답 시간이 크게 늘었다.
트랜잭션이 오래 걸림으로 인해, 커넥션 풀 점유 시간이 증가하고 같은 시점에 많은 요청이 들어오면 DB 커넥션 풀 고갈 -> 다른 API도 DB 접근을 못해서 느려짐 or 500 에러 발생
tomcat 스레드가 장기간 점유되면서 정상 API 요청도 스레드를 배정받기 못해서 timeout 발생
DB에 접근하려면 애플리케이션은 데이터베이스와의 연결(Connection)이 필요하다.
하지만 매번 연결을 만들고 끊는 것은 매우 비싸고 시간도 오래걸리기에 "미리 DB 연결을 여러 개 만들어 놓고" 요청이 들어올 때마다 그 중 하나를 빌려주는 구조로 진행된다.
HikariCP를 통해 커넥션 풀을 관리한다.HikariCP가 커넥션 풀에서 남는 커넥션 1개를 빌려줌시나리오
1. user.setFavoriteDrinks() 내에서 삭제, 삽입 대량 연산 수행
2. 동시에 수백~수천 개의 부하 테스트 요청
3. 각 요청마다 DB 트랜잭션이 끝나지 않고 커넥션을 점유
4. 커넥션 풀이 가득 참 -> 다른 요청은 대기하거나 실패
5. 다른 Get API 도 DB 연결 못해서 응답 지연, 심하면 500 에러 발생
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));
}

여전히 오류 발생..
// 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

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