프레임워크/Spring

[Spring Boot] Spring CRUD 구현 - Day 4 : Spring Security + JWT

munsik22 2026. 3. 13. 19:36

Spring Security란?

Spring Security는 스프링 애플리케이션을 보호해주는 보안 프레임워크로, 인증(Authentication), 인가(Authorization), 그리고 여러 보안 기능을 제공한다.
  • 인증: 너 누구야?
  • 인가: 너 이 기능 써도 돼?
  • 보안: 이상한 요청은 막자

보안 처리는 생각보다 신경 쓸 게 많아서, 직접 구현하려면 아래의 요소들을 전부 만들어야 한다.

  • 로그인 처리
  • 로그인 상태 유지
  • URL별 접근 권한 제어
  • 비밀번호 암호화
  • 인증 실패 처리
  • 필터 체인 관리

Spring Security는 이런 걸 검증된 방식으로 구조화해서 제공한다. 특히 서블릿 기반 Spring Security에서는 FilterChainProxy와 SecurityFilterChain을 중심으로 요청을 가로채고, 어떤 보안 필터를 적용할지 결정한다.

JWT란?

JWT(JSON Web Token)는 로그인에 성공했을 때 서버가 사용자에게 토큰 문자열 하나를 발급해주고, 이후 요청마다 그 토큰을 함께 보내면 서버가 사용자를 확인하는 방식이다.
  1. 사용자가 로그인 요청
  2. 서버가 아이디/비밀번호 확인
  3. 성공하면 JWT 발급
  4. 클라이언트는 JWT 저장
  5. 이후 요청 시 Authorization: Bearer 토큰값 전송
  6. 서버는 토큰이 유효한지 검사 후 인증 처리

Spring Security + JWT를 같이 쓰면 아래와 같은 방식으로 동작한다.

  1. /login 으로 아이디/비밀번호 전송
  2. 서버가 사용자 확인
  3. 맞으면 JWT 생성
  4. 이후 보호된 API 요청 시 JWT 포함
  5. Spring Security 필터가 JWT 검사
  6. 유효하면 로그인된 사용자처럼 처리

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.projectlombok:lombok'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-webservices'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-security' // 추가
    testImplementation 'org.springframework.security:spring-security-test' // 추가
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
    runtimeOnly 'com.h2database:h2'
    implementation 'org.springframework.boot:spring-boot-h2console'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.7' // 추가
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.7' // 추가
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.7' // 추가
}

.env

JWT_SECRET=비밀번호
  • Intellij에서는 실행 → 구성 편집 → 빌드 및 편집 → 옵션 수정 → '환경 변수' 체크 →  환경 변수 주소 입력 으로 env 파일을 설정해야 한다.
  • .gitignore에 .env 를 추가하는 것도 잊지 말자.

application.properties

spring.config.import=optional:file:.env[.properties]
jwt.secret=${JWT_SECRET}
jwt.access-token-expiration-ms=3600000

Role.java

package com.example.springcrudback.user;

public enum Role {
    USER
}

UserAccount.java

package com.example.springcrudback.user;

import jakarta.persistence.*;
import lombok.Getter;

@Getter
@Entity
@Table(name = "user_account")
public class UserAccount {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    protected UserAccount() {
    }

    public UserAccount(String username, String password, Role role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }

}
  • 게시글과 마찬가지로 계정 정보 역시 H2 데이터베이스에 저장된다.
사용자 회원가입 요청 → AuthController → AuthService → PasswordEncoder로 비밀번호 암호화 → UserAccount 엔티티 생성 → UserAccountRepository.save(...) → H2 DB의 user_account 테이블에 저장

UserAccountRepository.java

package com.example.springcrudback.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserAccountRepository extends JpaRepository<UserAccount, Long> {
    Optional<UserAccount> findByUsername(String username);
    boolean existsByUsername(String username);
}

SignupRequest.java

package com.example.springcrudback.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class SignupRequest {

    @NotBlank(message = "아이디는 비어 있을 수 없습니다.")
    @Size(min = 4, max = 20, message = "아이디는 4자 이상 20자 이하여야 합니다.")
    private String username;

    @NotBlank(message = "비밀번호는 비어 있을 수 없습니다.")
    @Size(min = 8, max = 100, message = "비밀번호는 8자 이상이어야 합니다.")
    private String password;

}

LoginRequest.java

package com.example.springcrudback.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class LoginRequest {

    @NotBlank(message = "아이디는 비어 있을 수 없습니다.")
    private String username;

    @NotBlank(message = "비밀번호는 비어 있을 수 없습니다.")
    private String password;

}

AuthResponse.java

package com.example.springcrudback.dto;

import lombok.Getter;

@Getter
public class AuthResponse {

    private final String accessToken;
    private final String tokenType = "Bearer";

    public AuthResponse(String accessToken) {
        this.accessToken = accessToken;
    }

}

SecurityConfig.java

package com.example.springcrudback.auth;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

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

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .httpBasic(Customizer.withDefaults())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**", "/h2-console/**").permitAll()
                        .anyRequest().authenticated()
                )
                .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()))
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}
  • Spring Security는 요청별 인가 규칙을 authorizeHttpRequests로 선언하고, 별도 설정이 없으면 모든 요청을 인증 대상으로 본다.
  • 이번 코드에서는 /auth/**와 /h2-console/**을 제외한 다른 API 주소들은 로그인하지 않으면 사용할 수 없다.
  • 세션 기반이 아닌 JWT API는 STATELESS로 두는 것이 맞고, 이 값은 Spring Security가 HttpSession을 만들지도 사용하지도 않는다는 뜻이다.
  • 비밀번호 저장은 단방향 PasswordEncoder를 써야 하고, BCryptPasswordEncoder는 강한 해시 구현이다.

CustomUserDetailsService.java (사용자 조회)

package com.example.springcrudback.auth;

import com.example.springcrudback.user.UserAccount;
import com.example.springcrudback.user.UserAccountRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserAccountRepository userAccountRepository;

    public CustomUserDetailsService(UserAccountRepository userAccountRepository) {
        this.userAccountRepository = userAccountRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        UserAccount account = userAccountRepository.findByUsername(username)
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));

        return User.builder()
                .username(account.getUsername())
                .password(account.getPassword())
                .roles(account.getRole().name())
                .build();
    }
}

JwtTokenProvider.java (JWT 발급 및 검증)

package com.example.springcrudback.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.access-token-expiration-ms}")
    private long accessTokenExpirationMs;

    private SecretKey key;

    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
    }

    public String createAccessToken(String username) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + accessTokenExpirationMs);

        return Jwts.builder()
                .subject(username)
                .issuedAt(now)
                .expiration(expiry)
                .signWith(key)
                .compact();
    }

    public String getUsername(String token) {
        Claims claims = Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();

        return claims.getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

JwtAuthenticationFilter.java

package com.example.springcrudback.auth;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomUserDetailsService customUserDetailsService;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
                                   CustomUserDetailsService customUserDetailsService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.customUserDetailsService = customUserDetailsService;
    }

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

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

        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            String token = bearerToken.substring(7);

            if (jwtTokenProvider.validateToken(token)) {
                String username = jwtTokenProvider.getUsername(token);
                UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );

                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}
  • Spring Security의 인증 정보는 SecurityContextHolder 안에 저장되고, 그 안의 Authentication이 현재 로그인 사용자를 나타낸다.

AuthService.java (회원가입 및 로그인)

package com.example.springcrudback.auth;

import com.example.springcrudback.dto.AuthResponse;
import com.example.springcrudback.dto.LoginRequest;
import com.example.springcrudback.dto.SignupRequest;
import com.example.springcrudback.user.Role;
import com.example.springcrudback.user.UserAccount;
import com.example.springcrudback.user.UserAccountRepository;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class AuthService {

    private final UserAccountRepository userAccountRepository;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthService(UserAccountRepository userAccountRepository,
                       PasswordEncoder passwordEncoder,
                       AuthenticationManager authenticationManager,
                       JwtTokenProvider jwtTokenProvider) {
        this.userAccountRepository = userAccountRepository;
        this.passwordEncoder = passwordEncoder;
        this.authenticationManager = authenticationManager;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    public void signup(SignupRequest request) {
        if (userAccountRepository.existsByUsername(request.getUsername())) {
            throw new IllegalArgumentException("이미 사용 중인 아이디입니다.");
        }

        UserAccount account = new UserAccount(
                request.getUsername(),
                passwordEncoder.encode(request.getPassword()),
                Role.USER
        );

        userAccountRepository.save(account);
    }

    public AuthResponse login(LoginRequest request) {
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(),
                        request.getPassword()
                )
        );

        String accessToken = jwtTokenProvider.createAccessToken(request.getUsername());
        return new AuthResponse(accessToken);
    }
}
  • 회원가입 때는 평문 비밀번호를 그대로 저장하지 않고 PasswordEncoder로 해시해서 저장
  • 로그인 때는 AuthenticationManager로 아이디/비밀번호를 검증한 뒤 토큰 발급

AuthController.java

package com.example.springcrudback.auth;

import com.example.springcrudback.dto.AuthResponse;
import com.example.springcrudback.dto.LoginRequest;
import com.example.springcrudback.dto.SignupRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

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

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/signup")
    @ResponseStatus(HttpStatus.CREATED)
    public String signup(@Valid @RequestBody SignupRequest request) {
        authService.signup(request);
        return "회원가입이 완료되었습니다.";
    }

    @PostMapping("/login")
    public AuthResponse login(@Valid @RequestBody LoginRequest request) {
        return authService.login(request);
    }
}

테스트

  • 회원가입

  • 로그인

  • /posts 요청

토큰이 없으면 401 Unauthorized 에러가 발생한다.
토큰이 같이 전송되어야 요청이 제대로 수행된다.


오늘 한 일

 

  1. 사용자가 /auth/signup으로 가입
  2. 비밀번호는 BCrypt로 해시해서 DB 저장
  3. 사용자가 /auth/login으로 로그인
  4. Spring Security가 아이디/비밀번호 검사
  5. 성공하면 JWT 발급
  6. 이후 요청마다 Authorization: Bearer ... 전송
  7. JWT 필터가 토큰을 읽고 사용자 인증 정보를 SecurityContextHolder에 넣음
  8. 보호된 API 접근 허용

내일 할 일

  • 댓글 기능 추가
  • 게시판 UI 구현