Spring 심화 주차 개인 과제

dereck·2025년 2월 27일
post-thumbnail

들어가기 전에

처음으로 과제를 마무리하는데 시간이 남지 않았던 것 같다. 특히 AOP를 구현하기 위해서 기본 개념을 공부하느라 시간을 좀 많이 잡아먹었고, 계속 조느라 집중을 잘 못한 것 같다.

결국 5단계와 6단계 중 하날 더 신경써서 하는 방법 밖에 없었는데, 테스트 코드가 더 중요하지 않을까 싶어서 테스트 코드에 더 시간을 많이 쏟아서 5단계를 제대로 마무리하지 못한 것 같아 아쉽다.

앞으로 팀 프로젝트를 하면서 다음 자격증 공부를 병행해야 하는데 잘할 수 있을지 모르겠다..

Lv 5 해결 과정

A 도메인의 서비스가 B 도메인의 리포지토리를 의존

  1. [문제 인식 및 정의]
    • A 도메인의 서비스가 B 도메인의 리포지토리를 의존하고 있음
  2. [해결 방안]
    1. [의사결정 과정]
      • 순환 참조 오류를 피할 순 있으나 단일 책임 원칙을 준수하는 것이 둘 중 더 중요하다고 판단.
    2. [해결 과정]
      • 각 서비스를 Write/Read로 나눠서 순환 참조를 피함과 동시에 타 도메인의 서비스를 의존하도록 바꿈
    3. [trade-off]
      • 유저를 찾을 수 없을 때 어떤 유저를 찾을 수 없는지에 대한 자세한 처리가 어려워 진다. (자세한 예외 처리를 할 수 없다)
        • 등록하려고 하는 담당자 유저가 존재하지 않습니다.유저를 찾을 수 없습니다.
  3. [해결 완료]
    1. [회고]
      • 이제 각 도메인의 Repository는 본인의 Service 들과만 의존해서 단일 책임 원칙을 지킬 수 있게 되었다.

회원 탈퇴 기능이 미구현

  1. [문제 인식 및 정의]
    • 회원 탈퇴 기능이 없음
  2. [해결 방안]
    1. [의사결정 과정]
      • 탈퇴 기능이 없어도 기능(서비스를 이용하고, 이용하지 않는 것) 문제는 없으나 앞으로 이용하지 않는 사용자의 경우엔 탈퇴시키는 것이 앞으로의 관리 측면에서 더 나을 것 같다고 판단
    2. [해결 과정]
      • UserController와 UserService에 deleteUser() 추가
  3. [해결 완료]
    1. [회고]
      • 회원 탈퇴가 생기면서 탈퇴를 원하는 사용자의 경우 탈퇴를 함으로써 (현재는 soft-delete가 적용되어 있지 않기 때문에) 탈퇴 이후 해당 사용자의 정보를 관리할 필요가 없어졌다.
    2. [전후 데이터 비교]

Timestamped의 각 필드가 nullable 함.

  1. [문제 인식 및 정의]
    • Timestamped의 각 필드가 nullable 함.
  2. [해결 방안]
    1. [의사결정 과정]
    • 어차피 JPA가 자동으로 해당 값을 채워주는데 굳이 nullable 하게 둘 필요가 없다고 판단함.
    • 또한 null 허용은 보수적으로 적용하는 것이 맞다고 생각함.
    1. [해결 과정]
      • nullable = false를 추가
  3. [해결 완료]
    1. [회고]
      • 이전보다 null 값에 대해 안전해졌다.
      • 테스트 코드 상에서의 불편함이 생길 수도 있을 것 같다.
    2. [전후 데이터 비교]

예외 발생 시 메시지의 언어가 통일되지 않음

  1. [문제 인식 및 정의]
    • 예외 발생 시 메시지의 언어가 통일되지 않음
  2. [해결 방안]
    1. [의사결정 과정]
      • 한국어로 통일하고, 이후 필요한 순간에 메시지 국제화를 통해 여러 언어로 사용할 수 있도록 만드는 것이 나을 것 같다고 판단함.
    2. [해결 과정]
      • 영어로 적힌 모든 에러 메시지를 최대한 같은 의미의 한국어로 치환.
  3. [해결 완료]
    1. [회고]
      • 이후 메시지 국제화 적용이 필요할 것 같음
      • 문자열로 관리하는 것이 아닌 Enum으로 관리하는 것이 더 편할 것 같고, 테스트에서도 유용하게 사용될 것 같음

일정 수정과 삭제가 미구현

  1. [문제 인식 및 정의]
    • 일정 수정과 삭제가 구현되어 있지 않음
  2. [해결 방안]
    1. [의사결정 과정]
      • 일정은 언제든지 바뀔 수 있고, 사라질 수도 있기 때문에 수정과 삭제 기능이 구현되어 있어야 한다고 생각함.
    2. [해결 과정]
      • 일정 수정과 삭제 구현
  3. [해결 완료]
    1. [회고]
      • 일정 수정과 삭제가 생겨서 더 이상 오타, 완료, 불발된 일정을 수정하거나 삭제할 수 있게 되면서 깔끔하게 관리할 수 있게 되었다.
      • 우선 순위에 따라 할 일을 조회하고, 할 일을 마치면 완료 상태로 바꾸는 등의 추가 기능도 구현할 수 있을 것 같다.
    2. [전후 데이터 비교]

예외 발생 시 메시지가 어울리지 않음

  1. [문제 인식 및 정의]
    • lv 3-2의 3번 케이스 해결 시 todo.getUser() 의 값이 null 일 경우 발생하는 예외 발생 시 메시지가 어울리지 않음.
  2. [해결 방안]
    1. [의사결정 과정]
      • 해당 할 일을 만든 유저가 없을 경우 발생하는 것이기 때문에 "담당자를 등록하려고 하는 유저와 일정을 만든 유저가 유효하지 않습니다.”보단 “일정을 만든 유저를 찾을 수 없습니다.” 등의 메시지가 더 어울리는 것 같다고 생각
    2. [해결 과정]
      • 메시지 수정
  3. [해결 완료]
    1. [회고]
      • 상황에 더 알맞는 예외 메시지를 보낼 수 있게 되었다.
    2. [전후 데이터 비교]

입력 값 검증

  1. [문제 인식 및 정의]
    • 회원가입 시 비밀번호 조건이 없고, @Email 어노테이션의 경우 너무 허술함 (@ 만 있으면 통과)
  2. [해결 방안]
    1. [의사결정 과정]
      • 회원가입의 경우엔 비밀번호 변경과 마찬가지로 조건을 명시해줘야 한다고 생각함.
      • @Email 어노테이션의 경우 단순히 @ 만 적으면 조건을 만족하기 때문에 더욱 정교한 이메일 형식 적용을 위해 @Pattern을 사용해서 커스텀으로 만들어 줘야 겠다고 생각함.
    2. [해결 과정]
      • 비밀번호 변경과 동일한 패턴 적용
      • 이메일의 경우
        • 로컬 파트는 영문 대소문자, 숫자, 하이픈, 언더스코어 중 하나 이상을 포함해야 하고, 온점이나 기타 특수문자는 허용하지 않음
        • 도메인 부분은 영문 대소문자, 숫자, 온점 및 하이픈을 사용할 수 있음.
        • 마지막 온점 뒤에 2자 이상 6자 이하의 영문만 허용
  3. [해결 완료]
  4. [회고]
    • 더 정확한 수준의 이메일 검증이 가능하게 되었다.
  5. [전후 데이터 비교]

Controller의 Path 구조 변경

  1. [문제 인식 및 정의]
    • CommentController 등의 requestUri가 계층적인 구조를 띄고 있음. 이런 경우라면 TodoController 쪽에 같이 위치하는 것이 나을 것 같음.
  2. [해결 방안]
    1. [의사결정 과정]
      • 하지만 단일 책임 원칙을 기준으로 바라본다면 이는 CommentController에 위치하는 것이 맞다고 생각함.
      • Manager 도메인도 같은 맥락으로 수정함
    2. [해결 과정]
      • RequestMapping으로 "/comments" 를 공통으로 받게 다시 설계
        • 생성: /comments (RequestDto에 todoId를 추가로 받도록 함)
        • 조회: /comments?todoId={todoId} (RequestParam으로 받도록 함)
        • 수정: /comments/{commentId}
        • 삭제: /comments/{commentId}
  3. [해결 완료]
    1. [회고]
    • 일정에 병합되어야 할 것 같던 path가 댓글 중심으로 바뀌면서 명확해진 것 같다. (다른 도메인도 마찬가지)

PersistanceConfig 클래스의 존재 의의

  1. [문제 인식 및 정의]
    • PersistanceConfig가 단순히 @EnableJpaAuditing을 설정하기 위해서 존재함
  2. [해결 방안]
    1. [의사결정 과정]
      • 실행 클래스에 적용해도 되는 것을 굳이 클래스를 더 만들어서 관리할 필요가 없다고 판단함
    2. [해결 과정]
      • 실행 클래스에 해당 어노테이션을 적용하고 PersistanceConfig 클래스 제거
  3. [해결 완료]
    1. [회고]
      • 의미 없는 클래스를 하나 줄일 수 있게 되었다.
      • (추가) 테스트 코드 작성 시 실행 클래스에 @EnableJpaAuditing을 붙이게 되면 추가적인 작업이 필요하다는 것을 알게 되었다.
    2. [전후 데이터 비교]

입력 값 예외 처리 시 불필요한 내용까지 응답

  1. [문제 인식 및 정의]
    • GlobalExceptionHandler에서 @Valid를 잡을 때의 메시지에 필요없는 내용까지 출력되고 있었음
  2. [해결 방안]
    1. [의사결정 과정]
      • 응답을 받을 때 필요한 내용만 받을 수 있도록 조치가 필요하다고 판단함.
    2. [해결 과정]
      • getBindingResult().getFieldError().getDefaultMessage() 를 통해 기본 메시지 또는 명시적으로 입력한 메시지를 출력할 수 있도록 변경함
  3. [해결 완료]
    1. [회고]
      • 응답 메시지에 불필요한 내용을 제거해서 서버의 비용을 줄이고, 더 명확한 예외 메시지를 보낼 수 있게 되었다.
    2. [전후 데이터 비교]

config 폴더 정리

  1. [문제 인식 및 정의]
    • config 폴더에 설정이 아닌 다른 종류의 클래스도 존재함
  2. [해결 방안]
    1. [의사결정 과정]
      • 각 클래스의 종류나 역할에 따라 리팩토링이 필요하다고 판단함
    2. [해결 과정]
      • 클래스가 사용되는 곳이나 역할에 따라 리팩토링을 진행함
  3. [해결 완료]
    1. [회고]
      • config엔 설정 관련 클래스만 위치하도록 해서 필요한 클래스를 더 빠르게 찾을 수 있게 된 것 같다. (가시성의 증가)
    2. [전후 데이터 비교]

트러블 슈팅

요청 본문 로깅 처리 하기

AOP를 사용해서 Lv4 과제를 해결하는 도중 요청 본문에 대한 로그를 찍어야 했었다. 처음엔 필터를 따로 만들어서 순서를 올리면 될까 싶었지만 해결되지 않았었다.

결국 현재 있는 필터에서 가장 빨리 요청을 받는 JwtFilter에 request를 재사용하기 위해 ContentCachingRequestWrapper로 감싸준 뒤 보내줬다.

"재사용하기 위해"라고 말한 이유는 요청 본문을 getInputStream()은 일회용이기 때문에 요청 로그를 찍기 위해선 말 그대로 재사용을 해야 하기 때문이다.

이후 LogTraceAspect 클래스에서 현재 요청 정보를 스레드 로컬 방식으로 저장하기 위해 아래와 같이 적용해줬다. 또한 execution을 사용한 포인트컷보다 커스텀 어노테이션을 만들어서 적용하는 편이 더 간편할 것 같아서 어노테이션이 있는 곳에 어드바이스를 적용하도록 했다.

AOP를 적용한 전체 코드는 다음과 같다.

@Slf4j
@Aspect
@Component
public class LogTraceAspect {

    @Pointcut("@annotation(org.example.expert.domain.common.annotation.LogTrace)")
    public void loggerPointcut() {
    }

    @Around("loggerPointcut()")
    public Object doLogTrace(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request;

        String traceId = UUID.randomUUID().toString().substring(0, 8);
        String requestBody = getRequestBody(cachingRequest);
        if (!StringUtils.hasText(requestBody)) {
            requestBody = "empty";
        }
        String method = cachingRequest.getMethod();
        LocalDateTime requestTime = LocalDateTime.now();

        // setAttribute()로 저장한 값을 읽어옴
        Long userId = (Long) cachingRequest.getAttribute("userId");

        log.info(toRequestLog(traceId, method, userId, cachingRequest.getRequestURL(), requestTime, requestBody));

        Object result = proceedingJoinPoint.proceed();  // proxy 객체가 target 객체의 메서드를 호출하고 나온 result
        if (!StringUtils.hasText((CharSequence) result)) {
            result = "empty";
        }

        log.info(toResponseLog(traceId, method, cachingRequest.getRequestURL(), userId, requestTime, result));
        return result;
    }

    // 요청 본문 읽어오기
    private String getRequestBody(ContentCachingRequestWrapper cachingRequest) {
        String requestBody = "";
        try {
            requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());
        } catch (UnsupportedEncodingException uee) {
            throw new InvalidRequestException("Logging 과정에서 에러가 발생했습니다.");
        }
        return requestBody;
    }

    public String toRequestLog(
        String traceId,
        String method,
        Long userId,
        StringBuffer requestUrl,
        LocalDateTime requestTime,
        String requestBody
    ) {
        return String.format(
            "%n========== HTTP REQUEST LOG ==========%n" +
                "Trace ID        : %s%n" +
                "HTTP Method     : %s%n" +
                "Request URI     : %s%n" +
                "Request User ID : %s%n" +
                "Request Time    : %s%n" +
                "Request Body    : %s%n" +
                "======================================",
            traceId, method, requestUrl, userId, requestTime, requestBody
        );
    }

    public String toResponseLog(
        String traceId,
        String method,
        StringBuffer requestUrl,
        Long userId,
        LocalDateTime requestTime,
        Object result
    ) {
        return String.format(
            "%n========== HTTP RESPONSE LOG ==========%n" +
                "Trace ID        : %s%n" +
                "HTTP Method     : %s%n" +
                "Request URI     : %s%n" +
                "Request User ID : %s%n" +
                "Request Time    : %s%n" +
                "Response Body   : %s%n" +
                "======================================",
            traceId, method, requestUrl, userId, requestTime, result
        );
    }
}

요청부터 응답까지 걸린 시간도 넣고 싶었지만 그건 필터에 들어왔을 때부터 나가기 직전까지를 구하는 것이 아니면 의미가 없다고 생각해서 제외하게 되었다.

0개의 댓글