Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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']
39 changes: 39 additions & 0 deletions backend/scripts/myscript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 payload = JSON.stringify({
userId: pageId
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};

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

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

http.get에 body 전달은 잘못된 시그니처 — 현재 payload/세 번째 인자(params)는 무시되거나 오동작합니다

k6의 http.get(url, params?)는 바디를 받지 않습니다. 바디가 필요하면 http.post 등을 사용해야 합니다. GET이라면 바디/Content-Type 제거 후 params만 전달하세요.

-    const payload = JSON.stringify({
-        userId: pageId
-    });
-    const params = {
-        headers: {
-            'Content-Type': 'application/json',
-        },
-    };
-
-    let response = http.get('http://host.docker.internal:8080/api/posts/' + pageId, payload, params);
+    const params = {
+        headers: {
+            'Accept': 'application/json',
+        },
+    };
+    let response = http.get(`http://host.docker.internal:8080/api/posts/${pageId}`, params);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let response = http.get('http://host.docker.internal:8080/api/posts/' + pageId, payload, params);
const params = {
headers: {
'Accept': 'application/json',
},
};
let response = http.get(`http://host.docker.internal:8080/api/posts/${pageId}`, params);
🤖 Prompt for AI Agents
In backend/scripts/myscript.js around lines 34-35, the http.get call wrongly
passes a payload and params (http.get(url, params?) accepts no body); either
change the request to http.post/http.put when a body is required, or remove the
payload and call http.get(url, params) so only headers/query params are passed;
also remove any Content-Type/body-related headers when using GET.

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/**").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 " +
"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 @@ -21,6 +21,10 @@ public JWTUtil(@Value("${spring.jwt.secret}")String secret) {
}

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,11 +39,12 @@ 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("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
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
10 changes: 10 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ spring:
jwt:
secret: ${JWT_SECRET_KEY}

management:
endpoints:
web:
exposure:
include:
"*"
endpoint:
health:
show-details: always

Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Actuator 전체 공개(*)와 health 상세(always)는 운영 보안 리스크 — 최소 노출 및 권한 기반으로 제한 권장

현재 설정은 모든 Actuator 엔드포인트를 무인증으로 노출할 가능성이 높습니다(SecurityConfig에서 /actuator/** permitAll이라고 가정). 운영/공개 환경에서는 health,prometheus만 노출하고, health 상세는 when_authorized로 제한하는 구성을 권장합니다.

다음과 같이 조정해 주세요:

 management:
-  endpoints:
-    web:
-      exposure:
-        include:
-          "*"
-  endpoint:
-    health:
-      show-details: always
+  endpoints:
+    web:
+      exposure:
+        include: "health,prometheus"
+  endpoint:
+    health:
+      show-details: when_authorized
+      # 필요 시 특정 역할만 상세 보이도록
+      # roles: ACTUATOR

또한 SecurityConfig에서 /actuator/** 전체 permitAll() 대신 /actuator/health, /actuator/prometheuspermitAll()로 좁히는 것을 권장합니다. 원하시면 해당 변경 패치도 제안드리겠습니다.

다음 스크립트로 보안 설정을 빠르게 훑어볼 수 있습니다:


🏁 Script executed:

#!/bin/bash
# /actuator/** 무차별 permitAll 사용 여부와 health/prometheus 개별 허용 여부 확인
rg -n -C3 -tjava 'requestMatchers\((.|\n)*actuator' backend/src/main/java || true
rg -n -C3 -tjava 'permitAll\(\).*actuator' backend/src/main/java || true

Length of output: 348


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "=== Searching for 'actuator' references in Java sources ==="
rg -n --hidden -S -U -C3 'actuator' backend/src/main/java || true

echo "=== Searching for security matcher or permitAll usages ==="
rg -n --hidden -S -U -C3 'requestMatchers|antMatchers|mvcMatchers|permitAll|WebSecurityConfigurerAdapter|SecurityFilterChain' backend/src/main/java || true

echo "=== Searching for literal '/actuator' usages ==="
rg -n --hidden -S -F -C3 '/actuator' backend/src/main/java || true

echo "=== Find classes with 'Security' in name ==="
rg -n --hidden -S -U -C1 'class\s+\w*Security\w*' backend/src/main/java || true

echo "=== List application*.yml in resources ==="
rg --hidden --files backend/src/main/resources | rg '\.yml$' || true

echo "=== Show backend/src/main/resources/application.yml (if exists) ==="
[ -f backend/src/main/resources/application.yml ] && sed -n '1,240p' backend/src/main/resources/application.yml || true

Length of output: 7201


조치 필요: Actuator 엔드포인트 공개 및 health 상세 노출 제한하세요

검증 결과 backend/src/main/resources/application.yml의 management 설정이 전체("*")로 노출되고(management.endpoint.health.show-details: always), backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java에서 "/actuator/**"를 permitAll()로 허용하고 있음을 확인했습니다(보안 리스크).

문제 위치:

  • backend/src/main/resources/application.yml — management 섹션 (exposure: "*" 및 health.show-details: always)
  • backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java — ~81행: .requestMatchers(..., "/actuator/**").permitAll()

권장 변경 (간단한 패치 예시):

--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@
 management:
-  endpoints:
-    web:
-      exposure:
-        include:
-          "*"
-  endpoint:
-    health:
-      show-details: always
+  endpoints:
+    web:
+      exposure:
+        include:
+          - health
+          - prometheus   # prometheus 사용 시에만 추가
+  endpoint:
+    health:
+      show-details: when_authorized
+      # roles: ACTUATOR
--- a/backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java
+++ b/backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java
@@
-                .requestMatchers("/api/auth/login", "/", "/api/auth/signup","/swagger-ui/**","/v3/api-docs/**",
-                    "/api/posts/details/**", "/actuator/**").permitAll()
+                .requestMatchers("/api/auth/login", "/", "/api/auth/signup","/swagger-ui/**","/v3/api-docs/**",
+                    "/api/posts/details/**", "/actuator/health", "/actuator/prometheus").permitAll()

요약: 운영 환경에서는 Actuator를 최소 노출(health, 필요 시 prometheus)로 제한하고, health 상세는 when_authorized + 특정 역할으로만 보이게 하세요. SecurityConfig의 "/actuator/**" 전체 허용을 즉시 좁히시기 바랍니다. 필요하면 변경 패치 제안드리겠습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
management:
endpoints:
web:
exposure:
include:
"*"
endpoint:
health:
show-details: always
management:
endpoints:
web:
exposure:
include:
- health
- prometheus # prometheus 사용 시에만 추가
endpoint:
health:
show-details: when_authorized
# roles: ACTUATOR
🤖 Prompt for AI Agents
In backend/src/main/resources/application.yml around lines 32-41 and
backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java around
line ~81, the Actuator endpoints are fully exposed ("*") and health.details is
set to always while SecurityConfig permits "/actuator/**" to everyone; change
application.yml to expose only minimal endpoints (e.g., health and
metrics/prometheus as needed) and set health.show-details to when_authorized,
and update SecurityConfig to restrict "/actuator/**" access to
authenticated/authorized roles (or narrow to specific endpoints like
"/actuator/health" and "/actuator/prometheus") instead of permitAll().




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