할일관리 과제 2 회고

Jaehyeon Han·2025년 5월 26일

깃허브 링크: https://github.com/Jaehyeon-Han/ScheduleManagement2

구현 과정에서 신경쓴 점

예외 응답의 일관성

저번 과제를 마치고 예외 응답에 일관성이 없고, 단순 String만 포함하는 것이 정보가 부족할 수 있겠다는 생각이 들었다. 그래서 이번 과제에서는 RFC 9457에 정의된 Problem Details 형식을 사용하여 예외 응답을 일관적으로 처리하고자 하였다. 그래서 uri를 별도로 생성해야 하는 type을 제외하고 다음과 같이 ErrorResponseFieldErrorResponse 객체를 통해 예외 객체를 구성하였고,

public class ErrorResponse {
    private final String title;
    private final String detail;
    private final int status;
    private final String instance;
    private final List<FieldErrorResponse> errors = new ArrayList<>();

    public ErrorResponse(String title, String detail, int status, String instance) {
        this.title = title;
        this.detail = detail;
        this.status = status;
        this.instance = instance;
    }

    public void addFieldErrorResponse(String field, String reason) {
        FieldErrorResponse fieldErrorResponse = new FieldErrorResponse(field, reason);
        errors.add(fieldErrorResponse);
    }
}

public record FieldErrorResponse(String field, String reason) {
}

@ExceptionHandler에서는 ResponseEntity<ErrorResponse>를 반환하도록 작성하였다.

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleMalformedJson(HttpServletRequest request) {
  // ...   
}

구현을 다 한 뒤에 더 찾아보니, 이미 스프링에 이 목적으로 ProblemDetails 클래스를 제공하고 있었다. 다음 번에는 이 클래스를 직접 활용해야겠다.

public class ProblemDetail implements Serializable {

	private static final long serialVersionUID = 3307761915842206538L;

	private static final URI BLANK_TYPE = URI.create("about:blank");


	private URI type = BLANK_TYPE;

	@Nullable
	private String title;

	private int status;

	@Nullable
	private String detail;

	@Nullable
	private URI instance;

	@Nullable
	private Map<String, Object> properties;
    
    // ...
}

API 관리

처음에는 springdoc-openapi를 적용해서 코드 내부에서 명세를 관리하려고 했었다. 하지만 코드 가독성이 너무 떨어지게 되어 적용하지 않았다. 또한 openapi-generator-cli를 통해 명세 문서를 작성하는 것도 자동화하려고 했었는데, 만들어진 문서가 읽기에 좋지 않은 것 같아 적용하지 않았다. 이렇게 되다보니 openapi.yml가 machine-readable하다는 특징을 사용할 수 없다고 생각이 들어서, 결과적으로는 명세를 직접 타이핑하였다. 소스에는 그대로 파일을 남겨두어서 확인할 수 있다.

springdoc-openapi 를 적용하려 한 과정은 별도 포스트에서 확인할 수 있다.

통합 테스트

저번 과제에서 API를 직접 테스트하는 부분이 번거로웠기 때문에, 이번에는 테스트 메소드를 작성하여 테스트를 해보기로 했다. 단위 테스트는 단순한 CRUD라 크게 의미는 없었던 것 같고, 통합 테스트는 시간이 부족하여 하나만 할 수 있었다. 대략 다음과 같은 방식으로 진행했다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ScheduleControllerIntegrationTest {
	@Autowired
    ScheduleRepository scheduleRepository;

    @Autowired
    UserRepository userRepository;

    @Autowired
    private TestRestTemplate restTemplate;
    
    private HttpHeaders headers;
    private Long lastAddedScheduleId;
    private long anotherUserId;
    
    @BeforeEach
    void addExampleSchedule() {
        // CreateScheduleRequest를 /schedules에 보내 예시 할일을 추가
    }

    @AfterEach
        // 테스트 스레드와 웹 서버가 다른 스레드에서 실행되므로, @Transactional 대신 직접 DB 초기화
    void clearScheduleRepository() {
        scheduleRepository.deleteAll();
    }

    // 로그인 정보 테스트 간 공유
    @BeforeAll
    public void signUpAndLogin() {
        // CreateUserRequest를 /user/signup에 보내 회원가입 후
        // LoginRequest를 /login에 보내 세션 생성
        // 세션 정보 추출
        List<String> cookies = loginResult.getHeaders().get("Set-Cookie");
        headers = new HttpHeaders();
        headers.put(HttpHeaders.COOKIE, cookies);
    }

    @BeforeAll
    void addAnotherUser() {
        // 다른 사용자 추가
        User user = new User();
        user.setPasswordHash("password");
        user.setEmail("user@email.com");
        user.setName("another_user");
        User saved = userRepository.save(user);
        anotherUserId = saved.getId();
    }

    @Test
    @DisplayName("저장 성공")
    void saveSchedule_withCorrectRequest_shouldSucceed() {
        // given
        String title = "과제";
        String content = "과제 완성 후 제출하기";
        CreateScheduleRequest request = new CreateScheduleRequest(title, content);

        String url = "/schedules";
        headers.setContentType(MediaType.APPLICATION_JSON);
        HttpEntity<CreateScheduleRequest> requestEntity = new HttpEntity<>(request, headers);

        // when
        ResponseEntity<ScheduleResponse> response = restTemplate.exchange(
          url, 
          HttpMethod.POST, 
          requestEntity, 
          ScheduleResponse.class
        );

        // then
        ScheduleResponse scheduleResponse = response.getBody();
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(scheduleResponse.getTitle()).isEqualTo(title);
        assertThat(scheduleResponse.getContent()).isEqualTo(content);
    }
}

구현 도중 겪었던 문제

flush()를 안 썼을 때, auditing이 작동 안 하는 문제

UserService를 테스트하던 중, 다음 테스트가 실패하였다.

@DisplayName("비밀번호 정상 변경")
@Test
void changePasswordById_withCorrectPassword_shouldSucceed() {
    // given (+ @BeforeEach)
    ChangePasswordRequest request = new ChangePasswordRequest("iamgod", "iamtruegod");

    // when
    UserResponse userResponse = userService.changePasswordById(godUserId, request);

    // then
    String savedEmail = userResponse.getEmail();
    String savedName = userResponse.getName();
    LocalDateTime createdAt = userResponse.getCreatedAt();
    LocalDateTime lastUpdatedAt = userResponse.getLastUpdatedAt();

    assertThat(savedEmail).isEqualTo("god@heaven.world");
    assertThat(savedName).isEqualTo("god");
    assertThat(createdAt).isNotEqualTo(lastUpdatedAt); // 실패
}

아마도 다음 메소드에서 return을 바로 했을 때, JPA의 dirty-checking과 auditing이 일어나기 전에 assert가 실행되기 때문이라고 생각했다. 그래서 다음과 같이 flush()를 호출하였더니 테스트에 성공하였다.

public UserResponse changePasswordById(
	long userId,
    @Valid ChangePasswordRequest changePasswordRequest
) {
    // 비밀번호 확인, 불일치 시 ForbiddenException 발생
    User foundUser = userRepository.findUserByIdOrElseThrow(userId);
    String currentPassword = changePasswordRequest.getCurrentPassword();
    String newPassword = changePasswordRequest.getNewPassword();
    checkPasswordMatchesOrElseThrow(foundUser.getPasswordHash(), currentPassword);

    String newPasswordHash = passwordEncoder.encode(newPassword);
    foundUser.setPasswordHash(newPasswordHash);

    userRepository.flush(); // flush 안 하면 테스트에서 auditing 실패

    return UserResponse.fromUser(foundUser);
}

테스트에서 로그인 모방법

ScheduleController를 테스트하려다보니 세션에 사용자 정보가 저장돼있어야 했다. 그런데 테스트 클래스는 메소드마다 별도로 만들어지므로 매번 @BeforeEach에서 회원가입, 로그인 과정을 다 호출해야 했다. 이렇게 하면 테스트가 느려질 것 같아서 @TestInstance(TestInstance.Lifecycle.PER_CLASS)를 사용했다. 그리고나서 일정 id에 1을 주고 조회를 했더니 테스트에 실패했다. 데이터베이스를 확인해보니 sequence가 계속 증가하고 있었다. 그래서 별도 필드에 마지막으로 추가된 id를 저장하는 방식을 사용했다.

private Long lastAddedScheduleId;

@BeforeEach
void addExampleSchedule() {
    CreateScheduleRequest request = new CreateScheduleRequest(EXAMPLE_TITLE, EXAMPLE_CONTENT);

    HttpEntity<CreateScheduleRequest> createScheduleRequestHttpEntity = 
    	getJsonRequestEntityWithSessionId(request);
    String url = "/schedules";

    ResponseEntity<ScheduleResponse> response = restTemplate.exchange(
      url, 
      HttpMethod.POST, 
      createScheduleRequestHttpEntity, 
      ScheduleResponse.class
    );
    lastAddedScheduleId = response.getBody().getId();
}

@DisplayName("정상 조회")
@Test
void getSchedules() {
	// ...
	assertThat(scheduleResponse.getId()).isEqualTo(lastAddedScheduleId);
}

테스트 @Transactional 미작동

ScheduleController의 통합 테스트를 작성하던 중, 테스트를 다시 돌리니까 실패하는 경우가 생겼다. 테이블을 직접 확인해보니 예시로 삽입했던 데이터가 그대로 남아있었다. 확인해보니, @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)를 사용하는 경우, 서버와 테스트 메소드가 별도의 스레드에서 동작하여 롤백이 동작하지 않는다고 하였다. 그래서 다음과 같이 직접 리포지토리를 초기화했다.

@AfterEach
// 테스트 스레드와 웹 서버가 다른 스레드에서 실행되므로, @Transactional 대신 직접 DB 초기화
void clearScheduleRepository() {
    scheduleRepository.deleteAll();
}

Page<T>의 직렬화 문제

할일 컨트롤러의 통합 테스트를 작성할 때 그냥 Page<ScheduleResponse>를 받으려고 하니 예외가 발생했다. 일단 Page<T>가 인터페이스라서 안 되는 건가 생각해서 PageImpl<T>로 적어도 그대로였다. 찾아보니 일반적인 해법은 별도의 객체를 만들어서 넣어주는 것이라고 했다.

내부에서 다음과 같이 PageResponse<T>를 정의하고

private static class PageResponse<T> {

    private List<T> content;
    private int page;
    private int size;
    private long totalElements;
    private int totalPages;
    private boolean first;
    private boolean last;
        
    // 기본 생성자
    
    // 게터, 세터
}

다음처럼 값을 받을 수 있었다.

ResponseEntity<PageResponse<ScheduleResponse>> result = restTemplate.exchange(
    url,
    HttpMethod.GET,
    requestEntity,
    new ParameterizedTypeReference<PageResponse<ScheduleResponse>>() {
});

아쉬운 점

테스트 내부에서 테스트 메소드로의 종속성

예시 정보를 매번 추가하기 위해, @BeforeEach 등 설정 과정에서 테스트하려는 메소드를 사용했다. 지금보니 그냥 Service를 직접 주입받아서 처리했어도 됐을 것 같다는 생각이 든다. 물론 해당 메소드를 테스트한 뒤에 적용한 것이지만, 개념 상으로는 서로 의존하지 않는 게 올바른 방법이라는 생각이 든다.

테스트 메소드의 독립성

@BeforeAll@TestInstance(TestInstance.Lifecycle.PER_CLASS)로 인해 테스트 메소드 간 완전한 독립성을 보장할 수 없다는 점이 아쉽다. 하지만 이렇게 하지 않았을 때, 입력이 되지 않은 상태에서 조회/수정/삭제를 할 수 있는 방법이 마땅치가 않았다. 테스트 간 독립성과 인증이 필요한 경우의 테스트 방법에 대해서 더 공부할 계획이다.

자세히 찾아보지 않은 내용

당장 구현이 급해서 원리를 찾아보지 않고 예제를 갖다 쓰기만 한 내용들이 있다. 설명할 수 있도록 다시 찾아봐야하는 부분들이 많다.

  • 페이징 응답

    • ParameterizedTypeReference

    • PageImpl<T>TestRestTemplate.exchange()의 응답으로 받지 못한 이유

  • 테스트 관련 배경 지식

    • TestRestTemplate과 그냥 RestTemplate 차이

    • @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)의 정확한 작동

    • 테스트 메소드 네이밍 컨벤션

예외 응답의 일관성

직접 정의한 예외와 빈 검증 오류는 ErrorResponse 객체 형식에 맞추어 응답이 나갔다. 그러나 아예 url 매핑 자체가 존재하지 않는 경우나 Content-Type을 잘못 보낸 경우 등에는 스프링의 BasicErrorController가 처리하는 것 같았다. 결과적으로는 응답의 일관성을 제대로 확보하지 못했다.

오류 메시지

지금은 빈 검증 어노테이션에 직접 message를 붙이거나 @ExceptionHandler에 메시지를 붙였다. 그래서 동일한 의미의 메시지를 반복해서 적어줘야 했고, 나중에 변경이 일어났을 때에도 일일이 찾아서 바꿔야할 것 같았다. 스프링 부트에서 메시지 관리 기능을 지원하는 걸로 알고 있는데 다음에는 적용해보고 싶다.

0개의 댓글