본문 바로가기

개발/Spring Boot

[Spring Boot] Spring Security

실습 목표 : Spring Security가 "사용자가 누구인지 확인하고(인증, Authentication), 어떤 요청을 허용할지 결정하는(인가, Authorization)" 구조를 직접 경험해보기

 

이번 실습에서는 회원가입한 사용자를 MySQL에 저장하고, 임시로 HTTP Basic 인증을 이용해 /todos를 보호할 것이다.

 

실습 구조

Client
  │
  │ email + password
  ▼
Spring Security
  │
  ├── 인증 실패 → 401
  │
  └── 인증 성공
        ↓
   Controller
        ↓
     Service
        ↓
       JPA
        ↓
      MySQL

 

입문 학습 단계이므로 API 접근 정책은 단순하게 잡는다.

API 접근
POST /auth/signup 누구나
/todos/** 로그인한 사용자만

 

Step 1. Spring Security 의존성 추가

build.gradle을 열어서 dependencies에 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-security'

 

그리고

./gradlew build

를 실행한다.

 

Spring Security 의존성이 정상적으로 추가됐다면 관련 라이브러리를 다운로드한 뒤, 컴파일러와 자바 가상 머신(JVM)이 그 파일들을 읽을 수 있도록 경로 목록에 넘겨준다. 여기서 "경로 목록"을 클래스패스(Classpath)라고 한다.

 

Spring Security가 클래스패스에 들어오면 Spring Boot 웹 애플리케이션은 기본적으로 보호된다. 그래서 아무 설정 없이 서버를 실행하면 기존 /todos API에도 인증이 요구되는 것을 볼 수 있다.

 

Step 2. 사용자 Entity 만들기

새로운 패키지를 만든다.

com.example.todolist
│
├── todo
│   └── ...
│
├── user
│   ├── AppUser.java
│   └── AppUserRepository.java
│
└── auth

 

user/AppUser.java

package com.example.todolist.user;

import jakarta.persistence.*;

@Entity
@Table(name = "app_users")
public class AppUser {

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

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

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String role;

    protected AppUser() {
    }

    public AppUser(
            String email,
            String password,
            String role) {
        this.email = email;
        this.password = password;
        this.role = role;
    }

    public Long getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    public String getPassword() {
        return password;
    }

    public String getRole() {
        return role;
    }
}

 

테이블 구조는 대략 이렇게 된다.

app_users

+----+------------------+---------------+------+
| id | email            | password      | role |
+----+------------------+---------------+------+
| 1  | test@example.com | $2a$10$...    | USER |
+----+------------------+---------------+------+

여기서 role은 나중에 인가(Authorization)에 사용된다.

 

USER
ADMIN

와 같은 역할이다.

 

지금은 모두 USER로 저장할 것이다.

 

Step 3. UserRepository 만들기

user/AppUserRepository.java

package com.example.todolist.user;

import java.util.Optional;

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

public interface AppUserRepository
        extends JpaRepository<AppUser, Long> {

    Optional<AppUser> findByEmail(String email);
}

 

여기서 새로운 JPA 기능 하나가 등장한다.

findByEmail(String email)

 

개발자가 직접 구현하지 않아도 Spring Data JPA가 메서드 이름을 보고

SELECT *
FROM app_users
WHERE email = ?

와 같은 쿼리를 만들어준다.

 

지금은 JPA를 통해 

Repository 메서드 이름으로 간단한 조회 조건을 만들 수도 있다.


정도로만 기억하면 된다.

 

Step 4. 비밀번호를 평문으로 저장하면 안 된다

예를 들어 사용자가

password1234

라는 비밀번호를 사용한다고 할 때

 

DB에 다음과 같이 저장하면 안 된다.

email             password
test@example.com  password1234

DB가 유출되면 비밀번호도 그대로 노출되기 때문이다.

Spring Security에서는 PasswordEncoder를 제공한다. PasswordEncoder는 비밀번호를 일방향 변환해서 저장하고, 로그인 시 입력한 비밀번호가 저장된 값과 일치하는지 검사한다.

실습에서는 BCrypt를 사용할 것이다.

 

Step 5. SecurityConfig 만들기

새 패키지를 만든다.

com.example.todolist
│
...
│
└──config
   └── SecurityConfig.java

 

config/SecurityConfig.java

package com.example.todolist.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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

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

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

        http
                .csrf(csrf -> csrf.disable())

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

                .httpBasic(Customizer.withDefaults());

        return http.build();
    }
}

 

이제 코드를 살펴보자.

 

SecurityFilterChain 이해하기

다음 부분을 보자.

.requestMatchers("/auth/**").permitAll()

의미는

/auth/** → 로그인하지 않아도 접근 가능

이다.

 

그리고

.requestMatchers("/todos/**").authenticated()

의미는

/todos/** → 인증된 사용자만 접근 가능

이다.

 

따라서

POST /auth/signup
→ 누구나 가능

GET /todos
→ 로그인 필요

POST /todos
→ 로그인 필요

DELETE /todos/1
→ 로그인 필요

가 된다.

 

Spring Security의 requestMatchers는 요청 URL에 따라 서로 다른 인가 규칙을 적용하는 데 사용한다.

 

Step 6. 인증(Authentication)과 인가(Authorization) 구분하기

Authentication(인증) 은

너 누구야?

를 확인하는 것이다.

 

예를 들어

email = test@example.com
password = password1234

↓

이 사용자가 실제로 존재하는가?
비밀번호가 맞는가?

이다.

 

반면

 

Authorization(인가) 는

이 사용자가 이 기능을 사용해도 돼?

이다.

 

예를 들어

로그인 안 함
→ /todos 접근 불가

로그인함
→ /todos 접근 가능

이다.

 

쉽게 말하자면

Authentication
= 신원 확인

Authorization
= 권한 확인

이다.

 

Step 7. 회원가입 DTO 만들기

이제 실제로 사용자를 DB에 저장해보자.

auth
├── SignupRequest.java
├── SignupResponse.java
├── AuthService.java
└── AuthController.java

 

auth/SignupRequest.java

package com.example.todolist.auth;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record SignupRequest(

        @NotBlank
        @Email
        String email,

        @NotBlank
        @Size(min = 8)
        String password

) {
}

이전 실습에서 배운 Validation을 그대로 사용했다.

 

Step 8. 회원가입 응답 DTO 만들기

auth/SignupResponse.java

package com.example.todolist.auth;

import com.example.todolist.user.AppUser;

public record SignupResponse(
        Long id,
        String email,
        String role) {

    public static SignupResponse from(AppUser user) {
        return new SignupResponse(
                user.getId(),
                user.getEmail(),
                user.getRole());
    }
}

중요한 점이 있다. 

 

응답에 password가 없다.

비밀번호는 API 응답으로 돌려주면 안 된다.

 

Step 9. AuthService 만들기

auth/AuthService.java

package com.example.todolist.auth;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.todolist.user.AppUser;
import com.example.todolist.user.AppUserRepository;

@Service
public class AuthService {

    private final AppUserRepository appUserRepository;
    private final PasswordEncoder passwordEncoder;

    public AuthService(
            AppUserRepository appUserRepository,
            PasswordEncoder passwordEncoder) {
        this.appUserRepository = appUserRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @Transactional
    public SignupResponse signup(SignupRequest request) {

        String encodedPassword = passwordEncoder.encode(request.password());

        AppUser user = new AppUser(
                request.email(),
                encodedPassword,
                "USER");

        AppUser savedUser = appUserRepository.save(user);

        return SignupResponse.from(savedUser);
    }
}

 

여기에서도 이전 실습에서 배웠던 DI가 등장한다.

private final PasswordEncoder passwordEncoder;

public AuthService(
        ...,
        PasswordEncoder passwordEncoder) {
    ...
    this.passwordEncoder = passwordEncoder;
}

 

그리고 Spring이

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

로 만든 객체를 넣어준다.

 

Step 10. 비밀번호 암호화 흐름

회원가입 요청

{
  "email": "test@example.com",
  "password": "password1234"
}

이 오면

 

Service에서

passwordEncoder.encode(request.password());

를 실행한다.

 

그러면 비밀번호가

password1234

      ↓ BCrypt

$2a$10$...

처럼 바뀐다.

 

DB에는

$2a$10$...

만 저장된다.

 

여기서 표현을 정확히 해두자면

비밀번호를 암호화(encryption) 한다기보다 해시(hash) 한다고 표현하는 것이 정확하다.


암호화는 일반적으로 복호화가 가능하지만, 비밀번호 해시는 원래 비밀번호를 다시 얻는 용도가 아니다.

 

Step 11. AuthController

auth/AuthController.java

package com.example.todolist.auth;

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 SignupResponse signup(
            @Valid @RequestBody SignupRequest request) {
        return authService.signup(request);
    }
}

이제

POST /auth/signup

이 만들어졌다.

 

Step 12. Spring Security가 사용자를 찾게 만들기

아직 부족한 점이 하나 있다.

 

예를 들어 사용자가

test@example.com
password1234

로 인증을 시도했을 때 Spring Security는

test@example.com이라는 사용자를 어디서 찾지?

 

라는 문제가 생긴다.

 

이를 알려주는 것이 UserDetailsService이다.

user 패키지에 다음 파일을 하나 만든다.

 

user/CustomUserDetailsService.java

package com.example.todolist.user;

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService
        implements UserDetailsService {

    private final AppUserRepository appUserRepository;

    public CustomUserDetailsService(
            AppUserRepository appUserRepository) {
        this.appUserRepository = appUserRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) {

        AppUser user = appUserRepository
                .findByEmail(email)
                .orElseThrow(
                        () -> new UsernameNotFoundException(
                                "사용자를 찾을 수 없습니다."));

        return User.builder()
                .username(user.getEmail())
                .password(user.getPassword())
                .roles(user.getRole())
                .build();
    }
}

여기서 아주 중요한 연결이 생겼다.

 

Spring Security

"test@example.com 찾아줘"

        ↓

CustomUserDetailsService

        ↓

AppUserRepository

        ↓

MySQL

        ↓

AppUser

        ↓

UserDetails

        ↓

Spring Security

UserDetailsService는 Spring Security의 username/password 인증에서 사용자 정보를 가져오는 핵심 인터페이스이다.

 

Step 13. 현재 애플리케이션 전체 구조 보기

                   HTTP Request
                        │
                        ▼
              ┌──────────────────┐
              │ Spring Security  │
              └────────┬─────────┘
                       │
              인증이 필요한가?
                  ┌────┴────┐
                  │         │
                 NO        YES
                  │         │
                  │         ▼
                  │  UserDetailsService
                  │         │
                  │         ▼
                  │  AppUserRepository
                  │         │
                  │         ▼
                  │       MySQL
                  │
                  ▼
              Controller
                  │
                  ▼
               Service
                  │
                  ▼
              Repository
                  │
                  ▼
                MySQL

 

여기서 

 

Spring Security가 Controller 앞쪽에 있다는 게 핵심이다.

 

Step 14. 서버 실행

./gradlew BootRun

 

그리고

 

MySQL에서

USE spring_study;

SHOW TABLES;

확인해보면

새로 app_users 테이블이 생성되어

 

이전에 생성한 테이블과 같이 총 두 테이블이 나타나는 것을 볼 수 있다.

 

Step 15. 회원가입 해보기

터미널에서

curl -i -X POST http://localhost:8080/auth/signup \
  -H "Content-Type: application/json" \
  -d '{
    "email":"test@example.com",
    "password":"password1234"
  }'

실행하면

 

응답이

 

Step 16. DB에서 회원가입한 사용자 확인해보기

MySQL에서 회원가입한 사용자를 확인해보자.

 

흐름은

사용자 입력
password1234

    ↓

PasswordEncoder

    ↓

$2a$10$...

    ↓

MySQL

이다.

 

Step 17. 로그인하지 않고 Todo 조회해보기

터미널에서

curl -i http://localhost:8080/todos

를 실행하면

HTTP/1.1 401 Unauthorized

가 출력되는 것을 볼 수 있을 것이다.

 

이유는 SecurityConfig에

.requestMatchers("/todos/**").authenticated()

가 있기 때문이다.

 

흐름은

GET /todos

    ↓

Spring Security

    ↓

인증 정보 없음

    ↓

401 Unauthorized

으로 Controller까지 도달하지 못한다. 이 부분이 중요하다.

 

Step 18. 인증해서 접근해보기

이번 실습에서는 JWT를 배우기 전에 인증 원리를 확인하기 위해 임시로 HTTP Basic을 잠깐 사용한다.

 

터미널에서

curl -i \
  -u "test@example.com:password1234" \
  http://localhost:8080/todos

curl -u가 내부적으로 사용자 이름과 비밀번호를 HTTP Authorization 헤더에 넣어준다.

 

정상적으로 인증되면

HTTP/1.1 200

와 함께 Todo목록이 반환된다.

 

틀린 비밀번호로 테스트해보면

curl -i \
  -u "test@example.com:wrongpassword" \
  http://localhost:8080/todos

 

HTTP/1.1 401 Unauthorized

가 나오는 것을 볼 수 있다.

 

개념적으로 내부에선

입력 password
      │
      ▼
PasswordEncoder.matches()
      │
      ├── 일치 → 인증 성공
      │
      └── 불일치 → 인증 실패

이 일어난다.

 

PasswordEncoder는 encode()뿐 아니라 입력 비밀번호와 저장된 해시를 비교하는 matches()도 제공한다.

 

 

이번 실습에서 가장 중요한 것

코드보다 이 흐름을 이해하는 것이 훨씬 더 중요하다.

GET /todos
     │
     ▼
SecurityFilterChain
     │
     │ authenticated()
     ▼
사용자 인증 필요
     │
     ▼
UserDetailsService
     │
     ▼
AppUserRepository
     │
     ▼
MySQL에서 사용자 조회
     │
     ▼
PasswordEncoder로
비밀번호 확인
     │
  ┌──┴──────────┐
  │             │
 성공           실패
  │             │
  ▼             ▼
Controller     401

 

개념 의미
SecurityFilterChain 어떤 요청을 허용할지 설정
UserDetailsService 사용자를 DB 등에서 조회
PasswordEncoder 비밀번호 해시/검증
Authentication 사용자가 누구인지 확인