스프링 부트 백엔드 개발 강좌, JWT로 로그인 로그아웃 구현, 토큰 필터 구현하기

1. 서론

스프링 부트(Spring Boot)는 Java 기반 웹 애플리케이션을 손쉽게 개발할 수 있도록 도와주는 프레임워크입니다. 최근 들어 웹 애플리케이션에 대한 수요가 급증하면서, 보안 및 인증에 대한 이해가 중요해졌습니다. 이 강좌에서는 JSON Web Token(JWT)을 사용하여 로그인 및 로그아웃 기능을 구현하고, 필터를 통해 JWT 토큰을 검증하는 방법을 알아보겠습니다. 이 과정에서 스프링 부트의 다양한 기능을 활용하여 더 안전하고 효율적인 백엔드 애플리케이션을 구축하는 법을 배우게 될 것입니다.

2. JWT란 무엇인가?

JSON Web Token(JWT)은 두 개의 엔티티 간에 정보를 안전하게 전송하기 위해 사용하는 JSON 기반의 오브젝트입니다. JWT는 사용자 인증뿐만 아니라, 클레임 기반의 정보 전송을 위한 표준으로 자리 잡고 있습니다. JWT는 다음과 같이 세 가지 구성 요소로 나뉩니다:

  • 헤더(Header): JWT의 타입과 해싱 알고리즘을 정의합니다.
  • 페이로드(Payload): 전송하려는 클레임 정보가 포함됩니다.
  • 서명(Signature): 헤더와 페이로드를 결합한 후 비밀 키를 사용하여 생성됩니다. 이를 통해 JWT의 무결성을 확인할 수 있습니다.

3. 스프링 부트 프로젝트 설정하기

스프링 부트 프로젝트를 시작하려면 기본적인 설정부터 해야 합니다. 이러한 설정을 위해 스프링 이니셜라이저(Spring Initializr)를 이용할 수 있습니다. 프로젝트 생성 시 선택해야 할 사항은 다음과 같습니다:

  • Project: Maven Project
  • Language: Java
  • Spring Boot: Stable version (Latest)
  • Dependencies: Spring Web, Spring Security, Spring Data JPA, Spring Boot DevTools, H2 Database

프로젝트가 생성되면 IDE에서 열고, 필요한 의존성을 추가할 수 있습니다.

4. 엔티티 클래스 및 리포지토리 생성하기

우선 유저(User) 엔티티를 정의해야 합니다. 간단한 User 엔티티는 사용자 정보를 저장하는 클래스입니다. 이를 통해 우리는 데이터베이스와 상호작용할 수 있습니다.

package com.example.demo.entity;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    
    // Getters and Setters
}

리포지토리는 엔티티와 데이터베이스의 CRUD 작업을 수행하는 데 필요합니다.

package com.example.demo.repository;

import com.example.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
    User findByUsername(String username);
}

5. 서비스 클래스 생성하기

비즈니스 로직을 처리하는 서비스 클래스가 필요합니다. 이 클래스에서는 사용자 등록, 로그인 등의 기능을 구현합니다.

package com.example.demo.service;

import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    @Autowired
    private BCryptPasswordEncoder passwordEncoder;
    
    public User register(User user) {
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        return userRepository.save(user);
    }
    
    public User findUserByUsername(String username) {
        return userRepository.findByUsername(username);
    }
}

6. 보안 설정 및 JWT 생성하기

스프링 시큐리티를 사용하여 애플리케이션의 보안을 강화할 수 있습니다. 이를 위해 SecurityConfig 클래스를 생성하고, JWT 토큰을 생성하는 클래스를 추가해야 합니다.

package com.example.demo.config;

import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .authorizeRequests()
            .antMatchers("/api/auth/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }

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

7. JWT 생성 및 검증 구현하기

로그인 시 사용자의 정보를 바탕으로 JWT를 생성하여 클라이언트에 전달합니다. 이를 위해 JWT 유틸리티 클래스를 생성하여 토큰 생성 및 검증 작업을 수행합니다.

package com.example.demo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtUtil {
    
    private final String SECRET_KEY = "secret_key";
    private final long EXPIRATION_TIME = 86400000; // 1 day

    public String generateToken(String username) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, username);
    }

    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    public boolean validateToken(String token, String username) {
        final String extractedUsername = extractUsername(token);
        return (extractedUsername.equals(username) && !isTokenExpired(token));
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getExpiration();
    }

    public String extractUsername(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getSubject();
    }
}

8. 로그인 및 로그아웃 기능 구현하기

이제 사용자가 로그인하면 JWT를 발급받고, 로그아웃 시에는 클라이언트가 토큰을 삭제하는 방식으로 구현합니다. 아래는 로그인 API의 예시입니다.

package com.example.demo.controller;

import com.example.demo.entity.User;
import com.example.demo.security.JwtUtil;
import com.example.demo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private UserService userService;

    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody User user) {
        User foundUser = userService.findUserByUsername(user.getUsername());
        if (foundUser != null && passwordEncoder.matches(user.getPassword(), foundUser.getPassword())) {
            return ResponseEntity.ok(jwtUtil.generateToken(foundUser.getUsername()));
        }
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
    }

    @PostMapping("/logout")
    public ResponseEntity<String> logout() {
        // 로그아웃 기능은 클라이언트 측에서 구현하도록 합니다.
        return ResponseEntity.ok("Successfully logged out.");
    }
}

9. JWT 필터 구현하기

JWT 필터를 구현하여 모든 요청에서 JWT를 검증하고, 인증을 처리하는 과정이 필요합니다. 이를 위해 JwtAuthenticationFilter 클래스를 생성합니다.

package com.example.demo.security;

import io.jsonwebtoken.ExpiredJwtException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private UserDetailsService userDetailsService;

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

        String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            try {
                username = jwtUtil.extractUsername(jwt);
            } catch (ExpiredJwtException e) {
                System.out.println("JWT is expired");
            }

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                if (jwtUtil.validateToken(jwt, userDetails.getUsername())) {
                    UsernamePasswordAuthenticationToken authenticationToken = 
                            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }
            }
        }
        filterChain.doFilter(request, response);
    }
}

10. 결론

이번 강좌에서는 스프링 부트를 이용하여 JWT를 통한 로그인 및 로그아웃 기능을 구현하는 방법을 배웠습니다. 우리는 JWT 토큰을 생성하고 검증하는 방법, 그리고 스프링 시큐리티를 활용하여 애플리케이션의 보안을 강화하는 방법에 대해서도 알아보았습니다. 이 과정을 통해 백엔드 애플리케이션의 인증 및 권한 관리를 더 안전하게 처리할 수 있는 기반을 마련할 수 있습니다. 더불어, 스프링 부트의 다양한 기능을 활용하여 효율적이고 안전한 웹 애플리케이션 개발을 실현할 수 있을 것입니다.

11. 참고 자료