티스토리 뷰

반응형

스프링 시큐리티 완전 처음부터 1편

처음 만나는 SecurityFilterChain, 진짜 하나도 모른다는 가정으로


솔직히 말하면, 나는 스프링 시큐리티 처음 쓸 때 완전 멘붕이었다.
“아니, 그냥 로그인만 되면 되는 건데… 왜 이렇게 복잡하지?”
근데 그 복잡함 뒤에 있는 **“구조”**를 한 번 이해하고 나니까, 왜 이걸 안 쓰면 안 되는지 좀 보이더라.

이번 글은 진짜로

“스프링 시큐리티 1도 모른다”
라고 가정하고 쓰겠다.

  • 왜 쓰는지
  • 요청이 들어오면 내부에서 무슨 일이 벌어지는지
  • 최소 설정으로 “진짜 돌아가는 코드”를 만들면서
  • 각 코드 한 줄씩 다 해석해 보는 게 목표다.

0. 이 글에서 사용하는 환경 먼저 짚고 가자

이건 앞으로 나오는 코드가 그대로 따라 해도 돌아가게 만들기 위한 전제다.

  • JDK: 17 (스프링 부트 3.x 기준)
  • Spring Boot: 3.x (2.x 아님)
  • 빌드 도구: Gradle (Maven 써도 되는데, 예제는 Gradle)
  • 의존성:
    • spring-boot-starter-web
    • spring-boot-starter-security

버전이 다르면 코드가 미묘하게 안 맞을 수 있다. 특히 스프링 시큐리티는 5.7 이후로 WebSecurityConfigurerAdapter 가 삭제됐기 때문에, 예전에 블로그 글 찾아보고 따라 하면 100% 꼬인다.

우리는 **“요즘 방식”**만 쓸 거다.


1. 스프링 시큐리티, 왜 굳이 써야 하나?

주니어 때 많이 보던 코드가 이거였다.

@WebFilter("/*")
public class LoginCheckFilter implements Filter {
    // 세션에 user 없으면 /login 으로 리다이렉트하는 방식
}

이렇게 직접 필터 만들어서 로그인 체크하고, 세션에 유저 객체 넣고, 권한도 if 문으로 분기하고…
이걸 “어떻게든” 구현하면 사실 동작은 한다.

문제는 여기서다.

  1. CSRF, 세션 고정 공격, 브루트포스 공격 같은 건 전혀 고려 안 된 상태
  2. 개발자가 직접 만든 인증/인가 로직은 구멍이 생기기 쉽다
  3. 서비스가 커질수록, “직접 만든 보안 코드”가 유지보수 지옥으로 변한다

그래서 현실적인 결론은 이거다.

  • 보안은 직접 구현하는 게 아니라
  • 검증된 프레임워크를 가져다 쓰는 게 맞다
  • 그게 스프링 시큐리티다

스프링 시큐리티는 아래를 “기본값”으로 제공한다.

  • 인증/인가(로그인/권한) 구조
  • 비밀번호 암호화(BCrypt)
  • 세션 관리, 쿠키, CSRF 방어
  • 필터 체인 기반의 일관된 처리 흐름
  • 확장 가능한 구조 (커스텀 인증/토큰/JWT 등)

그래서 “복잡해 보이는 이유”는 사실 이거 하나다.

온갖 경우를 다 처리할 수 있게 유연하게 만들어놔서…

우리는 오늘 그 중에서 가장 기본적인 한 줄기만 잡고 간다.


2. 스프링 시큐리티의 큰 그림: 요청 한 번에 무슨 일이 일어나는가

반응형

우선 머릿속에 이 흐름이 한 줄로 서 있어야 한다.

클라이언트 요청 → 여러 개의 시큐리티 필터들 → 인증/인가 판단 → 컨트롤러

조금 더 풀어보면:

  1. 사용자가 GET /private/hello 같은 요청을 보낸다.
  2. 스프링 MVC 컨트롤러에 도달하기 전에,
    SecurityFilterChain에 등록된 필터들이 줄줄이 실행된다.
  3. 이 필터들 중 일부가
    • “이 요청은 인증이 필요한가?”
    • “지금 사용자는 로그인된 상태인가?”
    • “권한은 충분한가?”
      이런 걸 판단한다.
  4. 인증이 안 되어 있고, 인증이 필요한 URL이면,
    → 로그인 페이지로 리다이렉트
  5. 인증이 되어 있고, 권한도 충분하면
    → 그제서야 우리가 작성한 @RestController 메서드가 호출된다.

이걸 꼭 기억하자.

“스프링 시큐리티는 컨트롤러 앞에 서서 모든 요청을 지켜보는 필터 레이어다.”


3. 진짜 프로젝트를 하나 만든다고 치고, 맨 처음부터 코드 짜보자

3-1. build.gradle 설정

아주 최소한의 코드만 넣겠다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.0'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.test {
    useJUnitPlatform()
}

여기서 중요한 건 딱 두 줄이다.

implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
  • spring-boot-starter-web
    → 기본적인 웹 MVC, 내장 톰캣, JSON 직렬화 등
  • spring-boot-starter-security
    → 스프링 시큐리티 핵심 기능, 필터, 인증/인가 구조 등

이 두 개만 있으면
“기본 로그인 화면이 뜨는 웹 애플리케이션”을 만들 수 있다.


3-2. 애플리케이션 진입점 만들기

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoSecurityApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoSecurityApplication.class, args);
    }
}

이건 그냥 스프링 부트 기본 템플릿이라고 보면 된다.
이름은 뭐든 상관없다. (DemoSecurityApplication 말고 다른 걸 써도 됨)


4. SecurityConfig – 요즘 방식의 기본 보안 설정

이제 핵심인 SecurityConfig를 만든다.

위치: com.example.demo.config.SecurityConfig
(패키지는 원하는 대로 쓰되, @SpringBootApplication이 있는 패키지 하위에 두면 된다)

4-1. 전체 코드

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http
                // 1. CSRF 설정
                .csrf(csrf -> csrf.disable())

                // 2. URL 별 권한(인가) 설정
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/public/**").permitAll() // 이 경로는 모두 허용
                        .anyRequest().authenticated()              // 나머지는 인증 필요
                )

                // 3. 기본 로그인 폼 사용
                .formLogin(Customizer.withDefaults());

        return http.build();
    }
}

위 코드 그대로 복붙해도 된다.
이제 이걸 한 줄씩 해석해 보자.


4-2. @Configuration, @Bean, SecurityFilterChain 이게 뭔데?

@Configuration
public class SecurityConfig {
  • @Configuration
    → “이 클래스 안에는 스프링 빈 설정이 들어있다”라는 의미
    → 예전에 XML에 쓰던 설정을 자바 코드로 옮겨온 것이라고 보면 된다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
  • @Bean
    → 이 메서드가 반환하는 객체를 스프링 컨테이너에 등록해라
  • 리턴 타입: SecurityFilterChain
    → 이게 바로 “스프링 시큐리티의 필터 체인 설정” 그 자체다.
  • 파라미터: HttpSecurity http
    → 스프링이 알아서 주입해주는 설정 빌더 같은 거라고 생각하면 된다.
    → 이 객체에 체이닝으로 설정을 붙여 나가고, 마지막에 http.build()를 호출하면 SecurityFilterChain이 만들어진다.

옛날에는 이렇게 썼다.

public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception { ... }
}

지금은 이 방식이 deprecated → 삭제 되었다.
그래서 우리는 SecurityFilterChain을 Bean으로 등록하는 방식만 쓴다.


4-3. CSRF 설정

http
    .csrf(csrf -> csrf.disable())
  • CSRF는 웹 브라우저 기반 폼에서 중요하다.
  • 하지만 우리가 지금 만드는 건 학습용 API/간단 서비스라, 일단 비활성화해서 헷갈리는 요소를 줄이는 게 좋다.
  • 나중에 폼 기반, 세션 기반 웹 앱을 제대로 만들 때 다시 켜고 설명할 수 있다.

중요한 건 “지금은 왜 끄는지”를 알고 끄는 것.

REST API 기반, 쿠키/폼 로그인 안 쓰고, JWT 같은 토큰 기반 인증을 사용할 예정인 경우,
보통 CSRF를 끄고 시작한다.


4-4. authorizeHttpRequests – 어떤 URL에 인증이 필요한지 선언하는 부분

.authorizeHttpRequests(auth -> auth
        .requestMatchers("/public/**").permitAll()
        .anyRequest().authenticated()
)

이 부분이 스프링 시큐리티의 “인가(Authorization)” 핵심이다.

  • authorizeHttpRequests(...)
    → “들어오는 HTTP 요청들에 대해, 어떤 규칙으로 접근을 허용/차단할지 정의하겠다”
  • requestMatchers("/public/**").permitAll()
    → /public/ 로 시작하는 모든 요청은
    “인증 없이 누구나 접근 가능”이라는 의미다.
  • anyRequest().authenticated()
    → 위에서 명시적으로 허용한 URL을 제외한 나머지 모든 요청은
    “인증(로그인)이 반드시 필요하다”는 의미.

즉 지금 설정은 이렇게 읽힌다.

  • /public/** → 로그인 안 해도 됨
  • 그 외 모든 URL → 반드시 로그인해야만 접근 가능

이 설정 하나만으로, 우리는
“로그인 여부에 따라 접근이 갈리는 API”를 만든 셈이다.


4-5. formLogin – 기본 폼 로그인 사용

.formLogin(Customizer.withDefaults());

이 한 줄의 의미는 간단하다.

“스프링 시큐리티가 제공하는 기본 로그인 폼 사용하겠다.”

여기서 자동으로 제공되는 것들:

  • /login GET → 기본 로그인 페이지
  • /login POST → 아이디/비밀번호 처리
  • 로그인 실패 시 → 다시 /login?error 로 리다이렉트
  • 로그인 성공 시 → 원래 가려고 했던 URL로 리다이렉트

아무 설정 안 해도 이 동작이 다 들어간다.

초반 학습 구간에서는 이 기본 폼이 오히려 더 낫다.

  • 우리가 HTML 로그인 페이지를 직접 만들 필요가 없고
  • 인증 흐름을 눈으로 쉽게 따라갈 수 있다

나중에 JWT로 가거나, SPA(React, Vue) 프론트와 연동할 때는 이걸 끄고 API 기반 인증으로 갈 거다.
그건 다음 편 이후의 이야기고, 지금은 “구조”를 보는 게 목표다.


4-6. 마지막으로 http.build()

return http.build();

앞에서 우리가 체이닝으로 붙여왔던 설정들이 이 시점에서 하나의 SecurityFilterChain 객체로 만들어진다.
그리고 이 객체가 @Bean으로 스프링에 등록된다.


5. 테스트용 컨트롤러 – 실제로 URL별로 접근 차이를 만들어 보자

이제 /public/hello 와 /private/hello 두 개의 API를 만들어서
설정이 진짜로 먹히는지 확인해보자.

위치: com.example.demo.controller.TestController

package com.example.demo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @GetMapping("/public/hello")
    public String publicHello() {
        return "누구나 접근 가능한 공개 API 입니다.";
    }

    @GetMapping("/private/hello")
    public String privateHello() {
        return "로그인한 사용자만 접근 가능한 비공개 API 입니다.";
    }
}

설명:

  • @RestController
    → 반환 값이 그대로 HTTP 응답 바디로 내려간다.
    (뷰 템플릿이 아니라 문자열/JSON)
  • /public/hello
    → 아까 SecurityConfig에서 permitAll() 했으니까, 로그인 없이 접근 가능
  • /private/hello
    → anyRequest().authenticated() 규칙에 걸린다.
    → 로그인해야만 접근 가능

6. 실제로 서버를 띄우고 하나씩 찍어보자

  1. 애플리케이션 실행
    (IDE에서 DemoSecurityApplication 실행)
  2. 콘솔 로그를 잘 보면 이런 메시지가 보인다.이게 바로 임시로 생성된 “user” 계정의 비밀번호다.
    • username: user
    • password: 콘솔에 찍힌 저 복잡한 문자열
  3. Using generated security password: 3f9e3b9f-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  4. 브라우저에서 아래 URL을 하나씩 호출해보자.

6-1. /public/hello

http://localhost:8080/public/hello

결과:

  • 바로 “누구나 접근 가능한 공개 API 입니다.” 라는 문자열이 뜨면 정상이다.
  • 이 URL은 permitAll() 로 열어뒀기 때문에, 로그인 체크를 하긴 하지만, 통과시켜준다.

6-2. /private/hello

http://localhost:8080/private/hello

결과:

  1. 바로 응답이 안 오고, /login 페이지로 리다이렉트된다.
  2. 스프링 시큐리티 기본 로그인 폼이 보인다.
  3. username: user / password: 콘솔에 출력된 값 입력
  4. 로그인 성공하면 다시 /private/hello로 이동
  5. 이번엔 “로그인한 사용자만 접근 가능한 비공개 API 입니다.”라는 문자열이 뜸

이 한 번의 경험이 굉장히 중요하다.

“아, 컨트롤러 전에 시큐리티가 끼어들어서
인증 여부에 따라 컨트롤러 진입이 막히기도, 허용되기도 하는구나.”

이 감각이 잡혀야 다음 단계(커스텀 로그인, JWT 인증 등)가 덜 헷갈린다.


7. 그럼 이때 내부에서 진짜 어떤 일이 벌어지는지, 흐름을 조금 더 깊게 보자

조금 더 “밀도 있게” 들여다보자.
GET /private/hello 를 처음 호출했을 때의 흐름을 따라가 보면:

  1. 브라우저 → 서버에 GET /private/hello 요청
  2. 스프링 MVC로 가기 전에, SecurityFilterChain에 있는 필터들이 차례로 실행
  3. 그 중 인가(Authorization) 관련 필터가 다음을 판단한다.
    • 이 URL(/private/hello)은 인증이 필요한 URL인가?
    • 현재 SecurityContextHolder에 인증 정보가 있는가?
  4. 현재는 아직 로그인 안 한 상태 → SecurityContextHolder 비어 있음
  5. 인증이 필요한데 인증 정보가 없음 → 스프링 시큐리티가 직접 대응
    • 예외를 던지고
    • ExceptionTranslationFilter가 이 예외를 가로챈 다음
    • AuthenticationEntryPoint를 통해 로그인 페이지로 리다이렉트 시킨다
  6. 브라우저는 /login 페이지를 띄운다.

여기까지가 “첫 번째 요청”.

로그인 폼 제출 후 흐름

  1. 사용자가 /login에 POST로 username/password 전송
  2. UsernamePasswordAuthenticationFilter가 이 요청을 가로챈다.
  3. 이 필터는 내부에서 AuthenticationManager를 호출하고,
    그 안에서 AuthenticationProvider → UserDetailsService → DB 조회 흐름이 돌아간다.
    (지금은 기본 인메모리 유저이기 때문에 우리가 구현하지 않아도 된다)
  4. 인증 성공 시:
    • Authentication 객체가 만들어지고
    • SecurityContextHolder에 저장된다
    • 이 정보는 보통 HTTP 세션에 함께 저장된다
  5. 로그인 성공 후, 원래 가려던 URL(/private/hello)로 다시 리다이렉트
  6. 두 번째 요청에서:
    • 같은 /private/hello를 호출하지만
    • 이번엔 SecurityContextHolder에 인증 정보가 이미 들어 있기 때문에
    • “인증된 사용자”로 판단하고 컨트롤러 실행을 허용한다

지금은 이 내부 구성요소(UsernamePasswordAuthenticationFilter, AuthenticationManager, UserDetailsService 등)를
“아, 저런 것들이 어딘가에서 돌아가고 있구나” 정도만 느낌으로 잡아두면 된다.

다음 편에서 하나씩 꺼내서 직접 구현해 볼 거라 굳이 지금 완벽히 이해하려고 압박받을 필요 없다.


8. 주니어가 여기서 많이 던지는 질문들, 미리 정리

Q1. 기본 계정 user / 콘솔 비밀번호, 이거 실제 서비스에서도 쓰나요?

절대 안 된다.
이건 어디까지나 “학습용 임시 계정”일 뿐이다.

실서비스에서는:

  • 우리가 직접 User 엔티티를 만들고
  • 비밀번호를 BCrypt로 암호화해서 저장하고
  • UserDetailsService를 구현해서 DB에서 유저를 조회해야 한다

이건 2편에서 낱낱이 뜯는다.


Q2. 왜 CSRF를 disable 했나요? 보안 위험 아닌가요?

정확히 말하면:

  • 폼 로그인 + 세션 기반 웹 앱에서는
    CSRF를 켜고 CSRF 토큰을 같이 써야 한다.
  • REST API + JWT 기반 구조에서는
    CSRF 공격 벡터가 다르고, 대부분 CSRF를 끄고 대신 다른 방식(토큰, SameSite, CORS 등)으로 방어한다.

지금 우리는 “구조 이해 + 최소 예제”가 목표라서
일단 꺼두고, 나중에 필요한 시점에 다시 켜는 게 좋다.


Q3. WebSecurityConfigurerAdapter 쓰면 안 되나요?

스프링 부트 3.x / 스프링 시큐리티 최신 버전에서는
이미 deprecated → 제거 수순이라, 새 프로젝트에선 아예 안 쓰는 게 맞다.

  • 예전 스타일:
  • public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { ... } }
  • 요즘 스타일:
  • @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { ... }

이 글에서는 전부 “요즘 스타일”만 쓴다.


Q4. formLogin 말고, JSON 로그인이나 JWT 로그인은 언제 배우나요?

솔직히 이게 실무에서 더 많이 쓰인다.
근데 구조부터 안 잡고 JWT로 바로 들어가면,
“왜 이게 돌아가는지” 모른 채로 복붙만 하게 된다.

그래서 순서대로 갈 거다.

  1. 기본 formLogin으로 로그인 흐름 이해
  2. UserDetailsService, PasswordEncoder, AuthenticationManager 직접 구현
  3. 그 다음에야 JSON 로그인, JWT 토큰 기반으로 넘어간다

9. 이번 글에서 꼭 가져가야 할 핵심 정리

  1. 스프링 시큐리티는 “컨트롤러 앞에서 모두를 검사하는 필터 레이어”다.
  2. URL 별로 인증/인가 규칙은 authorizeHttpRequests로 설정한다.
  3. SecurityFilterChain을 Bean으로 등록하는 게 지금 기준의 정석이다.
  4. /public/** → permitAll(), 그 외 → authenticated()
    이 조합만으로도 꽤 많은 걸 할 수 있다.
  5. 기본 로그인 폼은 “구조를 이해하기 위한 학습용 도구”라고 생각하면 된다.

10. 다음 글에서 할 것 (예고)

2편에서는 드디어 진짜 “우리 서비스 유저”를 기준으로 로그인 흐름을 만들 거다.

  • User 엔티티 설계 (테이블 구조)
  • 비밀번호 BCrypt 암호화
  • UserDetails, UserDetailsService 직접 구현
  • AuthenticationManager가 이걸 어떻게 호출하는지 흐름 잡기
  • “로그인 요청 → DB 조회 → 인증 성공 → 세션/컨텍스트 저장”까지 전 과정 따라가기

여기까지 오면, 시큐리티의 절반은 이해했다고 봐도 된다.


참고(출처)

  • Spring Security Reference (공식 문서)
  • Spring Boot Reference – Spring Security 섹션
  • 예전에 내가 운영하던 사내 백엔드 교육 자료 + 실서비스 코드 구조 경험 기반

 

스프링시큐리티, springsecurity, springboot, 백엔드개발, 주니어개발자, 인증인가, securityfilterchain, formlogin, 웹보안, 개발블로그

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/12   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
글 보관함
반응형