할 일 저장 API를 호출했을 때 아래와 같은 에러가 발생했다.
Connection is read-only. Queries leading to data modification are not allowed
즉, 저장 API인데 실제로는 데이터베이스가 “현재 읽기 전용 상태라서 insert를 허용할 수 없다”고 판단한 것이다.
여기서 중요한 건, 단순히 저장이 안 된다는 사실보다 왜 저장 메서드가 읽기 전용 트랜잭션 안에서 실행되고 있는지를 찾는 것이었다.
TodoService 클래스에는 다음과 같이 클래스 레벨에 @Transactional(readOnly = true) 가 선언되어 있었다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TodoService {
...
}
이 설정은 클래스 내부의 모든 메서드를 기본적으로 “조회 전용 트랜잭션”으로 동작하게 만든다.
그런데 saveTodo() 메서드는 실제로 todoRepository.save() 를 호출하는 쓰기 작업이다.
즉 구조는 이랬다.
readOnly = true 는 조회 전용 작업에 적합하다.
스프링과 JPA는 이 설정을 보고 “이번 트랜잭션은 변경이 아니라 조회가 목적”이라고 해석한다.
이 설정의 장점은 다음과 같다.
하지만 저장/수정/삭제가 필요한 메서드에 그대로 적용되면, 오히려 지금처럼 오류의 원인이 된다.
즉 이 문제는 단순한 문법 문제가 아니라, 조회 트랜잭션과 쓰기 트랜잭션의 목적이 다르다는 점을 코드로 구분하지 못했기 때문에 발생한 문제였다.
클래스 전체의 조회 전용 트랜잭션은 유지하되, 저장 메서드인 saveTodo() 에만 일반 @Transactional 을 다시 선언해 쓰기 트랜잭션으로 오버라이드했다.
@Transactional
public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) {
User user = User.fromAuthUser(authUser);
String weather = weatherClient.getTodayWeather();
Todo newTodo = new Todo(
todoSaveRequest.getTitle(),
todoSaveRequest.getContents(),
weather,
user
);
Todo savedTodo = todoRepository.save(newTodo);
return new TodoSaveResponse(
savedTodo.getId(),
savedTodo.getTitle(),
savedTodo.getContents(),
weather,
new UserResponse(user.getId(), user.getEmail())
);
}

POST /todos
요청 예시:
{
"title": "스프링 과제",
"contents": "Transactional 오류 해결"
}
확인 포인트는 다음과 같았다.
기존 Todo 목록 조회는 단순히 전체 목록을 수정일 기준 내림차순으로 조회하는 구조였다.
하지만 요구사항은 다음과 같았다.
즉, 검색 조건이 고정된 것이 아니라 선택적으로 적용되는 동적 검색 구조가 필요했다.
기존 컨트롤러와 서비스는 page, size만 받아 전체 목록을 조회하고 있었다.
@GetMapping("/todos")
public ResponseEntity<Page<TodoResponse>> getTodos(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size
) {
return ResponseEntity.ok(todoService.getTodos(page, size));
}
이 구조로는 사용자가 다음과 같은 요청을 보내도 처리할 수 없었다.
이번 문제의 핵심은 “조건이 없으면 무시하고, 값이 있으면 필터로 적용”하는 것이다.
그래서 컨트롤러에서 먼저 optional 파라미터를 받도록 바꾸고, Repository에서는 JPQL의 null 조건 패턴을 사용했다.
@GetMapping("/todos")
public ResponseEntity<Page<TodoResponse>> getTodos(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String weather,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime modifiedAtFrom,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime modifiedAtTo
) {
return ResponseEntity.ok(todoService.getTodos(page, size, weather, modifiedAtFrom, modifiedAtTo));
}
여기서 required = false 가 핵심이다.
이렇게 해야 사용자가 값을 보내지 않아도 null로 받을 수 있고, “조건이 없는 검색”도 안전하게 처리할 수 있다.
@Query(value = "SELECT t FROM Todo t " +
"LEFT JOIN FETCH t.user " +
"WHERE (:weather IS NULL OR t.weather = :weather) " +
"AND (:modifiedAtFrom IS NULL OR t.modifiedAt >= :modifiedAtFrom) " +
"AND (:modifiedAtTo IS NULL OR t.modifiedAt <= :modifiedAtTo) " +
"ORDER BY t.modifiedAt DESC",
countQuery = "SELECT COUNT(t) FROM Todo t " +
"WHERE (:weather IS NULL OR t.weather = :weather) " +
"AND (:modifiedAtFrom IS NULL OR t.modifiedAt >= :modifiedAtFrom) " +
"AND (:modifiedAtTo IS NULL OR t.modifiedAt <= :modifiedAtTo)")
Page<Todo> searchTodos(
@Param("weather") String weather,
@Param("modifiedAtFrom") LocalDateTime modifiedAtFrom,
@Param("modifiedAtTo") LocalDateTime modifiedAtTo,
Pageable pageable
);
예를 들어 이 조건:
(:weather IS NULL OR t.weather = :weather)
은 이렇게 해석된다.
즉 값이 없으면 조건을 무시하는 패턴이다.
이 방식은 날짜 조건에도 똑같이 적용된다.
GET /todos?page=1&size=10

GET /todos?page=1&size=10&weather=Sunny

GET /todos?page=1&size=10&modifiedAtFrom=2026-04-01T00:00:00&modifiedAtTo=2026-04-06T23:59:59

GET /todos?page=1&size=10&weather=Sunny&modifiedAtFrom=2026-04-01T00:00:00&modifiedAtTo=2026-04-06T23:59:59

동적 검색은 “조건이 많다”가 핵심이 아니라,
조건이 없을 때도 자연스럽게 동작해야 한다 가 핵심이라는 점을 배웠다.
그리고 JPQL에서도 null 조건 패턴을 적절히 활용하면 QueryDSL 없이도 꽤 유연한 검색을 구현할 수 있다는 걸 확인했다.
Todo를 새로 생성할 때, 그 Todo를 만든 사용자가 담당자로 자동 등록되어야 했다.
코드를 확인해보니 Todo 생성자 안에는 이미 다음과 같은 코드가 있었다.
this.managers.add(new Manager(user, this));
즉, 설계 의도 자체는 이미 존재했다.
문제는 리스트에 추가하는 것과 DB에 저장되는 것은 다르다는 점이었다.
JPA에서 연관 객체를 컬렉션에 넣는다고 해서 자동으로 DB에 저장되지는 않는다.
다시 말해,
은 다른 단계다.
이번 구조에서는:
TodoManager였고, Todo를 저장할 때 Manager도 함께 저장되게 하려면 영속성 전이(cascade) 가 필요했다.
Todo 엔티티의 managers 연관관계에 CascadeType.PERSIST 를 추가했다.
@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));
}
PERSIST를 선택했는가이번 요구사항의 핵심은 “Todo 생성 시 함께 저장”이다.
즉 저장 시점의 전이가 필요했기 때문에 CascadeType.PERSIST 가 가장 의도에 맞았다.
ALL 도 사용할 수는 있지만, 그 경우 저장/수정/삭제까지 모두 전이되어 범위가 넓어진다.
이번 요구사항은 “자동 등록”이라는 생성 시점의 동작이 중심이었기 때문에 PERSIST 가 더 명확한 선택이었다.
POST /todos
그 다음
GET /todos/{todoId}/managers

확인 포인트:
GET /todos/{todoId}/comments 호출 시 N+1 문제가 발생하고 있었다.
댓글 목록 조회 자체는 한 번에 수행되지만, DTO를 만드는 과정에서 각 댓글 작성자(User)를 꺼낼 때마다 추가 쿼리가 발생하는 구조였다.
Comment 엔티티는 다음과 같이 user 를 LAZY 로딩으로 가지고 있었다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
그리고 서비스에서는 다음과 같이 반복문에서 comment.getUser() 를 호출하고 있었다.
for (Comment comment : commentList) {
User user = comment.getUser();
...
}
이 경우 댓글 목록을 처음 조회할 때는 Comment 만 가져오고,
이후 각 댓글의 user 가 필요해질 때마다 추가 쿼리가 발생한다.
즉 댓글이 10개면:
총 11번 쿼리가 나갈 수 있다.
이게 전형적인 N+1 문제다.
Repository의 조회 쿼리를 단순 JOIN 에서 JOIN FETCH 로 변경했다.
기존:
@Query("SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId")
수정 후:
@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId")
List<Comment> findByTodoIdWithUser(@Param("todoId") Long todoId);
JOIN FETCH가 핵심인가단순 JOIN 은 SQL 레벨에서 조인은 일어나더라도, JPA가 연관 엔티티를 즉시 로딩된 객체로 다 채워준다는 보장이 없다.
반면 FETCH JOIN 은 연관 엔티티까지 한 번에 가져오라는 의도를 명확히 전달한다.
즉 이 변경 이후에는 서비스에서 comment.getUser() 를 호출하더라도 추가 쿼리가 발생하지 않는다.

GET /todos/{todoId}/comments
이 단계에서는 응답 자체뿐 아니라, 콘솔 SQL 로그도 함께 확인했다.
확인 포인트:
이번 문제는 “성능 최적화는 나중에 하는 것”이 아니라,
엔티티 연관관계를 어떻게 조회하느냐 자체가 성능과 직결된다는 걸 체감하게 해줬다.
LAZY 로딩은 기본 전략으로 매우 유용하지만,
“어차피 지금 반드시 함께 사용할 연관 객체”라면 fetch join을 통해 한 번에 가져오는 것이 훨씬 효율적이다.
기존 findByIdWithUser 는 문자열 JPQL 기반으로 작성되어 있었다.
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user WHERE t.id = :todoId")
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
이 방식도 동작은 하지만, 문자열 기반 쿼리는 다음과 같은 한계가 있다.
과제에서는 이를 QueryDSL로 바꾸고, 동시에 user 를 함께 조회해 N+1 성격의 문제도 막도록 요구했다.
먼저 build.gradle 에 QueryDSL 관련 의존성을 추가하고,
JPAQueryFactory 를 빈으로 등록했다.
그리고 TodoRepositoryCustom 과 TodoRepositoryImpl 구조를 만들어 기존 JPQL 메서드를 QueryDSL 구현체로 옮겼다.
@Override
public Optional<Todo> findByIdWithUser(Long todoId) {
QTodo todo = QTodo.todo;
QUser user = QUser.user;
Todo result = jpaQueryFactory
.selectFrom(todo)
.leftJoin(todo.user, user).fetchJoin()
.where(todo.id.eq(todoId))
.fetchOne();
return Optional.ofNullable(result);
}
여기서 중요한 건 QueryDSL로 바꾼 것 자체보다도,
여전히 fetchJoin() 을 유지했다는 점이다.
즉 이번 단계는 단순히 문법을 JPQL에서 QueryDSL로 옮긴 것이 아니라,
이 두 가지를 동시에 만족하도록 구성한 작업이었다.
처음에는 QTodo, QUser 를 찾지 못하는 문제가 발생했다.
원인은 QueryDSL Q클래스가 자동 생성되지 않았기 때문이었다.
이 문제를 해결하기 위해 다음을 확인했다.
compileJava 수행 여부이 과정을 통해 QueryDSL은 단순히 코드만 작성한다고 되는 게 아니라,
빌드 설정과 코드 생성까지 함께 맞아야 동작하는 구조라는 점을 체감했다.

GET /todos/{todoId}
확인 포인트:
QueryDSL은 “문자열 대신 자바 코드로 쿼리를 쓴다”는 점에서 유지보수성이 훨씬 높았다.
특히 복잡한 조건이나 동적 쿼리로 갈수록 JPQL보다 훨씬 확장성이 좋다는 걸 느꼈다.
기존 JWT에는 email과 userRole만 들어 있었고, 프론트엔드가 사용자 닉네임을 바로 사용할 수 없는 상태였다.
요구사항은 다음과 같았다.
이 문제는 단순히 필드 하나를 추가하는 것이 아니었다.
nickname 이 다음 흐름 전체를 타고 전달되어야 했다.
즉, DB -> JWT -> 인증 흐름 전체를 연결하는 작업이었다.
private String nickname;
@NotBlank
private String nickname;
.claim("nickname", nickname)
회원가입/로그인 시 토큰 생성 메서드에 nickname 전달
String bearerToken = jwtUtil.createToken(
user.getId(),
user.getEmail(),
user.getNickname(),
user.getUserRole()
);
private final String nickname;
nickname 작업 후 서버 실행 시 다음 오류가 발생했다.
Could not resolve placeholder 'jwt.secret.key'
처음에는 nickname 관련 수정 문제라고 생각할 수도 있었지만, 실제 원인은 전혀 달랐다.
JwtUtil 에서 다음 값을 주입받고 있었는데,
@Value("${jwt.secret.key}")
private String secretKey;
실행 환경에 jwt.secret.key 설정이 없어서 빈 생성이 실패한 것이었다.
즉 이번 단계의 핵심 트러블슈팅은
“내가 방금 수정한 코드가 문제일 것”이라는 추측보다,
로그에서 실제 root cause를 정확히 읽는 것이 더 중요하다는 점을 보여줬다.

POST /auth/signup
요청 예시:
{
"email": "test@test.com",
"password": "1234",
"nickname": "jimin",
"userRole": "USER"
}

POST /auth/signin
확인 포인트:
JWT는 단순히 로그인 여부만 판단하는 토큰이 아니라,
클라이언트가 자주 사용할 사용자 정보를 함께 담아 전달하는 수단이 될 수 있다.
다만 claim 하나를 추가하는 것만으로 끝나는 것이 아니라,
DB 저장, 토큰 생성, 토큰 파싱, 인증 객체 반영까지 모두 이어줘야 전체 흐름이 완성된다.
todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() 테스트가 실패하고 있었다.
문제를 확인해보니 서비스는 예외를 던지는데, 테스트는 성공 응답을 기대하고 있었다.
즉, 예외 상황을 검증해야 하는 테스트인데도 다음과 같이 200 OK 를 기대하고 있었다.
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(HttpStatus.OK.name()))
.andExpect(jsonPath("$.code").value(HttpStatus.OK.value()))
컨트롤러 테스트에서 중요한 것은 “서비스 내부에서 예외가 발생했는가”만이 아니다.
더 중요한 건
그 예외가 Global Exception Handler를 거쳐 어떤 HTTP 응답으로 변환되는가 이다.
즉 이 테스트는
“Todo가 없을 때 서비스가 예외를 던진다”를 검증하는 테스트가 아니라,
“그 예외가 웹 계층에서 어떤 응답 형태로 내려가는가”를 검증해야 했다.
기대값을 200 OK 에서 400 Bad Request 로 수정했다.
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name()))
.andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value()))
.andExpect(jsonPath("$.message").value("Todo not found"));
또한 AuthUser 생성자 구조가 nickname 추가로 변경되었기 때문에, 테스트 코드도 실제 구조에 맞게 함께 수정했다.
컨트롤러 테스트는 서비스 로직 자체를 다시 검증하는 것이 아니라,
서비스 결과가 HTTP 응답으로 어떻게 표현되는지 검증하는 레이어라는 점을 다시 확인할 수 있었다.
과제 요구사항은 UserAdminController.changeUserRole() 메서드가 실행되기 전에 관리자 접근 로그가 남아야 한다는 것이었다.
하지만 기존 Aspect는 전혀 다른 메서드에 걸려 있었고, 실행 시점도 잘못되어 있었다.
기존 코드:
@After("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))")
public void logAfterChangeUserRole(JoinPoint joinPoint) {
...
}
여기에는 두 가지 문제가 있었다.
UserController.getUser() 였다@After 였다즉 요구사항은 “권한 변경 메서드 실행 전 로그”인데,
실제 코드는 “다른 메서드 실행 후 로그”였던 것이다.
포인트컷과 실행 시점을 모두 수정했다.
@Before("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
public void logBeforeChangeUserRole(JoinPoint joinPoint) {
String userId = String.valueOf(request.getAttribute("userId"));
String requestUrl = request.getRequestURI();
LocalDateTime requestTime = LocalDateTime.now();
log.info("Admin Access Log - User ID: {}, Request Time: {}, Request URL: {}, Method: {}",
userId, requestTime, requestUrl, joinPoint.getSignature().getName());
}
AOP는 두 가지가 정확해야 한다.
이번 문제는 이 둘이 모두 어긋나 있었기 때문에 의도한 동작이 전혀 일어나지 않았다.
PATCH /admin/users/{userId}
요청 예시:
{
"userRole": "ADMIN"
}
추가로 로그 콘솔 확인
확인 포인트:
changeUserRole() 호출 전에 로그가 찍히는지AOP는 “붙이면 된다”가 아니라,
정확한 대상과 정확한 시점을 지정해야 의도한 공통 기능이 동작한다는 걸 확실히 느꼈다.
기존 인증 구조는 다음과 같았다.
JwtFilter 가 JWT를 파싱AuthUserArgumentResolver 가 request에서 값을 꺼내 @Auth AuthUser 로 주입이 구조도 동작은 했지만, Spring Security 없이 인증/인가를 거의 수동으로 구현한 방식이었다.
특히 /admin 권한 체크도 직접 if문으로 처리하고 있었다.
과제 요구사항은 명확했다.
즉 핵심은 “JWT를 버리는 것”이 아니라,
JWT 인증 흐름을 Spring Security 표준 방식 안으로 옮기는 것이었다.
JwtFilterFilterConfigAuthUserArgumentResolverWebConfig@Auth AuthUserJwtAuthenticationFilterSecurityConfigSecurityContext@AuthenticationPrincipal AuthUser기존에는 request attribute를 통해 사용자 정보를 넘겼다.
하지만 Spring Security에서는 인증 성공 시 SecurityContextHolder 에 Authentication 을 저장하고,
컨트롤러에서는 @AuthenticationPrincipal 로 principal을 받아 사용한다.
즉 사용자 정보의 전달 통로 자체가 바뀐 것이다.
AuthUser authUser = new AuthUser(userId, email, nickname, userRole);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
authUser,
null,
List.of(new SimpleGrantedAuthority(userRole.name()))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/admin/**").hasAuthority(UserRole.ADMIN.name())
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
이 설정을 통해
/auth/** 는 누구나 접근 가능/admin/** 는 ADMIN 권한 필요라는 정책을 Spring Security 설정으로 선언할 수 있었다.
기존:
@Auth AuthUser authUser
변경 후:
@AuthenticationPrincipal AuthUser authUser
이 과정에서 기존 AuthUserArgumentResolver, FilterConfig, WebConfig 는 더 이상 필요 없어져 제거했다.
처음에는 기존 AuthUserArgumentResolver 를 살려두고 SecurityContext 와 섞어 쓰는 방향도 고민했지만,
그렇게 되면 구조가 이중화된다.
즉,
가 생긴다.
결국 가장 깔끔한 방향은 Resolver 자체를 제거하고 @AuthenticationPrincipal 로 통일하는 것이었다.
WebConfig 에서 이미 삭제한 Resolver를 계속 등록하고 있던 문제WebConfig 가 여전히 AuthUserArgumentResolver 를 등록하려 해서 실행 에러가 발생했다.
이 문제는 예전 구조의 흔적을 완전히 제거하지 못해 생긴 문제였다.
결국 Spring Security 전환은 새 코드 추가만이 아니라,
기존 수동 인증 구조의 흔적을 함께 지우는 작업이라는 점을 배웠다.
(Postman으로 테스트 한 사진 삽입)
POST /auth/signin
토큰 발급 후
(Postman으로 테스트 한 사진 삽입)
POST /todos
Authorization 헤더에 Bearer 토큰 포함
(Postman으로 테스트 한 사진 삽입)
PATCH /admin/users/{userId}
일반 USER 토큰으로 요청한 경우 403 확인
(Postman으로 테스트 한 사진 삽입)
PATCH /admin/users/{userId}
ADMIN 토큰으로 요청한 경우 정상 처리 확인
Spring Security의 핵심은 보안 라이브러리 추가가 아니었다.
정말 중요한 건 다음 두 가지였다.
SecurityContext 로 관리하는 것즉 이번 전환은 단순 리팩토링이 아니라,
인증/인가 구조를 스프링 표준 방식으로 재설계한 작업에 가까웠다.
이번 과제는 단순히 기능 개수를 채우는 과제가 아니었다.
오히려 이미 존재하는 코드를 읽고, 그 안에 숨어 있는 설계 의도와 문제점을 파악하고,
스프링이 제공하는 기능들을 “왜 이럴 때 쓰는가”까지 연결해보는 과정이었다.
특히 다음 개념들을 실제 문제와 함께 연결해서 이해할 수 있었다.
@Transactional(readOnly = true) 와 쓰기 트랜잭션 구분무엇보다 가장 크게 느낀 점은,
문제를 해결하는 것과 문제를 설명할 수 있는 것은 다르다는 것이다.
이번 과제에서는 각 기능을 구현할 때마다
“왜 이렇게 해야 하는가”,
“이전 구조는 왜 한계가 있었는가”,
“스프링이 제공하는 기능은 어떤 원리로 동작하는가”
를 같이 정리하려고 노력했고, 그 과정이 오히려 가장 큰 공부가 되었다.
POST /auth/signin
POST /todos
PATCH /admin/users/{userId}
이번 개인 과제 기간 동안 개인 일정으로 인해 약 2일 정도 학습과 과제 진행에 집중하지 못했던 점이 아쉬움으로 남는다.
그로 인해 전체 과제를 몰아서 진행하게 되었고, 과정이 다소 촉박하게 흘러갔던 부분이 있었다.
하지만 그럼에도 불구하고 이번 과제를 통해 단순히 기능을 구현하는 것을 넘어,
각 기술을 왜 사용하는지, 그리고 어떤 상황에서 필요한지까지 깊이 있게 고민해볼 수 있었던 시간이었다.
특히 트랜잭션, JPA 연관관계, N+1 문제, QueryDSL, 그리고 Spring Security까지
실제 문제 상황과 연결하여 학습할 수 있었던 점이 의미있었다.
이번 경험을 통해 미리 계획하고 꾸준히 진행하는 것이 얼마나 중요한지 다시 한번 느끼게 되었고,
앞으로는 일정 관리까지 포함한 개발 습관을 개선해 나가고자 한다.