Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

이지은님의 블로그

250227 - Java Spring 일정 과제 진행과 해설: 리팩토링과 테스트코드, 환경변수 설정, N+1 문제 본문

TIL

250227 - Java Spring 일정 과제 진행과 해설: 리팩토링과 테스트코드, 환경변수 설정, N+1 문제

queenriwon3 2025. 2. 27. 09:45

▷ 오늘 배운 것

이번에는 주어진 과제를 어떻게 해결했는지를 설명하는 TIL을 써보고자 한다.

 

 

<<목차>>

1. Lv .1 코드 개선

    1) early return

    2) 불필요한 if-else 피하기

    3) Validation 사용으로 유효성 검사

2. Lv.2 N+1 문제 풀기

3. Lv.3 테스트 코드 연습

    1) passwordEncoder의 테스트

    2-1) todo_id 값이 없을 때, 예외를 처리하는 테스트

    2-2) 예외를 처리하는 테스트 코드

    2-3) 기존 로직을 수정했을 때 테스트 실패

4. 환경변수를 사용하여 secret-key 관리

5. Lv.4 interceptor를 이용한 API 로깅

6. Lv.5 내가 정의한 문제와 해결과정

    1) 리팩토링

    1-1) CommentService, ManagerService에서 타 Repository로의 의존성 줄이기 + 사용성이 높은 메서드 분리

    1-2) 디렉토리 분리

    2) 기능추가

    2-1) Validation 예외 처리 추가

    2-2) 인가기능을 사용한 각 기능 추가

    3) 수정 후 발견한 테스트코드 에러

7. Lv.6 테스트 커버리지

 

 

 


1. Lv 1. 코드 개선

1) early return

빨리 리턴할 있게 위치를 다음과 같이 수정한다.

 

@Transactional
public SignupResponse signup(SignupRequest signupRequest) {

    if (userRepository.existsByEmail(signupRequest.getEmail())) {
        throw new InvalidRequestException("이미 존재하는 이메일입니다.");
    }

    String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

    ...
}

이렇게 하면 존재하는 이메일이 입력될 경우에 따른 경우를 빨리 반환할 수 있고, 불필요하게 encode()메서드를 실행할 일이 없다.

 

 

2) 불필요한 if-else 피하기

코드의 depth 늘리게 되면 가독성이 떨어진다는 단점이 있다.

특히 if문이나 for문의 남용은 depth를 키우는 요인이 되기도 한다. 코드는 자기 자신만 확인하는 것이 아닌, 협업을 하면서 팀원들도 함께보는 것이므로 가독성을 늘릴 이유가 있다.

 

여기서 문제가 되는 부분은 if절로 빠지는 부분이 전부 throw 문으로 early return 가능하다는 것이다. 따라서 다음과 같이 코드를 수정해줄 있다.

WeatherDto[] weatherArray = responseEntity.getBody();
if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
    throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
}
if (weatherArray == null || weatherArray.length == 0) {
    throw new ServerException("날씨 데이터가 없습니다.");
}

 

 

3) Validation 사용으로 유효성 검사

Spring 클라이언트한테서 받은 DTO 대해 미리 유효성 검사를 있도록 한다. 그러기 위해서는 유효성검사를 해줄 의존성을 부여한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

@PutMapping("/users")
public void changePassword(
        @Auth AuthUser authUser,
        @Valid @RequestBody UserChangePasswordRequest userChangePasswordRequest) {
    userService.changePassword(authUser.getId(), userChangePasswordRequest);
}

클라이언트에게 dto 받는 부분에 @Valid 검사를 있도록 해주고, 기준을 UserChangePasswordRequest에서 해주면 된다.

 

@NotBlank
@Size(min = 8, message = "새 비밀번호는 8자 이상이어야 합니다.")
@Pattern(regexp = ".*\\d.*", message = "새 비밀번호는 숫자를 포함해야 합니다.")
@Pattern(regexp = ".*[A-Z].*", message = "새 비밀번호는 대문자를 포함해야 합니다.")
private String newPassword;

 

 

2. N+1 문제 풀기

todos를 가져올때 1번, 그리고 각 todos의 user 정보를 가져올 때 N번 쿼리를 N+1개를 가져오는 문제를 해결해보도록 하겠다.

N+1 문제를 해결하는 방법은 5가지가 있다. 나중에 til로도 작성하겠지만, 과제 제시내용에 따라 Entity Graphs를 사용하여 문제를 풀어보자.

 

Entity Graph 사용하면 특정 쿼리에 대한 엔티티의 로딩 전략을 세밀하게 제어할 있다.

@EntityGraph(attributePaths = {"user"})
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC")
Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);

@EntityGraph(attributePaths = {"user”})를 통해 todo를 조회할 때 user에 관한 정보도 함께 조회할 수 있도록 한다. (실제 엔티티 호출됨)

 

N+1 문제는 단순히 "하나의 쿼리가 더 실행된다"는 문제가 아니라, 시스템의 확장성과 성능에 영향을 미칠 수 있는 문제이므로, 다건의 정보를 조회할 때 신경써서 코드를 작성하도록 하자.

 

 

 

3. 테스트 코드 연습

1) passwordEncoder 테스트

passwordEncoder의 메서드를 테스트해보도록 하자.

 

테스트해 passwordEncoder.matches 다음과 같은 형태를 띄고 있다.

매개변수를 확인하면 rawPassword, encodedPassword 순으로 코드가 작성되어야 하는데 순서가 뒤바뀌었기 때문에 test 결과가 실패가 된다는 것을 있다.

 

boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);

 

메서드에서 선언한 매개변수에 맞게 값을 입력해주면 테스트결과가 정상적으로 나온다는 것을 확인 할 수 있었다.

 

 

2-1) todo_id 값이 없을 , 예외를 처리하는 테스트

 

  테스트는 아래 코드에 대한 예외를 제대로 다루고 있는지 확인할 있다. 따라서, 예외코드가 실행된 메서드를 살펴보면 어떤 문제인지 확실히 있다.

테스트 실행결과를 보아도 어떤 점이 문제였는지 확실하게 알 수 있다.

 

해당 메서드에서는 “Todo not found”라는 메세지를 던지는 예외를 던지고 있다. 이를 확실하게 테스트하기 위해서 테스트 코드와 테스트 코드의 메서드명을 수정해보자.

 

@Test
public void manager_목록_조회_시_Todo가_없다면_InvalidRequestException_에러를_던진다() {
    // given
    long todoId = 1L;
    given(todoRepository.findById(todoId)).willReturn(Optional.empty());

    // when & then
    InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> managerService.getManagers(todoId));
    assertEquals("Todo not found", exception.getMessage());
}

assertEquals 메세지 명을 수정하면 다음과 같이 테스트가 완료되는 것을 확인 있다.

 

 

 

 

2-2) 예외를 처리하는 테스트 코드

다음 테스트 코드에서는 테스트를 하는 예외가 다르다는 것을 확인 할 수 있다. 따라서 확인하는 테스트 예외에 맞게 테스트 코드를 다음과 같이 수정한다.

saveComment()에서는 InvalidRequestException 던지고 있으므로, InvalidRequestException 대한 예외를 확인하도록 수정한다.

 

InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> {
    commentService.saveComment(authUser, todoId, request);
});

 

 

 

2-3) 기존 로직을 수정했을 테스트 실패

테스트 코드를 실행했을때 기대하는 예외와 출력되는 예외가 다르다는 것을 있다.

 

힌트는 assertThrows에서 뜨는 노란줄에서 찾아볼 수 있다. Todo.getUser()가 null값을 가리키고 있다는 뜻이다. 따라서 NPE가 생길 수 밖에 없다.

 

그리고 테스트의 의도를 살펴보면 todo user null 경우에 출력하는 예외에 대해 다루고 있으므로 해당 상황을 만들어주고 그에 대한 테스트를 작성해주어야한다. 따라서 managerService.saveManager()메서드에 해당 예외를 처리하는 로직을 작성해주면 된다.

 

if (todo.getUser() == null ||
        !ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
    throw new InvalidRequestException("담당자를 등록하려고 하는 유저가 일정을 만든 유저가 유효하지 않습니다.");
}

 

 

 

 

4. 환경변수를 사용하여 secret-key 관리

참고한 블로그

https://velog.io/@gusrud13579/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-%EC%84%A4%EC%A0%95

 

IntelliJ 환경변수 설정

IntelliJ 환경변수 설정

velog.io

 

 

jwt의 비밀키는 보안상 엄중히 다루어야하므로 공개적인 곳(github 등)에 올리기에는 적합하지 않다. 따라서 환경변수 값은 .gitignore를 통해서 숨겨야한다.

 

이번에는 .yml 파일형식에 익숙해지고, 환경변수를 다루기 위해 사용하는 .env파일을 사용해 보겠다.

 

다음과 같이 경로 기준으로 값을 작성한다.

이후 .env파일을 등록해줄 것이다.

 

점 3개 옵션 > Edit… > Modify Options > Environment variables > .env 파일 설정

 

이렇게 하면 각 변수명이 바인딩되는 환경변수를 설정할 수 있다.

파일을 만들지 않고 각 별개로 key-value형태로 환경변수를 입력할 수도 있다.

 

 

 

5. lv.4 interceptor 이용한 API 로깅

@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        ...

        // 로그인 검증
        String bearerJwt = httpRequest.getHeader("Authorization");

        if (bearerJwt == null) {
            // 토큰이 없는 경우 400을 반환합니다.
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt); //베어러 제거

        try {
            // JWT 유효성 검사와 claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);
            if (claims == null) {
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
                return;
            }

            // 검증만하고 jwt에 담아줌
            httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
            httpRequest.setAttribute("email", claims.get("email"));
            httpRequest.setAttribute("userRole", claims.get("userRole"));

            chain.doFilter(request, response);
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            ...
        }
    }
}

먼저 필터는 로그인 사용자를 구분하는 필터로 사용된다.

회원가입, 로그인 url의 경우 바로 url리턴하고, 그외에는 로그인을 했는지(사용자가 토큰을 가지고 있는지 없는지)에 따라 필터링을 하고 예외를 던진다.

 

인터셉터에서는 admin사용자가 접근할 있는 url admin사용자만 접근했는지 확인하고 이를 로깅해주는 로직이 필요하다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    // Interceptor 등록
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AdminAuthInterceptor())
                .order(1)
                .addPathPatterns("/admin/**");
        registry.addInterceptor(new LoggingInterceptor())
                .order(2)
                .addPathPatterns("/admin/**");
    }
}

 

인터셉터는 다음과 같이 WebMvcConfigurer를 implements한 WebConfig에서 설정할 수 있다.

인터셉터를 2 만들어, Admin 구분하는 인터셉터, Loggin 해주는 인터셉터를 책임분리 시켜놓았다.

 

// AdminAuthInterceptor.class
@Slf4j
public class AdminAuthInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));

        log.info(String.valueOf(userRole));

        if(UserRole.ADMIN != userRole) {
            log.warn("관리자가 아닌 사용자 접근");
            throw new AuthException("관리자가 아닌 사용자 접근");
        }
        return true;
    }
}

인터셉터는 관리자 인지 아닌지만 확인해서 예외를 던진다.

 

@Slf4j
public class LoggingInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        Long userId = (Long) request.getAttribute("userId");

        LocalDateTime now = LocalDateTime.now();
        String formatDatetime = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

        log.info("[관리자 접근] id = {}, 요청시각 = {}, URI = {}", userId, formatDatetime, request.getRequestURI());
        return true;
    }
}

 

이 인터셉터는 관리자 접근의 로그를 작성해주기 위해 사용된다. id와 요청시각, URI를 설정해줄 수 있다.

 

AOP를 사용하여 요청본문 및 응답본문까지 출력할 수 있으나, AOP는 아직 어려운 개념이 많고, 프록시에 대한 개념도 알고 있어야하므로 다음기회로 미루도록 하겠다.

 

 

 

 

 

6. 내가 정의한 문제와 해결과정

내가 찾은 과제 코드의 개선점은 다음과 같다.

  • 리플레시 토큰 사용
  • CommentService, ManagerService에서 타 Repository로의 의존성 줄이기
  • config디렉토리 분리
  • 회원탈퇴 및 로그아웃 추가
  • 일정 수정 및 삭제 추가
  • 댓글 수정 및 삭제 추가
  • 사용성이 높은 메서드 분리
  • 일정 작성, 댓글 작성시 토큰에서의 사용자 정보를 사용(사용자를 DB에서 조회하는 것을 생략)
  • 커스텀 예외처리 개선
  • 관리자의 일정 삭제 추가
  • Validation 예외 처리 추가

 

여기서 리플레시 토큰의 사용은 추가 공부가 필요할 것 같아 다음으로 미루고 따로 리플레시 토큰의 TIL을 작성하면서 추가 공부를 하는게 좋을 것 같다.

그리고 커스텀 예외처리에 대해서도 시간이 걸린다고 판단, 그 외 다른 개선 점에 대해 고민해 보도록 하겠다.

 

크게 리팩토링, 기능 추가 두가지로 나누어보고자 한다.

 

 

1) 리팩토링

  • CommentService, ManagerService에서 타 Repository로의 의존성 줄이기
  • 사용성이 높은 메서드 분리
  • config디렉토리 분리

 

1-1) CommentService, ManagerService에서 타 Repository로의 의존성 줄이기 + 사용성이 높은 메서드 분리

  1. 문제 인식 정의

 

CommentService ManagerService 다른 도메인의 Repository까지 의존받고 있는 상태이다. 보통은 순환참조를 피하기 위해, 아니면 다른 개발자의 의도가 숨어있을 있지만, 결합도를 줄이고 유지보수에 용이하게 하기 위해 사용성이 높은 메서드들을 도메인에서 메서드로 정의, 이를 사용하는 방법으로 Repository 아닌 Service 의존받으려고 한다.

 

다음과 같은 다른 Service에서도 사용성이 높을 수 있는 것을 따로 메서드로 분리하여 유지보수성을 높일 수 있도록 수정해 보도록하겠다.

 

 

    2. 해결방안

2-1. 의사 결정 과정

findByIdorElseThrow()메서드는 재사용성이 매우 높다. 따라서 이를 service레이어에서 private 메서드로 정의해주어야 하고, 만약 다른 도메인에서까지 많이 사용된다고 한다면, public 메서드로 정의해주어야한다.

그러면서, 각 도메인은 다른 도메인의 repository를 의존해야하는 것이 아니라, 재사용성이 높은 메서드가 있는 service에 의존을 하는 것이 단일 책임 원칙을 지키는 것에도 좋을 것 같다는 생각을 했다.

 

2-2. 해결과정

다음과 같이 재사용성이 높은 메서드를 분리하여 작성하여 재사용할 있도록 Service단에서 만들었으며,

 

이를 다른 Service도 사용할 수 있도록 서비스끼리 의존성을 주입할 수 있도록 했다.

 

 

    3. 해결완료

3-1. 회고

이를 통해 단일 책임 원칙을 지킬 수 있는 구조를 만들 수 있었다. 이를 너무 철저히 지키려고 할때 순환참조문제가 발생할 수 있는데, 이는 서비스를 아예 분리하는 방법으로 순환 참조를 피할 수 있다.

다음 이미지는 순환참조가 발생하지 않고 실행되는 이미지이다.

리팩토링을 한 것이므로 전후데이터는 똑같다.

 

 

 

1-2) 디렉토리 분리

  1. 문제 인식 정의

제공된 과제는 모든 config 파일들이 config 파일안에 작성되어 있다. 따라서 이를 기능에 맞게 분리하여 패키지 구조를 잘 알 수 있도록 정리해보도록 하겠다.

 

 

    2. 해결방안

2-1. 의사 결정 과정

우선 interceptor는 interceptor끼리 argementResolver는 argementResolver끼리 모으고, 도메인에 흩어져 있는 exception을 한 곳에 모아 보고 어짜피 GlobalExceptionHandler가 모든 것을 처리하기 때문에 이를 한 패키지 안으로 모아보고자 한다.

 

2-2. 해결 과정

 

다음과 같이 각 특징에 따라 패키지를 분리했다.

 

 

    3. 해결 완료

3-1. 회고

여기서 한가지 커스텀한 에러를 세부적으로 여러개 나눌 수도 있다. 그럴경우도 한번에 모아볼 수 있도록 만드는 게 좋을 것 같다고 생각했다. InvaildRequestException과 ServerException이 common 패키지에 있었다. 그러나 모든 에러를 globalExceptionHandler에서 다루기 때문에 각 도메인에 exception을 넣는 것보다는 config에 핸들러와 함께 있는것이 확인하기 편하다는 생각을 했다.

리팩토링을 한 것이므로 전후데이터는 똑같다.

 

 

2) 기능추가

  • 회원탈퇴 및 로그아웃 추가
  • 일정 수정 및 삭제 추가
  • 댓글 수정 및 삭제 추가
  • 관리자의 일정 삭제 추가
  • Validation 예외 처리 추가

 

2-1) Validation 예외 처리 추가

  1. 문제 인식 정의

각 requestDto에 validation을 설정해줬음에도 불구하고 해당 예외를 처리하는 handler가 없어 400에러가 발생한다. 각 requestDto에 유효성 메세지를 출력하고 이를 확인할 수 있도록 예외처리가 필요할 것 같다.

 

 

    2. 해결방안

2-1. 의사결정 과정

각 요청으로 받아오는 값은 여러개이기 때문에 여러개의 유효성 예외사항이 생길 수 있다. 따라서 여러개의 오류를 확인하기 위해 메세지를 List의 형태로 받아서 응답으로 출력하도록 해야겠다고 생각했다.

그리고 공통으로 예외를 출력하는 메서드가 구현되어 있었는데,

public ResponseEntity<Map<String, Object>> getErrorResponse(HttpStatus status, String message) {
    Map<String, Object> errorResponse = new HashMap<>();
    errorResponse.put("status", status.name());
    errorResponse.put("code", status.value());
    errorResponse.put("message", message);

    return new ResponseEntity<>(errorResponse, status);
}

반환 값이 Map<String, Object>으로 되어있는 것을 보아 String값이 아닌 list로도 확장이 가능하도록 힌트가 주어진 것 같았다. 이를 이용하여 여러개의 에러사항을 출력해보도록 하겠다.

 

2-2. 해결과정

public <T> ResponseEntity<Map<String, Object>> getErrorResponse(HttpStatus status, T message) {
    Map<String, Object> errorResponse = new HashMap<>();
    errorResponse.put("status", status.name());
    errorResponse.put("code", status.value());
    errorResponse.put("message", message);

    return new ResponseEntity<>(errorResponse, status);
}

위의 코드를 다음과 같이 바꾸어서, message 타입을 확장시켜 받을 있도록 한다. 이러면 message값으로 String 유효성 검사로부터 받아올 에러메세지 값도 함께 받아올 있다.

 

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationFailed(MethodArgumentNotValidException exception) {
    List<String> validFailedList = exception.getBindingResult().getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .toList();

    return getErrorResponse(HttpStatus.BAD_REQUEST, validFailedList);
}

그리고 메세지 값은 다음과 같이 가져온다.

 

각 requestdto의 필드에 유효성 기준을 지키지 못했을때 출력하는 메세지를 작성한다.

 

 

    3. 해결 완료

3-1. 회고

성공에 대한 메세지는 출력해주지 않아도 되는 반면, 에러메세지는 가능한 자세하고 구체적으로 출력해야한다. 따라서 validation이 설정되어 있다면, 당연히 그에 대한 에러메세지를 출력해야한다.

다른 예외상황도 구체적으로 작성해주는 것이 좋다. enum으로 예외를 관리해주거나, 상세한 메세지를 던져주는 것이 제일 권장된다. 시간이 있었다면, 각 예외에 대해 구체적으로 응답을 작성했을 것이지만, 시간상 어렵기때문에 유효성검사까지만 예외처리를 하기로 했다.

 

3-2. 전후 데이터 비교

문제 해결

 

문제 해결 - 여러 유효성 검사 메세지를 출력할 있다.

 

 

2-2) 인가기능을 사용한 각 기능 추가

  • 회원탈퇴 및 로그아웃 추가
  • 일정 수정 및 삭제 추가
  • 댓글 수정 및 삭제 추가
  • 관리자의 일정 삭제 추가

 

  1. 문제 인식 및 정의

과제로 주어진 코드에는 사용자 당 권한이 주어질 수 있도록 하고 있다. 그리고 회원탈퇴 및 로그아웃이 없고, 일정 수정 및 삭제도 없으며, 댓글 수정 및 삭제가 없다. 그리고 관리자의 댓글삭제는 있지만 일정 삭제가 없다. 그러므로 각 인증인가의 특징을 살려서 CRUD를 구현할 필요성을 느꼈다. 

 

    2, 해결 방안

2-1. 의사결정 과정

회원탈퇴, 일정 수정 및 삭제, 댓글 수정 및 삭제는 사용자의 토큰으로 사용자의 정보에 따라 인가를 판단한 후 해당 기능을 수행할 수 있다.

그리고 관리자의 일정 삭제의 경우 url과 interceptor를 사용해서 관리자만 일정을 삭제할 수 있도록 한다.

 

2-2. 해결 과정

// 회원탈퇴
public void deleteUser(
        Long userId,
        UserDeleteRequest userDeleteRequest) {
    User user = findUserByIdOrElseThrow(userId);
    
    if (!passwordEncoder.matches(userDeleteRequest.getPassword(), user.getPassword())) {
        throw new InvalidRequestException("잘못된 비밀번호입니다.");
    }
    userRepository.delete(user);
}

 

비밀번호를 입력받아 해당 유저의 비밀번호와 비교한뒤 유저를 삭제시킨다.(hard delete)

 

// 일정수정
@Transactional
public TodoResponse updateTodo(AuthUser authUser, Long todoId, TodoRequest todoRequest) {
    Todo todo = findTodoByIdOrElseThrow(todoId);

    if (!ObjectUtils.nullSafeEquals(todo.getUser().getId(), authUser.getId())) {
        throw new InvalidRequestException("일정 작성자가 아닙니다.");
    }

    String todoTitle = todoRequest.getTitle() == null ? todo.getTitle() : todoRequest.getTitle();
    String todoContents = todoRequest.getContents() == null ? todo.getContents() : todoRequest.getContents();

    todo.update(todoTitle, todoContents);

    return new TodoResponse(
            todo.getId(),
            todo.getTitle(),
            todo.getContents(),
            todo.getWeather(),
            new UserResponse(authUser.getId(), authUser.getEmail()),
            todo.getCreatedAt(),
            todo.getModifiedAt()
    );
}

// 일정삭제
@Transactional
public void deleteTodo(Long userId, Long todoId) {
    Todo todo = findTodoByIdOrElseThrow(todoId);

    if (!ObjectUtils.nullSafeEquals(todo.getUser().getId(), userId)) {
        throw new InvalidRequestException("일정 작성자가 아닙니다.");
    }
    todoRepository.delete(todo);
}

 

회원정보가 일치하는지 확인하고 해당 일정을 수정 삭제 시킨다.

 

// 댓글수정
@Transactional
public CommentResponse updateComment(AuthUser authUser, Long commentId, CommentRequest commentRequest) {
    Comment comment = findCommentByIdOrElseThrow(commentId);

    if (!ObjectUtils.nullSafeEquals(comment.getUser().getId(), authUser.getId())) {
        throw new InvalidRequestException("댓글 작성자가 아닙니다.");
    }
    comment.update(commentRequest.getContents());

    return new CommentResponse(
            comment.getId(),
            comment.getContents(),
            new UserResponse(authUser.getId(), authUser.getEmail())
    );
}

// 댓글삭제
@Transactional
public void deleteComment(Long userId, Long commentId) {
    Comment comment = findCommentByIdOrElseThrow(commentId);

    if (!ObjectUtils.nullSafeEquals(comment.getUser().getId(), userId)) {
        throw new InvalidRequestException("댓글 작성자가 아닙니다.");
    }
    commentRepository.delete(comment);
}

회원정보가 일치하는지 확인하고 해당 댓글을 수정 삭제 시킨다.

 

// 관리자의 일정 삭제
@DeleteMapping("/admin/todos/{todoId}")
public void deleteTodo(@PathVariable long todoId) {
    todoAdminService.deleteTodo(todoId);
}

 

관리자가 권한이 없다면 interceptor에서 일정삭제 접근을 차단한다.

 

    3. 해결완료

3-1. 회고

Jwt를 이용한 로그아웃은 보통 리플래시 토큰을 만료시키므로써 구현할 수 있으므로, 다음으로 미루었다. 다른 도메인의 CRUD를 구현함으로써 사용자 토큰을 이용한 CRUD를 연습할 수 있었다.

 

3-2. 전후 데이터 비교(결과)

회원 탈퇴
일정 수정
일정 삭제

 

댓글 수정

 

댓글 삭제

 

관리자의 일정삭제

 

 

3) 수정 후 발견한 테스트코드 에러

  1. 문제 인식 정의

위에서 리팩토링을 진행했더니 다음과 같이 성공했던 테스트코드가 다시 실패한 상황이 발생했다. 내용은 대략 주입받은 Service레이어가 null이라고이에 대한 것을 정의해주어야한다고 한다.

이 이미지는 테스트코드를 실행했을때 나타나는 사항이다.

 

    2, 해결방안

2-1. 의사결정 과정

Service레이어가 null이라면 이에 대한 것으로 수정을 해주면 된다고 생각했다.

 

2-2. 해결과정

그래서 다음과 같이 테스트코드를 수정했다.

@Test
public void comment_등록_중_할일을_찾지_못해_에러가_발생한다() {
    // given
    long todoId = 1;
    CommentSaveRequest request = new CommentSaveRequest("contents");
    AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);

    given(todoService.findTodoByIdOrElseThrow(anyLong())).willThrow(new InvalidRequestException("Todo not found"));

    // when
    InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> {
        commentService.saveComment(authUser, todoId, request);
    });

    // then
    assertEquals("Todo not found", exception.getMessage());
}


@Test
public void comment를_정상적으로_등록한다() {
    // given
    long todoId = 1;
    CommentSaveRequest request = new CommentSaveRequest("contents");
    AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);
    User user = User.fromAuthUser(authUser);
    Todo todo = new Todo("title", "title", "contents", user);
    Comment comment = new Comment(request.getContents(), user, todo);

//        given(todoRepository.findById(anyLong())).willReturn(Optional.of(todo));
    given(todoService.findTodoByIdOrElseThrow(anyLong())).willReturn(todo);
    given(commentRepository.save(any())).willReturn(comment);

    // when
    CommentSaveResponse result = commentService.saveComment(authUser, todoId, request);

    // then
    assertNotNull(result);
    }

given에서 service에 대한 메서드의 테스트로 수정하고 .willThrow()와 .willReturn()에 대한 입력값을 내가 작성한 메서드의 반환값에 맞게 수정했다.

 

    3. 해결 완료

3-1. 회고

실제 코드를 수정했다면 그에 맞춰 테스트 코드도 수정해야함을 알게 된 것 같다. 본 로직을 작성함과 동시에 어떤 테스트를 수행하면 좋을지에 대해 생각하면서 로직을 작성하면 좋을 것 같다는 생각도 했다.

위와 같이 수정함으로써 테스트코드가 성공적으로 실행된 것도 확인 할 수 있었다.

 

3-2. 전후 데이터 비교

실패한 테스트코드를 성공으로 바꿀 있었다.

 

 

 

7. 테스트 커버리지

테스트 코드를 모두 til을 쓰진 못했지만 service위주로만 테스트 코드를 실행해보았다. 다음은 테스트 커버리지가 어느정도인지 나타내는 이미지이다.