게시글 작성, 댓글, 회원 인증 기능을 갖춘 커뮤니티 웹 애플리케이션입니다.
- TypeScript
- Next.js 16
- React 19
- Tailwind CSS 4
- Axios — HTTP 클라이언트
- Java 17
- Spring Boot 3.5
- Spring Security — JWT 기반 인증/인가
- Spring Data JPA — ORM
- QueryDSL 5.1 — 타입 세이프 동적 쿼리
- Spring Data Redis — 토큰 저장소 / 캐시
- Spring Boot Starter Validation — 요청 검증
- JJWT 0.12 — JWT 생성 및 검증
- PostgreSQL — RDBMS
- Lombok — 보일러플레이트 제거
- Gradle — 빌드 도구
┌──────────────────┐ ┌──────────────────────┐ ┌─────────────┐
│ Next.js App │ HTTP │ Spring Boot API │ JPA │ PostgreSQL │
│ (Port 3000) │◄──────►│ (Port 8080) │◄──────►│ (Port 5432) │
│ │ │ │ └─────────────┘
│ - App Router │ │ - Spring Security │
│ - TypeScript │ │ - JWT Filter │ ┌─────────────┐
│ - Tailwind CSS │ │ - QueryDSL │◄──────►│ Redis │
└──────────────────┘ └──────────────────────┘ │ (Port 6379) │
└─────────────┘
community-project/
├── frontend/ # Next.js 프론트엔드
└── backend/ # Spring Boot 백엔드
└── src/main/java/com/community/backend/
├── auth/ # 인증 (JWT)
├── member/ # 회원
├── post/ # 게시글
├── comment/ # 댓글
├── common/ # 공통 모듈
└── config/ # 설정
모노레포가 아닌 백엔드/프론트엔드 분리 구조를 사용한다. 실무에서 배포 파이프라인이 다르고 팀이 나뉘는 경우가 대부분이므로, 처음부터 분리해서 시작한다.
spring:
profiles:
active: devserver:
port: 8080
spring:
datasource:
url: jdbc:postgresql://localhost:5432/community_dev
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
data:
redis:
host: localhost
port: 6379
jwt:
secret: ZGV2LXNlY3JldC1rZXktZm9yLWNvbW11bml0eS1wcm9qZWN0LW11c3QtYmUtbG9uZy1lbm91Z2g=
access-token-validity: 1800000 # 30분 (ms)
refresh-token-validity: 604800000 # 7일 (ms)
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace- Java 17 이상
- Node.js 18 이상
- Docker Desktop (Redis, PostgreSQL 컨테이너용)
# PostgreSQL
docker run -d --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=community_dev postgres:16-alpine
# Redis
docker run -d --name redis -p 6379:6379 redis:7-alpinecd backend
# 빌드
./gradlew build
# 실행
./gradlew bootRun서버가 http://localhost:8080 에서 시작됩니다.
# 프로젝트 생성 (최초 1회)
npx create-next-app@latest frontend --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd frontend
# 패키지 설치
npm install axios
# 개발 서버 실행
npm run dev개발 서버가 http://localhost:3000 에서 시작됩니다.
# 컨테이너 중지
docker stop postgres redis
# 컨테이너 재시작
docker start postgres redis
# 컨테이너 삭제
docker rm -f postgres redis요약: 게시글 상세 조회 시 조회수가 1이 아닌 2씩 증가하는 문제가 발생했다. React StrictMode에서 useEffect가 개발 모드에서 2번 실행되면서 동일 API가 2회 호출된 것이 직접 원인이었지만, 근본 원인은 서버에 중복 요청 방어 로직이 없는 것이었다. 초기에는 LocalStorage 기반 클라이언트 처리를 고려했으나, 클라이언트 조작 가능성과 데이터 신뢰성 문제로 인해 Redis SET NX + TTL(30분) 기반의 서버 사이드 중복 방지 구조로 전환했다. 클라이언트 IP를 식별자로 사용하여 동일 사용자의 TTL 내 중복 조회를 원자적 연산으로 차단하고, TTL 만료 후에는 자연스럽게 재조회를 허용하는 구조로 개선했다.
게시글 상세 조회 API(GET /api/posts/{postId})를 한 번 호출할 때마다 조회수가 1이 아닌 2씩 증가하는 현상이 발생했다.
요청 흐름을 추적하면 문제의 구조가 드러난다.
Browser → GET /api/posts/1
→ Spring Security Filter Chain
→ DispatcherServlet
→ PostController.getPost()
→ PostService.getPost() ← 여기서 viewCount +1 (Redis INCR)
→ return PostResponse
(동일 요청이 한 번 더 발생)
→ PostService.getPost() ← viewCount +1 (Redis INCR, 총 2)
React StrictMode에서 useEffect가 개발 모드에서 2번 실행되면서 동일 API가 2회 호출되었다.
그러나 프론트엔드 이중 호출은 개발 모드 특성일 뿐이고, 근본 원인은 서버에 중복 요청 방어 로직이 없다는 것이었다.
기존 ViewCountService는 요청이 들어올 때마다 무조건 INCR을 수행했다:
// Before — ViewCountService
public void increaseViewCount(Long postId) {
String key = "post:viewcount:" + postId;
redisTemplate.opsForValue().increment(key); // 호출될 때마다 무조건 +1
}// Before — PostService.getPost()
public PostResponse getPost(Long postId) {
Post post = postRepository.findById(postId)...;
viewCountService.increaseViewCount(postId); // 읽기 메서드 안에서 상태 변경, 중복 방어 없음
long redisCount = viewCountService.getViewCountFromRedis(postId);
// ...
}이 구조에서는 다음 상황 모두에서 조회수가 중복 증가한다:
- React
StrictMode에서useEffect이중 실행 - 브라우저 prefetch/prerender로 동일 URL 재요청
- 프론트엔드 데이터 재검증(revalidation) 시 재호출
- 사용자가 새로고침을 반복하는 경우
-
가설 1: DB 트랜잭션 중복 커밋 →
show-sql: true로 확인한 결과, INSERT/UPDATE 쿼리는 1회만 실행 → 기각 -
가설 2: Redis increment가 2번 호출 → Redis CLI에서
MONITOR명령으로 확인 →INCR post:viewcount:1명령이 실제로 2회 찍힘 -
가설 3: 컨트롤러가 2번 호출 →
PostController에 로그 추가 → 브라우저 Network 탭에서 동일 요청 2회 확인 (React StrictMode) -
근본 원인 확정 → 프론트엔드 이중 호출은 개발 모드 특성이지만, 서버에 동일 사용자의 중복 조회를 방어하는 로직이 없는 것이 근본 원인
초기에는 프론트엔드 LocalStorage에 viewed_posts 목록을 저장하여 이미 조회한 게시글은 조회수 증가 API를 호출하지 않는 방식을 고려했다.
// 초기 접근 (LocalStorage)
const viewedPosts = JSON.parse(localStorage.getItem('viewed_posts') || '[]');
if (!viewedPosts.includes(postId)) {
// API 호출
viewedPosts.push(postId);
localStorage.setItem('viewed_posts', JSON.stringify(viewedPosts));
}
그러나 이 방식에는 구조적 한계가 있었다:
| 문제 | 설명 |
|---|---|
| 클라이언트 조작 가능 | DevTools에서 LocalStorage를 삭제하면 무한 조회수 증가 가능 |
| 데이터 신뢰성 없음 | 시크릿 모드, 브라우저 변경 시 중복 방어 무력화 |
| 서버 통제 불가 | 조회수 정합성을 클라이언트에 의존하는 구조 |
조회수는 서버 상태이므로, 중복 방어도 서버에서 통제해야 한다.
이를 개선하기 위해 Redis SET NX + TTL 기반 중복 방지 구조로 전환했다.
Before — 무조건 증가, 중복 방어 없음:
// ViewCountService
public void increaseViewCount(Long postId) {
String key = "post:viewcount:" + postId;
redisTemplate.opsForValue().increment(key);
}
// PostService
public PostResponse getPost(Long postId) {
Post post = postRepository.findById(postId)...;
viewCountService.increaseViewCount(postId);
// ...
}After — Redis TTL 기반 서버 사이드 중복 방지:
// ViewCountService
private static final String VIEW_COUNT_PREFIX = "post:viewcount:";
private static final String VIEW_LOG_PREFIX = "post:viewed:";
private static final Duration VIEW_DUPLICATE_TTL = Duration.ofMinutes(30);
public boolean increaseViewCountIfAbsent(Long postId, String clientId) {
String viewLogKey = VIEW_LOG_PREFIX + postId + ":" + clientId;
// SET NX: 키가 없을 때만 저장 (원자적 연산)
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent(viewLogKey, "1", VIEW_DUPLICATE_TTL);
if (Boolean.TRUE.equals(isNew)) {
String countKey = VIEW_COUNT_PREFIX + postId;
redisTemplate.opsForValue().increment(countKey);
return true;
}
return false; // TTL(30분) 내 중복 요청 → 무시
}// PostController — 클라이언트 IP를 식별자로 추출
@GetMapping("/{postId}")
public ApiResponse<PostResponse> getPost(
@PathVariable("postId") Long postId,
HttpServletRequest request
) {
String clientId = resolveClientId(request);
PostResponse response = postService.getPostWithView(postId, clientId);
return ApiResponse.success(response);
}
private String resolveClientId(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isBlank()) {
ip = request.getRemoteAddr();
} else {
ip = ip.split(",")[0].trim();
}
return ip;
}// PostService
public PostResponse getPostWithView(Long postId, String clientId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND));
viewCountService.increaseViewCountIfAbsent(postId, clientId);
long redisCount = viewCountService.getViewCountFromRedis(postId);
return PostResponse.builder()
.id(post.getId())
.viewCount(post.getViewCount() + redisCount)
// ...
.build();
}post:viewcount:{postId} → 누적 조회수 (INCR)
post:viewed:{postId}:{clientId} → 중복 방지 플래그 (SET NX, TTL 30분)
예시: 사용자 192.168.1.1이 게시글 42를 조회하는 경우
1회차 요청:
SET post:viewed:42:192.168.1.1 "1" NX EX 1800 → OK (새 키 생성)
INCR post:viewcount:42 → 조회수 +1
2회차 요청 (30분 이내):
SET post:viewed:42:192.168.1.1 "1" NX EX 1800 → nil (이미 존재)
→ INCR 실행하지 않음 → 조회수 변동 없음
30분 경과 후 재요청:
TTL 만료 → 키 자동 삭제
SET post:viewed:42:192.168.1.1 "1" NX EX 1800 → OK (새 조회로 인정)
INCR post:viewcount:42 → 조회수 +1
| 항목 | Before | After |
|---|---|---|
| 중복 방어 | 없음 | Redis SET NX + TTL 30분 |
| 통제 주체 | 없음 (또는 클라이언트) | 서버 (Redis) |
| 조작 가능성 | LocalStorage 삭제로 우회 가능 | 서버에서 차단, 클라이언트 조작 불가 |
| 동시성 안전 | INCR만 사용 |
SET NX 원자적 연산으로 Race Condition 방지 |
| 재조회 허용 | 불가능 (또는 무제한) | TTL 만료 후 자연스럽게 재조회 허용 |
요약: @AuthenticationPrincipal로 memberId를 주입받을 때 항상 null이 반환되는 문제가 발생했다. JWT 토큰 파싱과 Authentication 객체 생성은 정상이었으나, GenericFilterBean을 상속한 필터가 포워드/인클루드 시 2번 실행되면서 두 번째 실행에서 토큰 없이 SecurityContext가 덮어씌워지는 것이 원인이었다. OncePerRequestFilter로 전환하여 요청당 필터 실행을 1회로 보장하고, 토큰 블랙리스트 검증과 예외 시 SecurityContext 초기화 로직을 추가하여 해결했다.
@AuthenticationPrincipal Long memberId로 컨트롤러에서 인증된 사용자 ID를 받으려 했으나, 항상 null이 주입되었다.
@PostMapping
public ApiResponse<PostResponse> createPost(
@AuthenticationPrincipal Long memberId, // ← null
@Valid @RequestBody PostCreateRequest request
) { ... }JWT 토큰은 정상적으로 발급되고, 인증 필터도 예외 없이 통과하는 상태였다.
Spring Security의 @AuthenticationPrincipal 동작 흐름을 추적하면 원인이 보인다.
1. 요청 진입
→ JwtAuthenticationFilter.doFilterInternal()
2. Authentication 객체 생성
→ UsernamePasswordAuthenticationToken(principal, credentials, authorities)
→ principal 자리에 memberId(Long)를 넣음
3. SecurityContext에 저장
→ SecurityContextHolder.getContext().setAuthentication(authentication)
4. 컨트롤러 파라미터 리졸빙
→ @AuthenticationPrincipal
→ AuthenticationPrincipalArgumentResolver가 동작
→ SecurityContextHolder → Authentication → getPrincipal() 호출
→ 반환된 객체를 파라미터 타입(Long)으로 캐스팅
문제는 3단계에서 Authentication이 SecurityContext에 저장되지 않는 경우였다.
초기 구현에서 JwtAuthenticationFilter가 OncePerRequestFilter가 아닌 일반 GenericFilterBean을 상속했고, Spring Security의 필터 체인 등록 방식에 따라 하나의 요청에서 필터가 2번 실행되면서 두 번째 실행 시 토큰이 없는 상태로 SecurityContextHolder가 덮어씌워지는 상황이었다.
또 다른 원인으로는 UserDetails를 구현한 객체가 아닌 Long 타입을 principal로 사용할 때 발생하는 타입 불일치가 있었다. @AuthenticationPrincipal은 내부적으로 Authentication.getPrincipal()의 반환값을 파라미터 타입에 맞게 주입하는데, Spring Security의 기본 AuthenticationPrincipalArgumentResolver는 타입이 정확히 일치해야 주입한다.
-
가설 1: 토큰 파싱 실패 →
JwtTokenProvider.parseClaims()에 로그 추가 → claims 정상 파싱, memberId 추출 성공 → 기각 -
가설 2: Authentication 객체가 SecurityContext에 저장되지 않음 → 필터 내부에서
SecurityContextHolder.getContext().getAuthentication()로그 추가 → 필터 실행 직후에는 정상 저장되어 있으나, 컨트롤러 진입 시점에는null→ 필터 중복 실행 의심 -
가설 3: 필터가 중복 실행되면서 Context가 초기화됨 →
GenericFilterBean→OncePerRequestFilter로 변경하여 요청당 1회 실행 보장 → 여전히null→ 다른 원인 존재 -
가설 4: principal 타입 문제 → 디버깅으로
Authentication.getPrincipal()반환 타입 확인 →Long객체 정상 반환 → 그러나@AuthenticationPrincipal의 내부 resolver가Long을 직접 매핑하지 못하는 케이스 확인 → principal을Long으로 넣되, Spring의 타입 리졸빙이 정상 동작하는지 확인 필요 -
근본 원인 확정 →
OncePerRequestFilter적용 +UsernamePasswordAuthenticationToken의 첫 번째 인자(principal)에Long타입을 직접 전달하는 방식이 올바르게 동작하려면, 필터가 정확히 1회 실행되고 SecurityContext가 유지되어야 함. 필터 중복 실행이 1차 원인이었고, 이를 해결하자 정상 동작함.
Before — GenericFilterBean 상속, 필터 중복 실행 가능:
public class JwtAuthenticationFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
String token = resolveToken((HttpServletRequest) request);
if (token != null && jwtTokenProvider.validateToken(token)) {
var claims = jwtTokenProvider.parseClaims(token);
Long memberId = Long.parseLong(claims.getSubject());
String role = claims.get("role", String.class);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
memberId, null,
List.of(new SimpleGrantedAuthority("ROLE_" + role)));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}After — OncePerRequestFilter 상속으로 요청당 1회 실행 보장:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
if (tokenBlacklistService.isBlacklisted(token)) {
SecurityContextHolder.clearContext();
filterChain.doFilter(request, response);
return;
}
try {
var claims = jwtTokenProvider.parseClaims(token);
Long memberId = Long.parseLong(claims.getSubject());
String role = claims.get("role", String.class);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
memberId, // principal → @AuthenticationPrincipal로 꺼냄
null,
List.of(new SimpleGrantedAuthority("ROLE_" + role)));
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
log.warn("JWT 인증 실패: {}", e.getMessage());
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
}핵심 변경점:
| 항목 | Before | After |
|---|---|---|
| 상속 클래스 | GenericFilterBean |
OncePerRequestFilter |
| 요청당 실행 | 보장 안 됨 (포워드/인클루드 시 재실행) | 1회 보장 |
| 블랙리스트 확인 | 없음 | TokenBlacklistService 추가 |
| 예외 처리 | 없음 (파싱 실패 시 500) | try-catch로 Context 초기화 |
1. HTTP 요청 수신
↓
2. JwtAuthenticationFilter (OncePerRequestFilter)
- Authorization 헤더에서 Bearer 토큰 추출
- JwtTokenProvider.parseClaims()로 Claims 파싱
- claims.getSubject() → memberId (Long)
↓
3. Authentication 객체 생성
- new UsernamePasswordAuthenticationToken(memberId, null, authorities)
- 첫 번째 인자(principal) = memberId (Long)
↓
4. SecurityContextHolder에 저장
- SecurityContextHolder.getContext().setAuthentication(authentication)
- 현재 스레드의 SecurityContext에 바인딩
↓
5. DispatcherServlet → Controller 진입
↓
6. @AuthenticationPrincipal Long memberId
- AuthenticationPrincipalArgumentResolver 동작
- SecurityContextHolder → Authentication → getPrincipal()
- 반환된 Long 객체가 파라미터에 주입
OncePerRequestFilter적용으로 포워드/리다이렉트 시에도 필터가 정확히 1회만 실행@AuthenticationPrincipal Long memberId로 컨트롤러에서 안전하게 인증 사용자 ID를 주입받음UserDetails구현 없이Long타입을 principal로 직접 사용하는 경량 인증 구조 유지- 토큰 블랙리스트 검증과 예외 처리가 추가되어 필터 안정성 향상
요약: 스케일 아웃(서버 증설) 이전에 단일 서버에서 처리할 수 있는 성능을 최대한 끌어올리기 위해, DB 인덱스 추가와 HikariCP 커넥션 풀 튜닝을 적용했다. 인덱스가 없는 상태에서 게시글 검색·정렬·댓글 조회 시 Full Table Scan이 발생하고, 기본 커넥션 풀(10개)로는 동시 요청이 몰릴 때 커넥션 대기 병목이 생기는 구조적 문제가 있었다. 이를 기존에 적용된 Redis 조회수 버퍼링, JPA N+1 방지, Stateless JWT와 결합하여 단일 서버 기준의 종합적인 성능 최적화를 완성했다.
단일 서버 환경에서 트래픽이 증가할 때, 애플리케이션 레벨(Redis 버퍼링, fetchJoin 등)의 최적화만으로는 해결할 수 없는 두 가지 병목 지점이 남아 있었다.
병목 1 — DB 인덱스 부재
게시글 목록 조회 시 created_at 기준 정렬, title 검색, member_id 기준 JOIN이 빈번하게 발생한다. 인덱스가 없으면 PostgreSQL은 매번 Full Table Scan을 수행한다.
-- 인덱스 없는 상태에서의 실행 계획
EXPLAIN SELECT * FROM posts ORDER BY created_at DESC LIMIT 10;
Seq Scan on posts (cost=0.00..12345.00)
→ 테이블 전체를 순차적으로 읽은 후 정렬
→ 데이터가 10만 건이면 10만 건을 모두 읽고 정렬
특히 QueryDSL로 동적 검색을 수행하는 PostQueryRepository에서 다음 쿼리들이 인덱스 없이 실행되고 있었다:
// PostQueryRepository.java — 인덱스가 필요한 쿼리 패턴
queryFactory.selectFrom(post)
.join(post.member).fetchJoin() // member_id FK → 인덱스 필요
.where(post.title.containsIgnoreCase(keyword)) // title → 인덱스 필요
.orderBy(post.createdAt.desc()) // created_at → 인덱스 필요
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();병목 2 — HikariCP 기본 커넥션 풀(10개)
Spring Boot의 HikariCP 기본 설정은 maximumPoolSize = 10이다. 동시에 10개 이상의 요청이 DB 접근을 필요로 하면, 나머지 요청은 커넥션을 얻을 때까지 대기해야 한다.
동시 요청 15개 발생 시:
요청 1~10 → 커넥션 즉시 획득 → 쿼리 실행
요청 11~15 → 커넥션 풀 고갈 → 대기 (기본 timeout: 30초)
→ 30초 후에도 얻지 못하면 SQLTransientConnectionException 발생
기본 connection-timeout이 30초(30000ms)로 설정했어서, 사용자는 응답 없이 30초를 기다린 후 에러를 받게 된다.
단일 서버에서 최대 성능을 끌어내기 위해 DB 레벨과 커넥션 레벨 두 방향에서 튜닝했다.
JPA @Table(indexes = ...) 어노테이션으로 엔티티에 인덱스를 정의했다. ddl-auto: update 설정에 의해 애플리케이션 실행 시 Hibernate가 자동으로 인덱스를 생성한다.
Post 엔티티:
@Entity
@Table(name = "posts", indexes = {
@Index(name = "idx_posts_member_id", columnList = "member_id"),
@Index(name = "idx_posts_created_at", columnList = "createdAt"),
@Index(name = "idx_posts_title", columnList = "title"),
@Index(name = "idx_posts_view_count", columnList = "viewCount")
})
public class Post { ... }Comment 엔티티:
@Entity
@Table(name = "comments", indexes = {
@Index(name = "idx_comments_post_id", columnList = "post_id"),
@Index(name = "idx_comments_member_id", columnList = "member_id"),
@Index(name = "idx_comments_created_at", columnList = "createdAt")
})
public class Comment { ... }각 인덱스의 역할:
| 인덱스 | 대상 쿼리 | 효과 |
|---|---|---|
idx_posts_member_id |
JOIN post.member, 작성자별 게시글 조회 |
FK JOIN 시 Full Scan → Index Scan |
idx_posts_created_at |
ORDER BY createdAt DESC LIMIT 10 |
정렬 후 자르기 → 인덱스에서 바로 상위 10건 추출 |
idx_posts_title |
WHERE title LIKE '%keyword%' |
제목 검색 시 스캔 범위 축소 |
idx_posts_view_count |
ORDER BY viewCount DESC |
인기순 정렬 시 Full Sort → Index Scan |
idx_comments_post_id |
WHERE post_id = ?, COUNT(post_id) |
게시글별 댓글 조회/카운트 최적화 |
idx_comments_member_id |
JOIN comment.member |
댓글 작성자 JOIN 최적화 |
idx_comments_created_at |
ORDER BY createdAt ASC |
댓글 시간순 정렬 최적화 |
-- 인덱스 적용 후 실행 계획
EXPLAIN SELECT * FROM posts ORDER BY created_at DESC LIMIT 10;
Index Scan Backward using idx_posts_created_at on posts (cost=0.00..1.23)
→ 인덱스에서 역순으로 10건만 읽음
→ 데이터가 100만 건이어도 10건만 접근
# application-dev.yml
spring:
datasource:
hikari:
maximum-pool-size: 30 # 최대 커넥션 수 (기본 10 → 30)
minimum-idle: 10 # 유휴 시에도 유지할 최소 커넥션 수
connection-timeout: 3000 # 커넥션 획득 대기 시간 (30초 → 3초)
idle-timeout: 600000 # 유휴 커넥션 유지 시간 (10분)
max-lifetime: 1800000 # 커넥션 최대 수명 (30분)각 설정의 의미:
| 설정 | 기본값 | 변경값 | 이유 |
|---|---|---|---|
maximum-pool-size |
10 | 30 | 동시 요청 30개까지 커넥션 대기 없이 처리 |
minimum-idle |
10 | 10 | 트래픽이 낮을 때에도 10개 커넥션을 미리 준비하여 Cold Start 방지 |
connection-timeout |
30000ms | 3000ms | 커넥션을 3초 내에 얻지 못하면 즉시 실패 → 사용자가 30초 대기하는 대신 빠르게 에러 응답 |
idle-timeout |
600000ms | 600000ms | 10분간 사용하지 않은 커넥션을 minimum-idle까지 반환 |
max-lifetime |
1800000ms | 1800000ms | 30분마다 커넥션 교체 → DB 측 타임아웃 전에 선제적으로 갱신 |
변경 전 (pool-size: 10, timeout: 30s):
동시 요청 20개 → 10개 즉시 처리, 10개는 최대 30초 대기
→ 사용자 체감: 일부 요청이 30초 후 타임아웃 에러
변경 후 (pool-size: 30, timeout: 3s):
동시 요청 20개 → 20개 모두 즉시 처리 (풀 여유 10개)
동시 요청 35개 → 30개 즉시 처리, 5개는 3초 내 획득 시도 → 실패 시 빠른 에러 응답
→ 사용자 체감: 대부분 즉시 응답, 초과 시에도 빠르게 재시도 유도
maximum-pool-size를 30으로 설정한 근거: HikariCP 공식 문서에서 권장하는 공식은 pool size = (core_count * 2) + effective_spindle_count이다. 일반적인 4코어 서버 기준으로 (4 * 2) + 1 = 9가 최소 권장이며, 여유를 두어 30으로 설정했다. 너무 크게 설정하면 오히려 PostgreSQL의 컨텍스트 스위칭 오버헤드가 증가하므로, PostgreSQL의 max_connections(기본 100)를 고려하여 30~50 사이가 적절하다.
단일 서버 기준으로 적용된 모든 최적화를 계층별로 정리하면 다음과 같다:
┌────────────────────────────────────────────────────────────┐
│ 클라이언트 계층 │
│ │
│ • Stateless JWT — 서버 세션 없음, 수평 확장 준비 완료 │
│ • Axios 인터셉터 — 401 시 자동 토큰 재발급 (불필요한 재요청 방지)│
│ • Promise.all — 게시글+댓글 병렬 요청 (RTT 절감) │
├────────────────────────────────────────────────────────────┤
│ 애플리케이션 계층 │
│ │
│ • Redis 조회수 버퍼링 — DB 쓰기 부하 제거 (INCR + 5분 배치) │
│ • SET NX + TTL — 중복 조회 원자적 차단 (불필요한 쓰기 제거) │
│ • JPA LAZY 로딩 + fetchJoin — N+1 문제 방지 │
│ • batch_fetch_size: 100 — LAZY 로딩 안전망 │
│ • @Transactional(readOnly) — 읽기 시 Dirty Checking 생략 │
│ • QueryDSL 페이지네이션 — 필요한 데이터만 조회 │
├────────────────────────────────────────────────────────────┤
│ 커넥션 계층 │
│ │
│ • HikariCP 풀 튜닝 — 최대 30개, 최소 유휴 10개 │
│ • connection-timeout: 3초 — 빠른 실패 전략 │
│ • max-lifetime: 30분 — DB 타임아웃 전 선제 교체 │
├────────────────────────────────────────────────────────────┤
│ 데이터베이스 계층 │
│ │
│ • Post 인덱스 — member_id, created_at, title, viewCount │
│ • Comment 인덱스 — post_id, member_id, created_at │
│ • Full Table Scan → Index Scan 전환 │
└────────────────────────────────────────────────────────────┘
| 항목 | Before | After |
|---|---|---|
| DB 인덱스 | 없음 (Full Table Scan) | 7개 인덱스 적용 (Index Scan) |
| 커넥션 풀 | 기본 10개, 타임아웃 30초 | 최대 30개, 타임아웃 3초 |
| 조회수 처리 | Redis 버퍼링 적용 완료 | (유지) |
| N+1 방지 | fetchJoin + batch_fetch_size 적용 완료 | (유지) |
| 인증 구조 | Stateless JWT 적용 완료 | (유지) |
현재 단일 서버에서 할 수 있는 주요 튜닝은 완료된 상태이다. 이후 트래픽이 더 증가하면 다음 단계로 확장할 수 있다:
| 단계 | 내용 | 적용 시점 |
|---|---|---|
| 1단계 | GZIP 압축, @Cacheable 캐싱, Rate Limiting | 단일 서버 한계 도달 전 |
| 2단계 | Nginx 리버스 프록시, 로드 밸런서 | 단일 서버 한계 도달 시 |
| 3단계 | DB Read Replica, Redis Cluster | 데이터 계층 병목 시 |
| 4단계 | Kafka 이벤트 기반, 마이크로서비스 분리 | 서비스 규모 확장 시 |