5/20 부하테스트 + 성능 최적화

dev_joo·2026년 5월 20일

부하테스트

K6

설치

brew install k6

k6 설정 파일

export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';

export const USER_POOL_SIZE = 100;

export function getTestUser(id) {
    // id 를 8자리로 패딩 → 010-XXXX-YYYY 형식
    const padded = String(id).padStart(8, '0');  // 00000001
    const middle = padded.slice(0, 4);            // 0000
    const last = padded.slice(4, 8);              // 0001

    return {
        loginId: `loadtest_user_${id}`,
        password: 'LoadTest!1234',
        email: `loadtest${id}@pagely.com`,
        nickname: `테스트유저${id}`,
        name: `테스트${id}`,
        phone: `010-${middle}-${last}`,           // 010-0000-0001
        birthDate: '1990-01-01',
        gender: id % 2 === 0 ? 'MALE' : 'FEMALE',
    };
}

export const HEADERS = {
    'Content-Type': 'application/json',
};

회원가입 테스트

k6 run k6/signup.js 
import http from 'k6/http';
import { check } from 'k6';
import exec from 'k6/execution';
import { BASE_URL, USER_POOL_SIZE, getTestUser, HEADERS } from './shared/config.js';

export const options = {
    scenarios: {
        signup: {
            executor: 'shared-iterations',
            vus: 10,
            iterations: USER_POOL_SIZE,
            maxDuration: '2m',
        },
    },
    thresholds: {
        'checks': ['rate>0.99'],
    },
};

export default function () {
    // 전역 iteration 번호 사용
    const userId = exec.scenario.iterationInTest + 1;
    const user = getTestUser(userId);

    const payload = JSON.stringify(user);
    const res = http.post(`${BASE_URL}/api/v1/users`, payload, {
        headers: HEADERS,
        responseCallback: http.expectedStatuses(201, 409),
    });

    const success = check(res, {
        'signup ok or already exists': (r) => r.status === 201 || r.status === 409,
    });

    if (!success) {
        console.error(`Signup failed: id=${userId}, status=${res.status}, body=${res.body}`);
    }
}
# 1. 기존 잔재 삭제
docker exec -it pagely-postgres psql -U postgre -d pagely_user \
  -c "DELETE FROM p_users WHERE login_id LIKE 'loadtest_%';"

# 2. 재실행
k6 run load-tests/signup.js

# 3. 검증
docker exec -it pagely-postgres psql -U postgre -d pagely_user \
  -c "SELECT count(*) FROM p_users WHERE login_id LIKE 'loadtest_%';"

# 결과:
-------
   100

로그인 부하테스트

import http from 'k6/http';
import { check } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
import { BASE_URL, USER_POOL_SIZE, getTestUser, HEADERS } from './shared/config.js';

// 커스텀 메트릭
const loginSuccessRate = new Rate('login_success_rate');
const loginDuration = new Trend('login_duration', true);
const loginErrors = new Counter('login_errors');

export const options = {
  scenarios: {
    ramp_up: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '30s', target: 10 },    // Warmup
        { duration: '1m',  target: 50 },    // Ramp to 50 VU
        { duration: '1m',  target: 100 },   // Ramp to 100 VU
        { duration: '2m',  target: 100 },   // Hold 100 VU
        { duration: '30s', target: 0 },     // Cool down
      ],
      gracefulRampDown: '10s',
    },
  },
  thresholds: {
    'http_req_duration{name:login}': ['p(95)<1000', 'p(99)<2000'],
    'login_success_rate': ['rate>0.99'],
    'http_req_failed': ['rate<0.01'],
  },
};

export default function () {
  // 100명 사용자 풀에서 무작위 선택
  const userId = Math.floor(Math.random() * USER_POOL_SIZE) + 1;
  const user = getTestUser(userId);

  const payload = JSON.stringify({
    loginId: user.loginId,
    password: user.password,
  });

  const res = http.post(`${BASE_URL}/api/v1/auth/login`, payload, {
    headers: HEADERS,
    tags: { name: 'login' },
  });

  // 메트릭 기록
  loginSuccessRate.add(res.status === 200);
  loginDuration.add(res.timings.duration);

  const success = check(res, {
    'status is 200': (r) => r.status === 200,
    'has accessToken': (r) => {
      try {
        const body = JSON.parse(r.body);
        return body.data && body.data.accessToken;
      } catch {
        return false;
      }
    },
  });

  if (!success) {
    loginErrors.add(1);
    if (__ITER < 5 || res.status !== 200) {
      // 처음 몇 번 또는 실패 시만 로깅 (콘솔 폭주 방지)
      console.error(`Login failed: id=${userId}, status=${res.status}, body=${res.body.substring(0, 200)}`);
    }
  }
}
k6 run k6/login-load.js 

Grafana 메트릭

- JVM Heap 사용량 — 점진적 증가
- HTTP Requests (RPS) — 부하 증가
- Response Time (P95) — latency 변화
- Tomcat Active Threads — 동시 처리
- DB Connection Pool — Redis / DB 의 풀 사용량

prometheus 직접 쿼리

# RPS (초당 요청)
rate(http_server_requests_seconds_count{application="authservice", uri="/api/v1/auth/login"}[1m])

# P95 응답 시간
histogram_quantile(0.95, 
  rate(http_server_requests_seconds_bucket{application="authservice", uri="/api/v1/auth/login"}[1m]))

# 에러율
rate(http_server_requests_seconds_count{application="authservice", status=~"5.."}[1m])



에러 x

부하 테스트 결과

설정

  • 도구: k6 v0.55
  • 시나리오: ramping-vus (0 → 100 VU 점진 증가)
  • 지속 시간: 5분
  • 대상: POST /api/v1/auth/login

결과

지표
총 요청3,738
RPS12.5
성공률100%
에러율0%
평균 응답 시간5.38s
P9510.36s
P9910.87s
Max11.22s

복잡한 계산 = BCrypt?


오류 발견 및 해결

유저 생성 테스트를 하고 100건의 유저 생성 이벤트가 발행되었는데,이벤트가 처리되지 않고 있었다.
알고보니 로컬에서 kafka 관련으로 환경변수 설정이 잘못되어 있었다.
env파일에 맞게 application 설정 파일을 수정하니 grafana에서도 정상적으로 해결된 모습을 확인 할 수 있었다.


성능 최적화

kafka 동기/비동기 성능 비교하기

Outbox의 published_at, created_at 필드 덕분에 DB에서 이벤트 처리에 걸린 시간을 측정할 수있었다.

-- Outbox 발행 완료까지 걸린 시간 측정
SELECT 
    count(*) as total,
    avg(EXTRACT(EPOCH FROM (published_at - created_at)) * 1000)::int as avg_ms,
    max(EXTRACT(EPOCH FROM (published_at - created_at)) * 1000)::int as max_ms
FROM p_outbox
WHERE published = true;

Outbox Poller는 HTTP 엔드포인트가 아니라 백그라운드 스케줄러라
모니터링 도구를 활용하려면 Outbox Poller 에 Micrometer 커스텀 메트릭을 추가 할 필요가 있었다.

우선 DB를 통해 측정해 보고, 모니터링 도구에서도 같게 나오는지 비교해보기로 했다.

DB 계산

1. DB로 성능 측정하기

-- Outbox 발행 완료까지 걸린 시간 측정
SELECT 
    count(*) as total,
    avg(EXTRACT(EPOCH FROM (published_at - created_at)) * 1000)::int as avg_ms,
    max(EXTRACT(EPOCH FROM (published_at - created_at)) * 1000)::int as max_ms
FROM outbox_events
WHERE published = true;

2. Grafana에서 보기

Outbox Poller 가 HTTP 엔드포인트가 아니라 백그라운드 스케줄러라

모니터링 도구까지 활용하려면 Outbox Poller 에 Micrometer 커스텀 메트릭을 추가 필요.

@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxPoller {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;
    private final MeterRegistry meterRegistry; // ← 추가

    @Scheduled(fixedDelayString = "${outbox.poll-interval-ms:5000}")
    @Transactional
    public void publishPendingEvents() {
        List<OutboxEvent> events = outboxRepository.findUnpublished(PageRequest.of(0, BATCH_SIZE));
        if (events.isEmpty()) return;

        // ← 처리 시간을 Prometheus 로 전송
        Timer.Sample sample = Timer.start(meterRegistry);

        // ... 발행 로직 ...

        sample.stop(Timer.builder("outbox.publish.duration")
            .tag("mode", "async") // 또는 "sync"
            .register(meterRegistry));

        // 성공/실패 카운터
        meterRegistry.counter("outbox.publish.success").increment(successCount);
        meterRegistry.counter("outbox.publish.failure").increment(failCount);
    }
}

회원가입 1000명 (k6)

// config.js
export const USER_POOL_SIZE = 1000;

...

// signup.js
import http from 'k6/http';
import { check } from 'k6';
import exec from 'k6/execution';
import { BASE_URL, USER_POOL_SIZE, getTestUser, HEADERS } from './shared/config.js';

export const options = {
    scenarios: {
        signup: {
            executor: 'shared-iterations',
            vus: 10,
            iterations: USER_POOL_SIZE,
            maxDuration: '2m',
        },
    },
    thresholds: {
        'checks': ['rate>0.99'],
    },
};

export default function () {
    const userId = exec.scenario.iterationInTest + 1;
    const user = getTestUser(userId);

    const payload = JSON.stringify(user);
    const res = http.post(`${BASE_URL}/api/v1/users`, payload, {
        headers: HEADERS,
        responseCallback: http.expectedStatuses(201, 409),
    });

    const success = check(res, {
        'signup ok or already exists': (r) => r.status === 201 || r.status === 409,
    });

    if (!success) {
        console.error(`Signup failed: id=${userId}, status=${res.status}, body=${res.body}`);
    }
}
# 기존 테스트 정보 삭제

docker exec -it pagely-postgres psql -U postgre -d pagely_user \
  -c "DELETE FROM p_outbox;"

docker exec -it pagely-postgres psql -U postgre -d pagely_user \
  -c "DELETE FROM p_user_nickname_histories WHERE user_id IN (SELECT id FROM p_users WHERE login_id LIKE 'loadtest_%'); DELETE FROM p_users WHERE login_id LIKE 'loadtest_%';"
  
# 테스트 실행
k6 run signup.js

# 검증
docker exec -it pagely-postgres psql -U postgre -d pagely_user \
  -c "SELECT count(*) FROM p_users WHERE login_id LIKE 'loadtest_%';"

# 결과:
-------
   1000

동기 테스트

Outbox Poller

// Before: 동기 발행 — 실패 시 즉시 catch 가능 (성능보다 신뢰성 우선)
kafkaTemplate.send(topic, messageKey, payload).get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
SELECT 
    count(*) as total,
    avg(EXTRACT(EPOCH FROM (published_at - created_at)) * 1000)::int as avg_ms,
    max(EXTRACT(EPOCH FROM (published_at - created_at)) * 1000)::int as max_ms
FROM p_outbox
WHERE published = true;
 total | avg_ms | max_ms 
-------+--------+--------
  1000 |   2649 |   5255
(1 row)

비동기 테스트

Outbox Poller 수정

// After: 비동기 발행 — 배치 동시 send 후 트랜잭션 안에서 결과 확인
List<CompletableFuture<SendResult<String, String>>> futures = events.stream()
        .map(event -> kafkaTemplate.send(event.getTopic(), event.getAggregateId().toString(), event.getPayload())
                .toCompletableFuture())
        .toList();

futures.get(i).get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS); // 트랜잭션 안에서 대기
event.markPublished();

테스트

# 기존 테스트 정보 삭제

docker exec -it pagely-postgres psql -U postgre -d pagely_user \
  -c "DELETE FROM p_outbox;"

docker exec -it pagely-postgres psql -U postgre -d pagely_user \
  -c "DELETE FROM p_user_nickname_histories WHERE user_id IN (SELECT id FROM p_users WHERE login_id LIKE 'loadtest_%'); DELETE FROM p_users WHERE login_id LIKE 'loadtest_%';"
  
# 테스트 실행
k6 run signup.js

# 검증
docker exec -it pagely-postgres psql -U postgre -d pagely_user \
  -c "SELECT count(*) FROM p_users WHERE login_id LIKE 'loadtest_%';"

# 결과:
-------
   1000
 total | avg_ms | max_ms 
-------+--------+--------
  1000 |   2510 |   5185
 (1 row)

트러블 슈팅

기존 동기 코드를 비동기 코드로 바꾸면서 전체 이벤트 발행이 진행되지 않는 문제가 발생했었다.

   total    | avg_ms | max_ms 
------------+--------+--------
125 (??????)|  12629 |  15635

문제 — @Transactional 과 whenComplete 의 충돌

@Scheduled(...)
@Transactional// ← 트랜잭션: 메서드 종료 시 commit
public void publishPendingEvents() {
        
	for (OutboxEvent event : events) {
		kafkaTemplate.send(...).whenComplete(
			(result, ex) -> {
				event.markPublished();  // ← 다른 스레드에서 실행, 트랜잭션 이미 끝남!
			}
		);
	}
	// ← 여기서 메서드 종료 → 트랜잭션 commit
	// whenComplete 콜백은 아직 실행 안 됨
}

기존 코드 (동기)

package com.pagely.userservice.infrastructure.messaging.outbox;
  

@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxPoller {

	private static final int BATCH_SIZE = 100;
    private static final long SEND_TIMEOUT_SECONDS = 10;
    
    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;
        
    @Scheduled(fixedDelayString = "${outbox.poll-interval-ms:5000}")
    @Transactional
    public void publishPendingEvents() {
    	List<OutboxEvent> events = outboxRepository.findUnpublished(
                        PageRequest.of(0, BATCH_SIZE)
		);
        
        if (events.isEmpty()) {
        	return;
        }
        
		log.debug("Outbox 발행 시작: count={}", events.size());
        
        for (OutboxEvent event : events) {
        	try {
            	publishToKafka(event);
                event.markPublished();
            } catch (Exception e) {
            	event.recordFailure(e.getMessage());
                log.error("Outbox 발행 실패: outboxId={}, eventType={}, failureCount={}",
                event.getId(), event.getEventType(), event.getFailureCount(), e);
            	}
        }
        
		log.debug("Outbox 발행 완료: count={}", events.size());
	}
        
	private void publishToKafka(OutboxEvent event) throws ExecutionException, InterruptedException, TimeoutException {
    	String topic = event.getTopic();
        String messageKey = event.getAggregateId().toString();
        String payload = event.getPayload();

        // 동기 발행 — 실패 시 즉시 catch 가능 (성능보다 신뢰성 우선)
        kafkaTemplate.send(topic, messageKey, payload).get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);

		log.debug("Kafka 발행 성공: topic={}, key={}, eventType={}",
        			topic, messageKey, event.getEventType());
		}
	}

잘못 바꾼 코드 (비동기)

package com.pagely.userservice.infrastructure.messaging.outbox;
        

@Slf4j
@Component
@RequiredArgsConstructor
	public class OutboxPoller {
        
	private static final int BATCH_SIZE = 100;
        
	private final OutboxRepository outboxRepository;
	private final KafkaTemplate<String, String> kafkaTemplate;    
    @Scheduled(fixedDelayString = "${outbox.poll-interval-ms:5000}")
    @Transactional
    public void publishPendingEvents() {
		List<OutboxEvent> events = outboxRepository.findUnpublished(
                        PageRequest.of(0, BATCH_SIZE)
		);
        
        if (events.isEmpty()) {
        	return;
        }

      log.debug("Outbox 발행 시작: count={}", events.size());
        
    	for (OutboxEvent event : events) {
        	try {
            	publishToKafka(event);
            } catch (Exception e) {
            log.error("Outbox 발행 실패: outboxId={}, eventType={}, failureCount={}",
            event.getId(), event.getEventType(), event.getFailureCount(), e);
                    }
                }
        
                log.debug("Outbox 발행 완료: count={}", events.size());
            }
        
            private void publishToKafka(OutboxEvent event) {
                String topic = event.getTopic();
                String messageKey = event.getAggregateId().toString();
                String payload = event.getPayload();
        
                // 비동기 발행
                kafkaTemplate.send(topic, messageKey, payload).whenComplete(
                        (result, ex) -> {
                            if (ex == null) {
                                event.markPublished();
                            } else {
                                event.recordFailure(ex.getMessage());
                            }
                        }
                );
        
                log.debug("Kafka 발행 성공: topic={}, key={}, eventType={}",
                        topic, messageKey, event.getEventType());
            }
        }
        
1. publishPendingEvents() 시작 → 트랜잭션 시작
2. kafkaTemplate.send() × 100 → 비동기로 던지고 즉시 반환
3. 메서드 종료 → 트랜잭션 commit (markPublished 아직 호출 X)
4. 나중에 whenComplete 콜백 실행 → 트랜잭션 없음 → markPublished 저장 안 됨

=> 콜백이 트랜잭션 종료 전에 우연히 완료된 것들( 1000개 중 125개 )만 저장됨

올바른 비동기 방식

비동기로 send 는 동시에 하되, 결과 확인은 트랜잭션 안에서 하도록 고쳐야 한다.

package com.pagely.userservice.infrastructure.messaging.outbox;

@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxPoller {

    private static final int BATCH_SIZE = 100;
    private static final long SEND_TIMEOUT_SECONDS = 10;

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelayString = "${outbox.poll-interval-ms:5000}")
    @Transactional
    public void publishPendingEvents() {
        List<OutboxEvent> events = outboxRepository.findUnpublished(
                PageRequest.of(0, BATCH_SIZE)); // import 주의! 공통모듈 사용 X
        if (events.isEmpty()) {
            return;
        }

        log.debug("Outbox 발행 시작: count={}", events.size());

        List<CompletableFuture<SendResult<String, String>>> futures = events.stream()
                .map(event -> kafkaTemplate.send(
                        event.getTopic(),
                        event.getAggregateId().toString(),
                        event.getPayload()
                ).toCompletableFuture())
                .toList();

        for (int i = 0; i < events.size(); i++) {
            OutboxEvent event = events.get(i);
            try {
                futures.get(i).get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
                event.markPublished();   // 트랜잭션 안에서 실행 ✅
            } catch (Exception e) {
                event.recordFailure(e.getMessage());
                log.error("Outbox 발행 실패: outboxId={}, eventType={}, failureCount={}",
                        event.getId(), event.getEventType(), event.getFailureCount(), e);
            }
        }

        log.debug("Outbox 발행 완료: count={}", events.size());
    }
}
profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글