[Spring] - 리팩터링 일지 1일차

🛠 수정 1일차

현재 수정하고자 하는 프로젝트의 가장 큰 문제점은 다음과 같다.

  • 다이소와 같은 관심사 상태
    • Entity에 비즈니스 로직이 들어있음
    • Controller에 비즈니스 로직이 들어있음
    • Service에 다양한 Entity에 대한 비즈니스 로직이 들어있음
  • 대가족 형태의 fragment 파일
    • fragment 파일에 3000줄이 넘는 다양한 내용이 들어있음

Spring을 공부한지 1~2달 만에 시작한 프로젝트라 정말 얕은 지식을 갖고 개발을 했다.
Java에 대한 지식도 부족했고, 잘 나오기만하면 성공인 마음으로 개발해 많은 문제를 갖고 있다.

이 모든 내용을 하루 안에 수정하기에는 어려움이 있어 하나하나 수정을 해보고자 한다.
하나하나 수정하면서 앞으로 어떤 방식을 사용하는게 좋은지 공부해보도록 하자!

✅ 문제 해결

우선 오늘 해결할 문제는 Security와 관련된 내용들이다!
리팩터링을 진행한 순서대로 차근차근 다시 정리해보자!

🗂️ AccountService의 관심사 분리

Security 수정에 들어가기 앞서 기존 UserDetailsService가 회원 관리 Service에 포함되어 있었다.

public class AccountService implements UserDetailsService {
    
    ...
    
    @Override
    public UserDetails loadUserByUsername(String emailOrNickname) throws UsernameNotFoundException {
        log.info("UserDetail : mainOrNickname ={}", emailOrNickname);

        Account account = accountRepository.findByEmail(emailOrNickname);

        if (account == null) {
            throw new UsernameNotFoundException(emailOrNickname);
        }
        return new SecurityUser(account);
    }
}

위 코드와 같이 회원 관리 로직을 담당하는 AccountServiceloadUserByUsername를 구현한 상태이다.
또한, 코드를 보면 알 수 있듯이 권한에 대한 부분을 찾을 수 없다. 이 부분을 한 번 수정해보도록 하자!

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String emailOrNickname) throws UsernameNotFoundException {
        Account account = accountRepository.findByEmail(emailOrNickname);
        List<GrantedAuthority> authorities = new ArrayList<>();

        if (account.getUsername().contains("test")) {
            authorities.add(new SimpleGrantedAuthority("ADMIN"));
        }
        
        authorities.add(new SimpleGrantedAuthority("MEMBER"));

        return new SecurityUser(account, authorities);
    }
}

기존 AccountService에서 CustomUserDetailsService를 새로 생성해 기존 로직을 옮겼다.
또한, 권한을 주는 부분이 빠져있어 List<GrantedAuthority>를 통해 여러 권한을 가질 수 있도록 구현했다.
List를 사용한 이유는 단순하다. 한 유저가 MEMBER이면서 ADMIN일 수 있기 때문에 위와 같이 구현했다.

위 코드에서는 임시로 아이디에 test가 포함되어 있으면 ADMIN 권한을 주도록 해놓았지만, 추후에 수정할 예정이다.

🔐 SecurityConfig 리팩터링

앞서 언급했던 것과 같이 SecurityConfig를 구성하기 위한 WebSecurityConfigurerAdapterdeprecated되었다.

public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()
                .mvcMatchers("/login", "/sign-up", "/check-email", "/check-email-token",
                        "/email-login", "/check-email-login", "/login-link", "/email-login-view").permitAll()
                .antMatchers("/study/*", "/board/*", "/council/*", "/", "/logout").authenticated()
                .mvcMatchers(HttpMethod.GET, "/profile/*").permitAll()
                .antMatchers("/manager/*").hasAnyRole("ADMIN")
                .anyRequest().authenticated();
        http.logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/");

        http.formLogin().loginPage("/login").defaultSuccessUrl("/", true);
        http.formLogin().loginProcessingUrl("/login").defaultSuccessUrl("/", true);
        http.exceptionHandling().accessDeniedPage("/");

        http.userDetailsService(accountService);

        http.rememberMe()
                .userDetailsService(accountService)
                .tokenRepository(tokenRepository());
    }
}

공식문서에 나와있듯이 Spring Security 5.7.0-M2부터 해당 클래스의 사용을 권장하지 않고 있다.
이를 대체하기 위해 SecurityFilterChainBean으로 등록해 사용하는 것을 권장하고 있다.

위 내용을 담을 수 있는 filterChain을 구현해보자.

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .formLogin(
                        formLogin -> formLogin
                                .loginPage("/login")
                                .loginProcessingUrl("/login")
                )
                .logout(
                        logout -> logout
                                .logoutUrl("/logout")
                )
                .authorizeRequests(
                        request -> request
                                .antMatchers("/study/**", "/council/**", "/").authenticated()
                                .antMatchers("/manager/**").hasAnyRole("ADMIN")
                                .anyRequest().permitAll()
                )
                .csrf(
                        csrf -> csrf
                                .ignoringAntMatchers("/h2-console/**")
                );
        return http.build();
    }
}

새롭게 구현하면서 lambda을 통해 SecurityFilterChain의 필요 내용을 구현했다.
람다식을 사용하는 것은 유연성을 높이기 위한 것이지만, 필수가 아닌 선택적인 사항이라고 공식문서에도 잘 나와있다.

이전에는 모든 페이지에 로그인이 필요하도록 구현해놓았다.
이번에 수정하면서 비회원도 어느정도 서비스를 사용할 수 있도록 일부 URL을 제외하고는 모두 오픈해놓았다.

해당 부분을 제대로 활용하기 위해서는 우선 layoutThymeleaf Security를 적용해야한다.

🍃 Thymeleaf Security 적용

바로 위에서 설명했듯이 모든 페이지에 로그인을 필요하는 방식을 적용해서 아래와 같이 구현이 되어있다.

<div th:each="reply : ${reply}">
    <button th:if="${reply.getWriter().id==account.id}">수정하기</button>
</div>
public class BoardController{
    @GetMapping("/board/{id}")
    public String showPostDetail(@PathVariable long id, @CurrentUser Account account) {
        ...
        return "board/detail";
    }
}

대부분의 html에는 위와 같이 현재 로그인한 계정과 비교하는 로직이 구성되어 있다.
때문에 @CurrentUser라는 Custom Annotation을 통해 현재 로그인한 Account 객체를 가져와 사용했다.
앞으로는 비회원도 Read의 기능은 할 수 있도록 아래와 같은 코드로 바꿀 필요가 있었다.

<div sec:authorize="isAnonymous()">
    <p>로그인이 필요한 서비스입니다.</p>
</div>
<th:block sec:authorize="isAuthenticated()">
    <div th:each="reply : ${reply}">
        <button th:if="${reply.getWriter().id==account.id}">수정하기</button>
    </div>
</th:block>
public class BoardController{
    @GetMapping("/board/{id}")
    public String showPostDetail(@PathVariable long id,
                                 @AuthenticationPrincipal SecurityUser securityUser) {
      if (securityUser != null) {
          Account account = securityUser.getAccount();
          model.addAttribute(account);
      }
      ...
      return "board/detail";    
    }
}

이처럼 Thymeleaf Security에서 제공하는 authorize기능을 사용해 비회원은 동작하지 않도록한다.
우선 위와 같은 형태로 로직을 구성해 놓았고, 추후에 Optional 타입으로 반환할 수 있도록 수정할 예정이다!
그럼 로그인이 필요한 페이지에 대해서는 비회원을 Exception Handler로 잡을 수 있기 때문이다!

🤔 1일차 회고

해당 프로젝트를 개발할 당시에는 에러 로그도 하나하나 검색하면서 수정했지만,
이제는 로그를 확인하고 무엇이 문제인지 어느정도 감을 잡고 해결할 수 있는 능력을 갖게 되었다.

다음 리팩터링 때는 실질적인 비즈니스 로직을 수정하는 시간을 가져볼까 한다!

  • 비즈니스 로직 모듈화
  • 카테고리 ENUM 타입으로 변경
  • 에러를 잡기 위한 Handler 추가

우선 이렇게 작성을 해놓고 필요한 내용은 추가적으로 진행하도록 하겠다!

댓글남기기