난 현재 springboot 웹서버를 JMeter로 성능 테스트 하던 도중 문제가 발생하였다.
단일 스레드로는 문제가 없이 테스트가 되는데 멀티 스레드 환경에서는 문제가 발생한다는 것이다.
이를 해결하기 위해 아래와 같은 전략들이 있음을 알게되었고 여기선 첫번째 전략인 로깅 강화를 진행 해보려고 한다.
로깅 강화: 오류가 발생하는 지점에 대한 로깅을 강화하세요. 스택 트레이스, 입력 값, 쓰레드 이름 및 ID 등 중요한 정보를 포함해야 합니다. 이를 통해 동시성 문제의 원인을 추적하는 데 도움이 될 수 있습니다.
스레드 덤프 수집: 문제가 발생할 때의 스레드 덤프(thread dump)를 수집하세요. 이는 현재 실행 중인 스레드, 그 상태, 스택 트레이스 등의 정보를 제공하며, 락 경쟁, 데드락 등의 문제를 찾는 데 유용합니다.
동시성 도구 활용: Java의 java.util.concurrent 패키지와 같은 동시성 도구를 사용하여 코드의 동시성을 개선하고 문제를 해결할 수 있습니다.
코드 리뷰: 문제가 발생하는 부분의 코드를 다시 검토하고, 동기화 문제, 데드락 가능성, 공유 자원의 접근 문제 등이 있는지 확인하세요.
단위 테스트: 문제가 발생하는 부분에 대한 단위 테스트를 작성하고, 특히 동시성 문제를 감지할 수 있도록 스레드를 여러 개 사용하여 테스트를 수행합니다.
스트레스 테스트: 동시 사용자나 요청을 많이 발생시켜 시스템에 부하를 주는 테스트를 수행합니다. 이를 통해 실제 운영 환경에서 발생할 수 있는 문제를 사전에 찾아낼 수 있습니다.
동시성 문제 해결 도구 활용: FindBugs, JCStress 같은 도구들은 Java 코드에서 동시성 문제를 찾아내는 데 도움을 줍니다.
문제의 패턴 파악: 멀티 스레드 환경에서 자주 발생하는 문제 패턴(예: 경쟁 조건, 데드락)을 알고 있으면 문제의 원인을 더 빠르게 파악할 수 있습니다.
JMeter로 성능 테스트를 진행할 때 서버에 문제가 발생하는 경우 다양한 원인이 있을 수 있다.
여러 쓰레드를 동시에 사용하면서 발생하는 문제를 파악하기 위해 아래 처럼 로깅 클래스를 생성해서 문제 로직에 사용하면 원인을 파악하는데 도움이 될 수 있지 않을까? 싶다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PerformanceLoggingUtil {
private static Logger getLogger(Class<?> clazz) {
return LoggerFactory.getLogger(clazz);
}
public static void logPerformanceInfo(Class<?> clazz, String message) {
Logger logger = getLogger(clazz);
String threadName = Thread.currentThread().getName();
long threadId = Thread.currentThread().getId();
long timestamp = System.currentTimeMillis();
String formattedMessage = String.format("[Timestamp: %d, Thread Name: %s, Thread ID: %d] %s", timestamp, threadName, threadId, message);
if (logger.isInfoEnabled()) {
logger.info(formattedMessage);
}
}
public static void logPerformanceError(Class<?> clazz, String message, Throwable t) {
Logger logger = getLogger(clazz);
String threadName = Thread.currentThread().getName();
long threadId = Thread.currentThread().getId();
long timestamp = System.currentTimeMillis();
String formattedMessage = String.format("[Timestamp: %d, Thread Name: %s, Thread ID: %d] %s", timestamp, threadName, threadId, message);
if (logger.isErrorEnabled()) {
logger.error(formattedMessage, t);
}
}
}
오류 발생시 해당 쓰레드 정보가 보인다는 것을 알 수 있다 이를 통해 어떻게 해결하지?
구글링 해서 찾아보니
다중 스레드에서 문제가 발생하였는데 단일 스레드에서 정상 작동한다면, 동시성 문제가 있을 가능성이 크다고 한다.
그래서 동시성을 해결할 방법에 대해 탐구해 보았다.
몇 가지 방법을 알게 되었다.
트랜잭션 격리 수준을 더 높게 설정하기
Isolation.REPEATABLE_READ
대신 Isolation.SERIALIZABLE
을 사용하여 트랜잭션 격리 수준을 더 높게 설정할 수 있다.@Transactional(isolation = Isolation.SERIALIZABLE)
SERIALIZABLE
수준은 성능에 영향을 줄 수 있습니다. 따라서, 적용 후 성능 테스트를 반드시 수행하여야 합니다.특정 엔터티에 대한 동시 접근 제한
SELECT ... FOR UPDATE
쿼리를 사용하여 해당 엔터티에 대한 동시 접근을 제한한다.SELECT ... FOR UPDATE
를 사용하는 메서드를 구현합니다.애플리케이션 로직 재검토
synchronized
키워드나 ReentrantLock
등의 도구를 사용하여 동시성 문제를 해결할 수 있다.추가적인 예외 처리
NullPointerException
이 발생하는 곳에 더 구체적인 예외 처리를 추가하여 사용자에게 명확한 오류 메시지를 제공한다.Answer answer = answerRepository.findById(answerId)
.orElseThrow(() -> new CustomException("해당 ID에 대한 답변을 찾을 수 없습니다."));
CustomException
은 사용자 정의 예외로, 필요에 따라 만들어서 사용하면 됩니다.우선 문제가 발생하는 로직 과 부분이 확실하니 추가적인 예외 처리는 필요가 없을 것으로 판단된다.
그리고 1번은 성능이 낮아지는 이슈가 있으며 2번은 이전에 적용하였지만 해결하지 못한 방법이다.
따라서 3번 방법으로 먼저 시도하는게 합리적일 것 같다.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public MessageDto updateAnswer(AnswerRequestDto requestDto,Long answerId, User user) {
try{
PerformanceLoggingUtil.logPerformanceInfo(AnswerService.class, "설문지 응답 업데이트 시작");
Answer answer;
synchronized (answerRepository) { // 해당 로직 블록을 동시에 하나의 스레드만 실행할 수 있게 한다.
answer = answerRepository.findById(answerId).orElseThrow(() -> new NullPointerException("예외가 발생하였습니다."));
}
// Answer answer = answerRepository.findByIdForUpdate(answerId)
// .orElseThrow(() -> new IllegalArgumentException("해당 ID에 대한 답변을 찾을 수 없습니다."));
if (!answer.getUser().getUserId().equals(user.getUserId())){
throw new IllegalArgumentException("예외가 발생하였습니다.");
} // 사용자가 응답자가 아닐 시 에러 출력
if(answer.getSurvey().getMaxChoice() < requestDto.getAnswer()){
throw new IllegalArgumentException("예외가 발생하였습니다.");
} // 선택지에 없는 응답으로 변경 시 에러 출력
answer.update(requestDto.getAnswer());
MessageDto message = new MessageDto("수정이 완료되었습니다.");
PerformanceLoggingUtil.logPerformanceInfo(AnswerService.class, "설문지 응답 업데이트 완료");
return message;
}
catch(Exception e){
PerformanceLoggingUtil.logPerformanceError(AnswerService.class, "설문지 응답 업데이트 중 오류 발생", e);
throw e;
}
}
-> 여전히 다수의 오류가 발생한다.
뭐가 문제 일까?
synchronized를 사용해서 해당 로직 블록에 하나의 쓰레드만 실행 할 수 있도록 하였다
그런데 여전히 문제가 발생한다.
그럼 혹시 create로 answer가 만들어지기도 전에 요청을 해서 그런거 아닐까?
단일 쓰레드에서는 쓰레드가 하나라서 서버가 create 하는 속도가 빨라서 answer가 만들어지기 전에 create가 완료 되서 문제가 없었는데
점
다중 쓰레드에서는 쓰레드가 너무 많아 처리하는 속도가 느려서 create가 되지도 않았는데 update하려고 해서 말이다.
다중
단일
그런데 여기서 의문이 든다
왜 단일 쓰레드로 설정 했는데 왜 쓰레드 ID가 여러개지?
=> 곰곰히 생각해보니 이 로그는 서버의 로그다
즉 하나의 쓰레드로 요청을 하더라도 서버는 쓰레드 여러개로 처리가 가능하다
또한 보니 서버가 특정 쓰레드 ID들을 돌려서 쓰는걸로 확인 가능하다
그럼 다시 원래 문제로 돌아가자
create 로직을 synchronized로 감싼다면 문제를 해결 할 수 있지 않을까?
여전히 문제가 발생한다.
로그를 분석해보면
[nio-8080-exec-7] 스레드에서 "설문지 응답 업데이트 시작" 이라는 로그 메시지와 함께 Hibernate 쿼리가 3번 실행되었고
[io-8080-exec-10] 스레드에서 NullPointerException 오류가 발생하였다.
오류가 발생한 코드의 위치는 AnswerService.java의 58번째 줄이며 이전에 제시된 코드와 같은 부분이다.
즉 AnswerService의 updateAnswer 메서드 내에서 동일한 NullPointerException이 발생하였고
다른 스레드에서 동시에 이 메서드를 호출하고 있을 가능성이 있기에 문제가 발생한 걸로 보인다.
동시성 문제로 지금까지의 오류가 발생 하였을을 알 수 있다.
그럼 동시성 문제 해결을 위한 자료조사가 필요하다.
- **방법**
Java에서는 `synchronized` 키워드를 사용하여 메서드나 블록을 동기화 할 수 있다.
```java
public synchronized void b() {
// 동기화 블록 내의 코드
}
```
또는
```java
public void a() {
synchronized (lockObject) {
}
}
```
- **주의점**
너무 많은 코드를 동기화하면 성능이 저하될 수 있다.
이 방법을 사용했지만 여전히 동시성 문제가 발생하는 것을 알 수 잇다.
이번에는 Locking 방법을 사용해 보도록 하자
Locking
정의
Java의 java.util.concurrent.locks
패키지에 있는 Lock
인터페이스와 구현체들을 사용하여 명시적으로 락을 관리하는 방법이다.
방법
ReentrantLock
등의 구현체를 사용하여 락을 얻고 해제한다고 한다.
private final Lock lock = new ReentrantLock();
public MessageDto createAnswer(AnswerRequestDto requestDto, User user) {
lock.lock();
try {
Survey survey = surveyRepository.findById(requestDto.getSurveyId()).orElseThrow(() -> new NullPointerException("예외가 발생하였습니다."));
if (answerRepository.findByUserAndSurvey(user, survey).isPresent()) {
throw new IllegalArgumentException("예외가 발생하였습니다.");
} // 이미 선택한 설문지를 중복 응답 시 에러 출력
if (survey.getMaxChoice() < requestDto.getAnswer()) {
throw new IllegalArgumentException("예외가 발생하였습니다.");
} // 선택지에 없는 응답 시 에러 출력
Answer answer = new Answer(requestDto.getAnswer(), user, survey);
if (survey.getDeadline().isBefore(LocalDateTime.now())) {
throw new IllegalArgumentException("예외가 발생하였습니다.");
}
Answer savedAnswer = answerRepository.save(answer);
MessageDto message = new MessageDto("작성이 완료되었습니다.");
return message;
} finally {
lock.unlock();
}
}
주의점
항상 finally
블록에서 락을 해제해야 한다.
여전히 오류는 잡히지 않는다.
Semaphore
Semaphore는 동시에 액세스할 수 있는 스레드 수를 제한하는 데 사용되는 동시성 도구이다.
주로 한정된 자원을 여러 스레드가 동시에 사용하려 할 때 이를 제어하는 데 사용된다.
이를 이용해서 에러 줄이는 걸 시도해 보겠다
@Service
@RequiredArgsConstructor
public class AnswerService {
private final AnswerRepository answerRepository;
private final SurveyRepository surveyRepository;
private final Semaphore semaphore = new Semaphore(3); // 동시에 3개의 스레드만 허용
public MessageDto createAnswer(AnswerRequestDto requestDto, User user) throws InterruptedException {
semaphore.acquire(); // Semaphore 획득
try {
Survey survey = surveyRepository.findById(requestDto.getSurveyId()).orElseThrow(() -> new NullPointerException("예외가 발생하였습니다."));
if (answerRepository.findByUserAndSurvey(user, survey).isPresent()) {
throw new IllegalArgumentException("예외가 발생하였습니다.");
} // 이미 선택한 설문지를 중복 응답 시 에러 출력
if (survey.getMaxChoice() < requestDto.getAnswer()) {
throw new IllegalArgumentException("예외가 발생하였습니다.");
} // 선택지에 없는 응답 시 에러 출력
Answer answer = new Answer(requestDto.getAnswer(), user, survey);
if (survey.getDeadline().isBefore(LocalDateTime.now())) {
throw new IllegalArgumentException("예외가 발생하였습니다.");
}
Answer savedAnswer = answerRepository.save(answer);
MessageDto message = new MessageDto("작성이 완료되었습니다.");
return message;
} finally {
semaphore.release(); // Semaphore 해제
}
}
동시 접근 가능한 스레드를 3개로 설정 했을때 문제가 발생한다 여전히
그럼 1개로 수정해 보겠다
1개로 수정해도 여전히 오류가 발생한다
;;;;
근데 과연 이게 스레드의 문제일까?
스레드를 늘리고 ramp up time을 늘려 서버 부하를 줄였더니 오류역시 극단적으로 줄어들었다
서버에 짧은 시간 동안 부하를 주는 것이 문제 였던걸로 추정할 수 있을것 같다.
!!!
구글링을 해보니 @Transaction과 Syncronized를 같이 사용하면 데드락 문제가 발생할 수도 있다고 한다.
한번 트랜젝션 없이 Syncronized만 사용해보겠다.
그래도 여전히 오류가 발생한다.
혹시 survey와 answer가 양방향 관계라서 동시성 오류가 발생 하는거 아닐까?
그럼 다시 처음으로 돌아가자
트렌젝션 부터 다시 적용해보자
@Transactional(isolation = Isolation.SERIALIZABLE)
이번엔 answer 뿐만 아니라 survey에도 적용을 하였다.
@Transactional(isolation = Isolation.SERIALIZABLE)
public MessageDto createSurvey(SurveyRequestDto requestDto, User user) {
Survey survey = new Survey(requestDto, user);
surveyRepository.save(survey);
User savedUser = userRepository.findById(user.getUserId())
.orElseThrow(() -> new IllegalArgumentException("(임시) 일치하는 유저 없음"));
savedUser.addSurvey(survey);
return new MessageDto("작성이 완료되었습니다");
}
여전히 문제가 발생하는 것을 확인할 수 있다....
그럼 survey와 user만 성능테스트를 진행하고 answer는 테스트에서 제외하면 어떨까?
쓰레드를 이전 보다 높게 설정했음에도 불구하고 에러율은 현저히 낮은 것을 확인할 수 있다.
하지만 쓰레드를 이것보다 10배정도 많이 설정했을땐 아래 처럼 오류가 증가하는 것을 확인 할 수 있다.
하나하나 로그를 찍어보자
answer Entity에 다음 코드를 적용한다
@Override
public String toString() {
return "Answer{" +
"answerId=" + answerId +
", answerNum=" + answerNum +
", survey=" + (survey != null ? survey.getId() : "null") +
", user=" + (user != null ? user.getId() : "null") +
", version=" + version +
'}';
}
단일 스레드에서는 create -> update로 정상적으로 요청이 실행되지만
다중 스레드에서는 서버가 정상적으로 작동하지 않아서 순서가 보장 되지 않는다
=> constant Timer를 이용하면 순서 보장이 가능하다
하지만 여전히 문제가 발생한다.
다중 스레드 환경에서 쓰레드간에 병렬 처리로
처리가 완료 되는 순서가 보장되지 않기 때문이다.
스레드가 1, 2, 3, 4, 5로 시작 되더라도
DB에 저장이 완료되는 시간이 항상 1,2,3,4,5로 되지는 않는다
즉
스레드는 1, 3, 2, 4, 5 형태로 완료 되면 DB에서 각 요청에 대해
1, 3, 2, 4, 5 형태로 저장된다.
하지만 지금은 csv 파일을 읽어 업데이트할 survey Id를 가지고 오는데
이는 1, 2, 3, 4, 5로 고정 되어 있기에 오류가 발생한 것이다.
그래서 요청간의 순서를 보장하더라도 여전히 오류가 발생한다.
Jmeter 이전 요청 결과 다음 요청에 반영
우선 가장 먼저 기존에 메시지 형태로 반환 하던 거를 entity Dto로 바꾸어서 반환한다.
그리고 Json Extractor을 이전 요청 밑에 만든다
지금 반환 되는 형태가
아래와 같으므로
이런식으로 작성하면 된다.
사용은 아래처럼 하면 된다.
그럼 테스트를 돌려보자!!
아래처럼 오류없이 동작하는 것을 알 수 있다.
스레드를 100개로 늘려도 오류없이 동작한다!!!!