Simple BBS 애플리케이션 배포 #4

서론

오랜만에 SimpleBBS 애플리케이션을 업데이트했다. 지난번 SimpleTodoList 애플리케이션을 진행하면서 적용했던 스프링 시큐리티를 이번에는 MVC 기반 애플리케이션인 SimpleBBS에도 적용하는 것이 주 목적이었는데 확실히 까다로운 부분이 많았지만 결과적으로 잘 적용할 수 있었다.

본론

스프링 시큐리티 적용

SimpleTodoList는 API 서비스만 제공하기 때문에 로그인, 로그아웃 페이지를 렌더링하지 않는다. 그리고 인증, 인가도 JWT와 이에 저장된 사용자 정보를 이용했기 때문에 스프링 시큐리티의 로그인, 로그아웃 기능을 별로 활용할 수 없었는데 이번 SimpleBBS는 MVC 방식으로 로그인, 로그아웃 페이지를 서버에서 제공해야 하기 때문에 스프링 시큐리티의 로그인, 로그아웃 기능을 적용해볼 수 있었다.

그냥 로그인, 로그아웃 기능이라고 하면 조금 불분명하지만 정확히는 formLogin 설정과 로그인, 로그아웃 URL 등이다. 기존에 스프링 시큐리티가 적용되지 않은 SimpleBBS에서는 로그인 페이지에서 입력받은 아이디와 비밀번호를 기반으로 계정 서비스에서 제공하는 로그인 기능을 이용하여 계정 정보를 확인했다.

그리고 이를 기반으로 LoginSessionInfo라는 인증 정보를 담는 별도의 클래스의 객체를 생성하여 세션에 등록 후 필요할 때마다 세션에서 정보를 꺼내 쓰거나 세션에 LoginSessionInfo 객체가 있는지 확인하여 인증 및 인가를 적용했다.

그러나 위와 같은 기능을 스프링 시큐리티에서 정확히 제공하고 있기 때문에 이런 기능은 필요없어졌다. 심지어 로그인 과정도 미리 지정된 로그인 URL에 POST로 사용자 아이디(username)와 비밀번호(password)를 넘겨주기만 하면 자동으로 로그인 및 세션, 정확히는 SecurityContext에 로그인 정보를 Principal이라는 인터페이스의 객체로 유지하기 때문에 많은 코드를 줄일 수 있었다.

그리고 인가 역시 이전에는 핸들러 인터셉터를 이용하여 권한이 필요한 리소스에 요청 세션의 LoginSessionInfo를 확인하여 인가를 부여했으나 스프링 시큐리티를 적용한 이후 자체 설정(HttpSecurity.authorizeRequests)을 이용하여 인가를 구성할 수 있었다.

이 외에도 타임리프와 연동하여 자동으로 CSRF 토큰을 생성하여 검증한다던지 등 많은 부분을 자동화해주고 설정이 자유로운 프레임워크기 때문에 보안을 생각한다면 프로젝트 초기부터 적용하는 것이 좋은 것 같다.

불필요한 클래스 제거 및 통합

우선 이번 업데이트에서 스프링 시큐리티와 한 쌍을 이룰 정도로 가장 큰 업데이트는 불필요한 클래스를 많이 제거했다는 것이다. 기존에는 각 요청(회원가입, 글 작성, 글 수정 요청, 글 수정 전송 등)마다 각자 파라미터를 담는 커맨드 객체 클래스를 정의했기 때문에 한번에 살펴보기도 힘들고 중복되는 부분이 많았다.

지난번에 모 기업의 과제 테스트에 참가한 적이 있었는데 주어진 샘플 코드에서 이 커맨드 객체와 비슷한 역할의 클래스들을 한 클래스 내에서 정적 클래스로 깔끔하게 유지하는 것을 본 적이 있었다. 이는 SimpleTodoList에서 처음 적용했었는데 괜찮았기 때문에 이를 기반으로 SimpleBBS에도 적용할 수 있었다.
위의 커밋 로그에서 볼 수 있듯이 개별로 존재하던 커맨드 객체와 검증 그룹 인터페이스(@Validated 어노테이션 적용)을 삭제하고 아래처럼 같은 클래스 내의 정적 클래스로 등록하였다.
이렇게 되면 한 클래스 파일이 비대해진다는 단점이 있지만 같은 개체(게시글, 댓글 등)를 다루는 목적(게시글 출력, 수정 등)으로 사용되는 커맨드 객체들은 한 곳에서 관리하는 것이 바람직하다고 생각했다.

예를 들어 게시글 작성, 수정 시 필요한 작성자, 비밀번호, 제목, 내용 등 모든 필드를 포함하는 Submit 클래스나 게시글을 수정할 권한을 확인할 때 필요한 게시글의 식별자와 비밀번호 필드를 포함하는 Authorize 클래스를 내부 정적 클래스로 유지하는 식으로 활용하는 것이다.

    @Getter
    @Setter
    @NoArgsConstructor
    public static class Authorize {
        @Positive(message = "Article ID cannot be negative or zero.", groups = {Request.class, Submit.class})
        private long id;

        @NotBlank(message = "Password cannot be blank.", groups = {Submit.class})
        @Length(min = 4, message = "Password should be at least 4 characters.", groups = {Submit.class})
        private String password;

        public interface Request {}
        public interface Submit {}
    }

    @Getter
    @Setter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Submit {
        @Positive(message = "Article ID cannot be negative or zero.", groups = {Update.class})
        private long id;

        @NotBlank(message = "Writer cannot be blank.", groups = {Create.class, Update.class})
        @Length(max = 64, message = "Writer cannot exceeds 64 characters.", groups = {Create.class, Update.class})
        private String writer;

        @NotBlank(message = "Password cannot be blank.", groups = {Create.class, Update.class})
        @Length(min = 4, message = "Password should be at least 4 characters.", groups = {Create.class, Update.class})
        private String password;

        @NotBlank(message = "Title cannot be blank.", groups = {Create.class, Update.class})
        @Length(max = 255, message = "Title cannot exceeds 255 characters.", groups = {Create.class, Update.class})
        private String title;

        @NotBlank(message = "Content cannot be blank.", groups = {Create.class, Update.class})
        @Length(max = 65535, message = "Content cannot exceeds 65535 characters.", groups = {Create.class, Update.class})
        private String content;

        private final List<MultipartFile> uploadedFiles = new ArrayList<>();
        private final List<String> delete = new ArrayList<>();

        public interface Create {}
        public interface Update {}

        public void encodePassword(PasswordEncoder encoder) {
            this.password = encoder.encode(password);
        }
        public void encodePassword(PasswordEncoder encoder, String newPassword) {
            this.password = encoder.encode(newPassword);
        }
    }

@Validated

이렇게 내부 정적 클래스로 유지하면서도 이번에는 일부 클래스에서 @Validated 어노테이션과 검증 그룹 인터페이스를 활용했는데 이는 게시글 작성, 수정, 수정 요청, 삭제 요청 등 다양한 상황에서 동일하게 요구되는 필드를 여러 내부 정적 클래스에 중복시키고 싶지 않아서다.

게시글 작성과 수정에서 다른 것은 게시글의 식별자 뿐이다. 그렇기 때문에 작성에 필요한 필드를 담는 클래스와 수정에 필요한 필드를 담는 클래스를 분리하는 것은 코드 중복 관점에서 별로 좋지 않은 방식이라고 생각했다.

@PostMapping("/write")
public String submitBoardArticle(
        Model model,
        @ModelAttribute("command") 
        @Validated(Submit.Create.class) Submit command,
        BindingResult bindingResult,
        @CurrentSecurityContext SecurityContext context) {
        ...
@PostMapping("/edit/submit")
public String submitEditArticle(
	Model model,
	@ModelAttribute("article") 
	@Validated(Submit.Update.class) Submit command,
	BindingResult bindingResult,
	Principal principal) {
	...

대신 컨트롤러나 다른 곳에서 검증 시 상황에 따른 필드만 검증할 수 있도록 검증 그룹 인터페이스와 @Validated 어노테이션을 적극 활용하도록 했다. 위의 코드에서 볼 수 있듯이 전체 필드가 필요한 수정에서는 Submit 클래스의 Update 검증 그룹을, 식별자를 제외한 필드가 필요한 작성에서는 Create 검증 그룹을 적용한 것을 볼 수 있다.

그렇다면 위의 Submit 클래스와 Authorize 클래스도 분리할 필요가 없지 않을까? 물론 그렇지만 그 정도로 극한으로 모든 필드를 동일한 클래스로 처리하기에는 부적절하다고 생각했다. 위에서 언급한 것처럼 목적에 따라서 조금이나마 분리하는 것이 좀 더 알아보기 쉽다고 생각했기 때문에 분리하게 되었다.

물론 이런 원칙을 처음부터 정하고 프로젝트를 진행했던 것은 아니기 때문에 다른 클래스에서는 좀 다르게 적용되어 있을 수도 있다. 이는 추후 코드를 좀 더 살펴본 후 수정하도록 하겠다.

API와 MVC 환경의 피드백

SimpleBBS(MVC) 애플리케이션을 수정하면서 확실하게 느꼈던 차이는 SimpleTodoList(API) 애플리케이션과는 달리 예외나 어떤 문제가 발생했을 때 그냥 예외만 던지고 ExceptionHandler가 잡아서 처리하는 방식은 별로 적절하지 못하다는 것이다.

왜냐면 게시글을 작성하다가 검증 오류(최대 길이를 초과한 본문 길이 등)가 발생했을 때 그냥 예외 페이지로 넘겨버린다면 사용자는 작성하던 텍스트가 모두 날아가기 때문에 사용자 경험(UX?) 측면에서 별로 좋지 않다고 생각했다.

그렇기 때문에 BindingResult와 @ModelAttribute를 적극 활용하여 검증에 실패하거나 다른 문제가 있더라도 기존에 작성한 내용, 정확히는 서버측에 제출하려고 시도한 내용을 포함해서 페이지를 렌더링해주는 방식으로 구현했다.

물론 비밀번호나 굳이 다시 채워주지 않아도 될 필드는 생략해도 좋을 것이다.

결론

스프링 시큐리티를 적용하면서 많은 코드를 삭제하고 불필요한 클래스, 예외를 지우고 통합하면서 더 많은 코드를 삭제할 수 있었다. 얼른 스프링 시큐리티에 대해서 좀 더 공부해야겠다.

특히 인증 정보를 세션에 보관하기 위해 사용하던 LoginSessionInfo 같은 클래스를 구성하지 않아도 스프링 시큐리티에서 그대로 지원해주는 게 참 인상깊었다. 모든 건 정말 필요에 의해 탄생하게 됐다는 생각이 들었다.

좋은 웹페이지 즐겨찾기