요약
기존 Todo API를 Spring Boot 4.1.1 환경으로 옮기면서 단순히 의존성 버전만 올리는 데서 끝내지 않았다. JWT 인증 정보의 전달 방식, 매니저 등록 실패 시 남겨야 하는 로그의 트랜잭션 경계, 조회 API의 N+1과 집계 검색까지 함께 정리했다.
이 글에서 다루는 범위는 다음과 같다.
- Java 17·Spring Boot 4.1.1·Gradle Wrapper 8.14.3 기반 실행 환경 정비
- HttpServletRequest attribute 기반 JWT 전달 구조를 Spring Security 표준 구조로 전환
- 관리자 역할 변경 접근 로그와 담당자 등록 요청 로그의 목적 분리
- Todo 생성과 담당자 자동 등록, 요청 로그의 독립 트랜잭션 처리
- JPQL fetch join·@EntityGraph·QueryDSL을 역할에 따라 선택한 조회 개선
- 제목·담당자 닉네임·생성일 조건을 받는 Projection 검색 API
1. Spring Boot 4.1 실행 환경으로 마이그레이션
첫 작업은 기능 추가가 아니라 2년 전의 Spring Boot 3.3.3 실행 환경을 Spring Boot 4.1.1 기준으로 옮기는 일이었다. 이 작업은 build.gradle의 버전만 바꾸는 것으로 끝나지 않았다. Gradle Wrapper, Spring Boot 모듈, JJWT API, Web MVC 테스트 API가 함께 영향을 받았다.
구분 변경
| 플랫폼 | Spring Boot 3.3.3 → 4.1.1, Gradle Wrapper 8.14.3 |
| Java | Java 17 유지 |
| Spring 모듈 | WebMVC, AspectJ, RestClient, WebMVC test starter를 Boot 4 구성에 맞춤 |
| JWT | JJWT 0.11.5 → 0.13.0, 토큰 생성·Claims 파싱 코드 변경 |
| 테스트 API | Boot 4의 WebMvcTest, MockitoBean API 반영 |
| 데이터베이스 | MySQL 실행 환경과 H2 테스트 환경 분리 |
Java 21도 LTS이지만 QueryDSL이나 현재 코드가 Java 21을 요구하지 않았고, Java 17 전용 문제도 없었다. 이번 과제에서는 언어 버전까지 동시에 올려 호환성 확인 범위를 넓히기보다, Java 17을 유지한 채 Spring Boot와 관련 의존성의 호환 작업에 집중했다.
마이그레이션 범위도 기능과 직접 관련된 변경으로 제한했다. JJWT의 새 API에 맞춰 토큰 생성과 Claims 파싱을 바꾸고, Boot 4에서 달라진 테스트 API를 적용했다. 도메인 구조나 API 계약과 무관한 리팩터링까지 한 번에 섞으면, 이후 오류가 버전 호환성 문제인지 기능 변경 문제인지 구분하기 어려워진다.
최종적으로 애플리케이션은 MySQL과 환경변수 기반 JWT secret을 사용하고, 테스트는 H2와 테스트 전용 JWT key를 사용하도록 분리했다.
# src/test/resources/application.yml
spring:
datasource:
url: jdbc:h2:mem:expert;MODE=MySQL;DB_CLOSE_DELAY=-1
jpa:
hibernate:
ddl-auto: create
jwt:
secret:
key: abcdefghijklmnopqrstuvwxyz12345678901234567890
실행 환경을 먼저 분리한 이유는 단순하다. 로컬 MySQL 상태나 개인 secret이 없으면 테스트도 시작하지 못하는 구조에서는 이후 인증·조회 리팩터링의 회귀를 판단하기 어렵다. 이 기준선 위에서 기능을 하나씩 검증하고 커밋으로 닫는 순서를 사용했다.
2. JWT 필터의 역할을 인증으로 좁히기
기존 구조에서는 JWT 필터가 토큰 검증 후 사용자 정보를 request attribute에 넣고, 별도 HandlerMethodArgumentResolver가 이를 꺼내 Controller의 @Auth AuthUser 파라미터로 전달했다. URL 경로와 관리자 권한도 필터 안에서 직접 검사했다.
JwtFilter
├─ JWT 검증
├─ URL 검사
├─ 관리자 권한 검사
└─ request attribute 저장
AuthUserArgumentResolver
└─ request attribute → AuthUser
이 구조에서는 인증 정보의 저장 위치와 인가 규칙이 Servlet API에 묶이고, URL 정책이 필터 내부에 흩어진다. Spring Security로 전환하면서 역할을 다음처럼 나눴다.
JwtFilter
└─ JWT 검증 → Authentication 생성
SecurityContext
└─ 현재 인증 사용자 보관
SecurityConfig
└─ URL별 인증·인가 정책 결정
@Auth
└─ SecurityContext의 principal을 Controller 파라미터로 전달
JWT는 Authentication을 만들고 SecurityContext에 저장한다
필터는 Bearer 토큰이 있을 때만 Claims를 읽어 AuthUser와 권한을 만든다. 토큰이 없다는 이유만으로 필터가 응답을 끝내지 않는다. 해당 API가 인증을 요구하는지는 이후 SecurityConfig의 정책이 판단한다.
Claims claims = jwtUtil.extractClaims(token);
AuthUser authUser = createAuthUser(claims);
Authentication authentication = new UsernamePasswordAuthenticationToken(
authUser,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + authUser.getUserRole().name()))
);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
Spring Security에서 SecurityContext는 현재 인증 정보를 보관하고, Authentication의 principal·authorities는 이후 인가에 사용된다. 새 SecurityContext를 만든 뒤 Holder에 넣는 방식도 공식 인증 구조의 예시와 맞춘다.
Controller의 호출 형태는 유지했다. 다만 @Auth의 내부 구현을 @AuthenticationPrincipal 메타 어노테이션으로 바꿔 커스텀 ArgumentResolver를 제거했다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(errorOnInvalidType = true)
public @interface Auth {
}
@PostMapping("/todos")
public ResponseEntity<TodoSaveResponse> saveTodo(
@Auth AuthUser authUser,
@Valid @RequestBody TodoSaveRequest request
) {
return ResponseEntity.ok(todoService.saveTodo(authUser, request));
}
Controller 입장에서는 @Auth AuthUser를 그대로 쓰지만, 인증 정보의 출처는 request attribute에서 SecurityContext로 바뀌었다.
URL 정책은 SecurityConfig에 모은다
인가 정책은 필터의 if 문이 아니라 SecurityFilterChain에 둔다.
.authorizeHttpRequests(auth -> auth
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/todos", "/todos/**", "/users/**").permitAll()
.anyRequest().authenticated()
)
USER 권한으로 /admin/**에 접근하면 인증은 성공했지만 권한이 부족하므로 403이다. 토큰 없이 인증이 필요한 API를 호출하면 401이다. 이 구분을 위해 AuthenticationEntryPoint와 AccessDeniedHandler도 각각 설정했다.
Spring Security의 authorizeHttpRequests는 요청 경로별 규칙을 선언하는 방식이며, hasRole("ADMIN")은 ROLE_ADMIN authority를 기준으로 판단한다.
여기서 DispatcherType.ERROR를 공개한 것은 오류 dispatch도 다시 인가 대상이 될 수 있기 때문이다. 보호 API가 예외를 냈을 때 원래 오류 응답 대신 인증 오류가 나오는 것을 막기 위한 설정이다.
3. 기존 관리자 역할 변경 AOP 로그를 SecurityContext 기준으로 리팩터링
관리자 역할 변경 전 로그는 새로 추가한 DB 이력 기능이 아니라, 기존 AOP 기능을 인증 구조 변경에 맞춰 리팩터링한 작업이다. JWT 인증 정보의 저장 위치가 request attribute에서 SecurityContext로 바뀌었으므로 Aspect가 사용자 ID를 가져오는 방식도 함께 변경했다.
Controller 실행 직전에 로그를 남긴다
PATCH /admin/users/{userId}는 관리자만 호출할 수 있는 API다. 이 API의 실행 기록은 도메인 저장 데이터가 아니라 운영 중 확인할 애플리케이션 로그이므로 별도 테이블을 만들지 않고 AOP로 분리했다.
@Before("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
public void logBeforeChangeUserRole(JoinPoint joinPoint) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof AuthUser authUser)) {
log.warn("관리자 접근 로그 기록 실패 - 인증 사용자 정보를 확인할 수 없습니다.");
return;
}
log.info(
"Admin Access Log - User ID: {}, Request Time: {}, Request URL: {}, Method: {}",
authUser.getId(),
LocalDateTime.now(),
request.getRequestURI(),
joinPoint.getSignature().getName()
);
}
@Before를 선택했기 때문에 Controller에 도달한 관리자 요청은 역할 변경 서비스가 이후 검증에서 실패하더라도 기록된다. 반대로 토큰이 없거나 유효하지 않아 401이 된 요청, USER 권한으로 접근해 403이 된 요청은 Security FilterChain에서 끝나므로 이 Aspect가 기록하지 않는다. 이 로그만으로 “역할 변경이 성공했다”라고 판단하면 안 된다. 성공 여부까지 감사해야 하는 요구가 생긴다면 결과와 오류 정보를 별도의 영속 로그로 설계해야 한다.
Security 리팩터링 전에는 이 Aspect도 request attribute의 userId를 읽었다. 인증 정보의 출처를 SecurityContext로 통일하면서 AOP 역시 같은 principal을 사용하게 됐다.
4. Todo 생성은 작성자 담당자 등록까지 한 흐름으로 처리한다
Todo를 만들 때 작성자를 첫 담당자로 등록하는 기능은 Todo 생성 흐름 안에서 함께 성공해야 한다. 그래서 Todo 생성 시 Manager를 컬렉션에 넣고, cascade = CascadeType.PERSIST로 Todo 저장에 맞춰 담당자도 저장되게 했다.
@OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
private List<Manager> managers = new ArrayList<>();
public Todo(String title, String contents, String weather, User user) {
this.title = title;
this.contents = contents;
this.weather = weather;
this.user = user;
this.managers.add(new Manager(user, this));
}
반대로 Todo와 최초 담당자처럼 함께 성공해야 하는 데이터와 달리, 이후 담당자 등록 요청 이력은 업무 처리 실패 여부와 독립적으로 보존되어야 했다.
5. 매니저 등록 요청 이력은 독립 트랜잭션으로 저장한다
매니저 등록 요청 로그는 기존 AOP 로그를 확장한 것이 아니라, 새로 추가한 업무 요청 이력 기능이다. 존재하지 않는 담당자를 등록하려는 요청은 매니저 데이터로 저장되면 안 되지만, 누가 어떤 Todo에 어떤 사용자를 담당자로 지정하려고 했는지는 남겨야 한다.
매니저 등록 실패
→ Manager 저장은 롤백
요청 로그
→ 독립적으로 커밋
그래서 로그 저장 책임을 별도 Bean으로 분리하고 REQUIRES_NEW를 적용했다.
@Service
@RequiredArgsConstructor
public class ManagerAssignmentLogService {
private final ManagerAssignmentLogRepository logRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(Long requesterId, Long todoId, Long managerUserId) {
logRepository.save(
new ManagerAssignmentLog(requesterId, todoId, managerUserId)
);
}
}
ManagerService는 업무 검증보다 먼저 별도 Bean의 로그 저장 메서드를 호출한다.
@Transactional
public ManagerSaveResponse saveManager(
AuthUser authUser,
long todoId,
ManagerSaveRequest managerSaveRequest
) {
managerAssignmentLogService.saveLog(
authUser.getId(),
todoId,
managerSaveRequest.getManagerUserId()
);
Todo todo = todoRepository.findById(todoId)
.orElseThrow(() -> new InvalidRequestException("Todo not found"));
// Todo 작성자 확인
User managerUser = userRepository.findById(managerSaveRequest.getManagerUserId())
.orElseThrow(() -> new InvalidRequestException("등록하려고 하는 담당자 유저가 존재하지 않습니다."));
// 본인 등록 검증 후 Manager 저장
}
REQUIRES_NEW는 외부 트랜잭션에 참여하지 않고 독립된 물리 트랜잭션을 사용한다. 따라서 로그를 ManagerService 내부 자기 호출로 두지 않고 별도 Spring Bean으로 분리해 프록시 호출이 일어나도록 했다. saveLog()가 정상 반환되면 독립 트랜잭션은 커밋되고, 그 뒤 Todo·요청자·담당자 검증이 진행된다. 이후 예외가 발생해 외부 트랜잭션이 롤백되더라도 이미 커밋된 요청 이력은 남는다.
로그 엔티티는 User, Todo와 연관관계를 만들지 않고 요청자·Todo·대상 담당자의 ID를 직접 저장한다.
@Column(nullable = false)
private Long requesterId;
@Column(nullable = false)
private Long todoId;
@Column(nullable = false)
private Long managerUserId;
등록 대상 ID가 실제로 존재하지 않아도 요청 이력은 남겨야 하기 때문이다. @SpringBootTest 기반 통합 테스트에서는 존재하지 않는 담당자 ID로 요청을 실패시킨 뒤, Manager 수는 증가하지 않고 로그만 1건 증가하는지를 확인했다.
6. 조회 목적에 따라 JPQL, EntityGraph, QueryDSL을 섞어 쓰기
모든 조회를 QueryDSL로 바꾸지 않았다. 조회의 성격에 따라 더 단순한 표현을 유지했다.
조회 목적 선택 이유
| Todo 목록과 작성자 | JPQL JOIN FETCH | 날씨·수정일 조건과 page count 쿼리가 이미 선언형 쿼리로 명확했다. |
| 댓글 목록과 작성자 | @EntityGraph(attributePaths = "user") | Repository 메서드 한 곳에 필요한 fetch plan만 붙일 수 있었다. |
| Todo 단건과 작성자 | QueryDSL fetch join | 기존 QueryDSL Repository Fragment를 사용해 단건 조회만 확장했다. |
| 조건 조합·집계 검색 | QueryDSL Projection | 동적 조건과 담당자·댓글 집계를 엔티티 전체 조회 없이 구성해야 했다. |
예를 들어 댓글 목록은 응답 DTO를 만들 때 작성자 정보를 읽는다. Comment만 먼저 조회한 뒤 댓글마다 getUser()를 호출하면 N+1이 생길 수 있다. 이 조회 경로에만 @EntityGraph를 적용했다.
@EntityGraph(attributePaths = "user")
List<Comment> findAllByTodo_Id(Long todoId);
Spring Data JPA는 Repository 메서드에 @EntityGraph를 붙여 필요한 연관 필드의 fetch plan을 지정할 수 있다. 전역 FetchType을 EAGER로 바꾸지 않고, 필요한 조회에만 범위를 한정한 이유다.
QueryDSL 검색: 조건과 집계를 같은 join으로 풀지 않기
새로운 GET /todos/search는 제목 부분 일치, 담당자 닉네임 부분 일치, 생성일 범위를 선택 조건으로 받고 다음 세 값만 반환한다.
@Getter
@RequiredArgsConstructor
public class TodoSearchResponse {
private final String title;
private final long managerCount;
private final long commentCount;
}
처음에는 Todo → Manager, Todo → Comment를 한 쿼리에서 join한 뒤 집계하는 방식을 고려했다. 하지만 Todo 하나에 담당자 2명, 댓글 3개가 있다면 조인 결과는 최대 6행이 된다.
2 managers × 3 comments = 6 rows
단순 count()는 실제보다 큰 값을 만들 수 있다. 그래서 외부 쿼리는 Todo 한 행을 유지하고, 역할이 다른 값은 각각 서브쿼리로 분리했다.
List<TodoSearchResponse> content = jpaQueryFactory
.select(Projections.constructor(
TodoSearchResponse.class,
todo.title,
managerCount(),
commentCount()
))
.from(todo)
.where(searchConditions(condition))
.orderBy(todo.createdAt.desc(), todo.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
담당자 닉네임은 “이 닉네임을 가진 담당자가 존재하는 Todo인가”를 판단하는 조건이므로 exists 서브쿼리로 처리했다. 반면 managerCount는 검색어와 일치한 담당자 수가 아니라 해당 Todo의 전체 담당자 수여야 하므로 별도의 count 서브쿼리로 계산했다.
private BooleanExpression managerNicknameContains(String nickname) {
if (!StringUtils.hasText(nickname)) {
return null;
}
return JPAExpressions
.selectOne()
.from(manager)
.join(manager.user, user)
.where(
manager.todo.eq(todo),
user.nickname.contains(nickname)
)
.exists();
}
검색 조건은 BooleanExpression[]으로 한 번 만들고 content 쿼리와 count 쿼리가 함께 사용한다. 이후 조건을 추가할 때 한쪽 쿼리에만 조건을 넣는 실수를 줄이기 위해서다. PageableExecutionUtils.getPage()를 사용해 현재 조회 결과 수와 페이지 정보를 바탕으로 전체 건수를 추론할 수 있는 경우에는 별도의 count 쿼리를 실행하지 않도록 했다.
이 구조는 현재 요구사항에서 집계의 정확성과 코드의 역할 분리를 우선한 선택이다. 데이터가 충분히 커져 상관 서브쿼리 비용이 문제가 되면 실행 계획과 인덱스를 확인한 뒤 join·group by 방식 또는 다른 조회 전략을 비교해야 한다.
검증 기준과 남은 보완점
작업은 기능 구현만으로 닫지 않고, 컴파일·자동 테스트 또는 재현 가능한 API 호출·쿼리 로그 확인·커밋 순으로 정리했다.
항목 확인 방식
| 실행 환경 | MySQL 실행 환경과 H2 테스트 환경 분리 후 ./gradlew clean test 실행 |
| 인증·인가 | 무인증 보호 API 401, USER의 관리자 API 접근 403, ADMIN 접근 성공을 Swagger로 확인 |
| 관리자 역할 변경 실행 로그 | 인가를 통과한 ADMIN의 API 호출 때 요청자 ID·시간·URL·메서드가 애플리케이션 로그에 남는지 확인 |
| N+1 개선 | Todo·댓글 조회 시 작성자까지 함께 조회되는 Hibernate SQL 확인 |
| 독립 트랜잭션 | 존재하지 않는 담당자 ID로 실패시키고 Manager 수와 log 행 수를 통합 테스트로 확인 |
| QueryDSL 검색 | 조건별 응답, 생성일 정렬, 집계 수, 잘못된 page·size·날짜 범위 400을 API 호출로 확인 |
게시 후에는 아래 두 장면을 캡처로 추가할 예정이다.
- USER 토큰의 /admin/** 요청이 403으로 반환되는 Swagger 또는 API 응답 화면
- 담당자 등록 실패 뒤 Manager는 늘지 않고 log는 남은 통합 테스트 또는 DB 조회 결과
검색 결과에는 요구된 제목과 집계만 반환하므로 Todo ID는 포함하지 않았다. 검색 결과에서 바로 상세 화면으로 이동하는 기능이 필요해진다면, 그때는 API 사용 흐름을 다시 검토한 뒤 식별자를 추가하는 것이 맞다.
정리
이번 리팩터링에서 가장 크게 바뀐 것은 라이브러리 버전이 아니라 책임의 위치다.
인증 정보
request attribute → SecurityContext
URL별 권한 판단
JwtFilter → SecurityConfig
요청 로그의 성공 조건
업무 트랜잭션과 동일 → REQUIRES_NEW 독립 트랜잭션
로그의 기록 위치
관리자 역할 변경 실행 → AOP 애플리케이션 로그
매니저 등록 요청 → DB 영속 로그
조회 전략
모든 조회를 같은 방식으로 처리 → 조회 목적별 JPQL·EntityGraph·QueryDSL 선택
JWT 필터는 “누구인지 확인하는 일”에 집중하고, SecurityConfig는 “무엇을 허용할지”를 결정한다. 관리자 역할 변경이 실제 실행되기 직전의 운영 로그는 AOP로, 업무 검증 실패에도 보존해야 하는 매니저 등록 요청 이력은 독립 트랜잭션의 DB 로그로 남겼다. 조회는 엔티티 전체를 항상 가져오기보다 필요한 연관 데이터와 결과 필드에 맞춰 선택했다.
참고 자료
'Spring' 카테고리의 다른 글
| [Spring Security] JWT 인증 성공 로그가 있는데 관리자 API가 401이었던 이유 (0) | 2026.09.04 |
|---|---|
| [Spring] GlobalExceptionHandler와 AOP 로깅 - 응답과 실패 기록의 책임 나누기 (0) | 2026.08.25 |
| [Spring] 엔티티와 DTO는 왜 타입이 달라도 되는가 (0) | 2026.07.28 |
| [Spring Data JPA] 동적 쿼리 처리 — JPQL과 Specification (0) | 2026.07.22 |