Spring Security란?
Spring Security는 스프링 애플리케이션을 보호해주는 보안 프레임워크로, 인증(Authentication), 인가(Authorization), 그리고 여러 보안 기능을 제공한다.
- 인증: 너 누구야?
- 인가: 너 이 기능 써도 돼?
- 보안: 이상한 요청은 막자
보안 처리는 생각보다 신경 쓸 게 많아서, 직접 구현하려면 아래의 요소들을 전부 만들어야 한다.
- 로그인 처리
- 로그인 상태 유지
- URL별 접근 권한 제어
- 비밀번호 암호화
- 인증 실패 처리
- 필터 체인 관리
Spring Security는 이런 걸 검증된 방식으로 구조화해서 제공한다. 특히 서블릿 기반 Spring Security에서는 FilterChainProxy와 SecurityFilterChain을 중심으로 요청을 가로채고, 어떤 보안 필터를 적용할지 결정한다.
JWT란?
JWT(JSON Web Token)는 로그인에 성공했을 때 서버가 사용자에게 토큰 문자열 하나를 발급해주고, 이후 요청마다 그 토큰을 함께 보내면 서버가 사용자를 확인하는 방식이다.
- 사용자가 로그인 요청
- 서버가 아이디/비밀번호 확인
- 성공하면 JWT 발급
- 클라이언트는 JWT 저장
- 이후 요청 시 Authorization: Bearer 토큰값 전송
- 서버는 토큰이 유효한지 검사 후 인증 처리
Spring Security + JWT를 같이 쓰면 아래와 같은 방식으로 동작한다.
- /login 으로 아이디/비밀번호 전송
- 서버가 사용자 확인
- 맞으면 JWT 생성
- 이후 보호된 API 요청 시 JWT 포함
- Spring Security 필터가 JWT 검사
- 유효하면 로그인된 사용자처럼 처리
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 요청


오늘 한 일
- 사용자가 /auth/signup으로 가입
- 비밀번호는 BCrypt로 해시해서 DB 저장
- 사용자가 /auth/login으로 로그인
- Spring Security가 아이디/비밀번호 검사
- 성공하면 JWT 발급
- 이후 요청마다 Authorization: Bearer ... 전송
- JWT 필터가 토큰을 읽고 사용자 인증 정보를 SecurityContextHolder에 넣음
- 보호된 API 접근 허용
내일 할 일
- 댓글 기능 추가
- 게시판 UI 구현
'프레임워크 > Spring' 카테고리의 다른 글
| [Spring Boot] Spring CRUD 구현 - Day 5 : 댓글 기능 구현 (0) | 2026.03.16 |
|---|---|
| [Spring Boot] Spring CRUD 구현 - Day 3 (0) | 2026.03.12 |
| [Spring Boot] Spring CRUD 구현 - Day 2 (0) | 2026.03.11 |
| [Spring] JPA와 H2 DB (0) | 2026.03.10 |
| [Spring Boot] Spring CRUD 구현 - Day 1 (0) | 2026.03.10 |