
0️⃣ 에러 분석
포크 받았는데 yml이나 properties resource 파일 어디 갔을까요. 나 보려고 했는데 일단 넘깁시다.
일단 에러를 분석해야 되니까 밑에서부터 차근차근 봅시다.

그리고 그 위



오케이 대충 알겠고...... 전체 검색으로 찾아보자 무슨 짓을 하셨나요?

일단 `@Value` 어노테이션이란?
properties에 보관되어 있는 값을 가져오는 역할을 한대요. 구글링.....

생각해 보니까 어이가 없습니다. 인텔리제이에서 프로젝트 만들면 리소스 자동으로 일단 세팅해 주고 시작하는데 이게 없다구요? ㅎㅎ 처음부터 내 세팅에 맞추려고 확인해 봤는데 없을 때부터 알아봤습니다, 제가.

spring:
application:
name: expert
datasource:
url: jdbc:mysql://localhost:3306/expert
driver-class-name: com.mysql.cj.jdbc.Driver
username:
password:
jpa:
show-sql: true
hibernate:
ddl-auto: create
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
logging:
level:
org.hibernate.type: trace
jwt:
secret:
key: "PI1v50JKao5QZoxY3J3KpUWavk1y9AKcih3SAoofZ/4="
secret key에다가는 base64 랜덤으로 생성해 달라고 했다. 강의에 나온대로 그 사이트에 들어가서 해야 될 것 같은데.
base64 문자열 랜덤 생성 사이트입니다.
Base64 Encode and Decode - Online
Encode to Base64 format or decode from it with various advanced options. Our site has an easy to use online tool to convert your data.
www.base64encode.org
아 데이터베이스도 생성해 준다. 까먹지 말자.

성공
1️⃣ ArgumentResolver

일단 ArgumentResolver를 가 보면

WebConfig 어디 갔지? 이거 등록해 주는 것 있어야 되는데 없네.
package org.example.expert.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new AuthUserArgumentResolver());
}
}
만들어 주자 등록하자


성공이다.
2️⃣ 코드 개선
1. Early Return

앞에 로직들이 굳이.....? 앞에서 실행할 필요가 있나 뒤에서 어차피 이메일 중복체크를 하고 throw 되고 return이 될 거면 앞에 로직들을 먼저 수행할 필요가 없다. 올려.
@Transactional
public SignupResponse signup(SignupRequest signupRequest) {
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new InvalidRequestException("이미 존재하는 이메일입니다.");
}
String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
UserRole userRole = UserRole.of(signupRequest.getUserRole());
User newUser = new User(
signupRequest.getEmail(),
encodedPassword,
userRole
);
User savedUser = userRepository.save(newUser);
String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole);
return new SignupResponse(bearerToken);
}
2. 불필요한 if-else 피하기

따로 분리해서 작성을 하면 더 가독성이 좋을 것 같은데요? 위에 if문이 true가 나와서 throw로 던져질 거면......
public String getTodayWeather() {
ResponseEntity<WeatherDto[]> responseEntity =
restTemplate.getForEntity(buildWeatherApiUri(), WeatherDto[].class);
if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
}
WeatherDto[] weatherArray = responseEntity.getBody();
if (weatherArray == null || weatherArray.length == 0) {
throw new ServerException("날씨 데이터가 없습니다.");
}
바꿔 분리해.
+ 그런데 상태코드 먼저 체크를 하고 body를 꺼내야 되지 않을까? 순서도 바꿈
+ 지원님과의 토론...... 맞는 말이다
3. Validation

UserChangePasswordRequest 가 보자.
gradle에 이미 있네. 쓸 수 있는데 왜 안 썼어.

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserChangePasswordRequest {
@NotBlank
private String oldPassword;
@NotBlank
@Size(min = 8, message = "새 비밀번호는 8자 이상이어야 합니당")
@Pattern(regexp = ".*\\d.*", message = "새 비밀번호에는 숫자가 포함되어야 합니당")
@Pattern(regexp = ".*[A-Z].*", message = "새 비밀번호에는 대문자가 포함되어야 합니당")
private String newPassword;
// if (userChangePasswordRequest.getNewPassword().length() < 8 ||
// !userChangePasswordRequest.getNewPassword().matches(".*\\d.*") ||
// !userChangePasswordRequest.getNewPassword().matches(".*[A-Z].*")) {
// throw new InvalidRequestException("새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.");
// }
}
그리고 컨트롤러에 `@Valid` 어노테이션 붙여 주고, Service에서 저거 삭제하면 끝.
3️⃣ N+1 문제


public interface TodoRepository extends JpaRepository<Todo, Long> {
@EntityGraph(attributePaths = "user")
Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);
@Query("SELECT t FROM Todo t " +
"LEFT JOIN FETCH t.user " +
"WHERE t.id = :todoId")
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
int countById(Long todoId);
}
user랑 연결해 주세요

포스트맨으로 확인해 보니까 fetch join처럼 잘 나간다.
4️⃣ 테스트코드 연습
1. 테스트 코드 연습 - 1 (예상대로 성공하는지에 대한 케이스)



디버깅 해 보니까 나오는 왜 반대로 넣고 있어요?
@Test
void matches_메서드가_정상적으로_동작한다() {
// given
String rawPassword = "testPassword";
String encodedPassword = passwordEncoder.encode(rawPassword);
// when
boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);
// then
assertTrue(matches);
}
위치 바꾸면 테스트 통과합니다.
2. 테스트 코드 연습 - 2 (예상대로 예외처리 하는지에 대한 케이스)
1번 케이스


그래서 Todo not found가 어디서 쓰나요. 전체검색을 한다.
@Transactional
public CommentSaveResponse saveComment(AuthUser authUser, long todoId, CommentSaveRequest commentSaveRequest) {
User user = User.fromAuthUser(authUser);
Todo todo = todoRepository.findById(todoId).orElseThrow(() ->
new InvalidRequestException("Todo not found"));
Comment newComment = new Comment(
commentSaveRequest.getContents(),
user,
todo
);
Comment savedComment = commentRepository.save(newComment);
return new CommentSaveResponse(
savedComment.getId(),
savedComment.getContents(),
new UserResponse(user.getId(), user.getEmail())
);
}
여기서 사용 중이고
public class InvalidRequestException extends RuntimeException {
public InvalidRequestException(String message) {
super(message);
}
}
아 애초에 이렇게 해서 음 그럼 바꾸자
@Test
public void manager_목록_조회_시_Todo가_없다면_IRE_에러를_던진다() {
// given
long todoId = 1L;
given(todoRepository.findById(todoId)).willReturn(Optional.empty());
// when & then
InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> managerService.getManagers(todoId));
assertEquals("Todo not found", exception.getMessage());
}
바꾸고 통과
2번 케이스


@Test
public void comment_등록_중_할일을_찾지_못해_에러가_발생한다() {
// given
long todoId = 1;
CommentSaveRequest request = new CommentSaveRequest("contents");
AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);
given(todoRepository.findById(anyLong())).willReturn(Optional.empty());
// when
InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> {
commentService.saveComment(authUser, todoId, request);
});
// then
assertEquals("Todo not found", exception.getMessage());
}
InvalidRequestException으로 바꿔 준다.
3번 케이스


서비스 로직을 수정하러 가자


if (todo.getUser() == null || !ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
throw new InvalidRequestException("일정을 생성한 유저만 담당자를 지정할 수 있습니다.");
}
이렇게 바꿨다...... null이면 던져라.

5️⃣ API 로깅
Interceptor 또는 AOP를 활용해서 어드민 사용자만 접근할 수 있는 특정 API에는 접근할 때마다 접근 로그를 기록해야 합니다.
Interceptor를 쓸지 AOP를 쓸지 고민했는데
일단 Interceptor는
너 관리자야? 맞으면 들어가 아니면 차단. HTTP 요청 단계에서 동작한다. 이런 느낌이고
AOP는 함수 실행 시작~ 함수 실행 끝~ 실행 시간 기록~ 로그 남김~ 이런 느낌입니다.
일단 조금 더 어려워 보이는 AOP로 해 놓고 Interceptor는 시간이 남으면 해 보는 걸로......
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminAspect {
}
일단 기본 설정을 하고
@Slf4j
@Aspect
@Component
public class AdminLogAspect {
}
아.... 만들면서 후회했다 Interceptor 할걸..............
@Slf4j
@Aspect
@Component
public class AdminLogAspect {
@Pointcut("@annotation(org.example.expert.domain.common.annotation.AdminAspect)")
public void adminLog() {}
@Around("adminLog()")
public Object logAdmin(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
Long userId = (Long) request.getAttribute("userId");
String url = request.getRequestURI();
String method = request.getMethod();
LocalDateTime now = LocalDateTime.now();
String requestBody = Arrays.toString(joinPoint.getArgs());
log.info("(Admin req) userId={} time={} method={} url={} body={}",
userId, now, method, url, requestBody);
Object result = joinPoint.proceed();
String responseBody = String.valueOf(result);
log.info("(Admin res) userId={} time={} method={} url={} body={}",
userId, LocalDateTime.now(), method, url, responseBody);
return result;
}
}
일단 완성 코드. 이건 admin api가 호출될 때마다 자동으로 로그를 남기는 AOP 클래스......
컨트롤러 코드에 직접 로그를 넣지 않아도 요청 -> 로그 찍기 -> 컨트롤러 실행 -> 응답 로그 찍기
이 흐름이 자동으로 만들어진다
- `@Slf4j` - 롬복이 로그 객체를 만들어 준다. 바로 log.info(..) 사용 가능
- `@Aspect` - 이 클래스가 AOP 클래스라는 것을 알려 주는 어노테이션 (약간 이 클래스는 다른 메서드 실행에 끼어들기 가능) 이런 느낌인 듯
- `@Component` - 스프링 bean으로 등록하기 위한 어노테이션
- `@Pointcut` - AOP에서 어디에 적용할지 결정하는 조건 내가 기본 설정한 @AdminAspect 어노테이션이 붙은 메서드에만 적용된다.
- `@Around("adminLog()")` - 이건 메서드 실행 전 + 실행 후 두 군데 모두 코드를 끼워 넣겠다는 의미
- `ProceedingJoinPoint joinPoint` 이 객체는 지금 실행하려는 메서드 정보를 가지고 있다 joinPoint.getArgs() 메서드 파라미터를 가져와서 RequestBody처럼 사용이 가능하다. joinPoint.proceed() 이걸 호출해야 원래 컨트롤러 메서드가 실행이 된다.
HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
AOP에서는 컨트롤러처럼 HttpServletRequest를 파라미터로 받을 수 없다. 그래서 RequestContextHolder로 현재 요청 객체를 꺼내오는 방식을 사용한다.
userId에는 JWT 필터에서 설정해 둔 값이 있다.
httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
그래서 AOP에서는 이렇게 꺼내서 쓸 수 있습니다.
아 복붙 힘들다. 캡처로 가자.

AOP에서는 RequestBody를 직접 가져오기 어렵기 때문에 메서드 파라미터 전체를 가져와서 문자열로 변환하고 로그 남김




이렇게 하면

여기까지 했는데 또 스탠다드 반에서 다 하니까 AOP 수업을 했다. 왜 항상 제가 하고 나서 수업을 해 주시나요..... 좋은 게 좋은 거지. 수정을 하겠습니다.
package org.example.expert.config;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.LocalDateTime;
import java.util.Arrays;
@Slf4j
@Aspect
@Component
public class AdminLogAspect {
@Pointcut("@annotation(org.example.expert.domain.common.annotation.AdminAspect)")
public void adminLog() {}
@Around("adminLog()")
public Object logAdmin(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
Long userId = (Long) request.getAttribute("userId");
String url = request.getRequestURI();
String method = request.getMethod();
String params = Arrays.toString(joinPoint.getArgs());
long start = System.currentTimeMillis();
LocalDateTime startAt = LocalDateTime.now();
log.info("[ADMIN API 요청] userId={} time={} method={} url={} params={}",
userId, startAt, method, url, params);
try {
Object result = joinPoint.proceed();
long durationMs = System.currentTimeMillis() - start;
log.info("[ADMIN API 응답] userId={} time={} method={} url={} params={} durationMs={}",
userId, LocalDateTime.now(), method, url, params, durationMs);
return result;
} catch (Exception e) {
long durationMs = System.currentTimeMillis() - start;
log.warn("[ADMIN API 에러] userId={} time={} method={} url={} durationMs={} ex={} msg={}",
userId, LocalDateTime.now(), method, url, durationMs, e.getClass().getSimpleName(), e.getMessage());
throw e;
}
}
}
예외 상황 로그도 기록하게 하고
수행 시간을 보이게 만들었다.


try catch를 넣어서 예외 로그를 추가했다.

조금 더 예뻐졌네
6️⃣ 위 제시된 기능 이외 내가 정의한 문제와 해결 과정
@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {
private final JwtUtil jwtUtil;
private final ObjectMapper objectMapper;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String url = httpRequest.getRequestURI();
if (url.startsWith("/auth")) {
chain.doFilter(request, response);
return;
}
String bearerJwt = httpRequest.getHeader("Authorization");
if (bearerJwt == null) {
log.warn("인증 헤더 누락: URI={}", url);
sendErrorResponse(httpResponse, HttpStatus.UNAUTHORIZED, "인증이 필요합니다.");
return;
}
String jwt = jwtUtil.substringToken(bearerJwt);
try {
// JWT 유효성 검사와 claims 추출
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
log.warn("Claims 추출 실패: URI={}", url);
sendErrorResponse(httpResponse, HttpStatus.UNAUTHORIZED, "인증이 필요합니다.");
return;
}
UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class));
httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
httpRequest.setAttribute("userRole", claims.get("userRole"));
if (url.startsWith("/admin") && !UserRole.ADMIN.equals(userRole)) {
log.warn("권한 부족: userId={}, role={}, URI={}", claims.getSubject(), userRole, url);
sendErrorResponse(httpResponse, HttpStatus.FORBIDDEN, "접근 권한이 없습니다.");
return;
}
chain.doFilter(request, response);
} catch (ExpiredJwtException e) {
log.info("JWT 만료: userId={}, URI={}", e.getClaims().getSubject(), url);
sendErrorResponse(httpResponse, HttpStatus.UNAUTHORIZED, "인증이 필요합니다.");
} catch (SecurityException | MalformedJwtException | UnsupportedJwtException e) {
log.error("JWT 검증 실패 [{}]: URI={}", e.getClass().getSimpleName(), url, e);
sendErrorResponse(httpResponse, HttpStatus.BAD_REQUEST, "인증이 필요합니다.");
} catch (Exception e) {
log.error("예상치 못한 오류: URI={}", url, e);
sendErrorResponse(httpResponse, HttpStatus.INTERNAL_SERVER_ERROR, "요청 처리 중 오류가 발생했습니다.");
}
}
private void sendErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws IOException {
response.setStatus(status.value());
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", status.name());
errorResponse.put("code", status.value());
errorResponse.put("message", message);
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
1. [문제 인식 및 정의]
인증 필터가 요청 처리되는 과정에서 중복 실행이 될 수 있다. 인증 에러가 꼬인다든가 그런 위험이 있기 때문에 `OncePerRequestFilter`로 변경해서 1회만 실행할 수 있도록 한다
2. [해결 방안]


OncePerRequestFilter (Spring Framework 7.0.5 API)
Filter base class that aims to guarantee a single execution per request dispatch, on any servlet container. It provides a doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain) method with HttpServletRequest and HttpServletResponse argument
docs.spring.io

2-1. [의사결정 과정]
이거 그대로 유지하면 중복 실행이 될 가능성이 있고 변경하면 HttpServletRequest도 쓸 수 있습니다. 굳이 형변환 안 해도 된다구요.
2-2. [해결 과정]
클래스 선언도 바꾸고 메서드도 바꿨다
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest httpRequest, HttpServletResponse httpResponse, FilterChain chain) throws IOException, ServletException {
String url = httpRequest.getRequestURI();
if (url.startsWith("/auth")) {
chain.doFilter(httpRequest, httpResponse);
return;
}
String bearerJwt = httpRequest.getHeader("Authorization");
if (bearerJwt == null) {
log.warn("인증 헤더 누락: URI={}", url);
sendErrorResponse(httpResponse, HttpStatus.UNAUTHORIZED, "인증이 필요합니다.");
return;
}
try {
String jwt = jwtUtil.substringToken(bearerJwt);
// JWT 유효성 검사와 claims 추출
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
log.warn("Claims 추출 실패: URI={}", url);
sendErrorResponse(httpResponse, HttpStatus.UNAUTHORIZED, "인증이 필요합니다.");
return;
}
String roleString = claims.get("userRole", String.class);
UserRole userRole = UserRole.valueOf(roleString);
httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
httpRequest.setAttribute("userRole", claims.get("userRole"));
if (url.startsWith("/admin") && !UserRole.ADMIN.equals(userRole)) {
log.warn("권한 부족: userId={}, role={}, URI={}", claims.getSubject(), userRole, url);
sendErrorResponse(httpResponse, HttpStatus.FORBIDDEN, "접근 권한이 없습니다.");
return;
}
chain.doFilter(httpRequest, httpResponse);
} catch (ExpiredJwtException e) {
log.info("JWT 만료: userId={}, URI={}", e.getClaims().getSubject(), url);
sendErrorResponse(httpResponse, HttpStatus.UNAUTHORIZED, "인증이 필요합니다.");
} catch (SecurityException | MalformedJwtException | UnsupportedJwtException e) {
log.error("JWT 검증 실패 [{}]: URI={}", e.getClass().getSimpleName(), url, e);
sendErrorResponse(httpResponse, HttpStatus.BAD_REQUEST, "인증이 필요합니다.");
} catch (Exception e) {
log.error("예상치 못한 오류: URI={}", url, e);
sendErrorResponse(httpResponse, HttpStatus.INTERNAL_SERVER_ERROR, "요청 처리 중 오류가 발생했습니다.");
}
}
private void sendErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws IOException {
response.setStatus(status.value());
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", status.name());
errorResponse.put("code", status.value());
errorResponse.put("message", message);
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
3. [해결 완료]
JWT 인증 필터의 실행 흐름이 불필요하게 중복 실행될 가능성이 줄었습니다. 로그도 중복 안 되고 attribute 덮어쓰기도 리스크도.....
JWT 처리 코드를 try 안에 넣어서 혹시나 잘못된 토큰 때문에 서버가 안 죽게 만들었다.
3-1. [회고]
필터가 한 번 요청할 때 한 번 실행된다고 생각하기 쉽지만 실제로는 디스패치 방식에 따라서 중복 실행될 수도 있고 스프링이 제공하는데 안 쓸 이유가 없다.
3-2. [전후 데이터 비교]
전: request/response 캐스팅 필요했고 중복 실행 가능성이 있었는데
후: 요청당 1회 실행할 수 있으며, HttpServletRequest/Response를 사용할 수 있습니다.
7️⃣ 테스트 커버리지
'IL > TIL' 카테고리의 다른 글
| 20260310 [TIL] 클라우드 아키텍처 설계 & 배포 (0) | 2026.03.10 |
|---|---|
| 20260211 [TIL] 외래키로 매핑되어 있는 부모 테이블 row 삭제 (0) | 2026.02.11 |
| 20260210 [TIL] (0) | 2026.02.10 |
| [TIL] Hibernate Dialect(MySQL8Dialect) 오류 스키마 DDL 경고 (0) | 2026.02.08 |
| 20260129 [TIL] - Spring 입문 시작에서 (0) | 2026.01.29 |