Skip to main content

· 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

· 8 min read
Siyeon Son

안녕하세요. 스트릿 드랍에서 백엔드 개발을 하고 있는 손시연 입니다. 이번 포스팅에서는 서버 배포 중 Nginx Proxy Manger를 사용했던 경험을 공유하려고 해요.

개요

SSL 보안을 적용하는 방법은 여러 가지가 있습니다.

  • 웹 서버의 CLI를 사용하는 방법
    • 예: Nginx에서 SSL 인증서를 발급 받고 포트 포워딩하기
  • AWS의 ELB(Elastic Load Balancer)를 사용하는 방법
    • 예: AWS Route53, ACM, ELB를 사용하기
  • Nginx Proxy Manager 사용하는 방법

개인적인 경험을 바탕으로 느낀 장단점은 다음과 같았습니다.

  • 웹 서버의 CLI를 사용하는 방법
    • 장점: 서버의 특정 요구 사항에 맞게 SSL 보안을 정교하게 구성할 수 있다
    • 단점: 초보자가 시도하기에는 초기 설정 및 유지 보수 과정에서 기술적인 지식이 필요했다
  • AWS의 ELB를 사용하는 방법
    • 장점: AWS 콘솔을 통해 몇 번의 클릭만으로 쉽게 구성할 수 있다
    • 단점: AWS에 종속 되고, ELB는 사용량에 따라 요금이 부과된다

스트릿드랍은 두 번째 방법을 채택했습니다. 프로젝트가 확장되면서 EC2와 ELB에서 예상보다 큰 요금이 청구 되었고, AWS 비용 절감 방법을 고민하게 되었습니다. 그 방법 중 하나가 ELB를 사용하지 않고 SSL을 적용하는 것이었습니다.

Nginx의 경우 Let's Encrypt와 같은 인증 기관에서 SSL/TLS 인증서를 발급 받고, Nginx의 설정 파일을 CLI에서 직접 수정하여 HTTPS로 액세스할 수 있도록 설정할 수 있습니다. 그러나 CLI를 사용하여 설정하는 방법은 복잡해서 번거롭다고 느꼈습니다. 설정에 익숙해지면 좋겠지만, Nginx Proxy Manage(NPM)라는 좋은 선택지를 알게 되었고 새로운 기술을 도전해보기로 결정했습니다.

Nginx Proxy Manager란?

nginx가 지원하는 프록시 기능을 편하게 사용할 수 있도록 도와주는 솔루션 입니다. nginx의 프록시 설정들을 추출하여 웹 기반 인터페이스 형태로 보여줍니다.

설치 방법

ip 주소와 도메인은 포스팅을 위해 임의로 생성한 것입니다.

  1. docker-compose로 컨테이너 생성하기
    version: '3'
services:
db:
image: 'jc21/mariadb-aria:latest'
restart: always
environment:
MYSQL_ROOT_PASSWORD: 'abc123'
MYSQL_DATABASE: 'npm'
MYSQL_USER: 'abc'
MYSQL_PASSWORD: '123'
volumes:
- /home/me/docker/npm/mysql:/var/lib/mysql
app:
depends_on:
- db
image: 'jc21/nginx-proxy-manager:latest'
restart: always
ports:
- '80:80'
- '81:81'
- '443:443'
environment:
DB_MYSQL_HOST: "db"
DB_MYSQL_PORT: 3306
DB_MYSQL_USER: "abc"
DB_MYSQL_PASSWORD: "123"
DB_MYSQL_NAME: "npm"
volumes:
- /home/me/docker/npm/data:/data
- /home/me/docker/npm/letsencrypt:/etc/letsencrypt
  1. 접속 후 로그인
  • http://{#ip}:81 로 접근하면 NPM 로그인창이 뜹니다.
    • 혹시 AWS EC2에서 작업 중 로그인 창이 뜨지 않는다면 인스턴스 > 보안 > 인바운드 규칙에서 81번 포트 허용해 주시길 바랍니다.

  • 로그인 시 디폴트 ID/PW는 다음과 같습니다.
    • Email: admin@example.com
    • Password: changeme
  • 로그인 시 Bad Gateway 오류가 발생할 수도 있습니다.

version: '3'
services:
app:
image: 'jc21/nginx-proxy-manager:latest'
ports:
- '80:80'
- '81:81'
- '443:443'
environment:
DB_MYSQL_HOST: "db"
DB_MYSQL_PORT: 3306
DB_MYSQL_USER: "npm"
DB_MYSQL_PASSWORD: "npm"
DB_MYSQL_NAME: "npm"
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
db:
image: 'jc21/mariadb-aria:latest'
environment:
MYSQL_ROOT_PASSWORD: 'npm'
MYSQL_DATABASE: 'npm'
MYSQL_USER: 'npm'
MYSQL_PASSWORD: 'npm'
volumes:
- ./data/mysql:/var/lib/mysql
  • 수정
version: '3'
services:
db:
image: 'jc21/mariadb-aria:latest'
restart: always
environment:
MYSQL_ROOT_PASSWORD: 'abc123'
MYSQL_DATABASE: 'npm'
MYSQL_USER: 'abc'
MYSQL_PASSWORD: '123'
volumes:
- /home/me/docker/npm/mysql:/var/lib/mysql
app:
depends_on:
- db
image: 'jc21/nginx-proxy-manager:latest'
restart: always
ports:
- '80:80'
- '81:81'
- '443:443'
environment:
DB_MYSQL_HOST: "db"
DB_MYSQL_PORT: 3306
DB_MYSQL_USER: "abc"
DB_MYSQL_PASSWORD: "123"
DB_MYSQL_NAME: "npm"
volumes:
- /home/me/docker/npm/data:/data
- /home/me/docker/npm/letsencrypt:/etc/letsencrypt
  1. DNS 등록
  • A 레코드로 서버의 IP 주소 등록합니다. 스트릿드랍의 도메인 서비스는 AWS의 Route53을 사용했습니다.
  • 이제 DNS 주소로도 접근할 수 있습니다.
  1. Proxy Hosts 생성하기
  • Add Proxy Hosts : Proxy Host와 SSL 인증서를 생성합니다.
  • 스프링부트 서버로 연결하기 위해 8080 포트를 포트 포워딩 해줍니다.
  • SSL 인증서도 추가합니다.
  • Proxy Host가 등록되었습니다.
  • SSL 인증서도 자동으로 등록되었습니다.
  • HTTPS 적용 후 502 Bad Gateway 문제가 발생할 수 있습니다. 인스턴스 > 보안 > 인바운드 규칙에서 포트 포워딩 하고 싶은 포트를 열어주시길 주시길 바랍니다.

  • 이제 HTTPS로 접속할 수 있습니다.

후기

Nginx의 설정들을 웹 인터페이스 형태로 모아둔 NPM은 굉장히 편리했습니다. 배포에 리소스를 줄일 수 있었기 때문입니다. 마치 Git CLI를 사용하다가, GUI Tool에서 편하게 사용하는 기분이었습니다. 웹 서버의 리버스 프록시와 같은 간단한 기능을 하기에는 충분한 기술이라고 생각합니다. Nginx 설정을 바로 읽는 게 아니라 별도의 SQLite DB를 동작시키기 때문에, 동기화가 느릴 수 있습니다. 또 Nginx의 설정을 직접 코드로 조작할 수 없어, 자유도는 떨어질 수 있습니다. 그러나 아직까지는 리버스 프록시 이상의 필요성을 못 느꼈고, 현재 NPM에서 제공하는 기능으로도 충분했습니다. 다시 또 NPM을 사용할 것이냐고 물어본다면 "YES!" 라고 말하고 싶습니다.

· 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

· 6 min read
Siyeon Son

안녕하세요. 스트릿 드랍에서 백엔드 개발을 하고 있는 손시연(Son Si-yeon) 입니다.

약 9천만 곡 가까이 되는 음악 데이터 베이스를 어떻게 구축할까요?

저희는 MVP를 빠르게 개발하기 위해, 크롤링보다는 음악 검색 API를 활용했습니다.

음악 검색 API로 Youtube Music, Spotify, Apple Music 등 다양한 후보군이 존재했습니다. 그중 Apple Music API - Search for Catalog Resources를 사용하기로 결정했습니다. 별도의 계정 생성 없이 기존에 생성한 Apple Developer 계정으로 음악 검색 API를 사용할 수 있기 때문입니다.

이번 호스팅에서는 Apple Music API 토큰 발급 방법에 대해 알아보겠습니다.

Apple Music API 토큰 발급받기

공식 문서를 참고하였습니다.

1. Apple Developer 가입

Apple Music API를 사용하기 위해서는 Apple Developer 계정이 필요합니다. 계정 등록비는 US$99(한화 129,000원)으로, 유지 기간은 1년입니다 😭

2. Identifiers 생성

1) Apple Developer Member Center로 이동합니다. 프로그램 리소스 > Certificates, Identifiers & Profiles > Identifiers 메뉴를 선택합니다.

2) 새로운 Identifiers를 생성합니다.

3) Media IDs를 선택합니다.

4) Description과 Identifier를 입력하고, MusicKit을 선택합니다.

5) Media IDs Identifier가 생성된 것을 확인할 수 있습니다.

3. Keys 생성

1) 프로그램 리소스 > Certificates, Identifiers & Profiles > Keys 메뉴를 선택합니다. 새로운 Keys를 생성합니다.

2) Media Services(MusicKit, ShazamKit)를 선택합니다.

3) 앞에서 생성한 Identifiers를 선택합니다.

4) Key(.p8 파일)를 생성하였습니다. 발급된 인증 키는 1회만 다운로드 가능하니, 안전한 위치에 저장합니다. Key ID와 Team ID를 확인할 수 있습니다.

5) View Key Details에서도 Key ID와 Team ID를 확인할 수 있습니다.

6) 생성된 Key를 다운로드하여 확인합니다.

4. JWT 형식으로 토큰 생성

Apple Music API 공식 문서를 확인해 보면 Apple Music API는 JWT(JSON Web Token) 사양을 지원합니다.

앞서 생성한 Key, Key ID, Team ID를 활용하여 ES256 알고리즘으로 서명된 개발자 토큰을 생성해 보겠습니다.

1) python JWT 라이브러리 설치합니다.

sudo pip install pyjwt

2) 암호화 패키지를 설치합니다.

sudo pip install cryptography

3) 본인의 Key, Key ID, Team ID를 수정합니다.

import datetime
import jwt

key = """-----BEGIN PRIVATE KEY-----
ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123
-----END PRIVATE KEY-----"""
keyId = '0123456789'
teamId = '9876543210'
alg = 'ES256'

time_now = datetime.datetime.now()
time_expired = datetime.datetime.now() + datetime.timedelta(hours=12)

headers = {
'alg': alg,
'kid': keyId
}
payload = {
'iss': teamId,
'exp': int(time_expired.strftime("%s")),
'iat': int(time_now.strftime("%s"))
}

if __name__ == '__main__':
token = jwt.encode(payload, key, algorithm=alg, headers=headers)

print(token)

4) 파이썬 코드를 실행하면 JWT 토큰을 얻을 수 있습니다. Apple Music API 해더에 토큰을 추가하여 API 요청을 보내면 됩니다!

curl -X 'GET' \
'https://api.music.apple.com/v1/catalog/kr/search?types=songs&limit=10&term=apple' \
-H 'accept: */*' \
-H 'Authorization: Bearer ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123'

스트릿 드랍에서는

스트릿 드랍 검색 서버에 요청을 보내면

curl -X 'GET' \
'https://search.street-drop.com/music?keyword=apple' \
-H 'accept: */*'

내부 로직을 거쳐, Apple Music API - Search for Catalog Resources에 다음과 같은 요청을 보내게 됩니다.

curl -X 'GET' \
'https://api.music.apple.com/v1/catalog/kr/search?types=songs&limit=10&term=apple' \
-H 'accept: */*' \
-H 'Authorization: Bearer ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123'

내부 코드는 다음과 같이 작성하였습니다. 사용자가 입력한 keyword를 토대로, iTunes Store 한국 지역(kr), 노래들(songs), 10개(limits)를 가져오는 로직입니다. 자세한 소스코드는 스트릿 드랍 서버 깃허브을 참고해 주세요.

@Cacheable(value = "music", key = "#keyword")
public MusicInfoListResponseDto searchMusic(String keyword) {
String appleMusicApiKey = appleMusicConfig.getAppleMusicApiKey();
WebClient webClient = WebClient.builder().baseUrl("https://api.music.apple.com").build();

Mono<AppleMusicResponseDto> response = webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/v1/catalog/kr/search")
.queryParam("types", "songs")
.queryParam("limit", 10)
.queryParam("term", keyword)
.build())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + appleMusicApiKey)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.onStatus(httpStatus -> httpStatus.is4xxClientError() || httpStatus.is5xxServerError(),
clientResponse ->
{
if (clientResponse.statusCode() == HttpStatus.UNAUTHORIZED) {
eventPublisher.publishEvent(new AppleMusicApiKeyRefreshEvent());
throw new RuntimeException("error");
}
throw new RuntimeException("error");
}
)
.bodyToMono(AppleMusicResponseDto.class);
return MusicInfoListResponseDto.ofAppleMusicResponseDto(response.block());
}

· 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'를 사용하는 것을 고려해보세요.

· 14 min read
Young Yun

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

이번 포스팅에서는 스프링의 @Transactional 애노테이션의 적절한 사용 방법에 대해 좋은 글이 있어서 해당 글을 번역하고 추가적인 설명을 작성해보았습니다.

해당 포스팅에서 참조한 원문은 여기를 참조하실 수 있습니다.

우선 Spring에서 사용하는 @Transaction 에 대해 알아보겠습니다.

@Transactional

@Transactional은 스프링에서 제공하는 애노테이션으로, 트랜잭션 경계를 정의하는데 사용됩니다. 트랜잭션은 여러 데이터베이스 연산을 한 단위로 묶어, 모든 연산이 성공적으로 완료되거나 아무것도 실행되지 않도록 하는 것 (All or Nothing)을 의미합니다.

스프링은 1.0 버전부터 개발자가 트랜잭션 경계를 선언적으로 정의할 수 있도록 AOP 기반의 트랜잭션 관리를 지원했습니다.

그 직후 1.2 버전에서 Spring은 @Transactional 애노테이션을 지원하여 비즈니스 작업 단위의 트랜잭션 경계를 더욱 쉽게 설정할 수 있게 되었습니다.

@Transaction 애노테이션은 다음과 같은 속성을 제공합니다.

  • value and transactionManager: 애노테이션이 붙은 블록의 트랜잭션을 처리할 때 사용될 TransactionManager 참조를 제공하는데 사용됩니다.

  • propagation: 트랜잭션 경계가 애노테이션이 붙은 블록 내에서 직접 또는 간접적으로 호출되는 다른 메서드로 전파되는 방법을 정의합니다. 기본은 REQUIRED이며 트랜잭션이 이미 사용 가능하지 않은 경우 트랜잭션이 시작됨을 의미합니다. 그렇지 않으면 현재 실행 중인 트랜잭션이 사용됩니다.

  • timeout and timeoutString: TransactionTimedOutException을 발생시키기 전에 현재 메서드를 실행할 수 있는 최대 시간(초)을 정의합니다.

  • readOnly: 현재 트랜잭션이 읽기 전용인지 읽기/쓰기인지를 정의합니다.

  • rollbackFor and rollbackForClassName: 현재 트랜잭션이 롤백될 하나 이상의 Throwable 클래스를 정의합니다. 기본적으로 트랜잭션은 RuntimeException 또는 Error가 발생한 경우 롤백되지만 checkedException이 발생한 경우에는 롤백되지 않습니다.

  • noRollbackFor and noRollbackForClassName: 현재 트랜잭션이 롤백되지 않을 하나 이상의 Throwable 클래스를 정의 합니다.

@Transactional의 동작 원리

스프링의 @Transactional은 AOP(Aspect-Oriented Programming) 기반으로 동작합니다. @Transactional이 선언된 메서드가 호출되면, 스프링은 해당 메서드를 프록시(Proxy)로 감싸 해당 트랜잭션의 경계를 설정합니다. 메서드의 실행 전에 트랜잭션을 시작하고, 메서드 실행 후에 트랜잭션을 커밋하거나 롤백합니다. 이때, 런타임 예외가 발생하면 트랜잭션은 롤백 됩니다.

@Transactional은 어떤 계층에 속할까?

@Transactional 트랜잭션 경계를 정의하는 것은 서비스 계층(Service layer)의 책임이므로 @Transactional 애노테이션은 서비스 계층에 속합니다. 웹 계층(Presentation layer)에서는 사용하지 말아야 합니다. 이렇게 하면 데이터베이스 트랜잭션 응답 시간이 증가하고, 데이터베이스 트랜잭션 오류(예: 일관성, 교착 상태, 잠금 획득, 낙관적 잠금)에 대해 올바른 오류 메시지를 제공하기가 더 어려워질 수 있기 때문입니다. DAO(Data Access Object) 또는 Repository 계층은 애플리케이션 수준의 트랜잭션을 필요로 하지만 이 트랜잭션은 서비스 계층에서 전파되어야 합니다.

@Transactional의 적절한 위치

  • 클래스 레벨에서의 선언
    • 해당 클래스의 모든 public 메서드에 트랜잭션 처리가 적용됩니다.
    • 클래스의 모든 메서드에 동일한 트랜잭션 속성을 적용할 때 유용합니다.
  • 메서드 레벨에서의 선언
    • 해당 메서드에서만 트랜잭션 처리가 적용됩니다.
    • 클래스 내에서 특정 메서드에만 트랜잭션을 적용하거나 메서드마다 다른 트랜잭션 속성을 적용하고자 할 때 유용합니다.

@Transactional을 사용하는 가장 좋은 방법

서비스 계층에서는 데이터베이스 관련(database-related) 서비스와 비데이터베이스(non-database-related) 관련 서비스 모두를 포함할 수 있습니다. 주어진 비즈니스 사용 사례가 둘을 혼합해야 할 경우, 예를 들어 주어진 문장을 파싱하거나 보고서를 작성하고 데이터베이스에 일부 결과를 저장해야 하는 경우, 데이터베이스 트랜잭션이 가능한 늦게 시작되는 것이 좋습니다.

이러한 이유로, 다음과 같은 RevolutStatementService와 같은 비트랜잭션(non-transactional)인 게이트웨이 서비스를 가질 수 있습니다.

@Service
public class RevolutStatementService {

@Transactional(propagation = Propagation.NEVER) // 1
public TradeGainReport processRevolutStocksStatement(
MultipartFile inputFile,
ReportGenerationSettings reportGenerationSettings) {
return processRevolutStatement(
inputFile,
reportGenerationSettings,
stocksStatementParser
);
}

private TradeGainReport processRevolutStatement(
MultipartFile inputFile,
ReportGenerationSettings reportGenerationSettings,
StatementParser statementParser
) {
ReportType reportType = reportGenerationSettings.getReportType();
String statementFileName = inputFile.getOriginalFilename();
long statementFileSize = inputFile.getSize();

StatementOperationModel statementModel = statementParser.parse( // 1-1
inputFile,
reportGenerationSettings.getFxCurrency()
);
int statementChecksum = statementModel.getStatementChecksum();
TradeGainReport report = generateReport(statementModel); // 1-2

if(!operationService.addStatementReportOperation( // 2
statementFileName,
statementFileSize,
statementChecksum,
reportType.toOperationType()
)) {
triggerInsufficientCreditsFailure(report);
}

return report;
}
}
  1. processRevolutStocksStatement 메서드는 트랜잭션을 처리하지 않으며, 따라서 이 메서드가 활성 트랜잭션에서 호출되지 않도록 Propagation.NEVER전략을 사용할 수 있습니다.
  • 따라서 statementParser.parse() 메서드 및 generateReport() 메서드는 데이터베이스 연결이 필요 없고, 단순히 application-level의 처리만을 원하기 때문에 넌-트랜잭션 컨텍스트에서 실행됩니다.
  1. operationService.addStatementReportOperation만 트랜잭션 컨텍스트에서 실행해야 합니다. 이러한 이유로 addSatementReportOperation은 @Transactional 애노테이션을 사용합니다. addSatementReportOperation이 기본 격리 수준을 재정의하고 SERIALIZABLE 데이터베이스 트랜잭션에서 이 메서드를 실행한다는 것에 유의해야 합니다.
@Service
@Transactional(readOnly = true)
public class OperationService {

@Transactional(isolation = Isolation.SERIALIZABLE)
public boolean addStatementReportOperation(
String statementFileName,
long statementFileSize,
int statementChecksum,
OperationType reportType) {

}
}

또다른 주목할만한 사항은 클래스가 @Transactional(readOnly = true)로 애노테이션이 달려 있어서 기본적으로 모든 서비스 메서드는 이 설정을 사용하고 메서드가 자체 정의를 사용하여 트랜잭션 설정을 재정의하지 않는 한 읽기 전용 트랜잭션에서 실행됩니다. 트랜잭션 서비스의 경우 클래스에서 readOnly 속성을 true로 설정하고, 데이터베이스의 쓰기의 사용이 필요한 서비스 메서드에서 트랜잭션을 오버라이딩해서 사용하는 것이 좋습니다.

예를 들어 UserServicesms 동일한 패턴을 사용합니다.

@Service
@Transactional(readOnly = true)
public class UserService implements UserDetailsService {

@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
}

@Transactional
public void createUser(User user) {
}
}

loadUserByname은 읽기 전용 트랜잭션을 사용하며, Hibernate를 사용하고 있기 때문에 Spring은 읽기 전용 최적화를 수행합니다. 반면 createUser는 데이터베이스에 기록해야 합니다. 따라서 @Transactional 애노테이션에서 제공하는 기본 설정인 readOnly = false로 readOnly 속성값을 재정의하여 트랜잭션을 읽기-쓰기로 만듭니다.

읽기-쓰기와 읽기 전용 메서드를 분리하는 또 다른 큰 이점은 이 문서에서 설형한 대로 다른 데이터베이스 노드로 라우팅할 수 있다는 것입니다. 이렇게 하면 복제 노드의 수를 늘려 읽기 전용 트래픽을 확장할 수 있습니다.

결론

마지막으로 스프링의 @Transactional 애노테이션을 효율적으로 사용하기 위한 몇 가지 최적화 방법을 살펴보겠습니다.

1. 적절한 위치에 선언하기

@Transactional은 주로 서비스 계층에서 사용됩니다. DAO 또는 Repository 계층에서는 트랜잭션을 시작하지 않는 것이 일반적입니다. 컨트롤러 계층에서는 사용하지 않는 것이 좋습니다.

2. readOnly 속성 사용

데이터베이스에서 데이터만 읽을 때는 'readOnly = true' 속성을 사용하여 트랜잭션을 최적화할 수 있습니다. 이를 통해 불필요한 데이터베이스 write lock을 피할 수 있습니다.

3. 명시적인 트랜잭션 전파 정의

'propagation' 속성을 사용하여 트랜잭션의 전파 방식을 명시적으로 정의할 수 있습니다. 예를 들어, 상위 트랜잭션 내부에서 메서드를 호출할 때 새로운 트랜잭션을 시작하려면 Propagation.REQUIRES_NEW를 사용하세요.

4. 예외 처리에 따른 롤백 정의

rollbackFornoRollbackFor 속성을 사용하여 특정 예외가 발생했을 때 트랜잭션을 롤백할지 여부를 정의할 수 있습니다.

5. 클래스 레벨과 메서드 레벨 애노테이션 사용

클래스 레벨에 @Transactional 을 선언하면 해당 클래스의 모든 메서드에 트랜잭션 관리가 적용됩니다. 하지만 일부 메서드에 대한 트랜잭션 설정을 재정의하려면 해당 메서드에 @Transactional 을 재선언하면 됩니다.

6. 트랜잭션 격리 수준 정의

isolation 속성을 사용하여 트랜잭션의 격리 수준을 정의할 수 있습니다. 이를 통해 특정 트랜잭션 내에서 다른 트랜잭션에서 수행하는 작업의 가시성을 조정할 수 있습니다.

7. 트랜잭션 타임아웃 설정

timeout 속성을 사용하여 트랜잭션이 지정된 시간 이내에 완료되지 않을 경우 롤백되도록 설정할 수 있습니다.

8. 여러 트랜잭션 관리자 사용

transactionManager 속성을 사용하여 여러 데이터 소스를 가진 애플리케이션에서 특정 트랜잭션 관리자를 지정할 수 있습니다.

위의 방법들을 통해 Spring의 @Transactional 애노테이션을 더 효율적으로 사용하여 애플리케이션의 트랜잭션 관리를 최적화할 수 있습니다.

· One min read
Seonghun Jung

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

이번 포스팅에서는 스트릿 드랍 문서화 작업을 하면서 도입하게된 Docusaurus에 대해 간단하게 소개하고자 합니다. Docusaurus는 페이스북 그룹에서 만든 정적 사이트 생성 라이브러리 입니다. 기본적으로 버전 관리, 다국어 지원, 검색 기능, 테마 커스터마이징 등의 다양한 기능을 제공하고 있습니다.