Spring boot MVC Spring Security 로그인 구현 예제
이 예제는 Spring Boot 3.5.15 + Java 17 + Spring Security 6 + PostgreSQL + Spring Data JPA + Gradle + Thymeleaf 조합으로 구성합니다.
Spring Boot 공식 문서에는 현재 3.5.15가 안정 버전 계열로 제공되고 있으며, Spring Security의 DB 기반 폼 로그인은 UserDetailsService, PasswordEncoder, DaoAuthenticationProvider를 중심으로 동작합니다.
완성되는 기능은 다음과 같습니다.
- 회원가입
- BCrypt 비밀번호 암호화
- PostgreSQL 사용자 저장
- 아이디와 비밀번호 로그인
- 사용자·관리자 권한 구분
- 로그인 성공 후 메인 페이지 이동
- 로그인 실패 메시지
- 로그아웃
- CSRF 보호
- 관리자 전용 페이지
1. 프로젝트 구조
security-example
├── build.gradle
├── settings.gradle
└── src
└── main
├── java
│ └── com/example/securityexample
│ ├── SecurityExampleApplication.java
│ ├── config
│ │ ├── SecurityConfig.java
│ │ └── DataInitializer.java
│ ├── controller
│ │ ├── HomeController.java
│ │ └── MemberController.java
│ ├── domain
│ │ ├── Member.java
│ │ └── MemberRole.java
│ ├── dto
│ │ └── MemberSignupRequest.java
│ ├── repository
│ │ └── MemberRepository.java
│ ├── security
│ │ ├── CustomUserDetails.java
│ │ └── CustomUserDetailsService.java
│ └── service
│ └── MemberService.java
└── resources
├── application.yml
├── static
│ └── css
│ └── style.css
└── templates
├── home.html
├── login.html
├── signup.html
├── admin.html
└── access-denied.html
2. PostgreSQL 데이터베이스 생성
PostgreSQL에 접속한 다음 데이터베이스와 사용자를 생성합니다.
CREATE DATABASE security_db;
CREATE USER security_user
WITH PASSWORD '1234';
GRANT ALL PRIVILEGES
ON DATABASE security_db
TO security_user;
PostgreSQL 15 이상에서 스키마 권한도 필요할 수 있습니다.
GRANT ALL ON SCHEMA public TO security_user;
ALTER SCHEMA public OWNER TO security_user;
JDBC URL: PostgreSQL JDBC 드라이버는 jdbc:postgresql://host:port/database 형태의 연결 URL을 지원합니다.
jdbc:postgresql://localhost:5432/security_db
3. Gradle 설정
settings.gradle
rootProject.name = 'security-example'
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.15'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
// Spring MVC
implementation 'org.springframework.boot:spring-boot-starter-web'
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// Spring Data JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
// Thymeleaf에서 Spring Security 정보 사용
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
// DTO 입력값 검증
implementation 'org.springframework.boot:spring-boot-starter-validation'
// PostgreSQL JDBC Driver
runtimeOnly 'org.postgresql:postgresql'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// 테스트
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
4. 데이터베이스 연결 설정
application.yml
spring:
application:
name: security-example
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/security_db}
username: ${DB_USERNAME:security_user}
password: ${DB_PASSWORD:1234}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
open-in-view: false
thymeleaf:
cache: false
logging:
level:
org.hibernate.SQL: debug
org.springframework.security: info
server:
port: 8080
환경변수가 없으면 다음 기본값이 사용됩니다.
DB_URL=jdbc:postgresql://localhost:5432/security_db
DB_USERNAME=security_user
DB_PASSWORD=1234
이 예제는 개발 단계에서만 사용하고 운영환경에서는 아래 처럼 설정합니다.
spring:
jpa:
hibernate:
ddl-auto: validate
5. 애플리케이션을 실행하는 클래스
SecurityExampleAppication.java는 애플리케이션을 실행하는 클래스 입니다.
package com.example.securityexample;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SecurityExampleApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityExampleApplication.class, args);
}
}
@SpringBootApplication이 붙은 클래스의 하위 패키지는 기본 컴포넌트 스캔 대상이 됩니다.
따라서 다른 클래스들은 다음 패키지 아래에 배치합니다.
com.example.securityexample
6. 회원 권한 Enum 작성
MemberRole.java 에 회원들이 사용할 수 있는 권한을 Enum으로 열거해줍니다.
package com.example.securityexample.domain;
public enum MemberRole {
USER,
ADMIN
}
Spring Security에서는 일반적으로 권한 이름 앞에 ROLE_ 접두사를 붙입니다.
따라서 DB에는 위와 같이 실제 권한 이름으로 저장합니다.
나중에 CustomUserDetails에서 권한 이름들이 다음 형태로 변환합니다.
ROLE_USER
ROLE_ADMIN
7. 회원 Entity
Member.java 에서 JPA에서 사용할 Entity를 작성해줍니다.
package com.example.securityexample.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity //JPA가 관리하는 엔티티 임을 명시합니다.
@Table(
name = "members",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_members_username",
columnNames = "username"
),
@UniqueConstraint(
name = "uk_members_email",
columnNames = "email"
)
}
)
public class Member {
//Primary key로 지정하고 id 값을 자동 증가 시킵니다.(auto increase)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String username;
//여기에는 평문 비밀번호가 아니라 BCrypt(PasswordEncoder)가 암호화된 문자열이 들어갑니다.
@Column(nullable = false)
private String password;
@Column(nullable = false, length = 100)
private String email;
@Column(nullable = false, length = 50)
private String name;
//EnumType.STRING을 사용하면 DB에 USER, ADMIN 문자열이 저장됩니다.
//EnumType.ORDINAL을 사용하면 0, 1 처럼 숫자로 지정되어 Enum이 순서가 변경될 때 문제가 생길 수 있습니다.
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private MemberRole role;
@Column(nullable = false)
private boolean enabled;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
private Member(
String username,
String password,
String email,
String name,
MemberRole role
) {
this.username = username;
this.password = password;
this.email = email;
this.name = name;
this.role = role;
this.enabled = true;
this.createdAt = LocalDateTime.now();
}
public static Member createUser(
String username,
String encodedPassword,
String email,
String name
) {
return new Member(
username,
encodedPassword,
email,
name,
MemberRole.USER
);
}
public static Member createAdmin(
String username,
String encodedPassword,
String email,
String name
) {
return new Member(
username,
encodedPassword,
email,
name,
MemberRole.ADMIN
);
}
public void changePassword(String encodedPassword) {
this.password = encodedPassword;
}
public void deactivate() {
this.enabled = false;
}
}
8. Repository 작성
MemberRepository.java 를 작성합니다.
JPA는 Repository 인터페이스를 통해서 데이터(DB)에 접근합니다. (DAO 역할과 비슷)
package com.example.securityexample.repository;
import com.example.securityexample.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
메서드 이름으로 쿼리를 생성할 수 있어서 findByUsername 이름으로 메서드를 만들면 사용자 이름 조회 쿼리로 해석해서 JPA가 자동으로 만들어서 실행합니다.
9. 회원 가입용 DTO 작성
MemberSignupRequest.java 를 작성합니다.
Entity를 회원 가입 폼에 직접 사용하지 않고 클라이언트(뷰)와 데이터를 주고받을 때는 DTO를 사용합니다.
Entity와 화면 입력 구조를 분리하면 장점이 많습니다.
- 악의 적인 사용자가 role을 임의로 ADMIN으로 변경하는 것을 방지합니다.
- 비밀번호 확인 필드처럼 DB에 저장하지 않는 값을 처리하고 입력값 검증을 관리할 수 있습니다.
package com.example.securityexample.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class MemberSignupRequest {
@NotBlank(message = "아이디를 입력해주세요.")
@Size(min = 4, max = 20, message = "아이디는 4자 이상 20자 이하로 입력해주세요.")
@Pattern(
regexp = "^[a-zA-Z0-9_]+$",
message = "아이디는 영문, 숫자, 밑줄만 사용할 수 있습니다."
)
private String username;
@NotBlank(message = "비밀번호를 입력해주세요.")
@Size(min = 8, max = 50, message = "비밀번호는 8자 이상 입력해주세요.")
private String password;
@NotBlank(message = "비밀번호 확인을 입력해주세요.")
private String passwordConfirm;
@NotBlank(message = "이메일을 입력해주세요.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
private String email;
@NotBlank(message = "이름을 입력해주세요.")
@Size(max = 50, message = "이름은 50자 이하로 입력해주세요.")
private String name;
}
10. 서비스 클래스 작성
MemberService.java 에서 비즈니스 로직을 처리합니다.
package com.example.securityexample.service;
import com.example.securityexample.domain.Member;
import com.example.securityexample.dto.MemberSignupRequest;
import com.example.securityexample.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public Long signup(MemberSignupRequest request) {
validateSignupRequest(request);
String encodedPassword =
passwordEncoder.encode(request.getPassword());
Member member = Member.createUser(
request.getUsername(),
encodedPassword,
request.getEmail(),
request.getName()
);
return memberRepository.save(member).getId();
}
private void validateSignupRequest(MemberSignupRequest request) {
if (!request.getPassword()
.equals(request.getPasswordConfirm())) {
throw new IllegalArgumentException(
"비밀번호와 비밀번호 확인이 일치하지 않습니다."
);
}
if (memberRepository.existsByUsername(request.getUsername())) {
throw new IllegalArgumentException(
"이미 사용 중인 아이디입니다."
);
}
if (memberRepository.existsByEmail(request.getEmail())) {
throw new IllegalArgumentException(
"이미 사용 중인 이메일입니다."
);
}
}
}
로그인 할 때 비밀번호는 단방향 암호화인 해시를 사용하기 때문에 복호화 하지 않습니다.
암호문으로부터 평문을 유추할 수 없도록 하기 위해서 입니다.
11. Spring Security 사용자 객체 작성
Spring Security는 Member 엔티티를 직접 이해하지 못하므로 UserDetails 구현체로 변환해야 합니다.
CustomUserDetails.java 를 작성합니다.
package com.example.securityexample.security;
import com.example.securityexample.domain.Member;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
public class CustomUserDetails implements UserDetails {
private final Long memberId;
private final String username;
private final String password;
private final String name;
private final String email;
private final boolean enabled;
private final Collection<? extends GrantedAuthority> authorities;
private CustomUserDetails(
Long memberId,
String username,
String password,
String name,
String email,
boolean enabled,
Collection<? extends GrantedAuthority> authorities
) {
this.memberId = memberId;
this.username = username;
this.password = password;
this.name = name;
this.email = email;
this.enabled = enabled;
this.authorities = authorities;
}
//USER의 권한은 접두사 ROLE_을 붙여서 변환됩니다.
public static CustomUserDetails from(Member member) {
List<GrantedAuthority> authorities = List.of(
new SimpleGrantedAuthority(
"ROLE_" + member.getRole().name()
)
);
return new CustomUserDetails(
member.getId(),
member.getUsername(),
member.getPassword(),
member.getName(),
member.getEmail(),
member.isEnabled(),
authorities
);
}
public Long getMemberId() {
return memberId;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
12. UserDetailsService
CustomUserDetailsService.java를 작성합니다.
package com.example.securityexample.security;
import com.example.securityexample.domain.Member;
import com.example.securityexample.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
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;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
Member member = memberRepository.findByUsername(username)
.orElseThrow(() ->
new UsernameNotFoundException(
"사용자를 찾을 수 없습니다: " + username
)
);
return CustomUserDetails.from(member);
}
}
로그인 시 Spring Security는 다음 과정을 수행합니다.
1. 로그인 폼 submit
2. UsernamePasswordAuthenticationFilter
3. AuthenticationManager
4. DaoAuthenticationProvider
5. CustomUserDetailsService.loadUserByUsername()
6. MemberRepository.findByUsername()
7. PasswordEncoder로 비밀번호 비교
8. 인증 성공 또는 실
DaoAuthenticationProvider는 UserDetailsService로 사용자를 조회하고 PasswordEncoder로 비밀번호를 확인하는 인증 구현체입니다.
13. Spring Security 설정
SecurityConfig.java 에서 설정을 해줍니다.
package com.example.securityexample.config;
import com.example.securityexample.security.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider authenticationProvider(
PasswordEncoder passwordEncoder
) {
DaoAuthenticationProvider provider =
new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
DaoAuthenticationProvider authenticationProvider
) throws Exception {
http
.authenticationProvider(authenticationProvider)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/",
"/login",
"/signup",
"/css/**",
"/images/**",
"/js/**",
"/error"
).permitAll()
.requestMatchers("/admin/**")
.hasRole("ADMIN") //hasRole은 내부적으로 접두어 "ROLE_"을 붙이기 때문에 ADMIN만 입력합니다.
.requestMatchers("/member/**")
.authenticated()
.anyRequest()
.authenticated()
)
.formLogin(form -> form
.loginPage("/login") //사용자에게 보여줄 로그인 화면 URI입니다.
.loginProcessingUrl("/login") //로그인화면에서 POST로 요청이 왔을 때 처리하는 주소입니다. 이요청은 컨트롤러가 처리하지 않고 SpringSecurity가 필터 처리합니다.
.usernameParameter("username") //html의 input 태그의 name속성으로 지정된 값과 동일해야 합니다.
.passwordParameter("password") //html의 input 태그의 name속성으로 지정된 값과 동일해야 합니다.
.defaultSuccessUrl("/home", true)
.failureUrl("/login?error")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout") //로그아웃 처리
.logoutSuccessUrl("/login?logout") //로그인 페이지로 이동
.invalidateHttpSession(true) //세션 만료
.clearAuthentication(true) //권한 제거
.deleteCookies("JSESSIONID") //쿠키 제거
.permitAll()
)
.exceptionHandling(exception -> exception
.accessDeniedPage("/access-denied")
);
return http.build();
}
}
최신 Spring Security에서는 WebSecurityConfigurerAdapter를 상속하지 않고
SecurityFilterChan을 Bean으로 등록하는 방식으로 설정합니다.
URL 접근 권한은 SecurityFilterChain 에서 설정하는데 각 설정의 의미는 다음과 같습니다.
| permitAll() | 로그인하지 않아도 접근 가능 |
| authenticated() | 로그인한 사용자만 접근 가능 |
| hasRole("ADMIN") | ROLE_ADMIN 권한만 접근 가능 |
| anyRequest() | 앞에서 정의하지 않은 모든 요청 |
.hasRole("ADMIN") 대신 .hasAuthority("ROLE_ADMIN") 을 사용해도 됩니다.
접두어 "ROLE_"을 붙여주느냐 아니냐의 차이입니다.
14. 로그인용 컨트롤러 작성
MemberController.java 를 작성합니다.
package com.example.securityexample.controller;
import com.example.securityexample.dto.MemberSignupRequest;
import com.example.securityexample.service.MemberService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@GetMapping("/login")
public String loginPage() {
return "login";
}
@GetMapping("/signup")
public String signupPage(Model model) {
model.addAttribute(
"signupRequest",
new MemberSignupRequest()
);
return "signup";
}
@PostMapping("/signup")
public String signup(
@Valid
@ModelAttribute("signupRequest")
MemberSignupRequest request,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
return "signup";
}
try {
memberService.signup(request);
} catch (IllegalArgumentException e) {
model.addAttribute(
"signupError",
e.getMessage()
);
return "signup";
}
return "redirect:/login?signup";
}
}
BindingResult는 위 예제처럼 반드시 검증 대상 DTO 바로 다음에 위치해야합니다.
안그러면 검증 오류를 정상적으로 받지 못할 수 있습니다.
15. 메인 컨트롤러 작성
HomeController.java
package com.example.securityexample.controller;
import com.example.securityexample.security.CustomUserDetails;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/")
public String index() {
return "redirect:/home";
}
@GetMapping("/home")
public String home(
@AuthenticationPrincipal //현재 로그인한 사용자 객체를 주입합니다.
CustomUserDetails userDetails,
Model model
) {
model.addAttribute("member", userDetails);
return "home";
}
@GetMapping("/admin")
public String admin(
@AuthenticationPrincipal
CustomUserDetails userDetails,
Model model
) {
model.addAttribute("member", userDetails);
return "admin";
}
@GetMapping("/access-denied")
public String accessDenied() {
return "access-denied";
}
}
@AuthenticationPrincipal으로 현재 로그인한 사용자 객체를 주입하면 userDetails 에서 다음과 같이 정보를 얻어올 수 있습니다.
userDetails.getMemberId();
userDetails.getUsername();
userDetails.getName();
userDetails.getEmail();
userDetails.getAuthorities();
16. 로그인 페이지
templetes/login.html 을 작성합니다.
<!DOCTYPE html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>로그인</title>
<link rel="stylesheet"
th:href="@{/css/style.css}">
</head>
<body>
<div class="container">
<h1>로그인</h1>
<div class="message error"
th:if="${param.error}">
아이디 또는 비밀번호가 올바르지 않습니다.
</div>
<div class="message success"
th:if="${param.logout}">
로그아웃되었습니다.
</div>
<div class="message success"
th:if="${param.signup}">
회원가입이 완료되었습니다. 로그인해주세요.
</div>
<form th:action="@{/login}"
method="post">
<div class="form-group">
<label for="username">아이디</label>
<input
id="username"
name="username"
type="text"
autocomplete="username"
required
autofocus>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required>
</div>
<button type="submit">
로그인
</button>
</form>
<p>
계정이 없습니까?
<a th:href="@{/signup}">회원가입</a>
</p>
</div>
</body>
</html>
form 태그에서 th:action을 사용하면 Thymeleaf가 Spring Security의 CSRF 토큰을 폼에 자동으로 추가해줍니다.
브라우저에서 관리자 도구로 HTML을 확인해보면 <input type="hidden" name="_csrf" ... > 값이 생성된 것을 알 수 있습니다.
따라서 일반적인 Thymeleaf POST 폼에서는 CSRF를 끌 필요가 없습니다.
보안 문제 때문에 CSRF 전체 비활성화는 권장하지 않습니다.
Spring Security의 기본 JAVA 설정에는 CSRF 방어, 세션 고정 공격 방지, 보안 헤더 등 보호 기능이 포함되어 있습니다.
17. 회원 가입 페이지
templates/signup.html 을 작성합니다.
<!DOCTYPE html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>회원가입</title>
<link rel="stylesheet"
th:href="@{/css/style.css}">
</head>
<body>
<div class="container">
<h1>회원가입</h1>
<div class="message error"
th:if="${signupError}"
th:text="${signupError}">
</div>
<form
th:action="@{/signup}"
th:object="${signupRequest}"
method="post">
<div class="form-group">
<label for="username">아이디</label>
<input
id="username"
type="text"
th:field="*{username}">
<div class="field-error"
th:errors="*{username}">
</div>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input
id="password"
type="password"
th:field="*{password}">
<div class="field-error"
th:errors="*{password}">
</div>
</div>
<div class="form-group">
<label for="passwordConfirm">
비밀번호 확인
</label>
<input
id="passwordConfirm"
type="password"
th:field="*{passwordConfirm}">
<div class="field-error"
th:errors="*{passwordConfirm}">
</div>
</div>
<div class="form-group">
<label for="email">이메일</label>
<input
id="email"
type="email"
th:field="*{email}">
<div class="field-error"
th:errors="*{email}">
</div>
</div>
<div class="form-group">
<label for="name">이름</label>
<input
id="name"
type="text"
th:field="*{name}">
<div class="field-error"
th:errors="*{name}">
</div>
</div>
<button type="submit">
회원가입
</button>
</form>
<p>
이미 계정이 있습니까?
<a th:href="@{/login}">로그인</a>
</p>
</div>
</body>
</html>
th:field 가 생성하는 input 태그의 이름은 DTO의 필드와 동일합니다.
즉, th:field="*{username}" 은 다음과 같이 랜더링 됩니다.
<input id="username" name="username" type="text">
18. 홈 페이지
templates/home.html 을 작성합니다.
<!DOCTYPE html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>홈</title>
<link rel="stylesheet"
th:href="@{/css/style.css}">
</head>
<body>
<div class="container">
<h1>로그인 성공</h1>
<p>
안녕하세요,
<strong th:text="${member.name}">
사용자
</strong>
님
</p>
<table>
<tbody>
<tr>
<th>회원 번호</th>
<td th:text="${member.memberId}"></td>
</tr>
<tr>
<th>아이디</th>
<td th:text="${member.username}"></td>
</tr>
<tr>
<th>이메일</th>
<td th:text="${member.email}"></td>
</tr>
<tr>
<th>권한</th>
<td th:text="${member.authorities}"></td>
</tr>
</tbody>
</table>
<p sec:authorize="hasRole('ADMIN')">
<a th:href="@{/admin}">
관리자 페이지로 이동
</a>
</p>
<form th:action="@{/logout}"
method="post">
<button type="submit">
로그아웃
</button>
</form>
</div>
</body>
</html>
Thymeleaf Security 확장 기능을 사용하면 현재 인증 상태나 권한에 따라 HTML을 표시할 수 있습니다.
<p sec:authorize="hasRole('ADMIN')">
이 내용은 관리자에게만 표시됩니다.
로그아웃도 상태를 변경하는 요청이므로 POST 방식으로 작성합니다.
<form th:action="@{/logout}" method="post">
CSRF가 활성화된 상태에서는 단순 링크 방식보다 POST 폼 방식이 적합합니다.
19. 관리자 페이지
templates/admin.html 을 작성합니다.
<!DOCTYPE html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>관리자</title>
<link rel="stylesheet"
th:href="@{/css/style.css}">
</head>
<body>
<div class="container">
<h1>관리자 페이지</h1>
<p>
관리자
<strong th:text="${member.name}"></strong>
님이 접속했습니다.
</p>
<p>
이 페이지는
<code>ROLE_ADMIN</code>
권한이 있는 사용자만 접근할 수 있습니다.
</p>
<a th:href="@{/home}">
홈으로 돌아가기
</a>
</div>
</body>
</html>
/admin 접근 권한은 SpringSecurityFilterChain에 의해 제한되어 있습니다.
일반 사용자가 접근하면 /access-denied 페이지로 이동합니다.
20. 접근 거부 페이지
templates/access-denied.html 을 작성합니다.
<!DOCTYPE html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>접근 거부</title>
<link rel="stylesheet"
th:href="@{/css/style.css}">
</head>
<body>
<div class="container">
<h1>접근할 수 없습니다</h1>
<p>
해당 페이지를 사용할 권한이 없습니다.
</p>
<a th:href="@{/home}">
홈으로 돌아가기
</a>
</div>
</body>
</html>
21. css 작성
static/css/style.css 를 작성합니다.
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 40px 20px;
background: #f5f6f8;
color: #222;
font-family: Arial, sans-serif;
}
.container {
width: 100%;
max-width: 520px;
margin: 0 auto;
padding: 32px;
background: white;
border: 1px solid #ddd;
border-radius: 12px;
}
h1 {
margin-top: 0;
}
.form-group {
margin-bottom: 18px;
}
label {
display: block;
margin-bottom: 7px;
font-weight: bold;
}
input {
width: 100%;
padding: 11px 12px;
border: 1px solid #bbb;
border-radius: 6px;
font-size: 16px;
}
button {
width: 100%;
padding: 12px;
border: 0;
border-radius: 6px;
background: #222;
color: white;
font-size: 16px;
cursor: pointer;
}
button:hover {
opacity: 0.85;
}
.message {
margin-bottom: 18px;
padding: 12px;
border-radius: 6px;
}
.message.error {
background: #ffe7e7;
color: #a40000;
}
.message.success {
background: #e7f8ec;
color: #176b32;
}
.field-error {
margin-top: 5px;
color: #d00000;
font-size: 14px;
}
table {
width: 100%;
margin: 20px 0;
border-collapse: collapse;
}
th,
td {
padding: 10px;
border: 1px solid #ddd;
text-align: left;
}
th {
width: 130px;
background: #f5f5f5;
}
a {
color: #1558d6;
}
22. 테스트용 관리자 계정 생성
회원 가입으로 가입한 사용자는 항상 USER 권한을 갖습니다.
관리자 계정을 테스트하려면 초기 데이터 생성 클래스를 추가합니다.
DataInitializer.java
package com.example.securityexample.config;
import com.example.securityexample.domain.Member;
import com.example.securityexample.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
@Configuration
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
public void run(String... args) {
if (memberRepository.existsByUsername("admin")) {
return;
}
Member admin = Member.createAdmin(
"admin",
passwordEncoder.encode("admin1234"),
"admin@example.com",
"관리자"
);
memberRepository.save(admin);
}
}
애플리케이션 최초 실행시 관리자 계정 admin / admin1234 가 생성됩니다.
운영 환경에서는 관리자 비밀번호를 소스 코드에 작성해서는 안됩니다.
23. 애플리케이션 실행
리눅스 & 맥
./gradlew bootRun
윈도우:
gradlew.bat bootRun
로그인 테스트: http://localhost:8080/login
회원가입 테스트: http://localhost:8080/signup
관리자페이지 테스트: http://localhost:8080/admin
24. 로그인 처리 과정
1. 브라우저가 로그인 요청 전송(CSRF 토큰 포함)
2. Security Filter가 요청 처리( UsernamePasswordAuthenticationFilter) : Security 설정에서 지정한 이름과 HTML input의 name이 일치해야 합니다.
3. 사용자 조회: CustomUserDetailsService.loadUserByUsername(username) 가 호출되어 내부에서 JPA Repository인 memberRepository.findByUsername(username)를 사용합니다.
4. 비밀번호 비교: 사용자 입력 비밀번호와 DB에 저장된 비밀번호를 비교합니다.
passwordEncoder.matches(
rawPassword,
encodedPassword
);
5. SecurityContext와 세션 저장: 인증 성공 정보는 SecurityContext에 저장되고, 일반적인 폼 로그인에서는 세션을 통해 다음 요청에도 유지됩니다. 브라우저는 JSESSIONID라는 쿠키를 받습니다. 이후 요청에서 JSESSIONID가 전달 되므로 로그인 상태가 유지됩니다.
6. 로그인 성공 페이지로 이동
.defaultSuccessUrl("/home", true)
true는 이전에 접근하려던 주소가 있더라도 항상 /home으로 이동하는 옵션입니다.
false로 바꾸면 이전 요청 URL으로 이동합니다.
★ 메서드 단위로 권한 검사
@EnableMethodSecurity 를 SecurityConfig에 설정하면 메서드 별로 권한 검사를 사용할 수 있습니다.
예를들어 관리자 서비스에서 메서드를 제한할 수 있습니다.
package com.example.securityexample.service;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class AdminService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteMember(Long memberId) {
// 관리자만 실행 가능
}
}
URL 권한 검사와 서비스에서 메서드 권한 검사를 함께 사용하는 것이 안전합니다.
