[LG CNS 6기] 본 과정 30일차 TIL / [Spring Boot] - JWT 발급과 Filter 검증

김승진·2026년 9월 9일

LG CNS AM 6기 TIL

목록 보기
39/46

1. 오늘의 한 줄 요약

JWT와 Filter

2. 오늘 배운 것

2.1 Filter, Interceptor, AOP, Tomcat

Filter는 DispatcherServlet 앞에 있는 서블릿(웹) 컴포넌트, Interceptor와 AOP는 그 뒤에 있는 스프링 컴포넌트다.

Tomcat은 서블릿 컨테이너인 Catalina와 JSP 컨테이너인 Jasper로 이루어져 있고, Spring MVC(Spring Boot에 내장된 Tomcat)가 그 위에 올라가는 구조다.
Filter는 DispatcherServlet(스프링)보다 앞 단계인 서블릿 컨테이너 레벨에서 동작한다.

2.2 JWT 토큰을 실제로 만들기

지금까지 JwtProvider.createAT()는 그냥 "Bearer XXXXXXX"를 돌려주는 가짜였다. 오늘 대칭키(symmetric key) 방식으로 진짜 JWT를 발급하도록 바꿨다.

  • .env에 시크릿 키를 넣고 application-dev.yml의 jwt.secret으로 참조한다. @Value("${jwt.secret}")로 클래스 필드에 주입받는다.
  • 키는 최소 30자 이상이어야 하고 특수문자는 넣지 않는 게 좋다.
  • Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))로 평문 키를 SHA-256 방식으로 인코딩한다. (인코딩, 디코딩 사이트인 base64decode.org 도 확인했다.)
  • Jwts.builder().setSubject(email).setIssuedAt(new Date()).setExpiration(...).signWith(getSecretKey()).compact()로 Access Token을 만든다. subject에는 사용자 email이 들어간다.

2.3 JwtFilter — 요청이 컨트롤러에 닿기 전에 토큰을 검증한다

implements Filter로 만든 JwtFilter가 오늘의 핵심이다. doFilter(request, response, chain)가 요청마다 무조건 호출되는 콜백 메서드다.

  • 화이트리스트: /users/**, /swagger-ui/**, /v3/api-docs/**처럼 토큰이 필요 없는 경로는 AntPathMatcher로 매칭해서 그냥 통과시킨다.
  • preflight 처리: React(3000번)와 Spring Boot(8000번)는 Origin이 다르고, Authorization 헤더를 쓰기 때문에 브라우저가 본 요청 전에 OPTIONS로 먼저 물어본다(preflight). OPTIONS 요청이면 CORS 허용 헤더만 세팅하고 바로 chain.doFilter()로 넘긴다.
  • 토큰 검증: Authorization 헤더가 없거나 "Bearer "로 시작하지 않으면 401. 있으면 substring(7)로 "Bearer "를 잘라내고, Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)으로 서명·만료를 검증한다. 통과하면 chain.doFilter()로 다음 단계(컨트롤러)로 넘기고, 실패하면 그 자리에서 끝낸다.

chain.doFilter()를 호출하지 않으면 요청이 컨트롤러까지 가지 못한다는 게 포인트였다. 그리고 토큰을 만드는 쪽(JwtProvider)과 검증하는 쪽(JwtFilter)의 "Bearer " 접두어 규칙이 맞아야 한다 — 실제로 로그인 응답 헤더를 만드는 UserController에서도 headers.add("Authorization", "Bearer "+at)처럼 접두어를 붙여서 응답하도록 되어 있었다.

2.4 MyBatis에서 JPA(Hibernate)로 — Blog·Comment 다시 만들기

어제 User 도메인만 JPA로 옮겼는데, 오늘 Blog와 Comment까지 마무리했다. 가장 헷갈렸던 부분은 외래키를 다루는 방향이 담을 때와 꺼낼 때가 다르다는 점이다.

  • 담을 때: BlogEntity.author는 UserEntity 타입이라, email 문자열을 그대로 넣을 수 없다. userRepository.findById(email)로 엔티티를 먼저 조회하고, 그 엔티티를 toEntity(user)에 넘겨서 BlogEntity를 만든다.
  • 꺼낼 때: entity.getAuthor().getEmail()처럼 엔티티를 한 번 꺼낸 다음 그 안에서 값을 또 꺼낸다.

Comment도 같은 패턴이다 — blogRepository.findById(blogId)로 BlogEntity를 먼저 찾은 뒤 Comment와 연결한다.

2.5 N+1 문제와 JPQL Fetch Join

블로그 상세조회는 글 하나와 그 댓글들을 같이 내려줘야 한다. BlogEntity.comments는 @OneToMany(mappedBy = "blog", orphanRemoval = false)로 선언돼 있는데, @OneToMany는 별도로 fetch 옵션을 안 줘도 기본값이 LAZY라 자동으로 지연 로딩된다. 그래서 블로그를 조회한 뒤 comments에 접근하는 순간 댓글을 조회하는 쿼리가 추가로 나간다. 블로그 하나면 쿼리 2번(1+1)으로 끝나지만, 블로그가 N개면 각각의 댓글을 또 조회해야 해서 총 N+1번의 쿼리가 나가는 게 N+1 문제다.

해결은 JPQL의 Fetch Join이다.

@Query("""
    SELECT      b
    FROM        BlogEntity b
    LEFT JOIN   FETCH   b.comments
    WHERE       b.blogId = :blogId
""")
public Optional<BlogEntity> findByComments(@Param("blogId") Integer blogId);

JPQL은 SQL과 비슷해 보이지만 테이블/컬럼이 아니라 엔티티/필드 이름을 기준으로 쓴다. LEFT JOIN FETCH가 지연 로딩으로 미뤄뒀던 연관 엔티티(comments)를 이 쿼리 안에서 한 번에 같이 가져오게 만든다.

2.6 Dirty Checking

댓글 수정 로직에서 처음 보는 개념이었다.

CommentEntity entity = commentRepository.findById(id)
        .orElseThrow(...);
entity.updateComment(comment);
// commentRepository.save(entity); ← 이거 없어도 UPDATE된다

findById()로 조회한 엔티티는 영속성 컨텍스트 안에서 "관리되는" 상태가 된다. 이 상태에서 필드를 수정하면(updateComment()), Hibernate가 조회했을 때의 스냅샷과 현재 상태를 비교해서 달라진 필드를 찾아내고, 트랜잭션이 커밋되는 시점에 자동으로 UPDATE문을 실행한다. 이게 Dirty Checking(변경 감지)이다. 그래서 이 메서드엔 @Transactional을 반드시 붙여야 한다. 트랜잭션이 끝나야(커밋되어야) flush가 일어나고, 그 시점에야 변경 감지가 실제 UPDATE로 이어지기 때문이다.

3. 오늘의 회고

  • 느낀 점: spring도 마무리가 되어간다. 교육을 듣기 시작한 지 벌써 한달이 넘었다는 게 믿기지 않을 정도로 시간이 빠른 것 같다. 지난달보다 발전한 나였으면 하는 마음으로 더 열심히 달려봐야겠다.
  • 다음에 할 것: Token과 다음 강의 전까지 코드 복습

#LGCNS #LGCNS6기 #개발자 #LGCNSINSPIRECAMP

profile
이것저것

0개의 댓글