Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ dependencies {

implementation 'org.redisson:redisson-spring-boot-starter:3.27.0'

implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'

}

tasks.named('test') {
Expand Down
30 changes: 30 additions & 0 deletions backend/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
version: '3.8'
services:
promethus:
image: prom/prometheus:latest
container_name: prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
command:
- '--config.file=/etc/prometheus/prometheus.yml'
ports:
- "9090:9090"
networks:
- monitoring-network
grafana:
image: grafana/grafana:latest
container_name: grafana
ports:
- "3001:3000"
networks:
- monitoring-network
k6:
image: grafana/k6
volumes:
- ./scripts:/scripts
entrypoint: ""
command: ["k6", "run", "/scripts/myscript.js"]

networks:
monitoring-network:
driver: bridge
8 changes: 8 additions & 0 deletions backend/prometheus.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
global:
scrape_interval: 15s

scrape_configs:
- job_name: prometheus
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['host.docker.internal:8080']
36 changes: 36 additions & 0 deletions backend/scripts/myscript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import http from 'k6/http';
import { check } from 'k6';

function getRandomPageId(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}

export let options = {
scenarios: {
constant_request_rate: {
executor: 'ramping-arrival-rate',
startRate: 167, // 초당 약 167개의 요청으로 시작
timeUnit: '1s',
preAllocatedVus: 50, // 사전 할당된 VU 수
maxVUs: 100, // 최대 VU 수
stages: [
{ duration: '1m', target: 167},
]
}
}
}

export default function () {
const pageId = getRandomPageId(0,1);
const params = {
headers: {
'Content-Type': 'application/json',
},
};

let response = http.get('http://host.docker.internal:8080/api/posts/' + pageId, params);

check(response, {
'status is 200': (r) => r.status === 200,
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.RegexRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

Expand Down Expand Up @@ -76,7 +77,9 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
//경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/api/auth/login", "/", "/api/auth/signup","/swagger-ui/**","/v3/api-docs/**","/api/posts/details/**").permitAll()
.requestMatchers("/api/auth/login", "/", "/api/auth/signup","/swagger-ui/**","/v3/api-docs/**",
"/api/posts/details/**", "/actuator/health","/actuator/prometheus").permitAll()
.requestMatchers(RegexRequestMatcher.regexMatcher("/api/posts/\\d+")).permitAll()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

중요: /api/posts/\d+ 전부 permitAll은 DELETE/PATCH까지 무방비 — GET으로 한정 필요

현재 정규식 매처는 HTTP 메서드 제약이 없어 삭제·수정 등 민감 요청도 인증 없이 통과할 수 있습니다. GET만 허용하도록 제한하세요.

-                .requestMatchers(RegexRequestMatcher.regexMatcher("/api/posts/\\d+")).permitAll()
+                .requestMatchers(RegexRequestMatcher.regexMatcher(org.springframework.http.HttpMethod.GET, "/api/posts/\\d+")).permitAll()

또는 별도 import를 추가하고 간결히 표기할 수 있습니다:

import org.springframework.http.HttpMethod;
-                .requestMatchers(RegexRequestMatcher.regexMatcher("/api/posts/\\d+")).permitAll()
+                .requestMatchers(RegexRequestMatcher.regexMatcher(HttpMethod.GET, "/api/posts/\\d+")).permitAll()
🤖 Prompt for AI Agents
In backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java
around line 82, the RegexRequestMatcher currently permits all HTTP methods for
/api/posts/\d+; change it to only allow GET by specifying the HTTP method in the
matcher (use the RegexRequestMatcher constructor's second argument "GET" or use
HttpMethod.GET.name() with an added import) so DELETE/PATCH are not
unauthenticated, and keep the rest of the rule logic unchanged.

.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons
cookie.setPath("/");
cookie.setMaxAge(60 * 60 * 24 * 15); // 15 days
cookie.setHttpOnly(true);
request.setAttribute(COOKIE_NAME, guestId);
response.addCookie(cookie);
}
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ public ResponseEntity<PostResponse> update(@AuthenticationPrincipal CustomUserDe

// 게시글 목록 조회, 페이지네이션 적용
@GetMapping("/posts/{page}")
public ResponseEntity<PageResponse<PostResponse>> getPosts(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable int page) {
Long userId = customUserDetails.getUserId();
// null체크

public ResponseEntity<PageResponse<PostResponse>> getPosts(@AuthenticationPrincipal CustomUserDetails customUserDetails,
@PathVariable int page
) {
Long userId = (customUserDetails != null) ? customUserDetails.getUserId() : null;
Page<PostResponse> posts = postService.getPosts(userId, page);
PageResponse<PostResponse> response = new PageResponse<>(posts);
return ResponseEntity.ok(response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import jakarta.persistence.Index;

@Repository
public interface PostRepository extends JpaRepository<Post, Long>, PostRepositoryCustom {
Page<Post> findAllByDeletedAtIsNull(Pageable pageable);
Expand All @@ -25,5 +27,16 @@ public interface PostRepository extends JpaRepository<Post, Long>, PostRepositor
"WHERE p.deletedAt IS NULL " +
"GROUP BY p.id, p.title, p.readCount, u.name, u.id, p.createdAt, p.updatedAt, p.deletedAt"
)
Page<PostWithLikeCountProjection> findAllWithLikeCount(@Param("userId") Long userId, Pageable pageable);
Page<PostWithLikeCountProjection> findAllWithLikeCountForUser(@Param("userId") Long userId, Pageable pageable);

@Query(
"SELECT p.id AS id, p.title AS title, p.readCount AS readCount, u.name AS username, u.id AS userId, COUNT(l.id) AS likeCount, " +
"p.createdAt AS createdAt, p.updatedAt AS updatedAt, false AS isLiked " +
"FROM Post p " +
"JOIN p.user u " +
"LEFT JOIN Like l ON l.post.id = p.id " +
"WHERE p.deletedAt IS NULL " +
"GROUP BY p.id, p.title, p.readCount, u.name, u.id, p.createdAt, p.updatedAt, p.deletedAt"
)
Page<PostWithLikeCountProjection> findAllWithLikeCountForNonUser(Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package org.juniortown.backend.post.service;

import java.time.Clock;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
Expand All @@ -24,6 +26,7 @@
import org.juniortown.backend.user.entity.User;
import org.juniortown.backend.user.exception.UserNotFoundException;
import org.juniortown.backend.user.repository.UserRepository;
import org.springframework.dao.DataAccessException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
Expand Down Expand Up @@ -90,40 +93,54 @@ public PostResponse update(Long postId, Long userId, PostCreateRequest postCreat
}
@Transactional(readOnly = true)
public Page<PostResponse> getPosts(Long userId, int page) {
Pageable pageable = PageRequest.of(page, PAGE_SIZE, Sort.by("createdAt").descending());
Page<PostWithLikeCountProjection> postPage = postRepository.findAllWithLikeCount(userId, pageable);

List<Long> redisReadCounts = getReadCountFromRedisCache(postPage);

List<PostResponse> content = IntStream.range(0, postPage.getContent().size())
.mapToObj(i -> {
PostWithLikeCountProjection post = postPage.getContent().get(i);
Long redisReadCount = (redisReadCounts.size() > i && redisReadCounts.get(i) != null)
? redisReadCounts.get(i)
: 0L;
return PostResponse.builder()
.id(post.getId())
.title(post.getTitle())
.userId(post.getUserId())
.userName(post.getUsername())
.likeCount(post.getLikeCount())
.isLiked(post.getIsLiked())
.createdAt(post.getCreatedAt())
.updatedAt(post.getUpdatedAt())
.readCount(post.getReadCount() + redisReadCount)
.build();
})
Pageable pageable = PageRequest.of(page, PAGE_SIZE, Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")));

Page<PostWithLikeCountProjection> postPage = (userId == null)
? postRepository.findAllWithLikeCountForNonUser(pageable)
: postRepository.findAllWithLikeCountForUser(userId, pageable);

Map<Long,Long> redisReadCounts = getReadCountFromRedisCache(postPage);

List<PostResponse> content = postPage.getContent().stream()
.map(post -> PostResponse.builder()
.id(post.getId())
.title(post.getTitle())
.userId(post.getUserId())
.userName(post.getUsername())
.likeCount(post.getLikeCount())
.isLiked(Boolean.TRUE.equals(post.getIsLiked()))
.createdAt(post.getCreatedAt())
.updatedAt(post.getUpdatedAt())
.readCount(post.getReadCount() + redisReadCounts.getOrDefault(post.getId(), 0L))
.build())
.collect(Collectors.toList());


return new PageImpl<>(content, pageable, postPage.getTotalElements());
}

private List<Long> getReadCountFromRedisCache(Page<PostWithLikeCountProjection> postPage) {
List<String> keys = postPage.getContent().stream()
.map(post -> ViewCountService.VIEW_COUNT_KEY + post.getId())
.collect(Collectors.toList());
List<Long> redisReadCounts = redisTemplate.opsForValue().multiGet(keys);
return redisReadCounts;
private Map<Long,Long> getReadCountFromRedisCache(Page<PostWithLikeCountProjection> postPage) {
List<Long> ids = postPage.getContent().stream()
.map(PostWithLikeCountProjection::getId)
.toList();
if(ids.isEmpty()) return Map.of();

List<String> keys = ids.stream()
.map(id -> ViewCountService.VIEW_COUNT_KEY + id)
.toList();
try {
List<Long> readCounts = redisTemplate.opsForValue().multiGet(keys);

HashMap<Long, Long> map = new HashMap<>();
for (int i = 0; i < ids.size(); i++) {
Long readCount = (readCounts != null && i < readCounts.size()) ? readCounts.get(i) : null;
map.put(ids.get(i), (readCount != null) ? readCount : 0L);
}
return map;
} catch (DataAccessException e) {
log.error("Redis 장애: {}", e.getMessage());
return ids.stream().collect(Collectors.toMap(id -> id, id -> 0L));
}
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ public String getPassword() {

@Override
public String getUsername() {
return user.getEmail();
return user.getName();
}
Comment on lines 35 to 37
Copy link

@coderabbitai coderabbitai bot Aug 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

JWT/인증 컨텍스트 간 'username' 의미가 바뀌면서 JWTFilter와 불일치(버그 가능성)

이제 getUsername()이 name을 반환합니다. 그런데 JWTFilter는 여전히 토큰의 username 클레임을 User.email에 주입하고 있어, 인증 컨텍스트의 User.email이 사용자 이름으로 채워집니다. 이메일을 사용하는 로직(예: 권한 조회, 레포지토리 연동 등)에서 혼선/버그가 발생할 수 있습니다. JWTFilter에서 email은 getUserEmail(token)으로, name은 getUsername(token)으로 분리해 매핑하도록 수정이 필요합니다.

아래와 같이 JWTFilter의 사용자 재구성 로직을 정리하는 것을 권장합니다(참고용 코드):

// JWTFilter.java 내부 예시
String email = jwtUtil.getUserEmail(token);
String displayName = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
Long userId = jwtUtil.getUserId(token);

User user = User.builder()
    .id(userId)
    .email(email)
    .name(displayName) // 필요 시 name 세팅
    .role(role)
    .build();

토큰 클레임-도메인 매핑이 일관되게 적용되는지 점검해 주세요. 빠르게 확인하려면 다음 스크립트로 JWTFilter의 사용처를 찾아보세요.


🏁 Script executed:

#!/bin/bash
# JWTFilter에서 username/email 클레임 사용 위치 점검
rg -n -C3 'JWTFilter' backend/src/main/java
rg -n -C2 'getUsername\s*\(' backend/src/main/java/org/juniortown/backend/user/jwt
rg -n -C2 'getUserEmail\s*\(' backend/src/main/java/org/juniortown/backend/user/jwt

Length of output: 5390


🏁 Script executed:

#!/bin/bash
set -euo pipefail
files=(
  backend/src/main/java/org/juniortown/backend/user/jwt/JWTFilter.java
  backend/src/main/java/org/juniortown/backend/user/jwt/JWTUtil.java
  backend/src/main/java/org/juniortown/backend/user/jwt/LoginFilter.java
  backend/src/main/java/org/juniortown/backend/user/dto/CustomUserDetails.java
)
for f in "${files[@]}"; do
  echo "===== $f ====="
  sed -n '1,240p' "$f" || true
  echo
done

Length of output: 10063


JWTFilter: 토큰 클레임 → 도메인 필드 매핑 불일치 — 수정 필요

검증 결과 JWTFilter가 토큰의 "username" 클레임(jwtUtil.getUsername)을 User.email로 설정하고 있어 인증 컨텍스트에서 username/email의 의미가 섞여 있습니다. LoginFilter는 토큰 생성 시 email과 username을 각각 넣고 있으므로, JWTFilter에서 email은 getUserEmail(token)으로, 표시명(이름)은 getUsername(token)으로 분리 매핑해야 합니다.

수정 대상(요약)

  • backend/src/main/java/org/juniortown/backend/user/jwt/JWTFilter.java
    • 현재: String username = jwtUtil.getUsername(token); … User.builder().email(username) — 수정 필요
  • backend/src/main/java/org/juniortown/backend/user/dto/CustomUserDetails.java
    • getUsername()은 user.getName()을 반환(현재 상태) — 유지(단 JWTFilter가 name을 올바르게 세팅해야 함)
  • backend/src/main/java/org/juniortown/backend/user/jwt/LoginFilter.java
    • 토큰 생성 시 createJwt(userId, userEmail, username, …)로 둘 다 채움 — 일관성 확인(변경 불필요)

권장 수정 예시 (JWTFilter 내부):

String email = jwtUtil.getUserEmail(token);
String displayName = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
Long userId = jwtUtil.getUserId(token);

User user = User.builder()
    .id(userId)
    .email(email)
    .name(displayName)
    .role(role)
    .build();

토큰 클레임 ↔ 도메인 필드 매핑이 전체 코드(LoginFilter/JWTFilter/CustomUserDetails/토큰 생성/검증)에 걸쳐 일관된지 점검해 주세요.

🤖 Prompt for AI Agents
In backend/src/main/java/org/juniortown/backend/user/dto/CustomUserDetails.java
around lines 35-37, getUsername() currently returns user.getName() and should
remain returning the display name (no code change here); instead fix JWTFilter
to map JWT claims correctly by reading email via jwtUtil.getUserEmail(token) and
display name via jwtUtil.getUsername(token) when constructing the User (set
.email(email) and .name(displayName)), keep CustomUserDetails using
user.getName() as the principal display, and then verify LoginFilter still
issues both email and username claims so the mappings are consistent across
token creation and validation.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스프링 시큐리티 부분 너무 기억이 안나서 대충 코드를 짰네 부끄럽구먼유

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Doncham 전혀 부끄러워할 필요 없습니다! Spring Security는 복잡한 프레임워크라 세부사항을 기억하기 어려운 게 당연해요. 😊

JWT 토큰 클레임 매핑 부분만 정리해주시면 될 것 같습니다. 제가 찾은 JWTFilter 수정이 필요한 부분을 도와드릴까요? 아니면 나중에 시간 될 때 천천히 정리하셔도 괜찮습니다.

중요한 건 기능이 잘 동작한다는 점이니까요! 👍

  (\   /)
 ( ._. )
o_(")(")  *thoughtful rabbit*


public String getUserEmail() {return user.getEmail();}

public Long getUserId() {
return user.getId();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
filterChain.doFilter(request, response);
return;
}

String email = jwtUtil.getUserEmail(token);
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
Long userId = jwtUtil.getUserId(token);


//userEntity를 생성하여 값 set
User user = User.builder()
.id(userId)
.email(username)
.email(email)
.name(username)
.role(role)
.build();

Expand Down
17 changes: 13 additions & 4 deletions backend/src/main/java/org/juniortown/backend/user/jwt/JWTUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,20 @@
public class JWTUtil {

private final SecretKey secretKey;
private static final String CLAIM_USER_ID = "userId";
private static final String CLAIM_EMAIL = "email";
private static final String CLAIM_USERNAME = "username";
private static final String CLAIM_ROLE = "role";

public JWTUtil(@Value("${spring.jwt.secret}")String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}

public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}

public String getUserEmail(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("email", String.class);
}

Expand All @@ -35,12 +43,13 @@ public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}

public String createJwt(Long id, String email, String role, Long expiredMs) {
public String createJwt(Long id, String email, String username, String role, Long expiredMs) {

return Jwts.builder()
.claim("userId", id)
.claim("email", email)
.claim("role", role)
.claim(CLAIM_USER_ID, id)
.claim(CLAIM_EMAIL, email)
.claim(CLAIM_USERNAME, username)
.claim(CLAIM_ROLE, role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import org.juniortown.backend.user.dto.CustomUserDetails;
import org.juniortown.backend.user.dto.LoginDTO;
import org.juniortown.backend.user.response.LoginResultDTO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
Expand Down Expand Up @@ -48,6 +47,7 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
CustomUserDetails customUserDetails = (CustomUserDetails)authentication.getPrincipal();
String userEmail = customUserDetails.getUserEmail();
String username = customUserDetails.getUsername();
Long userId = customUserDetails.getUserId();

Expand All @@ -57,7 +57,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR

String role = auth.getAuthority();

String token = jwtUtil.createJwt(userId, username, role, 1000 * 60 * 60 * 24L ); // 24시간 유효한 토큰 생성
String token = jwtUtil.createJwt(userId, userEmail, username, role, 1000 * 60 * 60 * 24L ); // 24시간 유효한 토큰 생성

response.addHeader("Authorization", "Bearer " + token);

Expand Down
9 changes: 9 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ spring:
jwt:
secret: ${JWT_SECRET_KEY}

management:
endpoints:
web:
exposure:
include: ["health", "prometheus"]
endpoint:
health:
show-details: always




Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,16 +280,14 @@ void create_child_comment_fail_by_parent_post_not_exist() {
.build();




when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user));
when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post));
when(commentRepository.findById(parentId)).thenReturn(Optional.empty());

// when, then
Assertions.assertThatThrownBy(() -> commentService.createComment(USER_ID, commentCreateRequest))
.isInstanceOf(CircularReferenceException.class)
.hasMessage(CircularReferenceException.MESSAGE);
.isInstanceOf(CommentNotFoundException.class)
.hasMessage(CommentNotFoundException.MESSAGE);
verify(commentRepository, never()).save(any(Comment.class));
}
@Test
Expand Down
Loading