Spring Boot

Spring 5주차(회원 가입 기능 구현하기)

잔잔한 흐름 2025. 6. 1. 22:59

이 글은 <점프 투 스프링 부트!> 3판을 참고하여 만들어졌습니다.

3-06 회원 가입 기능 구현하기

회원 가입 가능 구성하기

회원 가입 기능을 구현하려면 회원 정보와 관련된 데이터를 저장하고 이를 관리하는 엔티티와 리포지터리 등을 만들어야 하고, 폼과 컨트롤러와 같은 요소를 생성해 사용자로부터 입력받은 데이터를 웹 프로그램에서 사용할 수 있도록 만들어야 한다.

회원 엔티티 생성하기

지금까지는 질문, 답변 엔티티만 사용했다면 이제 회원 정보와 관련된 데이터를 저장하는 엔티티가 필요하다. 즉, 회원 엔티티를 구상해야 한다.

  1. 회원 엔티티에는 최소한 다음 속성이 필요하다

속성 이름 설명

username 사용자 이름(또는 사용자ID)
password 비밀번호
email 이메일
  1. User 도메인을 만들어보자.
package com.mysite.sbb.user;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class SiteUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;

    private String password;

    @Column(unique = true)
    private String email;
}

⇒ 질문(Question)과 답변(Answer) 엔티티를 만드는 것과 동일한 방법으로 회원 엔티티(SiteUser 엔티티)를 만들었다.

  • 엔티티명을 SiteUser로 한 이유는 스프링 시큐리티에 이미 User 클래스가 있기 때문이다. 물론 패키지가 달라 User라는 이름을 사용할 수 있지만, 패키지 오용으로 인한 오류가 발생할 수 있으므로 SiteUser로 만들었다.
  • 그리고 username, email 속성에는 @Column(unique=true)으로 지정했다.
  • 여기서 unique=true는 유일한 값만 저장할 수 있음을 의미한다.
  • 즉, 값을 중복되게 저장할 수 없음을 말한다. ⇒ username과 email에 동일한 값이 저장되는 것을 막을 수 있다.
  • SITE_USER 테이블과 데이터 열들 그리고 unique로 설정한 속성들로 인해 생긴 UK_로 시작하는 인덱스들을 확인할 수 있다.

User 리포지터리와 서비스 생성하기

SiteUser 엔티티가 준비되었으니 이제 User 리포지터리와 User 서비스를 만들어보자.

UserRepository.java

package com.mysite.sbb.user;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<SiteUser, Long> {

}

⇒ SiteUser의 기본키 타입은 Long이므로 JpaRepository<SiteUser, Long>으로 사용했다.

UserService.java

package com.mysite.sbb.user;

import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public SiteUser create(String username, String email, String password){
        SiteUser user = new SiteUser();
        user.setUsername(username);
        user.setEmail(email);
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        user.setPassword(passwordEncoder.encode(password));
        this.userRepository.save(user);
        return user;
    }
}

  • User 서비스에는 User 리포지터리를 사용하여 회원(User) 데이터를 생성하는 create 메서드를 추가했다.
  • 이때 User의 비밀번호는 보안을 위해 반드시 암호화하여 저장해야 한다.
  • 그러므로 스프링 시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호를 저장했다.
  • BCryptPasswordEncoder 클래스는 비크립트 해시 함수(BScripting hashing function)를 사용한다.
  • 비그립트 해시 함수란? ⇒ 비크립트는 해시 함수의 하나로 주로 비밀번호와 같은 보안 정보를 안전하게 저장하고 검증할 때 사용하는 암호화 기술이다.

BCryptPasswordEncoder 객체를 직접 new로 생성하는 방식보다 PasswordEncoder 객체를 빈으로 등록해서 사용하는 것이 좋다.

⇒ 왜냐하면 암호화 방식을 변경하면 BCryptPasswordEncoder를 사용한 모든 프로그램을 일일이 찾아다니며 수정해야 되기 때문이다.

(PasswordEncoder는 BCryptPasswordEncoder의 인터페이스)

PasswordEncoder 빈을 만드는 가장 쉬운 방법은 @Configuration이 적용된 SecurityConfig.java 파일에 @Bean 메서드를 새로 추가하는 것이다.

SecurityConfig.java

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

⇒ BcryptPasswordEncoder 객체를 직접 생성하여 사용하지 않고 빈으로 등록한 Password Encoder 객체를 주입받아 사용할 수 있도록 수정했다. user.setPassword(passwordEncoder.encode(password));

회원 가입 폼 생성하기

UserCreateForm.java

package com.mysite.sbb.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserCreateForm {
    @Size(min = 3, max = 25)
    @NotEmpty(message = "사용자 ID는 필수 항목입니다.")
    private String username;

    @NotEmpty(message = "비밀번호는 필수 항목입니다.")
    private String password1;

    @NotEmpty(message = "이메일은 필수 항목입니다.")
    @Email
    private String mail;
}

  • username은 입력받는 데이터의 길이가 3~25사이여야 한다는 검증 조건을 설정했다.
  • @Size는 문자열의 길이가 최소 길이(min)과 최대 길이(max) 사이에 해당하는지를 검증한다.
  • password1과 password2는 ‘비밀번호’와 ‘비밀번호 확인’에 대한 속성이다.
  • 로그인할 때는 비밀번호가 한 번만 필요하지만 회원 가입 시에는 입력한 비밀번호가 정확한지 확인하기 위해 2개의 필드가 필요하므로 이와 같이 작성한다.
  • 그리고 email 속성에는 @Email 애너테이션 적용(해당 속성의 값이 이메일 형식과 일치하는지를 검증)

회원 가입 컨트롤러 생성하기

엔티티, 서비스 ⇒ 컨트롤러(URL 매핑)

UserController.java

package com.mysite.sbb.user;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    @GetMapping("/signup")
    public String signup(UserCreateForm userCreateForm){
        return "signup_from";
    }

    @PostMapping("/signup")
    public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult){
        if(bindingResult.hasErrors()){
            return "signup_form";
        }

        if(!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())){
            bindingResult.rejectValue("password2", "paswordInCorrect", "2개의 비밀번호가 일치하지 않습니다.");
            return "signup_form";
        }

        userService.create(userCreateForm.getUsername(), userCreateForm.getMail(), userCreateForm.getPassword1());

        return "redirect:/";
    }
}

  • /user/signup URL이 GET으로 요청되면 회원 가입을 위한 템플릿을 랜더링하고, POST로 요청되면 회원가입을 진행하도록 했다.
  • 회원 가입 시 password1과 password2가 동일한지를 검증하는 조건문을 추가했다.
  • 2개의 값이 서로 일치하지 않을 경우에는 bindingResult.rejectValue를 사용하여 입력받은 2개의 비밀번호가 일치하지 않는다는 오류가 발생하게 됐다.
  • bindingResult.rejectValue의 매개변수는 순서대로 각각 bindingResult.rejectValue(필드명, 오류 코드, 오류 메시지)를 의미한다.
  • userService.create 메서드를 사용하여 사용자로부터 전달받은 데이터를 저장한다.

회원 가입 화면 구성하기

회원 가입 템플릿 생성하기

signup_form.html

⇒ ‘사용자ID’, ‘비밀번호’, ‘비밀번호 확인’, ‘이메일’에 해당하는 input 요소들을 추가하여 회원 가입 화면에 각각의 필드가 나타나도록 했다.

그리고 [회원 가입] 버튼을 누르면 <form> 데이터가 POST 방식으로 /user/signup/URL에 전송된다.

내비게이션 바에 회원 가입 링크 추가하기

navbar.html에 추가하기

<li class="nav-item">
   <a class="nav-link" th:href="@{/user/signup}">회원 가입</a>
</li>

중복 회원 가입 방지하기

이미 등록된 사용자 ID 또는 이메일 주소를 DB에 저장하는 것은 회원 엔티티의 unique=true 설정으로 허용되지 않으므로 이와 같은 오류가 발생

⇒ 화면에 500 오류 메시지를 보여주는 것은 좋지 않다. 회원 가입 화면에서

 회원 가입 시 이미 동일한 ID와 메일 주소가 있다는 것을 알리는 메시지가 나타나도록 수정해보자.

UserController.java

package com.mysite.sbb.user;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    @GetMapping("/signup")
    public String signup(UserCreateForm userCreateForm){
        return "signup_form";
    }

    @PostMapping("/signup")
    public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult){
        if(bindingResult.hasErrors()){
            return "signup_form";
        }

        if(!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())){
            bindingResult.rejectValue("password2", "paswordInCorrect", "2개의 비밀번호가 일치하지 않습니다.");
            return "signup_form";
        }

        try{
            userService.create(userCreateForm.getUsername(), userCreateForm.getMail(), userCreateForm.getPassword1());
        }catch (DataIntegrityViolationException e){
            e.printStackTrace();
            bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
            return "signup_form";
        }catch (Exception e){
            e.printStackTrace();
            bindingResult.reject("signupFailed", e.getMessage());
            return "signup_form";
        }

        userService.create(userCreateForm.getUsername(), userCreateForm.getMail(), userCreateForm.getPassword1());

        return "redirect:/";
    }
}

  • 사용자 ID 또는 이메일 주소가 이미 존재할 경우에는 DateIntegrityViolationException이라는 예외가 발생하므로 ‘이미 등록된 사용자입니다.’라는 오류 메시지가 화면에 표시하도록 했다.
  • 그리고 그 밖에 다른 예외들은 해당 예외에 관한 구체적인 오류 메시지를 출력하도록 e.getMessage()를 사용했다.
  • bindingResult.reject(오류 코드, 오류 메시지)는 UseCreateForm의 검증에 의한 오류 외에 일반적인 오류를 발생시킬 때 사용한다.

3-07. 로그인과 로그 아웃 기능 구현하기

로그인 기능 구현하기

⇒ 회원 가입 단계에서 SITE_USER 테이블에 회원 정보를 저장했다. SITE_USER 테이블에 지정된 사용자명(사용자 ID)과 비밀번호로 로그인을 하려면 복잡한 단계를 거쳐야 한다. 하지만 스프링 시큐리티를 사용하면 이 단계를 보다 쉽게 진행할 수 있다.

로그인 URL 등록하기

package com.mysite.sbb;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf
                        .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**"))
                        .disable()
                )
                .headers(headers -> headers
                        .frameOptions(frame -> frame.sameOrigin())
                ).formLogin((formLogin) -> formLogin
                        .loginPage("/user/login")
                        .defaultSuccessUrl("/"));
        return http.build();
    }

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

  • .formLogin 메서드는 스프링 시큐리티의 로그인 설정을 담당하는 부분
  • 설정 내용은 로그인 페이지의 URL은 /user/login이고 로그인 성공 시에 이동할 페이지는 루트 URL(/)임을 의미한다.

User 컨트롤러에 URL 매핑 추가하기

스프링 시큐리티에 로그인 URL을 /user/login으로 설정했으므로 UserController에 해당 URL을 매핑해야 한다.

UserController.java

@GetMapping("/login")
    public String login(){
        return "login_form";
    }
  • @GetMapping(”/login”)을 통해 /user/login URL로 들어오는 GET 요청을 이 메서드가 처리한다. (/user/login URL을 매핑)
  • 매핑한 login 메서드는 login_form.html 템플릿을 출력하도록 만든다.
  • 실제 로그인을 진행하는 @PostMapping 방식의 메서드는 스프링 시큐리티가 대신 처리하므로 우리가 직접 코드를 작성하여 구현할 필요가 없다.

로그인 템플릿 작성하기

  • 스프링 시큐리티의 로그인이 실패할 경우에는 시큐리티의 기능으로 인해 로그인 페이지로 리다이렉트된다. (이때 페이지 매개변수로 error가 함께 전달된다.)
  • 로그인 페이지의 매개변수로 error가 전달될 경우 ‘사용자 ID 또는 비밀번호를 확인해 주세요.’라는 오류 메시지를 출력
  • 스프링 시큐리티는 로그인 실패 시 http://localhost:8080/user/login?error와 같이 error 매개변수를 전달한다.

but, 아직 로그인을 수행할 수는 없다. 왜냐면 스프링 시큐리티에 무엇을 기준으로 로그인해야 하는지 아직 설정하지 않았기 때문이다.

  • 스프링 시큐리티를 통해 로그인을 수행하는 방법에는 여러 가지가 있다.
  • 가장 간단한 방법으로 SecurityConfig.java와 같은 시큐리티 설정 파일에 사용자ID와 비밀번호를 직접 등록하여 인증을 처리하는 메모리 방식이 있다.
  • 가입을 통해 회원 정보를 DB에 저장했으므로 DB에서 회원 정보를 조회하여 로그인하는 방법을 사용할 것이다.

다음은 DB에서 사용자를 조회하는 서비스(UserSecurityService.java)를 만들고, 그 서비스를 스프링 시큐리티에 등록하는 방법을 알아보자.

하지만 UserSecurityService 서비스를 만들기 전에 UserRepository를 수정하고 UserRole 클래스를 생성하는 등 준비를 해야 한다.

User 리포지터리 수정하기

뒤에서 생성할 UserSecurityService는 사용자 ID를 조회하는 기능이 필요하므로 사용자 ID로 SiteUser 엔티티를 조회하는 findByUsername 메서드를 User 리포지터리에 추가하자

UserRepository.java

package com.mysite.sbb.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<SiteUser, Long> {
    Optional<SiteUser> findByUsername(String username);
}

UserRole 파일 생성하기

스프링 시큐리티는 인증뿐만 아니라 권한도 관리한다.

스프링 시큐리티는 사용자 인증 후에 사용자에게 부여할 권한과 관련된 내용이 필요하다.

사용자가 로그인한 후, ADMIN 또는 USER와 같은 권한을 부여해야 한다.

UserRole.java

package com.mysite.sbb.user;

import lombok.Getter;

@Getter
public enum UserRole {
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");
    
    UserRole(String value){
        this.value = value;
    }
    
    private String value;
}

  • UserRole은 enum 자료형(열거 자료형)
  • 관리자를 의미하는 ADMIN과 사용자를 의미하는 USER라는 상수를 만들었다.
  • ADMIN은 ‘ROLE_ADMIN’, USER는 ‘ROLE_USER’라는 값을 부여했다.
  • UserRole의 ADMIN과 USER 상수는 값을 변경할 필요가 없으므로 @Setter없이 @Getter만 사용

UserSecurityService 서비스 생성하기

UserSecurityService.java

package com.mysite.sbb.user;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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 java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username)throws UsernameNotFoundException{
        Optional<SiteUser> _siteUser = this.userRepository.findByUsername(username);
        if(_siteUser.isEmpty()){
            throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
        }
        SiteUser siteUser = _siteUser.get();
        // 사용자의 권한 정보를 나타내는 GrantedAuthority 객체를 생성하는데 사용할 리스트를 생성한다.
        List<GrantedAuthority> authorities = new ArrayList<>();
        if("admin".equals(username)){
            authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
        }else{
            authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
        }
        return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);
    }
}

  • 스프링 시큐리티가 로그인 시 사용할 UserSecurityService는 스프링 시큐리티가 제공하는 UserDetailService 인터페이스를 구현(implements)해야 한다.
  • 스프링 시큐리티의 UserDetailsService는 loadUserByname 메서드를 구현하도록 강제하는 인터페이스이다.
  • loadUserByname 메서드는 사용자명(username)으로 스프링 시큐리티의 사용자(User)객체를 조회하여 리턴하는 메서드이다.
  • loadUserByUsername 메서드는 사용자명으로 SiteUser 객체를 조회하고, 만약 사용자명에 해당하는 데이터가 없을 경우에는 UsernameNotFoundException을 발생시킨다.
  • 사용자명이 ‘admin’인 경우 ADMIN 권한(ROLE_ADMIN)을 부여하고 그 이외의 경우에는 USER 권한(ROLE_USER)을 부여했다.
  • 마지막으로 User 객체를 생성해 반환하는데, 이 객체는 스프링 시큐리티에서 사용하며 User 생성자에는 사용자명, 비밀번호, 권한 리스트가 전달된다.
  • 스프링 시큐리티는 loadUserByname 메서드에 의해 리턴된 User 객체의 비밀번호가 사용자로부터 입력받은 비밀번호와 일치하는지를 검사하는 기능을 내부에 가지고 있다.

스프링 시큐리티 설정 수정하기

SecurityConfig.java

@Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception{
        return authenticationConfiguration.getAuthenticationManager();
    }
  • AuthenticationManager 빈 생성
  • AuthenticationManager는 스프링 시큐리티의 인증을 처리
  • AuthenticationManager는 사용자 인증 시 앞에서 작성한 UserSecurityService와 PasswordEncoder를 내부적으로 사용해 인증과 권한 부여 프로세스를 처리

로그인 화면 수정하기

  1. 로그인 기능을 구현하는 마지막 단계로 로그인 페이지에 곧바로 진입할 수 있도록 로그인 링크(/user/login)을 내비게이션 바에 추가해보자.
  2. 로그인 후에도 내비게이션 바에는 여전히 ‘로그인’이란 이름으로 링크가 ㅍ시된다. 일반적으로 로그인한 상태라면 로그아웃을 위해 ‘로그아웃’ 링크로 바뀌어야 한다. (반대로 로그아웃 상태에서는 ‘로그인’ 링크로 바뀌어야 한다.)
  • sec:authorize=”isAnonymous()”: 로그인되지 않은 경우에 해당 요소(로그인 링크)가 표시
  • sec:authorize=”isAuthenticated()”: 로그인된 경우에 해당 요소(로그아웃 링크)가 표시

navbar.html

<li class="nav-item">
                    <a class="nav-link" sec:authorize="isAnnoymous()"
                       th:href="@{/user/login}">로그인</a>
                    <a class="nav-link" sec:authorize="isAuthenticated()"
                       th:href="@{/user/logout}">로그아웃</a>
</li>

로그아웃 기능 구현하기

  1. navbar.html에서 ‘로그아웃’ 링크 ⇒ /user/logout으로 설정
  2. SecurityConfig 파일 수정

SecurityConfig.java

package com.mysite.sbb;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf
                        .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**"))
                        .disable()
                )
                .headers(headers -> headers
                        .frameOptions(frame -> frame.sameOrigin())
                ).formLogin((formLogin) -> formLogin
                        .loginPage("/user/login")
                        .defaultSuccessUrl("/")).
                logout((logout) -> logout
                        .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
                        .logoutSuccessUrl("/").invalidateHttpSession(true));
        return http.build();
    }

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

    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception{
        return authenticationConfiguration.getAuthenticationManager();
    }
}

⇒ .invalidateHttpSession(true)를 통해 로그아웃 시 생성된 사용자 세션도 삭제

3-08. 글쓴이 항목 추가하기

⇒ 질문 또는 답변을 작성할 때 사용자 정보도 DB에 함께 저장해보자.

엔티티에 속성 추가하기

먼저 기존에 만든 Question(질문)과 Answer(답변) 엔티티에 글쓴이에 해당하는 author 속성을 추가해보자.

질문 엔티티에 속성 추가하기

⇒ author 속성에 @ManyToOne 애너테이션 추가

답변 엔티티에 속성 추가하기

⇒ 같은 방법으로 추가

테이블 확인하기

question, answer 테이블에 author_id 열이 생성된 것을 확인할 수 있다.

글쓴이 저장하기

  • 이제 Question, Answer 엔티티에 author 속성이 추가되었으므로 질문과 답변 데이터를 저장할 때 author(글쓴이)도 함께 저장
  • 새로운 데이터를 저장하려면 서버와 DB를 관리하는 컨트롤러와 서비스(또는 리포지터리)에도 관련 내용을 업데이트

답변 컨트롤러와 서비스 업데이트하기

AnswerController.java

  1. 현재 로그인한 사용자의 정보를 알려면 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다. 이와 같이 createAnswer 메서드에 Principal 객체를 매개변수로 지정하는 작업까지 해보자.
  2. prinicipal 객체를 사용하면 이제 로그인한 사용자명을 알 수 잇으므로 사용자명으로 SiteUser 객체를 조회할 수 있다.
  3. 먼저 SiteUser를 조회할 수 있는 getUser 메서드를 UserService에 추가하자
public SiteUser getUser(String username){
        Optional<SiteUser> siteUser = this.userRepository.findByUsername(username);

        if(siteUser.isPresent()){
            return siteUser.get();
        }else{
            throw new DataNotFoundException("siteuser not found");
        }
    

⇒ getUser 메서드는 userRepostiory의 findByUsername 메서드를 사용하여 쉽게 만들 수 있다. 사용자명에 해당하는 데이터가 없을 경우에는 DataNotFoundException이 발생하도록 했다.

AnswerController.java

⇒ principal 객체를 통해 사용자명을 얻은 후, 사용자명을 통해 SiteUser 객체를 얻어 답변을 등록할때 사용

질문 컨트롤러와 서비스 업데이트하기

QuestionService.java

⇒ create 메서드에 SiteUser를 추가하여 Question 데이터를 생성하도록 수정했다.

QuestionController.java

⇒ Principal 객체를 통해 사용자명을 구한 후, SiteUser를 조회하여 질문 저장 시 함께 저장할 수 있도록 했다.

로그인 페이지로 이동시키기

  1. 로그아웃 상태에서 질문 또는 답변을 등록해보자. (500 오류(서버 오류)가 발생) ⇒ 이는 principal 객체가 널(null)이라서 발생한 오류다. principal 객체는 로그인을 해야만 생성되는 객체인데 principal 객체에 값이 없어 오류가 발생
  2. 문제를 해결하기 위해서 principal 객체를 사용하는 메서드에 @PreAuthorize(”isAuthenticated()”) 애너테이션을 사용.
  3. 이 애너테이션은 로그인한 경우에만 실행.
  4. 만약에 로그아웃 상태에서 호출되면 로그인 페이지로 강제 이동

QuestionController.java

⇒ 로그인이 필요한 메서드(질문 등록과 관련된 메서드)에 애너테이션 적용

AnswerContoller.java도 마찬가지로 수정

SecurityConfig.java

@PreAuthorize 애너테이션이 동작할 수 있도록 스프링 시큐리티의 설정도 수정해야 한다.

  • SecurityConfig에 적용한 @EnableMethodSecurity 애너테이션의 prePostEnabled = true는 QuestionController와 AnswerController에서 로그인 여부를 판별할 때 사용한 @PreAuthorize 애너테이션을 사용하기 위해 반드시 필요한 설정이다.
  • 로그인을 완료하면 이전에 요청한 질문 등록 페이지가 등장한다. 이는 로그인 후에 원래 가려고 했던 페이지로 리다이렉트시키는 스프링 시큐리티의 기능 덕분에 가능한 것이다.

답변 작성 막아두기

  • 현재 질문 등록 페이지에서는 사용자가 로그아웃 상태라면 아예 글을 작성할 수 없다.
  • 하지만 답변 등록 페이지에서는 로그아웃 상태에서도 글을 작성할 수 있어 답변을 작성한 후 [답변 등록] 버튼을 눌러야 로그인 화면으로 이동한다.
  • 사용자가 작성한 답변이 사라지는 문제가 있다.
  • 이 문제를 해결하려면 사용자가 로그아웃 상태인 경우 아예 답변 작성을 못하게 막는 것이 좋은 방법일 것이다.

question_detail.html

<form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
        <div th:replace="~{form_errors :: formErrorsFragment}"></div>
        <textarea sec:authorize="isAnnoymous()" disabled
                  th:field="*{content}" class="form-control" rows="10"></textarea>
        <textarea sec:authorize="isAuthenticated()"
                  th:field="*{content}" class="form-control" rows="10"></textarea>
        <input type="submit" value="답변등록" class="btn btn-primary my-2">
    </form>
  • 로그인 상태가 아닌 경우 textarea 태그에 disabled 속성을 적용하여 사용자가 화면에서 아예 입력을 못하게 만들었다.
  • sec:authorize=”isAnoymous()”, sec:authorize=”isAuthenticated()”는 혅재 사용자의 로그인 상태를 체크하는 속성

화면에 글쓴이 나타내기

앞서 Question 엔티티와 Answer 엔티티에 Author 속성을 추가했다.

질문 목록에 글쓴이 표시하기

question_list.html

<thead class="table-dark">
            <tr class="text-center">
                <th>번호</th>
                <th style="width:50%">제목</th>
                <th>글쓴이</th>
                <th>작성일시</th>
            </tr>
</thead>
  • 각 요소들을 가운데 정렬하도록 tr 태그에 text-center 클래스를 추가하고 <th>제목</th>에서는 너비가 전체에서 50%를 차지자하도록 지정.

question_list.html

<td><span th:if="${question.author != null}"
                th:text="${question.author.username}"></span></td>

⇒ 글쓴이를 표시하기 위해 작성한다.

  • <td> … </td> 요소를 삽입하여 질문의 글쓴이를 표시.
  • 글쓴이 정보 없이 저장된 기존의 질문들은 author 속성에 해당하는 데이터가 없으므로(author 속성의 값으로 null을 가지고 있으므로) author 속성의 값이 null이 아닌 경우만 글쓴이를 표시
  • 여기서 표시되는 항목을 모두 가운데 정렬하도록 tr 요소에 text-center 클래스를 추가

3-09 수정과 삭제 기능 추가하기

질문 또는 답변을 작성한 후 이 글들을 수정하거나 삭제할 수 있어야 한다.

이번 절에서는 앞에서 작성한 질문 또는 답변을 수정하거나 삭제하는 기능을 추가해보자.

수정 일시 추가하기

SBB에 질문 또는 답변을 수정하거나 삭제하는 기능을 추가하기 전에 질문이나 답변이 언제 수정되었는지 확인할 수 있도록 Question 엔티티와 Answer 엔티티에 수정 일시에 의미하는 modifyDate 속성을 추가해 보자

질문 수정 기능 생성하기

질문 수정 버튼 만들기

사용자가 질문 상세 화면에서 [수정] 버튼을 클릭하면 수정할 수 있는 화면으로 진입할 수 있도록 추가해보자.

<div class="my-3">
                <a th:href="@{|/question/modify/${question.id|}" class="btn btn-smbtn-outline-secondary"
                    sec:authorize="isAuthenticated()"
                    th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
                    th:text="수정">
                </a>
            </div>
  • [수정] 버튼이 로그인한 사용자와 글쓴이가 동일한 경우에만 노출되도록 #authentication.getPrincipal().getUsername() == question.author.username을 적용했다.
  • #authentication.getPrincipal()은 타임리프에서 스프링 시큐리티와 함께 사용하는 표현식 ⇒ 이를 통해 현재 사용자가 인증되었다면 사용자 이름(사용자 ID)을 알 수 있다.
  • 만약 로그인한 사용자와 글쓴이가 다르다면 이 [수정] 버튼은 보이지 않을 것이다.

질문 컨트롤러 수정하기 1

[수정] 버튼에 GET 방식의 @{|/question/modify/${question.id|} 링크가 추가되었으므로 다음과 같이 수정해보자.

QuestionController.java

    @PreAuthorize("isAuthenticated()")
    @GetMapping("/modify/{id}")
    public String questionModify(QuestionForm questionForm, @PathVariable("id") Integer id, Principal principal){
        Question question = this.questionService.getQuestion(id);
        if(!question.getAuthor().getUsername().equals(principal.getName())){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정 권한이 없습니다.");
        }
        questionForm.setSubject(question.getSubject());
        questionForm.setContent(question.getContent());
        return "question_form";
    }
}
  • 이와 같이 questionModify 메서드를 추가했다.
  • 만약 현재 로그인한 사용자와 질문의 작성자가 동일하지 않을 경우 ‘수정 권한이 없습니다’라는 오류가 발생하도록 했다.
  • 그리고 수정한 질문의 제목과 화면에 보여주기 위해 questionForm 객체에 id값으로 조회한 질문의 제목(subject)과 내용(object)의 값을 담아서 템플릿으로 전달했다.
  • 이 과정이 없다면 질문 수정 화면에 ‘제목’, ‘내용’의 값이 채워지지 않아 비워져 보일 것이다.
  • 질문을 수정할 수 있는 템플릿을 따로 만들지 않고 질문을 등록했을 때 사용한 question_form.html 템플릿을 사용한다는 점이다.

질문 등록 템플릿 수정하기

질문을 수정하기 위한 템플릿을 새로 작성해도 문제는 없지만 제목과 내용을 기입하는 화면의 모양이 동일하므로 같은 템플릿을 사용하려고 한다.

그런데 question_form.html은 질문 등록을 위해 만든 템플릿이어서 조금 수정해야 질문 등록과 수정 기능을 함께 사용할 수 있다.

  • 먼저 기존에 있던 <form> 태그의 th:action 속성을 삭제해야 한다.
  • 단, th:action 속성을 삭제하면 CSRF값이 자동으로 생성되지 않아서 CSRF값을 설정하기 위해 hidden 형태로 input 요소를 이와 같이 작성하여 추가해야 한다.
  • CSRF 값을 수동으로라도 추가해야 되는 이유는 스프링 시큐리티를 사용할 때 CSRF 값이 반드시 필요하기 때문이다.
  • <form> 태그의 action 속성 없이 폼을 전송(submit)하면 action 속성이 없더라도 자동으로 현재 URL(URL 주소)을 기준으로 전송되는 규칙이 있다.
  • 즉, 질문 등록 시에 브라우저에 표시되는 URL은 /question/create 이어서 action 속성이 지정되지 않더라도 POST로 폼 전송할 때 action 속성으로 /question/create가 자동 설정된다.
  • 질문 수정 시에 브라우저에 표시되는 URL은 /question/modify/2와 같은 URL이기 때문에 POST로 폼 전송할 때 action 속성에 /question/modify/2와 같은 URL이 설정되는 것이다.

질문 서비스 수정하기

QuestionService.java

public void modify(Question question, String subject, String content){
        question.setSubject(subject);
        question.setContent(content);
        question.setModifyDate(LocalDateTime.now());
        this.questionRepository.save(question);
    }

⇒ 질문 제목과 내용을 수정할 수 있는 modify 메서드를 추가했다.

질문 컨트롤러 수정하기2

다시 질문 컨트롤러로 돌아와 질문을 수정하는 화면에서 질문 제목이나 내용을 변경하고 [저장하기] 버튼을 누르면 호출되는 POST 요청을 처리하기 위해 다음과 같은 메서드를 추가해보자.

QuestionController.java

@PreAuthorize("isAuthenticated()")
    @PostMapping("/modify/{id}")
    public String questionModify(@Valid QuestionForm questionForm, BindingResult bindingResult, Principal principal, @PathVariable("id") Integer id){
        if(bindingResult.hasErrors()){
            return "question_form";
        }
        Question question = this.questionService.getQuestion(id);
        if(!question.getAuthor().getUsername().equals(principal.getName())){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정 권한이 없습니다.");
        }
        this.questionService.modify(question, questionForm.getSubject(), questionForm.getContent());
        return String.format("redirect:/question/detail/%s", id);
    }
  • POST 형식의 /question/modify/{id} 요청을 처리하기 위해 이와 같이 questionModify 메서드를 추가했다.
  • questionModify 메서드는 questionForm의 데이터를 검증하고 로그인한 사용자와 수정하려는 질문의 작성자가 동일한지도 검증한다.
  • 검증이 통과되면 QuestionService에서 작성한 modify 메서드를 호출하여 질문 데이터를 수정한다.
  • 그리고 수정이 완료되면 질문 상세 화면(/question/detail/(숫자))으로 리다이렉트한다.

질문 삭제 기능 생성하기

질문 삭제 버튼 만들기

question_detail.html

<a href="javascript:void(0);"
                   th:data-uri="@{|/question/delete/${question.id}|}"
                   class="delete btn btn-sm btn-outline-secondary"
                   sec:authorize="isAuthenticated()"
                   th:if="${question.author != null and #authentication.getPrincipal.getPrincipal().getUsername() == question.author.username}"
                   th:text="삭제"></a>
  • 로그인한 사용자가 자신이 작성한 질문을 삭제할 수 있도록 [삭제] 버튼을 클릭하면 자바스크립트 코드가 실행되도록 구현했다.
  • [삭제] 버튼은 [수정] 버튼과는 달리 href 속성값을 javascript:void(0)로 설정하고 삭제를 실행할 URL을 얻기 위해 th:data-uri 속성을 추가한 뒤, [삭제] 버튼을 클릭하는 이벤트를 확인하기 위해 class 속성에 delete 항목을 추가했다.
  • href에 삭제를 위한 URL을 직접 사용하지 않고 이러한 방식을 사용한 이유는 [삭제] 버튼을 클릭했을 때 “정말로 삭제하시겠습니까?”와 같은 메시지와 함께 별도의 확인 절차를 중간에 끼워 넣기 위해서이다.
  • 만약 href에 삭제를 위한 URL을 직접 사용한다면 삭제를 확인하는 과정을 거치치 않고 질문이 삭제되어 버릴 것이다.

삭제를 위한 자바스크립트 작성하기

자바스크립트는 HTML, CSS와 함께 사용하며 웹 페이지에 동적인 기능을 추가할 때 사용하는 스크립트 언어이다.

<script type='text/javascript'>
const delete_elements = document.getElementByClassName("delete");
Array.from(delete_elements).forEach(function(element){
		element.addEventListener('click', function(){
			  if(confirm("정말로 삭제하시겠습니까")){
			      location.href = this.dataset.uri;
			  };
		});
});	  
</script>		  
  • 이 코드의 의미는 delete라는 클래스를 포함하는 컴포넌트를 클릭하면 ‘정말로 삭제하시겠습니까?”라고 질문하고 [확인]을 클릭했을 때 해당 컴포넌트에 속성으로 지정된 data-uri값으로 URL을 호출하라는 의미이다.
  • [확인] 대신 [취소]를 선택하면 아무런 일도 발생하지 않을 것이다.
  • 따라서 이와 같은 스크립트를 추가하면 [삭제] 버튼을 클릭하고 [확인]을 선택하면 data-uri 속성에 해당하는 @{|/question/delete/${question.id}|} URL이 호출될 것이다.

화면 출력이 완료된 후에 자바스크립트가 실행되는 것이 좋기 때문이다.

화면 출력이 완료되지 않은 상태에서 자바스크립트를 실행하면 오류가 발생할 수도 있고 화면 로딩이 지연될 수도 있다.

질문 서비스와 컨트롤러 수정하기

QuestionService.java

@PreAuthorize("isAuthenticated()")
    @GetMapping("/delete/{id}")
    public String questionDelete(Principal principal, @PathVariable("id") Integer id){
        Question question = this.questionService.getQuestion(id);
        if(!question.getAuthor().getUsername().equals(principal.getName())){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제 권한이 없습니다.");
        }
        this.questionService.delete(question);
        return "redirect:/";
    }
  • 사용자가 [삭제] 버튼을 클릭했다면 URL로 전달받은 id값을 사용하여 Question 데이터를 조회한 후, 로그인한 사용자와 질문 작성자가 동일한 경우 앞서 작성한 서비스를 이용하여 질문을 삭제하게 했다. 그리고 질문을 삭제한 후에는 질문 목록 화면(/)으로 돌아갈 수 있도록 했다.

답변 수정 기능 추가하기

버튼 추가하고 서비스와 컨트롤러 수정하기

답변 수정 기능을 구현하기 위한 템플릿이 따로 없으므로 답변 수정 시 사용할 템플릿이 추가로 필요하다.

question_detail.html

<div class="my-3">
                <a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-smbtn-outline-secondary"
                   sec:authorize="isAuthenticated()"
                   th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                   th:text="수정"></a>
            </div>

⇒ 로그인한 사용자와 답변 작성자가 동일한 경우 답변의 [수정] 버튼이 노출되도록 했다.

 [답변] 버튼을 누르면 ‘/answer/modify/답변ID’ 형태의 URL이 GET 방식으로 요청

AnswerService.java

public Answer getAnswer(Integer id){
        Optional<Answer> answer = this.answerRepository.findById(id);
        if(answer.isPresent()){
            return answer.get();
        }else{
            throw new DataNotFoundException("answer not found");
        }
    }

    public void modify(Answer answer, String content){
        answer.setContent(content);
        answer.setModifyDate(LocalDateTime.now());
        this.answerRepository.save(answer);
    }

⇒ getAnswer 메서드와 답변 내용을 수정하는 modify 메서드를 추가했다.

AnswerController.java

@PreAuthorize("isAuthenticated()")
    @GetMapping("/modify/{id}")
    public String answerModify(AnswerForm answerForm, @PathVariable("id") Integer id, Principal principal){
        Answer answer = this.answerService.getAnswer(id);
        if(!answer.getAuthor().getUsername().equals(principal.getName())){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정 권한이 없습니다.");
        }
        answerForm.setContent(answer.getContent());
        return "answer_form";
    }
  • DB에서 답변 ID를 통해 조회한 답변 데이터의 내용(content)을 AnswerForm 객체에 대입하여 answer_form.html 템플릿에서 사용

답변 수정 템플릿 생성하기

answer_form.html

답변 수정
  • 답변 작성 시 사용하는 <form> 태그에도 역시 action 속성을 사용하지 않았다.

답변 컨트롤러 재수정하기

/answer/modify/답변 ID URL을 처리하기 위해 코드를 추가해보자.

AnswerController.java

@PreAuthorize("isAuthenticated()")
    @GetMapping("/modify/{id}")
    public String answerModify(@Valid AnswerForm answerForm, BindingResult bindingResult, @PathVariable("id") Integer id, Principal principal){
        if(bindingResult.hasErrors()){
            return "answer_form";
        }

        Answer answer = this.answerService.getAnswer(id);
        if(!answer.getAuthor().getUsername().equals(principal.getName())){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정 권한이 없습니다.");
        }
        this.answerService.modify(answer, answerForm.getContent());
        return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
    }

⇒ POST 방식의 답변 수정을 처리하기 위해 answerModify 메서드를 추가했다. 그리고 답변 수정을 완료한 후에는 질문 상세 페이지로 리다이렉트함.

답변 삭제 기능 추가하기

[수정] 버튼 옆에 [삭제] 버튼이 노출되도록 [삭제] 버튼을 생성하는 코드를 추가했다. 질문의 [삭제] 버튼과 마찬가지로 답변의 [삭제] delete 클래스를 적용했으므로 [삭제] 버튼을 누르면 앞서 작성한 자바스크립트에 의해 data-uri 속성에 설정한 url이 실행된다.

⇒ 이미 같은 역할을 담당하는 자바스크립트가 작성되어 있으므로 별도로 스크립트를 추가하지 않는다.

<a href="javascript:void(0);" th:data-url="@{|/answer/delete/${answer.id|}"
                   class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
                   th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                   th:text="삭제"></a>

AnswerService.java

public void delete(Answer answer){
		this.answerRepository.delete(answer);

AnswerController.java

@PreAuthorize("isAuthenticated()")
    @GetMapping("/delete/{id}")
    public String answerDelete(Principal principal, @PathVariable("id") Integer id){
        Answer answer = this.answerService.getAnswer(id);
        if(!answer.getAuthor().getUsername().equals(principal.getName())){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제 권한이 없습니다.");
        }
        this.answerService.delete(answer);
        return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
    }

답변을 삭제하는 answerDelete 메서드 추가

수정 일시 표시하기

<div th:if="${question.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3"
                     <div class="mb-2">modified at</div>
                     <div th:text="${#temporals.format(question.modifyDate, 'yyyy-MM-dd HH:mm)}"></div>
                </div>
<div th:if="${answer.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
                    <div class="mb-2">modified at</div>
                    <div th:text="${#temporals.format(answer.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
                </div>