-
Notifications
You must be signed in to change notification settings - Fork 0
[fix] 모임방 참여 api 5xx 에러 발생 해결 #331
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
6d2ddea
0567ce9
d331f9f
b3e3197
b5ff13e
ab03c5c
9fd4169
f6d805e
30c61d3
a182cc8
87e4d12
bf5ad79
51835da
37e4a4b
31f79f8
7bfe838
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import http from 'k6/http'; | ||
| import { check, sleep } from 'k6'; | ||
| import { Trend, Counter } from 'k6/metrics'; | ||
|
|
||
| const BASE_URL = 'http://localhost:8080'; | ||
| const ROOM_ID = 12345; | ||
| const USERS_START = 10000; // 토큰 발급 시작 userId | ||
| const USERS_COUNT = 500; // 총 사용자 = VU 수 | ||
| const TOKEN_BATCH = 200; // 토큰 발급 배치 크기 | ||
| const BATCH_PAUSE_S = 0.2; // 배치 간 대기 (for 토큰 발급 API 병목 방지) | ||
| const START_DELAY_S = 5; // 테스트 시작 전 대기 (for 방 참여 요청 동시 시작) | ||
|
|
||
| // ===== 커스텀 메트릭 ===== | ||
| const joinLatency = new Trend('rooms_join_latency'); // 참여 API 지연(ms) | ||
| const http5xx = new Counter('rooms_join_5xx'); // 5xx 개수 | ||
| const http2xx = new Counter('rooms_join_2xx'); // 2xx 개수 | ||
| const http4xx = new Counter('rooms_join_4xx'); // 4xx 개수 | ||
|
|
||
| // 실패 원인 분포 파악용(응답 JSON의 code 필드 기준) | ||
| const token_issue_failed = new Counter('token_issue_failed'); | ||
| const fail_ROOM_MEMBER_COUNT_EXCEEDED = new Counter('fail_ROOM_MEMBER_COUNT_EXCEEDED'); | ||
| const fail_USER_ALREADY_PARTICIPATE = new Counter('fail_USER_ALREADY_PARTICIPATE'); | ||
| const fail_RESOURCE_LOCKED = new Counter('fail_RESOURCE_LOCKED'); // 423 Locked error | ||
| const fail_OTHER_4XX = new Counter('fail_OTHER_4XX'); | ||
|
|
||
| const ERR = { // THIP error code | ||
| ROOM_MEMBER_COUNT_EXCEEDED: 100006, | ||
| USER_ALREADY_PARTICIPATE: 140005, | ||
| RESOURCE_LOCKED: 50200, | ||
| }; | ||
|
|
||
| function parseError(res) { | ||
| try { | ||
| const j = JSON.parse(res.body || '{}'); // BaseResponse 구조 | ||
| // BaseResponse: { isSuccess:boolean, code:number, message:string, requestId:string, data:any } | ||
| return { | ||
| code: Number(j.code), // 정수 코드 | ||
| message: j.message || '', | ||
| requestId: j.requestId || '', | ||
| isSuccess: !!j.isSuccess | ||
| }; | ||
| } catch (e) { | ||
| return { code: NaN, message: '', requestId: '', isSuccess: false }; | ||
| } | ||
| } | ||
|
|
||
| // ------------ 시나리오 ------------ | ||
| // [인기 작가가 만든 모임방에 THIP의 수많은 유저들이 '모임방 참여' 요청을 보내는 상황 가정] | ||
| export const options = { | ||
| scenarios: { | ||
| // 각 VU가 "정확히 1회" 실행 → 1 VU = 1명 유저 | ||
| join_once_burst: { | ||
| executor: 'per-vu-iterations', | ||
| vus: USERS_COUNT, | ||
| iterations: 1, | ||
| startTime: '0s', // 모든 VU가 거의 동시에 스케줄링 | ||
| gracefulStop: '5s', | ||
| }, | ||
| }, | ||
| thresholds: { | ||
| rooms_join_5xx: ['count==0'], // 서버 오류는 0건이어야 함 | ||
| rooms_join_latency: ['p(95)<1000'], // p95 < 1s | ||
| }, | ||
| }; | ||
|
|
||
| // setup: 토큰 배치 발급 | ||
| // roomId 12345 방 & userId 10000 ~ 유저들은 사전에 만들어져 있어야 함 | ||
| export function setup() { | ||
| const userIds = Array.from({ length: USERS_COUNT }, (_, i) => USERS_START + i); | ||
| const tokens = []; | ||
|
|
||
| for (let i = 0; i < userIds.length; i += TOKEN_BATCH) { | ||
| const slice = userIds.slice(i, i + TOKEN_BATCH); | ||
| const reqs = slice.map((uid) => [ | ||
| 'GET', | ||
| `${BASE_URL}/api/test/token/access?userId=${uid}`, | ||
| null, | ||
| { tags: { phase: 'setup_token_issue', room: `${ROOM_ID}` } }, | ||
| ]); | ||
|
|
||
| const responses = http.batch(reqs); | ||
| for (const r of responses) { | ||
| if (r.status === 200 && r.body) { | ||
| tokens.push(r.body.trim()); | ||
| } | ||
| else { | ||
| tokens.push(''); // 실패한 자리도 인덱스 유지 | ||
| token_issue_failed.add(1); | ||
| } | ||
| } | ||
| sleep(BATCH_PAUSE_S); | ||
| } | ||
| if (tokens.length > USERS_COUNT) tokens.length = USERS_COUNT; | ||
|
|
||
| const startAt = Date.now() + START_DELAY_S * 1000; // 동시 시작 시간 | ||
|
|
||
| return { tokens, startAt }; | ||
| } | ||
|
|
||
| // VU : 각자 자기 토큰으로 참여 호출 & 각자 1회만 실행 | ||
| export default function (data) { | ||
| const idx = __VU - 1; // VU <-> user 매핑(1:1) | ||
| const token = data.tokens[idx]; | ||
|
|
||
| // 동기 시작: startAt까지 대기 → 모든 VU가 거의 같은 타이밍에 시작 | ||
| const now = Date.now(); | ||
| if (now < data.startAt) { | ||
| sleep((data.startAt - now) / 1000); | ||
| } | ||
|
|
||
| if (!token) { // 토큰 발급 실패 -> 스킵 | ||
| return; | ||
| } | ||
|
|
||
| const headers = { | ||
| Authorization: `Bearer ${token}`, | ||
| 'Content-Type': 'application/json', | ||
| }; | ||
|
|
||
| const body = JSON.stringify({ type: 'join' }); | ||
| const url = `${BASE_URL}/rooms/${ROOM_ID}/join`; | ||
|
|
||
| const res = http.post(url, body, { headers, tags: { phase: 'join', room: `${ROOM_ID}` } }); | ||
|
|
||
| // === 커스텀 메트릭 기록 === | ||
| joinLatency.add(res.timings.duration); | ||
| if (res.status >= 200 && res.status < 300) http2xx.add(1); | ||
| else if (res.status >= 400 && res.status < 500) { | ||
| http4xx.add(1); | ||
| const err = parseError(res); | ||
| switch (err.code) { | ||
| case ERR.ROOM_MEMBER_COUNT_EXCEEDED: | ||
| fail_ROOM_MEMBER_COUNT_EXCEEDED.add(1); | ||
| break; | ||
| case ERR.USER_ALREADY_PARTICIPATE: | ||
| fail_USER_ALREADY_PARTICIPATE.add(1); | ||
| break; | ||
| case ERR.RESOURCE_LOCKED: | ||
| fail_RESOURCE_LOCKED.add(1); | ||
| break; | ||
| default: | ||
| fail_OTHER_4XX.add(1); | ||
| } | ||
| } else if (res.status >= 500) { | ||
| http5xx.add(1); | ||
| } | ||
|
|
||
| // === 검증 === | ||
| check(res, { | ||
| 'join responded': (r) => r.status !== 0, | ||
| 'join 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500), | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package konkuk.thip.config; | ||
|
|
||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.retry.annotation.EnableRetry; | ||
|
|
||
| @Configuration | ||
| @EnableRetry(proxyTargetClass = true) | ||
| public class RetryConfig { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package konkuk.thip.room.application.service; | ||
|
|
||
| import konkuk.thip.common.exception.BusinessException; | ||
| import konkuk.thip.common.exception.InvalidStateException; | ||
| import konkuk.thip.common.exception.code.ErrorCode; | ||
| import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; | ||
| import konkuk.thip.room.application.port.in.RoomJoinUseCase; | ||
|
|
@@ -14,7 +15,11 @@ | |
| import konkuk.thip.user.application.port.out.UserCommandPort; | ||
| import konkuk.thip.user.domain.User; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.retry.annotation.Backoff; | ||
| import org.springframework.retry.annotation.Recover; | ||
| import org.springframework.retry.annotation.Retryable; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Propagation; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.Optional; | ||
|
|
@@ -30,13 +35,21 @@ public class RoomJoinService implements RoomJoinUseCase { | |
| private final RoomNotificationOrchestrator roomNotificationOrchestrator; | ||
|
|
||
| @Override | ||
| @Transactional | ||
| @Retryable( | ||
| noRetryFor = {InvalidStateException.class, BusinessException.class}, // 재시도 제외 예외 | ||
| maxAttempts = 3, | ||
| backoff = @Backoff(delay = 100, multiplier = 2) | ||
| ) | ||
| @Transactional(propagation = Propagation.REQUIRES_NEW) // 재시도마다 새로운 트랜잭션 | ||
| public RoomJoinResult changeJoinState(RoomJoinCommand roomJoinCommand) { | ||
| RoomJoinType type = roomJoinCommand.type(); | ||
|
|
||
| // 방이 존재하지 않거나 모집기간이 만료된 경우 예외 처리 | ||
| Room room = roomCommandPort.findById(roomJoinCommand.roomId()) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.USER_CANNOT_JOIN_OR_CANCEL)); | ||
| // Room room = roomCommandPort.findById(roomJoinCommand.roomId()) | ||
| // .orElseThrow(() -> new BusinessException(ErrorCode.USER_CANNOT_JOIN_OR_CANCEL)); | ||
|
|
||
| /** 락 타잉아웃 발생 포인트 **/ | ||
| Room room = roomCommandPort.getByIdForUpdate(roomJoinCommand.roomId()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. p3: 존재하지 않는 방에대해 방참여/취소 요청에대한 예외를 기존에는 USER_CANNOT_JOIN_OR_CANCEL로 처리했었는데 수정된 코드에서는 ROOM_NOT_FOUND로 처리하고있는데 이렇게 수정하신 이유가 따로있나용??
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Room 엔티티 조회 시 x-lock을 걸고 조회하게끔 수정하는데 집중하다보니, 기존 코드처럼 조회가 되지 않을 경우에 대한 고려를 깊게 하지는 않았습니다! 현재 작업 중인 브랜치에서 이 부분 또한 고려해보도록 하겠습니다! |
||
|
|
||
| room.validateRoomRecruitExpired(); | ||
|
|
||
|
|
@@ -59,6 +72,11 @@ public RoomJoinResult changeJoinState(RoomJoinCommand roomJoinCommand) { | |
| return RoomJoinResult.of(room.getId(), type.getType()); | ||
| } | ||
|
|
||
| @Recover | ||
| public RoomJoinResult recover(Exception e, RoomJoinCommand roomJoinCommand) { | ||
| throw new BusinessException(ErrorCode.RESOURCE_LOCKED); | ||
| } | ||
|
|
||
| private void sendNotifications(RoomJoinCommand roomJoinCommand, Room room) { | ||
| RoomParticipant targetUser = roomParticipantCommandPort.findHostByRoomId(room.getId()); | ||
| User actorUser = userCommandPort.findById(roomJoinCommand.userId()); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좋네여