개발 공부/백엔드

제미나이와 게시판 만들기: (6) Spring Security와 JWT로 인증/인가 구현하기

baby-t 2025. 10. 8. 11:55

지난 포스팅에서는 Controller 계층을 구현하여 게시판의 모든 핵심 CRUD API를 완성했습니다. 하지만 지금 우리의 API 서버는 아무런 보안 장치가 없어 누구나 게시글을 쓰고 다른 사람의 글을 지울 수 있는, 매우 취약한 상태입니다.

이번 포스팅에서는 현대적인 웹 서비스의 표준 기술인 Spring Security와 **JWT(JSON Web Token)**를 도입하여, 강력한 인증(Authentication) 및 인가(Authorization) 시스템을 구축하는 전 과정을 상세히 다뤄보겠습니다.

### 1. 시작하기 전에: 인증 vs 인가

  • 인증 (Authentication): 사용자가 누구인지 확인하는 과정입니다. (e.g., 아이디와 비밀번호로 로그인하기)
  • 인가 (Authorization): 인증된 사용자가 특정 작업을 수행할 권한이 있는지 확인하는 과정입니다. (e.g., 로그인한 사용자만 글쓰기 가능, 글 작성자 본인만 삭제 가능)

### 2. 기본 설정: 의존성 추가와 비밀번호 암호화

가장 먼저, 필요한 라이브러리를 설치하고 비밀번호를 안전하게 저장할 준비를 합니다.

#### 의존성 추가 (build.gradle)

Gradle
 
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

// JWT 라이브러리 (jjwt)
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

#### PasswordEncoder 빈 등록

실제 서비스에서는 절대 비밀번호를 그대로 DB에 저장하면 안 됩니다. BCrypt라는 강력한 해싱 알고리즘을 사용해 암호화해야 합니다. SpringConfig에 PasswordEncoder를 빈으로 등록합니다.

Java
// SpringConfig.java
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

 

이제 UserService의 join 메서드에서 이 PasswordEncoder를 주입받아, 회원을 저장하기 전에 비밀번호를 암호화합니다.

Java
// UserService.java - join() 메서드 내부
String encodedPassword = passwordEncoder.encode(user.getPassword());
user.setPassword(encodedPassword);
userRepository.save(user);

### 3. JWT 발급: 로그인 API 구현

사용자가 로그인을 성공하면, 서버는 "당신은 인증된 사용자입니다"라는 증표로 JWT 토큰을 발급해주어야 합니다.

JwtUtil 클래스 생성

JWT를 생성하고, 검증하고, 정보를 추출하는 역할을 담당할 JwtUtil 클래스를 만듭니다. 이 클래스는 application.properties에 설정된 비밀 키(jwt.secret)와 만료 시간(jwt.expiration_time)을 사용합니다. 

@Component
public class JwtUtil {

    private final SecretKey secretKey;
    private final long expirationTime;

    // application.properties에서 시크릿 키와 만료 시간을 주입받음
    public JwtUtil(@Value("${jwt.secret}") String secret,
                   @Value("${jwt.expiration_time}") long expirationTime) {
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expirationTime = expirationTime;
    }

    // 1. JWT 토큰 생성
    public String createToken(String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expirationTime);

        return Jwts.builder()
                .subject(username) // 토큰의 주체 (사용자 이름)
                .issuedAt(now) // 발급 시간
                .expiration(expiryDate) // 만료 시간
                .signWith(secretKey) // 서명에 사용할 키
                .compact();
    }

    // 2. JWT 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
            return true;
        } catch (Exception e) {
            // 토큰이 유효하지 않은 경우 (만료, 서명 불일치 등)
            return false;
        }
    }

    // 3. JWT 토큰에서 사용자 이름 추출
    public String getUsernameFromToken(String token) {
        Claims claims = Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
        return claims.getSubject();
    }
}

UserService 로그인 로직 수정

기존 login 메서드를, 암호화된 비밀번호를 비교하고 성공 시 JWT를 반환하도록 수정합니다.

Q: 암호화된 비밀번호는 어떻게 비교하나요? A: passwordEncoder.matches(평문 비밀번호, 암호화된 비밀번호) 메서드를 사용합니다. 이 메서드는 평문 비밀번호를 다시 암호화하여 DB에 저장된 값과 같은 원본인지 안전하게 비교해줍니다.

Java
 
// UserService.java
public String login(String username, String password) {
    User user = userRepository.findByUsername(username).orElse(null);

    if (user != null && passwordEncoder.matches(password, user.getPassword())) {
        return jwtUtil.createToken(user.getUsername()); // 성공 시 JWT 토큰 생성
    }
    return null; // 실패 시 null
}

UserController 로그인 API 수정

이제 컨트롤러는 서비스로부터 받은 토큰을 클라이언트에게 전달합니다.

Java
 
// UserController.java
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequestDto requestDto) {
    String token = userService.login(requestDto.getUsername(), requestDto.getPassword());
    if (token != null) {
        return ResponseEntity.ok(token); // 성공 시 200 OK와 토큰 반환
    } else {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("로그인 실패");
    }
}

### 4. JWT 검증: 모든 요청 가로채기

토큰 발급이 끝났다면, 이제 API를 요청할 때마다 이 토큰이 유효한지 검사하는 '보안 요원'을 배치해야 합니다.

UserDetails, UserDetailsService 구현

Spring Security가 우리 User 객체를 인식하고, 사용자 이름으로 DB에서 조회할 수 있도록 표준 인터페이스를 구현합니다.

  1. User 엔티티가 UserDetails를 implements 하도록 수정합니다.
  2. UserService가 UserDetailsService를 implements 하고, loadUserByUsername 메서드를 구현합니다.

// 메소드들 구현 해주기
public class User implements UserDetails
// UserDetailsService의 핵심 메서드 구현
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    // findByUsername의 결과를 UserDetails 타입으로 반환
    return userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));
}

JwtAuthenticationFilter 생성

모든 HTTP 요청을 가로채서 Authorization 헤더에 담긴 JWT 토큰을 검증하는 필터입니다.

Java
 
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserService userService;
    // ... 생성자 ...

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
        // 1. 헤더에서 "Bearer " 토큰 추출
        // 2. jwtUtil로 토큰 유효성 검증
        // 3. 토큰이 유효하면, UserDetails 객체를 가져와서 인증 정보(Authentication)를 생성
        // 4. SecurityContextHolder에 인증 정보를 설정하여, 해당 요청 동안 인증된 사용자로 간주
        
        filterChain.doFilter(request, response); // 다음 필터로 전달
    }
}

### 5. SecurityConfig 완성하기

마지막으로, 이 모든 설정을 종합하여 Spring Security가 동작하도록 SecurityConfig를 완성합니다.

Java
 
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    // ... 생성자 ...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화
                .sessionManagement(session -> 
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 비활성화
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login").permitAll() // 특정 경로는 모두 허용
                        .anyRequest().authenticated() // 나머지는 모두 인증 필요
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // JWT 필터 추가

        return http.build();
    }
}

Q: GET 요청은 되는데 POST 요청만 403 Forbidden 오류가 떴어요. A: 바로 .csrf(AbstractHttpConfigurer::disable) 설정이 없었기 때문입니다. CSRF 보호 기능은 REST API 환경에서는 필요 없으므로 비활성화해야 합니다.

### 6. 컨트롤러에 인증 정보 적용하기

이제 임시로 사용했던 tempUserId를, @AuthenticationPrincipal을 사용해 실제 로그인한 사용자 정보로 교체합니다.

Java
 
// PostController.java
@PostMapping
public ResponseEntity<Long> createPost(@RequestBody PostForm form,
                                       @AuthenticationPrincipal User user) {
    Long userId = user.getId(); // 실제 로그인한 사용자의 ID
    // ...
}

이것으로 JWT를 이용한 인증/인가 기능의 기본 구현이 모두 끝났습니다! 이제 Postman으로 로그인하여 토큰을 발급받고, 그 토큰을 Authorization 헤더에 담아 다른 API들을 호출해보면 정상적으로 동작하는 것을 확인할 수 있습니다.

 


실제 구현 및 의문점과 답변

유저 입력 - Post 방식으로 유저 생성
유저의 패스워드가 인코딩되어 변경
로그인 시도 / 잠깐 착각으로 일단 모든 데이터 초기화했음
로그인 시 토큰 얻음
Post 방식으로 모든 포스트를 얻으려고 시도했으나 토큰을 안넣어서 권한이 없음
Header의 Authentication부분에 key로 Authorization, value로 토큰을 넣고 실행하니 [] 반환

 

### 1. UserDetails의 메서드들은 원래 구현을 안 하나요?

단순한 서비스에서는 true를 반환하도록 두는 경우가 많지만, 실제로는 구현하는 것이 맞습니다.

UserDetails의 is...() 메서드들은 계정의 '상태'를 관리하기 위해 존재합니다.

  • isAccountNonLocked(): 관리자가 특정 사용자를 '잠금' 처리하는 기능 (e.g., 5회 로그인 실패 시)
  • isEnabled(): 이메일 인증을 통과해야만 계정을 '활성화'하는 기능

지금처럼 모두 true를 반환하면 "모든 계정은 항상 활성화되어 있고 잠겨있지 않다"는 의미가 됩니다. 간단한 프로젝트에서는 이렇게 시작하는 것이 일반적입니다.

getAuthorities()는 사용자의 '권한(Role)' 목록을 반환하는 중요한 메서드입니다. 지금은 빈 리스트를 반환하여 아무 권한이 없는 상태지만, 나중에 User 엔티티에 private String role; 같은 필드를 추가하고, "ROLE_USER", "ROLE_ADMIN" 같은 권한을 반환하도록 구현하여 관리자 API 등을 만들 수 있습니다.


### 2. UserDetails vs. UserDetailsService

** analogy **

  • UserDetails: 스프링 시큐리티가 알아보는 표준 규격의 '신분증'
  • UserDetailsService: 그 신분증이 진짜인지 DB에 확인하고 '발급해주는 직원'
  • UserDetails: '사용자'를 나타내는 스프링 시큐리티의 표준 인터페이스입니다. "스프링 시큐리티가 인증/인가에 사용하려면, 너의 User 객체는 최소한 이 정보들(username, password, authorities 등)을 이 메서드 이름으로 제공해야 해!"라는 '약속'입니다. 그래서 우리의 domain.User가 이 인터페이스를 구현(implements)한 것입니다.
  • UserDetailsService: 단 하나의 메서드, loadUserByUsername(String username)를 가집니다. 스프링 시큐리티가 로그인이나 토큰 검증 시 "아이디가 입력받은 username값인 사람의 신분증(UserDetails) 좀 찾아줘"라고 요청하면, 이 메서드가 호출됩니다. 우리는 UserService에 이 인터페이스를 구현하여, 실제로는 userRepository.findByUsername()를 통해 DB에서 사용자를 찾아 반환하도록 만든 것입니다.

### 3. filterChain.doFilter(request, response)의 역할

"내 필터에서 할 일은 끝났으니, 이제 다음 필터로 요청을 넘기세요" 라는 의미의 '릴레이 바통 터치' 코드입니다.

스프링 시큐리티는 여러 개의 보안 필터들이 체인(chain)처럼 연결되어 순서대로 동작합니다.

  1. 요청이 들어오면 첫 번째 필터가 검사합니다.
  2. doFilter()를 호출하여 두 번째 필터로 요청을 넘깁니다.
  3. JwtAuthenticationFilter(우리가 만든 필터)가 토큰을 검사합니다.
  4. doFilter()를 호출하여 다음 필터로 요청을 넘깁니다.
  5. ... 모든 필터를 통과하면 마침내 컨트롤러에 도달합니다.

만약 이 코드가 없다면, JwtAuthenticationFilter에서 요청 처리가 끝나버리고 컨트롤러까지 도달하지 못하게 됩니다.


### 4. SecurityConfig도 스프링 빈인가요?

네, 맞습니다. @Configuration 어노테이션은 내부에 @Component를 포함하고 있어, 그 자체로 스프링 빈으로 등록됩니다. 스프링은 @Configuration이 붙은 빈을 특별하게 취급하여, 그 안에 @Bean이 붙은 메서드들을 실행하고 반환된 객체들을 추가로 빈으로 등록합니다.


### 5. SecurityConfig의 역할 (코드 해설)

SecurityConfig는 스프링 시큐리티와 관련된 **모든 설정을 정의하는 '총괄 설계도'**입니다.

Java
 
@Configuration // "이 클래스는 스프링의 설정 파일입니다."
@EnableWebSecurity // "웹 보안 기능을 활성화합니다."
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    // 생성자를 통해 우리가 만든 JwtAuthenticationFilter 빈을 주입받음
    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean // 이 메서드가 반환하는 SecurityFilterChain 객체를 빈으로 등록
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // CSRF(Cross-Site Request Forgery) 보호 기능을 비활성화. REST API에서는 불필요.
                .csrf(AbstractHttpConfigurer::disable)

                // 세션을 사용하지 않고, 모든 요청을 상태 없이(stateless) 처리하겠다고 설정. JWT 방식에 필수.
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                // HTTP 요청에 대한 접근 권한을 설정
                .authorizeHttpRequests(authorize -> authorize
                        // "/api/users/signup", "/api/users/login" 경로는 인증 없이 누구나 접근 허용
                        .requestMatchers("/api/users/signup", "/api/users/login").permitAll()
                        // 위에서 허용한 경로 외의 모든 요청은 반드시 인증을 거쳐야 함
                        .anyRequest().authenticated()
                )
                // 우리가 만든 JWT 인증 필터를, Spring Security의 기본 로그인 필터 앞에 배치
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build(); // 설정된 내용을 바탕으로 SecurityFilterChain 객체 생성
    }
}

### 6. @AuthenticationPrincipal이란?

컨트롤러 메서드에서 현재 로그인(인증)된 사용자의 객체를 직접 받아올 수 있게 해주는 매우 편리한 어노테이션입니다.

JwtAuthenticationFilter가 토큰을 검증한 후, SecurityContextHolder에 사용자 정보(UserDetails 객체)를 저장해 둡니다. @AuthenticationPrincipal은 컨트롤러가 실행될 때, SecurityContextHolder에서 그 정보를 꺼내 파라미터에 바로 주입해주는 역할을 합니다.

Java
 
// @AuthenticationPrincipal User user <-- 이 부분!
public ResponseEntity<?> createPost(@AuthenticationPrincipal User user, @RequestBody PostForm dto) {
    // 이제 user.getId(), user.getUsername() 등을 통해 로그인한 사용자 정보를 바로 사용 가능!
}