본문 바로가기

Server Programming/Spring Boot Full-Stack Programming

[스프링 풀스택 클론 코딩 - 회원가입] (1-7) 회원가입 리팩토링 및 테스트

반응형

1. 메일 발송 테스트

실제로 이메일을 보내는지 확인하기에는 어려움이 있기 때문에

가짜객체를 이용해 콘솔창에 띄우는 방식을 일단 사용했는데,

그렇기 때문에, 해당 메일을 발송했는지 확인하는 테스트를 하기 위해서

외부연동을 통해 목업 객체를 이용해 테스트를 수행한다.

 

해당 테스트처럼 간단하게 하는 이유는 나중에 코드를 변경할 경우에 테스트 코드도 모두 변경을 해줘야하는데

그때 똑같이 변경을 해줘야하는 어려움이 따르기 때문이다.

 

2. 리팩토링

리팩토링이 필요한 이유는

 

먼저 기능이 성공적으로 수행할 수 있도록 작성한 코드를 간결하게, 가독성있게 변경해주는 작업이다.

 

(1) 함수화

Extract Method

 

(2) 컨트롤러 최소화

컨트롤러가 의존성을 많이 가지게 되는것을 줄이기 위해 -> 서비스로 이전한다.

 

-> 리팩토링을 수행하기 위해서도 테스트가 필요하다.

(리팩토링을 수행한 이후에도, 오류없이 작동하는지 테스트)


AccountControllerTest

package com.demo.account;

import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.web.servlet.MockMvc;

//테스트를 위한 어노테이션 -> 스프링부트테스트, 자동설정목업 MVC
@SpringBootTest @AutoConfigureMockMvc
//사용하면 서블릿이 뜨고, @AutoConfigureWebClient  @AutoConfigureTestClient를 이용하면 테스트 가능 
//@SpringBootTest (webEnvironment =WebEnvironment.DEFINED_PORT)
//@SpringBootTest (webEnvironment =WebEnvironment.NONE)
//@SpringBootTest (webEnvironment =WebEnvironment.RANDOM_PORT)

//테스트 코드가 필요한 이유
//코드를 변경한 이후에 작성한 코드가 오류를 발생시키지 않았다는 것을 확인하기 위해
//모놀리틱 아키텍처의 경우, 작은 변화가 코드 전체의 오류에 영향을 끼치기 쉽기 때문에 테스트 코드가 필요하다.
class AccountControllerTest {
	
	
	//의존성 주입
	@Autowired private MockMvc mockMvc;
	
	@Autowired private AccountRepository accountRepository;

	//이메일 보내는지 확인할 목업빈 -> 메일샌더가 메일을 보냈는지 여부 확인
	@MockBean
	JavaMailSender javaMailSender;

	@DisplayName("회원 가입 화면이 보이는지 테스트")
	@Test
	void signUpForm() throws Exception { //아래의 응답이 아닐경우 예외처리
		mockMvc.perform(get("/sign-up")) //회원가입 요청에서
		.andDo(print()) //실제 웹사이트 출력 -> 타임리프이기 때문에
		.andExpect(status().isOk()) //보이는지 -> 즉, 상태가 정상일때 (200일때)
		.andExpect(view().name("account/sign-up")) //뷰의 이름이 account/sign-up이 맞는지
		.andExpect(model().attributeExists("signUpForm"));
		
	}
	@DisplayName("회원 가입 처리 - 입력값 오류")
	@Test
	void signUpSubmit_with_wrong_input() throws Exception {
		mockMvc.perform(post ("/sign-up")
				.param("nickname", "jihun")
				 //이메일이나 패스워드 오류 검증
				.param("email", "email..")
				.param("password", "12345")
				.with(csrf()))		//csrf 토큰에 의한 403 오류 방지
		.andExpect(status().isOk())
		//status OK 확인 후 -> 다시 회원가입페이지 출력
		.andExpect(view().name("account/sign-up"));
	}
	//403출력하는데 -> 스프링 시큐리티에서 authorize만 확인하는데
	//-> CSRF 토큰에 의해 타사이트에서 폼데이터를 보내는 공격을 방지하기 위해
	//타임리프 탬플릿에서 form을 보낼 경우 토큰값을 확인하는데, 토큰값이 다르다는 것을 확인
	//-> csrf 토큰을 삽입한다.

	@DisplayName("회원 가입 처리 - 입력값 정상")
	@Test
	void signUpSubmit_with_correct_input() throws Exception {
		mockMvc.perform(post ("/sign-up")
				.param("nickname", "jihun")
				 //이메일이나 패스워드 오류 검증
				.param("email", "email@gmail.com")
				.param("password", "12345678")
				.with(csrf()))		//csrf 토큰에 의한 403 오류 방지
		.andExpect(status().is3xxRedirection())		//redirection 응답 확인
		.andExpect(view().name("redirect:/"));
		
		//유저 조회를 위한 의존성 주입 필요-> AccountRepository
		//junit 의존성 추가
		assertTrue(accountRepository.existsByEmail("email@gmail.com"));
		then(javaMailSender).should().send(any(SimpleMailMessage.class));
	}
	
}

 

AccuntController

package com.demo.account;

import javax.validation.Valid;

import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

import com.demo.domain.Account;

import lombok.RequiredArgsConstructor;

@Controller
@RequiredArgsConstructor
public class AccountController {

	private final SignUpFormValidator signUpFormValidator;
	private final AccountRepository accountRepository;
	// 이메일 발송 관련 빈 의존성 주입
	private final JavaMailSender javaMailSender;

	@InitBinder("signUpForm")
	public void initBinder(WebDataBinder webDataBinder) {
		webDataBinder.addValidators(signUpFormValidator);
	}

	// sign-up 페이지에 연결된다면
	@GetMapping("/sign-up")
	public String signUpForm(Model model) {
		// model.addAttribute(new SignUpForm()); 생략 가능
		model.addAttribute("signUpForm", new SignUpForm());
		return "account/sign-up";
	}
	// 스프링부트 자동설정에 의해
	// templates에 존재하는 view인 account/sign-up을 리턴한다.

	@PostMapping("/sign-up") // 복합객체는 본디 ModelAttribute로 받지만, 생략가능
	public String signUpSubmit(@Valid @ModelAttribute SignUpForm signUpForm, Errors errors) {
		if (errors.hasErrors()) {
			return "account/sign-up";
		}
		// SignUpForm 도메인에 Valid를 위한 처리 필요

//		@InitBinder를 이용해 대체 
//		signUpFormValidator.validate(signUpForm, errors);
//		if (errors.hasErrors()) {
//			return "account/sign-up";
//		}
		// -> 자동으로 SignUpForm 검증을 한다.

		
		
		//1. 회원정보 저장
		//2. 이메일 토큰 만들기
		//3. 이메일 전송
		
		
		// form을 이용해 account에 저장
		Account account = Account.builder().email(signUpForm.getEmail()).nickname(signUpForm.getNickname())
				.password(signUpForm.getPassword()) // TODO encoding (해시 암호화) 필요함
				// .emailVerified(false) //검증은 아직 이루어지지 않았으므로 null이 들어가 있어서 불필요
				.studyCreatedByWeb(true).studyEnrollmentResultByWeb(true).studyUpdatedByWeb(true)
				// 웹으로 알림을 알리는 옵션은 켜둔 상태로 저장 -> 나머지 기본값들은 모두 false
				.build();

		// 해당 account 생성 -> repository에 저장
		Account newAccount = accountRepository.save(account);
		
		
		// 회원가입 이메일 발송 -> starter-mail 이용 -> mailsender // 일단 가짜 객체에 저장해 콘솔에 출력

		// 계정인증을 위한 토큰값 생성 -> 유효 아이디를 이용해 랜덤 생성
		newAccount.generateEmailCheckToken();

		// 관련 객체 정의 후 -> 빈 정의 후 발송
		SimpleMailMessage mailMessage = new SimpleMailMessage();

		// 받는 주소
		mailMessage.setTo(newAccount.getEmail());
		// 제목 작성
		mailMessage.setSubject("Demo, 회원 가입 인증");
		// 본문 작성 -> 가입인증 링크 만들어 제공 -> 토큰값을 생성해 가입인증 진행
		mailMessage.setText(
				"/check-email-token?token=" + newAccount.getEmailCheckToken() + " &email=" + newAccount.getEmail());
		// 메일 발송 ->
		javaMailSender.send(mailMessage);
		//만약에 발송을 하지않는다면, test에서 오류를 내뿜게된다.

		// TODO 회원 가입 처리 valid에 안걸리면 회원가입
		return "redirect:/";

	}

}

 


리팩토링 수행

(1) 읽기 어려운 코드들을 메서드화시켜서 가독성을 높인다.

(2) 과도한 크기의 컨트롤러의 일부를 서비스로 이전

(3) 컨트롤러에서 몰라도 되는 서비스단은 private으로 수행

(4) 테스트를 통해, 해당 리팩토링이 제대로 수행되었는지 확인

 

AccountController

package com.demo.account;

import javax.validation.Valid;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

import lombok.RequiredArgsConstructor;

@Controller
@RequiredArgsConstructor
public class AccountController {

	private final SignUpFormValidator signUpFormValidator;
	//계정 리포지토리와 메일 샌더는 서비스로 이전
	private final AccountService accountService; 


	@InitBinder("signUpForm")
	public void initBinder(WebDataBinder webDataBinder) {
		webDataBinder.addValidators(signUpFormValidator);
	}

	// sign-up 페이지에 연결된다면
	@GetMapping("/sign-up")
	public String signUpForm(Model model) {
		// model.addAttribute(new SignUpForm()); 생략 가능
		model.addAttribute("signUpForm", new SignUpForm());
		return "account/sign-up";
	}
	// 스프링부트 자동설정에 의해
	// templates에 존재하는 view인 account/sign-up을 리턴한다.

	@PostMapping("/sign-up") // 복합객체는 본디 ModelAttribute로 받지만, 생략가능
	public String signUpSubmit(@Valid @ModelAttribute SignUpForm signUpForm, Errors errors) {
		if (errors.hasErrors()) {
			return "account/sign-up";
		}
		// SignUpForm 도메인에 Valid를 위한 처리 필요

//		@InitBinder를 이용해 대체 
//		signUpFormValidator.validate(signUpForm, errors);
//		if (errors.hasErrors()) {
//			return "account/sign-up";
//		}
		// -> 자동으로 SignUpForm 검증을 한다.

		//이메일 보내는 작업을 컨트롤러가 모르도록 서비스 쪽으로 이전 -> 서비스에서 private으로 수행
		accountService.processNewAccount(signUpForm);
		
		return "redirect:/";

	}

}

 

AccountService

package com.demo.account;

import javax.validation.Valid;

import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

import com.demo.domain.Account;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class AccountService {

	private final AccountRepository accountRepository;
	// 이메일 발송 관련 빈 의존성 주입
	private final JavaMailSender javaMailSender;
	
	public void processNewAccount(SignUpForm signUpForm) {
		// 1. 회원정보 저장
				// 2. 이메일 토큰 만들기
				// 3. 이메일 전송
				Account newAccount = saveNewAccount(signUpForm);

				// 회원가입 이메일 발송 -> starter-mail 이용 -> mailsender // 일단 가짜 객체에 저장해 콘솔에 출력
				// 계정인증을 위한 토큰값 생성 -> 유효 아이디를 이용해 랜덤 생성
				newAccount.generateEmailCheckToken();

				sendSignUpConfirmEmail(newAccount);

				// TODO 회원 가입 처리 valid에 안걸리면 회원가입
	}


	private Account saveNewAccount(SignUpForm signUpForm) {
		// form을 이용해 account에 저장
		Account account = Account.builder().email(signUpForm.getEmail()).nickname(signUpForm.getNickname())
				.password(signUpForm.getPassword()) // TODO encoding (해시 암호화) 필요함
				// .emailVerified(false) //검증은 아직 이루어지지 않았으므로 null이 들어가 있어서 불필요
				.studyCreatedByWeb(true).studyEnrollmentResultByWeb(true).studyUpdatedByWeb(true)
				// 웹으로 알림을 알리는 옵션은 켜둔 상태로 저장 -> 나머지 기본값들은 모두 false
				.build();

		// 해당 account 생성 -> repository에 저장
		Account newAccount = accountRepository.save(account);

		return newAccount;
	}

	private void sendSignUpConfirmEmail(Account newAccount) {
		// 관련 객체 정의 후 -> 빈 정의 후 발송
		SimpleMailMessage mailMessage = new SimpleMailMessage();

		// 받는 주소
		mailMessage.setTo(newAccount.getEmail());
		// 제목 작성
		mailMessage.setSubject("Demo, 회원 가입 인증");
		// 본문 작성 -> 가입인증 링크 만들어 제공 -> 토큰값을 생성해 가입인증 진행
		mailMessage.setText(
				"/check-email-token?token=" + newAccount.getEmailCheckToken() + " &email=" + newAccount.getEmail());
		// 메일 발송 ->
		javaMailSender.send(mailMessage);
		// 만약에 발송을 하지않는다면, test에서 오류를 내뿜게된다.
	}


	
}

 

AccountControllerTest

package com.demo.account;

import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.web.servlet.MockMvc;

//테스트를 위한 어노테이션 -> 스프링부트테스트, 자동설정목업 MVC
@SpringBootTest @AutoConfigureMockMvc
//사용하면 서블릿이 뜨고, @AutoConfigureWebClient  @AutoConfigureTestClient를 이용하면 테스트 가능 
//@SpringBootTest (webEnvironment =WebEnvironment.DEFINED_PORT)
//@SpringBootTest (webEnvironment =WebEnvironment.NONE)
//@SpringBootTest (webEnvironment =WebEnvironment.RANDOM_PORT)

//테스트 코드가 필요한 이유
//코드를 변경한 이후에 작성한 코드가 오류를 발생시키지 않았다는 것을 확인하기 위해
//모놀리틱 아키텍처의 경우, 작은 변화가 코드 전체의 오류에 영향을 끼치기 쉽기 때문에 테스트 코드가 필요하다.
class AccountControllerTest {
	
	
	//의존성 주입
	@Autowired private MockMvc mockMvc;
	
	@Autowired private AccountRepository accountRepository;

	//이메일 보내는지 확인할 목업빈 -> 메일샌더가 메일을 보냈는지 여부 확인
	@MockBean
	JavaMailSender javaMailSender;

	@DisplayName("회원 가입 화면이 보이는지 테스트")
	@Test
	void signUpForm() throws Exception { //아래의 응답이 아닐경우 예외처리
		mockMvc.perform(get("/sign-up")) //회원가입 요청에서
		.andDo(print()) //실제 웹사이트 출력 -> 타임리프이기 때문에
		.andExpect(status().isOk()) //보이는지 -> 즉, 상태가 정상일때 (200일때)
		.andExpect(view().name("account/sign-up")) //뷰의 이름이 account/sign-up이 맞는지
		.andExpect(model().attributeExists("signUpForm"));
		
	}
	@DisplayName("회원 가입 처리 - 입력값 오류")
	@Test
	void signUpSubmit_with_wrong_input() throws Exception {
		mockMvc.perform(post ("/sign-up")
				.param("nickname", "jihun")
				 //이메일이나 패스워드 오류 검증
				.param("email", "email..")
				.param("password", "12345")
				.with(csrf()))		//csrf 토큰에 의한 403 오류 방지
		.andExpect(status().isOk())
		//status OK 확인 후 -> 다시 회원가입페이지 출력
		.andExpect(view().name("account/sign-up"));
	}
	//403출력하는데 -> 스프링 시큐리티에서 authorize만 확인하는데
	//-> CSRF 토큰에 의해 타사이트에서 폼데이터를 보내는 공격을 방지하기 위해
	//타임리프 탬플릿에서 form을 보낼 경우 토큰값을 확인하는데, 토큰값이 다르다는 것을 확인
	//-> csrf 토큰을 삽입한다.

	@DisplayName("회원 가입 처리 - 입력값 정상")
	@Test
	void signUpSubmit_with_correct_input() throws Exception {
		mockMvc.perform(post ("/sign-up")
				.param("nickname", "jihun")
				 //이메일이나 패스워드 오류 검증
				.param("email", "email@gmail.com")
				.param("password", "12345678")
				.with(csrf()))		//csrf 토큰에 의한 403 오류 방지
		.andExpect(status().is3xxRedirection())		//redirection 응답 확인
		.andExpect(view().name("redirect:/"));
		
		//유저 조회를 위한 의존성 주입 필요-> AccountRepository
		//junit 의존성 추가
		assertTrue(accountRepository.existsByEmail("email@gmail.com"));
		then(javaMailSender).should().send(any(SimpleMailMessage.class));
	}
	
}
반응형