728x90
https://yeopseung.tistory.com/282
무신사 2차 코딩테스트 -2
https://yeopseung.tistory.com/281 무신사 2차 코딩테스트 -12월 8일 일요일 오후 3시, 대망의 2차 코딩테스트가 시작되었습니다. 그리고 2월 13일 오후 5시 31분, 다행히 합격 메일을 받을 수 있었습니다.이
yeopseung.tistory.com

제 코드에 보이는 부분은 Index를 걸었던 부분입니다.
why?
- 교수별 강의 목록 조회, 학과별 강의 목록 조회가 빈번할 가능성이 매우 높기 떄문에 인덱스를 걸어야한다고 생각했습니다.
prompt 는 ?
저의 프롬프트에서는 비관적 락 걸어야할 부분들을 미리 생각하고 넣었는데
수강 신청시 정원 초과 방지를 위한 비관적락을 걸고 싶은데 지금 문제에서 비관 락을 구현해야할 부분들을 알려달라고 했고,
그것을 Cursor에 넣었습니다.
또한 자주 조회 되는 교수별 강의 목록 조회, 학과별 강의 목록 조회 부분에는 인덱스 작업을 할 것인데, 이러한 인덱스 과정을 걸쳐야하는 부분들을 알려줘 라고 프롬프트를 던졌습니다. 역시 이것ㅇ르 Cursor에 넣고 작업을 했습니다 .
비관적 락으로 구현하다가 문득 대기열이 있으면 수강신청에 편리하지 않을까? 라는 고민을 했고, 그것을 구현했습니다.
처음에는 정원 초과 시 단순히 “마감되었습니다”로 끝내는 방식으로 설계했는데, 테스트 문제를 다시 보니 “정원 초과 시 대기 신청 가능”이라는 힌트가 눈에 들어왔고, 실제 대학 수강신청 시스템에서도 대기열 기능이 핵심 UX라고 생각했습니다.
그래서 급하게 대기열 로직을 추가했습니다.
물론 대기열 순번 계산 쿼리가 매번 범위 COUNT를 날리기 때문에 트래픽이 정말 많이 몰리면 DB 부하가 걱정되긴 했습니다. (나중에 Redis Sorted Set으로 바꾸면 훨씬 가벼워질 부분이라고 생각했어요)
저는 비관적락으로 구현을 해달라고 했는데 그 이유는 ?
- 수강신청은 정해진 시간에 트래픽이 몰리는 특성이 있어 데이터의 정합성이 가장 중요합니다. 낙관적 락은 충돌 시 재시도 로직이 복잡하고 사용자 경험을 해칠 수 있다고 판단했습니다.
- 비관적 락은 DB 커넥션을 점유하는 시간이 길어질 수 있습니다. 이를 최소화하기 위해 트랜잭션의 범위를 좁게 유지하고, 수강신청 가능 여부를 체크하는 인덱스 최적화와 쿼리 튜닝에 집중했습니다.
- 사실 요구 조건에 10000명 이상이 었는데 문제를 만명까지 제한하는 줄알고 오버엔지니어링을 고려해서 비관락으로 구현했습니다. 차라리 더 많은 학생을 예로 두고 Redis에서 더 나아가 Kafka를 고려헀을 것 같습니다.
사용해야할 부분들론
1. 강좌별 실시간 신청 인원입니다.
why?
- 수강신청 취소 시마다 DB count 쿼리를 타기 떄문에 10000명 요청이 몰리면 DB가 순식간에 힘들어한다. 또한 만명 이상을 고려했을 때,
- 그래서 수강신청 시작 전에 모든 강좌의 현재 enrolled 값을 Redis에 미리 읽어두고
- 학생이 신청 버튼 누르면 Redis에서 Get course: enrolled : -> capacity 비교 하는 등으로 쓸것 같다.
- 안정장치로 30초마다 Redis 값을 읽어 디비에 반영 -> 다운 장애시 fallback으로 디비 카운트 사용하도록 degrade로직을 준비할 것 같습니다.
2. 후반 부에 구현한 대기열 순번입니다.
why?
- 내 순번 보기를 누를 때 -> 대기열 1,000명 + 학생 500명이 동시에 새로고침 → DB에 수천 번 범위 스캔 → 느려지고 CPU 먹음
- 대기열 진입 시 순번 조회가 log(N)
- 시간이 됐다면 Jmeter로 1000명 동시 순번 조회 쏴보고 확인하고 싶었습니다.
늘 오버 엔지니어링을 고려해서 why 라는 것이 중요한 것 같습니다.
페이징 전략을 선택한 이유
처음에는 페이징까지 선택하지 못했고, 페이징 전략을 추후에 도입했습니다.
Why page?
- 전체 데이터를 한 번에 내려주면 서버 메모리 폭발, DB 부하가 증가합니다.
실제 제가 수강신청을 했던 경험을 고민했을 때 페이지 처리가 되어있었다고 생각했습니다.
그렇다면 어디에 적용하는것이 좋을까?
- 저는 일단 Controller 단에서 처리하도록했습니다.
- 학생 목록 조회 -> 만명 전체 조회시 메모리가 위험할수 도있음.
- 동시성 제어와 무관하지만, 시스템 전체 안정성, UX를 위해 필요하다고 판단했습니다.
고민했던 점
- 여기가 가장 핵심이라고 생각합니다.
- 프롬프트에게 던지기전에 페이징은 방식이 두 가지가 있습니다.
- Offset no offset
- 오프셋 간단 설명
-> 0번부터 10000명까지 200명씩 가져와
- 장점
-> 구현이 엄청 쉽습니다.
-> 페이지 번호 직접 입력이 가능함
- 단점
- > 페이지 번호가 커질수록 느려집니다.
- > offset 10000이면 10,000행을 다 스킵 해야합니다.
- > 만약 10만 건 넘어가면 page=5000에서 몇 십초가 걸릴 수도 있습니다.
- 노오프셋
- > 마지막 페이지 id =2000이면 id > 2000 다음 20개 가져오도록해
- 장점
- > 앞 행 스킵 안하고, 인덱스 타고 바로 점프 깊은 페이지도 빠르다.
-> 무한 스크롤에 최적
- 단점
- > 페이지 번호 직접 못간다.
- > 총 페이지 수나 전채 개수 계산이 어렵다.
- > 구현에 어려움
그렇다면 저는 오프셋으로 구현하도록 던져줬습니다.
why?
- 일단 학생 목록을 저는 10000명으로 세팅했습니다.
- 학생 목록은 이름 학번 학과가 필수라 대부분 1~10페이지 안에서 끝납니다.
- 만약 강좌 목록이 10만건 이상이거나, 무한 스크롤을 써야하는 경우가 생겼다.
- 성능이 1초가 넘었다면 no offset으로 전환했을 것 같습니다.
- 밑에는 k6 부하테스트 입니다.
// loadtest-courses.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 }, // 30초 동안 50명까지 점진적으로 증가
{ duration: '1m', target: 50 }, // 1분 동안 50명 유지
{ duration: '30s', target: 100 }, // 30초 동안 100명까지 증가
{ duration: '1m', target: 100 }, // 1분 동안 100명 유지
{ duration: '30s', target: 0 }, // 점진적으로 0으로 감소
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% 요청이 500ms 이내
http_req_failed: ['rate<0.01'], // 실패율 1% 미만
},
};
export default function () {
// 강좌 목록 조회 (기본 1페이지)
let res = http.get('http://localhost:8080/api/courses?page=0&size=20');
check(res, {
'status is 200': (r) => r.status === 200,
'success is true': (r) => r.json('success') === true,
'has content array': (r) => r.json('data.content') instanceof Array,
});
// 랜덤하게 학과 필터 걸어보기 (실제 존재하는 학과 중 하나)
let depts = ['COMPUTER_SCIENCE', 'BUSINESS', 'MECHANICAL_ENGINEERING'];
let randomDept = depts[Math.floor(Math.random() * depts.length)];
res = http.get(`http://localhost:8080/api/courses?department=${randomDept}`);
check(res, {
'status is 200 or 400': (r) => r.status === 200 || r.status === 400,
});
// 사용자처럼 1~3초 쉬기
sleep(Math.random() * 2 + 1);
}

seung-yeob@iseung-yeob-ui-noteubug-103 k6 % k6 run loadtest-courses.js
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: loadtest-courses.js
output: -
scenarios: (100.00%) 1 scenario, 100 max VUs, 4m0s max duration (incl. graceful stop):
* default: Up to 100 looping VUs for 3m30s over 5 stages (gracefulRampDown: 30s, gracefulStop: 30s)
█ THRESHOLDS
http_req_duration
✓ 'p(95)<500' p(95)=16.81ms
http_req_failed
✗ 'rate<0.01' rate=16.25%
█ TOTAL RESULTS
checks_total.......: 27000 127.39792/s
checks_succeeded...: 100.00% 27000 out of 27000
checks_failed......: 0.00% 0 out of 27000
✓ status is 200
✓ success is true
✓ has content array
✓ status is 200 or 400
HTTP
http_req_duration..............: avg=8.07ms min=492µs med=6.75ms max=237.72ms p(90)=12.68ms p(95)=16.81ms
{ expected_response:true }...: avg=9.36ms min=3.49ms med=7.2ms max=237.72ms p(90)=13.41ms p(95)=18.13ms
http_req_failed................: 16.25% 2194 out of 13500
http_reqs......................: 13500 63.69896/s
EXECUTION
iteration_duration.............: avg=2.01s min=1s med=2.01s max=3.06s p(90)=2.82s p(95)=2.91s
iterations.....................: 6750 31.84948/s
vus............................: 1 min=1 max=100
vus_max........................: 100 min=100 max=100
NETWORK
data_received..................: 35 MB 163 kB/s
data_sent......................: 1.4 MB 6.5 kB/s
running (3m31.9s), 000/100 VUs, 6750 complete and 0 interrupted iterations
default ✓ [======================================] 000/100 VUs 3m30s
ERRO[0212] thresholds on metrics 'http_req_failed' have been crossed
iseung-yeob@iseung-yeob-ui-noteubug-103 k6 %
페이징 성능 자체는 매우 훌륭함 100명 동시에도 평균 8ms, p17ms → 로컬에서 초당 수천 건 처리 가능 수준 enrolled=0 상태라 count 쿼리도 거의 부하 없음 (신청 시작 후에는 조금 느려질 수 있음)
실패율 16%는 버그가 아니라 테스트 스크립트 문제
728x90
'개발 공부 > 코딩테스트' 카테고리의 다른 글
| 코딩테스트 -4 (0) | 2026.02.26 |
|---|---|
| 무신사 2차 코딩테스트 -1 (0) | 2026.02.17 |
| 무신사 ROOKIE AI Native Engineer 1차 코딩테스트 (0) | 2026.02.17 |
| 같은숫자는 싫어 (0) | 2024.07.06 |
| 폰켓몬[JAVA] (0) | 2024.06.21 |