사이드 프로젝트로 커뮤니티 서비스를 개발하고 있습니다. AWS EC2 t2.micro에 Docker로 배포한 뒤 JMeter로 일반 사용자 시나리오 부하 테스트를 돌렸는데 이상한 점을 발견했습니다.
회원과 게스트가 같은 작업을 하는데 응답시간이 14배 차이가 났습니다.
회원 댓글 작성: 평균 88ms
게스트 댓글 작성: 평균 1,248ms ← 왜?
Grafana를 보니 피크 시 CPU 사용률이 100%까지 치솟고 있었습니다. 게스트 요청이 CPU를 과도하게 소비하고 있었습니다.
파고드니 BCrypt가 문제였습니다.
BCrypt는 비밀번호 해싱 알고리즘으로, strength(cost factor) 값에 따라 연산 비용이 기하급수적으로 증가합니다. 해커가 탈취한 해시를 brute force로 공격할 때 시간이 오래 걸리도록 의도적으로 느리게 설계된 겁니다.
| strength | 소요시간 |
|---|---|
| 8 | 61ms |
| 10 (기본값) | 103ms |
EC2 t2.micro (vCPU 1, RAM 1GB) 환경에서 jshell로 직접 측정. 각 strength당 10회 평균값.
Spring Security의 BCryptPasswordEncoder()는 기본 strength가 10입니다.
SecurityConfig에 빈이 하나뿐이었습니다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // strength=10, 모든 곳에 사용
}
회원 로그인, 회원 가입, 게스트 게시글, 게스트 댓글 — 모든 비밀번호 연산이 동일한 BCrypt(10)을 사용하고 있었습니다.
// PostService.java
public Long createGuestPost(String slug, GuestPostCreateRequest dto) {
// 매 게스트 게시글 작성마다 BCrypt(10) 실행 → 103ms
String encodedPassword = passwordEncoder.encode(dto.guestPassword());
...
}
// CommentService.java
public Long createGuestComment(Long postId, GuestCommentCreateRequest dto, Long parentId) {
// 매 게스트 댓글 작성마다 BCrypt(10) 실행 → 103ms
String hashedPassword = passwordEncoder.encode(dto.guestPassword());
...
}
t2.micro는 vCPU가 1개입니다. BCrypt(10) 요청이 몰릴수록 큐에 쌓여 대기시간이 누적됐고, 부하 테스트에서 Grafana CPU 100%, 게스트 댓글 작성 평균 응답시간 1,248ms가 나온 이유가 바로 이겁니다.
회원 비밀번호는 계정 자체를 보호합니다. DB가 털렸을 때 비밀번호가 뚫리면 해당 유저의 모든 개인정보, 다른 서비스의 동일 비밀번호까지 위험해집니다. strength=10 이상이 맞습니다.
게스트 비밀번호는 익명 게시글/댓글의 수정·삭제만 보호합니다. 탈취되더라도 피해 범위가 해당 게시글 하나입니다. strength=8로도 충분합니다.
보안 수준이 다른 대상에 동일한 비용을 쓰고 있었던 겁니다.
// SecurityConfig.java
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 회원용: strength=10 유지
}
@Bean
public PasswordEncoder guestPasswordEncoder() {
return new BCryptPasswordEncoder(8); // 게스트용: strength=8
}
같은 타입(PasswordEncoder)의 빈이 두 개가 됐는데, @Qualifier 없이도 필드명이 빈 이름과 일치하면 Spring이 자동으로 매칭해줍니다.
// PostService.java
@RequiredArgsConstructor
public class PostService {
private final PasswordEncoder guestPasswordEncoder; // → BCrypt(8) 자동 주입
}
// CommentService.java
@RequiredArgsConstructor
public class CommentService {
private final PasswordEncoder guestPasswordEncoder; // → BCrypt(8) 자동 주입
}
게스트 작성/수정/삭제 로직에서 guestPasswordEncoder를 쓰도록 바꿨고, 회원 로그인/가입에는 기존 passwordEncoder(BCrypt 10)를 그대로 유지했습니다.
// PostService.java - 변경 후
public Long createGuestPost(String slug, GuestPostCreateRequest dto) {
// BCrypt(10) → BCrypt(8): 103ms → 61ms
String encodedPassword = guestPasswordEncoder.encode(dto.guestPassword());
...
}
public boolean verifyGuestPassword(Long postId, String rawPassword) {
return guestPasswordEncoder.matches(rawPassword, post.getGuestPasswordHash());
}
// CommentService.java - 변경 후
public Long createGuestComment(Long postId, GuestCommentCreateRequest dto, Long parentId) {
String hashedPassword = guestPasswordEncoder.encode(dto.guestPassword());
...
}
| Thread Group | 역할 | 스레드 수 | 비율 |
|---|---|---|---|
| TG1 | 게시글 상세 조회 (비로그인) | 50 | 50% |
| TG2 | 회원 로그인 + 게시글/댓글 작성 | 20 | 20% |
| TG3 | 게스트 게시글/댓글 작성 | 20 | 20% |
| TG4 | 좋아요 | 10 | 10% |
읽기 50% / 쓰기 50% 비율, 총 100개 스레드로 구성했습니다.
전체 지표
| 지표 | 변경 전 | 변경 후 | 개선율 |
|---|---|---|---|
| 전체 평균 응답시간 | 322ms | 156ms | -51.5% |
| P95 | 1,961ms | 724ms | -63.1% |
| P99 | 3,014ms | 1,452ms | -51.8% |
| Throughput | 35.1 RPS | 38.1 RPS | +8.5% |
| 에러율 | 0% | 0% | 유지 |
엔드포인트별 주요 개선
| 엔드포인트 | 변경 전 | 변경 후 | 개선율 |
|---|---|---|---|
| POST /boards/{slug}/guest | 1,355ms | 600ms | -55.7% |
| POST /boards/{slug}/{postId}/comments/guest | 1,248ms | 540ms | -56.8% |
| GET /boards/{slug}/{postId} P99 | 1,985ms | 211ms | -89.4% |
게스트 작업 응답시간이 절반 이하로 줄었습니다. CPU 부하가 줄어들면서 GET 요청의 꼬리 지연(tail latency)도 함께 개선됐습니다.
strength를 낮추면 해시 강도가 달라지는 건 사실입니다. 다만 게스트 비밀번호가 보호하는 범위(게시글 하나의 수정/삭제 권한)와 회원 비밀번호가 보호하는 범위(계정 전체)의 차이를 생각하면 합리적인 트레이드오프라고 판단했습니다. 서버 성능에 여유가 생기면 strength를 다시 올릴 수 있습니다.
BCrypt strength는 "높을수록 좋다"가 아니라 보호하는 대상의 가치에 맞는 비용을 설정해야 한다는 게 이번 개선의 핵심이었습니다.