카테고리 없음

[유레카 / 백엔드] TIL - 17 (Spring Security/JWT)

coding-quokka101 2026. 1. 6. 23:55

스프링 부트 3 핵심 가이드 ❘ 장정우 - 교보문고

 

📌 들어가며



안녕하세요! 2026년이 되고 첫 블로그를 작성하는데요! 다들 새해 복 많이 받으세요ㅎㅎ
오늘은 백엔드 개발에서 정말 중요한 보안에 대해 배우는 시간이었습니다!
지금까지 만들었던 API들은 누구나 접근할 수 있는 상태였는데, 오늘 **Spring Security와 JWT(JSON Web Token)**을 배우면서 실제 서비스처럼 인증과 인가를 구현할 수 있게 됐습니다. 생각보다 어려웠지만, 그만큼 배운 게 많았던 하루였어요!


🎯 오늘의 학습 목표

오늘 주요 목표는 다음과 같았습니다:

  1. **인증(Authentication)**과 **인가(Authorization)**의 차이 이해하기
  2. Spring Security의 기본 구조와 동작 원리 파악
  3. JWT 토큰 생성 및 검증 로직 구현
  4. 로그인/회원가입 API 구현
  5. 필터 체인을 활용한 토큰 검증 자동화


📚 오늘 배운 것 (학습)

1. 인증 vs 인가, 제대로 이해하기

처음에는 인증과 인가가 같은 거라고 생각했는데,  공부하 명확히 구분할 수 있게 됐습니다:

인증(Authentication): "너 누구야?" - 사용자가 누구인지 확인하는 과정

  • 예: 로그인할 때 아이디/비밀번호 확인
  • 결과: "당신은 홍길동입니다"

인가(Authorization): "너 이거 할 수 있어?" - 사용자가 특정 자원에 접근할 권한이 있는지 확인

  • 예: 관리자 페이지 접근 시 ADMIN 권한 확인
  • 결과: "당신은 이 페이지를 볼 권한이 있습니다"

실생활로 비유하자면:

  • 인증: 회사 출입할 때 사원증으로 신원 확인
  • 인가: 특정 회의실에 들어갈 수 있는 등급인지 확인

이 개념을 확실히 이해하니까 뒤에 나오는 내용들이 훨씬 쉽게 이해됐습니다!

스프링 시큐리티 필터 : 네이버 블로그

2. Spring Security의 필터 체인

Spring Security의 가장 핵심은 **필터 체인(Filter Chain)**이라는 걸 배웠습니다. 모든 HTTP 요청은 여러 개의 필터를 거치면서 보안 검사를 받게 되는데요:

요청 → UsernamePasswordAuthenticationFilter 
     → JwtAuthenticationFilter (우리가 만들 필터)
     → FilterSecurityInterceptor 
     → Controller

처음에는 "왜 이렇게 복잡하게 만들었을까?"라고 생각했는데, 각 필터가 각자의 역할에만 집중하니까 모듈화가 잘 되어있더라고요. 예를 들어:

  • CorsFilter: CORS 정책 검사
  • CsrfFilter: CSRF 공격 방어
  • UsernamePasswordAuthenticationFilter: 아이디/비밀번호 인증
  • JwtAuthenticationFilter: JWT 토큰 검증 (우리가 직접 만듦)

오늘은 직접 JwtAuthenticationFilter를 만들어서 체인에 추가하는 실습을 했습니다!

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain filterChain) {
        String token = extractToken(request);
        
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication auth = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        
        filterChain.doFilter(request, response);
    }
}

 


3. JWT 토큰의 구조와 동작 원리

JWT는 Header.Payload.Signature 세 부분으로 구성된다는 걸 배웠습니다:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

각 부분을 Base64로 디코딩하면:

Header: 어떤 알고리즘으로 암호화했는지

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload: 실제 데이터 (사용자 정보, 권한 등)

{
  "userId": 1,
  "username": "coding-quokka",
  "role": "USER",
  "exp": 1735689600
}

Signature: 위변조 방지를 위한 서명

가장 중요한 포인트는 Payload는 암호화되지 않는다는 점이었습니다! 그래서 비밀번호 같은 민감한 정보는 절대 토큰에 넣으면 안 된다고 강조하셨어요. 토큰은 단지 위변조를 방지할 뿐이지, 내용을 숨기지는 않는다는 걸 확실히 이해했습니다.

https://dbfl720.tistory.com/851


4. 비밀번호 암호화 - BCrypt

사용자의 비밀번호를 절대 평문으로 저장하면 안 된다는 걸 배웠습니다. Spring Security는 BCryptPasswordEncoder를 제공하는데, 이걸 사용하면 같은 비밀번호라도 매번 다른 해시값이 생성됩니다:

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

// 회원가입 시
String rawPassword = "password123";
String encodedPassword = passwordEncoder.encode(rawPassword);
// 결과: $2a$10$N9qo8uLOickgx2ZMRZoMye...

// 같은 비밀번호를 다시 인코딩하면
String encodedPassword2 = passwordEncoder.encode(rawPassword);
// 결과: $2a$10$Q7jx3LMvZBKKkEwLqP9QJO... (다른 값!)

이게 가능한 이유는 **솔트(Salt)**를 랜덤으로 생성해서 추가하기 때문이라고 합니다. 같은 비밀번호라도 매번 다른 해시가 나오니까 레인보우 테이블 공격도 방어할 수 있다고 하더라고요!

5. 로그인 플로우 완전 정복

오늘 구현한 로그인 플로우를 처음부터 끝까지 정리하면:

  1. 프론트엔드: POST /api/auth/login으로 아이디/비밀번호 전송
  2. Controller: 요청 받아서 AuthService로 전달
  3. AuthService:
    • DB에서 사용자 조회
    • 비밀번호 검증 (passwordEncoder.matches())
    • 검증 성공 시 JWT 토큰 생성
  4. 프론트엔드: 받은 토큰을 localStorage에 저장
  5. 이후 요청: 모든 API 요청에 Authorization: Bearer {token} 헤더 추가
  6. JwtAuthenticationFilter: 토큰 검증 후 인증 정보를 SecurityContext에 저장
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
    // 1. 사용자 인증
    User user = userRepository.findByUsername(request.getUsername())
            .orElseThrow(() -> new BadCredentialsException("Invalid credentials"));
    
    // 2. 비밀번호 확인
    if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
        throw new BadCredentialsException("Invalid credentials");
    }
    
    // 3. JWT 토큰 생성
    String token = jwtTokenProvider.createToken(user.getUsername(), user.getRole());
    
    return ResponseEntity.ok(new LoginResponse(token));
}

 


🌟 오늘의 나는 무엇을 잘했는지 (성취)

1. 로그인/회원가입 API를 완성했다!

오늘 가장 뿌듯했던 순간은 로그인부터 인증이 필요한 API 호출까지 전체 플로우를 직접 구현하고 테스트한 것입니다.

Postman으로 다음 시나리오를 모두 테스트해봤어요:

  1. ✅ 회원가입 → 성공
  2. ✅ 로그인 → JWT 토큰 받음
  3. ✅ 토큰 없이 게시글 조회 → 401 Unauthorized
  4. ✅ 토큰과 함께 게시글 조회 → 200 OK
  5. ✅ 만료된 토큰으로 요청 → 401 Unauthorized
  6. ✅ 잘못된 토큰으로 요청 → 401 Unauthorized

모든 케이스가 예상대로 동작하는 걸 보고 정말 감동받았습니다! 특히 제가 만든 필터가 자동으로 토큰을 검증하는 걸 보고 "와, 내가 Spring Security의 일부를 만든 거구나"라는 생각이 들었어요.

2. 보안 취약점을 스스로 발견하고 수정했다

처음 구현했을 때 이렇게 코드를 작성했었는데:

// 문제가 있는 코드
@GetMapping("/user/{username}")
public User getUser(@PathVariable String username) {
    return userRepository.findByUsername(username);
}

테스트하다가 깨달은 게, 이러면 비밀번호까지 그대로 노출된다는 거였어요! 그래서 다음과 같이 수정했습니다:

// 개선된 코드
@GetMapping("/user/{username}")
public UserDto getUser(@PathVariable String username) {
    User user = userRepository.findByUsername(username);
    return UserDto.builder()
            .id(user.getId())
            .username(user.getUsername())
            .email(user.getEmail())
            // 비밀번호는 제외!
            .build();
}

https://www.entrust.com/ko/resources/learn/authentication-vs-authorization

 

 

3. 동료 수강생의 에러를 해결해줬다

페어 프로그래밍 시간에 옆 테이블 수강생분이 계속 IllegalArgumentException: JWT String argument cannot be null or empty라는 에러로 고생하고 계셨어요. 제가 가서 코드를 확인해보니 헤더에서 토큰을 추출할 때 문제가 있었습니다:

// 문제 코드
String token = request.getHeader("Authorization");

// 수정 코드
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
    token = bearerToken.substring(7); // "Bearer " 제거
}

Bearer 접두사를 제거하지 않아서 생긴 문제였어요. 도와드리고 감사하다는 말을 들으니 뿌듯했습니다. 설명하면서 제 이해도도 더 높아진 것 같아요!


🤔 어떤 문제를 겪었고, 어떻게 해결할지 (개선)

1. Refresh Token이 없어서 불편했다

오늘 구현한 방식은 Access Token만 사용하는 방식이었습니다. 문제는 토큰이 만료되면 다시 로그인해야 한다는 거였어요. 실습하다가 30분마다 로그인하는 게 너무 번거로웠습니다.

// 현재 구현 - Access Token만 발급
public String createToken(String username, String role) {
    Claims claims = Jwts.claims().setSubject(username);
    claims.put("role", role);
    Date now = new Date();
    Date validity = new Date(now.getTime() + 1800000); // 30분
    
    return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(validity)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
}

개선 계획: 내일 아침 일찍 와서 Refresh Token 구조를 추가로 구현할 예정입니다:

  • Access Token: 15분 (짧게)
  • Refresh Token: 7일 (길게)
  • Refresh Token은 DB 또는 Redis에 저장
  • /api/auth/refresh 엔드포인트로 토큰 갱신

그리 Refresh Token을 Redis에 저장하는 방법을 배울 수 있을 것 같습니다!

2. 예외 처리가 통일되지 않았다

인증 실패 시 예외 처리가 제각각이었습니다:

// Service에서는
throw new BadCredentialsException("Invalid credentials");

// Filter에서는
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Unauthorized");

// Controller에서는
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();

이렇게 하니까 프론트엔드에서 에러를 처리할 때 일관성이 없어서 혼란스럽더라고요.

해결 방법: @ControllerAdvice를 활용한 전역 예외 처리를 구현할 예정입니다:

@ControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<ErrorResponse> handleBadCredentials(BadCredentialsException e) {
        return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body(new ErrorResponse("AUTH_FAILED", e.getMessage()));
    }
}

이렇게 하면 어디서 예외가 발생하든 동일한 형식으로 에러를 반환할 수 있을 것 같습니다.

3. 토큰 만료 시간 관리가 하드코딩되어 있었다

코드 곳곳에 만료 시간이 하드코딩되어 있었습니다:

Date validity = new Date(now.getTime() + 1800000); // 30분이 뭔지 알기 어려움

개선 계획: application.yml에서 설정값으로 관리하도록 변경할 예정입니다:

jwt:
  secret-key: ${JWT_SECRET_KEY}
  access-token-validity: 1800000  # 30분
  refresh-token-validity: 604800000  # 7일
@Value("${jwt.access-token-validity}")
private long accessTokenValidity;

이렇게 하면 환경별로 다른 설정을 쉽게 적용할 수 있겠죠? (개발 환경에서는 길게, 프로덕션에서는 짧게)


👍 좋았던 점 & 😥 아쉬웠던 점 (피드백)

좋았던 점

1. 보안의 중요성을 체감했다 단순히 "보안이 중요하다"는 말로 듣는 것과 직접 구현해보는 건 완전히 다르더라고요. 특히 비밀번호를 평문으로 저장했을 때와 암호화해서 저장했을 때의 차이를 직접 보니까, "아, 이래서 보안이 중요하구나" 하고 뼈저리게 느꼈습니다.

2. 실무에서 사용하는 방식을 배웠다 현재 실무에서 가장 많이 사용하는 JWT 인증 방식을 배워 좋았습니다. 이론만 배우는 게 아니라 "실제로 많이 쓰는 방법"을 배우니까 동기부여가 더 되더라고요.

3. 디버깅 능력이 향상됐다 오늘은 유독 에러가 많이 발생했는데, 그 과정에서 Spring Security의 내부 동작을 더 깊이 이해하게 됐습니다. 에러 메시지를 읽고, 스택 트레이스를 따라가고, 공식 문서를 찾아보는 과정이 정말 값진 경험이었습니다.

아쉬웠던 점

1. OAuth2는 다루지 못했다 오늘은 기본적인 JWT 인증만 배웠는데, "구글 로그인", "카카오 로그인" 같은 소셜 로그인은 다루지 못해서 아쉬웠습니다. 실제 서비스에서는 소셜 로그인이 필수인데 말이죠. 다음 주에 배운다고 하니 기대됩니다!

2. 프론트엔드 연동은 못 해봤다 Postman으로만 테스트하다 보니 실제 웹 페이지에서 어떻게 동작하는지 감이 잘 안 왔습니다. 로그인 폼을 만들고, 토큰을 localStorage에 저장하고, 자동으로 헤더에 추가하는 부분까지 직접 해보고 싶었는데 시간이 부족했어요.

3. 보안 테스트가 부족했다 실제로 해킹을 시도해보는 침투 테스트를 해보고 싶었습니다. SQL Injection, XSS 같은 공격을 직접 시도해보고 Spring Security가 어떻게 막아주는지 확인하면 더 좋았을 것 같아요.

 

2025년 OWASP Top 10 미리보기 ❘ Insight


💡 나만의 팁 & 복습 방법

1. JWT 디버깅 사이트 활용하기

토큰이 제대로 생성됐는지 확인할 때 jwt.io 사이트가 정말 유용합니다! 토큰을 붙여넣으면 Header, Payload, Signature를 분리해서 보여줍니다.

https://jwt.io

저는 개발 중에 이 사이트를 항상 켜두고, 생성된 토큰의 내용을 실시간으로 확인하면서 작업했어요. 특히 **만료 시간(exp)**이 제대로 설정됐는지 확인하는 데 아주 좋습니다!

2. Postman Environment로 토큰 자동 관리

매번 토큰을 복사-붙여넣기 하는 게 귀찮아서, Postman의 Environment 기능을 활용했습니다:

로그인 API의 Tests 탭에 추가:

var jsonData = pm.response.json();
pm.environment.set("jwt_token", jsonData.token);

다른 API의 Authorization 탭:

Type: Bearer Token
Token: {{jwt_token}}

이렇게 하면 로그인 한 번만 하면 모든 API에서 자동으로 토큰이 적용됩니다!

3. 보안 체크리스트 만들기

API를 만들 때마다 확인하는 보안 체크리스트를 노션에 만들었습니다:

  • [ ] 비밀번호는 BCrypt로 암호화했는가?
  • [ ] JWT에 민감한 정보(비밀번호 등)가 포함되지 않았는가?
  • [ ] 토큰 만료 시간이 적절한가?
  • [ ] CORS 설정이 올바른가?
  • [ ] SQL Injection 방어가 되어있는가? (JPA 사용하면 자동)
  • [ ] XSS 공격 방어가 되어있는가?

매번 새로운 API를 만들 때마다 이 체크리스트를 확인하는 습관을 들이려고 합니다.

4. 시큐리티 설정은 별도 파일로 관리

Spring Security 설정이 점점 복잡해지니까, SecurityConfig 파일을 체계적으로 관리하는 게 중요하더라고요:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    // 1. 비밀번호 인코더
    @Bean
    public PasswordEncoder passwordEncoder() { ... }
    
    // 2. 필터 체인 설정
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) { ... }
    
    // 3. CORS 설정
    @Bean
    public CorsConfigurationSource corsConfigurationSource() { ... }
}

주석을 달아서 각 부분이 무슨 역할인지 명확히 해두니까 나중에 수정할 때 훨씬 편하더라고요!

5. 에러 시나리오 모두 테스트하기

정상 케이스만 테스트하지 말고, 에러 시나리오도 꼭 테스트해야 합니다:

정상 케이스:

  • 올바른 로그인 정보로 로그인
  • 유효한 토큰으로 API 호출

에러 케이스:

  • 잘못된 비밀번호로 로그인
  • 존재하지 않는 사용자로 로그인
  • 토큰 없이 보호된 API 호출
  • 만료된 토큰으로 API 호출
  • 변조된 토큰으로 API 호출
  • 토큰 형식이 잘못된 경우

이렇게 모든 에러 케이스를 테스트해보니까 놓쳤던 예외 처리를 많이 발견할 수 있었습니다.


🎓 내일의 학습 계획

내일은 다음 주제들을 학습할 예정입니다:

1. Refresh Token 구현
2. Role 기반 권한 관리 (USER, ADMIN 구분)
3.Method Security (@PreAuthorize, @Secured)
4.CORS 심화 (실제 프론트엔드와 연동)


특히 Role 기반 권한 관리가 궁금합니다. 예를 들어 게시글 삭제는 작성자 본인이나 관리자만 가능하도록 하는 로직을 어떻게 구현하는지 배울 수 있을 것 같아요!


마무리하며

오늘은 보안의 세계에 첫발을 들여놓은 날이었습니다. 솔직히 Spring Security는 생각보다 훨씬 복잡하고 어려웠어요. 필터 체인, SecurityContext, Authentication 객체... 처음 들어보는 개념들이 쏟아지면서 머리가 복잡했습니다.

하지만 하나씩 차근차근 이해하고 직접 구현해보니까, "아, 이래서 이렇게 설계된 거구나" 하고 납득이 됐습니다. 특히 제가 만든 코드로 실제로 안전한 로그인 시스템이 동작하는 걸 보니 정말 뿌듯했어요.

앞으로 더 복잡한 인증/인가 시스템을 구현하게 되더라도, 오늘 배운 기본기가 튼튼한 토대가 되어줄 것 같습니다. 보안은 끝이 없다고 하니, 계속 공부하면서 성장하는 개발자가 되겠습니다! 💪