@Auth AuthUser 파라미터 바인딩을 위한 ArgumentResolver 등록처음 프로젝트 실행을 위해 application.yml에 JWT secret, datasource 설정이 필요했다.
예시:
jwt:
secret:
key: (base64 key)
spring:
datasource:
url: jdbc:mysql://localhost:3306/expert
username: root
password: 12345678
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: create
실행 로그에서 Tomcat 8080, Hibernate, HikariCP 모두 정상 기동되면 Lv0은 통과로 볼 수 있다.
처음에는 application-local.yml에 DB 비밀번호/JWT 키를 그대로 넣고 커밋해버렸다.
그 결과 GitHub 커밋 diff에서 그대로 노출되는 걸 확인했다.
application.yml은 이렇게 바꾸었다:
jwt:
secret:
key: ${JWT_SECRET_KEY}
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
그리고 application-local.yml은 .gitignore에 추가해서 추적 제외.
이미 커밋 히스토리에 비밀번호가 남으면 “파일을 삭제했더라도” 과거 커밋에서 볼 수 있다.
그래서 아래처럼 히스토리에서 제거했다.
git filter-repo --force --path src/main/resources/application-local.yml --invert-paths
이 과정에서
originremote가 자동 제거되는 이슈가 있었고, remote를 다시 잡아야 push가 가능했다.
(이건 다음에 다시 같은 실수 안 하려고 꼭 기록해둔다.)
컨트롤러에서 이렇게 받을 수 있어야 한다:
@PostMapping("/todos")
public ResponseEntity<TodoSaveResponse> saveTodo(
@Auth AuthUser authUser,
@Valid @RequestBody TodoSaveRequest todoSaveRequest
) { ... }
즉, @Auth + AuthUser 조합으로 들어오면
필터에서 넣어둔 request attribute를 꺼내서 AuthUser로 만들어 주입해야 한다.
HandlerMethodArgumentResolver는 구현만 하면 자동으로 적용되지 않는다.
반드시 WebMvcConfigurer에서 등록해야 한다.
등록을 빼먹으면 컨트롤러에서
@Auth AuthUser를 받을 때 바인딩이 안 되거나,
예상치 못한 에러가 발생한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers){
resolvers.add(new AuthUserArgumentResolver());
}
}
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null;
boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);
// @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생
if (hasAuthAnnotation != isAuthUserType) {
throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
}
return hasAuthAnnotation;
}
@Override
public Object resolveArgument(...) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
Long userId = (Long) request.getAttribute("userId");
String email = (String) request.getAttribute("email");
UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
return new AuthUser(userId, email, userRole);
}
}
supportsParameter에서 @Auth와 AuthUser를 강제 묶음 처리@Auth String email 같은 형태로 쓰면 즉시 예외로 알려줌resolveArgument는 JwtFilter에서 저장한 attribute를 그대로 꺼내 인증 정보 전달 책임을 한 곳에 모음기존 코드에서는 조건문이 중첩되는 구조였다.
예를 들어 이메일 중복 체크 같은 로직에서 if 블록 안에 로직이 계속 들어가는 형태였다.
이 방식은 코드가 길어질수록 다음과 같은 문제가 발생한다.
Early Return은 조건이 만족되지 않으면 바로 return 또는 예외를 던지는 방식이다.
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new InvalidRequestException("이미 존재하는 이메일입니다.");
}
그 다음에 정상 로직을 바로 진행한다.
User savedUser = userRepository.save(
new User(
signupRequest.getEmail(),
encodedPassword,
signupRequest.getUserName(),
userRole
)
);
Early Return을 사용하면 다음 장점이 있다.
즉,
"예외 상황 먼저 처리 -> 정상 로직 실행"
이라는 구조가 만들어진다.
이 패턴은 Spring 서비스 로직에서 많이 사용하는 패턴이다.
WeatherClient 코드에서 다음과 같은 구조가 있었다.
if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
throw new ServerException(...);
} else {
if (weatherArray == null || weatherArray.length == 0) {
throw new ServerException(...);
}
}
여기서 문제는
였다.
불필요한 else를 제거하고 조건을 분리했다.
if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
throw new ServerException("날씨 데이터를 가져오는데 실패했습니다.");
}
if (weatherArray == null || weatherArray.length == 0) {
throw new ServerException("날씨 데이터가 없습니다.");
}
불필요한 else는 코드 가독성을 떨어뜨린다.
조건이 실패하면 바로 예외를 던지고
정상 흐름을 이어가는 방식이 더 자연스럽다.
이 역시 Early Fail / Guard Clause 패턴이라고 한다.
기존 코드에서는 비밀번호 검증을 서비스 레이어에서 직접 수행하고 있었다.
if (userChangePasswordRequest.getNewPassword().length() < 8 ||
!userChangePasswordRequest.getNewPassword().matches(".*\\d.*") ||
!userChangePasswordRequest.getNewPassword().matches(".*[A-Z].*")) {
throw new InvalidRequestException("새 비밀번호는 8자 이상이어야 하고, 숫자와 대문자를 포함해야 합니다.");
}
이 방식은 다음 문제가 있다.
DTO에 Validation을 적용했다.
@NotBlank
private String newPassword;
그리고 Controller에서
@Valid
를 사용하여 자동 검증하도록 했다.
Spring에서는 입력값 검증을 DTO에서 처리하는 것이 일반적인 패턴이다.
이렇게 하면
이번 리팩토링을 통해 단순히 기능 구현을 넘어서
을 이해할 수 있었다.
특히,
"비즈니스 로직과 검증 로직을 분리하는 것"
이 서비스 레이어 설계에서 매우 중요하다는 것을 느꼈다.
getTodos()에서 Todo 목록 조회 후 todo.getUser()를 접근한다.
Todo.user는 LAZY이기 때문에,
Todo N개 조회 후 user를 접근하는 순간 N번 추가 쿼리가 발생할 수 있다 -> N+1.
기존 코드는 JPQL fetch join을 사용하고 있었다:
public interface TodoRepository extends JpaRepository<Todo, Long> {
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC")
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);
}
EntityGraph 방식 예시 (최종 목표 형태)
@EntityGraph(attributePaths = {"user"})
Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);
그리고 단건 조회도 동일하게:
@EntityGraph(attributePaths = {"user"})
Optional<Todo> findById(Long todoId);
주의:
@EntityGraph는 “연관 엔티티를 함께 로딩”하도록 힌트를 주는 방식이고,
fetch join처럼 직접 JPQL을 쓰지 않아도 N+1을 방지할 수 있다.
Lv4는 “코드가 맞는데 테스트가 틀림 / 테스트는 맞는데 로직이 바뀌어서 깨짐” 같은 상황을 직접 다루게 했다.
matches() 메서드 파라미터 순서를 잘못 넣으면 테스트가 실패한다.
정상은:
boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);
encoded를 raw처럼 넣으면 당연히 비교가 안 맞는다.

InvalidRequestException을 기대해야 함)assertThatThrownBy를 사용assertThatThrownBy(() -> managerService.getManagers(todoId))
.isInstanceOf(InvalidRequestException.class)
.hasMessage("Todo not found");
assertThrows는 메시지 검증까지 하려면 예외를 변수로 받아서 추가 검증해야 함 -> 의도가 길어짐
테스트는 ServerException을 기대했지만, 실제 컨텍스트(서비스 구현)는 InvalidRequestException을 던질 가능성이 높다.
즉, 테스트의 기대값(예외 타입/메시지)을 서비스 컨벤션과 맞춰야 테스트가 의미를 가진다.

테스트에서 일부러 Todo.user = null로 만들었더니, 서비스 로직에서 아래가 터졌다:
todo.getUser().getId()
권한 체크 전에 null 방어 로직을 넣었다.
if (todo.getUser() == null) {
throw new InvalidRequestException("일정을 생성한 유저만 담당자를 지정할 수 있습니다.");
}
user가 null이면 권한 검증 자체가 불가능함 (비교할 대상이 없음)
“NPE(런타임 오류)” 대신 “의도한 비즈니스 예외”로 바꾸면
