3차 Spring Security 완전 해설 시리즈 — 개요 · 기술 스택 · 전체 구조 · 학습 로드맵
2차 React+JWT → 3차 Spring Security · 보안 체계화 · 관리자 페이지 · ROLE 기반 권한
3차 Spring Security 프로젝트를 5개 파일로 나눠서 완전 해설합니다. 단순히 코드를 나열하는 게 아니라, 왜 이렇게 설계했는지부터 한 줄씩 코드가 어떻게 동작하는지까지 전부 다룹니다. 이 파일(05a)이 첫 번째로, 전체 그림과 기술 스택을 정리합니다.
2차에서 JWT 인증은 직접 손으로 만든 JwtFilter로 처리했습니다. 3차는 Spring Security라는 보안 프레임워크 위에 JWT를 얹어서, 인증 체계를 표준화하고 관리자 권한 기능까지 추가합니다.
| 항목 | 2차 (React + JWT) | 3차 (Spring Security) |
|---|---|---|
| 인증 필터 | 직접 만든 JwtFilterrequest.setAttribute로 저장 |
JwtAuthenticationFilterSecurityContextHolder에 저장 (표준 방식) |
| 로그인 유저 꺼내기 | request.getAttribute("loginUser")직접 꺼내서 null 체크까지 매번 |
@AuthenticationPrincipal CustomUserDetailsSpring이 자동 주입 — null 체크 불필요 |
| URL 접근 권한 관리 | Controller마다 로그인 체크 코드 반복 | SecurityConfig 한 곳에서 전체 URL 권한 설정 |
| CORS 설정 위치 | 별도 CorsConfig.java에서 설정 |
SecurityConfig 안의 corsConfigurationSource()로 통합 |
| 비밀번호 인코더 등록 | BoardApplication.java에서 @Bean 등록 |
SecurityConfig.java에서 @Bean 등록 (보안 관련은 한 곳으로) |
| 유저 역할(Role) | 없음 — 모든 로그인 유저 동일 권한 | ROLE_USER / ROLE_ADMIN 구분 |
| 관리자 기능 | 없음 | AdminController + AdminService 신규 추가유저/게시글 강제 삭제 |
| JWT 클레임 | username, nickname | username, nickname, role 추가 |
| 로그인 반환값 | token (String) | Map<"token", "role"> — role도 함께 반환 |
2차에서 직접 만든 JwtFilter는 동작은 하지만 "Spring Security라는 표준 밖"에 있습니다.
Spring Security를 쓰면:
① URL별 권한 설정을 코드 한 곳(SecurityConfig)에서 관리할 수 있고
② @AuthenticationPrincipal로 로그인 유저를 자동 주입받고
③ hasRole("ADMIN")처럼 역할 기반 권한 제어를 선언적으로 할 수 있습니다.
④ 세션/CSRF/CORS 등 보안 설정을 표준 방식으로 처리합니다.
3차에서 새로 추가되거나 중요하게 쓰이는 기술들입니다. 각 기술이 왜 필요한지 함께 이해합니다.
// ===== 2차에서 있었는데 3차에서 제거 ===== // implementation 'org.springframework.security:spring-security-crypto' // → Spring Security 전체를 추가하면 crypto가 포함되므로 별도 불필요 // ===== 3차에서 새로 추가 ===== implementation "org.springframework.boot:spring-boot-starter-security" // Spring Security 본체. Filter Chain, SecurityContext, 인증/인가 처리 전체 포함 testImplementation "org.springframework.boot:spring-boot-starter-security" // 테스트 환경에서도 Security 설정 적용 (없으면 테스트 시 Security 무시됨) // ===== 기존 유지 ===== implementation "io.jsonwebtoken:jjwt-api:0.12.3" runtimeOnly "io.jsonwebtoken:jjwt-impl:0.12.3" runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.3"
이 의존성 하나에 다음이 전부 포함됩니다:
spring-security-core — 핵심 인증/인가 기능
spring-security-web — Filter Chain, SecurityContext 등 웹 관련
spring-security-config — @EnableWebSecurity, SecurityFilterChain 설정
spring-security-crypto — BCryptPasswordEncoder (2차에서 별도로 넣었던 것)
추가하는 순간 Spring Boot가 자동으로 모든 URL에 인증 요구를 걸어버립니다.
이를 우리 프로젝트에 맞게 커스텀하는 게 SecurityConfig.java의 역할입니다.
## ===== 기존 유지 ===== spring.datasource.url=jdbc:mysql://localhost:3306/board spring.datasource.username=root spring.datasource.password=your_password spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true jwt.secret=mySecretKey2024 jwt.expiration=86400000 // 24시간 (밀리초) ## ===== 3차에서 추가 ===== admin.code=ADMIN2024 // 관리자 코드 — 회원가입 시 이 코드를 입력하면 ROLE_ADMIN 부여 // @Value("${admin.code}")로 UserService에서 읽어옴 // 장점: 코드 수정 없이 properties만 바꾸면 관리자 코드 변경 가능
05b~05e를 읽기 전에 이 용어들을 먼저 이해해두면 훨씬 수월합니다.
| 용어 | 한 줄 설명 | 상세 설명 |
|---|---|---|
| 인터페이스 (interface) | "뭘 해야 하는지" 정의만 하는 틀 | implements로 구현. Spring Security의 UserDetails, UserDetailsService가 인터페이스 — 우리가 구현체를 만들어야 Security가 쓸 수 있음 |
| @Override | 부모/인터페이스 메서드를 구현했다는 표시 | 없어도 동작하지만 붙이면 컴파일러가 "이게 정말 부모 메서드인지" 검증해줌. 오타 방지 효과 |
| @Bean | Spring이 관리할 객체(빈)를 직접 만들어 등록 | @Configuration 클래스 안 메서드에 붙임. new BCryptPasswordEncoder()를 Spring 빈으로 등록하면 어디서든 @Autowired/@RequiredArgsConstructor로 주입받을 수 있음 |
| @Component | Spring이 자동으로 빈으로 등록 | @Service, @Repository, @Controller도 내부적으로 @Component. JwtUtil, JwtAuthenticationFilter에 사용 |
| Filter | 요청이 Controller에 도달하기 전에 가로채는 것 | Spring Security는 Filter Chain으로 동작. 우리 JwtAuthenticationFilter도 Filter의 일종 |
| Filter Chain | 여러 Filter가 순서대로 연결된 파이프라인 | 요청이 들어오면 Filter Chain의 각 Filter를 순서대로 통과. filterChain.doFilter()가 "다음 Filter로 넘겨라"는 뜻 |
| SecurityContext | 현재 요청의 인증 정보 보관함 | JwtAuthenticationFilter가 여기에 Authentication 저장 → 이후 @AuthenticationPrincipal로 꺼낼 수 있음. 요청이 끝나면 자동 비워짐 |
| Authentication | 인증된 유저 정보를 담는 객체 | 우리는 UsernamePasswordAuthenticationToken 구현체 사용. (유저 정보, null, 권한 목록)을 담음 |
| @RequiredArgsConstructor | final 필드에 대한 생성자 자동 생성 | Lombok. private final UserRepository userRepository; 선언만 하면 생성자 주입 자동 처리. @Autowired 안 써도 됨 |
| @Value | properties 파일 값을 필드에 주입 | @Value("${jwt.secret}") → application.properties의 jwt.secret 값이 필드에 자동 주입됨. 하드코딩 방지 |
| CSRF | 세션 기반 인증의 취약점 방어 기법 | JWT 사용 시 불필요 (JWT는 매 요청마다 토큰 검증). SecurityConfig에서 .csrf(disable)로 비활성화 |
| STATELESS | 서버가 세션을 만들지 않음 | JWT 사용하므로 서버 메모리에 세션 저장 불필요. SessionCreationPolicy.STATELESS 설정 |
| GrantedAuthority | 권한을 나타내는 인터페이스 | SimpleGrantedAuthority("ROLE_USER")가 가장 단순한 구현체. Security가 권한 체크 시 이 목록을 봄 |
| permitAll() | 인증 없이 누구나 접근 허용 | 로그인/회원가입 URL에 적용. 없으면 Security가 미인증 요청을 자동 차단 |
| hasRole("ADMIN") | ROLE_ADMIN 권한을 가진 유저만 허용 | 내부적으로 "ROLE_" 접두사를 자동으로 붙여서 비교. hasAuthority("ROLE_ADMIN")과 결과는 같지만 ROLE_ 자동 추가 여부 차이 |
React에서 POST /api/board/write가 오면, Controller의 write() 메서드에 도달하기 전에 아래 Filter들이 순서대로 실행됩니다.
corsConfigurationSource() 설정을 읽어서 CORS 처리.Authorization: Bearer <token> 헤더 읽기jwtUtil.validateToken(token) — 유효성 검증loadUserByUsername() → DB 유저 조회UsernamePasswordAuthenticationToken 생성 → SecurityContextHolder에 저장filterChain.doFilter()로 다음 Filter로 넘김/api/user/login, /api/user/register → permitAll() → 누구나 통과/api/admin/** → hasRole("ADMIN") → ADMIN만 통과authenticated() → SecurityContext에 Authentication 있어야 통과@AuthenticationPrincipal CustomUserDetails userDetails → Spring이 SecurityContext에서 꺼내서 자동 주입.
SecurityContextHolder가 ThreadLocal에 저장. 요청 1개 = 스레드 1개 = SecurityContext 1개. 요청이 끝나면 자동으로 비워짐.UsernamePasswordAuthenticationToken이 구현체. principal(CustomUserDetails), credentials, authorities 포함.request.getAttribute() 불필요.hasRole("ADMIN") → 내부적으로 ROLE_ADMIN을 체크. hasAuthority("ROLE_ADMIN")은 접두사 포함해서 직접 지정. DB에는 ROLE_USER로 저장.doFilterInternal()만 오버라이드하면 됨.@Getter public class CustomUserDetails implements UserDetails { private final User user; // 우리 User 엔티티를 그대로 보관 public CustomUserDetails(User user) { this.user = user; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(new SimpleGrantedAuthority(user.getRole())); // ROLE_USER 또는 ROLE_ADMIN → Security가 권한 체크할 때 사용 } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired(){ return true; } @Override public boolean isEnabled() { return true; } }
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ... { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); // "Bearer " 제거 if (jwtUtil.validateToken(token)) { String username = jwtUtil.getUsername(token); UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); // ★ 핵심 — Authentication 객체 만들어서 SecurityContext에 저장 UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); } } filterChain.doFilter(request, response); // 반드시 다음 Filter로 넘겨야 함 } }
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // JWT 방식 → CSRF 불필요 .sessionManagement(sm -> sm.sessionCreationPolicy( SessionCreationPolicy.STATELESS)) // 세션 안 씀 .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/user/login", "/api/user/register").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") // ROLE_ADMIN만 .anyRequest().authenticated()) // 나머지는 로그인 필요 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
public void register(UserDto userDto) { String role = ("admin1234".equals(userDto.getAdminCode())) ? "ROLE_ADMIN" // 관리자 코드 일치 → 관리자 : "ROLE_USER"; // 아니면 일반 유저 User user = User.builder() .username(userDto.getUsername()) .password(bCryptPasswordEncoder.encode(userDto.getPassword())) .nickname(userDto.getNickname()) .role(role) // ← 여기서 결정된 role이 DB에 저장됨 .build(); userRepository.save(user); }
@PostMapping("/api/board/write") public ResponseEntity<?> write( @RequestBody BoardDto boardDto, @AuthenticationPrincipal CustomUserDetails userDetails) { // userDetails → Security가 SecurityContext에서 꺼내서 자동 주입 // null 체크 불필요 — AuthorizationFilter가 이미 인증 보장 User user = userDetails.getUser(); boardService.write(boardDto, user); return ResponseEntity.ok("작성 완료"); }
① React 라우터 단 — PrivateRoute(로그인 체크) + AdminRoute(ADMIN 체크)
② Spring Security 단 — hasRole("ADMIN") 서버 권한 체크
프론트에서 막아도 API를 직접 호출하면 뚫릴 수 있음 → 서버에서도 반드시 체크해야 진짜 보안.
// 로그인 여부 체크 const PrivateRoute = ({ children }) => { const token = localStorage.getItem("token"); return token ? children : <Navigate to="/login" />; }; // 관리자 여부 체크 (3차 신규) const AdminRoute = ({ children }) => { const role = localStorage.getItem("role"); return role === "ROLE_ADMIN" ? children : <Navigate to="/board/list" />; }; // 라우터 적용 <Route path="/board/list" element={<PrivateRoute><BoardList /></PrivateRoute>} /> <Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
const response = await api.post("/api/user/login", { username, password }); localStorage.setItem("token", response.data.token); localStorage.setItem("role", response.data.role); // ← 3차 추가 (ROLE_USER / ROLE_ADMIN) navigate("/board/list");
const role = localStorage.getItem("role"); {role === "ROLE_ADMIN" && ( <button onClick={() => navigate("/admin")}>관리자 페이지</button> )} // role이 ROLE_ADMIN일 때만 버튼 렌더링 — 일반 유저에게는 버튼 자체가 없음
const api = axios.create({ baseURL: "http://localhost:8080" }); api.interceptors.request.use(config => { const token = localStorage.getItem("token"); if (token) { config.headers["Authorization"] = `Bearer ${token}`; // 모든 api.xxx() 호출마다 헤더 자동 첨부 — 개별 컴포넌트에서 토큰 처리 불필요 } return config; });
bCryptPasswordEncoder.matches() → jwtUtil.createToken(username, nickname, role) → Map.of("token", token, "role", role)localStorage.setItem("token", ...) + localStorage.setItem("role", ...)Authorization: Bearer eyJhbGci...loadUserByUsername("hong") → DB 조회 → UsernamePasswordAuthenticationToken → SecurityContextHolder.getContext().setAuthentication(auth)boardRepository.deleteByUser(user) → userRepository.delete(user) — 순서 중요. FK 제약조건 때문에 게시글 먼저.| 항목 | 1차 (Spring MVC) | 2차 (React + JWT) | 3차 (Spring Security) |
|---|---|---|---|
| 인증 방식 | 세션 / 쿠키 | JWT (직접 만든 Filter) | JWT + Spring Security FilterChain |
| 로그인 유저 꺼내기 | session.getAttribute() |
request.getAttribute("loginUser") |
@AuthenticationPrincipal 자동 주입 |
| 프론트엔드 | Thymeleaf (서버 렌더링) | React (SPA) | React (SPA) + AdminRoute 추가 |
| 권한 관리 | 없음 | 없음 | ROLE_USER / ROLE_ADMIN |
| URL 권한 설정 | Controller 직접 체크 | Controller 직접 체크 | SecurityConfig 한 곳에서 선언 |
| CORS | 불필요 (같은 서버) | 별도 CorsConfig.java | SecurityConfig 통합 |
| 비밀번호 인코더 위치 | BoardApplication.java | BoardApplication.java | SecurityConfig.java (보안 설정 한 곳) |
| 관리자 기능 | 없음 | 없음 | AdminController + AdminService |
| 파일 | 핵심 내용 | 선행 이해 필요 |
|---|---|---|
| 06b_security_filterchain.html | Spring Security 동작 원리, Filter Chain, JWT+Security 흐름, @AuthenticationPrincipal | 이 파일(05a) — 개념 사전 파악 후 읽기 |
| 06c_security_backend.html | 백엔드 전체 코드 한 줄씩 해부 (Security 파일들 + Controller + Service) | 05b — Filter Chain 이해 후 코드 읽기 |
| 06d_security_frontend.html | React 전체 코드 해부 (AdminRoute, AdminPage, role 처리) | 05c — 백엔드 API 이해 후 프론트 읽기 |
| 06e_security_full_flow.html | 요청별 전체 흐름 완전 추적 + 1~3차 비교표 | 05b~05d — 코드 다 읽고 나서 흐름 정리용 |