개발자는 기록이 답이다
Request에 대한 Validation과 Exception 처리에 대한 고찰 본문
프로젝트를 하면서 회원가입 시 요청 값에 대한 검증과 예외 처리를 어떻게 할지 여러 방법을 생각해봤습니다.
요청 값을 검증하는 방법으로 4가지가 있습니다.
- Argument Resolver 사용
- Assert 단언문을 사용한 검증 메소드
- Bean Validation 사용
- 커스텀 어노테이션 사용
회원 가입 로직은 아래와 같습니다.
1. 회원가입 요청 값에 대한 요구사항
- 이메일
- 공백이거나 null일 수 없다
- 이메일 형식이어야 한다
- 비밀번호
- 공백이거나 null일 수 없다
- 영단어 소문자, 숫자 조합으로 각각 1개 이상 포함되어야 한다
- 범위는 8자~13자 이내여야 한다
- 생년월일
- 공백이거나 null일 수 없다
- YYYY-MM-DD 형식이어야 한다
- 이름
- 공백이거나 null일 수 없다
- 핸드폰 번호
- 공백이거나 null일 수 없다
- 010-123-1234 형식이어야 한다
- 요청 값이 검증에 실패할 경우 프론트엔드가 명확하게 알 수 있도록 응답에 같이 보내줘야 한다.
2. Argrument Resolver 사용
기본적으로 Controller 메소드에서 요청 본문의 내용을 받기 위해서는 @Requestbody를 사용합니다.
@Requestbody를 사용하게 되면 HandlerAdapter에서 메소드의 파라미터를 처리하기 위해 HandlerMethodArgumentResolver를 찾는데, 여러개의 ArgumentResolvers들 중에서 RequestResponseBodyMethodProcessor에 의해 처리됩니다.
관련된 내용은 이전에 포스팅한 내용을 참고해주시면 좋을 것 같습니다.
하지만 Argument Resolver를 커스텀할 수 있는 방법에 대해 알게 되어 테스트해보고 싶었습니다.
먼저 컨트롤러와 요청으로 받을 DTO를 준비해줬습니다.
@RequiredArgsConstructor
@RequestMapping("/members")
@RestController
public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public ResponseEntity<ResponseDTO> signup(MemberMapperDTO memberMapperDTO) {
ResponseDTO responseDTO = memberService.signUp(memberMapperDTO);
return ResponseEntity.ok(responseDTO);
}
}
@Generated
@Getter
@EqualsAndHashCode
public final class MemberMapperDTO {
private final String email;
private final String password;
private final String name;
private final String birthDate;
private final String phone;
private MemberMapperDTO(String email, String password, String name, String birthDate, String phone) {
this.email = email;
this.password = password;
this.name = name;
this.birthDate = birthDate;
this.phone = phone;
}
public static MemberMapperDTO create(String email, String password, String name, String birthDate, String phone) {
return new MemberMapperDTO (email, password, name, birthDate, phone);
}
}
그리고 커스텀한 ArgumentResolver를 만들기 위해 HandlerMethodArguementResolver인터페이스를 구현해 줍니다.
HandlerMethodArgumentResolver
HandlerMethodArgumentResolver는 들어온 요청의 context에서 메소드 파라미터의 인자값을 처리하는 전략 인터페이스 입니다.
- supportsParameter() : 주어진 메소드 파라미터가 해당 Argument Resolver에서 지원되는지 확인합니다. 지원한다면 true 를, 그렇지 않다면 false 를 반환합니다.
- resolveArgument() : 들어온 요청에서 메소드 파라미터를 실제로 어떻게 해석하고, 어떤 값을 반환할지 정의합니다.
SignupArguemntResovler 구현
resolveArgument()메소드에서 네트워크에서 전송된 데이터를 읽어오기 위해 InputStream을 사용해야 유효성 검증이 가능합니다.
HTTP요청의 본문을 바이트 스트림으로 읽은 JSON 문자열을 ObjectMapper를 사용해서 MemberMapperDTO 객체로 변환합니다.
@RequestBody를 사용하면 전부 자동으로 이뤄지는 작업이지만, 직접 구현할 경우 i/o작업과 Json -> JavaBean 으로 변환하는 작업이 필요합니다.
@Component
@RequiredArgsConstructor
public class SignupArgumentResolver implements HandlerMethodArgumentResolver {
private final ObjectMapper objectMapper;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(MemberMapperDTO.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) webRequest.getNativeRequest();
// 요청의 본문을 바이트로 읽기 위해 InputStream 사용
String body = readRequestBody(httpServletRequest.getInputStream());
// 읽어온 JSON 문자열을 MemberMapperDTO로 변환
MemberMapperDTO memberMapperDTO = objectMapper.readValue(body, MemberMapperDTO.class);
// 유효성 검사
validateInput(memberMapperDTO.getEmail(), memberMapperDTO.getPassword(), memberMapperDTO.getName(),
memberMapperDTO.getBirthDate(), memberMapperDTO.getPhone());
return memberMapperDTO;
}
private String readRequestBody(InputStream inputStream) throws IOException {
// InputStream에서 본문을 읽어옴
StringBuilder stringBuilder = new StringBuilder();
byte[] bytes = new byte[1024];
int read;
while ((read = inputStream.read(bytes)) != -1) {
stringBuilder.append(new String(bytes, 0, read));
}
return stringBuilder.toString();
}
// email, birthDate, phone 필드에 대한 유효성 검사 ( 테스트 용으로 3가지만 만들었습니다 )
private void validateInput(String email, String password, String name, String birthDate, String phone) {
String emailPattern = "^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$";
if (!Pattern.matches(emailPattern,email)) {
throw new IllegalArgumentException();
}
String birthdatePattern = "^\\d{4}-\\d{2}-\\d{2}$";
if (!Pattern.matches(birthdatePattern, birthDate)) {
throw new IllegalArgumentException();
}
String phonePattern = "^\\d{3}-\\d{3,4}-\\d{4}$";
if (!Pattern.matches(phonePattern, phone)) {
throw new IllegalArgumentException();
}
}
}
WebMvcConfigurer에서 Argument Resolver 등록
WebMvcConfigurer를 구현한 클래스에 위에서 만들어놓은 SignupArgumentResolver를 추가합니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private SignupArgumentResolver signupArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(signupArgumentResolver);
}
}
이렇게 구현하면 이전에 위에서 봤던 ArgumentResolver 선택하는 부분에서, RequestResponseBodyMethodProcessor를 지나치고 저희가 만든 SignupArgumentResolver가 선택되는걸 볼 수 있습니다.
(참고로 argmentResolver는 총 35개 정도 되는데, 그 중에 저희가 만든 resolver를 포함하게 되면 36개가 됩니다.)
0 = ProxyingHandlerMethodArgumentResolver
1 = RequestParamMethodArgumentResolver
2 = RequestParamMapMethodArgumentResolver
3 = PathVariableMethodArgumentResolver
4 = PathVariableMapMethodArgumentResolver
5 = MatrixVariableMethodArgumentResolver
6 = MatrixVariableMapMethodArgumentResolver
7 = ServletModelAttributeMethodProcessor
8 = RequestResponseBodyMethodProcessor
9 = RequestPartMethodArgumentResolver
10 = RequestHeaderMethodArgumentResolver
11 = RequestHeaderMapMethodArgumentResolver
12 = ServletCookieValueMethodArgumentResolver
13 = ExpressionValueMethodArgumentResolver
14 = SessionAttributeMethodArgumentResolver
15 = RequestAttributeMethodArgumentResolver
16 = ServletRequestMethodArgumentResolver
17 = ServletResponseMethodArgumentResolver
18 = HttpEntityMethodProcessor
19 = RedirectAttributesMethodArgumentResolver
20 = ModelMethodProcessor
21 = MapMethodProcessor
22 = ErrorsMethodArgumentResolver
23 = SessionStatusMethodArgumentResolver
24 = UriComponentsBuilderMethodArgumentResolver
25 = SignupArgumentResolver
26 = AuthenticationPrincipalArgumentResolver
27 = AuthenticationPrincipalArgumentResolver
28 = CurrentSecurityContextArgumentResolver
29 = CsrfTokenArgumentResolver
30 = SortHandlerMethodArgumentResolver
31 = PageableHandlerMethodArgumentResolver
32 = ProxyingHandlerMethodArgumentResolver
33 = PrincipalMethodArgumentResolver
34 = RequestParamMethodArgumentResolver
35 = ServletModelAttributeMethodProcessor
이렇게 구현하면 요청이 들어왔을때 Argument에 대한 처리가 이뤄진 다음에 Handler로 전달되게 됩니다.
하지만 매번 모든 Request에 대한 검증 처리를 하기 위해 이렇게 Custom ArgumentResolver 클래스를 만드는 건 DTO가 늘어나는 만큼 비효율적이라는 생각이 들었고, 보통 이런 방법은 HTTP Header, Session, Cookie 등 common 기능을 위해 사용되는 방법이라고 판단했습니다.
2. Assert 단언문을 사용한 검증 메소드
다른 방식으로 컨트롤러에서 assert를 이용해서 별도의 검증 메소드로 처리한 뒤 Service Layer로 넘겨주는 방법입니다.
Assert는 공식문서에 따르면 파라미터를 검증하는데 도움을 주는 유틸리티 클래스입니다. 조건에 맞지 않을 경우 IllegalArgumentException 또는 IllegalStateException를 발생시킵니다.
메소드 | 설명 |
doesNotContain | 해당 문자열 안에 subString이 포함되어 있지 않은 경우 |
hasLength | null이 아니고 비어있는 문자열("")이 아닌 경우 |
hasText | null이 아니고 공백이 아닌 유효한 문자가 존재하는 문자열일 경우 |
isTrue | 해당 조건식이 참 일 경우 |
isNull | 해당 객체가 null일 경우 |
notNull | 해당 객체가 null이 아닐 경우 |
notEmpty | 해당 Array가 null이 아니고, 1개 이상의 Element를 가지고 있을 경우 |
noNullElements | 해당 Array에 null인 객체가 없을 경우 |
state | 해당 조건식이 참 일 경우 |
@RequiredArgsConstructor
@RequestMapping("/members")
@RestController
public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public ResponseEntity<ResponseDTO> signup(@RequestBody MemberMapperDTO memberMapperDTO) {
validateMemberDto(memberMapperDTO);
ResponseDTO responseDTO = memberService.signUp(memberMapperDTO);
return ResponseEntity.ok(responseDTO);
}
// email, password, birthDate, phone 필드에 대한 유효성 검사 ( 테스트 용으로 일부만 만들었습니다. )
private void validateMemberDto(MemberMapperDTO memberMapperDTO) {
Assert.hasText(memberMapperDTO.getEmail(), "이메일을 입력해야 합니다.");
Assert.hasText(memberMapperDTO.getPassword(), "비밀번호를 입력해야 합니다.");
Assert.hasText(memberMapperDTO.getName(), "이름을 입력해야 합니다.");
Assert.hasText(memberMapperDTO.getPhone(), "휴대전화를 입력해야 합니다.");
Assert.hasText(memberMapperDTO.getBirthDate(), "생일을 입력해야 합니다.");
Assert.isTrue(
Pattern.matches("^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$",
memberMapperDTO.getEmail()), "이메일 양식에 맞춰야 합니다.");
Assert.isTrue(
Pattern.matches("^(?=.*[a-z])(?=.*\\\\d).{8,13}$", memberMapperDTO.getBirthDate()),
"비밀번호는 영문과 숫자 조합으로 8 ~ 13자리까지 가능합니다.");
Assert.isTrue(
Pattern.matches( "^\\d{4}-\\d{2}-\\d{2}$", memberMapperDTO.getBirthDate()),
"생일은 yyyy-MM-dd 형식으로 입력해야 합니다.");
Assert.isTrue(
Pattern.matches( "^\\d{3}-\\d{3,4}-\\d{4}$", memberMapperDTO.getPhone()),
"휴대전화는 010-1234-5678 형식으로 입력해야 합니다.");
}
}
하지만 이 방법도 컨트롤러가 많아진다면 꽤나 코드양이 많아질 것 같고, validateMethod를 다른 Validation 유틸 클래스로 이동시킨다고 해도, 중복된 메소드를 여러번 사용한다는 게 보기가 좋지 않았어요.
DTO 하나만 검증하는데도 Blank처리, 포맷 처리 다 해야하는게 보이시나요..? 내부적으로 필드가 많아지면 손도 아플 것 같아서 더 간편한 방법을 사용하고자 했습니다.
무엇보다 저는 컨트롤러 내부로 요청이 들어오기 전에 Argument Resolver에서 요청에 대한 검증이 완료되길 바랬습니다. Controller는 요청을 받아서 Service로 보내고 응답을 하는 역할로서만 존재하도록 하기 위함입니다.
3. Bean Validation 사용
Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준입니다.
어노테이션 하나로 검증 로직을 편리하게 적용할 수 있습니다.
어노테이션 | 설명 |
@NotBlank | null이 아니고 비어있는 문자열("")이 아닌 것만 허용 |
@NotNull | null이 아닌 것만 허용 |
@Range(min = xx , max =xx) / @Size도 동일 | 범위 안의 값만 허용 |
@Max(9999) | 최대 9999까지만 허용 |
이메일 형식의 포맷만 허용 |
@RequiredArgsConstructor
@RequestMapping("/members")
@RestController
public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public ResponseEntity<ResponseDTO> signup(@RequestBody @Valid final MemberMapperDTO memberMapperDTO) {
ResponseDTO responseDTO = memberService.signUp(memberMapperDTO);
return ResponseEntity.ok(responseDTO);
}
}
@Generated
@Getter
@EqualsAndHashCode
public final class MemberMapperDTO {
@NotBlank(message = EMAIL_MUST_NOT_EMPTY)
@Email(message = CHECK_REQUEST_EMAIL)
private final String email;
@Range(min = 8, max= 13, message = CHECK_REQUEST_PSWD_LENGTH)
@Pattern( message= CHECK_REQUEST_PSWD_FORMAT, regexp = "^(?=.*[a-z])(?=.*\\d).+$")
@NotBlank(PSWD_MUST_NOT_EMPTY)
private final String password;
@NotBlank (message = NAME_MUST_NOT_EMPTY)
private final String name;
@NotBlank(message = BIRTHDATE_MUST_NOT_EMPTY)
@Pattern(message= CHECK_REQUEST_BIRTHDATE , regexp = "^\\d{4}-\\d{2}-\\d{2}$")
private final String birthDate;
@Pattern( message= CHECK_REQUEST_PHONE , regexp = "^\\d{3}-\\d{3,4}-\\d{4}$")
private final String phone;
private MemberMapperDTO(String email, String password, String name, String birthDate, String phone) {
this.email = email;
this.password = password;
this.name = name;
this.birthDate = birthDate;
this.phone = phone;
}
public static MemberMapperDTO create(String email, String password, String name, String birthDate, String phone) {
return new MemberMapperDTO (email, password, name, birthDate, phone);
}
}
훨씬 더 간단해진 코드로 검증이 가능하도록 만들었지만, 비밀번호 관련해서 PR 리뷰를 받게 되었습니다.
확실히 정규 표현식은 가독성이 좋지 않고, 추후에 검증 요구사항에 변경되면 손대기가 더 복잡해질 것이라고 판단했습니다.
예를 들어 비밀번호 같은 경우에는 현재 상황에 따른 요구사항에 맞춰서 정규식을 만들었지만.
아래와 같이 요구사항이 여러개로 변경될 경우 모두 정규식으로만 담기에는 무리라고 생각이 들었습니다.
- 일정 길이 구간 이내 일것
- 문자, 숫자, 특수문자를 사용할 것
- 이전에 사용하지 않은 비밀번호일 것
- 비밀번호 재확인
그렇다고 비밀번호만 따로 assert단언문을 사용한 메소드로 검증하면 제가 원하는 대로 Exception 처리가 되지 않을 것이라고 생각했습니다. 저는 요청이 들어왔을때 어떤 필드가 문제인지 모두 담아서 같이 Response에 담아주고 싶었거든요.
예를 들어, @Valid 어노테이션을 사용하여 Bean Validation에 대한 입력 검증을 수행할 경우,
검증 실패 시 발생하는 MethodArgumentNotValidException을 GlobalExceptionHandler에서 캐치하여 처리합니다.
이 과정에서 검증 실패로 인해 발생한 오류들을 각 필드명과 오류 메시지를 키-값 쌍으로 담은 Map으로 구성하여, 이를 Response의 본문으로 반환합니다. 이렇게 해야 어떤 요청 값이 잘못 입력되었는지 클라이언트에게 명확하게 알려줄 수 있다고 생각했습니다.
@Slf4j
@ControllerAdvice
public final class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ResponseDTO> handleValidationExceptions(
MethodArgumentNotValidException ex,
HttpServletRequest request) {
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
if (error instanceof FieldError fieldError) {
fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage());
}
});
return ResponseEntity.badRequest().body(ResponseDTO.getFailResult(fieldErrors.toString()));
}
Bean Validation의 Exception 동작 원리
Bean Validation이 어떻게 검증을 처리가 되는지 내부 디버깅을 통해 알아보겠습니다.
먼저 RequestResponseBodyMethodProcessor 내의 검증 로직은 resolveArgument메소드에서 처리되는데,
요청 본문을 파싱해서 메소드 파라미터에 필요한 객체로 변환하는 과정에서 @Valid 또는 @Validated 어노테이션이 적용된 객체에 대해 Bean Validation API를 통한 검증을 수행합니다. 이때 검증 실패 시 MethodArgumentNotValidException이 발생합니다.
DataBinder클래스의 validate() 메소드를 통해 Bean Validation에 대한 검증 과정을 거치고, 내부적으로 검증 과정에서 오류가 발견되면 이 오류들을 담고 있는 BidingResult 객체가 생성되고, BidingResult가 hasErrors() 에러들을 갖고 있는게 맞다면 MethodArgumentNotValidException가 발생하는 것입니다.
※ Bean Validation에 대한 내부 로직은 hibernate-validator Github 문서를 통해 확인 할 수 있습니다.
Assert단언문의 검증 메소드 Exception 동작 원리
반면에 컨트롤러 내부에서 처리한 Assert문은 HandlerMethod를 호출한 이후에 컨트롤러 내부에서 처리되는 검증이라서 다른 필드와 같은 시점에 Exception이 발생하지 않습니다. (InvocableHandlerMethod클래스의 doInvoke메소드에서 컨트롤러 호출 후검증 처리)
@RequiredArgsConstructor
@RequestMapping("/members")
@RestController
public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public ResponseEntity<ResponseDTO> signup(@RequestBody @Valid final MemberMapperDTO memberMapperDTO) {
validateMemberDto(memberMapperDTO.getPassword());
ResponseDTO responseDTO = memberService.signUp(memberMapperDTO);
return ResponseEntity.ok(responseDTO);
}
private void validateMemberDto(String password) {
Assert.hasText(password, PSWD_MUST_NOT_EMPTY);
Assert.isTrue(validatePasswordLength(password), CHECK_REQUEST_PSWD_FORMAT);
Assert.isTrue(validateLowerCaseAndDigit(password), CHECK_REQUEST_PSWD_LENGTH);
}
private boolean validateLowerCaseAndDigit(String password) {
return password.chars().anyMatch(Character::isLowerCase) &&
password.chars().anyMatch(Character::isDigit);
}
private boolean validatePasswordLength(String password) {
return 8 <= password.length() && password.length() <= 13;
}
}
결론적으로 bean Validation 어노테이션이랑 Assert메소드랑 동시에 같이 쓴다면 Exception이 처리되는 시점도 달라지고 Exception 종류도 달라져서 같이 응답을 보내기가 어렵습니다.
Bean Validation | 컨트롤러 내 Assert | |
예외 발생 위치 | HandlerMethodArgumentResolver | 컨트롤러 메서드 실행 중 |
예외 종류 | MethodArgumentNotValidException | IllegalArgumentException |
4. 커스텀 어노테이션 사용
결국 컨트롤러에서 검증 처리 로직을 다 없애고, 동일한 시점에 검증을 처리하기 위해 비밀번호만 Custom annotation을 만들었습니다.
이렇게 할 Bean Validation 과 Custom annotation 을 같이 사용할 경우 아래 2가지 요구사항을 만족할 수 있게 되었습니다.
- 추후 변경될 요구사항에 맞춰 비밀번호 검증 로직 변경 가능
- 다른 요청 필드와 함께 오류 발생 시 응답 같이 반환
@Documented
@Constraint(validatedBy = PasswordValidator.class)
@Target({ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@ReportAsSingleViolation
public @interface Password {
String message() default PSWD_MUST_NOT_EMPTY;
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
/**
* @Password 어노테이션 구현체
*/
public final class PasswordValidator implements ConstraintValidator<Password, String> {
private static final int MIN_SIZE = 8;
private static final int MAX_SIZE = 13;
@Override
public void initialize(Password phone) {
}
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
if(validateIfNull(password)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(PSWD_MUST_NOT_EMPTY)
.addConstraintViolation();
return false;
}
if (!validatePasswordLength(password)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
MessageFormat.format(CHECK_REQUEST_PSWD_LENGTH, MIN_SIZE, MAX_SIZE))
.addConstraintViolation();
return false;
}
if (!validateLowerCaseAndDigit(password)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(CHECK_REQUEST_PSWD_FORMAT)
.addConstraintViolation();
return false;
}
return true;
}
private boolean validatePasswordLength(String password) {
return MIN_SIZE <= password.length() && password.length() <= MAX_SIZE;
}
private boolean validateLowerCaseAndDigit(String password) {
return password.chars().anyMatch(Character::isLowerCase) &&
password.chars().anyMatch(Character::isDigit);
}
private boolean validateIfNull(String password) {
return password == null;
}
}
@Generated
@Getter
@EqualsAndHashCode
public final class MemberMapperDTO {
...
@Password
private final String password;
...
}
정리
요청을 검증하는 방법에는 여러가지 방법이 있는데, 요구사항에 맞춰서 처리하기 위해 어떤 기능을 선택하는게 좋을지 점검해보는 시간을 가졌습니다. 또한 Argument Resolver의 동작원리를 내부적으로 디버깅해보면서 어떻게 Bean Validation 어노테이션이 검증되는지 확인하면서 다시 한번 더 Spring MVC에 대해 알 수 있는 시간이었습니다.
참고 블로그 :
'Spring > 트러블 슈팅' 카테고리의 다른 글
쿠폰 발급에 대한 동시성 처리 (2) - MySQL의 NamedLock, Redis의 분산락(Lettuce, Redisson) (0) | 2024.03.02 |
---|---|
쿠폰 발급에 대한 동시성 처리 (1) - synchronized, pessimistic Lock, optimistic Lock (0) | 2024.02.29 |
DATETIME vs TIMESTAMP 둘 중 어느것이 더 나을까? (0) | 2024.02.11 |
@RequestBody는 어떻게 바인딩 되는걸까? (with. 디버깅 과정) (0) | 2024.02.09 |
org.hibernate.LazyInitializationException 에러 해결 방법 (0) | 2023.10.25 |