Skip to main content

3 posts tagged with "java"

View All Tags

· 20 min read
Seonghun Jung

안녕하세요. 스트릿 드랍에서 백엔드 개발을 하고 있는 정성훈이라고 합니다.

이번 포스팅에서는 스트릿 드랍에서 테스트를 진행하면서, 스트릿 드랍에서 에러 시나리오 테스트를 간편하게 진행했던 방법 공유드리려고 합니다.

에러 시나리오 테스트

스트릿 드랍에서는 유닛 테스트 코드를 통해서, 특정 엔드포엔트에 대한 응답값을 검증하고 있습니다. 그러나, 유닛 테스트만으로는 실제 사용 환경에서 발생할 수 있는 모든 상황을 충분히 검증하기 어렵습니다. 따라서 사용자의 관점에서 애플리케이션의 전체적인 작동을 확인하는 것을 목적으로 하는 E2E 테스트가 필요합니다. 이를 통해 여러 API 통신간에서 또는 실제 유저가 어플을 사용하면서 발생할 수 있는 에러를 포착할 수 있습니다.

만약 5분전에 생성된 게시글이 현재는 삭제되어, 해당 게시글을 다시 조회할 경우 404 에러 페이지가 보이는 경우를 테스트 하기 위해서는 iOS에서는 아래와 같은 시나리오를 따라서 처리해야 합니다.

  1. 새로운 게시글을 생성
  2. 새롭게 작성된 특정 게시글을 삭제
  3. 어플 기획상 id로 특정 게시글 조회가 어렵기 때문에, 게시글 리스트에서는 보이지만, 삭제된 게시글을 조회 함.
  4. 404 에러 페이지가 명확히 나오는 지를 테스트

서비스의 기획에 따라서 특정 에러 시나리오가 매우 간헐적으로 발생해서 이런 에러가 발생했을 때 실제로 E2E 관점에서의 에러는 매우 테스트하기 어려울 수 있습니다. (특히, 스트릿 드랍은 지역 기반으로 작동하며, 새로운 게시글이 생성되기 위해서는 지역정보와 음악정보들이 필요했기 때문에 특정 시나리오 테스트를 위한 더미 데이터를 만들고 구성하는데에 있어서도 큰 어려움이 있었습니다.)

커스텀 헤더 추가하기

HTTP 헤더는 클라이언트와 서버가 요청 또는 응답으로 부가적인 정보를 전송할 수 있도록 해줍니다. 키와 값으로 구성되며 User-Agent, 언어정보, 미디어 타입에 대한 여러 정보를 주고 받을 수 있습니다. 개발자는 커스텀 헤더를 추가해서 부가적인 정보를 처리하는 것이 가능합니다.

2012년 이전에는 'X-' 라는 접두사를 붙이는 것이 권장되었으나 최신에서는 HTTP 스펙에서는 더 이상 해당 접두사를 사용하지 않고 있습니다. 개발자가 커스텀 헤더를 추가할때, 헤더의 목적을 쉽게 이용할 수 있도록 대시(-)를 기준으로 의미가 명확한 용어를 사용하며, 각 단어의 첫 글자를 대문자로 작성하는 것이 관례입니다. 하지만, Spring에서 Request 헤더 정보를 확인할 때, 대/소문자를 구분하지 않습니다.

스트릿 드랍에서는 "STREET-DROP-ERROR-TEST-CODE"라는 커스텀 헤더를 추가해서 에러 시나리오를 테스트하는데 활용하고 있습니다.

특정 ITEM이 삭제되어 더 이상 찾을 수 없을때, ITEM_NOT_FOUND 라는 에러코드가 발생한다고 하면, 해당 에러코드를 넣어서 API 에러를 테스트 할 수 있습니다.

STREET-DROP-ERROR-TEST-CODE : ITEM_NOT_FOUND

위의 시나리오와 같이 특정 1번 아이템이 삭제된 경우를 가졍하세 이를 테스트하려면, 다음과 같이 iOS에서 에러를 추가해서 요청하면 해당 요청에 따라 적합한 에러를 제공합니다. (테스트 서버에서만 활성화되어 실서버에서는 작동하지 않습니다.)

curl -X 'GET' \
'https://api.street-drop.com/items/1' \
-H 'STREET-DROP-ERROR-TEST-CODE: ITEM_NOT_FOUND'

제공된 에러 응답

{
"timestamp": "2024-03-16T14:42:49.978736",
"traceId": "0af1f586-2ecd-42a8-baa6-93cd91e747f7",
"status": 404,
"errorResponseCode": "ITEM_NOT_FOUND",
"title": "Item Not Found",
"message": "Item was deleted or can not accessible"
}

이를 통해서 iOS에서는 특정 에러에 대한 시나리오나 에러 화면을 바로바로 테스트 할 수 있습니다.

스프링에서 커스텀 헤더를 통한 에러 테스트 구현

구현 로직은 대략 다음과 같습니다.

  1. HTTP 요청 헤더에서 STREET-DROP-ERROR-TEST-CODE의 값을 가져옵니다.
  2. 이 헤더의 값이 에러 코드에 정의되어 있는지 확인하고 정의되어 있으면 에러를 발생시키고, 없는 경우 기존 요청을 처리합니다.

OncePerRequestFilter 사용하기

이를 구현하기 위해서 필터와 인터셉트를 고민해볼 수 있는데, 필터의 경우 Request와 Response를 조작할 수 있지만, 인터셉트는 이를 조작할 수 없기 때문에 필터를 사용하는 거이 적합합니다.

[Spring] 필터(Filter) vs 인터셉터(Interceptor) 차이 및 용도

Spring은 공통적으로 여러 작업을 처리함으로써 중복된 코드를 제거할 수 있도록 많은 기능들을 지원하고 있다. 이번에는 그 중에서 필터(Filter) vs 인터셉터(Interceptor)의 차이에 대해 알아보고자 한다....

https://mangkyu.tistory.com/173

필터중에서는 Http Request 한번의 요청에 대해서 한 번만 실행하는 필터인 OncePerRequestFilter를 사용했습니다. OncePerRequestFilter 상속하여 구현하면서, doFilter 대신 doFilterInternal 메서드를 구현하면 됩니다.


@Component
public class OncePerRequestFilterTestFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 필터에서 전처리
filterChain.doFilter(request, response);
// 필터에서 후처리
}
}

커스텀 헤더를 통한 에러 던지기

먼저 기존에 에러코드들이 다음과 같이 정의되어 있다고 가정해보겠습니다. 아래는 발생할 수 있는 모든 에러들을 묶어둔 ErrorCode enum 입니다,

public enum ErrorCode {
BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_BAD_REQUEST", "Bad Request", "The request could not be understood."),
ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "ITEM_NOT_FOUND", "Item Not Found", "Item was deleted or can not accessible");

private HttpStatus status;
private String errorResponseCode;
private String title;
private String message;

}

아래는 해당 에러코드에서 맞는 enum이 있는지 체크하는 코드입니다.

public class ErrorCodeMapper {
public static Optional<ErrorCode> findByErrorCode(String code) {
for (ErrorCode errorCode : ErrorCode.values()) {
if (errorCode.getCode().equals(code)) {
return Optional.of(errorCode);
}
}
return Optional.empty();
}
}

우선 실서버에서 헤더를 통해서 에러를 던지게 되면, 악용되는 사례가 있을 수 있기 때문에 프로파일을 통해서 테스트 서버와, 로컬 환경에서만 테스트 하도록 프로필을 지정해줍니다.

@Profile({"dev", "local"})

스프링에서 들어온 헤더를 읽으려면 HttpServletRequest request에서 getHeader 메서드를 사용해서 요청으로 들어온 헤더를 읽을 수 있습니다.

public interface HttpServletRequest extends ServletRequest {
String getHeader(String var1);
}

우선 스프링에서 들어온 헤더값을 읽고, 이 헤더와 일치하는 enum 값이 있을 경우 에러를 던지고, 아닌 경우 필터를 그냥 지나치도록 개발하면 됩니다.


@Profile({"dev", "local"})
@Component
@Slf4j
public class ErrorTestHeaderFilter extends OncePerRequestFilter {

public static final String ERROR_TEST_HEADER = "STREET-DROP-ERROR-TEST-CODE";
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
String errorTestHeader = request.getHeader(ERROR_TEST_HEADER); // 헤더에서 STREET-DROP-ERROR-TEST-CODE에 대한 Value를 가져옴

if (errorTestHeader != null) {
Optional<ErrorCode> errorCode = ErrorCodeMapper.findByErrorCode(errorTestHeader); // enum에서 해당 에러코드에 일치하는 값이 있는지 찾음

if (errorCode.isPresent()) { // 일치하는 에러코드의 값이 있을 경우
throwErrorResponse(response, errorCode.get()); // 에러를 발생
return;
}
}
filterChain.doFilter(request, response);
}

private void throwErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { // 에러를 발생시킬 경우
response.setContentType(APPLICATION_JSON_VALUE);
response.setStatus(errorCode.getStatus().value());

ErrorResponseDto errorResponseDto = new ErrorResponseDto(errorCode); // 서버에서 정의된 에러를 발생시킬 경우의 스키마
String errorResponseJson = objectMapper.writeValueAsString(errorResponseDto);

response.getWriter().write(errorResponseJson);
}

}
  1. request.getHeader(ERROR_TEST_HEADER); 을 통해서 헤더에서 STREET-DROP-ERROR-TEST-CODE에 대한 Value를 가져옵니다.
  2. ErrorCodeMapper.findByErrorCode(errorTestHeader); 를 통해서 맞는 에러 enum이 있는지 확인합니다.
  3. 일치하는 에러코드가 있을 경우 throwErrorResponse 내장 메서드를 통해서 에러를 발생시킵니다.
  4. throwErrorResponse 메서드에서는 HttpServletResponse에 ContentType과 상태코드, 에러코드를 에러응답 dto로 변환하고 Json으로 작성하여 response body에 추가합니다.

실제 스트릿 드랍 에러 테스트용 필터

아래는 스트릿 드랍에서 실제로 사용하는 에러 테스트용 필터입니다. 도메인 별로 에러코드를 묶어주었기 때문에, 제너릭을 일부 사용하였습니다.


@Profile({"dev", "local"})
@Component
@Slf4j
public class ErrorTestHeaderFilter extends OncePerRequestFilter {

public static final String ERROR_TEST_HEADER = "STREET-DROP-ERROR-TEST-CODE";
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
String errorTestHeader = request.getHeader(ERROR_TEST_HEADER);

if (errorTestHeader != null) {
Optional<ErrorCode> errorCode = ErrorCodeMapper.findByErrorCode(errorTestHeader);

if (errorCode.isPresent()) {
throwErrorResponse(response, errorCode.get());
return;
}
}
filterChain.doFilter(request, response);
}

private <T extends ErrorCodeInterface> void throwErrorResponse(HttpServletResponse response, T errorCode) throws IOException {
response.setContentType(APPLICATION_JSON_VALUE);
response.setStatus(errorCode.getStatus().value());

HttpErrorResponseDto httpErrorResponseDto = HttpErrorResponseDto.from(errorCode);
String errorResponseJson = objectMapper.writeValueAsString(httpErrorResponseDto);

response.getWriter().write(errorResponseJson);
}

}

스웨거에서 에러코드 제공하기

스트릿 드랍은 Swagger를 통해서 API를 문서화 하고 있습니다. 위에서 에러 테스트 코드가 가능한 에러 코드를 제공할 때, 이를 스웨거에서 같이 제공하고자 했었습니다. 스트릿 드랍 스웨거 에러 코드

SpringDoc - OperationCustomizer

SpringDoc 라이브러리를 활용하여 스웨거를 적용하고 있습니다. SpringDoc 에서는 OperationCustomizer 커스텀하여, 스웨거의 파라미터, 응답값을 수정할 수 있습니다. CustomOperationCustomizer를 만들어 Swagger Config에 다음과 같이 추가할 수 있습니다


@Component
@Slf4j
@AllArgsConstructor
public class CustomOperationCustomizer implements OperationCustomizer {
@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
}
}

@Configuration
@RequiredArgsConstructor
public class SwaggerConfig {

private final OperationCustomizer operationCustomizer;

@Bean
public GroupedOpenApi publicApi() {
return GroupedOpenApi.builder()
.group("v1")
.pathsToMatch("/**")
.addOperationCustomizer(operationCustomizer)
.build();
}
}

커스텀 어노테이션 만들기 - ApiErrorResponse와 ApiErrorResponses

SpringDoc에서는 다음과 같이 ApiResponse 어노테이션을 통해서 응답코드와 설명을 작성할 수 있습니다.

    @Operation(summary = "Reverse Geocoding")
@ApiResponse(responseCode = "200", description = "좌표 주소 변환 성공")
public ResponseEntity<ReverseGeocodeResponseDto> reverseGeocode() {

}

Api 오류 코드의 경우 이를 응용하여 ApiErrorResponse와 ApiErrorResponses 어노테이션을 만들어 처리하고자 하였습니다. ApiErrorResponses의 경우 SpringDoc에서 ApiResponses로 어노테이션이 복수인 경우 감싸서 처리하고 있었기 때문에 유사하게 처리하고자 했습니다.


@Target({ElementType.METHOD, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Repeatable(ApiErrorResponses.class)
public @interface ApiErrorResponse {

String description() default "";

String errorCode() default "COMMON_INTERNAL_SERVER_ERROR";

}

@Target({ElementType.METHOD, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface ApiErrorResponses {

ApiErrorResponse[] value() default {};

Extension[] extensions() default {};

}

ApiErrorResponses는 아래와 같이 사용할 수 있습니다.

    @Operation(summary = "아이템 신고하기")
@ApiErrorResponses(value = {
@ApiErrorResponse(errorCode = "ITEM_NOT_FOUND", description = "아이템을 찾을 수 없습니다."),
@ApiErrorResponse(errorCode = "ITEM_ALREADY_ITEM_REPORTED_ERROR", description = "이미 신고한 아이템입니다.")
})
public ResponseEntity<Void> claimItem() {
}

어노테이션값 가져와서 Swagger 수정하기

CustomOperationCustomizer의 customize에서 우선 해당 어노테이션 정보를 가져옵니다.

    @Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
ApiErrorResponse apiErrorResponseAnnotation = handlerMethod.getMethodAnnotation(ApiErrorResponse.class);

if (apiErrorResponseAnnotation != null) {
handleApiErrorResponse(operation, apiErrorResponseAnnotation);
}

return operation;
}

handleApiErrorResponses는 handleApiErrorResponse와 큰 차이가 없기 때문에 handleApiErrorResponse를 우선으로 보면, ErrorCodeMapper에서 맞는 에러 코드를 찾고 해당 하는 에러코드가 있으면 errorResponseExampleCustomizer와 errorRequestHeaderCustomizer를 통해 각각 에러 응답예제와 응답헤더 예제를 생성합니다.

    private void handleApiErrorResponse(Operation operation, ApiErrorResponse apiErrorResponseAnnotation) {
ErrorCodeMapper.findByErrorCode(apiErrorResponseAnnotation.errorCode())
.ifPresent(errorCode -> {
var errorCodeExampleList = List.of(Map.of(errorCode, apiErrorResponseAnnotation.description()));
errorResponseExampleCustomizer.generateErrorResponseExample(operation, errorCodeExampleList);
errorRequestHeaderCustomizer.generateErrorRequestHeader(operation, errorCode);
});
}

ErrorRequestHeaderCustomizer는 아래 사진에서 보이는 파란 색 부분으르 만드는 객체입니다. 스트릿 드랍 스웨거 에러 코드 - 헤더

ErrorRequestHeaderCustomizer 에서는 Operation안의 Parameter 리스트의 값에 헤더를 추가합니다.

@Component
public class ErrorRequestHeaderCustomizer {

public void generateErrorRequestHeader(Operation operation, List<ErrorCode> errorCodeList) {
var defaultParameters = getDefaultParameter(operation);
var newErrorCodeParameters = generateErrorRequestHeader(errorCodeList);
defaultParameters.add(newErrorCodeParameters);
if (defaultParameters.size() == 1) {
operation.setParameters(defaultParameters);
}
}

public void generateErrorRequestHeader(Operation operation, ErrorCode errorCode) {
var defaultParameters = getDefaultParameter(operation);
var newErrorCodeParameters = generateErrorRequestHeader(singletonList(errorCode));
defaultParameters.add(newErrorCodeParameters);
if (defaultParameters.size() == 1) {
operation.setParameters(defaultParameters);
}
}

private List<Parameter> getDefaultParameter(Operation operation) {
List<Parameter> parameters = operation.getParameters();
if (parameters == null) {
parameters = new ArrayList<>();
}
return parameters;
}

private Parameter generateErrorRequestHeader(List<ErrorCode> errorCode) { // 에러 헤더 만들기
var parameter = new Parameter();
parameter.setName("STREET-DROP-ERROR-TEST-CODE");
parameter.setIn("header");
parameter.setDescription("에러를 확인하기 위한 코드입니다. 테스트 서버 환경에서 해당 에러코드를 STREET-DROP-ERROR-TEST-CODE 헤더에 추가하여 테스트할 수 있습니다.");
parameter.setRequired(false);
parameter.setDeprecated(false);
parameter.setSchema(generateErrorRequestHeaderSchema(errorCode));
return parameter;
}

private Schema<?> generateErrorRequestHeaderSchema(List<ErrorCode> errorCode) { // 에러 리스트를 Enum 형태로 선택할 수 있도록 하기
var schema = new Schema<>();
schema.setEnum(Arrays.asList(errorCode.stream().map(ErrorCode::getErrorResponseCode).toArray()));
schema.setType("string");
return schema;
}
}

ErrorResponseExampleCustomizer 는 아래 사진에서 보이는 파란 색 부분인 응답 예제를 만드는 부분입니다. SpringDoc에서는 Example을 통해서 응답예제를 만들어내는데, 이를 수정하여 예외 스키마를 만들어 낼 수 있습니다.

스트릿 드랍 스웨거 에러 코드 - 응답 ErrorResponseExampleCustomizer 에서는 Example을 변경하여 에러 예외 스키마 예시를 만들어냅니다.

@Component
public class ErrorResponseExampleCustomizer {

public void generateErrorResponseExample(Operation operation, List<Map<ErrorCode, String>> errorCodeExampleList) {
ApiResponses responses = operation.getResponses();

Map<Integer, List<Map<ErrorCode, String>>> errorCodeExampleByStatus = errorCodeExampleList.stream()
.collect(groupingBy(errorCodeStringMap -> errorCodeStringMap.keySet().iterator().next().getStatus().value()));

errorCodeExampleByStatus
.forEach(
(statusCode, value) -> {
MediaType mediaType = new MediaType();
value.forEach(map ->
map.forEach(
(errorResponse, description) -> {
Example example = generateExample(errorResponse, description);
mediaType.addExamples(errorResponse.getErrorResponseCode(), example);
}
));
Content content = generateContent(mediaType);
ApiResponse apiResponse = generateApiResponse(content);
responses.addApiResponse(statusCode.toString(), apiResponse);
});
}

private ApiResponse generateApiResponse(Content content) {
ApiResponse apiResponse = new ApiResponse();
apiResponse.setContent(content);
return apiResponse;
}

private Content generateContent(MediaType mediaType) {
Content content = new Content();
content.addMediaType("application/json", mediaType);
return content;
}

private Example generateExample(ErrorCode errorCode, String description) {
Example example = new Example();
HttpErrorResponseDto httpErrorResponseDto = HttpErrorResponseDto.from(errorCode);
example.setValue(httpErrorResponseDto);
example.setDescription(description);
return example;
}
}

아래는 ApiResponses도 함께 처리하는 CustomOperationCustomizer 전체 코드입니다.


@Component
@Slf4j
@AllArgsConstructor
public class CustomOperationCustomizer implements OperationCustomizer {

private final ErrorRequestHeaderCustomizer errorRequestHeaderCustomizer;
private final ErrorResponseExampleCustomizer errorResponseExampleCustomizer;

@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
ApiErrorResponse apiErrorResponseAnnotation = handlerMethod.getMethodAnnotation(ApiErrorResponse.class);
ApiErrorResponses apiErrorResponsesAnnotation = handlerMethod.getMethodAnnotation(ApiErrorResponses.class);

if (apiErrorResponseAnnotation != null) {
handleApiErrorResponse(operation, apiErrorResponseAnnotation);
}

if (apiErrorResponsesAnnotation != null) {
handleApiErrorResponses(operation, apiErrorResponsesAnnotation);
}

return operation;
}

private void handleApiErrorResponse(Operation operation, ApiErrorResponse apiErrorResponseAnnotation) {
ErrorCodeMapper.findByErrorCode(apiErrorResponseAnnotation.errorCode())
.ifPresent(errorCode -> {
var errorCodeExampleList = List.of(Map.of(errorCode, apiErrorResponseAnnotation.description()));
errorResponseExampleCustomizer.generateErrorResponseExample(operation, errorCodeExampleList);
errorRequestHeaderCustomizer.generateErrorRequestHeader(operation, errorCode);
});
}

private void handleApiErrorResponses(Operation operation, ApiErrorResponses apiErrorResponsesAnnotation) {
List<Map<ErrorCode, String>> errorCodeExampleList = Arrays.stream(apiErrorResponsesAnnotation.value())
.map(apiErrorResponse -> ErrorCodeMapper.findByErrorCode(apiErrorResponse.errorCode())
.map(code -> Map.of(code, apiErrorResponse.description()))
.orElse(emptyMap()))
.filter(map -> !map.isEmpty())
.toList();

List<ErrorCode> resultList = errorCodeExampleList.stream().flatMap(map -> map.keySet().stream()).toList();

errorRequestHeaderCustomizer.generateErrorRequestHeader(operation, resultList);
errorResponseExampleCustomizer.generateErrorResponseExample(operation, errorCodeExampleList);
}

}

결론

커스텀 헤더와 스웨거를 통한 에러 코드 문서화 작업을 통해서, 다양한 상황에서의 에러 시나리오를 iOS 측에서도 에러 화면에 대해서 빠르게 처리할 수 있었고, 이를 기존에 사용하던 Swagger를 통해서 문서화 하면서 에러 응답을 간단하게 Swagger에서 확인할 수 있었습니다.

참고

HTTP 헤더로 에러 테스트하기

토스페이먼츠 API를 사용해서 커스텀 HTTP 헤더로 다양한 에러 시나리오를 테스트하세요. 개발 과정에서 예상치 못한 문제를 미리 발견하고 대응할 수 있...

https://velog.io/@tosspayments/HTTP-헤더로-에러-테스트하기-zuya4t6v

[스프링] spring swagger 같은 코드 여러 에러 응답 예시 만들기

두둥 프로젝트에서는 처리중에 에러가 발생할경우 RuntimeException 을 상속받은 DuDoongException 에서 다시 상속받아서 코드별 에러클래스를…

https://devnm.tistory.com/29

[Spring] 필터(Filter) vs 인터셉터(Interceptor) 차이 및 용도

Spring은 공통적으로 여러 작업을 처리함으로써 중복된 코드를 제거할 수 있도록 많은 기능들을 지원하고 있다. 이번에는 그 중에서 필터(Filter) vs 인터셉터(Interceptor)의 차이에 대해 알아보고자 한다....

https://mangkyu.tistory.com/173

· 14 min read
Seonghun Jung

안녕하세요. 스트릿 드랍에서 백엔드 개발을 하고 있는 정성훈이라고 합니다.

이번 포스팅에서는 스트릿 드랍에서 에러를 처리하면서, 특정 인터페이스를 구현한 클래스를 찾는 방식에 대해서 고민했던 과정을 공유드리려고 합니다.

스트릿 드랍의 에러처리 방식

스트릿 드랍에서는 에러코드를 도메인 별로 분리하여, 처리하고 있습니다.

@Getter
@AllArgsConstructor
public enum CommonErrorCode implements ErrorCodeInterface {
/*
* Basic Client Error
*/
BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_BAD_REQUEST", "Bad Request", "The request could not be understood or was missing required parameters."),
METHOD_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST, "COMMON_METHOD_ARGUMENT_NOT_VALID", "Method Argument Not Valid", "One or more method arguments are not valid."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_UNAUTHORIZED", "Unauthenticated", "Authentication is required and has failed or has not been provided."),
FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_FORBIDDEN", "Forbidden", "Access to the requested resource is forbidden."),
NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_NOT_FOUND", "Not Found", "The requested resource could not be found."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_METHOD_NOT_ALLOWED", "Method Not Allowed", "The method received in the request-line is known by the origin server but not supported."),
CONFLICT(HttpStatus.CONFLICT, "COMMON_CONFLICT", "Conflict", "The request could not be completed due to a conflict with the current state of the target resource."),

/*
* Basic Server Error
*/
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_INTERNAL_SERVER_ERROR", "Internal Server Error", "An unexpected error occurred"),
NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "COMMON_INTERNAL_SERVER_ERROR", "Not Implemented", "The server does not support the functionality required to fulfill the request.");


private final HttpStatus status;
private final String errorResponseCode;
private final String title;
private final String message;

@Override
public ErrorCode toErrorCode() {
return ErrorCode
.builder()
.status(status)
.errorResponseCode(errorResponseCode)
.title(title)
.message(message)
.build();
}
}
@Getter
@AllArgsConstructor
public enum ItemErrorCode implements ErrorCodeInterface {
ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "ITEM_NOT_FOUND", "Item Not Found", "Item Not Found"),
ITEM_ALREADY_LIKED(HttpStatus.CONFLICT, "ITEM_ALREADY_LIKED", "Item Already Liked", "User already item liked"),
ITEM_ALREADY_REPORTED(HttpStatus.CONFLICT, " ITEM_ALREADY_REPORTED", "Item Already Reported", "User already item reported");

private final HttpStatus status;
private final String errorResponseCode;
private final String title;
private final String message;


@Override
public ErrorCode toErrorCode() {
return ErrorCode
.builder()
.status(status)
.errorResponseCode(errorResponseCode)
.title(title)
.message(message)
.build();
}
}

위의 예와 같이, CommonErrorCode와 ItemErrorCode에 ErrorCodeInterface를 통해서 Enum에 상태값, 에러제목, 에러 메세지를 형식을 포함하도록 강제하고 있습니다.

private final HttpStatus status; // HTTP 상태 코드
private final String errorResponseCode; // 에러 응답 코드
private final String title; // 에러 메세지 제목
private final String message; // 에러 메세지 내용

아래는 디프만에서 같이 활동했던, 찬진님의 에러코드를 분리하는 블로그글인데 참고하셔도 좋을 것 같습니다.

[스프링] error code 도메인 별 분리하기

두둥 프로젝트에서는 처리중에 에러가 발생할경우 RuntimeException 을 상속받은 DuDoongException 에서 다시 상속받아서 코드별 에러클래스를 만들고 있다. @Getter…

https://devnm.tistory.com/27

커스텀 헤더를 통한 errorResponseCode 기반의 시나리오 테스트

스트릿 드랍에서는 커스텀 헤더를 통해서 개발 환경에서 클라이언트가 에러 시나리오를 보다 쉽게 테스트할 수 있도록 제공하고 있습니다. 위의 errorResponseCode를 추가하여 테스트할 수 있도록 구성하였습니다.

STREET-DROP-ERROR-TEST-CODE: USER_NOT_FOUND

토스에서 사용하는 HTTP 헤더로 에러 시나리오를 테스트 하는 방법인데 참고하셔도 좋을 것 같습니다.

HTTP 헤더로 에러 테스트하기

토스페이먼츠 API를 사용해서 커스텀 HTTP 헤더로 다양한 에러 시나리오를 테스트하세요. 개발 과정에서 예상치 못한 문제를 미리 발견하고 대응할 수 있...

https://velog.io/@tosspayments/HTTP-헤더로-에러-테스트하기-zuya4t6v

스프링 필터를 통해서 구현하였고 개발과 로컬 환경에서 작업이 가능하며, 에러 테스트 헤더가 들어오면, 적합한 에러 ErrorCode Enum을 가져와서 적합한 에러를 반환합니다.

@Profile({"dev", "local"})
@Component
@Slf4j
public class ErrorTestHeaderFilter extends OncePerRequestFilter {

public static final String ERROR_TEST_HEADER = "STREET-DROP-ERROR-TEST-CODE";
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
String errorTestHeader = request.getHeader(ERROR_TEST_HEADER);

if (errorTestHeader != null) {
Optional<ErrorCode> errorCode = ErrorCodeMapper.findByErrorCode(errorTestHeader); // 에러코드를 찾는 부분

if (errorCode.isPresent()) {
throwErrorResponse(response, errorCode.get());
return;
}
}
filterChain.doFilter(request, response);
}

private <T extends ErrorCodeInterface> void throwErrorResponse(HttpServletResponse response, T errorCode) throws IOException {
response.setContentType(APPLICATION_JSON_VALUE);
response.setStatus(errorCode.getStatus().value());

HttpErrorResponseDto httpErrorResponseDto = HttpErrorResponseDto.from(errorCode);
String errorResponseJson = objectMapper.writeValueAsString(httpErrorResponseDto);

response.getWriter().write(errorResponseJson);
}

}

여러 enum에서 맞는 errorResponseCode 찾기

도메인별로 에러코드를 Enum으로 다르게 구분하였기 때문에, 에러처리 필터에서, errorResponseCode를 가지고 도메인별로 흩어져있는 enum에서 맞는 errorResponseCode를 찾아야 합니다.( ErrorCodeMapper.findByErrorCode(errorTestHeader) 에서 이 과정을 처리하고 있습니다.) 만약, HELLLO_BAD_REQUEST 라는 errorResponseCode가 들어오면 아래와 같이 여러 패키지에 있는 ErrorCode의 errorResposeCode에서 맞는 값을 찾아주어야 합니다.

@Getter
@AllArgsConstructor
public enum HelloErrorCode implements ErrorCodeInterface {
BAD_REQUEST_HELLO(HttpStatus.BAD_REQUEST, "HELLLO_BAD_REQUEST", "Bad Request", "The request could not be understood or was missing required parameters.");
}
@Getter
@AllArgsConstructor
public enum NiceErrorCode implements ErrorCodeInterface {
BAD_REQUEST_NICE(HttpStatus.BAD_REQUEST, "NICE_BAD_REQUEST", "Bad Request", "The request could not be understood or was missing required parameters.");
}

이를 위해서는 특정 Interface를 구현한 Enum을 찾아서, 모든 Enum에서 errorResponse와 맞는 값을 찾는 과정이 필요합니다. 값을 찾기 위해서는 크게 두가지 방법이 있습니다.

  1. QueryDSL이나 Lombok과 같이 컴파일 시점에서 도메인 별로 흩어져 있는 Enum 값들 하나로 모은 StreetDropErrorCode를 파일로 만들어 이를 참고하는 방식 (QType과 같은 방식으로 처리)
  2. 실행시점에서 특정 인터페이스를 구현한 클래스를 찾아서 해당 클래스에서 맞는 errorResponse 코드를 찾는 방식

커스텀 헤더를 통한 errorResponseCode 기반의 시나리오 테스트가 개발환경에서만 제공되기 때문에 크게 성능에 대해서 민감하지 않으며, 1번의 경우 복잡성이 올라갈 것으로 예상해 실행시점에서 특정 인터페이스를 구현한 클래스를 찾는 방식을 선택해 구현하였습니다.

특정 인터페이스를 구현한 클래스를 찾기

특정 인터페이스를 구현한 클래스를 찾는 방법은 크게 3가지가 있습니다.

  1. Service Loader를 사용
  2. Reflection을 통한 접근
  3. 스프링의 ClassPathScanningCandidateComponentProvider를 통한 접근

Service Loader의 사용

Service Loader는 자바에서 제공하는 기능으로, 특정 인터페이스를 구현한 클래스를 동적으로 찾아내고 로드할 수 있도록 도와주는 메커니즘입니다. 이는 Java6부터 도입되었으며, 자바의 모듈 시스템이 도입되기 전에 서비스 제공자를 찾기 위해 주로 사용되었습니다.

public interface ErrorCodeInterface {
}
public enum HelloErrorCode implements ErrorCodeInterface {
BAD_REQUEST_HELLO(HttpStatus.BAD_REQUEST, "HELLLO_BAD_REQUEST", "Bad Request", "The request could not be understood or was missing required parameters.");
}
public enum NiceErrorCode implements ErrorCodeInterface {
BAD_REQUEST_NICE(HttpStatus.BAD_REQUEST, "NICE_BAD_REQUEST", "Bad Request", "The request could not be understood or was missing required parameters.");
}

위와 같이 ErrorCodeInterface와 이를 구현한 HelloErrorCode와 NiceErrorCode가 있으면

META-INF/services/com.streetdrop.error.dto.interfaces.ErrorCodeInterface

파일을 생성하고, 해당 파일안에 구현 클래스인 HelloErrorCode와 NiceErrorCode의 classpath를 작성해줍니다.

# META-INF/services/com.streetdrop.error.dto.interfaces.ErrorCodeInterface
com.streetdrop.hello.error.dto.HelloErrorCode;
com.streetdrop.nice.error.dto.NiceErrorCode;

코드에서는 다음과 같이 서비스를 로드하여 사용할 수 있습니다.

private List<StreetDropErrorCode> createStreetDropErrorCodeList() {
ServiceLoader<ErrorCodeInterface> errorCodeInterfaces = ServiceLoader.load(ErrorCodeInterface.class);
for (ErrorCodeInterface errorCodeInterface : errorCodeInterfaces) {
var errorResponseCode = errorCodeInterface.getErrorResponseCode();
var error = errorCodeInterface.toErrorCode();
var streetDropError = new StreetDropErrorCode(errorResponseCode, error);
streetDropErrorCodeList.add(streetDropError);
}
}

Reflection을 통한 접근

이전에 자바와 Reflection과 관련한 글입니다.

[Spring] Unit테스트간 Reflection을 활용하여 접근 제어필드 값 변경하기

스프링에서 Unit 테스트를 작성하다 보면, Private, Protected 등 접근제어자가 설정된 필드에 값을 넣어주어야 하는 경우가 발생하는데, 이때 접근 제어자...

https://dev-seonghun.medium.com/unit테스트간-reflection을-활용하여-접근-제어필드-값-변경하기-5c68766b1c35

Reflection을 통해서, JVM 상의 클래스 정보를 바탕으로 특정 인터페이스를 구현한 클래스를 찾을 수 있습니다.

dependencies {
implementation 'org.reflections:reflections:0.10.2'
}

getSubTypesOf 메서드를 사용하면 특정 타입의 하위 타입을 모두 가져올 수 있습니다.

public <T> Set<Class<? extends T>> getSubTypesOf(Class<T> type)

다음과 같이 하위 타입을 모두 가져와 Enum인지 가져와서 사용할 수 있습니다.

private List<StreetDropErrorCode> createStreetDropErrorCodeList() {
List<StreetDropErrorCode> streetDropErrorCodeList = new ArrayList<>();
Set<Class<? extends ErrorCodeInterface>> list = new Reflections().getSubTypesOf(ErrorCodeInterface.class);
for (Class<? extends ErrorCodeInterface> errorCodes : list) {
if (errorCodes.isEnum()){
for (var errorCode : errorCodes.getEnumConstants()) {
if (errorCode != null){
var errorResponseCode = errorCode.getErrorResponseCode();
var error = errorCode.toErrorCode();
var streetDropError = new StreetDropErrorCode(errorResponseCode, error);
streetDropErrorCodeList.add(streetDropError);
}
}
}

}

return streetDropErrorCodeList;
}

ClassPathScanningCandidateComponentProvider을 통한 접근

ClassPathScanningCandidateComponentProvider (Spring Framework 6.1.3 API)

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.html

ClassPathScanningCandidateComponentProvider는 스프링 프레임워크에서 제공하는 클래스로, 지정된 기본 패키지를 시작으로 후보 컴포넌트를 찾는 역할을 합니다.이 클래스는 컴포넌트 인덱스를 사용할 수 있으면 사용하고, 그렇지 않으면 클래스패스 스캐닝을 수행합니다. TypeFilter 를 통해서 특정 타입을 상속받은 클래스만을 식별할 수 있습니다. 먼저 ClassPathScanningCandidateComponentProvider 생성하고

ClassPathScanningCandidateComponentProvider s = new ClassPathScanningCandidateComponentProvider(false);

TypeFilter를 통해서 ErrorCodeInterface를 구현한 클래스를 찾습니다. filtCandidateComponents에서의 패키지 내의 모든 클래스를 검색하고, 필터에 있는 해당 조건을 만족하는 클래스들을 찾고 가져올 수 있습니다.

TypeFilter typeFilter = new AssignableTypeFilter(ErrorCodeInterface.class);
scanner.addIncludeFilter(typeFilter);

Set<BeanDefinition> components = scanner.findCandidateComponents("com.streetdrop");
for (BeanDefinition component : components) {
Class<?> className = Class.forName(component.getBeanClassName());
}

전체 코드는 아래와 같습니다.

private synchronized List<StreetDropErrorCode> createStreetDropErrorCodeList() {
ClassPathScanningCandidateComponentProvider s = new ClassPathScanningCandidateComponentProvider(false);

TypeFilter tf = new AssignableTypeFilter(ErrorCodeInterface.class);
s.addIncludeFilter(tf);

Set<BeanDefinition> components = s.findCandidateComponents("com.depromeet");

for (BeanDefinition component : components) {
Class<?> className = Class.forName(component.getBeanClassName());

if (className.isEnum()) {
for (var errorCode : className.getEnumConstants()) {
if (errorCode != null) {
String errorResponseCode = (String) errorCode.getClass().getMethod("getErrorResponseCode").invoke(errorCode);
ErrorCode error = (ErrorCode) errorCode.getClass().getMethod("toErrorCode").invoke(errorCode);
var streetDropError = new StreetDropErrorCode(errorResponseCode, error);
streetDropErrorCodeList.add(streetDropError);
}
}
}
}

return streetDropErrorCodeList;
}

결론

Service Loader를 사용

  • 장점: Java 내부 내장, 추가적인 의존성 필요 없음
  • 단점: 매번 classpath를 작성하는 것이 번거롭고, 실수할 가능성이 있음

Reflection을 통한 접근

  • 장점: 라이브러리 추가와 메서드 하나만을 통해서, 빠르게 구현이 가능
  • 단점: Reflection이기 때문에 속도가 다소 느림, 외부 라이브러리 의존성 필요

ClassPathScanningCandidateComponentProvider

  • 장점: 빠르게 구현이 가능
  • 단점: 스프링에 의존성, 에러 추가 핸들링이 필요

결론적으로는 ClassPathScanningCandidateComponentProvider를 선택했습니다. 테스트 코드에서 errorResponse 코드의 중복을 확인해주어야 하는데 reflection 의 경우 JVM 상의 클래스 정보를 읽어야 하기 때문에 단순히 Unit 테스트를 통해서 확인이 어려워, ClassPathScanningCandidateComponentProvider 를 활용해 구현하였습니다.

관련 PR : https://github.com/depromeet/street-drop-server/pull/415

· 4 min read
Young Yun

안녕하세요. 스트릿 드랍에서 백엔드 개발을 하고 있는 윤 영(Yun Young) 입니다.

Java는 지속적으로 발전하면서 개발자들에게 더 나은 프로그래밍 경험을 제공하려고 노력합니다. Java 14에서 프리뷰 기능으로 도입된 'Record'는 그러한 노력의 한 예 입니다.

스트릿 드랍(Street Drop)을 개발할 때 Response DTO를 Java의 Record 타입으로 사용했는데요.

이번 포스팅에서는 Java의 'Record'에 대한 개념과 그 사용 이유를 알아보도록 하겠습니다.

Record란?

Java에서의 'Record'는 데이터만을 포함하는 불변(immutable)한 객체를 간단하게 정의할 수 있게 도와주는 새로운 타입입니다.

기존의 Java에서 데이터 전용 클래스를 정의하기 위해서는 많은 보일러 플레이트(자동 생성된 코드)가 필요했습니다. 생성자, Getter, eqauls(), hashCode(), toString() 등 다양한 메소드가 필요했습니다.

Record는 이런 불편함을 해소해주며, 간결한 문법으로 데이터를 표현할 수 있게 해줍니다.

스트릿 드랍에서는 음악 아이템에 대해 좋아요를 눌렀을 때 요청을 처리하고 ItemLikeResponseDto를 반환합니다.

Record 타입으로 응답 DTO 클래스를 정의함으로써 코드가 한껏 간결해진 것을 볼 수 있습니다.

Record의 특성

  1. 불변성: Record의 모든 필드는 불변(immutable) 입니다. 따라서 한번 생성되면 그 값을 변경할 수 없습니다.
  2. 표준 메서드 자동 제공: equals(), hashCode(), toString()과 같은 메서드가 자동으로 제공됩니다.
  3. 제한된 상속: Record는 다른 클래스를 상속받을 수 없으며, 상속되도록 할 수 없습니다.

왜 Record를 사용했는가?

  1. 간결성: DTO와 같은 데이터 전용 클래스를 정의할 때 필요한 보일러플레이트 코드를 줄일 수 있습니다.
  2. 명확한 의도: Record를 사용함으로써 해당 클래스가 데이터 전용임을 명확히 나타낼 수 있습니다.
  3. 불변성 보장: Record는 데이터의 불변성을 보장함으로써 버그 발생을 최소화하고 코드의 안정성을 향상시킵니다.

결론

Java의 Record는 데이터 전용 클래스의 정의를 간결하게 만들어주면서도 코드의 안정성과 명확성을 보장합니다.

데이터를 표현하는 객체에서 많은 보일러플레이트 코드를 줄이고, 불변성을 갖는 객체를 정의할 땐 'Record'를 사용하는 것을 고려해보세요.