본문 바로가기

(11.05) Spring Boot - Spring Security 기능, 인터페이스, JPA와 Security 활용 회원가입 간단 구현

@starweb2025. 11. 5. 19:08

[ 12주차 - 1105 ]

    금일 커리큘럼
        ├ 09:00 ~ 12:00 backend 프로그래밍 (Spring Security, rememberMe, inMemoryUserDetailsManager)
        └ 13:00 ~ 18:00 backend 프로그래밍 (Spring Security 인터페이스 정리, JPA 활용 security 회원가입 심플 구현)

1. rememberMe 기능

JSESSIONID이 만료되거나 쿠키가 없을 지라도 어플리케이션이 사용자를 기억하는 기능

  • rememberMe : 토큰으로 접속 상태 유지 설정 (재방문 시 자동 로그인인 상태)
    • rememberMeParameter : 로그인 폼에서 사용할 파라미터 이름 설정
    • tokenValiditySeconds : 토큰 유효 기간 설정 (초 단위)
    • rememberMeCookieName : 쿠키 이름 지정 (기본값 : "remember-me")
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
        .authorizeHttpRequests(auth -> auth
            .anyRequest()
            .authenticated()
        )
        // 기본 로그인 폼 설정
        .formLogin(Customizer.withDefaults())
        // 토큰으로 접속 상태 유지 설정 (기본폼에 rememberMe 체크박스 추가됨)
        .rememberMe(rememberMe -> rememberMe
            // 로그인 폼에서 사용할 파라미터 이름 설정
            .rememberMeParameter("rememberMe")
            // 쿠키 이름 지정 (기본값 : "remember-me")
            // .rememberMeCookieName("customRememberMe")
            // 토큰 유효 기간 설정 (초 단위)
            .tokenValiditySeconds(60)
        );

        return http.build();
    }
}

로그아웃 시 rememberMe 쿠키 삭제 설정

  • rememberMe 에서 쿠키의 네임 기본값은 "remember-me" (소문자, 하이픈)
  • rememberMe 에서 rememberMeCookieName 로 쿠키이름 지정한 경우 해당 이름으로 쿠키삭제
.logout(logout -> logout
    .logoutUrl("/logout")
    .logoutSuccessUrl("/hello")
    // 로그아웃 시 rememberMe 쿠키 삭제 설정
    .deleteCookies("JSESSIONID", "remember-me") // 기본값 쿠키 이름
);

rememberMe 동작 방식

.tokenValiditySeconds(60 * 60 * 24) : 1일 설정 시

  1. 사용자가 로그인 시 rememberMe 체크박스 선택 후 로그인
  2. 서버는 사용자를 인증하고, remember-me 쿠키를 생성하여 클라이언트에 전송
  3. 클라이언트는 이후 요청 시 remember-me 쿠키를 함께 전송
  4. 서버는 remember-me 쿠키를 확인하여 사용자를 자동으로 인증
  5. 사용자는 해당 유효기간 내 재방문하여도 자동으로 로그인된 상태 유지된다

2. inMemoryUserDetailsManager 유저 설정

메모리에 유저 정보를 저장하는 UserDetailsService 구현체

  • UserDetailsService 인터페이스를 구현한 InMemoryUserDetailsManager 클래스를 빈으로 등록
  • User.withUsername() 메서드를 사용하여 유저 생성
  • passwordEncoder를 사용하여 비밀번호 암호화
  • roles() 메서드를 사용하여 유저 역할 지정
  • 여러 유저를 생성하여 InMemoryUserDetailsManager에 전달
  • http에서 단일 : hasRole() , 다중 : hasAnyRole() 통해서 접근 권한 설정

@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/hello", "/static/**").permitAll()
                .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                .requestMatchers("/admin/super").hasRole("SUPERUSER")
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin(Customizer.withDefaults())
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/hello")
            );

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // 얘가 있어야 패스워드 인코딩 됨
    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        UserDetails user1 = User.withUsername("user1")
                .password(encoder.encode("1234"))
                .roles("USER")
                .build();
        UserDetails user2 = User.withUsername("star1431")
                .password(encoder.encode("star1431"))
                .roles("USER", "ADMIN")
                .build();
        UserDetails user3 = User.withUsername("admin")
                .password(encoder.encode("1234"))
                .roles("ADMIN")
                .build();
        UserDetails user4 = User.withUsername("super")
                .password(encoder.encode("1234"))
                .roles("SUPERUSER")
                .build();

        return new InMemoryUserDetailsManager(user1, user2, user3, user4);
    }
}

현재 로그인 유저 정보 가져오는 방법

  • 방법1 : SecurityContext에서 직접 인증 정보 가져오기
  • 방법2 : @AuthenticationPrincipal 어노테이션 사용
@RestController
public class TestController {

    // 1. SecurityContext에서 직접 인증 정보 가져오기
    @GetMapping("/info")
    public String info() {
        String msg;
        // SecurityContext에서 현재 인증 정보를 가져옴
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        // 인증 객체가 null이거나 인증되지 않은 상태인지 확인
        if(authentication == null || !authentication.isAuthenticated()) {
            msg = "로그인한 사용자 없습니다";
        }

        // 인증된 주체(principal) 객체를 가져옴
        Object principal = authentication.getPrincipal();
        // principal이 UserDetails 타입인지 확인 (일반적인 사용자 인증의 경우)
        if(principal instanceof UserDetails) {
            UserDetails userDetails = (UserDetails) principal;
            msg = "현재 로그인 유저 : " + userDetails.getUsername();
        } else {
            // UserDetails가 아닌 다른 타입의 principal인 경우 (예: 익명 사용자)
            msg = "현재 로그인 유저 : " +  principal.toString();
        }

        return msg;
    }

    // 2. @AuthenticationPrincipal 어노테이션 사용
    @GetMapping("/info_spring")
    public String infoSpring(@AuthenticationPrincipal UserDetails userDetails) {
        if(userDetails == null) {
            return "로그인한 사용자 없습니다";
        }
        return "현재 로그인 유저 : " + userDetails.getUsername();
    }
}

3. Spring Security 인터페이스

앞선 예제의 UserDetails, UserDetailsService ... 등 인터페이스 설명

Spring Security 아키텍처 요약

사용자 로그인 요청
        ↓
UserDetailsService → UserDetails 반환
        ↓
PasswordEncoder → 비밀번호 검증
        ↓
Authentication 객체 생성
        ↓
SecurityContext에 저장
        ↓
Controller에서 @AuthenticationPrincipal로 접근

UserDetails (사용자 정보 객체)

사용자 정보를 나타내는 인터페이스

  • 사용자명, 비밀번호, 권한, 계정 상태 등의 메서드 제공
  • UserDetailsService에서 반환하는 사용자 정보 타입임
public interface UserDetails {
    String getUsername();           // 사용자명
    String getPassword();           // 비밀번호  
    Collection<? extends GrantedAuthority> getAuthorities(); // 권한들
    boolean isAccountNonExpired();  // 계정 만료 여부
    boolean isAccountNonLocked();   // 계정 잠금 여부
    boolean isCredentialsNonExpired(); // 자격증명 만료 여부
    boolean isEnabled();            // 계정 활성화 여부
}

UserDetailsService (사용자 정보 서비스)

사용자 정보를 로드하는 서비스 인터페이스

  • InMemoryUserDetailsManager : 메모리에 사용자 정보 저장 (테스트/개발용)
  • loadUserByUsername(String username) 메서드를 통해 사용자명으로 UserDetails 객체 반환
  • 실제 운영환경에서는 데이터베이스 연동하여 구현해야함. only test 용
public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

PasswordEncoder (비밀번호 암호화)

비밀번호를 안전하게 암호화하는 인터페이스

  • BCrypt 해시 알고리즘을 사용하여 안전하게 비밀번호 암호화
  • salt + hash를 사용해 같은 비밀번호라도 매번 다른 해시값 생성
  • 평문 비밀번호 저장 시 보안 위험을 방지
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(); // BCrypt 해시 알고리즘 사용
}

// 예시: "1234" → "$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iYqiSfFVMLT..."

Authentication (인증 정보 객체)

현재 로그인한 사용자의 인증 정보를 담는 객체

  • 현재 스레드에서 인증된 사용자의 모든 정보를 포함
  • SecurityContext에 저장되어 애플리케이션 전반에서 접근 가능
public interface Authentication extends Principal {
    Collection<? extends GrantedAuthority> getAuthorities(); // 권한들
    Object getCredentials();    // 자격증명 (비밀번호 등)
    Object getDetails();        // 상세정보 (IP, 세션ID 등)  
    Object getPrincipal();      // 주체 (UserDetails 객체)
    boolean isAuthenticated();  // 인증 여부
}

SecurityContext와 SecurityContextHolder

현재 스레드의 보안 컨텍스트를 관리하는 클래스

  • SecurityContext : 현재 보안 정보를 담는 컨테이너
  • SecurityContextHolder : 현재 스레드에서 SecurityContext에 접근하는 유틸리티
// 현재 인증 정보 가져오기
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

// 인증된 사용자 정보 확인
if (auth != null && auth.isAuthenticated()) {
    String username = auth.getName();
    Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
}

@AuthenticationPrincipal 어노테이션

컨트롤러에서 현재 로그인 사용자 정보를 쉽게 받아오는 어노테이션

// 기존 방식 (복잡)
@GetMapping("/profile")
public String profile() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    UserDetails user = (UserDetails) auth.getPrincipal();
    return "사용자: " + user.getUsername();
}

// @AuthenticationPrincipal 사용 (간편)
@GetMapping("/profile_simple")  
public String profileSimple(@AuthenticationPrincipal UserDetails userDetails) {
    return "사용자: " + userDetails.getUsername();
}

전체 Spring Security 인증 흐름

  1. 로그인 요청 → 사용자가 username/password 입력
  2. 사용자 조회UserDetailsService.loadUserByUsername() 호출
  3. 비밀번호 검증PasswordEncoder가 입력된 비밀번호와 저장된 비밀번호 비교
  4. 인증 성공Authentication 객체 생성 및 인증 상태 설정
  5. 보안 컨텍스트 저장SecurityContextAuthentication 저장
  6. 세션 생성JSESSIONID 쿠키 생성하여 클라이언트에 전송
  7. 이후 요청 → 세션 쿠키로 자동 인증, @AuthenticationPrincipal로 사용자 정보 접근

4. JPA 활용 security 회원가입 만들어보기

  • Spring Security + JPA 활용하여 n:n 권한과 유저 매핑 및 회원가입기능 심플 구현
  • 회원가입 시 비밀번호 암호화 및 권한 부여 목적

디렉토리 구조

org.example.securityexam4
    ├── config
    │   └── SecurityConfig.java
    ├── controller
    │   └── UserController.java
    ├── domain
    │   ├── Role.java
    │   └── User.java
    ├── dto
    │   └── UserRegisterDTO.java
    ├── repository
    │   └── RoleRepository.java
    │   └── UserRepository.java
    ├── service
    │   └── UserService.java
    └── SecurityExam4Application.java

그래들 의존성

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

apllication 설정

spring:
  application:
    name: securityexam
  output:
    ansi:
      enabled: always
  datasource:
    url: jdbc:mysql://localhost:{포트번호}/{데이터베이스명}?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: {접속계정}
    password: {접속계정 비밀번호}
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

엔티티 (domain)

Role.java
  • 접근권한 엔티티
  • 사용자와 n:n 관계 상태
@Entity
@Getter
@Setter
@Table(name = "roles")
public class Role {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(unique = true, nullable = false, length = 50)
    private String name;

}
User.java
  • 유저 엔티티
  • Role과 n:n 관계 상태이며, 조인테이블 "user_roles"로 매핑
@Entity
@Getter
@Table(name = "lion_users")
@Builder
@AllArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;            // PK
    @Column(unique = true, nullable = false, length = 50)
    private String username;    // 유저아이디
    @Column(nullable = false, length = 100)
    private String password;    // 비밀번호
    @Column(nullable = false, length = 50)
    private String name;        // 이름
    @Column(nullable = false, length = 100)
    private String email;       // 이메일
    @Column(name = "registration_date", nullable = false, updatable = false)
    @Builder.Default            // 빌더시 기본값 적용
    private LocalDate registrationDate = LocalDate.now(); // 가입일

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
            name = "user_roles",
            joinColumns = @JoinColumn(name="user_id"),
            inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private Set<Role> roles = new HashSet<>();
}

DTO (dto)

UserRegisterDTO.java
  • 유저 회원가입 DTO
  • from 메서드로 User 엔티티를 DTO로 변환 기능 제공
@Builder
@Getter
@Setter
public class UserRegisterDTO {
    private String username;
    private String password;
    private String name;
    private String email;
    private List<String> roles;

    public static UserRegisterDTO from(User user) {
        return UserRegisterDTO.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .name(user.getName())
                .email(user.getEmail())
                .roles(user.getRoles().stream()
                        .map(Role::getName)
                        .toList())
                .build();
    }
}

레포지토리 (repository)

RoleRepository.java
  • Role 레포지토리 인터페이스
  • findByName 메서드로 역할 이름으로 조회 기능 제공
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
    Optional<Role> findByName(String name);
}
UserRepository.java
  • User 레포지토리 인터페이스
  • existsByUsername 메서드로 중복아이디 체크
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // User findByUsername(String username);
    boolean existsByUsername(String username);

}

security 설정 (config)

SecurityConfig.java
  • Spring Security 설정 클래스
  • 시큐리티 인메모리 아닌 DB 연동하여 사용자 인증 처리
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    // DB 연결 정보(커넥션 풀) 주입
    private final DataSource dataSource;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/users/signup", "/users/userreg").permitAll()
                        .anyRequest().authenticated()
                )
                // .csrf(csrf -> csrf.disable())
                .csrf(csrf -> csrf
                    .ignoringRequestMatchers("/users/userreg") // 회원가입 요청만 CSRF 예외
                )
                .formLogin(form -> form.defaultSuccessUrl("/users/welcome", true));

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        // InMemoryUserDetailsManager → 메모리 방식
        // JdbcUserDetailsManager → DB 조회 방식
        JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);


        // 로그인 요청 시 사용자 정보 조회
        // username, 암호화된 password 를 DB에서 가져옴
        // enabled = true (계정 활성화)
        manager.setUsersByUsernameQuery(
                "SELECT username, password, true AS enabled FROM lion_users WHERE username = ?"
        );

        // 로그인 성공 후 사용자 권한 조회
        // 유저 테이블(lion_users) + 조인 테이블(user_roles) + 권한 테이블(roles)
        // DB에는 USER / ADMIN 저장되어 있으므로 ROLE_ prefix 붙여서 반환
        manager.setAuthoritiesByUsernameQuery(
                "SELECT u.username, CONCAT('ROLE_', r.name) " +
                        "FROM lion_users u " +
                        "JOIN user_roles ur ON u.id = ur.user_id " +
                        "JOIN roles r ON ur.role_id = r.id " +
                        "WHERE u.username = ?"
        );
        return manager;
    }

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

서비스 (service)

UserService.java
  • 유저 서비스 계층
  • 회원가입 및 중복아이디 체크 기능 구현
  • 패스워드 암호화 및 권한 매핑 처리

패스워드 암호화 관련 설명

  • DB에 저장 시 평문 비밀번호 저장은 보안상 위험
  • PasswordEncoder를 사용하여 비밀번호를 암호화 후 저장
  • 로그인시 입력된 비밀번호를 암호화하여 DB에 저장된 암호화된 비밀번호와 비교

@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    /** 유저아이디 중복인지 */
    public boolean existsByUsername(String username) {
        return userRepository.existsByUsername(username);
    }

    /** 회원가입 */
    public User registerUser(UserRegisterDTO registerDTO) {
        Set<Role> roles = new HashSet<>();

        // 회원가입시 권한 선택없으면 디폴트 USER
        if (registerDTO.getRoles() == null || registerDTO.getRoles().isEmpty()) {
            Role defaultRole = roleRepository.findByName("USER")
                    .orElseThrow(() -> new RuntimeException("USER 권한을 찾을 수 없습니다"));
            roles.add(defaultRole);
        } else {
            // 선택한 권한들
            for (String roleName : registerDTO.getRoles()) {
                Role role = roleRepository.findByName(roleName)
                        .orElseThrow(() -> new RuntimeException(roleName + " 권한 찾을 수 없음"));
                roles.add(role);
            }
        }

        User user = User.builder()
                .username(registerDTO.getUsername())
                .name(registerDTO.getName())
                .email(registerDTO.getEmail())
                .password(passwordEncoder.encode(registerDTO.getPassword()))
                .roles(roles)
                .build();

        log.info("username 들어온 값 = {}", registerDTO.getUsername());
        log.info("builder username = {}", user.getUsername());
        return userRepository.save(user);
    }
}

컨트롤러 (controller)

UserController.java
  • 유저 컨트롤러
  • 회원가입 폼 및 회원가입 처리 기능 구현
    • 실패시 : 중복아이디 경고 후 회원가입 폼으로 리다이렉트
    • 성공시 : 환영 페이지로 리다이렉트

URL 매핑

  • localhost:8080/login : 시큐리티 폼로그인
  • localhost:8080/users/signup : 회원가입 폼 (GET)
  • localhost:8080/users/userreg : 회원가입 처리 (POST)
  • localhost:8080/users/welcome : 환영 페이지 (GET)
@Controller
@RequiredArgsConstructor
@RequestMapping("/users")
@Slf4j
public class UserController {
    private final UserService userService;

    @GetMapping("/welcome")
    public String welcome(Model model) {
        return "exam4/welcome";
    }

    /** 회원가입 폼 */
    @GetMapping("/signup")
    public String register(Model model) {
        // model.addAttribute("user", new UserRegisterDTO());
        return "exam4/users/signup";
    }
    /** 회원가입 POST */
    @PostMapping("/userreg")
    public String userReg(
            @ModelAttribute UserRegisterDTO userRegisterDTO
    ) {
        if(userService.existsByUsername(userRegisterDTO.getUsername())) {
            log.warn("이미 존재한 아이디 : " + userRegisterDTO.getUsername() );
            return "redirect:/users/signup";

        } else {
            userService.registerUser(userRegisterDTO);
            return  "redirect:/users/welcome";
        }
    }
}

애플리케이션 메인

SecurityExam4Application.java
  • 애플리케이션 메인 클래스
  • 애플리케이션 시작 시 roles 테이블에 초기값 권한 데이터 추가
@SpringBootApplication
@Slf4j
public class SecurityExam4Application {
    public static void main(String[] args) {
        SpringApplication.run(SecurityExam4Application.class, args);
    }

    @Bean
    public CommandLineRunner commandLineRunner(
            UserService userService,
            RoleRepository roleRepo
    ) {
        return args -> {
            if (roleRepo.count() == 0) {
                Role userRole = new Role();
                userRole.setName("USER");

                Role adminRole = new Role();
                adminRole.setName("ADMIN");

                roleRepo.saveAll(List.of(userRole, adminRole));
                log.info("[TABLE roles] 초기값 권한 데이터 추가됨");
            } else {
                log.warn("[TABLE roles] 이미 초기값 존재함");
            }
        };
    }
}

templates 화면

html 소스 확인
  • resources/templates/exam4/users/signup.html
  • 회원가입 폼
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>회원가입</title>
</head>
<body>
    <h2>회원 가입</h2>
    <form action="/users/userreg" method="post">
        <label for="username">아이디 : </label>
        <input type="text" id="username" name="username" required><br><br>

        <label for="password">비밀 번호 : </label>
        <input type="password" id="password" name="password" required><br><br>

        <label for="name">이름 : </label>
        <input type="text" id="name" name="name" required><br><br>

        <label for="email">이메일: </label>
        <input type="text" id="email" name="email" required><br><br>



        <label>권한 선택:</label><br>
        <input type="checkbox" id="role_user" name="roles" value="USER">
        <label for="role_user">USER</label><br>

        <input type="checkbox" id="role_admin" name="roles" value="ADMIN">
        <label for="role_admin">ADMIN</label><br><br>

        <button type="submit">가입하기</button>
    </form>
</body>
</html>
  • resources/templates/exam4/welcome.html
  • 로그인 후 리다이렉트 화면용
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>웰컴</title>
</head>
<body>
    <h1>환영합니다.</h1>
</body>
</html>

starweb
@starweb :: starweb 님의 블로그

starweb 님의 블로그 입니다.

공감하셨다면 구독도 환영합니다!

목차