🔌 SPARTA/Assignments

[플러스 Spring] 코드 개선 과제

eunjiom 2026. 4. 3. 13:13

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 핵심 흐름

  1. SecurityFilterChain : URL별 인증/인가 규칙 설정
  2. OncePerRequestFilter : JWT 검증 후 SecurityContextHolder에 인증 정보 저장
  3. UsernamePasswordAuthenticationToken : Security가 인식하는 인증 객체
  4. SecurityContextHolder : 현재 요청의 인증 정보 보관소
  5. 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 개념을 이해해야 인증 정보를 올바르게 전달할 수 있음