스프링과 JPA 기반 웹 애플리케이션 개발 #12 회원 가입 리팩토링 및 테스트 (+MockBean +CSRF)

Jake Seo·2021년 5월 27일
0

스프링과 JPA 기반 웹 애플리케이션 개발 #12 회원 가입 리팩토링 및 테스트 (+MockBean +CSRF)

해당 내용은 인프런, 스프링과 JPA 기반 웹 애플리케이션 개발의 강의 내용을 바탕으로 작성된 내용입니다.

강의를 학습하며 요약한 내용을 출처를 표기하고 블로깅 또는 문서로 공개하는 것을 허용합니다 라는 원칙 하에 요약 내용을 공개합니다. 출처는 위에 언급되어있듯, 인프런, 스프링과 JPA 기반 웹 애플리케이션 개발입니다.

제가 학습한 소스코드는 https://github.com/n00nietzsche/jakestudy_webapp 에 지속적으로 업로드 됩니다. 매 커밋 메세지에 강의의 어디 부분까지 진행됐는지 기록해놓겠습니다.


리팩토링 전 테스트 코드를 먼저 작성하자

이유

  • 그래야 코드를 변경한 이후에도 불안하지 않다.
  • 변경한 코드가 무언가를 깨트리지 않았다는 것을 확인할 수 있다.

테스트할 것

  • 폼에 이상한 값이 들어간 경우에 다시 폼이 보여지는가?
  • 폼에 값이 정상적인 경우
    • 가입한 회원 데이터가 존재하는가?
    • 이메일이 보내지는가?

리팩토링

  • 메소드가 너무 길지 않은가?
  • 코드를 읽기 쉬운가?
    • 내가 작성한 코드를 내가 읽기 어렵다면 남들에겐 훨씬 더 어렵다.
  • 코드가 적절한 위치에 있는가?
    • 객체 사이의 의존 관계
    • 책임이 너무 많진 않은지

AccountControllerTest 코드 작성

의존성 추가

@Autowired private AccountRepository accountRepository;
@MockBean JavaMailSender javaMailSender;

위와 같이, JavaMailSenderAccountRepository를 테스트하기 위해 의존성을 주입받았다. JavaMailSender의 경우에는 내부 기능을 실행해보는 테스트가 아닌, 해당 객체가 특정 메소드를 특정 타입의 인자로 보냈는지에 대해 알아보기 위해 @MockBean이라는 애노테이션으로 의존성 주입을 하였다.

MockBean 공식문서 설명

공식문서에 아래와 같은 설명이 있다.

Annotation that can be used to add mocks to a Spring ApplicationContext. Can be used as a class level annotation or on fields in either @Configuration classes, or test classes that are @RunWith the SpringRunner.
Mocks can be registered by type or by bean name. Any existing single bean of the same type defined in the context will be replaced by the mock. If no existing bean is defined a new one will be added. Dependencies that are known to the application context but are not beans (such as those registered directly) will not be found and a mocked bean will be added to the context alongside the existing dependency.

스프링 애플리케이션 컨텍스트에 목을 추가하는데 이용되는 애노테이션이다. @Configuration 클래스나 @RunWith 테스트 클래스에서 클래스 레벨이나 필드 레벨 애노테이션으로 사용될 수 있다. 타입 이름 또는 빈 이름에 의해 등록된다. 컨텍스트 내부에 정의된 존재하는 동일한 타입의 단일 빈이 Mock으로 대체될 것이다. 만일, 정의된 빈이 존재하지 않는다면, 새로운 빈이 추가될 것이다. (이를테면 직접 등록된 것과 같은) 빈이 아닌 어플리케이션 컨텍스트에 알려진 의존성은 찾을 수 없다. 그리고 Mock이 된 빈은 존재하는 의존성과 함께 컨텍스트에 추가될 것이다.

결국 스프링 애플리케이션 컨텍스트에서 단일 빈을 찾아서 Mock으로 만들어버린다고 생각된다. 만일 스프링 애플리케이션 컨텍스트에 없으면, 그냥 새로 만든다.

입력값 오류 시 테스트 코드

    @DisplayName("회원 가입 처리 - 입력값 오류")
    @Test
    void signUpSubmitWithWrongInput() throws Exception {

        // 기본적으로 CSRF 라는 시큐리티 기능이 활성화 되어있다. 그래서 403 에러가 뜬다.
        // 타 사이트에서 해당 사이트로 요청을 보낼 때를 방지한 보안이 적용되어 있다.
        // 스프링 시큐리티에서는 해당 보안 취약점을 방지하기 위해 CSRF 토큰 기능을 지원해준다.
        // 스프링 시큐리티가 적용된 thymeleaf 결과 html 을 잘 보면 input 에 hidden 값으로 CSRF TOKEN 이 들어있다.
        // 194b360d-b353-4063-a228-fc41903df06f와 같은 형식이다.
        // 이러한 CSRF 토큰이 없는 경우 보안 정책에 위배된다.

        mockMvc.perform(post("/sign-up")
                    .param("nickname", "minsu")
                    .param("email", "email..")
                    .param("password", "12345")
                    .with(csrf())) // CSRF 토큰도 mock 하기
                .andExpect(status().isOk())
                .andExpect(view().name("account/sign-up"));
    }
  • mockMvc를 이용하여 테스트한다.
  • 임의의 닉네임, 이메일, 패스워드를 보내지만 실패하는 케이스를 보낸다.
  • 스프링 시큐리티 때문에 반드시 .csrf() 메소드로 CSRF 토큰을 적용해주어야 한다.
  • 잘못된 인풋을 넣으면, 200 상태와 함께 다시 account/sign-up 뷰로 이동한다.

CSRF(Cross Site Request Forgery)란?

Forgery는 위조를 뜻한다.

악성 사용자가 악성 스크립트를 통해 서버로 일반 사용자가 원치 않는 리퀘스트를 보내는 것을 말한다. CSRF 토큰이 있다면, 해당 리퀘스트가 올바른 과정을 거쳐서 들어온 것인지 알 수 있다.

악성 스크립트 외에도 기존에 존재하는 사이트 모양과 비슷하게 생긴 사이트를 구성하여, 사용자를 낚는 이른바 피싱 사이트에서도 이러한 공격이 발생할 수 있다.

CSRF 토큰은 해당 사용자에게 고유하게 발급된 UUID 등을 통해 적용할 수 있다. 스프링 시큐리티에서는 아래와 같이 CSRF 토큰을 준다.

아래의 토큰은 서버에서 뷰 페이지를 발행하면서 랜덤으로 생성된 CSRF Token을 같이 뷰로 전송한 것이다.

서버에서는 이 CSRF Token을 받아 세션에 저장된 값과 일치하는지 확인하여 해당 요청이 위조된 것이 아니라는 것을 확인한다.

서버는 일치 여부를 확인한 Token은 바로 폐기하고 새로운 뷰 페이지를 발행할 때마다 새로 생성한다.

inputtype=hidden으로 고유 value가 위와 같이 들어있다. CSRF 공격자는 저 고유한 CSRF 토큰값을 생성해낼 수 없기 때문에, 공격에 실패하게 된다.

스프링 시큐리티 CSRF 토큰에 대한 자세한 설명

입력값 정상 시 테스트 코드

    @DisplayName("회원 가입 처리 - 입력값 정상")
    @Test
    void signUpSubmitWithCorrectInput() throws Exception {

        String email = "minsu@minsu.co.kr";
        String nickname = "minsu";

        mockMvc.perform(post("/sign-up")
                    .param("nickname", nickname)
                    .param("email", email)
                    .param("password", "12345678")
                .with(csrf())) // CSRF 토큰도 mock 하기
                .andExpect(status().is3xxRedirection())
                .andExpect(view().name("redirect:/"));

        assertTrue(accountRepository.existsByEmail(email));
        assertTrue(accountRepository.existsByNickname(nickname));

        // BDD Mockito
        // `SimpleMailMessage` 타입의 인스턴스 중 아무거나 가지고 send가 호출됐는지 확인
        // `JavaMailSender` 는 결국 소스에서 인터페이스만 관리하고 외부의 이메일 서비스가 메일을 보낸다.
        // 실제로 이메일을 보내는 것까지 테스트하기엔 테스트 코드가 너무 작성하기 힘들어서 이정도만...
        then(javaMailSender).should().send(any(SimpleMailMessage.class));
    }
  • 올바르게 입력된 인풋을 넣어준다.
  • Redirection 상태가 발생하는지 확인한다.
  • 뷰도 정상적으로 redirect:/로 가는지 확인한다.
  • 리포지토리에 실제 값이 저장되었는지 확인해준다.
  • JavaMailSenderSimpleMailMessage 클래스를 이용하여 send()메소드를 호출하는지 확인한다.

코드 리팩토링

AccountService 클래스

@Service
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accountRepository;
    private final JavaMailSender javaMailSender;

    private Account saveNewAccount(SignUpForm signUpForm) {
        Account account = Account.builder()
                .email(signUpForm.getEmail())
                .nickname(signUpForm.getNickname())
                .password(signUpForm.getPassword()) // TODO: PASSWORD ENCODING 해야 함 (HASH로)
                .studyEnrollmentResultByWeb(true)
                .studyCreatedByWeb(true)
                .studyUpdatedByWeb(true)
                .build();

        Account newAccount = accountRepository.save(account);
        return newAccount;
    }

    private void sendSignUpConfirmEmail(Account newAccount) {
        // 이메일 보내기, 이메일을 보내기 위한 의존성은 메이븐에
        // spring-boot-starter-mail 을 이용한다.
        // `MailSenderAutoConfiguration` 클래스에 메일과 관련된 기본 설정이 들어있다.

        // 토큰 값은 UUID를 이용해서 랜덤하게 생성한다.
        // 인증 이메일을 다시 보내는 경우
        // 매번 이메일을 보낼 때마다 토큰이 변경되게 하기 위해 이쪽에 토큰을 만드는 부분을 넣었다.
        // 또한 이 부분은 이메일과만 연관 있는 기능이니 여기에 넣어도 될 것 같다.
        newAccount.generateEmailCheckToken();

        SimpleMailMessage simpleMailMessage = new SimpleMailMessage();
        simpleMailMessage.setTo(newAccount.getEmail());
        simpleMailMessage.setSubject("제이크 스터디 회원가입 메일 인증");
        simpleMailMessage.setText("/check-email-token?token=" + newAccount.getEmailCheckToken() + "&email=" + newAccount.getEmail());

        javaMailSender.send(simpleMailMessage);
    }

    public void processNewAccount(SignUpForm signUpForm) {
        Account newAccount = saveNewAccount(signUpForm);
        sendSignUpConfirmEmail(newAccount);
    }
}
  • 새로운 계정을 생성하는 부분과 인증 이메일을 보내는 부분을 나누었다.
  • 서비스에서 리포지토리에 새로운 계정을 생성하는 부분을 담당한다.
  • 서비스에서 새로운 계정에 인증 이메일을 보내는 부분을 담당한다.
  • 두 과정을 합쳐 processNewAccount() 라는 메소드를 만들었다.

AccountController 리팩토링

의존성 제거

기존의 JavaMailSenderAccountRepository 의존성은 필요 없어지게 된다.

새 계정 처리 로직을 Service에 위임

// 메소드만 추출해주어도 코드의 가독성이 훨씬 좋아진다.
// 서비스쪽으로 이메일 보내기 등의 로직을 숨겨두었다.
// 컨트롤러는 JSR 303으로 들어온 데이터에 대한 검증만 하고,
// 나머지 회원 가입 과정은 서비스에게 위임한다.
accountService.processNewAccount(signUpForm);

이렇게 하면 AccountController는 폼을 통해 들어온 값을 검증하고, 처리 과정은 AccountService에 위임하게 된다.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글

관련 채용 정보