brew install 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
- JVM Heap 사용량 — 점진적 증가
- HTTP Requests (RPS) — 부하 증가
- Response Time (P95) — latency 변화
- Tomcat Active Threads — 동시 처리
- DB Connection Pool — Redis / DB 의 풀 사용량
# 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
| 지표 | 값 |
|---|---|
| 총 요청 | 3,738 |
| RPS | 12.5 |
| 성공률 | 100% |
| 에러율 | 0% |
| 평균 응답 시간 | 5.38s |
| P95 | 10.36s |
| P99 | 10.87s |
| Max | 11.22s |

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

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를 통해 측정해 보고, 모니터링 도구에서도 같게 나오는지 비교해보기로 했다.
-- 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;
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);
}
}
// 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
@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());
}
}