1. @Transactional의 이해
1) 문제사항 + 원인
- POST /todos 호출 시 아래 에러 발생
Connection is read-only. Queries leading to data modification are not allowed
- TodoService 클래스 레벨에 @Transactional(readOnly = true) 가 선언되어 있어서 클래스 안의 모든 메서드가 읽기 전용 트랜잭션으로 실행
- saveTodo()는 DB에 INSERT가 필요한 쓰기 작업인데, 읽기 전용 설정과 충돌해 에러 발생
// 문제 코드 — saveTodo()도 읽기 전용으로 실행됨
@Service
@Transactional(readOnly = true)
public class TodoService {
public TodoSaveResponse saveTodo(...) {
...
Todo savedTodo = todoRepository.save(newTodo); // ← 여기서 에러
}
}
2) 알아야 할 개념
- @Transactional(readOnly = true) 는 DB 연결을 읽기 전용으로 설정해 flush를 막는 성능 최적화 옵션임. 클래스 레벨 선언은 전체 메서드에 적용되고, 메서드 레벨 @Transactional로 오버라이드 가능
3) 해결방법
- saveTodo() 메서드에 @Transactional 별도 추가
4) 코드 설명
// 변경 전 — readOnly로 INSERT 불가
public TodoSaveResponse saveTodo(...) { ... }
// 변경 후
@Transactional
public TodoSaveResponse saveTodo(...) { ... }
5) 왜 이 지식이 필요한가
- 클래스/메서드 레벨 트랜잭션 우선순위를 이해해야 쓰기 메서드를 빠뜨리는 실수를 방지할 수 있음
2. JWT의 이해
1) 문제사항 + 원인
- User 테이블에 nickname 컬럼이 없고 JWT claim에도 포함되지 않아, 프론트에서 닉네임을 꺼내 쓸 수 없었음.
2) 알아야 할 개념
- JWT Payload에 claim 키-값 쌍을 자유롭게 담을 수 있음
- 서버가 토큰 발급 시 정보를 claim에 넣으면, 클라이언트가 토큰 디코딩으로 꺼내 씀
- claim이 많을수록 토큰 크기가 커지므로 필요한 정보만 담는 것이 원칙
3) 해결방법
- User 엔티티, AuthUser, SignupRequest, JwtUtil, AuthService, JwtFilter 전체에 nickname 필드 추가 및 JWT claim으로 발급
4) 코드 설명
// User 엔티티 — nickname 컬럼 추가
private String nickname;
public User(String email, String password, String nickname, UserRole userRole) {
this.nickname = nickname;
...
}
// AuthUser.java — nickname 필드 추가
private final String nickname; // 추가
private final UserRole userRole;
public AuthUser(Long id, String email, String nickname, UserRole userRole) {
this.id = id;
this.email = email;
this.nickname = nickname;
this.userRole = userRole;
// SignupRequest — nickname 필드 추가
@NotBlank
private String nickname; // 닉네임 필드 추가
// JwtUtil — nickname claim 추가
public String createToken(Long userId, String email, UserRole userRole, String nickname) {
return BEARER_PREFIX + Jwts.builder()
.claim("nickname", nickname)
...
.compact();
}
// AuthService — 회원가입/로그인 시 nickname 사용
@Transactional
public SignupResponse signup(SignupRequest signupRequest) {
...
User newUser = new User(
signupRequest.getEmail(),
encodedPassword,
signupRequest.getNickname(), // 닉네임 추가
userRole
);
User savedUser = userRepository.save(newUser);
String bearerToken = jwtUtil.createToken(
savedUser.getId(),
savedUser.getEmail(),
userRole,
savedUser.getNickname()); // 닉네임 추가
return new SignupResponse(bearerToken);
}
public SigninResponse signin(SigninRequest signinRequest) {
...
String bearerToken = jwtUtil.createToken(
user.getId(), user.getEmail(), user.getUserRole(), user.getNickname()); // 닉네임 추가
return new SigninResponse(bearerToken);
}
// JwtFilter — claims에서 nickname 파싱 → SecurityContext에 저장
String nickname = claims.get("nickname", String.class);
AuthUser authUser = new AuthUser(userId, email, nickname, userRole);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(authUser, null, ...);
SecurityContextHolder.getContext().setAuthentication(authentication);
// AuthUserArgumentResolver — SecurityContext에서 꺼내기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return (AuthUser) authentication.getPrincipal();
5) 왜 이 지식이 필요한가
- JWT는 stateless 인증 방식의 핵심
- claim에 자주 쓰는 정보를 담으면 DB 부하를 줄일 수 있음
3. JPA의 이해
1) 문제사항 + 원인
- GET /todos API에 weather, 수정일 기간 필터가 없었음
- 각 조건은 있을 수도 없을 수도 있는 선택적 필터여야 했음
2) 알아야 할 개념
- JPQL의 :param IS NULL OR 조건 패턴을 쓰면 파라미터가 null이면 조건을 무시하고, 값이 있으면 필터링함
- LEFT JOIN FETCH로 연관 엔티티도 한 번에 가져와 N+1을 방지할 수 있음
3) 해결방법
- TodoRepository에 searchTodos() JPQL 추가, Controller → Service → Repository 계층 모두 파라미터 전달 수정
4) 코드 설명
// TodoRepository
@Query(" SELECT t FROM Todo t LEFT JOIN FETCH t.user " +
"WHERE (:weather IS NULL OR t.weather = :weather)" +
"AND (:startDate IS NULL OR t.modifiedAt >= :startDate)" +
"AND (:endDate IS NULL OR t.modifiedAt <= :endDate)" +
"ORDER BY t.modifiedAt DESC ")
Page<Todo> searchTodos(
@Param("weather") String weather,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
Pageable pageable
);
// TodoController — 선택적 파라미터 추가
@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) LocalDateTime startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDateTime endDate
) {
return ResponseEntity.ok(todoService.getTodos(page, size, weather, startDate, endDate));
}
// TodoService.java — 검색 조건 파라미터 추가
public Page<TodoResponse> getTodos(int page, int size,
String weather,
LocalDateTime startDate,
LocalDateTime endDate) {
Pageable pageable = PageRequest.of(page - 1, size);
Page<Todo> todos = todoRepository.searchTodos(weather, startDate, endDate, 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()
));
}
5) 왜 이 지식이 필요한가
- 실무 검색 조건은 대부분 선택적
- JPQL IS NULL 패턴으로 쿼리 하나로 다양한 필터 조합을 처리할 수 있음
4. 컨트롤러 테스트의 이해
1) 문제사항 + 원인
- todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() 테스트 실패
- InvalidRequestException 발생 시 200 OK를 기대했지만, GlobalExceptionHandler가 400 BAD_REQUEST를 반환하기 때문
2) 알아야 할 개념
- @WebMvcTest는 Web 레이어만 띄우는 슬라이스 테스트이고, GlobalExceptionHandler도 함께 로드됨. 테스트 코드의 기대값은 실제 시스템 동작과 반드시 일치해야 함
3) 해결방법
- 기대 상태 코드와 응답 body를 BAD_REQUEST(400)으로 수정.
4) 코드 설명
// 변경 전 (잘못된 기대값)
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(HttpStatus.OK.name()))
.andExpect(jsonPath("$.code").value(HttpStatus.OK.value()))
// 변경 후 (실제 동작과 일치)
.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"))
5) 왜 이 지식이 필요한가
- 잘못된 기대값의 테스트는 버그를 잡지 못함
- GlobalExceptionHandler가 반환하는 형식을 이해하고 기대값을 정확히 작성해야 함
5. AOP의 이해
1) 문제사항 + 원인
- AOP가 @After + UserController.getUser() 대상으로 잘못 설정되어 있었음
- 실제로는 UserAdminController.changeUserRole() 실행 전에 동작해야 했음
2) 알아야 할 개념
- AOP 어드바이스 타입: @Before(실행 전), @After(실행 후), @Around(전후 모두)
- Pointcut 표현식 execution(* 패키지.클래스.메서드(..)) 으로 대상 메서드를 지정함
3) 해결방법
- @After → @Before, Pointcut 대상을 UserAdminController.changeUserRole()로 수정
4) 코드 설명
// 변경 전
@After("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))")
// 변경 후
@Before("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
public void logAfterChangeUserRole(JoinPoint joinPoint) { ... }
5) 왜 이 지식이 필요한가
- 보안 로깅은 실행 전에 기록해야 함
- Pointcut 표현식을 정확히 작성해야 의도한 메서드에만 AOP가 적용됨
6. JPA Cascade
1) 문제사항 + 원인
- Todo 저장 시 managers 컬렉션에 cascade 설정이 없어서 Manager가 DB에 저장되지 않았음
2) 알아야 할 개념
- JPA cascade(영속성 전이)는 부모 엔티티의 상태 변화를 자식에 자동 전파함
- CascadeType.PERSIST를 설정하면 save() 시 자식도 함께 INSERT됨
3) 해결방법
- @OneToMany에 cascade = CascadeType.PERSIST 추가
4) 코드 설명
// 변경 전
@OneToMany(mappedBy = "todo")
private List<Manager> managers = new ArrayList<>();
// 변경 후
@OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
private List<Manager> managers = new ArrayList<>();
// 생성자에서 Manager 자동 추가
public Todo(String title, String contents, String weather, User user) {
this.user = user;
this.managers.add(new Manager(user, this));
}
5) 왜 이 지식이 필요한가
- cascade를 모르면 연관 엔티티를 매번 직접 save()해야 하고, 빠뜨리면 데이터 누락 발생
7. N+1 문제
1) 문제사항 + 원인
- 댓글 목록 조회 후 루프에서 comment.getUser() 호출 시마다 User 조회 쿼리가 추가 발생 (N+1)
- Repository 쿼리에서 JOIN만 사용해 User를 즉시 로딩하지 않았기 때문
2) 알아야 할 개념
- JOIN — 조건 필터링만, 연관 엔티티는 Lazy 유지 → 접근 시 추가 쿼리 발생
- JOIN FETCH — 연관 엔티티를 즉시 함께 조회 → 쿼리 1번으로 해결
3) 해결방법
- CommentRepository JPQL에서 JOIN → JOIN FETCH로 변경
4) 코드 설명
// 변경 전 — N+1 발생
@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);
5) 왜 이 지식이 필요한가
- N+1은 운영 환경에서 DB 과부하로 이어짐
- Lazy 로딩 + 루프 접근 패턴이 보이면 JOIN FETCH를 검토해야 함
8. QueryDSL
1) 문제사항 + 원인
- findByIdWithUser()가 JPQL 문자열로 작성되어 컴파일 시점에 오타를 잡을 수 없었고, LEFT JOIN만 사용해 N+1 위험도 있었음
2) 알아야 할 개념
- QueryDSL은 자바 코드로 타입 안전하게 쿼리를 작성하는 라이브러리
- Q클래스로 필드명 오타를 컴파일 시점에 잡을 수 있음
- fetchJoin()은 JPQL의 JOIN FETCH와 동일하게 즉시 로딩
3) 해결방법
- TodoRepositoryCustom 인터페이스, TodoRepositoryImpl 구현체 생성, QueryDslConfig 빈 등록, 기존 JPQL 쿼리 삭제
4) 코드 설명
// build.gradle
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// QueryDslConfig — JPAQueryFactory 빈 등록
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
// TodoRepositoryCustom.java — 인터페이스 생성
public interface TodoRepositoryCustom {
Optional<Todo> findByIdWithUser(Long id);
}
// TodoRepositoryImpl — QueryDSL 구현
@Override
public Optional<Todo> findByIdWithUser(Long todoId) {
Todo result = queryFactory
.selectFrom(todo)
.leftJoin(todo.user, user).fetchJoin()
.where(todo.id.eq(todoId))
.fetchOne();
return Optional.ofNullable(result);
}
// TodoRepository.java — Custom 인터페이스 extends 추가, 기존 JPQL 삭제
5) 왜 이 지식이 필요한가
- 복잡한 쿼리를 JPQL 문자열로 관리하면 유지보수가 어렵고 런타임 에러 위험이 있음
- QueryDSL로 타입 안전성과 가독성을 모두 챙길 수 있음
9. Spring Security
1) 문제사항 + 원인
- 기존 Servlet Filter와 AuthUserArgumentResolver로 인증/인가를 처리하던 구조를 Spring Security로 전환
2) 알아야 할 개념 : Spring Security 핵심 흐름
- SecurityFilterChain : URL별 인증/인가 규칙 설정
- OncePerRequestFilter : JWT 검증 후 SecurityContextHolder에 인증 정보 저장
- UsernamePasswordAuthenticationToken : Security가 인식하는 인증 객체
- SecurityContextHolder : 현재 요청의 인증 정보 보관소
- AuthUserArgumentResolver : SecurityContext에서 AuthUser를 꺼내 컨트롤러에 주입
3) 해결방법
- SecurityConfig 추가, JwtFilter를 OncePerRequestFilter 상속으로 전환, AuthUserArgumentResolver 수정
4) 코드 설명
// 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
// SecurityConfig ㅡ 신규 생성
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// JwtFilter — Servlet Filter → OncePerRequestFilter로 전환
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter { // OncePerRequestFilter 상속
private final JwtUtil jwtUtil;
@Override
public void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String url = request.getRequestURI();
if (url.startsWith("/auth")) {
filterChain.doFilter(request, response);
return;
}
String bearerJwt = request.getHeader("Authorization");
if (bearerJwt == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
return;
}
String jwt = jwtUtil.substringToken(bearerJwt);
try {
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
Long userId = Long.parseLong(claims.getSubject());
String email = claims.get("email", String.class);
String nickname = claims.get("nickname", String.class); // nickname 파싱
String roleValue = claims.get("userRole", String.class);
UserRole userRole = UserRole.valueOf(roleValue);
AuthUser authUser = new AuthUser(userId, email, nickname, userRole);
// Security가 인식하는 인증 객체 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
authUser, // principal — AuthUser 객체
null, // credentials — JWT 방식은 불필요
List.of(new SimpleGrantedAuthority("ROLE_" + userRole.name()))
);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token", e);
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
} catch (Exception e) {
log.error("Internal server error", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
}
// AuthUserArgumentResolver — SecurityContext에서 꺼내기
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);
if (hasAuthAnnotation != isAuthUserType) {
throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
}
return hasAuthAnnotation;
}
@Override
public Object resolveArgument(
@Nullable MethodParameter parameter,
@Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
@Nullable WebDataBinderFactory binderFactory
) {
// 기존: HttpServletRequest에서 setAttribute된 값을 꺼내는 방식
// 변경: SecurityContextHolder에서 인증 정보를 꺼내는 방식
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getPrincipal() == null) {
throw new AuthException("인증 정보가 없습니다.");
}
return (AuthUser) authentication.getPrincipal();
}
}
5) 왜 이 지식이 필요한가
- Spring Security는 실무 표준
- SecurityContextHolder 개념을 이해해야 인증 정보를 올바르게 전달할 수 있음
'🔌 SPARTA > Assignments' 카테고리의 다른 글
| [CH 6 실전] 서버 개발 과제 (0) | 2026.05.11 |
|---|---|
| [클라우드 과제] 클라우드_아키텍처 설계 & 배포 (0) | 2026.03.13 |
| [Spring 과제] 코드 개선 (1) | 2026.03.04 |
| [Spring 과제] 일정 관리 앱 Develop (0) | 2026.02.13 |
| [Spring 과제] 일정 관리 앱 만들기 (0) | 2026.02.05 |