14-2. 세션 기반 인증 (실습)

박은서's avatar
Feb 25, 2026
14-2. 세션 기반 인증 (실습)

1. 실습

1) config 폴더

① SecurityConfig.java

package com.metacoding.springv2._core.config; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import com.metacoding.springv2._core.filter.JwtAuthorizationFilter; @Configuration public class SecurityConfig { @Bean public BCryptPasswordEncoder encode() { return new BCryptPasswordEncoder(); } // 시큐리티 필터 등록 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 인증/권한 주소 커스터마이징 http.authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/**").authenticated() .anyRequest().permitAll() ); // UsernamePasswordAuthenticationFilter 비활성화 // 폼 로그인 비활성화 ( POST : x-www-form urlencoded : username, password ) http.formLogin(f -> f.disable()); // 베이직 인증 비활성화 (request 할 때마다 username, password를 요구) http.httpBasic(b -> b.disable()); // input에 csrf 토큰 받는 것을 비활성화 http.csrf(c -> c.disable()); // 인증 필터를 변경 (before/after 상관 없음!) http.addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }

2) filter 폴더

① JwtAuthorizationFilter.java

package com.metacoding.springv2._core.filter; import java.io.IOException; import org.springframework.web.filter.OncePerRequestFilter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; // 인가 필터 (로그인하는 필터 아님! -> 인가필터) public class JwtAuthorizationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { filterChain.doFilter(request, response); } }

3) auth 폴더

① AuthController.java

package com.metacoding.springv2.auth; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @RestController public class AuthController { private final AuthService authService; private final HttpSession session; @PostMapping("/login") public String login(@RequestBody AuthRequest.LoginDTO reqDTO){ // @RequestBody -> JSON 데이터 파싱 authService.로그인(reqDTO); session.setAttribute( HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext() ); return "login까지 오나?"; } @GetMapping("/health") public String healthCheck() { return "health ok"; } }

② AuthRequest.java

package com.metacoding.springv2.auth; import com.metacoding.springv2.user.User; import jakarta.validation.constraints.*; import lombok.Data; public class AuthRequest { @Data public static class JoinDTO { private String username; private String password; private String email; } @Data public static class LoginDTO { private String username; private String password; } }

③ AuthService.java

package com.metacoding.springv2.auth; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import com.metacoding.springv2._core.handler.ex.Exception401; import com.metacoding.springv2.auth.AuthRequest.LoginDTO; import com.metacoding.springv2.user.User; import com.metacoding.springv2.user.UserRepository; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Service public class AuthService { private final UserRepository userRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; public void 로그인(LoginDTO reqDTO) { // 1. UserRepository에서 username 확인 User findUser = userRepository.findByUsername(reqDTO.getUsername()) .orElseThrow(() -> new Exception401("유저네임을 찾을 수 없습니다.")); // 2. password를 hash해서 비교 검증 boolean isSamePassword = bCryptPasswordEncoder.matches(reqDTO.getPassword(), findUser.getPassword()); if (!isSamePassword) throw new Exception401("비밀번호가 틀렸습니다."); // 3. Authentication 객체 만들기 Authentication authentication = new UsernamePasswordAuthenticationToken(findUser, null, findUser.getAuthorities()); // 4. SecurityContextHolder에 저장 SecurityContextHolder.getContext().setAuthentication(authentication); } }

4) user 폴더

① User.java

package com.metacoding.springv2.user; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Collection; import org.hibernate.annotations.CreationTimestamp; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @NoArgsConstructor @Getter @Entity @Table(name = "user_tb") public class User implements UserDetails { @GeneratedValue(strategy = GenerationType.IDENTITY) @Id private Integer id; @Column(unique = true, length = 20, nullable = false) private String username; @Column(length = 60, nullable = false) private String password; @Column(length = 30, nullable = false) private String email; private String roles; // 디폴트값은 USER @CreationTimestamp private Timestamp createdAt; @Builder public User(Integer id, String username, String password, String email, String roles, Timestamp createdAt) { this.id = id; this.username = username; this.password = password; this.email = email; this.roles = roles; this.createdAt = createdAt; } public void update(String email, String password) { this.email = email; this.password = password; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> as = new ArrayList<>(); String[] roleList = roles.split(","); for (String role : roleList) { as.add(() -> role); } return as; } }

② UserRepository.java

package com.metacoding.springv2.user; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; // findById, findAll, save, deleteById, count, paging public interface UserRepository extends JpaRepository<User, Integer> { @Query("select u from User u where u.username = :username") public Optional<User> findByUsername(@Param("username") String username); }

2. 코드 이해하기

1️⃣ 전체 구조

  • /login에서 아이디/비번 검증 → Authentication 생성 → SecurityContextHolder에 넣음 → 그 SecurityContextHttpSession에 저장
  • 이후 /api/ 요청이 들어오면 Spring Security가 세션에서 SecurityContext를 복원해서 “인증됨” 상태로 처리

2️⃣ 파일별 역할 정리

1) SecurityConfig

Spring Security의 “보안 규칙 + 필터 체인” 설정
  • BCryptPasswordEncoder Bean 등록
    • 회원 비밀번호(해시) 비교할 때 matches()로 검증하기 위한 인코더
  • 인가(접근 제어) 규칙
    • .requestMatchers("/api/**").authenticated() .anyRequest().permitAll()
    • /api/**는 “로그인(인증)된 사용자만 접근 가능”
    • 나머지는 모두 허용 (예: /login, /health)
  • 기본 인증 방식들 비활성화
    • formLogin.disable() : 스프링 기본 로그인 폼/처리 로직 안 씀
    • httpBasic.disable() : 요청마다 브라우저 팝업 뜨는 basic auth 안 씀
    • csrf.disable() : (실습 편의상) CSRF 토큰 요구 안 함
  • 커스텀 필터 등록
    • http.addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
    • 지금은 이름이 JwtAuthorizationFilter이지만 실제로는 **“커스텀 인가 필터 자리”**만 잡아둔 상태
    • 현재 구현은 그냥 filterChain.doFilter()만 해서 아무 것도 안 함

2) JwtAuthorizationFilter

OncePerRequestFilter 기반의 “요청당 1회 실행되는 필터”
  • 현재 코드
    • filterChain.doFilter(request, response);
    • “요청을 가로채서 검사/인증/인가 처리” 같은 걸 추가로 하지 않고 그냥 통과만 시킴
  • 보통 여기서 하는 일(확장 포인트)
    • JWT면 Authorization: Bearer ... 토큰 파싱
    • 세션이면 “세션이 없을 때 처리/로그/예외 변환” 등 커스텀 가능

3) AuthController

로그인 요청을 받아서 “세션에 SecurityContext를 저장”하는 컨트롤러
  • POST /login
      1. authService.로그인(reqDTO) 호출해서 인증 성공 시 SecurityContextHolder에 Authentication 저장
      1. 그 다음 세션에 SecurityContext를 직접 저장
        1. session.setAttribute( HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext() );
          • 핵심: SPRING_SECURITY_CONTEXT_KEY라는 정해진 키로 저장해야 Spring Security가 다음 요청에서 읽어옴
          • 즉 “로그인 성공 상태를 세션에 영속화”하는 작업
  • GET /health
    • 보안과 무관한 헬스체크 (항상 permitAll)

4) AuthRequest

요청 DTO 모음
  • LoginDTO { username, password }
  • JoinDTO는 있지만 현재 흐름에서는 로그인에만 사용

5) AuthService

실제 로그인(인증) 로직
로그인(LoginDTO) 내부 단계
  1. username으로 유저 조회
      • 없으면 Exception401("유저네임을 찾을 수 없습니다.")
  1. 비밀번호 검증
      • BCryptPasswordEncoder.matches(평문, 해시)로 비교
      • 실패하면 Exception401("비밀번호가 틀렸습니다.")
  1. Authentication 생성
    1. Authentication authentication = new UsernamePasswordAuthenticationToken(findUser, null, findUser.getAuthorities());
      • principal: findUser (UserDetails 구현체)
      • credentials: null (이미 검증 끝났으니 보통 null)
      • authorities: 권한 목록
  1. SecurityContextHolder에 저장
    1. SecurityContextHolder.getContext().setAuthentication(authentication);
      • 여기까지는 “현재 요청 스레드 안에서만 인증된 상태”
      • 다음 요청까지 유지하려면 “세션 저장”이 필요하고, 그건 Controller에서 수행 중

6) User

엔티티 + Spring Security의 UserDetails 구현체
  • DB 테이블: user_tb
  • UserDetails 구현의 핵심은 getAuthorities()
    • String[] roleList = roles.split(","); as.add(() -> role);
    • roles가 "USER,ADMIN" 형태면 권한 2개 생성
    • Spring Security에서는 보통 "ROLE_USER" 같은 컨벤션도 쓰는데, 지금은 문자열 그대로 권한으로 들어감

7) UserRepository

username으로 유저 조회용 JPA Repository
  • findByUsername로 로그인 시 조회

3️⃣ 실제 동작 순서 (요청 흐름)

A. POST /login 요청이 들어오면

  1. Spring Security FilterChain 진입
      • /loginpermitAll이라 “로그인 안 해도 접근 가능”
  1. (커스텀) JwtAuthorizationFilter 실행 → 그냥 통과
  1. AuthController.login() 실행
  1. AuthService.로그인() 실행
      • 유저 조회 → 비밀번호 검증 → Authentication 생성 → SecurityContextHolder 저장
  1. 컨트롤러가 세션에 SecurityContext 저장
      • SPRING_SECURITY_CONTEXT_KEY로 저장
  1. 응답 반환
    1. → 이 시점에 브라우저/클라이언트는 세션 쿠키(JSESSIONID) 를 갖게 되고, 이후 요청에 그 쿠키를 같이 보냄

B. 이후 GET /api/** 요청이 들어오면

  1. 요청이 들어오면 Spring Security의 기본 필터들 중에서
      • 세션에 저장된 SPRING_SECURITY_CONTEXT를 읽어서
      • SecurityContextHolder에 다시 복원하는 과정이 일어남(표준 동작)
  1. SecurityConfig의 규칙 검사
      • /api/**.authenticated() 필요
  1. 세션에서 복원된 Authentication이 있으면 → “인증됨” → 컨트롤러까지 통과
  1. 없으면 → “미인증” → 401/리다이렉트 등이 발생 (설정에 따라 다름. 지금은 폼로그인/베이직 꺼서 보통 401 계열로 떨어지는 흐름)

4️⃣ 이 코드의 핵심 포인트 3개

  1. SecurityContextHolder 저장만으로는 “다음 요청”까지 인증이 유지되지 않는다
      • 그래서 HttpSessionSPRING_SECURITY_CONTEXT_KEY로 넣는 게 핵심
  1. JwtAuthorizationFilter는 현재 “자리만 있음”
      • 이름은 JWT지만 실제 구현이 없어서 인증/인가에 영향 없음
      • 나중에 토큰 기반으로 바꾸려면 여기에서 토큰 검증 후 SecurityContextHolder 세팅하는 식으로 확장
  1. /api/**만 인증 요구
      • /loginpermitAll이라 로그인 엔드포인트 접근이 가능하고, 그 내부에서 세션 인증을 세팅
Share article