ThreadLocalRandom을 사용해 성능 개선하기
문제
현재 이메일 인증 랜덤 코드를 발생할때 요청 이후 인증코드 발송까지 꽤 오랜시간이 걸리는데, 클라이언트에서 인증코드 발송 요청을 보낸 후 요청이 성공했다는 서버의 응답이 오면 아래와 같은 팝업을 띄운다.
문제는 서버에서 response자체가 오래걸려 인증 메일 발송 완료 팝업
이 뜨는데 오래 걸리고, 인증 메일 발송 버튼이 계속 활성화 되어 유저가 여러번의 인증 메일 발송을 요청하는 문제가 있었다.
즉, 서버의 응답 시간이 늦어 유저가 여러 요청을 하게된 것이다.
우선적으로 프론트엔드에서 응답이 오지 않더라도 팝업을 띄우고 버튼을 비활성화하는 방법을 마련했지만, 서버측에서도 요청시간을 줄일 필요를 느꼈다.
Random 클래스 사용
현재는 java.util.Random
라이브러리를 사용해 아래와 같은 코드를 사용 중이다.
private String getVerificationCode() {
try {
Random random = SecureRandom.getInstanceStrong();
StringBuilder builder = new StringBuilder();
for (int i = 0; i < CODE_LENGTH; i++) {
builder.append(random.nextInt(CODE_BOUNDARY));
}
return builder.toString();
} catch (NoSuchAlgorithmException e) {
throw new AppException(ErrorCode.CREATE_VERIFY_CODE_FAIL);
}
}
java.util.Random의 Random을 사용하는데 동시 요청 상황, 멀티 스레드 환경에서 성능 문제가 발생할 수 있다.
실제 Random의 구현 부를 보면 AtomicLong을 사용해 seed를 구한다. 또한 구현부의 next()메서드는 아래와 같이 구현되어있는데,
protected int next(int bits) {
long oldseed, nextseed;
AtomicLong seed = this.seed;
do {
oldseed = seed.get();
nextseed = (oldseed * multiplier + addend) & mask;
} while (!seed.compareAndSet(oldseed, nextseed));
return (int)(nextseed >>> (48 - bits));
}
compareAndSet()
이 존재한다.
CAS(Compare And Set(Swap)이란?
현재 내에서 기존 자원이 변경되었는지를 확인하고 변경되지 않았다면 다시 Set함
무엇을 비교하지?
특정 자원에 대해 내가 기존에 읽었던 값과 현재 읽는 값을 비교함.
(여기선 seed의 값을 비교)
무엇을 변경함?
- 내가 알던 seed와 현재 읽은 seed가 동일하다면 새로운 seed로 변경
장점과 단점
장점:
- 락(locking)을 사용하지 않기 때문에, 락으로 인한 성능 저하가 없음
- 교착 상태(deadlock)의 위험이 없음.
단점:
- 바쁜 대기(busy-wait) 상태가 발생할 수 있어, 많은 계산 자원을 소모할 수 있음
- ABA 문제: CAS 연산 동안 값이 A에서 B로 변경되었다가 다시 A로 변경될 경우, 연산이 성공적으로 진행된다. 변경이 되었어도 내가 읽는 시점의 값이 동일하기에 변경이 없었다고 판단함.
- 루프 스핀(Spin Loop): CAS 연산이 실패하면, 일반적으로 성공할 때까지 반복해서 시도합니다. 이러한 반복적인 시도는 CPU의 시간을 낭비하게 만들 수 있음
결과적으로 Random 라이브러리를 사용하면 AtomicLong seed가 공유자원이기에 여러 스레드에서 동시에 읽고 변경할 수 있는 문제가 발생할 수 있다.
따라서, 이를 해결하기 위해선 Thread자체 변수인 Local값을 활용하는 ThreadLocalRandom
을 사용해 문제를 방지해야한다.
적용하기
실제, 코테이토 사이트에서 위와 같은 코드로 동작할 땐 요청 전송 → 응답까지 오랜 시간이 걸린다.
서버의 응답을 기다리는 시간이… 어 .. 음.. 한 요청에 3초면… 문제가 많지
ThreadLocalRandom
위에선 seed값이 모든 스레드에서 공통으로 사용되기에 동시성 문제가 발생했다.
하지만, ThreadLocalRandom을 사용하게 되면 스레드별로 공유하지 않는 별도의 seed값을 가지고 있기 때문에 동시성 문제에서 벗어날 수 있다.
따라서 이를 개선하기 위해 ThreadLocalRandom 으로 변경해보자.
private String getVerificationCode() {
final ThreadLocalRandom random = ThreadLocalRandom.current();
StringBuilder builder = new StringBuilder();
for (int i = 0; i < CODE_LENGTH; i++) {
builder.append(random.nextInt(CODE_BOUNDARY));
}
return String.valueOf(builder);
}
변경 후 개선된 시간
변경 후 1개의 요청을 기준으로 시간은 크게 변화되지 않았다. 아마 SMTP 내부 문제인 것 같다.
K6 테스트
사실 하나의 요청만 보낸 것이라 이렇게 큰 차이가 없지만 여러 요청이 온다면 분명 시간 문제가 발생하지 않을까 싶었다.
// ThreadLocalRandom 활용
@GetMapping("/local")
public ResponseEntity<String> threadLocalRandom(){
final ThreadLocalRandom random = ThreadLocalRandom.current();
StringBuilder builder = new StringBuilder();
for (int i = 0; i < CODE_LENGTH; i++) {
builder.append(random.nextInt(CODE_BOUNDARY));
}
System.out.println(builder);
return ResponseEntity.ok().body(String.valueOf(builder));
}
// 일반 Random 활용
@GetMapping
public ResponseEntity<String> justRandom() {
final Random random = new Random();
StringBuilder builder = new StringBuilder();
for (int i = 0; i < CODE_LENGTH; i++) {
builder.append(random.nextInt(CODE_BOUNDARY));
}
System.out.println(builder);
return ResponseEntity.ok().body(String.valueOf(builder));
}
이를 확인하기 위해 ‘이메일 인증 코드를 발급’하는 2가지 로직 (Random, ThreadLocalRandom)을 위와 같이 작성했다.
작성 후 아래의 k6 테스트를 통해 10초 동안 가상의 10000명의 유저의 요청이 올 경우의 응답 시간을 측정해봤다.
import http from 'k6/http';
import { check, group } from 'k6';
import { Rate } from 'k6/metrics';
const failRate1 = new Rate('fail_rate_api_1');
const failRate2 = new Rate('fail_rate_api_2');
export const options = {
stages: [
{ duration: '10s', target: 10000 }, // 10초 동안 10000명의 가상 사용자
],
};
export default function () {
group('API 1 Test', function () {
const res = http.get('http://localhost:8080/api/random/local');
check(res, { 'status was 200': (r) => r.status === 200 });
failRate1.add(res.status !== 200);
});
group('API 2 Test', function () {
const res = http.get('http://localhost:8080/api/random');
check(res, { 'status was 200': (r) => r.status === 200 });
failRate2.add(res.status !== 200);
});
}
결과
Metric | API 1 | API 2 |
---|---|---|
Success Rate | 99.40% | 99.28% |
Failure Rate | 0.59% | 0.71% |
Avg Response Time | 132.56ms | 148.93ms |
Max Response Time | 2.15s | 2.01s |
90% Response Time | 287.36ms | 341.78ms |
Group Duration (Avg) | 277.94ms | 328.33ms |
Max Group Duration | 27.56s | 30.04s |
Data Received (kB/s) | 942 kB/s | 823 kB/s |
Data Sent (kB/s) | 760 kB/s | 622 kB/s |
VUs (Min) | 100 | 169 |
VUs (Max) | 9614 | 9578 |
확인 결과 ThreadLocalRandom을 활용한 경우에 평균 응답 시간이 18ms 가량 더 빨랐다. 별도의 메서드를 호출하지 않고, 단순 6자리 코드 발급 로직이 이정도 차이가 난다면 앞 뒤에 이메일 인증 관련 다른 로직이 붙을 때 더 많은 차이가 날 것이라 생각한다. 이는 장기적으로 운영환경에 모니터링 툴을 달아 확인해볼 예정이다.
결론
흔히 유저가 적은 프로젝트를 하다보면 ‘문제 없이 잘 돌아가는데?’라는 생각을 하기 쉽다. 특히 요즘은 GPT를 활용해 로직을 짜면 돌아가는 코드는 짜기 쉽다. 하지만 이러한 성능 개선포인트는 별도로 주 언어에 대해 깊게 공부하지 않으면 알 수 없는 포인트이다. 백엔드 개발자로 성능 개선에 대한 고민을 위해 프레임워크, CS지식을 아는 것도 중요한만큼 주 언어에 대한 공부도 깊게 해야한다는 것을 다시 한번 느꼈다.
참고자료
https://velog.io/@sojukang/Random-대신-ThreadLocalRandom을-써야-하는-이유
'프로젝트 > COTATO.KR' 카테고리의 다른 글
지속 성장 가능한 코드: import문도 코드이다. (0) | 2024.08.13 |
---|---|
Refresh Token Rotation (0) | 2024.07.21 |
선착순 퀴즈 프로젝트 V1 회고 (2) | 2024.07.08 |
선착순 로직 개선기 (2) - Redis 분산락 (0) | 2024.07.05 |
선착순 로직 개선기 (1) - 낙관적 락 활용 (1) | 2024.07.05 |