Skip to content

Commit

Permalink
Feature: 정답 제출 POST API 멱등성 처리 (#25)
Browse files Browse the repository at this point in the history
* feat: 정답 제출 API 멱등성 구현

- 커스텀 필터를 통한 서블릿 응답 래핑
- 인터셉터와 캐시를 이용한 멱등성 DB 구현

* feat: 키가 존재하지 않는 경우 스킵

* feat: 인터셉터에서 반환하는 형식 및 로그 수정

* refactor: 멱등성 관련 클래스 패키지 분리
  • Loading branch information
Youthhing authored Jun 18, 2024
1 parent 4ce3d52 commit 20641f4
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 2 deletions.
22 changes: 22 additions & 0 deletions src/main/java/org/cotato/csquiz/common/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.cotato.csquiz.common.config;

import lombok.RequiredArgsConstructor;
import org.cotato.csquiz.common.idempotency.IdempotencyInterceptor;
import org.cotato.csquiz.common.idempotency.IdempotencyRedisRepository;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

private final IdempotencyRedisRepository idempotencyRedisRepository;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new IdempotencyInterceptor(idempotencyRedisRepository))
.addPathPatterns("/v1/api/record/reply")
.order(1);
}
}
6 changes: 4 additions & 2 deletions src/main/java/org/cotato/csquiz/common/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ public enum ErrorCode {
CONTENT_IS_ALREADY_ANSWER(HttpStatus.BAD_REQUEST, "Q-303", "이미 정답인 답을 추가했습니다"),
QUIZ_ACCESS_DENIED(HttpStatus.BAD_REQUEST, "Q-401", "해당 퀴즈는 아직 접근할 수 없습니다."),
QUIZ_TYPE_NOT_MATCH(HttpStatus.BAD_REQUEST, "Q-402", "주관식 정답만 추가 가능합니다."),


KING_MEMBER_EXIST(HttpStatus.CONFLICT, "K-301", "이미 킹킹 멤버가 존재합니다"),

SUBJECT_INVALID(HttpStatus.BAD_REQUEST, "E-000", "교육 주제는 NULL이거나 비어있을 수 없습니다."),

KING_MEMBER_EXIST(HttpStatus.CONFLICT, "K-301", "이미 킹킹 멤버가 존재합니다"),
PROCESSING(HttpStatus.CONFLICT, "D-999", "해당 키의 요청은 아직 처리 중 입니다."),

ALREADY_REPLY_CORRECT(HttpStatus.BAD_REQUEST, "R-301", "해당 사용자는 이미 정답 처리되었습니다."),

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.cotato.csquiz.common.idempotency;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingResponseWrapper;

@Component
public class CustomServletWrappingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

final ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
filterChain.doFilter(request, responseWrapper);

responseWrapper.copyBodyToResponse();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package org.cotato.csquiz.common.idempotency;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.cotato.csquiz.common.error.ErrorCode;
import org.cotato.csquiz.common.error.response.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.util.ContentCachingResponseWrapper;

@Slf4j
@Component
@RequiredArgsConstructor
public class IdempotencyInterceptor implements HandlerInterceptor {

private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
private final IdempotencyRedisRepository idempotencyRedisRepository;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String idempotencyKey = request.getHeader(IDEMPOTENCY_HEADER);
if (idempotencyKey == null) {
return true;
}
ObjectMapper objectMapper = new ObjectMapper();

if (idempotencyRedisRepository.hasSucceedResult(idempotencyKey)) {
response.getWriter()
.write(objectMapper.writeValueAsString(
idempotencyRedisRepository.getSucceedResponse(idempotencyKey)));
log.info("[멱등성 DB에 데이터 존재]");
return false;
}

if (idempotencyRedisRepository.isProcessing(idempotencyKey)) {
response.setStatus(HttpStatus.CONFLICT.value());
response.setContentType("application/json; charset=UTF-8");
response.getWriter()
.write(objectMapper.writeValueAsString(ErrorResponse.of(ErrorCode.PROCESSING, request)));
log.warn("[요청은 왔지만 아직 처리 중]");
return false;
}

// 캐시에 결과가 존재하지 않으면 -> 처리중이란 값을 넣고 컨트롤러를 실행함
idempotencyRedisRepository.saveStatusProcessing(idempotencyKey);
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
String idempotencyKey = request.getHeader(IDEMPOTENCY_HEADER);
if (idempotencyKey == null) {
return;
}
ObjectMapper objectMapper = new ObjectMapper();

final ContentCachingResponseWrapper responseWrapper = (ContentCachingResponseWrapper) response;

idempotencyRedisRepository.saveSucceedResult(idempotencyKey,
objectMapper.readTree(responseWrapper.getContentAsByteArray()));

responseWrapper.copyBodyToResponse();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.cotato.csquiz.common.idempotency;

import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

@Slf4j
@Repository
@RequiredArgsConstructor
public class IdempotencyRedisRepository {

private static final String KEY_PREFIX = "$Idempotency ";
private static final int RESPONSE_EXPIRATION = 30;

private final RedisTemplate<String, Object> redisTemplate;


public boolean hasSucceedResult(String idempotencyKey) {
String key = KEY_PREFIX + idempotencyKey;
IdempotencyResponse response = (IdempotencyResponse) redisTemplate.opsForValue().get(key);

if (response == null) {
return false;
}
return response.isSucceed();
}

public void saveStatusProcessing(String idempotencyKey) {
String key = KEY_PREFIX + idempotencyKey;
IdempotencyResponse response = IdempotencyResponse.builder()
.processStatus(ProcessStatus.PROCESSING)
.build();

redisTemplate.opsForValue().set(
key,
response,
RESPONSE_EXPIRATION,
TimeUnit.MINUTES
);
}

public boolean isProcessing(String idempotencyKey) {
String key = KEY_PREFIX + idempotencyKey;
IdempotencyResponse response = (IdempotencyResponse) redisTemplate.opsForValue().get(key);
log.info("[처리 중]");
if (response == null) {
return false;
}
return response.isProcessing();
}

public Object getSucceedResponse(String idempotencyKey) {
String key = KEY_PREFIX + idempotencyKey;
IdempotencyResponse savedData = (IdempotencyResponse) redisTemplate.opsForValue().get(key);
return savedData.getResult();
}

public void saveSucceedResult(String idempotencyKey, Object result) {
final String key = KEY_PREFIX + idempotencyKey;

IdempotencyResponse response = IdempotencyResponse.builder()
.processStatus(ProcessStatus.SUCCESS)
.result(result)
.build();

redisTemplate.opsForValue().set(
key,
response,
RESPONSE_EXPIRATION,
TimeUnit.MINUTES
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.cotato.csquiz.common.idempotency;

import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

@Builder
@AllArgsConstructor
@Data
public class IdempotencyResponse implements Serializable {

private ProcessStatus processStatus;
private Object result;

public boolean isProcessing() {
return this.processStatus == ProcessStatus.PROCESSING;
}

public boolean isSucceed() {
return this.processStatus == ProcessStatus.SUCCESS;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.cotato.csquiz.common.idempotency;

import lombok.AllArgsConstructor;
import org.cotato.csquiz.common.error.ErrorCode;

@AllArgsConstructor
public enum ProcessStatus {


PROCESSING("현재 해당 요청 처리 중"),
SUCCESS("요청 완료")

;

private final String description;
}

0 comments on commit 20641f4

Please sign in to comment.