본문 바로가기
Daily Dev Q&A 정리 템플릿

25.12.31 Spring Security의 인증과 인가에 대해.

by teg0 2025. 12. 31.

인증과 인가

스프링의 인증과 인가에 대해 살펴보기 전에 인증과 인가가 무엇인지 간단하게 살펴보면,

 

이 둘은 비슷해 보이지만 엄연히 다른 단계입니다. "누구냐?""뭘 할 수 있냐?"의 차이이다.

순서는 무조건 인증(로그인) 후에 인가(권한 확인)가 일어난다.

 

인증

  • 정의: 당신이 누구인지 증명하는 과정.
  • 예시: 회사 건물에 들어갈 때 사원증을 태그하는 것, 웹사이트에 아이디/비밀번호를 입력하는 것.
  • 핵심: "나야 유저." (Identity Verification)

 

인가

  • 정의: 인증된 사용자가 특정 자원에 접근할 권한이 있는지 확인하는 과정.
  • 예시: 사원증을 찍고 들어왔어도(인증), '임원실'이나 '서버실'에는 못 들어가는 것(인가 실패). 일반 유저는 공지사항을 '읽기'만 가능하고, 관리자는 '쓰기'도 가능한 것.
  • 핵심: "너 이거 할 자격 있어?" (Access Control)

 

스프링의 전통 Security 구조 - 세션 방식

스프링 시큐리티는 수많은 필터(Filter)들이 체인처럼 엮여서 요청을 가로채고 검사하는 방식이다. (필터 체인)

이 구조를 이해하는 것이 핵심이다.

 

인증(Authentication) 처리 과정

사용자가 로그인을 시도할 때, 내부에서는 주고 받는 과정이 발생한다.

  1. 요청 (Request): 사용자가 아이디/비번을 입력해서 로그인을 요청한다.
  2. AuthenticationFilter: 문지기로써, 요청을 가로채서 아이디/비번을 담은 인증 토큰(UsernamePasswordAuthenticationToken)을 만든다.
  3. AuthenticationManager: 관리자 역할을 수행하며, "이 토큰 진짜인지 확인해 와!"라고 실무자(Provider)에게 시킨다.
  4. AuthenticationProvider: 실무자 역할을 수행하며, 실제 인증 로직을 수행한다.
  5. UserDetailsService: (개발자가 구현한 비즈니스) DB에서 유저 정보를 가져오는 역할 한다고 할 때, findUserByUsername() 메서드를 통해 DB에 있는 유저 정보를 찾는다.
  6. UserDetails: DB에서 가져온 유저 정보를 스프링 시큐리티가 알아들을 수 있는 형태로 변환한 객체(DTO)이다.
  7. 인증 성공: 비밀번호가 일치하면, 최종적으로 SecurityContextHolder라는 세션 저장소에 인증된 유저 정보(Authentication 객체)를 저장한다.

    핵심: SecurityContextHolder에 값이 들어있으면, 스프링은 "아, 이 사람은 로그인된 사람이다"라고 인식한다.

 

인가(Authorization) 처리 과정

로그인이 끝난 후, 사용자가 /admin/payment 같은 민감한 페이지에 접근하려고 할 때 작동한다.

  1. FilterSecurityInterceptor: 가장 마지막에 위치한 필터이다.
  2. 권한 확인: SecurityContextHolder에 저장된 유저 정보를 꺼내서, "이 유저가 ROLE_ADMIN 권한을 가지고 있나?"를 확인한다.
  3. 결과: 권한이 있으면 통과(Pass), 없으면 403 Forbidden 에러(접근 거부)를 뱉는다.

 

코드 레벨에서의 구현 (어떻게 쓰나요?)

스프링 부트 최신 버전(3.x)부터는 람다식을 활용해 설정이 매우 간결해졌다.

 

SecurityFilterChain (설정 파일)

인가 규칙(누가 어디에 갈 수 있는지)을 여기서 정한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/join", "/").permitAll() // 누구나 접근 가능
                .requestMatchers("/admin/**").hasRole("ADMIN")       // ADMIN만 접근 가능
                .anyRequest().authenticated()                        // 나머지는 로그인해야 접근 가능
            )
            .formLogin(login -> login                                // 폼 로그인 사용
                .loginPage("/login")
                .defaultSuccessUrl("/home")
            );

        return http.build();
    }
}

 

UserDetailsService (유저 정보 로딩)

실제 DB와 연동하기 위해 개발자가 가장 많이 건드리는 부분이다.

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override // 필수 구현 메서드
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. DB에서 유저 조회
        User user = userRepository.findByUsername(username);

        if (user == null) {
            throw new UsernameNotFoundException("유저 없음");
        }

        // 2. UserDetails 객체로 변환해서 리턴 (스프링 시큐리티 전용 포맷)
        return new CustomUserDetails(user);
    }
}

 

과거에는 세션 방식을 사용했지만, JWT가 등장하면서 세션으로 서버에 저장하는 방식이 아닌, 사용자에게 모든 정보를 담은 암호화된 표를 주는 방식(토큰)을 사용한다.

 

스프링의 Security 구조 - JWT 방식

JWT

JWT는 길다란 문자열처럼 보이지만, 사실 점(.) 두 개로 구분된 세 부분으로 이루어져 있다. (aaaa.bbbb.cccc)

 

  • 헤더 (Header): "나는 어떤 알고리즘으로 암호화됐어" (예: HS256)라는 정보가 담긴다.
  • 페이로드 (Payload): 실제 데이터(Claim)가 담기는 곳이다.
    • 여기에 유저 ID(sub), 유효기간(exp), 권한(role) 등을 넣을 수 있다.
    • 주의할 점: 이 부분은 암호화된 게 아니라 단순히 인코딩(Base64)된 것이라, 누구나 열어볼 수 있다.
      비밀번호 같은 민감한 정보는 절대 넣으면 안 된다.
  • 서명 (Signature): 가장 중요한 보안 장치이다.
    • 헤더 + 페이로드 + 서버만 아는 비밀키(Secret Key)를 섞어서 만든다.
    • 누군가 페이로드를 조작(예: 일반 유저 $\rightarrow$ 관리자)하면, 서명 값이 완전히 달라져서 서버가 "어? 이거 위조됐네?" 하고 바로 알아챈다.

 

잠깐! 서명이란?

공개키 암호화

 

디지털 서명

 

간단하게 설명하자면, 공개키 암호화에 사용되는 공개 키는 대중에게도 보여도 상관없는 키이다.

이 공개 키와 개인 키는 한 쌍으로 생성됩니다.생성 과정은 두 가지의 소인수(간단한 3, 5가 아닌 자릿수가 높은 소인수)를 사용하여 RSA와 같은 암호 알고리즘을 사용한다. 이 공개 암호 알고리즘의 순서를 반대로 한 것이 디지털 서명이라고 생각하면 된다.

개인키로 암호화하고 공개키로 복호화한다.

 

JWT 흐름

 

  1. 로그인: (React) 유저가 ID/PW를 보냄 -> (Spring) DB 확인 후 일치하면 로그인 성공.
  2. 발급: (Spring) 유저 ID와 권한을 담아 JWT를 생성하고 클라이언트에게 던져준다. (응답 바디나 헤더에 실어서)
  3. 저장: (React) 받은 토큰을 localStorage나 Cookie에 잘 보관한다.
  4. 요청: (React) API를 요청할 때마다 HTTP 헤더에 토큰을 실어 보낸다.
    • 형식: Authorization: Bearer <토큰값>
  5. 검증: (Spring) 요청이 들어올 때마다 필터(Filter)가 토큰을 가로채서 "내 비밀키로 만든 거 맞아?", "유효기간 안 지났어?" 확인한다.

 

스프링 시큐리티 구현 전략 (코드 구조)

세션 방식은 스프링이 알아서 다 해주지만, JWT는 개발자가 직접 두 가지 핵심 클래스를 만들어야 한다.

 

JwtTokenProvider (도구 상자)

토큰을 생성(Create)하고 검증(Validate)하고, 토큰에서 정보를 꺼내는(Parse) 역할을 하는 유틸리티 클래스이다.

@Component
public class JwtTokenProvider {
    
    private final String secretKey = "vverySecretKey..."; // application.yml에서 관리 추천

    // 1. 토큰 생성
    public String createToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); // 유저 ID 저장
        claims.put("roles", roles); // 권한 저장
        Date now = new Date();
        
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + 30 * 60 * 1000L)) // 30분 유효
                .signWith(SignatureAlgorithm.HS256, secretKey) // 암호화
                .compact();
    }

    // 2. 토큰에서 인증 정보 조회 (DB 안 거치고 토큰만 보고 권한 획득!)
    public Authentication getAuthentication(String token) {
        // ... 토큰 뜯어서 UserDetails 객체 만들고 리턴 ...
    }

    // 3. 토큰 유효성 검사
    public boolean validateToken(String token) {
        // ... 날짜 지났는지, 서명 맞는지 확인 ...
    }
}

 

이렇게 jwts로 설정할 수 있지만, 

# application.yml

jwt:
  # 임의의 문자열 (보안을 위해 32바이트 이상 길게 작성하는 것을 추천)
  secret: VverySecretKeyForJwtAuthenticationMustBeLongEnoughToSecure
  
  # 유효 시간 (밀리초 단위: 30분 = 1800000)
  expiration: 1800000

여기서 관리하는 방법이 옳다.

 

// application.yml의 값을 가져옴
@Value("${jwt.secret}")
private String secretKey;

@Value("${jwt.expiration}")
private long validityInMilliseconds;

return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            // 하드코딩했던 30분 -> 설정 파일 값으로 변경
            .setExpiration(new Date(now.getTime() + validityInMilliseconds)) 
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
}

이렇게 하면, 외부에서 키를 가져올 수 있다.

 

JwtAuthenticationFilter (문지기)

스프링 시큐리티의 필터 체인 앞단에 위치하여, 모든 요청을 검사하는 필터이다.
GenericFilterBean이나 OncePerRequestFilter를 상속받아 만든다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) 
            throws IOException, ServletException {
        
        // 1. 헤더에서 토큰 꺼내기
        String token = resolveToken(request);

        // 2. 토큰이 유효한지 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 3. 유효하다면? -> 인증 정보를 만들어서 SecurityContextHolder에 저장!
            Authentication auth = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        // 4. 다음 필터로 넘기기 (통과)
        chain.doFilter(request, response);
    }
}

 

설정 파일에 등록 (SecurityConfig)

마지막으로, 우리가 만든 이 필터를 스프링 시큐리티가 알 수 있도록 설정 파일에 끼워 넣는다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable()) // JWT는 세션을 안 쓰므로 CSRF 보안 불필요
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // "세션 쓰지 마!" (중요)
        
        // ... 권한 설정 부분 ...
        
        // ★ 핵심: UsernamePasswordAuthenticationFilter 앞에 우리 필터를 끼워 넣는다!
        .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), 
                         UsernamePasswordAuthenticationFilter.class);
    
    return http.build();
}

 

요약

 

  • JWT는 헤더, 페이로드, 서명으로 이루어진 암호화된 문자열이다.
  • 핵심은 서버가 기억하지 않고(Stateless), 토큰 자체가 증명서가 된다는 점이다.
  • 구현 시 JwtTokenProvider(생성/검증기)와 JwtAuthenticationFilter(검문소) 두 개를 만들어 필터 체인에 끼워 넣으면 된다.

 

면접 답변식 요약

인증'당신이 누구인가(Who)'를 증명하는 과정이고, 인가'당신이 무엇을 할 수 있는가(What)'를 확인하는 과정입니다.

반드시 인증이 먼저 이루어진 후, 인가가 진행됩니다.

 

스프링 시큐리티는 이 과정을 '필터 체인(Filter Chain)' 구조로 처리합니다.

첫째, 인증 과정입니다. 사용자가 로그인을 시도하면 AuthenticationFilter가 요청을 가로챕니다. 이후 AuthenticationManager와 UserDetailsService를 거쳐 DB에 있는 사용자 정보를 확인하고, 인증에 성공하면 SecurityContextHolder라는 저장소에 사용자 정보인 Authentication 객체를 저장합니다.

 

둘째, 인가 과정입니다. 인증된 사용자가 특정 리소스에 접근하려 할 때, FilterSecurityInterceptor가 작동합니다. 앞서 저장된 SecurityContextHolder에서 사용자 정보를 꺼내 권한(Role)을 확인하고, 해당 리소스에 접근 자격이 있는지 판단하여 접근을 허용하거나 거부합니다.

 

결론적으로 스프링 시큐리티는 이 일련의 보안 로직을 서블릿 필터 단계에서 처리함으로써, 개발자가 비즈니스 로직에만 집중할 수 있도록 돕는 프레임워크입니다.