[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_58일차_"Spring Security와 DB연결"
🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [58]일차
🚀58일차에는 Spring Security와 데이터베이스를 연결하여 유저세부정보를 직접 설계하지 않고 DB로 연결해보는 실습을 하였다.
학습 목표 : DB 명세를 통해 관계 테이블을 설계할 수 있어야함
학습 과정 : 회고를 통해 작성
UserDetailsService + Database
- 기존에 UserDetailService를 직접 구현하여 유저에 대한 세부정보들을 직접 넣어주었다면
- DB의 User 테이블의 Role에 관련된 내용을 넣어줄 수 있도록 구현해야할 것
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder){
UserDetails user = User.withUsername("user")
.password(passwordEncoder.encode("1234")) // 원하는 패스워드를 넣음
.roles("USER")
.build();
//...
return new InMemoryUserDetailsManager(user, user2, user3, user4);
}
- User ↔ Role 은 N : N (다 : 다) 관계를 가지고있을때 이 관계를 데이터베이스에서 정의해야함
- User 1명 당 여러 개의 Role을 가질 수 있고 Role 1개 당 여러 명의 User를 가질 수 있기때문에 N : N 관계
엔티티 설계
- DB 명세를 기준으로 엔티티를 설계
Role 엔티티, User 엔티티을 만들고
@Entity
@Table(name = "lions_users")
@Getter@Setter
public class User {
// ... 다른 필드들
// updatable을 false로 설정하여 수정이 되지 않도록 설정
@Column(name = "registration_date", nullable = false, updatable = false)
private LocalDateTime registrationDate = LocalDateTime.now();
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles;
}
- @ManyToMany(fetch = FetchType.EAGER)
➡️FetchType.EAGER : User정보를 꺼낼때 Security가 Role정보까지 꺼내오도록 구현
➡️@JoinTable : ForeignKey를 가질 수 없으므로 조인을 이용해서 Role 테이블에 접근 - @JoinTable( ... )
➡️JoinTable 내에서 @JoinColumn 을 통해 user_id와 role_id를 Join 할때
➡️role_id는 inverseJoinColumns 속성을 이용해 반대편의 컬럼을 가져오도록 명시 - private Set<Role> roles;
➡️ User에서 Role을 얻어오는 부분 - 중복되지 않으므로 Set으로 받아옴 - ⚠️Role정보를 가져올때는 User 정보를 모두 꺼내올 필요는 없으므로
Role테이블에는 @ManyToMany 어노테이션을 정의하지 않는다. (=User 리스트를 가져오지 않아도 된다)
➡️양방향 참조 문제 방지
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES lion_users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);
- @JoinColumn의 name은 관계테이블 (user_roles)의 FOREIGN KEY부분에서 가져와지는
user_id, role_id와 일치해야함
Main 테스트
@Bean
public CommandLineRunner run(RoleRepository roleRepository){
return args -> {
if(roleRepository.count() == 0){ // DB에 저장된 권한이 없다면
Role userRole = new Role();
userRole.setName("USER"); // USER라는 권한을 생성
Role adminRole = new Role();
adminRole.setName("ADMIN"); // ADMIN이라는 권한을 생성
roleRepository.saveAll(List.of(userRole, adminRole)); // 생성한 권한들을 모두 DB에 저장
log.info("USER, ADMIN 권한이 추가되었습니다. ::: {}, {}", userRole, adminRole);
}else{
log.info("권한 정보가 이미 존재합니다.");
}
};
}
- select * from roles; 로 확인해보면 USER, ADMIN 권한들이 추가되어있는 것을 확인 가능
회원 가입 구현
Service
@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
// - 회원가입
@Transactional
public User registUser(User user){
Role userRole = roleRepository.findByName("USER").get();
user.setRoles(Collections.singleton(userRole));
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user);
}
}
- Role userRole = roleRepository.findByName("USER").get();
➡️Role정보를 User엔티티에 채워줌
➡️회원가입 요청 시 User 권한으로 가입 (roleRepository 활용) - user.setPassword(passwordEncoder.encode(user.getPassword()));
➡️반드시 인코딩(=암호화)되어야함 (PasswordEncoder 활용)
SecurityConfig
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- 올바른 PasswordEncoder 사용을 위해 PasswordEncoder 빈을 등록해주어야함
- SecurityConfig에서 PasswordEncoder 빈을 등록해주어야함
SecurityConfig - SecurityFilterChain 추가
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/signup", "/userreg", "/loginform").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
Controller와 폼 연결
@Controller
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// 1. 회원가입 폼 요청
@GetMapping("/signup")
public String signUp(){
return "/exam4/users/signup";
}
// 2. 회원가입 요청
@PostMapping("/userreg")
public String userReg(){
return "redirect:/welcome";
}
// 3. 로그인 폼 요청
@GetMapping("/loginform")
public String loginForm(){
return "exam4/users/loginform";
}
}
- UserService를 받아오고,
- 정확한 경로를 명시하여 signup.html, welcome.html, loginform.html 등에 접근할 수 있도록 구현
- 회원가입에서 “가입하기”버튼을 누르면 login페이지로 리다이렉트 되고
userreg를 통해 form의 action 태그가 보내고 있고 이때 @PostMapping(”/userreg”)로 받아오고 있음
Service
// - username에 해당하는 사용자가 있는지 체크
public boolean existsUser(String username){
return userRepository.existsByUsername(username);
}
- UserRepository에서 구현했던 existsByUsername 메소드를 활용해 Service에 구현
Controller
// 2. 회원가입 요청
@PostMapping("/userreg")
public String userReg(@ModelAttribute User user){
// 사용자가 입력한 username과 동일한 유저가 이미 존재하는지 검사
if(userService.existsUser(user.getUsername())){
// 이미 존재한다면
log.info("이미 존재하는 유저 [{}] 입니다.", user.getUsername());
return "exam4/users/userreg-error";
}
// 사용자가 존재하지 않는다면 받아온 User객체를 registUser()메소드를 통해 저장해줌
userService.registUser(user);
return "redirect:/loginform";
}
- @PostMapping("/userreg")
➡️가입하기 버튼을 눌렀을경우 userreg 를 통해 값을 가져옴
// 3. 로그인 폼 요청
@GetMapping("/loginform")
public String loginForm(){
return "exam4/users/loginform";
}
- @PostMapping에서 보내주는 redirect는 exam4/… 처럼 경로명이 아닌
@GetMapping의 /loginform 에 리다이렉트해야함
<body>
<h2>회원가입 에러</h2>
<p>사용자 아이디가 이미 사용중입니다. 다시 시도해주세요.</p>
<a href="/signup">회원가입</a>
<a href="/loginform">로그인</a>
</body>
- 이미 존재하는 아이디이면 에러 페이지를 출력할 수 있도록 함
❓loginform.html이 제대로 가져와지지 않는 오류 해결
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // csrf를 비활성화시키는 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/signup", "/userreg", "/loginform").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
- ✅SecurityConfig 설정에 csrf 관련 설정 추가
▶️실습 - 에러 페이지 출력 테스트
// 2. 회원가입 요청 - 가입하기 버튼을 눌렀을경우 userreg 를 통해 값을 가져옴
@PostMapping("/userreg")
public String userReg(@ModelAttribute User user){
log.info("+++++[userreg : 사용자 요청 정보 성공적으로 전달완료]+++++");
// 사용자가 입력한 username과 동일한 유저가 이미 존재하는지 검사
if(userService.existsUser(user.getUsername())){
// 이미 존재한다면
log.info("이미 존재하는 유저 [{}] 입니다.", user.getUsername());
return "exam4/users/userreg-error";
}
// 사용자가 존재하지 않는다면 받아온 User객체를 registUser()메소드를 통해 저장해줌
userService.registUser(user);
return "redirect:/loginform";
- log로 userreg가 제대로 가져와지고 있는지 검사
- 이미 사용중인 아이디일 경우의 오류페이지 출력 확인
▶️실습 - 사용자 정의 로그인 폼 적용 및 로그아웃 처리
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // csrf를 비활성화시키는 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/signup", "/userreg", "/loginform").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/loginform") // 사용자 정의
.defaultSuccessUrl("/welcome") // 기본설정
)
.logout(logout -> logout
.logoutUrl("/logout") // 기본설정
.logoutSuccessUrl("/") // 기본설정
);
return http.build();
}
- .userDetailsService()를 사용하기 위해서 CustomUserDetailService 클래스를 만들어 사용자 정의로 만든다.
public class CustomUserDetailService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
}
- 이 클래스는 UserDetailService 인터페이스를 구현
- loadUserByUsername() 메소드는 UserDetails 를 반환타입으로 가지고 있음
UserDetails의 내부 구조
UserDetails user = User.withUsername("user")
.password(passwordEncoder.encode("1234")) // 원하는 패스워드를 넣음
.roles("USER")
.build();
CustomUserDetailService
import org.springframework.security.core.userdetails.User.UserBuilder;
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if(user == null){ // 가져올 유저가 없으면
throw new UsernameNotFoundException("[" + username + "]사용자가 없습니다.");
}
UserBuilder userBuilder = org.springframework.security.core.userdetails.User.withUsername(username);
userBuilder.password(user.getPassword());
userBuilder.roles(user.getRoles().stream().map(Role::getName).toList().toArray(String[]::new));
return userBuilder.build(); // UserDetails가 반환될 것
}
}
- UserBuilder userBuilder = org.springframework.security.core.userdetails.User.withUsername(username);
➡️동일한 이름의 클래스를 두 번 쓸 수 없기때문에 직접 참조하여 userDetails의 User를 사용
➡️UserBuilder또한 import를 직접 작성
SecurityConfig 수정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/signup", "/userreg", "/loginform", "/").permitAll()
.requestMatchers("/welcome", "myinfo").hasRole("USER")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/loginform") // 사용자 정의
.loginProcessingUrl("/login")
.defaultSuccessUrl("/welcome") // 기본설정
)
//...
- .requestMatchers("/welcome").hasRole("USER")
➡️"/welcome" 경로에 대한 접근을 "USER" 역할을 가진 사용자만 허용하도록 설정하는 역할
ex. "ROLE_USER" - 접근 가능
ex. "ROLE_ADMIN" - 접근 불가능 - .loginProcessingUrl(”/login”)
➡️/login은 loginform.html의 form태그의 action에 있는 url과 일치해야함
- lions_users 테이블에서 들어있는 데이터 확인 가능
▶️실습 - myinfo : 내 정보 보기 페이지
<body>
<h1>내 정보 보기</h1>
<div sec:authorize="isAuthenticated()">
<p>안녕하세요. <span sec:authentication="name"></span>님!! </p>
<p>당신의 권한은 <span sec:authentication="principal.authorities"></span>님!! </p>
<p>상세정보</p>
<ul>
<li>사용자 이름 : <span sec:authentication="principal.username"></span>님!! </li>
<li>계정 만료 여부 : <span sec:authentication="principal.accountNonExpired"></span>님!! </li>
<li>계정 잠김 여부 : <span sec:authentication="principal.accountNonLocked"></span>님!! </li>
<li>자격 증명 여부 : <span sec:authentication="principal.credentialIsNonExpired"></span>님!! </li>
<li>활성 여부 : <span sec:authentication="principal.enabled"></span>님!! </li>
</ul>
</div>
<div sec:authorize="!isAuthenticated()">
<p>로그인 되지 않았습니다.</p> <a href="/loginform">로그인</a> 해주세요.
</div>
</body>
▶️실습 - 회원 가입 시 권한 선택
signup.html
<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>
- 회원 가입에 checkbox를 넣어서 회원 가입 시 권한을 미리 선택할 수 있도록 구현 가능
domain/UserRegisterDTO
@Getter@Setter
@NoArgsConstructor
public class UserRegisterDTO {
private String username;
private String password;
private String name;
private String email;
private List<String> roles;
}
- 필드들은 signup의 (회원가입 폼의) 속성이름들과 일치해야함
- private List roles;
➡️권한 선택의 roles 같은 경우 USER, ADMIN 둘다 roles인데,
roles 리스트 안에 USER, ADMIN이 들어가는 형태 - 이 클래스는 클라이언트에서 받은 회원가입 데이터를 전달하기 위한 용도
- UserRegisterDTO는 단순히 요청 데이터를 담는 역할이므로 JPA 엔티티 객체를 정의할 때 사용하는@Entity는 불필요
Service - registUser 메소드 오버로딩
// registUser 메소드 오버로딩
public User registUser(UserRegisterDTO registerDTO){
String encodedPassword = passwordEncoder.encode(registerDTO.getPassword());
Set<Role> roles = registerDTO.getRoles().stream()
.map(roleRepository::findByName)
.flatMap(Optional::stream) // Optional이 비어있다면 무시 후 값만 추출
.collect(Collectors.toSet());
User user = new User();
user.setUsername(registerDTO.getUsername());
user.setPassword(encodedPassword);
user.setName(registerDTO.getName());
user.setEmail(registerDTO.getEmail());
user.setRoles(roles);
return userRepository.save(user);
}
- 회원가입 요청을 처리하고, DB에 유저 정보를 저장
- String encodedPassword = passwordEncoder.encode(registerDTO.getPassword());
➡️passwordEncoder.encode() : 비밀번호를 BCrypt 방식으로 암호화 - Set<Role> roles = registerDTO.getRoles().stream()
➡️유저가 선택한 권한 리스트를 Set<Role>로 변환
➡️registerDTO.getRoles() : 사용자가 선택한 권한(예: ["USER", "ADMIN"])을 리스트로 가져옴
➡️.stream() : 리스트를 스트림(Stream) 형태로 변환 - .flatMap(Optional::stream)
➡️roleRepository.findByName(role)은 Optional<Role>을 반환
flatMap(Optional::stream) 사용으로 값이 있는 경우만 추출하고, 없는 경우는 무시
➡️ex. findByName()이 존재하지 않는 역할을 조회하면 Optional.empty()가 반환
.flatMap(Optional::stream)을 사용하여 값이 존재하는 경우만 처리하고, empty()인 경우 무시가능
signup.html 수정
<form action="/userreg_role" method="post">
<!-- ... -->
<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>
- action에 /userreg → /userreg_role 로 변경
- 회원가입 시 권한 2개를 선택하여도 권한 2개를 잘 가져오고 있음
🚀회고 결과 :
이번 회고에서는 DB 명세를 확인 후 관계형 테이블을 생성할 수 있었고 이를 통해 권한에 따른 URL 접근도 설계할 수 있었다.
- 체크박스를 통한 값 가져오기
- 비밀번호 암호화 적응
느낀 점 :
비밀번호 암호화를 구현하는 방법에 대해 배울 수 있었던 것이 뜻깊었다.
SecurityConfig 설정 파일을 통해 접근 가능한 페이지를 permitAll(), requestMatchers()로 제한할 수 있었고
다 대 다 관계의 테이블을 Spring Security로 구현할 수 있었다.
🚀Docker에 대한 문제가 다시 한 번 발생해서 net stop winnat, net start winnat 명령어를 통해 해결할 수 있었다.
향후 계획 :
- 비밀번호 암호화 과정 흐름 공부
- 유저 리스트 출력같은 페이지도 실습 가능