원문의 글이 너무나도 이해하기 쉽게 쓰여졌다고 생각합니다. 그래서 내용은 간단히 살펴보고 수도코드로 적어준 예제를 활용해 간단한 멱등성 있는 서버를 만들어보기로 했습니다.
멱등성
이라고 합니다. 안전성
도 있습니다.리소스에 변화를 일으키기 때문에
안전한 메서드는 아닙니다.처음 요청과 같은 값을 반환하고, 서버 상태(DB)에도 영향을 미치지 않아야
합니다.실제로 처리하지 않고 첫 요청과 동일한 응답
을 반환합니다.저는 멱등하지 않은 POST요청으로 테스트를 진행해 볼 예정입니다.
cnt라는 변수와, cnt를 10000 증가시키는 addCnt라는 메서드를 가지는 Domain이 있습니다.
@Entity
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Domain {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Integer id;
public Integer cnt;
public void addCnt(){
this.cnt += 10000;
}
}
요청한 Id값의 데이터가 존재하지 않으면 처음 생성할 때 cnt값을 그대로 반환하고, 데이터가 존재하면 addCnt로 cnt값을 증가시킨 후 이를 반환합니다.
@Transactional
@Service
@RequiredArgsConstructor
public class ServiceLogic {
private final Repository repository;
public Integer myLogic(Dto dto){
int id = dto.getId();
// 이미 동일한 id의 데이터가 존재하면
if(repository.findById(id).isPresent()){
Domain data = repository.findById(id).get();
data.addCnt(); // cnt값을 증가시킴
return data.cnt;
}else{ // 아직 해당 id의 데이터가 존재하지 않으면
Domain data = Domain.builder().cnt(1).build();
repository.save(data);
return data.cnt;
}
}
}
이 로직은 멱등성과 관련된 처리를 하지 않으면 동일한 Id로 POST요청을 보낼 경우 계속해서 addCnt가 실행돼 해당 데이터의 cnt값이 증가해 멱등성이 보장되지 않습니다. 이제 이 API를 수정해 보겠습니다.
우선 조금 더 단순한 예제로 시작해 봤습니다. 이 예제는 별도의 멱등키 서버나 멱등키의 만료기간을 가지지 않습니다. 메모리에서 Map으로 구현된 멱등키 값에 해당 요청에 대한 응답이 있으면 이를 그대로 반환합니다.
@RestController
@RequiredArgsConstructor
public class basicController {
private final ServiceLogic service;
Map<String,Integer> idempotentDB = new HashMap<>();
@PostMapping("/idempotent")
public Integer idempotent(@RequestHeader("idempotentId") String idempotentId, @RequestBody Dto dto){
if(idempotentDB.containsKey(idempotentId)){
return idempotentDB.get(idempotentId);
}else {
Integer returnVal = service.myLogic(dto);
idempotentDB.put(idempotentId, returnVal);
return returnVal;
}
}
@PostMapping("/nonIdempotent")
public Integer nonIdempotent(@RequestBody Dto dto){
return service.myLogic(dto);
}
}
idempotentDB
라는 멱등키 보관소를 메모리 안에 구현했습니다./idempotent
요청은 멱등성을 보장하는 경우, /nonIdempotent
요청은 멱등성을 보장하지 않는 경우입니다./idempotent
요청은 idempotentDB
에 존재하면 service로직이 아니라 idempotentDB
에 담겨 있는 값을 그대로 반환합니다.idempotentDB
이 존재하지 않으면 service로직을 실행한 결과값을 idempotentDB
에 담고 그 값을 반환합니다.아주 간단하게 멱등API를 만들어 봤습니다. 하지만 처음에 설명했듯이 지금은 만료 기간을 설정하지 않아 한번 들어온 요청은 무한히 갱신되지 않는다는 단점이 있습니다.
글을 읽으면서 별도의 멱등키 DB로는 Redis가 가장 적절하겠다는 생각을 했습니다. 기존 서버와 분리된 별도의 서버, key-value형태의 자료구조, 만료기간 설정 등등의 조건이 적합하다고 판단했기 때문입니다. 이번에는 Redis로 만든 멱등키 DB를 활용해 보겠습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/redis")
public class withRedisController {
private final ServiceLogic serviceLogic;
private final RedisService redisService;
@PostMapping("/idempotent")
public Integer idempotent(@RequestHeader("idempotentId") String idempotentId, @RequestBody Dto dto){
if(redisService.isExists(idempotentId)){
return Integer.parseInt(redisService.getValues(idempotentId));
}else {
Integer returnVal = serviceLogic.myLogic(dto);
redisService.setValues(idempotentId, Integer.toString(returnVal), Duration.ofSeconds(3));
return returnVal;
}
}
@PostMapping("/nonIdempotent")
public Integer nonIdempotent(@RequestBody Dto dto){
return serviceLogic.myLogic(dto);
}
}
idempotentDB.containsKey(idempotentId)
-> redisService.isExists(idempotentId)
idempotentDB.put(idempotentId, returnVal);
-> redisService.setValues(idempotentId, Integer.toString(returnVal), Duration.ofSeconds(3))
Duration이라는 만료기간(3초)이 추가되었습니다.idempotentDB.get(idempotentId)
-> redisService.getValues(idempotentId))
Redis 설정은 다음과 같습니다.
RedisConfig
@Configuration
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
RedisService
@Service
@RequiredArgsConstructor
public class RedisService {
private final RedisTemplate<String, String> redisTemplate;
public void setValues(String key, String value, Duration duration) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
values.set(key, value, duration);
}
public String getValues(String key) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
return values.get(key);
}
public boolean isExists(String key){
return redisTemplate.hasKey(key);
}
}
postman으로 post요청으로 Body에 { "id": 1 }
를, Header에 {idempotentId: abc}
를 담아 http://localhost:8080/redis/idempotent
에 요청하면 테스트를 진행해볼 수 있습니다.
3초 안에는 동일한 요청을 보내도 cnt가 증가하지 않습니다.
전체 코드는 여기에서 다운받으실 수 있습니다.