MarbleUs Project #3: 공통기능 구현: Advice, Auditable, etc.

John Jun·2023년 10월 10일
0

table of contents


1. 예외처리
2. 기본 Entity 정의
3. Auditable 기능 정의
4. Argument Resolver
5. 회고

1. 예외처리

나는 비지니스 로직을 처리하면서 사용자의 실수로 인해 일어나는 예외 사항들을 고지하고 팀 프로젝트에의 빠른 디버깅을 돕기위해 기본 예외들에 대한 메세지들을 편집하고 응답해 줄 수 있는 예외처리기능을 가진 ExceptionHandler들을 정의한 GlobalExceptionAdvice를 만들었다.

이를 위해 우선 비지니스 로직을 처리하면서 생길 수 있는 예외사항들을 처리하기위해 ExceptionCode Enum 클래스를 만들고 발생 가능한 예외사항들을 정의해주었다.

public enum ExceptionCode {
    MISSION_EXISTS(404, "Mission not found"),
    MISSION_NOT_FOUND(409, "Mission exists"),
    MEMBER_NOT_FOUND(404, "Member not found"),
    MEMBER_INACTIVE(404,"Member is inactive"),
    
    ...
    
    @Getter
    private int status;

    @Getter
    private String message;

    ExceptionCode(int status, String message) 		{
        this.status = status;
        this.message = message;
    }
    
 }

그리고 이를 명시적으로 담아줄 수 있는 자바의 RuntimeException을 상속하는 BusinessLogicException을 만들어 ExceptionCode객체를 파라미터로 갖는 생성자를 만들었다.

public class BusinessLogicException extends RuntimeException{
    @Getter
    ExceptionCode exceptionCode;

    public BusinessLogicException(ExceptionCode exceptionCode) {
        super(exceptionCode.getMessage());
        this.exceptionCode = exceptionCode;
    }
}

이를 통해 서버에서 비지니스 로직중에 에상가능한 예외들을 정의해 throw 할 수 있다.

다음으로 예외사항들을 편집해 Response해 줄 수 있는 일종의 Dto인 ErrorResponse를 정의해 예외사항에 대한 메세지중 필요한 부분만 편집하여 클라이언트에 응답해 줄 수있게 하였다. 이때 생성자 대신 static, of 메서드를 활용하여 보다 명시적으로 에러를 외부 코드 단게에서 주입하여 ErrorResponse 객체를 생성시킬 수 있게 하였다. 또한, 비지니스 로직에서 발생하는 예러외에 데이터의 객체를 생성하는데 validation을 통과하지 못한 예외 사항(MethodArgumentNotValidException)을 사용자에게 핵심적으로 알리기 위해 자바에서 제공하는 BindingResult를 이용하는 FieldError 클래스를 정의하여 오류가 발생한 필드와 통과하지 못한 value 그리고 그 이유를 담아 편집해주고 ErrorResponse의 필드값으로 넣어 주었다.@A 또한 ConstraintViolationException (PathVariable의 유효성검사 실패)의 예외를 처리해 주는 편집된 리스폰스또한 만들어 ErrorResponse의 필드값아 담아주었다.@B 이렇게 함으로써 모든 예외사항에 대하여 그 종류에 따라 핵심적인 부분만을 담아 하나의 ErrorResponse 객체로 다룰 수 있게 되었다.

@Getter
public class ErrorResponse {

    private int status;

    private String message;

    private List<FieldError> fieldErrors;

    private List<ConstraintViolationError> constraintViolationErrors;

    private ErrorResponse(int status, String message) { //of 메서드가 사용할 생성자 1
        this.status = status;
        this.message = message;
    }

    private ErrorResponse(List<FieldError> fieldErrors, List<ConstraintViolationError> constraintViolationErrors) { //of 메서드가 사용할 생성자 2
        this.fieldErrors = fieldErrors;
        this.constraintViolationErrors = constraintViolationErrors;
    }
    
    

    public static ErrorResponse of(BindingResult bindingResult){
        return new ErrorResponse(FieldError.of(bindingResult),null);
    }

    public static ErrorResponse of(Set<ConstraintViolation<?>> constraintViolations){
        return new ErrorResponse(null, ConstraintViolationError.of(constraintViolations));
    }

    // 비지니스 로직 에러들만 따로 처러
    public static ErrorResponse of(ExceptionCode exceptionCode){
        return new ErrorResponse(exceptionCode.getStatus(),exceptionCode.getMessage());
    }

    public static ErrorResponse of(HttpStatus httpStatus){
        return new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase());
    }
    public static ErrorResponse of(HttpStatus httpStatus, String message){
        return new ErrorResponse(httpStatus.value(), message);
    }

    @Getter
    public static class FieldError { //@A
        private String field;
        private Object rejectedValue;
        private String reason;

        public FieldError(String field, Object rejectedValue, String reason) {
            this.field = field;
            this.rejectedValue = rejectedValue;
            this.reason = reason;
        }



        public static List<FieldError> of(BindingResult bindingResult) {
            final List<org.springframework.validation.FieldError> fieldErrors =
                    bindingResult.getFieldErrors();

            return fieldErrors.stream()
                    .map(fieldError -> new FieldError(
                            fieldError.getField(),
                            fieldError.getRejectedValue() == null ? "" : fieldError.getRejectedValue().toString(),
                            fieldError.getDefaultMessage()))
                    .collect(Collectors.toList());
        }

    }

    @Getter
    public static class ConstraintViolationError { //@B

        private String propertyPath;
        private Object rejectedValue;
        private String reason;

        public ConstraintViolationError(String propertyPath, Object rejectedValue, String reason) {
            this.propertyPath = propertyPath;
            this.rejectedValue = rejectedValue;
            this.reason = reason;
        }

        public static List<ConstraintViolationError> of(
                Set<ConstraintViolation<?>> constraintViolations){
            return constraintViolations.stream()
                    .map(
                            constraintViolation ->
                                    new ConstraintViolationError(
                                            constraintViolation.getPropertyPath().toString(),
                                            constraintViolation.getInvalidValue().toString(),
                                            constraintViolation.getMessage()
                                    )
                    ).collect(Collectors.toList());
        }
    }

2. 기본 Entity 정의(equasl & hashcode)

JPA의 기능을 사용하면서 하나의 테이블의 인스턴스를 만들고 이의 동일성을 판별하는 경우가 많은데 이때 Java의 최상위 클래스인 Object의 equals()와 hashCode()를 사용한다. 그런데 equals가 기본적으로 구현된 방법은 2개의 객체가 참조하는 것이 동일한지를 확인하는 것으로, 이는 동일성(Identity)을 비교하는 것이다. 즉, 인스턴스가 만들어질때 변수가 참조하고 있는 그 주소값을 가지고 equals메소드는 비교를 하기 때문에 프로그래밍 상으로는 같은 값을 지님에도 다른 객체로 인식되게 된다.

HashTable과 같은 자료구조를 사용할 때 데이터가 저장되는 위치를 결정하기 위해 사용되는 Object클래스의 hashCode() 또한 기본적으로 heap에 저장된 객체의 메모리 주소를 반환하도록 되어있다. 따라서 동일한 객체(저장된 같은 값을 가지는 객체)는 동일한 메모리 주소를 갖는다는 것을 의미하므로, 동일한 객체는 동일한 해시코드를 가져야 한다. 그래야만 같은 값을 가진 객체의 인스턴스가 생기더라도 hash 자료구조에 저장될때 같은 주소값으로 하나만 저장될 수 있다.

이러한 동등성(Equality)문제를 해결하기 위해 나는 우리 서비스의 모든 앤티티들의 최상위 앤티티를 만들어 eqauls()와 hashCode()메소드를 재정의 해주어야 했다. 객체의 아이디 값으로만 객체를 비교하고 저장하도록 equals와 hashCode 메소드를 오버라이딩해주었다.

그리고 모든 앤티티에서 이를 extends하여 모든 앤티티들에 이를 적용하였다.

public class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected Long id;

    // Getter and setter for id

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o))
            return false;

        BaseEntity that = (BaseEntity) o;
        return Objects.equals(id, that.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

3. Auditable 기능 정의

테이블에 저장되는 시간과 변경사항이 적용되는 시간을 기록하는 기능을 가진 추상 클래스 Auditable을 만들어 프로그램 전방위적으로 적용하여 코드의 수를 줄이고자 하였다. 이때 만들어 놓은 BaseEntity의 재정의된 메소드들은 필수적으로 모든 앤티티에 적용되어야 하기 때문에 Auditable 추상클래스 또한 BaseEntity를 상속하여 Auditable 만 상속하더라도 BaseEntity의 역할도 함께 적용될 수 있도록 하였다.

@Getter
@Setter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable extends BaseEntity {

    @CreatedDate
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "last_modified_at")
    private LocalDateTime modifiedAt;

}

그리고 메인 Application 클래스에 @EnableJpaAuditing를 붙혀 기능 사용을 가능하게 하였다.

4. Argument Resolver

기본적인 애플리케이션 특성상 앤티티를 수정할때에 필연적으로 작성자와 수정자가 같은지를 판별하여야 했다. 이는 쿼리파라미터 혹은 리퀘스트바디로 받은 유저 아이디()와 로그인되어 있는 사용자의 아이디가 같은지를 판별하는 식으로 처리하기로 하였다.

문제해결 1

이를 위해 로그인 사용자의 정보를 전역적으로 읽어올 필요가 있었고 JWT 토큰을 검증하는 과정에서 성공시에 SecurityContextHolder에 사용자 정보를 저장하고 있기 때문에 SecurityContextHolder를 통해 정보를 읽어와 아이디를 비교할 수 있었다. 하지만 매번 이를 위한 똑같은 반복된 코드가 거의 모든 컨트롤러에서 필요하였고 이는 굉장한 비효율이라 생각이 되었다. 그래서 이를 공통기능으로 묶어 처리할 수 있는 방법을 고민하였다.

문제해결 최종

이를 편리하게 모든 곳에서 반복된 코드없이 처리하기위해 나는 HandlerMethodArgumentResolver를 구현하는 ArgumentResolver 만들어 전방위 메소드에서 파라미터로 로그인 유저의 아이디가 사용될 수 있게 하였다.

이를 위해 우선 파라미터에 사용될 어노테이션을 만들어 주었다.

@Target(ElementType.PARAMETER) // 메서드의 파라미터에 적용
@Retention(RetentionPolicy.RUNTIME) // 어노테이션의 수명주기를 런타임 동안
public @interface LoginMemberId {
}

그리고 이 어노테이션이 가지게 될 값을 지정해주는 HandlerMethodArgumentResolver를 구현하는 ArgumentResolver 만들어 해당 어노테이션의 값을 정의해 주었다.

@Component
@RequiredArgsConstructor
public class LoginUserIdArgumentResolver implements HandlerMethodArgumentResolver { // 컨트롤러 메서드의 파라미터 해석하여 값 전달

    private final MemberService service;



    @Override
    public boolean supportsParameter(MethodParameter parameter) {  // 구현한 argument resolver가 특정 파라미터를 지원할지 여부 판단
        parameter.getParameterAnnotations(); //현재 파라미터에 @LoginMemberId 어노테이션이 있는지 검증 있으면 아래의 코드 실행(return true)
        return true;
    }

    @Override // 파라미터를 해석하여 값을 반환하는 역할
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); 		  
        // 사용자 인증 정보
        // 익명이면 0L 리턴(비로그인)
        if(principal.equals("anonymousUser")){
            return 0L;
        }
        Member member = service.findMemberByEmail(principal.toString());
        return member.getId();
    }
}

5. 회고

필수적으로 선행되어야 하는 프로그램의 뼈대를 잡는 작업을 마무리하였다. 이로써 프로그램 전역에서 사용할 공통기능들을 만들 수 있었고 작업을 하면서 더 필요함을 느끼는 기능들이 있다면 공통기능으로 묶어 추가적으로 처리하기로 하였다.

수정자와 작성자를 검증하는 기능을 구현하면서 단지 기능이 구현됨에 만족하지 않고 팀원들이 어떻게 하면 편리하게 중복되는 코드를 피해 효율적으로 사용할 수 있을까 고민하였고 이가 잘 구현된거 같아 뿌듯하였다. 그리고 이런 AOP를 만드는 것으로 전반적인 프로그램의 완성도가 높아지고 훨씬 가독성있는, Layer가 나뉨으로 오류가 생겼을때 모든 해당하는 코드를 수정하는것이 아닌 해당 기능만 수정하면 되는것 처럼 수정하기 또한 용이한 효율적인 프로그램을 만들 수 있음을 다시 느꼈고 앞으로의 작업에서도 끊임없이 고민해야하는 부분임을 느꼈다.

profile
I'm a musician who wants to be a developer.

0개의 댓글