[#01] 프로젝트 처음부터 다시 만들기 프로젝트
https://velog.io/@wjsdj2009/01.-프로젝트-처음부터-다시-만들기-프로젝트
사실 Spring에서 제공하는 validation을 쓰면 아주 간편하게 검증 프로세스를 구현할 수 있다.
하지만 나는 우선 없다고 생각하고 이게 얼마나 편한지 느껴보자.
내가 생각하는 검증 플로우는 다음과 같다.
1. 유저가 form 을 입력하고 회원가입 버튼 클릭 (POST)
2.@ModelAttribute
로MemberJoinDto
객체에 바인딩과 동시에 검증
3.MemberJoinDto
객체가 정상적으로 생성되면 검증 완료, 그렇지 않으면 검증 실패
여기서 @ModelAttribute
가 어떻게 객체를 생성하고 값을 넣어주는지를 알아야 검증 로직을 넣을 수 있다. 보통 @Setter
를 Dto 클래스에 넣어두면, 즉 Setter
를 통해서 동작하는 것으로 알고 있지만, 이렇게 하면 검증 로직을 각각 Setter
에 일일히 넣어둬야한다. 그래서 알아보니 @ModelAttribute
는 Setter
없이 파라미터 생성자만으로 동작한다는 것을 알았다.
https://hyeon9mak.github.io/model-attribute-without-setter/
이 블로그 글을 참고했는데, ModelAttributeMethodProcessor
클래스의 constructAttribute()
메서드에 답이 나와있다.
if (ctor.getParameterCount() == 0) {
// A single default constructor -> clearly a standard JavaBeans arrangement.
return BeanUtils.instantiateClass(ctor);
}
// A single data class constructor -> resolve constructor arguments from request parameters.
String[] paramNames = BeanUtils.getParameterNames(ctor);
Class<?>[] paramTypes = ctor.getParameterTypes();
Object[] args = new Object[paramTypes.length];
WebDataBinder binder = binderFactory.createBinder(webRequest, null, attributeName);
String fieldDefaultPrefix = binder.getFieldDefaultPrefix();
String fieldMarkerPrefix = binder.getFieldMarkerPrefix();
boolean bindingFailure = false;
Set<String> failedParams = new HashSet<>(4);
첫번째 줄에서 해당 클래스 생성자의 파라미터 갯수가 0개이면, 아래 로직에서 Setter
를 통해 값을 바인딩하고, 그게 아니라면 해당 생성자를 통해서 객체에 바인딩하는 방식이다.
즉, 파라미터가 없는 기본 생성자가 있다면 Setter
를 사용하고, 기본 생성자 없이 파라미터가 있는 생성자만 있다면 이를 통해서 값을 넣어주는 방식이라는 것이다. 그래서 해보면 알겠지만, 기본 생성자를 넣어두고 Setter
를 넣어두지 않으면 모두 null 값이 들어간다.
그래서 나는 더 깔끔하게 Setter
없이 생성자에서 값을 검증하고, 객체에 바인딩할 수 있도록 코드를 작성하려고 했으나!!! 문제가 발생했다.
public class MemberJoinController {
@GetMapping("/join")
public String joinPage(Model model) {
model.addAttribute(ConstantAttribute.MEMBER_JOIN_DTO, new MemberJoinDto("", "", "", ""));
return "join/page";
}
@PostMapping("/join")
@ResponseBody
public String join(@ModelAttribute MemberJoinDto memberJoinDto) {
System.out.println("memberJoinDto = " + memberJoinDto);
return "memberJoinDto";
}
}
@Getter
@ToString
public class MemberJoinDto {
private String loginEmail;
private String loginPassword;
private String loginPasswordVerify;
private String companyName;
public MemberJoinDto(String loginEmail, String loginPassword, String loginPasswordVerify, String companyName) {
// TODO - 여기서 입력값 검증
if (!loginEmail.contains("@")) {
throw new IllegalArgumentException("[ERROR] 올바른 이메일 형식이 아닙니다.");
}
this.loginEmail = loginEmail;
this.loginPassword = loginPassword;
this.loginPasswordVerify = loginPasswordVerify;
this.companyName = companyName;
}
}
이런식으로 기본 생성자와 Setter
없이 DTO를 만들고 보니, 뷰에 넘겨줄 비어있는 객체를 new MemberJoinDto("", "", "", "")
이런식으로 보내줄 수 밖에 없었다.(이것도 맘에 안든다) 하지만 이 생성자 안에서 검증을 거치기 때문에 비어있는 "" 값으로는 객체를 생성할 수가 없게 된다. 뷰에 넘겨줄 용도로만 쓸 기본 생성자만 가지고 있는 클래스를 하나 만들어볼까 했지만 중복되는 코드도 그렇고 느낌상 이건 아닌 것 같았다.
그래서 어쩔 수 없이 Setter
를 사용해서 각각 값을 검증하도록 코드를 작성했다.
@Getter
@ToString
public class MemberJoinDto {
private String loginEmail;
private String loginPassword;
private String loginPasswordVerify;
private String companyName;
public void setLoginEmail(String loginEmail) {
// TODO - 이메일 검증
if (!loginEmail.contains("@")) {
throw new IllegalArgumentException("[ERROR] 올바른 이메일 형식이 아닙니다.");
}
if (!loginEmail.split("@")[1].contains(".")) {
throw new IllegalArgumentException("[ERROR] 올바른 이메일 형식이 아닙니다.");
}
this.loginEmail = loginEmail;
}
// 나머지 Setter...
}
이렇게 Setter
안에서 값을 검증해서 실패하면 예외를 발생시키는 방법을 썼다. 이러면 기본 생성자를 쓸 수도 있고(아무 생성자도 없으면 자동으로 생성되니까), @ModelAttribute
도 Setter
를 이용하여 객체에 바인딩할 수 있게 된다.
그런데!!
또 문제가 발생했다. 바로 이 예외를 받아서 처리할 곳이 없었다. (내가 모르는 건가?)
왜냐하면 지금은
@PostMapping("/join")
public String join(@ModelAttribute MemberJoinDto memberJoinDto) { ... }
이 부분에서 @ModelAttribute
로 파라미터 단에서 객체 바인딩을 완료한 다음 구현부로 넘겨주기 때문에, 만약 객체 생성 중에 예외가 발생하면 아예 메서드 구현부, 즉 MVC 자체가 시작 될 수가 없다. 따라서 예외를 받아서 처리할 곳이 없어 오류 메시지를 추가적으로 보여주는 등 이에 따른 추가적인 로직을 작성할 수가 없다.
그래서 @ModelAttribute
을 쓰면 객체 바인딩이 끝난 후 Controller
단에서 객체의 값을 하나하나 꺼내서 검증해야 한다. 지금 우리 서비스는 입력 값 타입이 전부 String 이어서 크게 상관이 없지만 만약, int 값으로 바인딩 해야 할 때 문자열이 들어오게 되면 마찬가지로 MVC 자체에 들어오기 전에 예외가 발생한다.
다시 말하지만 지금 회원가입 폼은 전부 String
타입을 받게 되어있어서 타입 오류가 발생할 일은 없다. 하지만 나는 해당 Dto 객체를 생성하기 전에 검증 로직을 거치고 싶기 때문에 @ModelAttribute
을 쓰지 않고, @RequestParam
으로 값을 하나하나 받아서 Dto 객체를 생성할 것이다. 그래서 2번 플로우가 다음과 같이 바뀐다.
2.
@RequestParam
으로 값을 하나하나 받아서MemberJoinDto
객체 생성과 동시에 검증
@PostMapping("/join")
public String join(@RequestParam String loginEmail,
@RequestParam String loginPassword,
@RequestParam String loginPasswordVerify,
@RequestParam String companyName) {
MemberJoinDto memberJoinDto = new MemberJoinDto(loginEmail, loginPassword, loginPasswordVerify, companyName);
return "join/email_verify";
}
지저분해 보이지만, 우선 이렇게 받아서 객체 생성과 동시에 검증을 할 것이다.
@Getter
@ToString
@NoArgsConstructor
public class MemberJoinDto {
private String loginEmail;
private String loginPassword;
private String loginPasswordVerify;
private String companyName;
public MemberJoinDto(String loginEmail, String loginPassword, String loginPasswordVerify, String companyName) {
MemberJoinValidator memberJoinValidator = new MemberJoinValidator();
memberJoinValidator.validate(loginEmail, loginPassword, loginPasswordVerify, companyName);
this.loginEmail = loginEmail;
this.loginPassword = loginPassword;
this.loginPasswordVerify = loginPasswordVerify;
this.companyName = companyName;
}
}
MemberJoinDto
생성자에서 MemberJoinValidator
객체를 통해 입력 값을 검증하도록 설계했다.
public class MemberJoinValidator {
public void validate(String loginEmail, String loginPassword, String loginPasswordVerify, String companyName) {
validateLoginEmail(loginEmail);
validateLoginPassword(loginPassword, loginPasswordVerify);
validateCompanyName(companyName);
}
private void validateLoginEmail(String loginEmail) {
validateEmptyEmail(loginEmail);
validateEmailForm(loginEmail);
}
private void validateEmptyEmail(String loginEmail) {
if (loginEmail.isEmpty()) {
throw new IllegalArgumentException(ErrorCode.INVALID_EMAIL.name());
}
}
// 생략...
}
이렇게 각 입력값들을 검증하는 로직이 들어있고, 검증에 실패하면 예외를 발생시킨다. 그러면 그 예외에 따라서 사용자에게 해당 입력값에 대한 오류 메시지를 보여주면 된다.
에러 메시지는 다음과 같이 관리했다.
@Getter
public enum ErrorCode {
EMPTY,
INVALID_PASSWORD,
INVALID_COMPANY_NAME,
INVALID_EMAIL;
private int code;
private String reason;
@RequiredArgsConstructor
@Component
public static class ErrorMessageInjector {
private final ErrorCodeDataRepository repository;
private final String locale = "ko";
@PostConstruct
public void postConstruct() {
Arrays.stream(ErrorCode.values())
.forEach(errorCode ->
repository.findByNameAndLocale(errorCode.name(), locale)
.ifPresent(errorCodeDto -> {
errorCode.code = errorCodeDto.getCode();
errorCode.reason = errorCodeDto.getReason();
})
);
}
}
}
https://hyune-c.tistory.com/47
아래 블로그를 참고해서 거의 똑같이 구현했다. 에러 메시지, 정보들을 어떻게 관리하는게 좋을지 찾아보다가 가장 깔끔하고 외부에서도 에러 메시지를 관리할 수 있는 DB를 사용하는 방법을 택했다.
그런데 static 내부 클래스 안의 메서드에서 어떻게 외부 enum 클래스의 private 속성에 직접 접근할 수 있는 건지 이해가 되지 않았다. 그래서 enum 클래스와 내부 클래스에 대한 개념을 다시 공부했다.
enum은 비슷한 의미를 가지는 상수들을 같이 관리하기 아주 좋은 클래스이다.
public enum Direction { EAST, SOUTH, WEST, NOTRH }
public enum Direction2 {
EAST(1), SOUTH(2), WEST(3), NOTRH(4);
private final int value; // 정수를 저장할 필드(인스턴스 변수)
Direction(int value) { this.value = value; } // 생성자
}
enum 클래스의 각 요소들은 모두 public static final 이고, 대문자로 적는다(소문자를 입력해도 오류는 발생하지 않는다).
각 요소들은 하나의 객체처럼 동작하는데 사실 여기서 EAST
는 EAST()
와 같은 의미이다. 그래서 Direction
의 기본생성자를 호출하는 모양이고 각 요소들은 Direction
타입의 객체가 된다. 밑에 있는 Direction2
enum 클래스는 각각 Direction(int value) {...}
생성자를 호출하여 value 값을 초기화하는 모양이다. 그리고 이 enum 생성자는 외부에서 호출할 수 없도록 private
으로 되어있다. (접근 제어자를 붙이지 못한다.)
Direction
enum 클래스가 EAST
, SOUTH
, WEST
, NOTRH
객체를 static 변수로 가지고 있는 것인데, 이 부분을 명확히 이해하기 위해서 일반 클래스 형태로 비슷하게 만들어 보았다.
class Person {
public static final Person ME = new Person("jjp");
private final String name;
private Person(String name) {
this.name = name;
}
static void printName() {
System.out.println(Person.ME.name);
}
void printNameTwice() {
System.out.println(Person.ME.name + Person.ME.name);
}
}
이 클래스는 public static final
변수 ME
를 가지고 있고, 이는 자신의 클래스인 Person
을 객체로 생성하여 가지고 있다. 생성자도 private
으로 enum 과 같이 외부에서 생성자를 호출할 수 없게 되어있다.
ME
는 자신의 클래스인 Person
객체이다. 따라서 ME
가 가지고 있는 private
변수인 name
도 Person
객체 안에서 직접 접근이 가능하다.
이런 형태로 enum 클래스 또한 각 요소들이 본인의 enum 클래스 타입의 객체를 각각 가지고 있고, 맴버 변수를 가지고 있으며, enum 클래스 내부에서는 해당 객체의 private
변수에 직접 접근이 가능하다.
이렇게 익숙치 않았던 enum 클래스를 일반 클래스로 구현해보니 결국 enum 도 클래스라는 것을 더 명확하게 알 수 있어 이해에 도움이 되었다.
enum에 대한 자세한 내용은 아래 블로그 글을 참고했다.
https://breakcoding.tistory.com/m/409
다시 위 코드를 보자.
@Getter
public enum ErrorCode {
EMPTY,
INVALID_PASSWORD,
INVALID_COMPANY_NAME,
INVALID_EMAIL;
private int code;
private String reason;
@RequiredArgsConstructor
@Component
public static class ErrorMessageInjector {
private final ErrorCodeDataRepository repository;
private final String locale = "ko";
@PostConstruct
public void postConstruct() {
Arrays.stream(ErrorCode.values())
.forEach(errorCode ->
repository.findByNameAndLocale(errorCode.name(), locale)
.ifPresent(errorCodeDto -> {
errorCode.code = errorCodeDto.getCode();
errorCode.reason = errorCodeDto.getReason();
})
);
}
}
}
여기서는 생성자로 맴버 변수(에러 정보)값들을 초기화하지 않고, 내부 클래스에서 스프링 빈으로 의존성을 주입받아 DB에 접근하여 에러 정보 값들을 받아서 각각 초기화하고 있다.
궁금했던 점은 static 내부 클래스 안의 메서드에서 어떻게 외부 enum 클래스의 private 속성에 직접 접근할 수 있는지 였다.
우선 내부 클래스 안에서 외부 클래스의 맴버 변수에 접근할 수 있다는 것은 알고 있었다. postConstruct()
메서드 안에 있는 errorCode.code
에서 errorCode
는 각 Errorcode
enum 클래스의 요소들이다(ErrorCode.EMPTY
, ErrorCode.INVALID_PASSWORD
, ... ).
즉, ErrorCode.EMPTY.code
형태로 직접 접근하고 있는 것이다.
아까봤던 ME
변수처럼 ErrorCode.EMPTY
도 ErrorCode
타입 객체이므로 내부 클래스에서도 ErrorCode.EMPTY.code
같이 직접 접근이 가능했던 것이다.
아니 근데 이 내부 클래스는 static
인데? 어떻게 맴버변수에 접근하지??
-> static 클래스 또한 배포 시에 메모리에 올라가기 때문에 맴버 변수에는 접근할 수 없다. 하지만 여기서는 static 변수인 EMPTY를 통해서 해당 객체의 맴버 변수에 접근했기에(이미 객체가 생성되기에) 가능하다.
여담으로 내부 클래스는 무조건 static으로 선언해야한다. 일반 맴버 변수처럼 선언하면, 내부 클래스에서 외부 클래스를 매개변수로 받아 인스턴스 변수로 저장한다. 즉, 내부 클래스가 외부 클래스를 참조하고 있는 것이다.
그래서 나중에 외부 클래스를 더이상 사용하지 않게 되었을 때 GC가 이를 제거해야 하지만, 내부 클래스 참조가 남아있어 GC가 삭제하지 않게 된다. (메모리 누수)
자세한 내용은 아래 블로그 글을 참고했다. 이 부분은 나중에 한 번 따로 다뤄보자
https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%9E%90%EB%B0%94%EC%9D%98-%EB%82%B4%EB%B6%80-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%8A%94-static-%EC%9C%BC%EB%A1%9C-%EC%84%A0%EC%96%B8%ED%95%98%EC%9E%90
이렇게 해서 회원 가입 검증 로직을 모두 작성했다. 이제 유저의 입력 정보가 검증에 통과하지 못하면 MemberJoinDto
객체가 생성되기 전에 예외가 발생하고 메시지의 해당 에러의 이름을 담는다. 이를 Controller
단에서 받아 오류 정보를 response 해줄 것이다.
private void validateEmptyEmail(String loginEmail) {
if (loginEmail.isEmpty()) {
throw new IllegalArgumentException(ErrorCode.INVALID_EMAIL.name());
}
}
글을 적으면서도 쌔함이 느껴진다. 알다시피 예외를 발생시키면 그 이후의 로직은 실행되지 않고 바로 함수를 빠져나온다. 즉, 이메일과 비밀번호를 모두 잘못 입력 했을 때, 이메일 검증에서 예외가 발생하면 비밀번호 검증은 할 수 없게 된다.
유저 입장에서는 한 번에 잘못된 내용을 모두 받아보고 한 번에 고치는게 100배 편하다. 그래서 예외를 발생시키는 것이 아닌, 검증 실패한 에러 코드들을 각각 Set
에 담아서 모든 검증을 거친 후에, Set
에 담겨있는 에러들을 꺼내서 유저에게 보여주는 방식으로 고쳐봤다.
public class MemberJoinValidator {
private final Set<ErrorCode> errorCodes = new HashSet<>();
public Set<ErrorCode> validate(String loginEmail, String loginPassword, String loginPasswordVerify, String companyName) {
validateLoginEmail(loginEmail);
validateLoginPassword(loginPassword, loginPasswordVerify);
validateCompanyName(companyName);
return errorCodes;
}
private void validateLoginEmail(String loginEmail) {
validateEmptyEmail(loginEmail);
validateEmailForm(loginEmail);
}
private void validateEmptyEmail(String loginEmail) {
if (loginEmail.isEmpty()) {
errorCodes.add(ErrorCode.INVALID_EMAIL);
}
}
// 생략...
}
MemberJoinValidator
는 Set<ErrorCode>
에 모든 검증 오류들을 담아서 반환한다.
@Getter
@ToString
public class MemberJoinDto {
private final Set<ErrorCode> errorCodes;
private String loginEmail;
private String loginPassword;
private String loginPasswordVerify;
private String companyName;
public MemberJoinDto() {
errorCodes = new HashSet<>();
}
public MemberJoinDto(String loginEmail, String loginPassword, String loginPasswordVerify, String companyName) {
MemberJoinValidator memberJoinValidator = new MemberJoinValidator();
errorCodes = memberJoinValidator.validate(loginEmail, loginPassword, loginPasswordVerify, companyName);
this.loginEmail = loginEmail;
this.loginPassword = loginPassword;
this.loginPasswordVerify = loginPasswordVerify;
this.companyName = companyName;
}
}
이를 MemberJoinDto
가 받아서 맴버 변수에 저장하고,
@PostMapping("/join")
public String join(@RequestParam String loginEmail,
@RequestParam String loginPassword,
@RequestParam String loginPasswordVerify,
@RequestParam String companyName,
Model model) {
MemberJoinDto memberJoinDto = new MemberJoinDto(loginEmail, loginPassword, loginPasswordVerify, companyName);
Set<ErrorCode> errorCodes = memberJoinDto.getErrorCodes();
if (!errorCodes.isEmpty()) {
model.addAttribute(ConstantAttribute.ERROR_CODES, errorCodes);
model.addAttribute(ConstantAttribute.MEMBER_JOIN_DTO, memberJoinDto);
return "join/page";
}
return "join/email_verify";
}
컨트롤러에서 해당 Set<ErrorCode>
을 꺼내서 오류가 있으면 사용자에게 다시 페이지를 보여주는 방식으로 처리한다.
이렇게 하면 유저가 잘못 입력한 내용들을 한번에 보여줄 수 있다.
타임리프를 이용해서 다음과 같이 유저의 잘못된 입력을 유지하고, 모든 오류 메시지를 한 번에 띄우는데까지 성공했다.
오류 메시지는 DB에서 관리되는 오류 테이블을 참조해서 띄워주고 있다.
조금 아쉬운건 dto 안에Set<ErrorCode> errorCodes
변수를 넣어두고 있다는 점과 errorCodes
객체가 여기저기서 중복된다는 점이다. 그래도 우선 여기까지 스프링 validation을 사용하지 않고, 직접 검증을 구현해봤다.
핵심적인 부분은 사용자의 입력값을 유지하는 것과 모든 에러를 한 번에 유저에게 전달해야하는 부분인 것 같다. 이제 스프링 validation이 얼마나 편하게 해주는지 써보도록 하자.
이번 포스팅 소스 코드 링크
https://github.com/JP-company/smartwire-backend2/tree/01-1.join_validation_without_spring
썸네일 출처
https://velog.io/@oneook/썸네일-메이커Thumbnail-Maker-Toy-Project