실습 목표 : 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 | 사용자가 누구인지 확인 |
'개발 > Spring Boot' 카테고리의 다른 글
| [Spring Boot] Docker로 Spring Boot 프로젝트 배포하기 (0) | 2026.10.11 |
|---|---|
| [Spring Boot] JWT(JSON Web Token) (0) | 2026.10.08 |
| [Spring Boot] CRUD + DTO + Validation + 예외 처리 (0) | 2026.10.06 |
| [Spring Boot] Service + DI + JPA + MySQL (0) | 2026.10.03 |
| [Spring Boot] Spring Boot + MySQL 실습 환경 세팅 (0) | 2026.10.02 |