@Data
public class MemberRegisterRequest {
private String loginId;
private String password;
private String email;
private String name;
private LocalDate birthDate;
}
위 코드는 회원가입 시 Request로 받는 데이터이다. 보통의 데이터베이스는 제약조건이 걸려있다.
그러므로 서비스에서는 들어온 데이터가 올바른지 유효성 검사를 해주어야 한다.
아이디의 길이 제한이라던가 이메일 형식이 맞게 들어왔는지, 이름에 특수기호가 포함되어있지 않은지 등등
이러한 제약조건을 검사하는 방법은 코드상에서 검사하는 방법도 있지만 그럴 경우엔
if(request.getLoginId().length()<30){
//아이디 길이 검사
}
이런 조건문이 엄청 많이 붙게 되고 길어질 것이다. 스프링에서는 validation을 통해 유효성검사를 쉽게 할 수 있다.
목표
{
"dataHeader": {
"successCode": 0,
"resultCode": "400 BAD_REQUEST",
"resultMessage": "에러가 발생했습니다."
},
"data": null,
"error": {
"password": "영문, 숫자, 특수문자를 포함한 10~20자리로 입력해주세요",
"email": "이메일 형식을 확인하세요"
}
}
예외처리하고 위와 같은 형태로 Response 구조화하기
Validation
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
우선 `build.gradle`파일에 의존성을 추가해 준다. 이렇게 개발 도중에 의존성을 추가하는 방법에는 프로젝트를 만들었던 https://start.spring.io/ 에 방문하여 의존성만 입력하고 하단에 EXPLORE를 클릭하면
아래와 같이 `build.gradle`을 볼 수 있는데 필요한 문장을 가져와 붙여 넣으면 된다.
다른 방법으로는 https://mvnrepository.com/ 사이트를 이용하는 방법이 있다.
해당 사이트에서 필요한 의존성을 검색하면 위와 같이 목록이 출력된다.
들어가면 버전도 선택할 수 있고
Gradle 뿐만 아니라 Maven 도 있으므로 활용성이 많다.
적용하기
@Data
public class MemberRegisterRequest {
@NotBlank
private String loginId;
@NotBlank
private String password;
@NotBlank
private String email;
@NotBlank
private String name;
private LocalDate birthDate;
}
다시 Request에 와서 위와 같이 String에 빈 값을 받지 않겠다는 NotBlank 어노테이션을 추가해 주었다.
password의 경우 보통 특수문자와 숫자, 영문만을 받게 된다.
@Pattern을 어노테이션의 정규표현식을 이용해서 규칙을 작성할 수 있다.
@Email 어노테이션은 Email 형식으로 입력을 받는 것이고, @Past를 통해 과거 날짜만 입력을 받을 수 있다.
각 어노테이션의 message를 통해 잘못된 값 입력 시 반환해 줄 오류메시지를 보낼 수 있다.
@Data
public class MemberRegisterRequest {
@NotBlank
@Pattern(regexp = "^[a-zA-Z0-9_]{5,20}",
message = "영문, 숫자, _만을 사용하세요")
private String loginId;
@NotBlank
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@$%^&*])[a-zA-Z0-9!@$%^&*]{8,20}",
message = "영문, 숫자, 특수문자를 포함한 10~20자리로 입력해주세요")
private String password;
@NotBlank
@Email(message = "이메일 형식을 확인하세요")
private String email;
@NotBlank
@Pattern(regexp = "^[ㄱ-힣]{1,10}",
message = "올바른 이름을 입력하세요")
private String name;
@Past(message = "유효한 날짜를 입력하세요")
private LocalDate birthDate;
}
작성이 마무리되었다.
테스트
잘못된 값을 입력하였더니 각 필드별로 잘못된 부분을 알려주는 메시지가 출력된 것을 알 수 있다.
작성한 유효성을 전부 만족시켜야 가입이 제대로 되는 모습이다.
Response Entity
import lombok.Getter;
@Getter
public enum ResultCode {
SUCCESS(1, "정상 처리 되었습니다."),
ERROR(0, "에러가 발생했습니다.");
private final int result;
private final String msg;
ResultCode(int result, String msg) {
this.result = result;
this.msg = msg;
}
}
요청 처리의 결괏값을 관리하는 Enum타입의 `ResultCode`를 먼저 작성해 주었다.
int 값인 result는 제대로 요청이 처리되었다면 1, 아니면 0을 반환하도록 하였다. boolean 혹은 byte 등으로 해도 되지만 혹시 나중에 다른 경우의 수가 추가될지도 몰라서 int타입으로 해두었다.
BaseResponse
`BaseResponse`
package com.study.springStarter.common.response;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import java.util.Map;
@Getter
public class ApiResponse<T> {
private final DataHeader dataHeader;
private final T data;
private final T error;
public ApiResponse(DataHeader dataHeader, T data, T error) {
this.dataHeader = dataHeader;
this.data = data;
this.error = error;
}
public static <T> ApiResponse<?> ok(T data) {
return new ApiResponse<>(DataHeader.ok(), data, null);
}
public static ApiResponse<?> error(HttpStatus status, Map<String, String> errors) {
return new ApiResponse<>(DataHeader.error(status), null, errors);
}
}
상태 코드를 다루는 부분은 `DataHeader`클래스를 따로 선언하여 다루었다.
올바르게 통신이 완료된 경우 반환값을 보낼 data와 에러 발생 시 상세 에러 메시지를 다룰 error를 만들었다.
`DataHeader`
package com.study.springStarter.common.response;
import com.study.springStarter.common.status.ResultCode;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class DataHeader {
private int successCode;
private String resultCode;
private String resultMessage;
public DataHeader(int successCode, String resultCode, String resultMessage) {
this.successCode = successCode;
this.resultCode = resultCode;
this.resultMessage = resultMessage;
}
public static DataHeader ok() {
return new DataHeader(ResultCode.SUCCESS.getResult(),
HttpStatus.OK.value() + " " + HttpStatus.OK.name(),
ResultCode.SUCCESS.getMsg());
}
public static DataHeader error(HttpStatus status) {
return new DataHeader(ResultCode.ERROR.getResult(),
status.value() + " " + status.name(),
ResultCode.ERROR.getMsg());
}
}
통신 결과를 표시할 successCode는 아가 작성한 Enum타입의 ResultCode에서 0과 1로 표현된 값이다.
resultCode는 상태코드 값이며 resultMessage는 결과 메세지를 담고 있다.
`InvalidInputException`
package com.study.springStarter.common.exception;
import lombok.Getter;
@Getter
public class InvalidInputException extends RuntimeException {
private final String fieldName;
private final String message;
public InvalidInputException(String fieldName, String message) {
super(message);
this.fieldName = fieldName;
this.message = message;
}
}
잘못된 입력을 받았을 경우, 잘못 입력된 field와 메세시를 담아내기 위한 예외사항을 작성하였다.
`GlobalExceptionHandler`
package com.study.springStarter.common.exception;
import com.study.springStarter.common.response.BaseResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public BaseResponse<?> methodArgumentNotValidException(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach((error) -> {
String fieldName = error.getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage != null ? errorMessage : "Invalid Input");
});
return BaseResponse.error(HttpStatus.BAD_REQUEST, errors);
}
@ExceptionHandler(InvalidInputException.class)
public BaseResponse<?> invalidInputException(InvalidInputException ex) {
Map<String, String> errors = new HashMap<>();
String errorMessage = ex.getMessage();
errors.put(ex.getFieldName(), errorMessage != null ? errorMessage : "Invalid Input");
return BaseResponse.error(HttpStatus.BAD_REQUEST, errors);
}
@ExceptionHandler(Exception.class)
public BaseResponse<?> defaultException(Exception ex) {
Map<String, String> errors = new HashMap<>();
String errorMessage = ex.getMessage();
errors.put("미처리 예외", errorMessage != null ? errorMessage : "Error");
return BaseResponse.error(HttpStatus.BAD_REQUEST, errors);
}
}
예외 발생 시 원하는 방식으로 처리하기 위해 ExceptionHandler를 작성해 준다. 가장 위에 `MethodArgumentNotValidException`는 valid로 작성한 조건에 맞지 않는 request가 들어왔을 경우 처리하는 예외이고, `InvalidInputException`는 직접 발생시켰을 경우 처리하기 위함이다.
적용
이로써 준비는 마쳤고 작성한 코드를 통해 기존의 회원가입 서비스와 컨트롤러를 수정해야 한다.
package com.study.springStarter.member.controller.request;
import com.study.springStarter.member.entity.Member;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.time.LocalDate;
@Data
public class MemberRegisterRequest {
@NotBlank
@Pattern(regexp = "^[a-zA-Z0-9_]{5,20}",
message = "영문, 숫자, _만을 사용하세요")
private String loginId;
@NotBlank
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@$%^&*])[a-zA-Z0-9!@$%^&*]{8,20}",
message = "영문, 숫자, 특수문자를 포함한 10~20자리로 입력해주세요")
private String password;
@NotBlank
@Email(message = "이메일 형식을 확인하세요")
private String email;
@NotBlank
@Pattern(regexp = "^[ㄱ-힣]{1,10}",
message = "올바른 이름을 입력하세요")
private String name;
@Past(message = "유효한 날짜를 입력하세요")
private LocalDate birthDate;
public Member toEntity() {
return Member.builder()
.loginId(this.getLoginId())
.password(this.getPassword())
.name(this.getName())
.birthDate(this.getBirthDate())
.email(this.getEmail())
.build();
}
}
우선 기존 `MemberService`에서 지저분하게 적혀있던 Member.Builder()를 `MemberRegisterRequest`안에서 변환해 주도록 수정하였다.
그리고 서비스에서 아이디 중복확인을 하는 부분에서 예외처리를 해주었다.
Optional<Member> findMember = memberRepository.findByLoginId(request.getLoginId());
if (findMember.isPresent()) {
return "중복된 ID 입니다.";
}
위와 같이 직접 결괏값을 반환하여 String 형식의 값으로 반환하던 것에서
`MemberService`
package com.study.springStarter.member.service;
import com.study.springStarter.common.exception.InvalidInputException;
import com.study.springStarter.member.controller.request.MemberRegisterRequest;
import com.study.springStarter.member.entity.Member;
import com.study.springStarter.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
/**
* 회원 가입
*/
public String signUp(MemberRegisterRequest request) {
memberRepository.findByLoginId(request.getLoginId())
.orElseThrow(() -> new InvalidInputException("loginId", "중복된 ID 입니다."));
Member member = request.toEntity();
memberRepository.save(member);
return "가입되었습니다.";
}
}
이렇게 수정하여 valid에서 걸러주지 못한 잘못된 입력을 예외를 던지도록 하였다.
`MemberController`
package com.study.springStarter.member.controller;
import com.study.springStarter.common.response.BaseResponse;
import com.study.springStarter.member.controller.request.MemberRegisterRequest;
import com.study.springStarter.member.service.MemberService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/member")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public BaseResponse<?> signup(@RequestBody @Valid MemberRegisterRequest request) {
return BaseResponse.ok(memberService.signUp(request));
}
}
마지막으로 Controller를 위와 같이 반환값을 `BaseResponse`, Request 앞에 `@Valid` 어노테이션을 추가해 주면 마무리된다.