Recording/멋쟁이사자처럼 BE 13기

[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_58일차_"Spring Security와 DB연결"

LEFT 2025. 3. 4. 17:44

🦁멋쟁이사자처럼 백엔드 부트캠프 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 명령어를 통해 해결할 수 있었다.

향후 계획 : 

- 비밀번호 암호화 과정 흐름 공부
- 유저 리스트 출력같은 페이지도 실습 가능