JWT와 Filter
Filter는 DispatcherServlet 앞에 있는 서블릿(웹) 컴포넌트, Interceptor와 AOP는 그 뒤에 있는 스프링 컴포넌트다.
Tomcat은 서블릿 컨테이너인 Catalina와 JSP 컨테이너인 Jasper로 이루어져 있고, Spring MVC(Spring Boot에 내장된 Tomcat)가 그 위에 올라가는 구조다.
Filter는 DispatcherServlet(스프링)보다 앞 단계인 서블릿 컨테이너 레벨에서 동작한다.
지금까지 JwtProvider.createAT()는 그냥 "Bearer XXXXXXX"를 돌려주는 가짜였다. 오늘 대칭키(symmetric key) 방식으로 진짜 JWT를 발급하도록 바꿨다.
.env에 시크릿 키를 넣고 application-dev.yml의 jwt.secret으로 참조한다. @Value("${jwt.secret}")로 클래스 필드에 주입받는다.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이 들어간다.implements Filter로 만든 JwtFilter가 오늘의 핵심이다. doFilter(request, response, chain)가 요청마다 무조건 호출되는 콜백 메서드다.
/users/**, /swagger-ui/**, /v3/api-docs/**처럼 토큰이 필요 없는 경로는 AntPathMatcher로 매칭해서 그냥 통과시킨다.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)처럼 접두어를 붙여서 응답하도록 되어 있었다.
어제 User 도메인만 JPA로 옮겼는데, 오늘 Blog와 Comment까지 마무리했다. 가장 헷갈렸던 부분은 외래키를 다루는 방향이 담을 때와 꺼낼 때가 다르다는 점이다.
BlogEntity.author는 UserEntity 타입이라, email 문자열을 그대로 넣을 수 없다. userRepository.findById(email)로 엔티티를 먼저 조회하고, 그 엔티티를 toEntity(user)에 넘겨서 BlogEntity를 만든다.entity.getAuthor().getEmail()처럼 엔티티를 한 번 꺼낸 다음 그 안에서 값을 또 꺼낸다.Comment도 같은 패턴이다 — blogRepository.findById(blogId)로 BlogEntity를 먼저 찾은 뒤 Comment와 연결한다.
블로그 상세조회는 글 하나와 그 댓글들을 같이 내려줘야 한다. 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)를 이 쿼리 안에서 한 번에 같이 가져오게 만든다.
댓글 수정 로직에서 처음 보는 개념이었다.
CommentEntity entity = commentRepository.findById(id)
.orElseThrow(...);
entity.updateComment(comment);
// commentRepository.save(entity); ← 이거 없어도 UPDATE된다
findById()로 조회한 엔티티는 영속성 컨텍스트 안에서 "관리되는" 상태가 된다. 이 상태에서 필드를 수정하면(updateComment()), Hibernate가 조회했을 때의 스냅샷과 현재 상태를 비교해서 달라진 필드를 찾아내고, 트랜잭션이 커밋되는 시점에 자동으로 UPDATE문을 실행한다. 이게 Dirty Checking(변경 감지)이다. 그래서 이 메서드엔 @Transactional을 반드시 붙여야 한다. 트랜잭션이 끝나야(커밋되어야) flush가 일어나고, 그 시점에야 변경 감지가 실제 UPDATE로 이어지기 때문이다.
#LGCNS #LGCNS6기 #개발자 #LGCNSINSPIRECAMP