???
어디에서 유저 정보가 사라지는지 추적하기 위해 log로 찍어 볼 예정.
즉, 로그를 통해 authUser가 null인지 확인.
일단 로그 찍기도 전에 에러 발생. 그럼 매핑 문제라고 생각했음.
바로 요 부분.
nickname의 필드를 추가해줬는데 지금 없는 것이 보입니다.
다 추가해주면 됩니다.
이렇게 추가해주면 됨.
근데 이렇게 추가하면 당연히 생성자도 새로 해줘야함.
이렇게 하니까 에러 발생.
수정해주면 됩니다.
HandlerMethodArgumentResolver는
Spring MVC에서 컨트롤러 메서드의 매개변수(파라미터)를 자동으로 변환해주는 역할을 하는 인터페이스입니다.
@PostMapping("/todos")
public ResponseEntity<TodoSaveResponse> saveTodo(HttpServletRequest request, @RequestBody TodoSaveRequest todoSaveRequest) {
Long userId = (Long) request.getAttribute("userId");
String email = (String) request.getAttribute("email");
String nickname = (String) request.getAttribute("nickname");
UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
AuthUser authUser = new AuthUser(userId, email, nickname, userRole);
return ResponseEntity.ok(todoService.saveTodo(authUser, todoSaveRequest));
}
👉 HttpServletRequest에서 직접 값을 꺼내어 AuthUser를 만들어야 함
👉 코드 중복이 심하고, 다른 컨트롤러에서도 똑같이 반복해야 함 😵
@PostMapping("/todos")
public ResponseEntity<TodoSaveResponse> saveTodo(@Auth AuthUser authUser, @RequestBody TodoSaveRequest todoSaveRequest) {
return ResponseEntity.ok(todoService.saveTodo(authUser, todoSaveRequest));
}
👉 컨트롤러에서 @Auth AuthUser authUser 만 넣으면 자동으로 AuthUser가 생성됨
👉 이를 통해, 코드가 깔끔해지고 유지보수도 쉬워짐.
HandlerMethodArgumentResolver는 Spring MVC 내부에서 컨트롤러의 매개변수를 확인하고, 특정 조건을 만족하면 해당 매개변수를 자동으로 변환해주는 역할을 합니다.
Spring이 HTTP 요청을 처리할 때
이 개념을 잘 이해하면 Spring MVC 내부 동작 원리와 함께 커스텀 핸들러도 쉽게 만들 수 있다고 생각합니다.
👉 DispatcherServlet이 컨트롤러를 실행하기 전에 HandlerMethodArgumentResolver가 동작함
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null;
boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);
if (hasAuthAnnotation != isAuthUserType) {
throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
}
return hasAuthAnnotation;
}
컨트롤러의 매개변수가 특정 조건을 만족하는지 검사
여기서는 @Auth 애노테이션이 붙어 있고, AuthUser 타입이면 true 반환
@Override
public Object resolveArgument(
@Nullable MethodParameter parameter,
@Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
@Nullable WebDataBinderFactory binderFactory
) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
Long userId = (Long) request.getAttribute("userId");
String email = (String) request.getAttribute("email");
String nickname = (String) request.getAttribute("nickname");
UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
return new AuthUser(userId, email, nickname, userRole);
}
더 나아간다면 어떤 것의 개념을 알면 코드 작성에 더 좋을까?
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
WebDataBinder binder = binderFactory.createBinder(webRequest, null, parameter.getParameterName());
return binder.convertIfNecessary(webRequest.getParameter(parameter.getParameterName()), parameter.getParameterType());
}
컨트롤러의 Model과 View 정보를 담고 있는 객체
resolveArgument()에서 특정 데이터를 Model에 추가할 수도 있음
아래와 같이 학습하며 해봤습니다.
1️⃣ Spring MVC 동작 원리 공부 (DispatcherServlet, HandlerMapping 등)
2️⃣ HandlerMethodArgumentResolver의 사용 예제 직접 구현
3️⃣ JWT 기반 인증을 직접 만들어보기 (AuthUserArgumentResolver 확장)
4️⃣ WebDataBinder와 데이터 바인딩 개념 익히기
5️⃣ 애노테이션 기반 AOP 활용법 공부
처음에는 비밀번호 오류인가 생각해봤는데, 오류 로그를 확인해보니, JWT 디코딩 오류 (DecodingException: Illegal base64url character: ' ') 를 고려하면, 비밀번호보다는 JWT 생성 및 검증 과정에서 문제가 발생한 것일 가능성이 커보임.
헤더값은 잘 넣었음.
따라서 util 확인해보고 수정 시작.
JWT 시크릿 키(Base64 인코딩 확인)
현재 @Value("${jwt.secret.key}")를 통해 secretKey를 읽어오지만, Base64로 인코딩되지 않은 경우 decode 과정에서 오류 발생 가능.
따라서 Base64.isBase64(secretKey)로 검증 후, 올바른 Base64 인코딩이 아니라면 예외를 발생시킴.
JWT 서명 및 검증 강화
parseClaimsJws()에서 예외가 발생하는 경우 원인을 더 정확하게 로깅.
JWT 파싱 오류 처리
MalformedJwtException, DecodingException, SignatureException 등의 오류를 명확하게 처리.
package org.example.expert.config;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.example.expert.domain.common.exception.ServerException;
import org.example.expert.domain.user.enums.UserRole;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
private static final String BEARER_PREFIX = "Bearer ";
private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct
public void init() {
try {
String cutSecretKey = secretKey.replaceAll("\\s","");
byte[] bytes = Base64.getDecoder().decode(cutSecretKey);
this.key = Keys.hmacShaKeyFor(bytes);
log.info("jwtUtil, jwt 키가 정상적으로 설정됨");
} catch (IllegalArgumentException e) {
log.error("jwt 시크릿 키 오류 : 올바른 base64 인지 확인", e);
throw new ServerException("jwt 키 설정 중 오류 발생함: 확인해야함");
}
}
public String createToken(Long userId, String email, String nickname, UserRole userRole) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("email", email)
.claim("nickname", nickname)
.claim("userRole", userRole)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
public String getNicknameFromToken(String token) {
Claims claims = extractClaims(token);
return claims.get("nickname", String.class);
}
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(BEARER_PREFIX.length()).trim();
}
throw new ServerException("토큰이 올바르지 x");
}
public Claims extractClaims(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
log.error("만료된 JWT 토큰: {}", e.getMessage());
throw new ServerException("만료된 JWT 토큰입니다.");
} catch (MalformedJwtException e) {
log.error("잘못된 JWT 형식: {}", e.getMessage());
throw new ServerException("잘못된 JWT 형식.");
} catch (UnsupportedJwtException e) {
log.error("지원되지 않는 JWT 토큰: {}", e.getMessage());
throw new ServerException("지원되지 않는 JWT 토큰.");
} catch (SecurityException | SignatureException e) {
log.error("JWT 서명 검증 실패: {}", e.getMessage());
throw new ServerException("유효하지 않은 JWT 서명.");
} catch (io.jsonwebtoken.io.DecodingException e) {
log.error("JWT 디코딩 오류-Base64 URL 인코딩 문제: {}", e.getMessage());
throw new ServerException("JWT 디코딩 오류: Base64 형식이 잘못되었습니다.");
} catch (Exception e) {
log.error("JWT 처리 중 알 수 없는 오류 발생: {}", e.getMessage());
throw new ServerException("JWT 처리 중 알 수 없는 오류가 발생했습니다.");
}
}
}
원래 이전에 했던 프로젝트에선 공백을 제거하고 코드를 구현하는게 좋다는 블로그를 따라 했었는데, 이번에도 한 번 공백 제거를 하고 코드를 구현해보도록 하겠습니다.
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `org.example.expert.client.dto.WeatherDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 2, column: 6] (through reference chain: java.lang.Object[][0])
at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67) ~[jackson-databind-2.18.2.jar:2.18.2]
... 75 common frames omitted
수정하고 실행했지만, 에러 발생. 콘솔 확인.
에러 메시지를 보면, WeatherDto 클래스가 JSON 직렬화/역직렬화(Jackson) 과정에서 문제가 발생하고 있음 (이것을 잘 몰라 구글링 1, 2, 3)
org.springframework.http.converter.HttpMessageConversionException:
Type definition error: [simple type, class org.example.expert.client.dto.WeatherDto]
com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Cannot construct instance of `org.example.expert.client.dto.WeatherDto` (no Creators, like default constructor, exist)
이 부분이 핵심인데, 이건 json이 weatherDto 객체를 생성할 수 없다는 것을 의미합니다
!!왜!!?
기본생성자가 생성이 안되네요.. 허허허 ㅋㅋㅋ..
정확하게는, 현재 코드에서 기본 생성자가 없기 때문에 Jackson이 역직렬화를 할 수 없어서 발생하는 문제입니다.
why?
코드를 보면, final 필드를 가지고 있습니다.
@JsonCreator
public WeatherDto(
@JsonProperty("date") String date,
@JsonProperty("weather") String weather
) {
this.date = date;
this.weather = weather;
}
뭐가 더 좋은 방법인지 명확하게 모르기 때문에 일단 편한 어노테이션 생성으로 진행.
Todo 목록 조회 및 특정 Todo 조회가 되지 않네요 ??
@Auth 어노테이션 추가.
현재 getTodos() 메서드는 모든 사용자의 Todo를 반환하고 있음.
인증된 사용자(authUser.getId())만 자신의 Todo 목록을 조회할 수 있도록 필터링해야 함.
public Page<TodoResponse> getTodos(Long userId, int page, int size) {
Pageable pageable = PageRequest.of(page - 1, size);
Page<Todo> todos = todoRepository.findAllByOrderByModifiedAtDesc(userId,pageable);
return todos.map(todo -> new TodoResponse(
todo.getId(),
todo.getTitle(),
todo.getContents(),
todo.getWeather(),
new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()),
todo.getCreatedAt(),
todo.getModifiedAt()
));
}
특정 Todo 조회 시, 인증된 유저의 Todo인지 확인하지 않음
java.lang.IllegalArgumentException: Name for argument of type [long] not specified, and parameter name information not available via reflection.
Ensure that the compiler uses the '-parameters' flag.
@GetMapping("/todos")
public ResponseEntity<Page<TodoResponse>> getTodos(
@Auth AuthUser authUser,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "10") int size
) {
return ResponseEntity.ok(todoService.getTodos(authUser.getId(), page, size));
}
@GetMapping("/todos/{todoId}")
public ResponseEntity<TodoResponse> getTodo(
@Auth AuthUser authUser,
@PathVariable(name = "todoId") long todoId
) {
return ResponseEntity.ok(todoService.getTodo(authUser.getId(), todoId));
}
이렇게 수정.
성공.
근데 전체 목록 조회는 되지않음.
Todo 목록 조회 API 요청에서 InvalidDataAccessApiUsageException이 발생.
-> 핵심 원인은 쿼리에 최소 1개의 파라미터가 필요하지만, 0개만 제공됨
At least 1 parameter(s) provided but only 0 parameter(s) present in query
➡ findAllByOrderByModifiedAtDesc() 메서드 실행 중 최소 한 개의 파라미터가 필요하지만, 전달된 파라미터가 0개라서 오류 발생.
➡ Repository 코드 문제 가능성,
TodoRepository 에서 findAllByOrderByModifiedAtDesc() 메서드를 사용하고 있을 텐데, 해당 메서드가 파라미터를 필요로 하는 메서드인데도 파라미터 없이 호출되었을 가능성이 높다고 판단했음.
이는 findAllByOrderByModifiedAtDesc(Long userId, Pageable pageable); 메서드에서 userId를 사용하려고 했지만,
해당 쿼리가 QueryDSL 기반으로 구현되지 않았기 때문에 발생한 문제인 것으로 확인됨. 구현만 해주면 되는거.
현재 TodoRepository에 있는 findAllByOrderByModifiedAtDesc(Long userId, Pageable pageable);는 잘못된 선언임으로 삭제.
QueryDSL을 사용할 거면 인터페이스 메서드를 제거하고, TodoRepositoryQueryDslImpl에서 직접 구현해야하기 때문.
주말동안 개념만 공부해서인지 queryDsl을 적용한 것을 까먹다니,,
아래 순서대로 수정하면 됩니다.
TodoRepositoryQueryDslImpl 수정(QueryDSL을 사용해서 userId 기준으로 modifiedAt 내림차순 정렬하여 목록 조회하는 메서드를 추가)
TodoRepositoryQueryDsl에 메서드 추가
TodoService에서 getTodos 수정
안될 수가 없습니다 !ㅎㅎ
테스트 코드 리팩토링
claim("userRole", userRole)은 UserRole Enum 자체를 넣고 있음. ➡ 이 경우 직렬화될 때 USER, ADMIN이 아닌 org.example.expert.domain.user.enums.UserRole.USER 같은 형식이 들어갈 가능성이 있음. ➡ Enum을 String 값으로 저장하도록 변경해야 함.
이전 테스트 코드는 jwt를 직접 문자열로 입력하고 있는데, 올바른 jwtUtil.createToken()을 사용해서 생성된 토큰을 테스트에서 활용해야 하기 때문에 수정.