Spring Boot 4 프로젝트에 로그인, 회원가입, 역할 기반 권한 기능 적용하기
Spring Boot 4 프로젝트에 인증 기능을 붙일 때는 보통 Spring Security + Spring Data JPA + Thymeleaf + BCrypt 조합으로 시작하면 가장 무난합니다.
구조도 단순한 편이고, 인메모리 H2 데이터베이스를 써서 빠르게 테스트해 보기에도 좋습니다.
이 글에서는 다음 기능을 한 번에 정리합니다.
- 회원가입
- 로그인
- 로그아웃
- 내 정보 보기
- 역할(Role) 기반 접근 제어
- 관리자 전용 Dashboard
- 애플리케이션 시작 시 기본 관리자 계정 자동 생성
테이블 이름은 ASP.NET Core Identity 스타일을 참고해 AspNetUsers, AspNetRoles, AspNetUserRoles처럼 둘 수 있습니다.
다만 실제 인증 처리 방식은 Spring Security를 기준으로 갑니다.
비밀번호는 평문이 아니라 bcrypt 해시로 저장하는 방식으로 구성합니다.
1. 먼저 전체 흐름 이해하기
Spring Security의 일반적인 아이디/비밀번호 인증은 보통 AuthenticationManager, DaoAuthenticationProvider, UserDetailsService를 중심으로 동작합니다.
조금 단순하게 보면 흐름은 이렇습니다.
- 사용자가 로그인 폼에 아이디와 비밀번호를 입력합니다.
- Spring Security가 로그인 요청을 받습니다.
UserDetailsService가 DB에서 사용자를 조회합니다.PasswordEncoder가 입력한 비밀번호와 저장된 해시를 비교합니다.- 인증이 성공하면 세션에 인증 정보가 저장됩니다.
- 이후 요청에서는 로그인 여부와 역할에 따라 접근 가능 여부가 결정됩니다.
처음 보면 구조가 조금 복잡해 보일 수 있지만, 실제로 구현해 보면 핵심은 세 가지입니다.
- 사용자 정보를 DB에서 읽어오는 코드
- 비밀번호를 해시로 저장하고 비교하는 코드
- URL별 접근 권한 설정
2. 의존성 추가
Maven 기준으로 가장 기본적인 조합은 다음과 같습니다.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
이 정도면 웹, 템플릿, 인증, JPA, 검증, 테스트까지 기본 골격은 다 갖춰집니다.
3. application.properties 기본 설정
인메모리 H2 데이터베이스를 쓴다면 다음처럼 시작하면 됩니다.
spring.application.name=demo-security-app
spring.datasource.url=jdbc:h2:mem:appdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
여기서 눈여겨볼 부분은 두 가지입니다.
spring.jpa.hibernate.ddl-auto=update엔터티를 기준으로 테이블을 자동 보완 생성합니다.spring.jpa.open-in-view=false뷰 렌더링 과정에서 DB 접근이 계속 이어지는 패턴을 줄여서 구조를 조금 더 명확하게 잡는 데 도움이 됩니다.
개발 초반에는 이 설정만으로도 충분히 편하게 테스트할 수 있습니다.
4. 사용자, 역할, 사용자-역할 엔터티 만들기
가장 단순한 형태는 세 개의 엔터티입니다.
AppRole.java
@Entity
@Table(name = "AspNetRoles")
public class AppRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String name;
// getters/setters
}
AppUser.java
@Entity
@Table(name = "AspNetUsers")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String userName;
@Column(nullable = false, length = 200)
private String passwordHash;
@Column(nullable = false, unique = true, length = 200)
private String email;
@Column(nullable = false)
private boolean enabled = true;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "AspNetUserRoles",
joinColumns = @JoinColumn(name = "UserId"),
inverseJoinColumns = @JoinColumn(name = "RoleId")
)
private Set<AppRole> roles = new HashSet<>();
// getters/setters
}
이렇게 두면 테이블 이름은 익숙한 형태로 맞추면서도, 실제 인증은 Spring Security 방식으로 자연스럽게 연결할 수 있습니다.
5. Repository 만들기
사용자와 역할을 조회할 Repository도 간단하게 만들 수 있습니다.
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUserName(String userName);
boolean existsByUserName(String userName);
boolean existsByEmail(String email);
}
public interface AppRoleRepository extends JpaRepository<AppRole, Long> {
Optional<AppRole> findByName(String name);
}
회원가입 중복 검사나 로그인 시 사용자 조회에 바로 사용할 수 있는 형태입니다.
6. UserDetailsService 구현
Spring Security는 로그인 과정에서 UserDetailsService를 사용합니다.
여기서 사용자명으로 사용자를 찾고, 권한 정보를 꺼내서 UserDetails 형태로 넘겨주면 됩니다.
@Service
public class AppUserDetailsService implements UserDetailsService {
private final AppUserRepository userRepository;
public AppUserDetailsService(AppUserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AppUser user = userRepository.findByUserName(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
List<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.toList();
return User.builder()
.username(user.getUserName())
.password(user.getPasswordHash())
.authorities(authorities)
.disabled(!user.isEnabled())
.build();
}
}
여기서 중요한 점은 역할 이름을 어떻게 넘길지 일관성을 유지하는 것입니다.
예를 들어 hasRole("ADMINISTRATORS")를 쓸 생각이라면 내부적으로는 ROLE_ADMINISTRATORS 형태가 되도록 맞춰 두는 편이 깔끔합니다.
7. PasswordEncoder 등록
비밀번호는 절대로 평문으로 저장하면 안 됩니다.
가장 간단하면서도 널리 쓰는 방식은 BCryptPasswordEncoder를 등록해서 사용하는 것입니다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
회원가입 시에는 반드시 해시로 변환해서 저장합니다.
user.setPasswordHash(passwordEncoder.encode(rawPassword));
이렇게 해 두면 DB에 저장되는 값은 사람이 읽을 수 없는 해시 문자열이 됩니다.
8. SecurityFilterChain 설정
Spring Boot 4 환경에서는 SecurityFilterChain 빈을 등록하는 방식으로 보안 설정을 하는 것이 자연스럽습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/",
"/css/**",
"/js/**",
"/images/**",
"/Identity/Account/Login",
"/Identity/Account/Register",
"/h2-console/**"
).permitAll()
.requestMatchers("/Dashboard", "/Administrations/**")
.hasRole("ADMINISTRATORS")
.requestMatchers("/Identity/Account/Manage/**")
.authenticated()
.anyRequest().permitAll()
)
.formLogin(form -> form
.loginPage("/Identity/Account/Login")
.loginProcessingUrl("/Identity/Account/Login")
.defaultSuccessUrl("/", true)
.failureUrl("/Identity/Account/Login?error")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/Identity/Account/Logout")
.logoutSuccessUrl("/Identity/Account/Login?logout")
);
http.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()));
return http.build();
}
}
이 설정에서 기억할 부분은 다음 정도입니다.
- 로그인 화면 URL과 로그인 처리 URL을 같은 경로로 둘 수 있습니다.
/Dashboard는 관리자 역할이 있어야만 접근하게 잠글 수 있습니다.- H2 콘솔을 쓰는 동안에는
frameOptions().sameOrigin()설정이 필요한 경우가 있습니다.
9. 로그인 폼 만들기
로그인 폼 자체는 생각보다 단순합니다.
Spring Security가 처리할 수 있도록 username, password 이름으로 값을 보내면 됩니다.
templates/identity/account/login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>로그인</title>
</head>
<body>
<h1>로그인</h1>
<div th:if="${param.error}">
아이디 또는 비밀번호가 올바르지 않습니다.
</div>
<div th:if="${param.logout}">
로그아웃되었습니다.
</div>
<form th:action="@{/Identity/Account/Login}" method="post">
<div>
<label>아이디</label>
<input type="text" name="username" />
</div>
<div>
<label>비밀번호</label>
<input type="password" name="password" />
</div>
<button type="submit">로그인</button>
</form>
</body>
</html>
나중에 Bootstrap을 붙이더라도 기본 구조는 그대로 유지됩니다.
10. 회원가입 DTO와 Controller 만들기
회원가입은 보통 DTO 하나와 Controller 하나로 시작하면 충분합니다.
RegisterViewModel.java
public class RegisterViewModel {
@NotBlank
private String userName;
@Email
@NotBlank
private String email;
@NotBlank
@Size(min = 6)
private String password;
@NotBlank
private String confirmPassword;
public boolean isPasswordConfirmed() {
return password != null && password.equals(confirmPassword);
}
// getters/setters
}
AccountController.java
@Controller
@RequestMapping("/Identity/Account")
public class AccountController {
private final AppUserRepository userRepository;
private final AppRoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
public AccountController(
AppUserRepository userRepository,
AppRoleRepository roleRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.passwordEncoder = passwordEncoder;
}
@GetMapping("/Login")
public String login() {
return "identity/account/login";
}
@GetMapping("/Register")
public String registerForm(Model model) {
model.addAttribute("model", new RegisterViewModel());
return "identity/account/register";
}
@PostMapping("/Register")
public String register(
@Valid @ModelAttribute("model") RegisterViewModel model,
BindingResult bindingResult) {
if (!model.isPasswordConfirmed()) {
bindingResult.rejectValue("confirmPassword", "Mismatch", "비밀번호가 일치하지 않습니다.");
}
if (userRepository.existsByUserName(model.getUserName())) {
bindingResult.rejectValue("userName", "Duplicate", "이미 사용 중인 아이디입니다.");
}
if (userRepository.existsByEmail(model.getEmail())) {
bindingResult.rejectValue("email", "Duplicate", "이미 사용 중인 이메일입니다.");
}
if (bindingResult.hasErrors()) {
return "identity/account/register";
}
AppRole userRole = roleRepository.findByName("USERS")
.orElseThrow();
AppUser user = new AppUser();
user.setUserName(model.getUserName());
user.setEmail(model.getEmail());
user.setPasswordHash(passwordEncoder.encode(model.getPassword()));
user.getRoles().add(userRole);
userRepository.save(user);
return "redirect:/Identity/Account/Login?registered";
}
}
핵심은 세 가지입니다.
- 비밀번호 확인 일치 여부 검사
- 아이디 중복 검사
- 이메일 중복 검사
이 정도만 있어도 기본 회원가입 흐름은 충분히 테스트 가능합니다.
11. 내 정보 페이지 만들기
로그인한 사용자 정보를 보여줄 때는 Authentication을 받으면 편합니다.
@GetMapping("/Manage/Index")
public String manageIndex(Authentication authentication, Model model) {
String userName = authentication.getName();
AppUser user = userRepository.findByUserName(userName).orElseThrow();
model.addAttribute("user", user);
return "identity/account/manage/index";
}
로그인한 현재 사용자가 누구인지 확인해서, 그 사용자의 정보를 바로 화면에 보여주는 방식입니다.
12. 관리자 전용 Dashboard 추가
역할 기반 접근 제어를 확인하기 가장 쉬운 방법은 관리자 전용 페이지를 하나 만드는 것입니다.
@Controller
public class DashboardController {
@GetMapping("/Dashboard")
public String dashboard() {
return "dashboard/index";
}
}
그리고 보안 설정에서는 이렇게 잠가 둡니다.
.requestMatchers("/Dashboard", "/Administrations/**").hasRole("ADMINISTRATORS")
이렇게 해 두면 로그인했다고 해서 모두 접근할 수 있는 것이 아니라, 관리자 역할이 있는 사용자만 접근하게 됩니다.
13. 기본 역할과 관리자 계정 자동 생성
개발 중에는 애플리케이션을 실행하자마자 바로 로그인 테스트를 하고 싶을 때가 많습니다. 그래서 시작 시 역할과 기본 계정을 자동으로 넣어 두면 편합니다.
@Component
public class IdentitySeedData implements CommandLineRunner {
private final AppRoleRepository roleRepository;
private final AppUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public IdentitySeedData(
AppRoleRepository roleRepository,
AppUserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.roleRepository = roleRepository;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public void run(String... args) {
AppRole adminRole = roleRepository.findByName("ADMINISTRATORS")
.orElseGet(() -> roleRepository.save(createRole("ADMINISTRATORS")));
AppRole userRole = roleRepository.findByName("USERS")
.orElseGet(() -> roleRepository.save(createRole("USERS")));
if (!userRepository.existsByUserName("Administrator")) {
AppUser admin = new AppUser();
admin.setUserName("Administrator");
admin.setEmail("admin@javacampus.local");
admin.setPasswordHash(passwordEncoder.encode("Pa$$w0rd"));
admin.setEnabled(true);
admin.getRoles().add(adminRole);
admin.getRoles().add(userRole);
userRepository.save(admin);
}
}
private AppRole createRole(String name) {
AppRole role = new AppRole();
role.setName(name);
return role;
}
}
이렇게 하면 앱을 실행한 뒤 바로 아래 계정으로 테스트할 수 있습니다.
- 아이디:
Administrator - 비밀번호:
Pa$$w0rd
14. 레이아웃에서 로그인 상태에 따라 메뉴 바꾸기
실제로 화면에서 테스트하려면 메뉴도 조금 정리해 두는 편이 좋습니다.
<div th:if="${#authorization.expression('isAuthenticated()')}">
<a th:href="@{/Identity/Account/Manage/Index}">내 정보</a>
<a th:href="@{/Dashboard}">Dashboard</a>
<form th:action="@{/Identity/Account/Logout}" method="post" style="display:inline;">
<button type="submit">로그아웃</button>
</form>
</div>
<div th:unless="${#authorization.expression('isAuthenticated()')}">
<a th:href="@{/Identity/Account/Login}">로그인</a>
<a th:href="@{/Identity/Account/Register}">회원가입</a>
</div>
이렇게 해 두면 로그인 전과 로그인 후 화면 구성이 자연스럽게 달라집니다.
15. 테스트 포인트
최소한 아래 네 가지는 직접 확인해 보는 것이 좋습니다.
- 비로그인 상태에서
/Dashboard에 접근하면 로그인 페이지로 이동하는가 Administrator / Pa$$w0rd로 로그인되는가- 로그인 후
/Identity/Account/Manage/Index에서 내 정보가 보이는가 - 일반 사용자로 로그인하면
/Dashboard접근이 거부되는가
여기까지 확인되면 기본적인 인증과 권한 흐름은 잘 연결된 것입니다.
16. 실무로 갈 때는 꼭 더 손봐야 하는 부분
이 글의 예시는 학습용이나 데모용으로는 충분하지만, 실제 서비스라면 다음 항목들은 추가로 고려해야 합니다.
- CSRF 비활성화 금지
- 비밀번호 재설정 기능
- 계정 잠금 정책
- 이메일 인증
- remember-me 적용 여부 검토
- 감사 로그
- 실제 운영 DB 사용
- 시드 계정 비활성화 또는 환경 분리
- 접근 거부 페이지와 오류 페이지 정리
즉, 여기서 만든 구조는 “출발점”으로는 좋지만, 운영 환경에 바로 넣기 전에는 반드시 보강이 필요합니다.
17. 다른 프로젝트에 다시 적용할 때의 순서
다른 Spring Boot 프로젝트에 같은 기능을 넣을 때는 아래 순서대로 진행하면 비교적 안정적입니다.
spring-boot-starter-security,data-jpa,thymeleaf,validation추가AppUser,AppRole,AppUserRepository,AppRoleRepository생성AppUserDetailsService구현PasswordEncoder등록SecurityConfig에서 공개 URL, 인증 URL, 관리자 URL 분리- 로그인, 회원가입, 내 정보 템플릿 추가
IdentitySeedData로 기본 역할과 관리자 계정 추가- 관리자 전용 Dashboard 추가
- 공통 레이아웃 메뉴에 로그인, 로그아웃, Dashboard 연결
- H2에서 먼저 검증한 뒤 실제 DB로 전환
이 순서대로 하면 중간에 어디가 빠졌는지 확인하기도 쉽습니다.
마무리
Spring Boot 프로젝트에 인증과 권한 기능을 넣을 때는 처음부터 너무 복잡하게 잡기보다, JPA로 사용자와 역할을 저장하고 Spring Security의 UserDetailsService, PasswordEncoder, SecurityFilterChain을 연결하는 기본 구조부터 만드는 편이 좋습니다.
이 방식은 구조가 단순하고, H2 같은 인메모리 데이터베이스에서도 바로 테스트할 수 있으며, 나중에 MySQL이나 SQL Server 같은 실제 데이터베이스로 바꾸는 것도 어렵지 않습니다.
처음 한 번만 흐름을 제대로 잡아 두면, 이후에는 새 프로젝트에서도 큰 틀을 거의 그대로 재사용할 수 있습니다.