[프로젝트] 개발자 포트폴리오 - 4. [백엔드] Account 관련 DTO, Controller, Service 생성 후 Test

개발자 포트폴리오 프로젝트

4. [백엔드] Account 관련 DTO, Controller, Service 생성 후 Test

전체 소스 - https://github.com/vividswan/Portfolio-For-Developers


계정 관련 DTO 생성

source commit - c2c09c7

회원가입, 로그인, 회원 정보 전달, 비밀번호 변경과 관련된 DTO 들을 미리 만들어준다.
클라이언트에게 입력받는 값은 validation 조건을 어노테이션으로 걸어준다.
테스트 코드 및 여러 다른 상황에서 사용하기 위해 @Builder도 선언해 주었다.

SignUpRequest.java

package com.portfolio.backend.account.dto;

// ... import 생략

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SignUpRequest {

    @NotBlank
    @Email
    private String email;

    @NotBlank
    @Length(min=3, max = 20)
    @Pattern(regexp = "^[ㄱ-ㅎ가-힣a-zA-Z0-9_-]{3,20}$")
    private String nickname;

    @NotBlank
    @Length(min=6, max = 50)
    private String password;
}

회원 가입 Request DTO 객체이다.

@Pattern(regexp = “^[ㄱ-ㅎ가-힣a-zA-Z0-9_-]{3,20}$”)

정규 표현식으로 다른 특수문자가 들어올 수 없게 Validation 해준다.

SignInRequest.java

package com.portfolio.backend.account.dto;

// ... import 생략

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SignInRequest {

    @NotBlank
    @Email
    private String email;

    @NotBlank
    @Length(min=6, max = 50)
    private String password;
}

회원 가입 Request DTO 객체이다.

AccountInfoResponse.java

package com.portfolio.backend.account.dto;

// ... import 생략

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AccountInfoResponse {
    private Long id;
    private String email;
    private String nickname;
    private boolean isCertify;
    private List<String> roles;
    private LocalDateTime createdDate;
    private LocalDateTime modifiedDate;

    public AccountInfoResponse(Account account){
        this.id = account.getId();
        this.email = account.getEmail();
        this.nickname = account.getNickname();
        this.isCertify = account.isCertify();
        this.roles = account.getRoles();
        this.createdDate = account.getCreatedDate();
        this.modifiedDate = account.getModifiedDate();
    }
}

클라이언트가 정확한 JWT 토큰을 보내줬을 때 백엔드에서 보내주는 회원정보 Response DTO 객체이다.

PasswordUpdateRequest.java

package com.portfolio.backend.account.dto;

// ... import 생략

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class PasswordUpdateRequest {

    @NotBlank
    @Length(min=6, max = 50)
    private String beforePassword;

    @NotBlank
    @Length(min=6, max = 50)
    private String updatePassword;

}

비밀번호 교체 API를 위한 Request DTO이다.
기존의 비밀번호와 수정하고 싶은 비밀번호 두 가지를 받는다.
Service 단에서 기존의 비밀번호를 확인하고 맞으면 교체해 주는 방식으로 만들 예정이다.

feat: 계정 관련 Controller, Service 생성 및 필요한 Exception, Repository 수정

source commit - da747c5

위에서 작성한 DTO들을 이용해 계정에 관련된 컨트롤러와 서비스 객체를 만들고 필요한 Exception과 Repository 수정도 함께해 준다.

AccountController.java

package com.portfolio.backend.account;

// ... import 생략

@Api(tags = {"Account API"})
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/api")
public class AccountController {

    private final  AccountService accountService;

    @ApiOperation(value = "회원 가입 API", notes = "이메일, 닉네임, 비밀번호 전송")
    @PostMapping(value = "/sign-up")
    public CommonResponse signUp(@ApiParam(value = "회원가입 요청 객체", required = true) @RequestBody @Valid SignUpRequest dto, Errors errors){
        if(errors.hasErrors()) throw new CustomValidationException();
        return accountService.signUp(dto);
    }

    @ApiOperation(value = "로그인 API",notes = "학번, 비밀번호 전송")
    @PostMapping(value =  "/sign-in")
    public SingleResponse<String> signIn(@ApiParam(value = "로그인 요청 객체",required = true) @RequestBody @Valid SignInRequest dto, Errors errors){
        if(errors.hasErrors()) throw new CustomValidationException();
        return accountService.signIn(dto);
    }

    @ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 토큰", required = false, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "로그인한 회원 조회", notes = "로그인 후 받은 토큰으로 인증한다.")
    @GetMapping(value = "/account")
    public SingleResponse<AccountInfoResponse> getAccountInfo() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();
        return accountService.getAccountInfo(email);
    }

    @ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 토큰", required = false, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "비밀번호 변경 API", notes ="이전 비밀번호, 새로운 비밀번호 전송")
    @PutMapping(value = "/account/password")
    public SingleResponse<String> updatePassword(@ApiParam(value = "비밀번호 변경 객체", required = true) @RequestBody @Valid PasswordUpdateRequest dto, Errors errors){
        if(errors.hasErrors()) throw new CustomValidationException();
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String email = authentication.getName();
        return accountService.updatePassword(dto, email);
    }
}

Swagger에서의 API 문서화 및 테스트를 위해 관련 어노테이션들도 작성하였다.
회원가입, 로그인, 로그인한 회원의 정보 조회, 비밀번호 변경 API를 제공한다.
회원가입과 로그인에서 DTO에서 설정했던 Validation의 형식 점검이 Error가 있을 때 컨트롤러에서 이를 예외 처리해준다.

@ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 토큰", required = false, dataType = "String", paramType = "header")
    })

로그인한 회원의 정보 조회와 비밀번호 변경 API는 JWT 토큰 값이 있어야 사용할 수 있다.

AccountService.java

package com.portfolio.backend.account;

// ... import 생략

@RequiredArgsConstructor
@Transactional
@Service
public class AccountService {

    private final AccountRepository accountRepository;
    private final PasswordEncoder passwordEncoder;
    private final ResponseService responseService;
    private final JwtTokenProvider jwtTokenProvider;

    public CommonResponse signUp(SignUpRequest dto) {
        if(accountRepository.findByEmail(dto.getEmail()).isPresent()){
            throw new CustomValidationException("email-duplication");
        }

        Account account = Account.builder()
                .email(dto.getEmail())
                .nickname(dto.getNickname())
                .password(passwordEncoder.encode(dto.getPassword())) // 암호화
                .email(dto.getEmail())
                .emailToken(UUID.randomUUID().toString()) // 이메일 인증 문자열
                .isCertify(false) // 이메일 인증 여부
                .roles(Collections.singletonList("ROLE_USER"))
                .build();

        accountRepository.save(account);

        return responseService.getSuccessResponse();
    }

    public SingleResponse<String> signIn(SignInRequest dto) {
        Account account = accountRepository.findByEmail(dto.getEmail())
                .orElseThrow(CustomUserNotFoundException::new);
        if(!passwordEncoder.matches(dto.getPassword(),account.getPassword())){
            throw new CustomUserNotFoundException();
        }
        return responseService.getSingleResponse(jwtTokenProvider.createToken(account.getEmail(), account.getRoles()));
    }

    public SingleResponse<AccountInfoResponse> getAccountInfo(String email) {
        Account account = accountRepository.findByEmail(email)
                .orElseThrow(CustomUserNotFoundException::new);

        return responseService.getSingleResponse(new AccountInfoResponse(account));
    }

    public SingleResponse<String> updatePassword(PasswordUpdateRequest dto, String email) {
        Account account = accountRepository.findByEmail(email)
                .orElseThrow(CustomUserNotFoundException::new);
        if(!passwordEncoder.matches(dto.getBeforePassword(),account.getPassword())){
            throw new CustomUserNotFoundException();
        }
        account.changePassword(passwordEncoder.encode(dto.getUpdatePassword()));
        return responseService.getSingleResponse("비밀번호 변경 완료");
    }
}

컨트롤러에서 만든 API에서 호출하는 메소드를 구현하는 서비스단이다.

throw new CustomValidationException(“email-duplication”)

email은 unique해야 하므로 회원 가입 시 중복된 이메일로 가입 시도를 하면 예외 처리를 하는데 이때 "email-duplication"라는 메세지를 예외 처리에 보내준다.

protected CommonResponse validationException(HttpServletRequest req, CustomValidationException e){
        if (e.getMessage() != null) {
            if (e.getMessage().equals("email-duplication")) return responseService.getFailResponse("중복되는 이메일입니다.");
        }
        return responseService.getFailResponse("잘 못 된 입력 값입니다.");
    }

ExceptionAdvisevalidationException 메소드에 다음과 같은 이메일 중복 메세지에 대한 처리를 추가해 준다.

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findAccountByEmail(String email);

    Optional<Account> findByEmail(String email);

    Optional<Account> findByNickname(String email);
}

AccountRepository에도 Service 객체에 필요한 쿼리메소드를 다음과 같이 추가해 주었다.

서비스단의 모든 api response 객체는 앞서 만들었던 model 객체들로 return 했다.

responseService.getSingleResponse(jwtTokenProvider.createToken(account.getEmail(), account.getRoles()));

로그인 api에서 Request에서 보낸 회원 정보가 맞는다면 JWT 토큰을 만들어서 보내주는 코드이다.

Test Code & Swagger

다음과 같은 회원가입 및 로그인 테스트 코드를 작성해보았다.

AccountControllerTest.java

package com.portfolio.backend.account;

// ... import 생략

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class AccountControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private AccountRepository accountRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @DisplayName("회원가입 테스트")
    @Test
    public void signupTest() throws Exception{

        // given
        SignUpRequest signupDto = SignUpRequest.builder()
                .email("SignUp@test.com")
                .nickname("signUp")
                .password("signUp1234")
                .build();

        // when
        final ResultActions perform = mockMvc.perform(post("/api/sign-up")
                .content(objectMapper.writeValueAsString(signupDto))
                .contentType(MediaType.APPLICATION_JSON));

        //then
        perform.andExpect(status().isOk())
                .andExpect(jsonPath("$.message").value("Success"))
                .andExpect(jsonPath("$.code").value(0))
                .andExpect(jsonPath("$.success").value(true));

    }

    @DisplayName("로그인 테스트")
    @Test
    public void signInTest() throws Exception{
        
        //given
        String email = "test@test.com";
        String nickname = "testUser";
        String password = "test1234";

        Account accountEntity = Account.builder()
                .email(email)
                .nickname(nickname)
                .password(passwordEncoder.encode(password))
                .isCertify(false)
                .roles(Collections.singletonList("ROLE_USER"))
                .emailToken(UUID.randomUUID().toString())
                .build();
        accountRepository.save(accountEntity);

        SignInRequest signInRequest = SignInRequest.builder()
                .email(email)
                .password(password)
                .build();

        //when
        final ResultActions perform = mockMvc.perform(post("/api/sign-in")
                .content(objectMapper.writeValueAsString(signInRequest))
                .contentType(MediaType.APPLICATION_JSON));

        //then
        perform.andExpect(status().isOk())
                .andExpect(jsonPath("$.message").value("Success"))
                .andExpect(jsonPath("$.code").value(0))
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data").exists());

    }

}

1
두 가지 테스트가 모두 통과됐다.

Swagger

2

http://localhost:5000/swagger-ui.html로 접속하면 위와 같은 Swagger 페이지가 나온다.
Controller 객체에서 만든 API들을 확인할 수 있고 JSON을 작성해서 회원가입, 로그인 등의 테스트가 가능하다.