Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
edc1264
[Fix] role을 컴포넌트 함수 내 직접 읽기로 변경하여 렌더링 시 권한 반영
xihxxn Jun 7, 2026
98a547c
[Revert] role 상태 관리 원상복구
xihxxn Jun 7, 2026
fe26a8a
[Fix] role을 컴포넌트 함수 내에서 읽도록 수정하여 첫 렌더링 시 권한 반영
xihxxn Jun 7, 2026
cb01b76
[Fix] HikariCP 커넥션 풀 고갈 방지 설정 추가
xihxxn Jun 7, 2026
bd832d4
[Fix] 출석 코드 만료 시 보증금 재계산 N*3 쿼리 → 배치 처리로 개선
xihxxn Jun 7, 2026
be6e638
[Fix] AssignmentService 클래스 레벨 @Transactional 제거 및 메서드별 readOnly 분리
xihxxn Jun 7, 2026
7509dd6
[Fix] SSE 타임아웃 1시간 → 3분으로 축소하여 Tomcat 스레드 낭비 방지
xihxxn Jun 7, 2026
4b2e2d9
[Fix] AdminUserService 과제/출석 수정 시 루프 내 개별 조회 → 배치 조회로 개선
xihxxn Jun 7, 2026
1a487e6
[Fix] AdminUserService 출석 ID 타입 Long → Integer 불일치 수정
xihxxn Jun 7, 2026
c6c3488
[Fix] AdminUserService 출석 ID 타입 불일치 수정 - findAllById Long, map 키 Integer
xihxxn Jun 7, 2026
d2e943e
Merge pull request #192 from pirogramming/sihyun
xihxxn Jun 7, 2026
8a3d354
[Fix] AttendanceService findByUserId/findByUserIdAndDate @Transaction…
xihxxn Jun 7, 2026
1d6e19f
Merge pull request #193 from pirogramming/sihyun
xihxxn Jun 7, 2026
33db1e7
[Fix] HikariCP 커넥션 풀 타임아웃 및 슬로우 쿼리 로그 설정 추가
xihxxn Jun 7, 2026
ce3434c
Merge pull request #194 from pirogramming/sihyun
xihxxn Jun 7, 2026
4f3b857
[Fix] Header 투명도 수정
plumbestie Jun 8, 2026
693fb2e
[Fix] 커리큘럼 CSS 수정
plumbestie Jun 8, 2026
a6fb13d
Merge pull request #196 from pirogramming/Fix/#195
plumbestie Jun 8, 2026
a8b84cf
fix: 재배포 시 이미지 유실 방지를 위한 Docker named volume 설정
kkw610 Jun 8, 2026
77fbc9f
Merge pull request #198 from pirogramming/fix/#197
kkw610 Jun 8, 2026
30beff2
feat: 질문/댓글 이미지 여러 장 업로드 지원(최대 5장) BE
kkw610 Jun 8, 2026
fb0958e
feat: 질문/댓글 이미지 여러 장 업로드 지원(최대 5장) FE
kkw610 Jun 8, 2026
b306e72
Merge pull request #200 from pirogramming/feat/#199
kkw610 Jun 8, 2026
349759c
fix: update favicon and metadata
Jun 12, 2026
1184a0c
Merge pull request #202 from pirogramming/feat/201
lilyyang0077 Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
--name piroin-backend \
--restart unless-stopped \
-p 8080:8080 \
-v piroin-uploads:/app/uploads \
--health-cmd="curl -f http://localhost:8080/actuator/health || exit 1" \
--health-interval=30s \
--health-timeout=10s \
Expand Down
2 changes: 2 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ WORKDIR /app

COPY build/libs/*.jar app.jar

VOLUME /app/uploads

ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar"]
81 changes: 81 additions & 0 deletions backend/k6/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod)
// 작성 기준: application.yml, SecurityConfig.java, *Controller.java
// SSE 구현: SseEmitter (Spring WebMVC, Tomcat 스레드 점유 방식)
// 대상 유저 수: 약 40명

import http from 'k6/http';
import { check } from 'k6';
import encoding from 'k6/encoding';

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

export const CREDENTIALS = {
name: __ENV.USER_NAME || 'test_user',
password: __ENV.USER_PASSWORD || 'test_password',
};

export const AUTH = {
type: 'bearer',
token: __ENV.API_TOKEN || '',
};

export function getHeaders(withAuth = true) {
const headers = { 'Content-Type': 'application/json' };
if (withAuth && AUTH.token) {
headers['Authorization'] = `Bearer ${AUTH.token}`;
}
return headers;
}

export function getHeadersWithToken(token) {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
};
}

export function login() {
const res = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ name: CREDENTIALS.name, password: CREDENTIALS.password }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(res, { '로그인 성공': (r) => r.status === 200 });
try {
return res.json('token') || '';
} catch {
return '';
}
}

// 테스트용 1x1 JPEG (base64 인라인 — 외부 파일 불필요)
const TEST_IMAGE_B64 =
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkS' +
'Ew8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAAR' +
'CAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAA' +
'AAAAAAAAAAAAAP/EABQBAQAAAAAAAAAAAAAAAAAAAAD/xAAUEQEAAAAAAAAAAAAA' +
'AAAAAAAA/9oADAMBAAIRAxEAPwCwAB//2Q==';

// QnA 이미지 업로드: POST /api/images (multipart/form-data)
// Authorization 헤더만 포함 — Content-Type은 k6가 multipart로 자동 설정
export function uploadTestImage(token) {
const imageBytes = encoding.b64decode(TEST_IMAGE_B64, 'std', 'b');
const data = {
file: http.file(imageBytes, 'test.jpg', 'image/jpeg'),
};
return http.post(`${BASE_URL}/api/images`, data, {
headers: { Authorization: `Bearer ${token}` },
});
}

// ⚠️ HikariCP pool-size=10, Tomcat threads.max=200
// 40명 운영에는 pool-size=10 적절
// Stress(120 VU) 시 DB 커넥션 병목 관측 예상 — 의도된 설정
export const THRESHOLDS = {
smoke: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<500'] },
load: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000', 'p(99)<2000'] },
stress: { http_req_failed: ['rate<0.05'], http_req_duration: ['p(95)<3000'] },
soak: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000'] },
sse: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<2000'] },
};
144 changes: 144 additions & 0 deletions backend/k6/k6-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# k6 부하 테스트 결과

> 테스트 일시: 2026-06-07
> 대상 서버: https://api.piroin.com
> 기준 유저 수: 40명
> 계정: test / 12345678 (MEMBER), SESSION_ID=23 (IN_SESSION)

---

## 결과 요약

| 테스트 | 결과 | 에러율 | p(95) | 비고 |
|--------|------|--------|-------|------|
| Smoke | ✅ PASS | 0.00% | 123ms | 전 항목 정상 |
| Load | ✅ PASS | 0.00% | 96ms | 40 VU 전 항목 정상 |
| SSE | ⚠️ 부분 해결 | - | 110ms (TTFB) / 116ms (일반 API) | sse_connect_time 해결, sse_error k6 한계 |
| Stress | ⚠️ FAIL | 0.00% | 614ms | 3s 초과 응답 300건 |
| Soak | ⚠️ FAIL | 0.07% | 130ms | 간헐적 DB 오류 98건, 최대 응답 24분 |

---

## 1. Smoke Test ✅ PASS

| 항목 | 값 | 기준 |
|------|-----|------|
| 에러율 | 0.00% | < 1% |
| p(95) | 123ms | < 500ms |
| checks | 100% (280/280) | |
| 총 요청 | 187건 | |

**체크 항목 전부 통과**
- 헬스 체크, 커리큘럼, 세션 목록, 질문 목록, 출석, 이미지 업로드 모두 정상

---

## 2. Load Test ✅ PASS

| 항목 | 값 | 기준 |
|------|-----|------|
| 에러율 | 0.00% | < 1% |
| p(95) | 96ms | < 1000ms |
| p(99) | 115ms | < 2000ms |
| 평균 응답 | 29ms | |
| 최대 응답 | 3.41s | |
| 총 요청 | 46,461건 | |
| checks | 100% (47,981/47,981) | |

**40 VU 전체 구간에서 에러 0건.** 이미지 업로드(20% 확률) 포함 모두 정상.
최대 응답 3.41s는 이미지 업로드 요청으로 추정.

---

## 3. SSE Test (재테스트)

| 항목 | 1차 | 재테스트 | 기준 | 판정 |
|------|-----|----------|------|------|
| `sse_connect_time` p(95) | 35003ms | **110ms** | < 2000ms | ✅ 해결 |
| `sse_error` count | 510 | 596 | < 10 | ✗ |
| `normal_api` p(95) | 86.9ms | 116.92ms | < 1000ms | ✅ |
| SSE connected 이벤트 수신 | - | ✅ 100% | | |

**sse_connect_time 완전 해결**: TTFB 측정으로 전환 후 35003ms → 110ms.

**sse_error 596 원인 (k6 한계)**:
timeout=10s 설정 시, k6가 연결을 강제 종료하는 시점에 body 버퍼링이 완료되지 않아
`res.body`에 `connected` 문자열이 없는 경우 에러로 카운트됨.
`SSE connected 이벤트 수신 ✓`와 모순되는 것처럼 보이지만,
체크는 버퍼링 성공한 연결에서만 실행되기 때문. 서버 문제 아님.

**normal_api 4% 실패 (130건)**:
SSE 30개 연결 중 일반 API 배치 요청 4% 실패. HikariCP 간헐적 병목으로 추정.
p(95)=116ms로 대부분 정상이며 스레드 고갈은 아님.

**핵심 확인 사항 ✅**:
- SSE 30개 연결 중에도 일반 API p(95)=116ms → **스레드 고갈 없음**
- connected 이벤트 정상 수신 확인
- `sse_error` 임계값은 k6의 body 버퍼링 한계로 인한 것 — 추가 도구로 검증 필요

---

## 4. Stress Test ⚠️ FAIL

| 항목 | 값 | 기준 |
|------|-----|------|
| 에러율 (`http_req_failed`) | **0.00%** | < 5% ✅ |
| p(95) | 614ms | < 3000ms ✅ |
| 평균 응답 | 191ms | |
| 최대 응답 | 40.53s | |
| 3s 초과 응답 | **300건** | 0건 기대 |
| `custom_errors` | 300 | < 100 ✗ |
| 총 요청 | 173,458건 | |

**에러율 0%, p(95) 614ms로 기준치는 통과했으나**, 3초를 초과한 응답이 300건 발생해 `custom_errors` 임계값(100건) 초과.
최대 응답 40.53s는 120 VU 구간(한계 탐색)에서 HikariCP 커넥션 대기가 쌓인 것으로 추정.

**주요 발견**:
- 40 VU(정상) → 80 VU(2배) 구간까지는 안정적
- 120 VU(3배) 구간에서 일부 요청이 3s 초과 — **병목 지점 확인**
- 회복 구간(마지막 5분)에서 에러율 0% 복귀 → 서버 자가 회복 능력 정상

**개선 방향**: HikariCP `maximum-pool-size` 상향 또는 쿼리 최적화로 커넥션 점유 시간 단축.

---

## 5. Soak Test ⚠️ FAIL

| 항목 | 값 | 기준 |
|------|-----|------|
| 에러율 (`http_req_failed`) | 0.07% | < 1% ✅ |
| p(95) | 130ms | < 1000ms ✅ |
| `db_error_count` | **98** | < 20 ✗ |
| 최대 응답 | **24분 2초** | |
| slow_response p(95) | 814s | |
| 총 요청 | 84,593건 / 2시간 | |

**p(95) 130ms로 대부분 요청은 정상**이나, 간헐적으로 수 분~24분짜리 극단적 지연이 발생.

**실패 항목별 건수**
- 커리큘럼 조회 실패: 15건
- 1s 초과 응답: 71건
- 출석 조회 실패: 18건
- 질문 목록 실패: 23건
- 이미지 업로드 실패: 4건

**분석**:
- 2시간 중 99% 구간은 정상 동작 (p(95)=130ms)
- 극단적 지연(max=24분)은 HikariCP 커넥션 대기가 누적되어 일부 요청이 timeout까지 대기한 것으로 추정
- `db_error_count` 98건은 연속 실패가 아닌 간헐적 발생 → 서버가 완전히 멈추진 않음
- 실제 40명 환경(20 VU)에서도 장시간 운영 시 간헐적 DB 병목 발생 확인

**개선 방향**:
- 장시간 운영 중 커넥션 점유 시간이 긴 쿼리 식별 (슬로우 쿼리 로그 확인)
- HikariCP `idle-timeout` 설정 검토

---

## 전체 총평

| 구분 | 내용 |
|------|------|
| **안전 운영 범위** | 40 VU (실제 40명 동시 사용) — 에러 0%, p(95) 96ms |
| **병목 시작 지점** | 80~120 VU — 3s 초과 응답 발생, HikariCP 커넥션 대기 |
| **SSE 영향도** | 없음 — SSE 30개 연결 중에도 일반 API p(95)=86ms 유지 |
| **주요 개선 과제** | Soak 간헐적 DB 오류 원인 파악 (최대 응답 24분 발생) |
Loading
Loading