Develop

[Spring Boot] 게시판 - 19. 비동기 처리로 사용자 응답 속도 개선하기 (feat. K6 성능 테스트) 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 19. 비동기 처리로 사용자 응답 속도 개선하기 (feat. K6 성능 테스트)

230801 2025. 12. 30. 05:09

안녕하세요~ .ᐟ 

 

회원가입 API를 테스트하다가 문제를 발견했습니다.

이메일 인증 코드를 전송하는데 3.5초나 걸려서, 사용자가 회원가입 버튼을 누르고 3.5초 동안 기다려야 하는 상황이었습니다.

 

오늘은 이 문제를 Spring의 @Async를 사용해 해결하고, K6로 성능을 측정한 내용을 작성해보겠습니다.

 

 

결과 요약 : 3.5초 -> 0.08초로 개선함

 


문제 상황 

: 회원가입 시 인증코드를 이메일로 발송하는 로직이 느림

// 동기 방식
회원가입 요청 → 인증코드 생성 → DB저장 → 이메일전송(5초 대기) → 응답
ex) 응답 시간: 5초

// 비동기 방식
회원가입 요청 → 인증코드 생성 → DB저장 → 이메일전송 요청 → 즉시 응답
                                   ↓ (백그라운드)
                                 실제 전송 (5초)
ex) 응답 시간: 0.5초

 


구현

: 이메일 발송 로직을 백그라운드에서 비동기로 처리하고, 사용자에게 바로 응답하는 방식으로 변경

 

1. @Async 설정

  • 이메일 전송처럼 시간이 오래 걸리는 작업을 별도 스레드에서 처리해서 메인 로직의 응답 속도를 개선해줍니다.
  • 이를 통해 사용자는 이메일 전송 완료를 기다리지 않아도 됩니다.
더보기
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);      // 기본 스레드 수
        executor.setMaxPoolSize(5);       // 최대 스레드 수
        executor.setQueueCapacity(100);   // 대기 큐 크기
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncExceptionHandler();
    }
}

 

# ThreadPool 설정 이유

- CorePoolSize(2): 
  - 이메일 발송은 I/O 작업 (CPU 많이 안 씀)
  - 평소 2개면 충분 (동시 회원가입 많지 않음)
  
- MaxPoolSize(5): 
  - 갑자기 회원가입 몰려도 5개까지 처리
  - 너무 많으면 메모리/DB 연결 고갈 위험
  
- QueueCapacity(100): 
  - 5개 스레드 모두 바쁘면 100개까지 대기
  - 초과 시 RejectedExecutionException 발생

 

  • 비동기 작업에 사용할 스레드 풀 설정
  • @Async 어노테이션 활성화
  • 스레드 풀 크기, 큐 크기 등 세부 설정

 

 

2. EmailService를 비동기로 변경

  • SMTP 서버 연결은 네트워크 I/O 작업으로 느리므로 EmailService에서 이메일 발송 로직을 비동기로 변경합니다.
    • 기존 동기 메서드에 @Async 추가
    • 별도 스레드에서 실행되도록 변경
더보기
@Service
@RequiredArgsConstructor
public class EmailService {
    
    @Async  // ← 어노테이션 추가
    public void sendVerificationEmail(String toEmail) {
        // 기존 코드 그대로
    }
}

 

* @Async 사용 시 주의사항

  • 동작하는 경우
    • public 메서드
    • 다른 Bean 에서 호출
  • 동작하지 않는 경우
    • private 메서드
    • 같은 클래스 내부 호출
    • 반환값이 필요한 경우 -> 반환값에 CompletableFuture 사용
 

 

 

3. 비동기 예외 처리

  • 비동기 메서드에서 발생한 예외는 호출자가 받을 수 없기 때문에, 별도 예외 핸들러를 생성했습니다.
    • 비동기 작업 중 예외 로깅
    • 필요시 재시도 또는 알림
  • 사용자 회원가입 응답이 이메일 전송 완료까지 기다릴 필요가 없으므로 회원가입은 완료되지만, 계정은 비활성 상태로 처리합니다.
    • 인증메일 발송 실패의 경우 나중에 '인증 메일 재발송' 기능으로 해결이 가능합니다.
더보기
@Component
public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        log.error("비동기 작업 예외 발생 - 메서드: {}, 예외: {}", 
                  method.getName(), ex.getMessage(), ex);
    }
}

 

 

 

 

4. @Scheduled로 정기 작업 구현

더보기

App - 스케쥴러 활성화

@SpringBootApplication
@EnableScheduling  // 추가
public class BoardApplication {
    public static void main(String[] args) {
        SpringApplication.run(BoardApplication.class, args);
    }
}

 

 

스케쥴러

@Slf4j
@Service
@RequiredArgsConstructor
public class ScheduledTasks {

    private final EmailVerificationRepository tokenRepository;

    @Transactional
    @Scheduled(cron = "0 0 3 * * *")
    public void cleanupExpiredTokens() {
        LocalDateTime now = LocalDateTime.now();

        long expiredCount = tokenRepository.countByExpiryDateBefore(now);

        if (expiredCount > 0) {
            tokenRepository.deleteByExpiryDateBefore(now);
            log.info("만료된 이메일 인증 토큰 {}개 삭제 완료: {}", expiredCount, now);
        } else {
            log.debug("삭제할 만료 토큰 없음: {}", now);
        }
    }

//    @Scheduled(fixedRate = 10000)
//    public void healthCheck() {
//        log.info("스케줄러 동작 확인: {}", LocalDateTime.now());
//    };
}

 

 

Repository

    // 인증기간 만료토큰 개수 세기
    long countByExpiryDateBefore(LocalDateTime dateTime);

    // 인증기간 만료토큰 삭제
    @Modifying
    @Transactional
    @Query("DELETE FROM EmailVerification e WHERE e.expiryDate < :dateTime")
    void deleteByExpiryDateBefore(@Param("dateTime") LocalDateTime dateTime);

왜 필요한가?

  • 만료된 인증 토큰 정기적으로 삭제 (DB 정리)
  • 매일 자정 통계 집계
  • 배치 작업 자동화

무슨 역할인가?

  • cron 표현식으로 실행 시간 지정
  • 스프링이 자동으로 스케줄링
 
cron 표현식
초 분 시 일 월 요일
0 0 3 * * * = 매일 3시 정각
0 */10 * * * * = 10분마다

 


TEST

1. IntelliJ HTTP Client로 측정

  • 비동기 처리 후 응답속도 개선 ( 3,544 ms -> 79ms (약 97.8%) )

(좌) 동기방식 / (우) 비동기 방식

 

  • 한번 더 테스트해보니 값이 달라졌습니다  .ᐟ
  • 아래와 같은 요인들로 값이 변동될 수 있다고 하니 참고해주세요!
    • JVM 워밍업 상태
    • DB 커넥션 풀 상태
    • OS 레벨 캐시
    • SMTP 연결 상태

비동기 방식 (2번째 시도 - 캐싱적용)

 

 

 


 

2. K6로 부하 테스트 진행

 

K6 란?

K6는 Grafana에서 만든 성능 테스트 도구입니다.

 

 

사용 방법

  • JavaScript로 테스트 시나리오 작성
  • 테스트 결과는 .json / .txt 파일로 추출 가능하며, K6 Cloud 웹에서 그래프로 볼 수 있습니다!
    • 저는 포트폴리오를 생각해서 K6 Cloud로 시각화 하는방법을 공부하기로 했습니다.

 


K6 설치

더보기
brew install k6

 

웹에서 Grafana 설정하기

더보기

1. Grafana 접속 + 계정 생성

https://grafana.com/

 

2. 좌측 메뉴바 - Teseting & synthetics 선택

 

3. Project -> Performance 클릭

 

4. 온보딩 가이드 -> Run a test from the CLI -> 2번 토큰 로그인 명령어 -> 터미널 복붙

k6 cloud login --token 본인의 토큰

 

성능 테스트 환경

더보기
하드웨어
- MacBook Air 15 (Apple M2)
- RAM: 16GB

소프트웨어
- Java 21.0.6
- Spring Boot 3.5.5
- MySQL 8.0 (로컬)
- HikariCP: max-pool-size=20

제약 사항
- 로컬 환경 (네트워크 지연 없음)
- 단일 서버 (로드밸런서 없음)
- 로컬 DB (네트워크 레이턴시 없음)

 


 

테스트 시나리오 작성하기

  • 저는 K6로 아래 4개 테스트를 단계별로 진행했습니다.
Functional Test (5VUs, 기능 테스트)
Load Test (50VUs, 부하 테스트)
Stress Test (100Vus, 스트레스 테스트)
Spike Test (급증 테스트)
 
 
 

* 시나리오 작성시 주의사항

  • 회원가입 테스트를 여러번 돌려야하므로, 유저 정보가 중복되지않도록 payload 에 세팅합니다.
  • K6 Cloud 무료플랜은 VU를 100명까지 제한하기 때문에, 시나리오에 target을 100 까지 테스트할 수 있습니다.
export default function () {
  const url = 'http://localhost:8080/auth/signup';

  const uniqueEmail = `load-${__VU}-${__ITER}-${Date.now()}@test.com`;

  const payload = JSON.stringify({
    email: uniqueEmail,
    password: 'Test1234!@',
    nickname: `load-${__VU}`,
    name: '부하테스트',
  });

 

 

 

* 테스트 시 주의사항

  • 배포가 안된 로컬 시나리오는 K6 Cloud 에서 테스트를 위해 접속할 수 없습니다.
    • 저는 로컬에서 먼저 돌려보고 배포하고 싶어서 로컬에서 테스트를 run 하고 결과를 Cloud로 out 해서 결과를 확인하는 방법을 사용했습니다.
  • 실행 명령어
# k6 run --out cloud 경로/시나리오명.js
k6 run --out cloud src/test/k6/functional/signup-test.js

 

 


 

시나리오 예시

더보기

부하테스트.js

import http from 'k6/http';
import { sleep, check } from 'k6';

// 트래픽 시뮬레이션
export const options = {
  stages: [
    { duration: '30s', target: 20 },  // 20명으로 증가
    { duration: '1m', target: 50 },   // 50명 유지
    { duration: '30s', target: 0 },   // 종료
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],
    http_req_failed: ['rate<0.1'],
  },
};

export default function () {
  const url = 'http://localhost:8080/auth/signup';

  const uniqueEmail = `load-${__VU}-${__ITER}-${Date.now()}@test.com`;

  const payload = JSON.stringify({
    email: uniqueEmail,
    password: 'Test1234!@',
    nickname: `load-${__VU}`,
    name: '부하테스트',
  });

  const res = http.post(url, payload, {
    headers: { 'Content-Type': 'application/json' },
  });

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });

  sleep(1);
}

 


 

테스트 결과 - 종합

  • 총 25,000개 이상 요청 처리 - 실패 0건 (100% 성공률)
  • Peak RPS: 64 req/s (초당 최대 64개 응답 처리)
  • 100 VUs까지 안정적으로 처리 (100개의 가상사용자가 스크립트 실행 -> sleep -> 반복)
테스트명 VUs 총 요청 성공률 P95 응답시간
Functional 5 10 100% 371ms
Load 50 2.8K 100% 221ms
Stress 100 18.1K 100% 721ms
Spike 100 4.3K 100% 909ms

 

테스트 결과 - 상세

더보기

1. 테스트 결과 - Signup 기능 동작 Test

 

2. 테스트 결과 - load Test

 

 

3. 테스트 결과 - Stress Test

 

4. 테스트 결과 - Spike Test



 

 

오늘 진행한 K6 테스트의 한계점

  • 회원가입 API 만 테스트 함
    • 실제론 사용자 행동패턴에 따른 테스트를 진행한다고 한다.
    • 예를들면 '회원가입 -> 로그인 -> 게시글 조회 -> 게시글 작성' 과 같은 형태로 시나리오를 작성해서 테스트 해봐야겠다.
  • 로컬 환경 테스트인 점
    • 네트워크 지연, 인프라 복잡도를 반영하지않았기 때문에 배포환경 테스트를 해봐야겠다.
  • 무료플랜 제한으로 테스트를 100 VUs 까지 해본 점
    • 실제 대규모 트래픽은 수천~수만건 이상으로 높을텐데 이부분을 테스트할 다른 방법을 찾아봐야겠다.

 

 


개선 효과

1. 사용자 관점

  • 응답 대기 시간 단축: 3.5초 → 0.08초 (44배 빠른 응답)
  • "회원가입 완료" 응답을 즉시 확인 가능

2. 시스템 관점

  • 이메일 전송 시간: 여전히 ~3초 소요
  • 단, 백그라운드에서 처리되어 응답 차단 안 함

 

 


느낀점

  • 프로젝트에 비동기처리를 도입해보고자 회원가입 시 이메일발송하는 로직만 건드려봤는데, 찾아보니 파일업로드, 외부 API 호출, 알림 발송, 로그 기록 등 많은 부분에서 비동기 처리를 사용하는 걸 알 수 있었다.
    • 오늘 작업에 많은 시간을 썼는데 '다른 비동기처리는 언제 적용하지?' 라는 생각과 '언제 입사할 실력이 되나..' 라는 생각..이 들었다 흑흑..
  • 포트폴리오에 기재할 성능개선 결과를 만들어보고 싶어서 K6 를 사용했는데 설정도 간편하고, 시각화돼서 첨부하기도 좋고, 결과값도 대시보드로 한번에 관리하기 편했다.
    • 오늘 작업으로 성능개선 사이클(측정 -> 분석 -> 개선 -> 검증)을 경험해본점이 취준생 입장으로 값지다고 생각한다.
  • 오늘은 단일 API 테스트를 진행했지만, 조만간 사용자 이용패턴을 몇개 짜서 테스트해보고싶다.
    • 스트레스 테스트할때 CPU 미친듯이 치솟아서 컴퓨터꺼지면 어떡하나 ~ 했는데 다행히 버텼다. 이건 스레드를 설정하거나 시나리오를 좀 손봐야겠다.