DEV&OPS/Java

Spring boot MVC Spring Security 로그인 구현 예제

ALEPH.GEM 2026. 7. 17. 09:18
728x90

이 예제는 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 권한 검사와 서비스에서 메서드 권한 검사를 함께 사용하는 것이 안전합니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

728x90