JUINTINATION

Board Clone 프로젝트에 Spring Security를 활용한 로그인 기능 구현하기 본문

Java Spring Boot

Board Clone 프로젝트에 Spring Security를 활용한 로그인 기능 구현하기

DEOKJAE KWON 2024. 8. 10. 02:28
반응형

프로젝트 소개

기존의 코드로 배우는 스프링 부트 웹 프로젝트 Board Clone 프로젝트는 예전에 스프링 공부를 위해 ETRI에서 연구연수생으로 근무할 때 대여했던 코드로 배우는 스프링 부트 웹 프로젝트 책과 해당 코드가 적힌 깃허브를 참고하여 게시판 프로젝트를 따라 치며 만든 프로젝트이다. 이 프로젝트는 게시판의 회원이 글을 쓰거나 댓글을 수정할 수 있지만 임시로 만든 회원 데이터로만 테스트를 진행하였으며 회원가입 및 로그인 기능이 구현되지 않아서 실제로 사용이 불가능하다는 문제점이 있었다. 스프링 부트 버전이 올라가면서 스프링 시큐리티에 관련된 많은 기능이 deprecated되고, 문법이 바뀐 경우가 너무 많아서 당시에 포기하고 넘어갔었는데 우연히 개발자 유미님의 스프링 시큐리티 관련 유튜브 재생목록을 보게 되었고, 해당 프로젝트에 스프링 시큐리티를 활용한 로그인 기능을 추가하였다. Docker 관련 내용은 최근에 Dockerfile의 빌드 관련 관련 오류를 발견하고 수정한 스프링부트 + MySQL 프로젝트 도커라이징하기 글을 참고하면 된다.

 

코드로 배우는 스프링 부트 웹 프로젝트 Board Clone

지난 Mac OS 자바 버전 여러 개 관리하기 글에서 잠깐 언급했듯이 스프링을 쓸 일이 생겨서 jdk 17 버전을 설치했었다. 이후에 ETRI에서 대여한 코드로 배우는 스프링 부트 웹 프로젝트 책과 해당 코

juintination.tistory.com

 

스프링부트 + MySQL 프로젝트 도커라이징하기

지난번에 책을 보면서 따라서 만든 간단한 게시판 프로젝트인 Board-Clone 프로젝트를 도커라이징해봤다. Express.js를 사용한 스퍼트 프로젝트에도 적용해본 적이 있는데 그 내용을 내가 안 적어둬

juintination.tistory.com

프로젝트 내용

기존의 프로젝트의 내용은 코드로 배우는 스프링 부트 웹 프로젝트 Board Clone 글을 참고하면 되며, 아래부터는 바뀌거나 추가된 내용에 대해서 다룰 것이다.관련 코드는 깃허브에 올렸던 커밋 내용을 보면 더 자세히 확인할 수 있다.

 

feat: add membership features using Spring Security · juintination/Board-Clone@186a27d

juintination committed Aug 8, 2024

github.com

또한 스프링 시큐리티의 개념적인 부분에 대해서는 다루지 않을 것이며, 이 부분은 추후에 추가적인 공부를 진행한 후에 다른 글에 추가할 예정이다.

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
testImplementation 'org.springframework.security:spring-security-test'

build.gradle은 위와 같이 스프링 시큐리티 관련 내용을 추가하여야 한다.

application.properties

server.servlet.session.timeout=90m

application.properties에 세션 타임아웃 설정을 통해 로그인 이후 세션이 유지되고 소멸하는 시간을 90분으로 설정하였다. 세션 소멸 시점은 서버에 마지막 특정 요청을 수행한 뒤에 설정한 시간 만큼 유지된다. (기본 시간 1800초)

config/SecurityConfig.java

package org.zerock.board.config;

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.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/css/**", "/vendor/**", "/favicon.ico/**").permitAll()
                        .requestMatchers("/", "/login", "/loginProc", "/join", "/joinProc", "/checkUsername").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .requestMatchers("/board/**").hasAnyRole("ADMIN", "USER")
                        .requestMatchers("/replies/**").hasAnyRole("ADMIN", "USER")
                        .anyRequest().authenticated()
                );

        http
                .formLogin((auth) -> auth.loginPage("/login")
                        .loginProcessingUrl("/loginProc")
                        .defaultSuccessUrl("/board/list", true)
                        .permitAll()
                );

        http
                .logout((auth) -> auth.logoutUrl("/logout")
                        .logoutSuccessUrl("/"));

        http
                .csrf(AbstractHttpConfigurer::disable);

        http
                .sessionManagement((auth) -> auth
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true));

        http
                .sessionManagement((auth) -> auth
                        .sessionFixation().changeSessionId());

        return http.build();
    }
}

SecurityFilterChain 설정을 진행하는 클래스이다. @Configuration 어노테이션을 통해 이 클래스가 Spring의 설정 클래스로 사용된다는 것을 명시하며, @EnableWebSecurity 어노테이션을 통해 Spring Security가 관리하도록 한다.

  • bCryptPasswordEncoder()
    • 비밀번호를 해싱을 통해 암호화하는 데 사용하는 BCryptPasswordEncoder 객체를 리턴하는 메서드이다.
  • filterChain(HttpSecurity http)
    • SecurityFilterChain은 보안 필터 체인을 구성하는 메서드로, http 객체를 통해 체이닝(chaining) 방식으로 아래와 같은 보안 설정을 구성한다.
      • requestMatchers로 HTTP 요청에 대해 접근 권한을 설정한다.
        • "/css/**", "/vendor/**", "/favicon.ico/**": 사용된 템플릿 관련 url로, 처음 로그인 및 회원가입 페이지에서 제대로 템플릿을 호출할 수 있도록 모든 사용자가 접근할 수 있도록 허용한다.
        • "/", "/login", "/loginProc", "/join", "/joinProc", "/checkUsername": 로그인, 회원가입 관련 페이지로, 모두에게 접근 허용한다.
        • "/admin": ADMIN 역할이 있는 사용자만 접근할 수 있게 설정하였지만 admin 페이지는 따로 제작하지 않았으며, 역할 관련 테스트를 진행할 때만 사용하였다.
        • "/board/**", "/replies/**": ADMIN, USER 역할이 있는 사용자만 접근할 수 있다.
        • 그 외 모든 요청은 인증된 사용자만 접근할 수 있다.
      • formLogin을 통해 로그인 페이지, 로그인 처리 URL, 로그인 성공 후 리다이렉트할 URL 등을 설정한다.
        • loginPage("/login")을 통해 로그인 페이지는 /login에서 확인한다고 명시한다.
        • loginProcessingUrl("/loginProc")을 통해 로그인 처리는 /loginProc에서 진행한다고 명시한다.
        • defaultSuccessUrl("/board/list", true)을 통해 로그인 성공 후 전에 방문한 다른 URL이 있더라도 리다이렉트할 URL을 /board/list로 설정한다.
      • logout을 통해 로그아웃 URL과 로그아웃 성공 후 리다이렉트할 URL을 설정한다.
        • logoutUrl("/logout")을 통해 로그아웃을 /logout에서 진행하도록 명시한다.
        • logoutSuccessUrl("/")을 통해 로그아웃이 완료되면 기본 URL로 이동하도록 설정한다.
      • csrf().disable()을 통해 CSRF 보호를 비활성화한다.
        • CSRF 보호 비활성화는 개발 중에나 특정한 상황에서만 사용하고, 일반적으로는 CSRF 보호를 활성화하는 것이 좋지만 댓글 관련해서 문제가 있어서 일단 비활성화하였다.
        • 해당 문제는 스프링부트 서버로 댓글 관련 DTO 정보가 정상적으로 넘어오지 않기 때문에 CORS 관련 설정을 진행하면 해결될 수도 있다고 생각하는데, 지금 당장 중요한 내용이 아니라고 생각해서 넘어가는 것으로 결정했다.
      • sessionManagement을 통해 세션 관련 설정을 진행한다.
        • maximumSessions(1)을 통해 최대 세션 수를 1로 제한하고, maxSessionsPreventsLogin(true)을 통해 새로운 로그인 시 이전 세션을 무효화하도록 설정한다.
        • sessionFixation().changeSessionId()를 사용하여 세션 고정 공격을 방지한다.

entity/Member.java

package org.zerock.board.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@ToString
public class Member extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @Column(unique = true)
    private String username;

    private String password;

    private String name;

    private String role;

}

Member 엔티티의 내용을 조금 수정했다. 기존에 @Id 어노테이션을 사용하던 필드를 Email에서 id로 변경하였고, 무조건 Email 형식을 사용해야하던 기존의 코드를 그냥 String이면 되도록 username 필드를 추가하였으며 관련 코드를 모두 수정하였다. 이로 인해 바뀐 Member 관련 RepositoryTests, ServiceTests는 알맞게 수정해주어야 한다.

dto/BoardDTO.java

package org.zerock.board.dto;

import lombok.*;

import java.time.LocalDateTime;

@Data
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BoardDTO {
    private Long bno;
    private String title, content;
    private String writerId, writerName, writerUsername;
    private LocalDateTime regDate, modDate;
    private int replyCount;
}

Member 엔티티가 위와 같이 변경되면서 기존의 BoardDTO 관련 필드에도 약간의 변경이 있다. 기존의 writerEmail 필드를 삭제하고, writerId와 writerUsername 필드를 추가하였다. 이렇게 변경한 이유는 BoardService.java의 dtoToEntity 메서드를 보면 알 수 있다.

service/BoardService.java

package org.zerock.board.service;

import org.zerock.board.dto.BoardDTO;
import org.zerock.board.dto.PageRequestDTO;
import org.zerock.board.dto.PageResultDTO;
import org.zerock.board.entity.Board;
import org.zerock.board.entity.Member;

public interface BoardService {

    // 생략..

    default Board dtoToEntity(BoardDTO dto) {
        // Member member = Member.builder().email(dto.getWriterEmail()).build(); // 기존 코드
        Member member = Member.builder().id(Integer.parseInt(dto.getWriterId())).build();
        Board board = Board.builder()
                .bno(dto.getBno())
                .title(dto.getTitle())
                .content(dto.getContent())
                .writer(member)
                .build();
        return board;
    }

    // 생략..
}

기존에 게시글을 작성한 Member 정보는 BoardDTO의 writerEmail을 통해 알 수 있었는데, 기존의 @Id 어노테이션을 사용하던 필드를 Email에서 id로 변경했기 때문에 BoardDTO에 writerId가 필요해진 것이다.

dto/MemberDTO.java

package org.zerock.board.dto;

import lombok.*;

@Data
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberDTO {
    private String username;
    private String password;
    private String name;
}

MemberDTO는 회원의 username, password, name 필드만 갖도록 하였다.

repository/MemberRepository.java

package org.zerock.board.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.zerock.board.entity.Member;

public interface MemberRepository extends JpaRepository<Member, String> {
    boolean existsByUsername(String username);
    Member findByUsername(String username);
}

JpaRepository를 활용한 클래스이며, username으로 해당 계정을 가진 회원이 존재하는지 여부를 알기 위해 existsByUsername 메서드를, username으로 해당 계정을 가진 회원의 정보를 리턴하는 findByUsername 메서드를 추가했다.

service/MemberService.java

package org.zerock.board.service;

import org.zerock.board.dto.MemberDTO;
import org.zerock.board.entity.Member;

public interface MemberService {
    long join(MemberDTO memberDTO);
    Member getMember(String username);
    boolean isUsernameTaken(String username);
}

MemberService 인터페이스는 join 메서드로 회원가입을 진행하고, getMember로 어떤 username을 가진 회원 정보를 리턴하고, isUsernameTaken 메서드로 어떤 username을 가진 회원의 존재 유무를 리턴하도록 명시한다.

service/MemberServiceImpl.java

package org.zerock.board.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.zerock.board.dto.MemberDTO;
import org.zerock.board.entity.Member;
import org.zerock.board.repository.MemberRepository;

@Service
@RequiredArgsConstructor
@Log4j2
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @Override
    public long join(MemberDTO memberDTO) {

        if (memberRepository.existsByUsername(memberDTO.getUsername())) {
            return 0;
        }

        Member member = Member.builder()
                .username(memberDTO.getUsername())
                .password(bCryptPasswordEncoder.encode(memberDTO.getPassword()))
                .name(memberDTO.getName())
                .role("ROLE_USER")
                .build();
        memberRepository.save(member);
        return member.getId();
    }

    @Override
    public Member getMember(String username) {
        return memberRepository.findByUsername(username);
    }

    @Override
    public boolean isUsernameTaken(String username) {
        return memberRepository.existsByUsername(username);
    }
}

MemberService에서 명시된 기능들을 수행하는 클래스이며, 특히 join 메서드를 보면 간단하게 중복된 username을 갖는 회원에 대한 처리를 하는 것을 확인할 수 있다. 중복된 username을 입력한다면 0을 return하게 되며, 중복되지 않는다면 새로운 Member 엔티티를 만들어 memberRepository를 통해 저장한 후 해당 member의 Id를 return한다.

dto/CustomUserDetails.java

package org.zerock.board.dto;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.zerock.board.entity.Member;

import java.util.ArrayList;
import java.util.Collection;

public class CustomUserDetails implements UserDetails {

    private Member member;

    public CustomUserDetails(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add((GrantedAuthority) () -> member.getRole());
        return collection;
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

Spring Security에서 사용자 정보를 다루기 위해 UserDetails 인터페이스를 구현한 클래스이다. 이 클래스는 애플리케이션의 사용자 정보를 Spring Security가 이해할 수 있는 형식으로 변환하여 인증 및 권한 부여에 사용된다.

  • CustomUserDetails 클래스는 내부에 데이터베이스에서 가져온 사용자 정보를 담고 있는 Member 엔티티 객체를 갖고 있으며, 이 정보를 이용해 UserDetails 인터페이스의 메서드를 구현한다.
  • getAuthroties 메서드는 사용자가 가지고 있는 권한(roles)을 반환한다.
    • GrantedAuthority 인터페이스는 각 권한을 나타내며, 여기서는 member.getRole()을 이용해 사용자의 역할을 GrantedAuthority 형태로 반환한다.
    • 이 예제에서는 간단히 사용자의 역할을 문자열로 반환하고 있지만, 필요에 따라 더 복잡한 권한 구조를 구현할 수 있다.
  • getPassword와 getUsername, 이 두 메서드는 각각 사용자의 비밀번호와 사용자명을 반환한다.
    • Spring Security는 로그인 시 이 정보를 사용하여 사용자 인증을 수행한다.
  • isAccountNonExpired(), isAccountNonLocked(), isCredentialsNonExpired(), isEnabled() 메서드들은 계정의 현재 상태를 나타낸다.
    • isAccountNonExpired(): 계정이 만료되지 않았는지 여부를 반환한다.
    • isAccountNonLocked(): 계정이 잠기지 않았는지 여부를 반환한다.
    • isCredentialsNonExpired(): 자격 증명(비밀번호)이 만료되지 않았는지 여부를 반환한다.
    • isEnabled(): 계정이 활성화되어 있는지 여부를 반환한다.

위의 메서드들은 기본적으로 true를 반환하도록 설정되어 있어 모든 계정이 활성화 상태로 간주되며, 필요에 따라 이 메서드들을 커스터마이징하여 특정 조건을 기반으로 계정 상태를 관리할 수 있다.

service/CustomUserDetailsService.java

package org.zerock.board.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
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 org.zerock.board.dto.CustomUserDetails;
import org.zerock.board.entity.Member;
import org.zerock.board.repository.MemberRepository;

@Service
@RequiredArgsConstructor
@Log4j2
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        Member userData = memberRepository.findByUsername(username);

        if (userData == null) {
            throw new UsernameNotFoundException("User not found with username: " + username);
        }

        return new CustomUserDetails(userData);
    }
}

Spring Security에서 사용자 인증을 처리하기 위해 커스터마이징된 UserDetailsService를 구현한 클래스로, MemberRepository를 통해 데이터베이스에서 Member 엔티티를 조회한다.

  • loadUserByUsername(String username) 메서드를 통해 주어진 사용자 이름을 기반으로 사용자의 인증 정보를 조회한다.
    • 이 메서드는 Spring Security가 사용자의 로그인 시도를 처리할 때 호출된다.
    • username을 이용해 memberRepository.findByUsername(username)을 호출하여 데이터베이스에서 Member 엔티티를 조회하고, 사용자가 존재하면 조회된 Member 객체를 기반으로 CustomUserDetails 객체를 생성하고 반환한다.
    • username으로 사용자를 찾지 못했을 때 UsernameNotFoundException을 던져서 Spring Security가 비정상적인 로그인 시도를 인식하도록 한다.

https://yummi-image-1.s3.amazonaws.com/image-78098402-b63e-47fb-bf16-6ccabf497edf.jpg

사용자가 로그인 폼을 통해 로그인을 진행하게 되면, /loginProc와 같은 특정한 로그인 프로세스 URL에서 시큐리티가 자동으로 로그인 데이터를 Security Config에서 검증하게 된다. DB에서 저장된 회원 정보를 가지고 로그인 데이터가들어오는 것을 검증시켜주기 위해 위와 같이 UserDetailsService와 UserDetails를 구현해주어야 한다.

controller/MemberController.java

package org.zerock.board.controller;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.zerock.board.dto.MemberDTO;
import org.zerock.board.service.MemberService;

@Controller
@Log4j2
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/login")
    public String login() {
        return "member/login";
    }

    @GetMapping("/join")
    public String join() {
        return "member/join";
    }

    @PostMapping("/joinProc")
    public String joinProcess(MemberDTO memberDTO) {
        log.info("member dto..." + memberDTO);
        long result = memberService.join(memberDTO);
        if (result == 0) {
            return "redirect:/join";
        } else {
            return "redirect:/login";
        }
    }

    @GetMapping("/logout")
    public String logout(HttpServletRequest request, HttpServletResponse response) throws Exception {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null) {
            new SecurityContextLogoutHandler().logout(request, response, authentication);
        }

        return "redirect:/";
    }

    @GetMapping("/checkUsername")
    public ResponseEntity<Boolean> checkUsername(@RequestParam String username) {
        boolean isTaken = memberService.isUsernameTaken(username);
        return ResponseEntity.ok(isTaken);
    }
}

Member 관련 컨트롤러이다. /login에서 로그인 페이지를, /join에서 회원가입 페이지를 불러오며, /logout에서 로그아웃을 진행한 후 기본 URL로 리다이렉트된다.

/joinProc에 입력한 MemberDTO와 함께 POST 요청을 보내면 회원가입을 진행하는데, 이때 중복되는 username을 가지는 회원이 있으면 result가 0이 되는데, result가 0이 되면 다시 회원가입 페이지로 리다이렉트되도록 하였으며, result가 0이 아니라면 다시 로그인 페이지로 리다이렉트되도록 하였다.

추가적으로 /checkUsername?username=${username}에서 DB에 ${username}을 가진 회원 정보가 있는지 여부를 확인할 수 있는데, 실제로 프론트 부분에서 해당 로직을 통해 중복되는 username이라면 회원가입이 불가능하도록 코드를 작성하여서 result가 0이 되지는 않기 때문에 로그인 페이지로 리다이렉트되는 로직은 테스트를 진행할 때만 사용하였다.

controller/BoardController.java

package org.zerock.board.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.zerock.board.dto.BoardDTO;
import org.zerock.board.dto.PageRequestDTO;
import org.zerock.board.entity.Member;
import org.zerock.board.service.BoardService;
import org.zerock.board.service.MemberService;

@Controller
@RequestMapping("/board")
@Log4j2
@RequiredArgsConstructor
public class BoardController {

    private final BoardService boardService;
    private final MemberService memberService;

    @GetMapping("/")
    public String index() {
        return "redirect:/board/list";
    }

    @GetMapping("/list")
    public void list(PageRequestDTO pageRequestDTO, Model model) {
        log.info("list............." + pageRequestDTO);
        model.addAttribute("result", boardService.getList(pageRequestDTO));
        String username = SecurityContextHolder.getContext().getAuthentication().getName();
        model.addAttribute("username", username);
    }

    @GetMapping("/register")
    public void register(Model model) {
        String username = SecurityContextHolder.getContext().getAuthentication().getName();
        Member member = memberService.getMember(username);
        model.addAttribute("userId", member.getId());
    }

    @PostMapping("/register")
    public String registerPost(BoardDTO dto, RedirectAttributes redirectAttributes) {
        log.info("dto..." + dto);
        Long bno = boardService.register(dto);
        log.info("BNO: " + bno);
        redirectAttributes.addFlashAttribute("msg", bno);
        return "redirect:/board/list";
    }

    @GetMapping({"/read", "/modify"})
    public void read(@ModelAttribute("requestDTO") PageRequestDTO pageRequestDTO, Long bno, Model model) {
        log.info("bno: " + bno);
        BoardDTO boardDTO = boardService.get(bno);
        log.info(boardDTO);
        model.addAttribute("dto", boardDTO);
        String username = SecurityContextHolder.getContext().getAuthentication().getName();
        model.addAttribute("username", username);
    }

    @PostMapping("/modify")
    public String modify(BoardDTO dto, @ModelAttribute("requestDTO") PageRequestDTO requestDTO, RedirectAttributes redirectAttributes) {
        log.info("post modify.........................................");
        log.info("dto: " + dto);
        boardService.modify(dto);

        redirectAttributes.addAttribute("page", requestDTO.getPage());
        redirectAttributes.addAttribute("type", requestDTO.getType());
        redirectAttributes.addAttribute("keyword", requestDTO.getKeyword());
        redirectAttributes.addAttribute("bno",dto.getBno());
        return "redirect:/board/read";
    }

    @PostMapping("/remove")
    public String remove(long bno, RedirectAttributes redirectAttributes){
        log.info("bno: " + bno);
        boardService.removeWithReplies(bno);
        redirectAttributes.addFlashAttribute("msg", bno);
        return "redirect:/board/list";
    }
}

기존의 코드와 크게 달라진 부분은 없지만, 현재 로그인된 사용자의 정보를 model에 추가하기 위해 SecurityContextHolder.getContext().getAuthentication().getName() 메서드를 활용하며, 추가적으로 MemberService의 로직을 활용한다.

실제 실행화면

프론트 관련 html 코드에 대한 설명은 글이 너무 길어지는 관계로 생략하고 진행하도록 하겠다. 관련 코드와 프로젝트 실행 방법 등은 이 프로젝트가 저장된 깃허브를 보면 확인할 수 있다.

 

GitHub - juintination/Board-Clone: 코드로 배우는 스프링 부트 웹 프로젝트 Board 프로젝트에 Dockerizing과 Sp

코드로 배우는 스프링 부트 웹 프로젝트 Board 프로젝트에 Dockerizing과 Spring Security를 적용한 연습용 Clone 프로젝트 - juintination/Board-Clone

github.com

처음 프로젝트를 실행한 후 localhost:8080으로 접속하게 되면 자동으로 localhost:8080/login으로 리다이렉트되어 위와 같은 화면을 볼 수 있다.

 

Sign up 버튼을 누르면 localhost:8080/join으로 리다이렉트되어 위와 같이 회원가입 페이지를 볼 수 있다. 가입하고자 하는 회원 정보를 입력하지 않으면 join 버튼은 활성화되지 않는다.

 

위와 같이 가입하고자 하는 회원 정보를 올바르게 입력하면 Join 버튼이 활성화되며, 해당 버튼을 누르면 회원가입이 완료된다.

 

중복되는 username을 갖는 회원이 이미 존재한다면 Username is already taken이라는 문구와 함께 Join 버튼이 비활성화된다.

 

회원가입을 완료하고 로그인을 완료하면 게시글 리스트를 볼 수 있는 화면이 나온다.

 

여기서 Register 버튼을 누르면 위와 같이 게시글을 작성할 수 있는 화면이 나온다. 기존에는 Writer도 입력할 수 있었는데, 작성자 정보를 Controller에서 전달해주기 때문에 해당 부분은 삭제했다.

 

게시글이 정상적으로 작성된다면, 위와 같이 리스트 페이지에서 해당 게시글을 확인할 수 있다.

 

글을 작성하지 않은 다른 유저가 해당 게시글을 조회했을 때 나오는 화면이다. 해당 글에 대한 수정 권한을 현재 로그인중인 사용자의 username으로 비교했을 때 작성자의 username과 맞지 않아서 Modify 버튼을 볼 수 없는 것을 확인할 수 있다.

 

반대로 글을 작성한 작성자가 해당 게시글을 조회하면 Modify 버튼이 있는 것을 확인할 수 있다. 해당 버튼을 클릭했을 때 게시글의 제목과 내용을 수정하거나 해당 게시글을 삭제할 수 있다.

 

Add Reply 버튼을 누르면 위와 같이 댓글을 작성할 수 있다. 이때 작성자 Replyer는 현재 로그인중인 사용자의 username으로 고정된다.

 

댓글 작성이 완료되면 위와 같이 alert가 뜬다.

 

게시글의 경우와 같이 댓글을 작성한 작성자는 Remove, Modify 버튼이 있는 것을 확인할 수 있으며, 이전에 작성했던 댓글을 수정 및 삭제가 가능하다.

 

마찬가지로 해당 댓글을 작성하지 않은 사용자는 수정 및 삭제가 불가능한 것을 확인할 수 있다.


결론

스프링 시큐리티 관련 기본적인 기능만 적용한 것이지만 예전에 포기했던 내용에 대해 빠르게 공부하고 기존의 내 프로젝트에 적용했다는 사실이 뿌듯했다. 며칠 동안 이거 때문에 밤을 새우면서 수면 패턴이 많이 망가졌는데, 그럼에도 불구하고 이렇게 진득하게 앉아서 코딩하는 것은 뭔가 설명하기 힘든 즐거운 기분이 든다는 것을 매번 새롭게 느끼는 것 같다. 얼른 원래의 수면 패턴도 되찾고, 남은 프로젝트도 열심히 진행해야겠다.

728x90
Comments