반응형
개요
프로젝트를 진행하던 중 게시글의 요청이 연속으로 두 번 오면 게시글이 2개 작성되서 이전에 만들어둔 로직과 충돌하는 일이 발생했다.
- 기존의 로직은 현재 진행중인 게시글이 있다면 더이상 게시글이 작성되지 않도록 했지만 연속으로 들어오는 요청을 방지할 방안이 없었다.
즉, 2개의 요청이 들어왔을 때, 진행중인 게시글의 존재 여부를 체크를 2개의 요청이 모두 넘어가면 2개의 게시글 모두 작성된다는 문제가 발생한다.
문제의 코드
@Override
public WantedResponseDTO createWanted(WantedDTO wantedDTO, MultipartFile mainImage, MultipartFile signature) throws IOException {
UserEntity user = this.userRepository.findByEmail(wantedDTO.getUserEmail())
.orElseThrow(() -> new CustomException(ErrorCode.EMAIL_NOT_FOUND));
// 존재 여부 검사
if(wantedRepository.existsByUserAndStatus(user, WantedStatus.PROGRESS))
throw new CustomException(ErrorCode.WANTED_ALREADY_PROGRESS);
String mainImageUrl = this.imageService.ImageUpload(mainImage);
String signautreUrl = this.imageService.ImageUpload(signature);
WantedEntity wanted = wantedDTO
.toEntity(user, mainImageUrl, signautreUrl, WantedStatus.PROGRESS);
// 게시글 작성
return this.wantedRepository.save(wanted)
.toResponseDTO();
}
- 위의 코드에서
존재 여부 검사
와게시글 작성
사이가 겹치면서 문제가 발생했다.
본론
이러한 문제를 해결하기 위해 중복 요청 방지에 대해서 찾아보았고 멱등성
을 제공해주어야 한다는 것을 알게되었다.
멱등성은 하나의 요청을 수행했을 때, 여러번 수행하더라도 같은 결과를 보장해주어야 한다.
- 나의 상황의 경우 여러번 동시에 POST 요청이 오더라도 일정 시간동안은 같은 결과를 뱉어야 한다.
- 즉, 첫 번째 요청을 제외한 두 번째, 세 번째 요청들은 작업이 수행되어서는 안된다.
따라서 나는 Redis를 도입해서 현재 요청에 대해서 멱등성을 보장해주도록 만들었다.
Redis 적용
- build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
- application.yml
spring:
data:
redis:
port: 6379
host: localhost
- 블로그에서 spring.redis.port, spring.redis.host를 사용하라고 되어있었지만 deprecated되어 있었다.
- RedisConfig
package dev.changuii.project.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value(("${spring.data.redis.port}"))
private String port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(Integer.parseInt(port));
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
}
- host와 port를 따로 설정해준 이유는 배포 환경에서 spring이 redis를 찾지 못했다..
IdempotentService
package dev.changuii.project.service.impl;
import dev.changuii.project.enums.ErrorCode;
import dev.changuii.project.exception.CustomException;
import dev.changuii.project.service.IdempotentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Service
public class IdempotentServiceImpl implements IdempotentService {
private final String VALUE = "IDEMPOTENT";
private final StringRedisTemplate redisTemplate;
private final Integer TIME_LIMIT = 3;
public IdempotentServiceImpl(
@Autowired StringRedisTemplate redisTemplate
){
this.redisTemplate=redisTemplate;
}
@Override
public void isValidIdempotent(List<String> keyElement) {
String idempotentKey = this.compactKey(keyElement);
Boolean isSUCCESS = this.redisTemplate
.opsForValue()
.setIfAbsent(idempotentKey, VALUE, TIME_LIMIT, TimeUnit.SECONDS);
if(!isSUCCESS)
throw new CustomException(ErrorCode.DUPLICATION_REQUESST);
}
private String compactKey(List<String> keyElement){
return keyElement.stream()
.collect(Collectors.joining());
}
}
멱등성을 보장해주기 위해서 Redis에 key값을 담고 해당 값의 존재 여부를 판별해서 현재 요청이 진행중인지 아닌지 체크했다.
- KeyElement : 멱등키를 만들기 위한 String 리스트이다. 이 문자열들을 합쳐서 멱등키를 만들어주었다.
- 이렇게 한 이유는 사실 앞에서 하나의 로직만 설명했지만 다른 여러 등록과 수정작업에도 멱등성을 처리해주기 위해서 이렇게 구현하였다.
- setIfAbsent() : 이 메서드는 해당 키가 redis에 존재하면 false를 존재하지 않으면 true를 반환한다.
- 따라서 해당 메서드가 true이면 로직을 수행하도록 만들었고 false이면, 현재 해당 로직이 수행 중 일수 있으므로 예외를 던졌다.
- TIME_LIMIT : 여기서 설정한 시간동안 redis에서 값이 유지되고 해당 시간이 지나면 redis에서 자동으로 값을 삭제한다.
- 3초로 설정했고 3초 뒤에는 해당 값이 삭제되면서 다시 요청을 수행할 수 있다.
적용
@PostMapping("/image")
public ResponseEntity<WantedResponseDTO> createWantedWithImage(
@RequestPart("dto") WantedDTO wantedDTO,
@RequestPart("main") MultipartFile mainImage,
@RequestPart("signature") MultipartFile signature
) throws IOException {
this.idempotentService.isValidIdempotent(Arrays.asList(new String[]{NAME, "POST", wantedDTO.getUserEmail()}));
return ResponseEntity.status(HttpStatus.CREATED)
.body(this.wantedService.createWanted(wantedDTO, mainImage, signature));
}
- 로직을 수행하기 전에 현재 Controller의 이름, 요청 메서드, 유저의 이메일로 구분하여 멱등키를 만들어주었다.
- 물론 멱등키를 만들기위한 요소들은 얼마든지 바꿔서 사용할 수 있다.
결론
- 첫 요청을 수행한 후 성공적으로 중복된 요청을 걸러내는 것을 확인했다.
반응형
'Spring' 카테고리의 다른 글
[Spring Boot] 스프링 동시성 제어하기 (Java Synchronized keyword) (1) | 2024.04.25 |
---|---|
[Spring Boot] Spring Boot에서 JPA QueryDSL 적용 방법 (0) | 2024.04.17 |
[Spring Boot] 도커 컨테이너에 환경에서 application.yml 민감한 정보 환경변수로 묶어내기 (0) | 2024.04.04 |
[Spring] 스프링 프로젝트에 카카오 로그인, 회원가입 구현 - (2) (0) | 2024.03.31 |
[Spring] 스프링 프로젝트에 카카오 로그인, 회원가입 구현 - (1) (0) | 2024.03.31 |