토스페이먼츠 - 멱등성이 뭔가요?

최창효·2023년 4월 27일
0

기업_IT블로그_리딩

목록 보기
10/14
post-thumbnail

들어가기 전에

  • 이 글은 토스페이먼츠 블로그의 멱등성이 뭔가요?를 읽고 제 생각을 덧붙여 작성한 글입니다.
  • 모든 이미지 및 내용 출처 역시 토스페이먼츠 블로그의 멱등성이 뭔가요?에서 비롯된 것임을 밝힙니다.

서론

원문의 글이 너무나도 이해하기 쉽게 쓰여졌다고 생각합니다. 그래서 내용은 간단히 살펴보고 수도코드로 적어준 예제를 활용해 간단한 멱등성 있는 서버를 만들어보기로 했습니다.

내용 정리

  • 여러 번 수행해도 그 결과가 동일(단순히 동일한 response를 반환하는 게 아니라 서버 resource의 상태가 동일)한 것을 멱등성이라고 합니다.
  • HTTP 메서드에도 멱등성이 있습니다.
    예를 들어 GET은 여러 번 호출해도 같은 결과가 나오고, 리소스에 변화를 일으키지 않기 때문에 멱등성이 보장된 메서드입니다. 사실 대부분의 HTTP 메서드는 멱등합니다. 보통 POST와 PATCH(사용에 따라 멱등하기도 함)가 멱등하지 않습니다.
  • HTTP 메서드의 속성에는 멱등성 말고 안전성도 있습니다.
    안전성이 보장된 메서드는 리소스를 변경하지 않습니다.
    안전성이 보장된 메서드는 자동으로 멱등성도 보장하지만, 멱등성이 보장된 메서드가 항상 안전성을 보장하는 건 아닙니다.
    예를 들어 PUT과 DELETE는 멱등한 메서드지만, 리소스에 변화를 일으키기 때문에 안전한 메서드는 아닙니다.
  • API 관점에서 멱등한 API란 여러 번 요청해도 처음 요청과 같은 값을 반환하고, 서버 상태(DB)에도 영향을 미치지 않아야 합니다.
  • 멱등성을 보장하려면 API 요청에 멱등키를 담아서 보내면 됩니다.
    이전 요청과 동일한 멱등키를 가진 요청이라면 서버는 이를 중복으로 판단해 실제로 처리하지 않고 첫 요청과 동일한 응답을 반환합니다.
    IETF에서는 멱등키는 요청의 헤더에 담아보내는 방법을 표준으로 제안하고 있습니다.
  • 멱등성이 필요한 곳
    • 같은 요청을 반복해서 보냈을 때 문제가 생길 수 있는 민감한 API
      • 결제 취소, 지급대행
    • pub/sub구조에서 pub의 메시지가 다시 전송되는 문제가 발생할 수 있기 때문에 sub는 메시지 처리에 대해 멱등성을 보장하고 있어야 합니다.
  • 토스페이먼츠에서는 멱등키만으로 멱등성을 판단하는 게 아니라 멱등키와 API 키, API 주소, HTTP 메서드를 조합해 비교한다고 합니다. 같은 멱등키가 들어왔어도 앞에서 말한 다른 값이 달랐다면 이를 다른 요청으로 판단하는 방식입니다.

시나리오

  1. 클라이언트는 헤더에 멱등키를 추가해서 요청을 보냅니다.
  2. 서버는 멱등키를 저장하기 위한 별도의 DB를 가집니다.
  3. 서버는 요청마다 헤더에 있는 멱등키를 확인합니다.
    3-1. 만약 멱등키 DB에 해당 멱등키를 활용한 요청값이 없으면 새롭게 응답을 생성하고, 이를 멱등키 DB에 저장한 뒤 사용자에게 반환해 줍니다. 이때 멱등키의 유효 기간을 설정해 둘 수 있습니다.
    3-2. 만약 멱등키 DB에 해당 멱등키를 활용한 요청값이 있으면 Domain서버에 요청을 보내지 않고 저장되어 있던 값을 그대로 반환합니다.

예제

준비

저는 멱등하지 않은 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를 수정해 보겠습니다.

예제1

우선 조금 더 단순한 예제로 시작해 봤습니다. 이 예제는 별도의 멱등키 서버나 멱등키의 만료기간을 가지지 않습니다. 메모리에서 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);
    }

}
  • Map자료구조를 이용해 idempotentDB라는 멱등키 보관소를 메모리 안에 구현했습니다.
  • /idempotent 요청은 멱등성을 보장하는 경우, /nonIdempotent요청은 멱등성을 보장하지 않는 경우입니다.
  • /idempotent 요청은
    • header값에 담긴 Id값이 idempotentDB에 존재하면 service로직이 아니라 idempotentDB에 담겨 있는 값을 그대로 반환합니다.
    • header값에 담긴 Id값이 idempotentDB이 존재하지 않으면 service로직을 실행한 결과값을 idempotentDB에 담고 그 값을 반환합니다.

아주 간단하게 멱등API를 만들어 봤습니다. 하지만 처음에 설명했듯이 지금은 만료 기간을 설정하지 않아 한번 들어온 요청은 무한히 갱신되지 않는다는 단점이 있습니다.

예제2

글을 읽으면서 별도의 멱등키 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가 증가하지 않습니다.

전체 코드는 여기에서 다운받으실 수 있습니다.

profile
기록하고 정리하는 걸 좋아하는 개발자.

0개의 댓글