본문 바로가기
내일배움캠프/내일배움캠프

[내일배움캠프] 심화 과제 진행해보기 Lv .2

by TIP__ 2024. 11. 18.

플러스 주차 개인 과제를 진행하며 기록을 남겨보기 위해 글을 작성합니다.
이번 과제는 레거시 코드를 리펙토링해보는 과제입니다.


레거시 코드

-. 레거시 코드는 개발된 지 오래되어서 최신 기술이나 표준과는 다소 맞지 않는 코드를 뜻합니다.

레거시 코드를 리펙토링해보면

-. 레거시 코드를 리펙토링 하기 위해서는 먼저 코드를 확실히 이해하고있어야 하기 때문에 리펙토링을 해보며 코드를 이해하고 분석하는 능력을 키울 수 있습니다.


Level. 2

6. JPA Cascade

앗! 실수로 코드를 지웠어요!

@OneToMany(mappedBy = "todo")
private List<Manager> managers = new ArrayList<>();

할 일을 새로 저장할 시, 할 일을 생성한 유저는 담당자로 자동 등록되어야 합니다.
JPA의 cascade 기능을 활용해 할 일을 생성한 유저가 담당자로 등록될 수 있게 해주세요.

문제 해결 과정

Cascade Type.PERSIST를 추가

@OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
private List<Manager> managers = new ArrayList<>();

7. N + 1

CommentController 클래스의 getComments() API를 호출할 때 N+1 문제가 발생하고 있어요. N+1 문제란, 데이터베이스 쿼리 성능 저하를 일으키는 대표적인 문제 중 하나로, 특히 연관된 엔티티를 조회할 때 발생해요.
해당 문제가 발생하지 않도록 코드를 수정해주세요.

 

N + 1 로그

Hibernate: select c1_0.id,c1_0.contents,c1_0.created_at,c1_0.modified_at,c1_0.todo_id,c1_0.user_id from comments c1_0 where c1_0.todo_id=?
Hibernate: select u1_0.id,u1_0.created_at,u1_0.email,u1_0.modified_at,u1_0.nickname,u1_0.password,u1_0.user_role from users u1_0 where u1_0.id=?

문제 해결 과정

CommentRepository의 Query를 fetch join으로 변경

N + 1 문제를 해결하는 데에는 fetch join 외에도 여러 가지 방법이 있지만 현재 주어진 코드에서 가장 간편하게 해결할 수 있는 방법이 fetch join을 사용하는 것이라고 생각했습니다.

@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId")

 

변경 후 로그

Hibernate: /* SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId */ select c1_0.id,c1_0.contents,c1_0.created_at,c1_0.modified_at,c1_0.todo_id,c1_0.user_id,u1_0.id,u1_0.created_at,u1_0.email,u1_0.modified_at,u1_0.nickname,u1_0.password,u1_0.user_role from comments c1_0 join users u1_0  on u1_0.id=c1_0.user_id where c1_0.todo_id=?

 


8. QueryDSL

JPQL로 작성된 findByIdWithUser 를 QueryDSL로 변경합니다.
7번과 마찬가지로 N+1 문제가 발생하지 않도록 유의해 주세요!

문제 해결 과정

fetchJoin을 사용하여 N+1문제 방지

1. QueryDSL 설정

1-1. build.gredle 의존성 추가

implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

 

1-2. QClass 생성
Gradle의 clean, compileJava을 실행

 

1-3. Q파일 생성 확인 후 Application 1회 실행하여 QueryDSL 설정 완료

2. TodoRepositoryQuery와 TodoRepositoryQueryImpl 추가

TodoRepositoryQuery

public interface TodoRepositoryQuery {

    // weather 조건과 기간 조건이 모두 있는 경우
    Page<Todo> findByWeatherAndDate(String weather, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable);

    // weather  조건만 있는 경우
    Page<Todo> findByWeatherOrOrderByModifiedAt(String weather, Pageable pageable);

    // 기간 조건만 있는 경우
    Page<Todo> findByDateOrderByModifiedAt(LocalDateTime startDate, LocalDateTime endDate, Pageable pageable);

    // 아무 조건도 없는 경우 전체 조회
    Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);

    // 특정 일정 조회
    Optional<Todo> findByIdWithUser(Long todoId);

}

 

TodoRepositoryQueryImpl

public class TodoRepositoryQueryImpl implements TodoRepositoryQuery {

    private final JPAQueryFactory queryFactory;
    private final QTodo todo = QTodo.todo;
    private final QUser user = QUser.user;

    public TodoRepositoryQueryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    // weather 조건과 기간 조건이 모두 있는 경우
    @Override
    public Page<Todo> findByWeatherAndDate(String weather, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable) {
        List<Todo> todos = queryFactory
                .selectFrom(todo)
                .leftJoin(todo.user, user).fetchJoin()
                .where(todo.weather.eq(weather).and(todo.modifiedAt.between(startDate, endDate)))
                .orderBy(todo.modifiedAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        // 페이징 처리를 위한 조회된 일정을 카운트
        long total = Optional.ofNullable(queryFactory
                        .select(todo.count())
                        .from(todo)
                        .where(todo.weather.eq(weather).and(todo.modifiedAt.between(startDate, endDate)))
                        .fetchOne())
                .orElse(0L);

        return new PageImpl<>(todos, pageable, total);
    }

    ... 외 Qeury

}

3. TodoRepository

public interface TodoRepository extends JpaRepository<Todo, Long>, TodoRepositoryQuery {

}

9. Spring Security

Spring Security를 도입하기로 결정했어요!

기존 FilterArgument Resolver를 사용하던 코드들을 Spring Security로 변경해주세요.

  • 접근 권한 및 유저 권한 기능은 그대로 유지해주세요.
  • 권한은 Spring Security의 기능을 사용해주세요.
    토큰 기반 인증 방식은 유지할 거예요. JWT는 그대로 사용해주세요.

문제 해결 과정

1. Spring Security 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-security'

2. 필요한 클래스 추가

SecurityConfig

SecurityFilterChain을 사용해 접근 권한을 관리하고 JWT 인증 필터를 추가합니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtUtil jwtUtil;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // JWT 인증을 사용하기 때문에 비활성화
                .csrf(csrf -> csrf.disable())
                // JWT 인증을 사용하기 때문에 세션 관리 비활성화
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

 

JwtAuthenticationFilter

OncePerRequestFilter를 상속하여 모든 요청에서 JWT 토큰을 검사하고 인증 정보를 설정합니다.

-. OncePerRequestFilter

 : Spring에서 제공하는 필터 클래스로 한 요청당 한 번만 실행되는 필터를 구현할 때 사용됩니다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String bearerToken = request.getHeader("Authorization");

        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            String token = bearerToken.substring(7);
            try {
                Claims claims = jwtUtil.extractClaims(token);
                String email = claims.getSubject();
                String userRole = claims.get("userRole", String.class);
                String nickname = claims.get("nickname", String.class);

                if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                            email, null, List.of(new SimpleGrantedAuthority("ROLE_" + userRole))
                    );
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    request.setAttribute("userId", Long.parseLong(claims.getSubject()));
                    request.setAttribute("email", email);
                    request.setAttribute("userRole", userRole);
                    request.setAttribute("nickname", nickname);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (Exception e) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token");
                return;
            }
        }

        filterChain.doFilter(request, response);
    }
}

3. 필요없는 클래스 제거

WebConfig, JwtFilter, FilterConfig, AuthUserArgumentResolver

댓글