🔌 SPARTA/Courses

스탠다드반 10회차: Filters & Proxy Objects

eunjiom 2026. 3. 9. 21:12

Filter

1. 기존 JWT 토큰 검증

  • api마다 확인 > 토큰 꺼내기 > 토큰 유효성 검증(예외처리)
  • api가 많으면 중복 코드 발생
  • 컨트롤러는 가벼워야 함 (보안과 인증 책임 부여X)

2. Filter를 사용하는 이유

 

1) AOP나 Interceptor로도 토큰 검증을 할 수 있으나 보안에 있어서 방어선 위치가 중요함

 

2) Filter (1층 로비의 건물 경비원)

  • 위치: 톰캣(Tomcat) 같은 웹 서버(서블릿 컨테이너) 영역에 존재합니다. 스프링의 핵심부로 들어오기 '전'에 작동
  • 역할: 건물을 방문하는 모든 사람의 신분증을 가장 먼저 검사

3) Interceptor (7층 개발팀 사무실 앞의 비서)

  • 위치: 스프링 MVC(DispatcherServlet) 내부로 들어온 '후'에 작동
  • 역할: 특정 부서(Controller)로 들어가는 사람의 세부 권한을 한 번 더 확인

3. OncePerRequestFilter

 

1) 기본 Filter 사용했을 때 문제점

  • API 호출하다가 에러 터짐 > 에러 내용을 보여주기 위한 포워딩(내부 재요청) = 2번 일하게 됨

2) OncePerRequestFilter

  • 사용자의 1회 HTTP 요청당 무슨 일이 있어도 무조건 딱 1번만 실행

4. JwtAuthenticationFilter 만들기

더보기
더보기
더보기
package com.example.instagramclone.security.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Collections;
import com.example.instagramclone.constant.AuthConstants;

/**
 * [모든 요청의 첫 번째 검문소, JwtAuthenticationFilter]
 * * Q. 왜 스프링의 Interceptor가 아니라 서블릿의 Filter를 사용할까요?
 * A. 방어의 "최전선"이기 때문입니다.
 * Interceptor는 스프링 MVC(DispatcherServlet) 내부로 들어온 이후에 동작합니다.
 * 악성 요청이나 미인증 요청을 스프링 내부까지 들어오게 허용하면 리소스가 낭비되고 공격 표면이 넓어집니다.
 * 따라서 "가장 바깥쪽 문"인 서블릿 Filter 단에서 원천 차단하는 것이 현업의 표준 보안 프랙티스입니다.
 * * Q. 왜 Filter 대신 OncePerRequestFilter를 상속받나요?
 * A. 내부 포워딩(동일 서버 내 다른 컨트롤러로 요청 전달 등)이 발생할 때
 * 일반 Filter는 불필요하게 두 번 이상 실행될 수 있습니다.
 * OncePerRequestFilter는 한 요청(Request) 당 정확히 한 번만 검문하도록 보장합니다.
 */
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

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

        // 1. Request Header 에서 클라이언트가 보낸 JWT 토큰을 가로채기
        String token = resolveToken(request);

        // 2. 가로챈 토큰이 존재하고(null이 아니고), 위변조 및 만료되지 않은 "유효한" 토큰인지 검사
        // 실무 포인트: StringUtils.hasText()는 null, 빈 문자열(""), 공백("   ")을 한 번에 걸러주는 스프링 필수 유틸입니다.
        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {

            // 3. 유효한 토큰이면 Spring Security Context 에 인증 정보 심기
            // 실무 포인트: 매 API 요청마다 DB나 세션을 조회하지 않습니다. (Stateless)
            // 토큰 자체에 들어있는 Payload(Claims) 정보만으로 주체(Principal)를 확인합니다.
            Long memberId = jwtTokenProvider.getMemberId(token);
            String role = jwtTokenProvider.getRole(token);

            // 토큰에서 추출한 정보로 Authentication(인증 도장) 객체 생성
            // Principal(주체)로 memberId를 넣고, Credentials(비밀번호)는 null 처리, Authorities(권한) 부여
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    memberId,
                    null,
                    Collections.singletonList(new SimpleGrantedAuthority(StringUtils.hasText(role) ? role : "ROLE_USER")) // 토큰에서 추출한 권한 사용, 없으면 기본값
            );

            // SecurityContextHolder의 Context(스프링 시큐리티의 '임시 보안 명부')에 생성한 Authentication 객체 저장
            // 이렇게 등록해두면 이후의 컨트롤러에서 @AuthenticationPrincipal 로 memberId를 바로 꺼내 쓸 수 있습니다.
            SecurityContextHolder.getContext().setAuthentication(authentication);

            log.debug("Security Context에 '{}' 인증 정보를 저장했습니다. (uri: {})", memberId, request.getRequestURI());
        }

        // 4. 다음 검문소(필터)로 요청 넘기기
        // 주의: 토큰이 없거나 유효하지 않아도 여기서 에러를 내지 않고 일단 넘깁니다.
        // 인증정보(도장)가 없는 상태로 넘어가면, 이어지는 인가(Authorization) 필터가 알아서 401(Unauthorized)로 튕겨냅니다.
        filterChain.doFilter(request, response);
    }

    /**
     * HTTP Header 에서 토큰 값만 순수하게 추출하는 헬퍼 메서드
     * 보통 클라이언트는 "Authorization: Bearer eyJhbGci..." 형태로 토큰을 보냅니다.
     */
    private String resolveToken(HttpServletRequest request) {
        // "Authorization" 헤더 값을 가져옵니다.
        String bearerToken = request.getHeader(AuthConstants.AUTHORIZATION_HEADER);

        // 가져온 값이 "Bearer " 로 시작한다면, 앞의 7글자("Bearer ")를 잘라내고 순수 토큰 문자열만 반환합니다.
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(AuthConstants.BEARER_PREFIX)) {
            return bearerToken.substring(AuthConstants.BEARER_PREFIX.length()); // "Bearer " 길이만큼 잘라냄
        }

        return null; // 토큰이 없거나 규격에 맞지 않으면 null 반환
    }
}

5. 핵심 로직

 

1) StringUtils.hasText()

  • null체크, 빈 문자열("") 체크, 그리고 공백 덩어리(" ") 체크를 단 한 번에 방어

2) resolveToken() 과 Bearer 

  • Bearer = "나 지금 JWT 토큰(Bearer 타입) 보낸다!"라고 명시
  • 앞의 7글자("Bearer ")를 잘라내고 순수 토큰 문자열만 반환

3) DB 조회가 없음

  • (Payload) 그 안에 적혀있는 memberId와 role만 꺼내서 바로 인증을 통과 (대용량 트래픽 견디기 좋음)

4) SecurityContextHolder

  • "화이트보드"에 방문증 걸어두기
  • (setAuthentication(authentication)) 이렇게 적어두고 나면, 나중에 컨트롤러에서 @AuthenticationPrincipal이라는 어노테이션을 통해 이 화이트보드에 적힌 정보를 언제든지 빼올 수 있음

5) 에러를 터뜨리지 않고 filterChain.doFilter로 넘기는 이유

  • 로그인 없이도 볼 수 있는 API(예: 커뮤니티 게시판 목록 조회)가 있을 수 있기 때문
  • 인증 도장 필요한 API에서는 프링 시큐리티의 인가 필터(AuthorizationFilter)가 알아서 401(Unauthorized) 에러 발생

SecurityContext

1. 보안 요원과 로비의 대형 화이트보드

  • 방문객 등장: 방문증(JWT Token)을 목에 걸고 건물 1층 로비로 들어옴
  • 보안 요원의 검사 (Filter): (JwtAuthenticationFilter)이 방문증을 꼼꼼히 검사
  • 화이트보드에 기록 (SecurityContextHolder)
  • 건물 내부 직원들의 확인 (Controller / Service): 로비의 화이트보드(SecurityContextHolder) 확인

2. SecurityConfig

  • JwtAuthenticationFilter가 실제로 작동하기 위해 필터 체인(Filter Chain)이라는 겹겹이 쌓인 바리케이드 중 정확한 위치에 끼워 넣어 주어야 함
  • UsernamePasswordAuthenticationFilter: 폼 로그인(아이디/비밀번호를 입력하고 엔터를 치는 방식)을 처리
  • 기본 필터 작동 전에 배치

3. SecurityConfig에 수문장 배치

더보기
더보기
더보기
package com.example.instagramclone.config;

import com.example.instagramclone.security.jwt.JwtAuthenticationFilter;
import com.example.instagramclone.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
            .csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화 (JWT를 사용하므로 불필요)
            .formLogin(form -> form.disable()) // 기본 폼 로그인 비활성화
            .httpBasic(basic -> basic.disable()) // 기본 HTTP Basic 인증 비활성화

            // 핵심 포인트: "우리는 더 이상 세션 사물함을 쓰지 않습니다!"
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 사용하지 않음 (Stateless)
            )

            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()
            )

            // Step 2: "SecurityContext 에 신분증 걸어두기" (Filter 등록)
            // 우리가 직접 만든 JwtAuthenticationFilter 객체를 생성하여 필터 체인에 끼워 넣습니다.
            // Q. 왜 UsernamePasswordAuthenticationFilter '앞(Before)'에 넣나요?
            // A. 스프링 시큐리티의 기본 인증 동작(폼 로그인 시 유저네임/비번 검사)이 일어나기 전에,
            //    우리가 가로챈 JWT 토큰이 유효하다면 "이 사람은 이미 통과!"라고 인증 도장(Authentication)을
            //    미리 쾅 찍어주기 위해서입니다.
            .addFilterBefore(
                new JwtAuthenticationFilter(jwtTokenProvider),
                UsernamePasswordAuthenticationFilter.class
            );

        return http.build();
    }
}

4. 시큐리티 설정 핵심

 

1) STATELESS (무상태성)

session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)

 

2) addFilterBefore : 우리 수문장 새치기

.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
  • UsernamePasswordAuthenticationFilter가 작동하기 '전(Before)'에 우리가 만든 JwtAuthenticationFilter를 끼워 넣음

5. Filter를 @Component로 등록하지 않은 이유

 

1) JwtAuthenticationFilter에 @Component를 붙이면 필터가 두 번 체이닝에 걸림

  • SecurityConfig에서 .addFilterBefore(...)로 스프링 시큐리티 필터 체인에 끼워 넣음
    > Filter 인터페이스를 구현한 녀석이 있으면, "일반 서블릿 필터 체인(전역 필터)"에도 자동으로 등록 
    > 체인에 필터가 2개 걸림(중복 검사는 안 함)

2) 해결책

  • 1: new로 명시적 생성 = 이 필터는 오직 스프링 시큐리티 체인 안에서만 돈다
  • 2: @Component로 주입받되, 일반 필터 등록 막기 = 프링 부트가 일반 서블릿 필터에 얘를 자동으로 등록하지 못하도록 수동으로 막는 설정 빈을 하나 더 만들어야 함

authorizeHttpRequests

1. 열린문, 닫힌 문 구분

 

1) 열린 문

  • 로그인(/api/auth/login), 회원가입(/api/auth/signup), 이메일 중복 검사(/api/auth/check-duplicate)
  • requestMatchers("...").permitAll()

2) 닫힌 문

  • 그 외의 모든 API (게시글 작성, 피드 조회, 좋아요, 댓글 등)
  • anyRequest().authenticated()

2. /** 엔드포인트 묶어서 개방

 

1) 같은 api 내 로그아웃 등 토큰이 필요한 부분도 있기 때문에 에러 발생

 

2) 해결 방법

  • 명시적 선언 (가장 안전함)
auth.requestMatchers("/api/auth/signup", "/api/auth/login").permitAll()
  • URL 컨벤션(설계) 자체를 분리하기
    : 누구나 접근 가능한 API: `/api/public/**` (이것만 permitAll 처리)
    : 인증이 필요한 API: `/api/v1/**` 등

3. SecurityConfig 출입 통제소 설정

더보기
더보기
더보기
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

    http
        // ... (앞부분 csrf, sessionManagement 등 생략) ...

        .authorizeHttpRequests(auth -> auth
		        // ==================== 개발용 허용 ====================
            .requestMatchers(
                   "/", "/assets/**", "/img/**", "/error", "/favicon.ico",
                   "/h2-console/**"
            ).permitAll()

            // 1. [열린 문] 인증이 필요 없는 최소한의 API만 문을 열어둡니다.
            // 인스타그램은 로그인하지 않아도 회원가입과 로그인은 할 수 있어야 하니까요!
            .requestMatchers(HttpMethod.POST,
                    "/api/auth/login",
                    "/api/auth/signup",
            ).permitAll()
            .requestMatchers(HttpMethod.GET, "/api/auth/check-duplicate").permitAll()

            // [실무 꿀팁 복습] 와일드카드("/api/auth/**") 주의보!
            // 앞선 API들 외에 /api/auth/logout 도 같은 폴더(?)에 있지만,
            // 로그아웃은 반드시 "로그인된(인증된)" 사람만 할 수 있어야 합니다.
            // 따라서 로그아웃이나 다른 API들은 아래의 anyRequest().authenticated() 에 걸려서 보안을 유지하게 됩니다.

            // 2. [닫힌 문] 그 외의 모든 API (게시글 작성, 피드 조회, 댓글 달기 등)
            // "반드시 인증된(로그인된)" 사용자만 접근할 수 있도록 철저하게 막아버립니다.
            .anyRequest().authenticated()
        )

        // ... (뒷부분 addFilterBefore 생략) ...

    return http.build();
}

4. 출입 통제 코드

requestMatchers(...).permitAll()
  • 수문장(JwtAuthenticationFilter)이 토큰이 없거나 만료된 것을 발견해도 에러를 내지 않고 일단 통과
anyRequest().authenticated()
  • 명시한 URL들을 제외한 "나머지 모든 요청"에 대한 규칙

5. 401이 아니라 403이 뜨는 이유

  • 토큰 없이 게시글 작성(/api/posts) API를 찔러보면 03 Forbidden(권한 없음) 에러
  • GlobalExceptionHandler는 스프링 MVC 내부(DispatcherServlet 안쪽)에서 발생한 에러만 잡을 수 있음 > 위 토큰 에러는 서블릿 필터(Filter) 단에서 튕겨낸 것
  • 해결: AuthenticationEntryPoint 사용

@AuthenticationPrincipal

1. @AuthenticationPrincipal

// ✨ 프로의 코드: 시큐리티 종속성 없이 깔끔하게 분리된 컨트롤러
@PostMapping("/api/posts")
public ResponseEntity<?> createPost(
    @RequestBody PostRequest dto,
    @AuthenticationPrincipal Long userId // 💉 스프링이 알아서 주사기를 꽂아줌! 끝!
) {
    postService.create(dto, userId);
    return ResponseEntity.ok().build();
}
  • 메서드를 테스트할 때는 createPost(dto, 5L) 처럼 숫자 5만 파라미터로 넘겨줌

2. PostController 리팩토링: 세션 걷어내기

더보기
더보기
더보기
package com.example.instagramclone.controller.rest;

import org.springframework.data.domain.Pageable;
import com.example.instagramclone.domain.post.dto.response.PostCreateResponse;
import com.example.instagramclone.domain.post.dto.response.PostResponse;
import com.example.instagramclone.domain.post.dto.request.PostCreateRequest;
import com.example.instagramclone.service.PostService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.example.instagramclone.domain.common.dto.ApiResponse;
import com.example.instagramclone.domain.common.dto.FeedResponse;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.io.IOException;
import java.util.List;
import com.example.instagramclone.util.PageableUtil;

@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @PostMapping
    public ResponseEntity<ApiResponse<PostCreateResponse>> createPost(
            @RequestPart("feed") PostCreateRequest request,
            @RequestPart(value = "images", required = false) List<MultipartFile> images,

            // Step 4: "@AuthenticationPrincipal 로 우아하게 유저 정보 받기"
            // Q. 방금까지 있던 @SessionAttribute 랑 지저분한 null 체크 로직은 어디 갔나요?
            // A. 우리가 방금 만든 '보안 요원(JwtAuthenticationFilter)'이 정상적인 토큰을 확인하면,
            //    SecurityContextHolder(화이트보드)에 `memberId`를 적어두고 통과시킵니다.
            //    스프링 시큐리티의 @AuthenticationPrincipal 은 그 화이트보드를 확인해서
            //    여기에 `memberId`를 "주사기처럼 쏙!" 꽂아주는 마법의 어노테이션입니다!
            //    덕분에 컨트롤러는 "토큰이 정상인가?" 에 대한 고민을 1도 안 해도 됩니다. 완전 깔끔하죠?
            @AuthenticationPrincipal Long memberId) throws IOException {

        // 필터가 앞에서 다 막아주기 때문에, 컨트롤러는 비즈니스 로직에만 집중!
        Long postId = postService.create(request, images, memberId);

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(ApiResponse.success(PostCreateResponse.from(postId)));
    }

    @GetMapping
    public ResponseEntity<ApiResponse<FeedResponse<PostResponse>>> getFeed(
            @RequestParam(name = "page", defaultValue = "1") int page,
            @RequestParam(name = "size", defaultValue = "5") int size,

            // 피드 조회도 마찬가지로 @AuthenticationPrincipal 을 사용합니다.
            @AuthenticationPrincipal Long memberId) {

        // 파라미터 검증 및 Pageable 생성 (관심사 분리)
        Pageable pageable = PageableUtil.createSafePageableDesc(page, size, "id");

        FeedResponse<PostResponse> response = postService.getFeed(pageable);

        return ResponseEntity.ok(ApiResponse.success(response));
    }
}

3. 컨트롤러 리팩토링 핵심

 

1) 사라진 세션과 null 체크 방어 로직 

  • 컨트롤러에 요청이 도달했다는 것 = 이미 검증이 완벽하게 끝난 안전한 유저

2) 완벽한 관심사의 분리

  • 클라이언트에게 요청(Request)을 받고, 서비스(postService.create)로 데이터를 토스하고, 응답(Response)을 반환

getReferenceById

1. 하수: 무조건 DB부터 찌르고 보기

더보기
더보기
더보기
// 🚨 하수의 코드: 낭비되는 SELECT 쿼리 발생!
@Transactional
public Long createPost(PostCreateRequest request, Long memberId) {
    // 1. 유저 ID로 실제 DB를 찔러서 유저 엔티티를 통째로 가져온다. (SELECT 발생!)
    Member member = memberRepository.findById(memberId)
            .orElseThrow(() -> new CustomException("유저를 찾을 수 없습니다."));

    // 2. 게시글 엔티티를 만들고 연관관계를 맺는다.
    Post post = Post.builder()
            .content(request.getContent())
            .member(member) // 통째로 가져온 유저 객체를 넣음
            .build();

    // 3. 게시글 저장 (INSERT 발생!)
    postRepository.save(post);
    return post.getId();
}
  • 성능 문제
    : 게시글을 DB(post 테이블)에 INSERT 할 때 post 테이블에는 유저의 이름이나 이메일이 들어가지 않음
    : 식별자 번호(member_id FK)만 들어감
    : 멤버의 아이디만 찌르면 되는데 필요없는 정보도 찌름
    : 불필요한 1번의 SELECT 쿼리가 쌓이고 쌓여 엄청난 DB 부하

2. 가짜 객체(Proxy)로 영속성 컨텍스트 속이기

// ✨ 고수의 코드: SELECT 쿼리를 완벽하게 제거한 최적화 마법
@Transactional
public Long createPost(PostCreateRequest request, Long memberId) {
    // 1. DB를 찌르지 않고, ID 값만 가진 '가짜(Proxy) 객체'를 껍데기만 만들어 온다! (SELECT 안 나감!)
    Member proxyMember = memberRepository.getReferenceById(memberId);

    // 2. 게시글 엔티티를 만들고 연관관계를 맺는다.
    Post post = Post.builder()
            .content(request.getContent())
            .member(proxyMember) // 가짜 객체를 넣어도 JPA는 FK(member_id) 값만 쏙 빼서 쓴다!
            .build();

    // 3. 게시글 저장 (INSERT 발생!)
    postRepository.save(post);
    return post.getId();
}
  • Proxy(프록시) 기능, getReferenceById() 메서드
  • DB에 SELECT 쿼리를 날리지 않고, 식별자(ID)만 덜렁 가지고 있는 껍데기 객체를 만들어서 연관관계만 맺어주기

3. 하이버네이트(Hibernate) 로그로 증명

 

1) findById를 썼을 때 (하수)

-- 낭비되는 SELECT 쿼리 등장 ㅠㅠ
Hibernate:
    select m1_0.id, m1_0.email, m1_0.name, m1_0.password
    from member m1_0
    where m1_0.id=?

-- 그제서야 게시글 저장
Hibernate:
    insert into post (content, member_id, id) values (?, ?, ?)

 

2) getReferenceById를 썼을 때 (고수)

-- 오잉? SELECT가 안 나감! 바로 INSERT 시작!
Hibernate:
    insert into post (content, member_id, id) values (?, ?, ?)

 

4. PostService에 최적화 적용

더보기
더보기
더보기
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {

    private final PostRepository postRepository;
    private final PostImageRepository postImageRepository;
    private final MemberService memberService;
    private final FileStore fileStore;

    @Transactional
    public Long create(PostCreateRequest request, List<MultipartFile> images, Long loginMemberId) throws IOException { 

        // Step 5: [실무 핵심] "토큰 안에 다 있는데 굳이 DB 가야 해? (getReferenceById)"
        // Q. memberService.getMemberById(loginMemberId) 를 쓰면 안 되나요?
        // A. 안 될 건 없지만, Post(자식)를 저장할 때 FK인 member_id 번호만 있으면 되는데,
        //    굳이 DB에 진짜 SELECT 쿼리를 날려서 Member(부모) 전체를 다 캐올 필요가 전혀 없습니다! (낭비)
        //
        // 고수의 방법: Hibernate의 프록시(가짜 객체) 기술을 활용하는 getReferenceById()를 씁니다.
        // 이 녀석은 DB를 전혀 찌르지 않고, 속이 텅 빈 가짜 객체(Proxy)에 ID 값 껍데기만 씌워서 가져옵니다.
        // 덕분에 불필요한 SELECT 쿼리 1회를 우아하게 없앨 수 있는 실무 최적화 기법입니다.
        Member writer = memberService.getReferenceById(loginMemberId);

        Post post = Post.builder()
                .content(request.content())
                .writer(writer) // 식별자만 가진 프록시 객체를 쏙!
                .build();

        // ... 이하 코드 동일
}

5. 성능(Stateless) vs 정합성(Stateful)

 

1) 성능 최우선

  • 토큰을 100% 믿고 DB I/O를 없앱니다. 대규모 트래픽을 견뎌야 하는 일반적인 SNS 피드 작성, 조회 등의 도메인에 적합

2) 정합성 최우선

  • 매 요청마다 무조건 DB(혹은 Redis)를 찔러서 이 유저가 현재 '활성(Active)' 상태인지, 정지당하진 않았는지 꼼꼼히 검사
  • 결제, 송금, 혹은 관리자(Admin) 권한과 관련된 매우 민감한 도메인에서 주로 채택하는 방식

실무형 Refresh Token 발급과 쿠키 저장

1. Access Token과 Refresh Token

 

1) Access Token (일일 방문증)

  • 수명: 30분 (매우 짧음)
  • 역할: 실제로 API(게시글 작성, 피드 조회 등)를 호출할 때 헤더에 넣어서 사용하는 진짜 출입증
  • 특징: 탈취당해도 30분 뒤면 썩은 동아줄(?)이 되므로 비교적 안전

2) Refresh Token (임원용 마스터키 교환권)

  • 수명: 1주 ~ 2주 (매우 김)
  • 역할: 오직 "Access Token이 만료되었을 때, 새로운 Access Token을 발급(Reissue)받기 위한 용도"로만 사용
  • 특징: 서버의 DB에도 저장해 두고, 클라이언트가 재발급을 요청할 때 DB에 있는 값과 똑같은지 대조하는 아주 중요한 보안 키

2. 탈취의 위협과 HttpOnly 쿠키

 

1) Refresh Token > HttpOnly 속성을 가진 쿠키에 저장

 

2) HttpOnly 쿠키

  • 자바스크립트로 읽기 X
  • 서버로 HTTP 요청이 날아갈 때 브라우저가 알아서 실어보냄

3) 실무형 토큰 재발급(Reissue) 플로우

  • [로그인 성공 시] : 서버는 30분짜리 `Access Token`을 만들어 응답 Body(JSON)로 주고, 2주짜리 `Refresh Token`은 DB(`RefreshToken` 엔티티)에 저장한 뒤 HttpOnly` 쿠키에 담아서 클라이언트에게 보냄
  • [평소 API 호출 시]: 클라이언트는 `Access Token`만 헤더에 담아서 열심히 게시글 씀
  • [토큰 만료 (401 에러)]: 30분이 지나서 서버가 "토큰 만료됐어!(401)"라고 쫓아냄
  • [재발급(Reissue) 요청]: 재발급 API(`/api/auth/reissue`)를 찌름 / 브라우저는 아까 받아둔 `HttpOnly` 쿠키(Refresh Token)를 자동으로 함께 보냄
  • [새 토큰 발급]: 서버는 쿠키로 들어온 토큰이 DB에 저장된 값과 일치하는지, 수명은 남았는지 검사 / 통과하면 새로운 30분짜리 `Access Token`을 발급

3. 실무형 Refresh Token 발급 및 재발급 아키텍처 구현

 

1) 토큰 보관소: Entity와 Repository 생성

더보기
더보기
더보기
  • 먼저 발급한 Refresh Token을 데이터베이스에 저장해두기 위해 엔티티 생성
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private Long memberId; // 1명의 유저는 1개의 RefreshToken만 가지도록 설계 (다중 기기 로그인은 심화)

    @Column(nullable = false, length = 512)
    private String token;

    // ... (빌더 및 생성자 생략) ...

    // 기존 토큰을 새로운 토큰으로 교체 (더티 체킹 활용)
    public void updateToken(String newToken) {
        this.token = newToken;
    }
}
  • Repository(RefreshTokenRepository)에서는 토큰 문자열로 엔티티를 찾는 findByToken(String token)과, 유저 ID로 찾는 findByMemberId(Long memberId)를 선언
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

    Optional<RefreshToken> findByToken(String token);

    Optional<RefreshToken> findByMemberId(Long memberId);
    
}

2) 핵심 로직: AuthService 의 토큰 저장 및 재발급(RTR)

더보기
더보기
더보기
  • RTR(Refresh Token Rotation) : 재발급 요청이 오면 Access Token만 새로 주는 게 아니라, Refresh Token까지 아예 통째로 새로 발급해서 DB를 덮어써 버림
@Service
public class AuthService {

    // ... 의존성 주입 생략 ...

    @Transactional
    public LoginResponse login(LoginRequest request) {
        // 1. 유저네임/이메일/전화번호 등 아이디 검증 및 비밀번호 일치 확인 (생략)

        // 2. 토큰 발급 (Access & Refresh 둘 다 발급)
        AuthTokens tokens = generateTokens(member);

        // 3. DB에 Refresh Token 저장 (기존에 있으면 Update, 없으면 Insert)
        refreshTokenRepository.findByMemberId(member.getId())
                .ifPresentOrElse(
                        rt -> rt.updateToken(tokens.refreshToken()), // 더티체킹으로 UPDATE
                        () -> refreshTokenRepository.save( // 신규 INSERT
                                RefreshToken.builder()
                                        .memberId(member.getId())
                                        .token(tokens.refreshToken())
                                        .build()
                        )
                );

        return LoginResponse.of(tokens, member);
    }
	
    // 액세스 토큰 재발급 로직
    @Transactional
    public AuthTokens reissue(String refreshToken) {
        // 1. [검증] 전달받은 쿠키 속 토큰이 위조되지 않았는지, 만료되지 않았는지 검사
        if (!jwtTokenProvider.validateToken(refreshToken)) {
            throw new MemberException(MemberErrorCode.UNAUTHORIZED_ACCESS);
        }

        // 2. [조회] DB에 진짜로 이 토큰이 저장되어 있는지 대조! (탈취된 예전 토큰이면 여기서 걸러짐)
        RefreshToken tokenEntity = refreshTokenRepository.findByToken(refreshToken)
                .orElseThrow(() -> new MemberException(MemberErrorCode.UNAUTHORIZED_ACCESS));

        // 3. [발급] 유효성 검사를 통과했으니, Access / Refresh 토큰을 둘 다 '새로' 발급
        Member member = memberRepository.findById(tokenEntity.getMemberId())
                .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
        AuthTokens newTokens = generateTokens(member);

        // 4. [RTR 적용] 한 번 사용한 Refresh Token은 즉시 폐기하고, 새로 발급한 것으로 DB를 덮어씀
        tokenEntity.updateToken(newTokens.refreshToken());

        return newTokens;
    }
}

3) 쿠키 굽기 장인: AuthController

더보기
더보기
더보기
  • Refresh Token을 HttpOnly 쿠키에 담아 내려보냄
  • @CookieValue: request.getCookies()를 루프 돌며 "이름이 refresh_token인 쿠키 어딨지?" 찾을 필요 없이 우아하게 토큰 문자열을 뽑아냄
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    // ... 의존성 주입 생략 ...

    @PostMapping("/login")
    public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestBody @Valid LoginRequest loginRequest, HttpServletResponse response) {
        LoginResponse loginResponse = authService.login(loginRequest);

        // 1. 서버 측 쿠키 유틸(cookieUtils)을 사용해 HttpOnly, Secure 속성이 걸린 철통보안 쿠키를 생성
        Cookie cookie = cookieUtils.createCookie(
                AuthConstants.REFRESH_TOKEN, // 쿠키 이름 ("refresh_token")
                loginResponse.tokens().refreshToken(), // 쿠키 값 (암호화된 토큰)
                jwtTokenProvider.getRefreshTokenValidityInSeconds() // 만료 시간
        );

        // 2. HTTP 응답(Response) 헤더에 쿠키를 살포시 얹어줌
        response.addCookie(cookie);

        return ResponseEntity.ok(ApiResponse.success(loginResponse));
    }

    @PostMapping("/reissue")
    public ResponseEntity<ApiResponse<AuthTokens>> reissue(
            // 🚨 핵심 포인트: 쿠키에서 토큰 꺼내기!
            // 프론트엔드가 바디에 담아 보내지 않아도, 브라우저가 알아서 보낸 쿠키를 스프링이 쏙 뽑아줍니다.
            @CookieValue(value = AuthConstants.REFRESH_TOKEN, required = false) String refreshToken,
            HttpServletResponse response) {

        if (refreshToken == null) {
            throw new MemberException(MemberErrorCode.UNAUTHORIZED_ACCESS);
        }

        // 서비스 로직 (새로운 Access + Refresh 토큰 발급)
        AuthTokens tokens = authService.reissue(refreshToken);

        // 새로 발급된 리프레시 토큰(RTR)으로 브라우저 쿠키를 다시 덮어쓰기!
        Cookie cookie = cookieUtils.createCookie(
                AuthConstants.REFRESH_TOKEN,
                tokens.refreshToken(),
                jwtTokenProvider.getRefreshTokenValidityInSeconds()
        );
        response.addCookie(cookie);

        return ResponseEntity.ok(ApiResponse.success(tokens));
    }
}

4) 출입 통제소(SecurityConfig)에 재발급 창구 열어두기

더보기
더보기
더보기
  • 토큰 만료시 authenticated() 때문에 1층 로비에서 입구컷 당할 수 있음
  • SecurityConfig로 돌아가서, 재발급 API를 permitAll() 목록에 추가
// SecurityConfig.java 핵심 발췌
.authorizeHttpRequests(auth -> auth
    // HTTP POST 요청 중 인증이 필요 없는 열린 문
    .requestMatchers(HttpMethod.POST,
            "/api/auth/login",
            "/api/auth/signup",
            "/api/auth/reissue" //  Refresh Token 재발급 창구 개방!
    ).permitAll()
    // HTTP GET 요청 중 인증이 필요 없는 열린 문
    .requestMatchers(HttpMethod.GET,
            "/api/auth/check-duplicate"
    ).permitAll()

    .anyRequest().authenticated()
)