public class MemberDTO {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
setter가 있는 경우, 가변 객체로 활용 가능
public class MemberDTO {
private final String name;
private final int age;
public MemberDTO(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
생성자를 이용해서 초기화 하는 경우 불변 객체로 활용 가능. 불변 객체로 만들면 데이터를 전달하는 과정에서 변하지 않음을 보장
VO(Value Object)
VO는 값 자체를 표현하는 객체
VO는 객체들의 주소가 달라도 값이 같으면 동일한 것으로 여김
ex) 고유 번호가 다른 서로 다른 만원 2장 (주소는 다르지만 값은 동일하다.)
VO는 getter 메서드와 함께 비즈니스 로직 포함 가능하다. 단, Setter 메서드는 가지지 않는다.
++ 값 비교를 위해 equals()와 hashCode() 메서드를 오버라이딩 해줘야 한다. 그렇지 않으면 테스트가 실패한다. (주소 값을 비교하기 때문이다.)
public class Money {
private final String currency;
private final int value;
public Money(String currency, int value) {
this.currency = currency;
this.value = value;
}
public String getCurrency() {
return currency;
}
public int getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return value == money.value && Objects.equals(currency, money.currency);
}
@Override
public int hashCode() {
return Objects.hash(currency, value);
}
}
public class MoneyTest {
@DisplayName("VO 동등 비교")
@Test
void isSameObjects() {
Money money1 = new Money("원", 10000);
Money money2 = new Money("원", 10000);
assertThat(money1).isEqualTo(money2);
assertThat(money1).hasSameHashCodeAs(money2);
}
}
Entity
Entity는 실제 DB 테이블과 매핑되는 핵심 클래스. 이를 기준으로 테이블이 생성되고 스키마가 변경된다.
Lombok을 이용해 @Getter, @Setter를 사용하여 사용하고 있었는데, 빌더 패턴이라는 것을 알게 되었고
꽤 많은 장점이 있는 것 같아서 공부해 보게 되었다.
Builder 패턴은 Effective Java의 규칙인 것 같다.
(최근 Effective Java에 대해 알게 되었는데 궁금해서 이 부분도 추가로 공부해 볼 예정이다.)
🥑 생성자에 인자가 많을 때는 빌더 패턴을 고려
빌더 패턴이란 복합 객체의 생성 과정과 표현 방법을 분리하여 동일한 생성 절차에서 서로 다른 표현 결과를 만들 수 있게하는 패턴이라고 한다.
레퍼런스 블로그 개발자 분은 생성자가 많아지면 빌더 패턴을 만드는 편이라고 한다.
이유로는 빌더 패턴을 활용하면 어떤 필드에 어떤 인자를 넣어 주었는지 명확히 알 수 있고, 넣어줄 필요가 없는 필드는 굳이 선언할 필요가 없어서 선호하는 편이라고 함. 하지만 필드에 null이 들어간다는 걸 명확히 볼 수 있는 점 때문에 생성자를 통해 객체를 생성하는 방법을 택하는 개발자 분들도 있다고 한다.
Builder 패턴의 장점
1. 객체들마다 들어가야 할 인자가 각각 다를 때 유연하게 사용할 수 있다.
2. Setter 생성을 방지하고 불변 객체로 만들 수 있다.
3. 필수 argument를 지정할 수 있다. (PK 역할을 할 ID값이 보통이라고 한다.)
Builder 패턴을 작성하는 방법
public static class Builder {
private final Profession profession;
private final String name;
private HairType hairType;
private HairColor hairColor;
private Armor armor;
private Weapon weapon;
public Builder(Profession profession, String name) {
if (profession == null || name == null) {
throw new IllegalArgumentException("profession and name can not be null");
}
this.profession = profession;
this.name = name;
}
public Builder withHairType(HairType hairType) {
this.hairType = hairType;
return this;
}
public Builder withHairColor(HairColor hairColor) {
this.hairColor = hairColor;
return this;
}
public Builder withArmor(Armor armor) {
this.armor = armor;
return this;
}
public Builder withWeapon(Weapon weapon) {
this.weapon = weapon;
return this;
}
public Hero build() {
return new Hero(this);
}
}
Hero mage = new Hero.Builder(Profession.Mage, "Riobard")
.withHairColor(HairColor.BLACK)
.withWeapon(Weapon.DAGGER)
.build();
@Builder
빌더 패턴으로 클래스를 만들 때 위와 같은 방법으로 클래스를 만들면 너무 길고 불편하다.
그래서 Lombok에 @Builder를 사용하면 보일러플레이트 코드를 줄일 수 있다.
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder(builderMethodName = "TodoListBuilder")
@ToString
public class todoList {
private Long id;
private String title;
private String content;
public static TodoListBuilder builder(Long id) {
if (id == null) {
throw new IllegalArgumentException("필수 파라미터 누락");
}
return travelCheckListBuilder().id(id);
}
}
public class Main {
public static void main(String[] args) {
TodoList todoList = TodoList.builder()
.title("글 쓰기")
.content("블로그에 글을 써야해요")
.build();
}
}
페이먼츠의 길은 멀고도 험한 것 같다ㅎㅎ;; 테스트 모드에서 카드결제, 빌링 프로세스는 완료했는데 Exception부분의 코드가 부족한 것을 느꼈다. 문제라고 생각해서 무조건 해결해야겠다고 생각했고, 제대로된 예외처리를 위해 공부한 기록을 남기려 한다.
레퍼런스 블로그를 보고 열심히 공부해 보았다.
[제 공부를 위해 적으면서 머리에 넣은 거라 원본 글 보시는 게 더 좋을 거예요~!!]
먼저 스프링은 예외처리를 위해 다양한 어노테이션을 제공한다고 한다.
레퍼런스 블로그의 글은 일관성 있는 코드 스타일을 유지하면서 Exception을 처리하는 방법에 대해 소개하고 있다.
통일된 Error Response 객체
Error Response 객체는 항상 동일한 Error Response를 가져야 한다.
이유: 그렇지 않으면, 클라이언트에서 예외 처리를 항상 동일한 로직으로 처리하기 여렵다.
Error Response 객체를 유연하게 처리하기 위해서 Map<Key, Value> 형식으로 처리하는 것은 좋지 않다.
Map은 런타임 시, 정확한 형태를 갖추기 때문에 객체를 처리하는 개발자들도 정확히 무슨 키에 무슨 데이터가 있는지 확인하기 어렵다.
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException (MethodArgumentNotValidException e) {
log.error("handleMethodArgumentNotValidException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
해당 예제 코드처럼 리턴 타입이 ResponseEntity<ErrorResponse>로 무슨 데이터가 있는지 명확하게 추론하기 쉽도록 구성하는 것이 바람직하다.
Error Response JSON
{
"message": "Invalid Input Value",
"status": 400,
"errors": [
{
"field": "name.last",
"value": "",
"reason": "must not be empty"
},
{
"field": "name.first",
"value": "",
"reason": "must not be empty"
}
],
"code": "C001"
}
ErrorResponse 객체의 JSON이다.
message: 에러에 대한 message를 작성한다.
status: http status code를 작성한다. header 정보에도 포함된 정보이니 굳이 추가하지 않아도 된다.
errors: 요청 값에 대한 field, value, reason을 작성한다. 일반적으로 @Valid 어노테이션으로 JSR 303: Bean Validation에 대한 검증을 진행한다.
만약 errors에 바인딩된 결과가 없을 경우 null이 아니라 빈 배열을 응답해준다. null 객체는 절대 리턴하지 않는다.(null이 의미하는 것이 애매하기 때문)
code: 에러에 할당되는 유니크한 코드 값
Error Response 객체
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {
private String message;
private int status;
private List<FieldError> errors;
private String code;
...
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public static class FieldError {
private String field;
private String value;
private String reason;
...
}
}
ErrorResponse 객체이다. POJO(Plain Old Java Object) 객체로 관리하면 errorResponse.get~~(); 로 명확하게 객체에 있는 값을 가져올 수 있다. 그 밖에 특정 Exception에 대해서 ErrorResponse 객체를 어떻게 만들 것인가에 대한 책임을 명확하게 갖는 구조로 설계할 수있다.
@ControllerAdvice로 모든 예외를 핸들링
@ControllerAdvice 어노테이션으로 모든 예외를 한 곳에서 처리할 수 있다.
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* javax.validation.Valid or @Validated 으로 binding error 발생시 발생한다.
* HttpMessageConverter 에서 등록한 HttpMessageConverter binding 못할 경우 발생
* 주로 @RequestBody, @RequestPart 어노테이션에서 발생
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.error("handleMethodArgumentNotValidException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
/**
* @ModelAttribute로 binding error 발생시 BindException이 발생한다.
*/
@ExceptionHandler(BindException.class)
protected ResponseEntity<ErrorResponse> handleBindExcepton(BindException e) {
log.error("handleBindException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
/**
* enum type이 일치하지 않아 binding 못할 경우 발생
* 주로 @RequestParam enum으로 binding 못했을 경우 발생
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(HttpRequestMethodNotSupportedException e) {
log.error("handleMethodArgumentTypeMismatchException", e);
final ErrorResponse response = ErrorResponse.of(e);
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
/**
* 지원하지 않은 HTTP method 호출 할 경우 발생
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
protected ResponseEntity<ErrorResponse> handleHttpRequestMethodNotSupportedExcepton(HttpRequestMethodNotSupportedException e) {
log.error("handleHttprequestMethodNotSupportedException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED);
return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED);
}
/**
* Authentication 객체가 필요한 권한을 보유하지 않은 경우 발생
*/
@ExceptionHandler(AccessDeniedException.class)
protected ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException e) {
log.error("handleAccessDeniedException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.HANDLE_ACCESS_DENIED);
return new ResponseEntity<>(response, HttpStatus.valueOf(ErrorCode.HANDLE_ACCESS_DENIED.getStatus()));
}
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(final BusinessException e) {
log.error("handleEntityNotFoundException", e);
final ErrorCode errorCode = e.getErrorCode();
final ErrorResponse response = ErrorResponse.of(errorCode);
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
}
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("handleEntityNotFoundException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
handleMethodArgumentNotValidException
HttpMessageConverter에서 등록한 HttpMessageConverter binding이 안된 경우 주로 발생,
@RequestBody, @RequestPart 어노테이션에서 발생
handleBindException
@ModelAttribute으로 binding 에러 발생하는 경우 BindException이 발생
MethodArgumentTypeMismatchException
enum type이 일치하지 않아 binding 못하는 경우 발생
주로 @RequestParam enum으로 binding 못하는 경우 발생
handleHttpRequestMethodNotSupportedException
지원하지 않은 HTTP method 호출 할 경우 발생
handleAccessDeniedException
Authentication 객체가 필요한 권한을 보유하지 않은 경우 발생
Security에서 던지는 예외
handleException
그 밖에 발생하는 모든 예외 처리, NullPointException 등등
개발자가 직접 핸들링해서 다른 예외로 던지지 않으면 모두 이곳으로 모인다.
handleBusinessException
비즈니스 요구사항에 따른 Exception
스프링 라이브러리 등 자체적으로 발생하는 예외는 @ExceptionHandler로 추가해서 적절한 Error Response를 만들고 비즈니스 요구사항에 대한 예외일 때는 BusinessException으로 통일성 있게 처리하는 것을 목표로 해야한다.
Error Code 정의
public enum ErrorCode {
// Common
INVALID_INPUT_VALUE(400, "C001", "Invalid Input Value"),
METHOD_NOT_ALLOWED(405, "C002", "Invalid Input Value"),
...
HANDLE_ACCESS_DENIED(403, "C006", "Access is Denied"),
// Member
EMAIL_DUPLICATION(400, "M001", "Email is Duplication"),
LOGIN_INPUT_INVALID(400, "M002", "Login input is invalid"),
private final String code;
private final String message;
private int status;
ErrorCode(final int status, final String code, final String message) {
this.status = status;
this.message = message;
this.code = code;
}
}
Error Code는 enum 타입으로 한 곳에서 관리한다.
에러 코드가 전체적으로 흩어져있을 경우 코드, 메세지 중복을 방지하기 어렵고 전체적으로 관리하는 것이 어렵다.
C001 같은 코드도 동일하게 Enum으로 관리하면 좋다. 에러 메세지는 Common과 각 도메인 별로 관리하는 것이 효율적이다.
Business Exception 처리
요구사항에 맞지 않을 경우 발생시키는 Exception을 말한다.
(블로그에서 쿠폰의 예를 들어주셨다.)
만약 쿠폰을 사용하려고 하는데 이미 사용한 쿠폰인 경우에는 더이상 정상적인 흐름을 이어갈 수 없게 된다. 이런 경우 적절한 Exception을 발생시키고 로직을 종료 시켜야한다.
더 쉽게 정리하면 요구사항에 맞게 개발자가 직접 Exception을 발생시키는 것들이 Business Exception이라고 할 수 있다.
쿠폰을 입력해서 상품을 주문했을 때, 상품 계산 로직에서 이미 사용해버린 쿠폰이면 로직을 이어나갈 수 없다. (계산 로직 책임 증가)
계산 로직은 특정 공식에 의해서 제품의 가격을 계산하는 것이 책임, 쿠폰 이미 사용한 경우, 만료된 경우, 매진된 경우에 해당하는 처리에 대한 책임을 갖는 순간 유지보수하기 어려운 코드가 된다. 객체의 적절한 책임을 주기 위해서라도 적절한 Exception 발생이 필요하다.
public class DeviceController {
...
public void sendShutDown() {
DeviceHandle handle = getHandle(DEV1);
// 디바이스 상태를 점검한다.
if (handle != DeviceHandle.INVALID) {
// 레코드 필드에 디바이스 상태를 저장한다.
retrieveDeviceRecord(handle);
// 디바이스가 일시정지 상태가 아니라면 종료한다.
if (record.getStatus() != DEVICE_SUSPENDED) {
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
} else {
logger.log("Device suspended. Unable to shut down");
}
} else {
logger.log("Invalid handle for: " + DEV1.toString());
}
}
...
}
if else의 반복으로 인해 sendShutDown 핵심 비즈니스 코드의 이해가 어렵다.
객체 본인의 책임 외적인 것들은 DeviceShutDownError 예외를 발생시키고 있다. (코드의 가독성과 책임이 분명하게 드러남)
비즈니스 예외를 위한 Business Exception 클래스
최상위 BusinessException을 상속받는 InvalidValueException, EntityNotFoundException 등이 있다.
InvalidValueException: 유효하지 않은 값일 경우 예외를 던지는 Exception
쿠폰 만료, 이미 사용한 쿠폰 등의 이유로 더이상 진행이 안되는 경우
EntityNotFoundException: 각 엔티티들을 못찾았을 경우
findById, findByCode 메서드에서 조회가 안 되는 경우
최상위 BusinessException을 기준으로 예외를 발생시키면 통일감 있는 예외 처리를 가질 수 있다. 비즈니스 로직을 수행하는 코드 흐름에서 로직의 흐름을 진행할 수 없는 상태인 경우에는 적절한 BusinessException 중에서 예외를 발생시키거나 직접 정의한다.
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(final BusinessException e) {
log.error("handleEntityNotFoundException", e);
final ErrorCode errorCode = e.getErrorCode();
final ErrorResponse response = ErrorResponse.of(errorCode);
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
}
이렇게 발생한 모든 예외는 handleBusinessException에서 동일하게 핸들링 된다. 예외 발생시 알람을 받는 등의 추가 기능도 가능하다. 또 BusinessException 클래스의 하위 클래스 중에서 특정 예외에 대해서 다른 알람을 받는 등의 더 디테일한 핸들링도 가능하다.
Coupon Code
public class Coupon {
...
public void use() {
verifyExpiration();
verifyUsed();
this.used = true;
}
private void verifyUsed() {
if (used) throw new CouponAlreadyUseException();
}
private void verifyExpiration() {
if (LocalDate.now().isAfter(getExpirationDate())) throw new CouponExpireException();
}
}
쿠폰의 use 메서드이고, 만료일과 사용 여부를 확인하고 예외가 발생하면 적절한 Exception을 발생시킨다.
컨트롤러 예외 처리
컨트롤러에서 모든 요청에 대한 값 검증을 진행하고 이상이 없을 시에 서비스 레이어를 호출해야 한다. 잘못된 값이 있으면 서비스 레이어에서 정상적인 작업을 진행하기 어렵다. 무엇보다 컨트롤러의 책임을 다 하고 있지 않으면 그 책임은 자연스럽게 다른 레이어로 전해지게 된다. 이 때, 넘겨받은 책임을 처리하는 큰 비용과 유지보수하기 어려워지는 문제가 생기게 된다.
컨트롤러의 중요한 책임 중 하나는 요청에 대한 값 검증이다. 스프링은 JSR 303 기반 어노테이션으로 값 검증을 쉽고 일관성 있게 처리할 수 있도록 도와준다. 모든 예외는 @ControllerAdvice로 선언된 객체에서 핸들링 된다. 컨트롤러로 본인이 직접 예외까지 처리하지 않고 예외가 발생하면 그냥 던져버리는 패턴으로 일관성있게 개발할 수 있다.
Controller
@RestController
@RequestMapping("/members")
public class MemberApi {
private final MemberSignUpService memberSignUpService;
@PostMapping
public MemberResponse create(@RequestBody @valid final SignUpRequest dto) {
final Member member = memberSignUpService.doSignUp(dto);
return new MemberResponse(member);
}
}
public class SignUpRequest {
@Valid private Email email;
@Valid private Name name;
}
public class Name {
@NotEmpty private String first;
private String middle;
@NotEmpty private String last;
}
public class Email {
@javax.validation.constraints.Email
private String value;
}
회원 가입 Request Body 중에서 유효하지 않은 값이 있을 때, @Valid 어노테이션으로 예외를 발생시킬 수 있다. 이 예외는 @ControllerAdvice에서 적절하게 핸들링 된다. @NotEmpty 외에도 다양한 어노테이션 제공