멱등성이란

박태현·2025년 6월 26일
0

예약 프로젝트

목록 보기
6/8

이번 프로젝트에서 결제 로직, 리워드 지급 로직 등등과 같이 중복 요청이 발생했을 때 발생할 수 있는 위험성을 제거하고 방지하고자 이러한 로직들에 멱등성을 적용하기로 하였습니다.

멱등성이 지켜지지 않는다면 동일한 작업이 여러 번 처리되어 리소스 낭비가 발생하고, 데이터 불일치 문제가 발생할 수도 있습니다. 또한 동일한 요청에 대해 다른 결과가 반환되어 시스템의 신뢰성이 떨어지게 됩니다.

멱등성이란 ?


같은 연산을 여러 번 수행해도 결과가 처음 한 번 수행했을 때와 동일하게 유지되는 성질을 의미합니다.

즉, 어떤 리소스에 대해 A라는 요청을 보냈을 때 B라는 응답을 받았다면, 그 리소스의 상태가 바뀌지 않는 한 A 요청은 반복적으로 보내더라도 항상 동일한 응답 B를 반환해야 합니다.

ex ) 1을 무한히 곱하는 것, 절대값 함수

HTTP 메서드의 멱등성


GET, PUT 처럼 리소스를 조회하거나 대체하는 메서드는 멱등합니다.

GET은 여러 번 호출해도 리소스에 변화를 일으키지 않고, 같은 결과가 돌아오기 때문이고

, PUT은 여러 번 호출해도 매번 같은 리소스로 업데이트 되기 때문에 결과가 달라지지 않기 때문입니다.

반면, POSTPATCH 요청은 호출할 때마다 리소스에 변화를 일으키고 이에 따라 응답도 달라지기 때문에 멱등하지 않은 메서드입니다.

⇒ 멱등하지 않은 메서드에 멱등성을 제공하려면 서버에서 멱등성을 구현해줘야 함

HTTP 메서드의 안정성과 멱등성

안전한 메서드란 서버의 리소스를 변경하지 않는 메서드로 GET, HEAD, OPTIONS가 이에 해당하며, 이러한 메서드는 단순히 데이터를 조회하거나 통신 가능 여부만 확인하므로 안전성뿐만 아니라 멱등성도 보장합니다.

반면, 멱등한 메서드는 여러 번 호출해도 서버의 최종 상태가 변하지 않는다는 특징은 있지만, 리소스를 변경할 수 있기 때문에 안전하다고 볼 수는 없습니다.

예를 들어, PUT이나 DELETE는 멱등하지만 서버 상태를 변경하므로 안전하지 않은 메서드입니다.

즉, 안전한 메서드는 항상 멱등하지만, 멱등한 메서드가 반드시 안전한 것은 아닙니다.

API 관점


멱등한 API는 동일한 요청을 두 번 이상 해도 첫 번째 요청과 동일한 응답이 올 뿐만 아니라 서버의 상태에도 영향을 미치지 않기에 의도치 않은 문제를 일으키지 않고 요청을 재시도 할 수 있어 중요합니다.

만약 사용자가 결제하는 시점에 네트워크 오류 혹은 다른 이유로 응답을 받지 못했을 때, 만약 멱등하지 않은 API라면 결제의 성공 여부를 수동으로 확인해야 하며 결제가 안된 경우 다시 해야하는 상황이 발생할 수 있지만, 만약 멱등한 API라면 다시 같은 요청을 보내지 않고 전에 받지 못한 결과만 다시 받을 수 있습니다 !

다시 같은 요청을 보내지 않고 전에 받지 못한 결과만 다시 받는다는 말은 요청은 다시 보내지만 → 처리는 하지 않고 → 응답만 재사용하는 것

또한, 실수로 중복 요청이 되더라도 ( 따닥 이슈 ) 실제로는 결제가 되지 않아 여러 번 요청해도 문제가 없습니다.

멱등한 요청인지 알 수 있는 방법


Idempotency-Key: {IDEMPOTENCY_KEY}

멱등성을 보장하기 위해서는 멱등키를 API 요청에 포함하면 됩니다.

이전 요청과 동일한 멱등키를 가진 요청을 받으면 서버에서 이 요청을 중복으로 판단한 뒤 실제도 처리하지 않고 첫 요청과 같은 응답을 반환하는 방식

요청 본문, URL 쿼리 매개변수, 헤더 중 하나에 멱등키를 포함시켜 보내면 됨

하지만 위 중 하나에 키를 추가하는 것만으로 같은 요청이 반복된 건지 어떻게 식별해서 처리할 수 있을까요 ??

  1. API 서버는 취소 요청마다 헤더에 멱등키가 있는지 확인

  2. 멱등키를 저장하기 위한 DB를 만들고, 멱등키가 포함된 취소 요청이 들어왔을 때, DB 조회를 통해 요청이 들어온 멱등키와 매칭되는 요청 기록이 있는지 확인

    ⇒ 멱등한 요청 기록을 DB에 저장하는 기간을 정해둘 수 있으며, 그 기간이 지나면 DB에 저장된 멱등키와 기록이 없기에 같은 멱등키를 사용해서 새로운 요청을 보낼 수 있음 ( 멱등키의 유효 기간 )

  3. 만약 이전에 같은 멱등키로 들어온 요청이 있었다면, 서버에서 요청을 처리하지 않고 저장되어 있는 응답 데이터를 돌려줌

  4. 만약 멱등키와 매칭되는 이전 기록이 없다면, 새로 생성된 응답을 저장하는 새로운 기록을 만들고 응답을 클라이언트에 돌려줌

예제


클라이언트는 멱등키를 추가해서 요청을 보내며, 멱등키는 UUID v4와 같이 충분히 무작위적인 고유 값이어야

최초 요청 이후에는 다시 요청해도 HTTP 코드 200과 함께 매번 같은 결과가 돌아오게 됨

서버는 멱등키 DB에 멱등키와 매칭되는 요청 기록을 추가하고, 요청에 성공하면 성공 응답을 전달하며, 같은 요청이 반복되면 요청에 멱등키가 포함되어 있는지, 이미 저장된 멱등키가 있는지 확인

하나의 API가 아닌 여러 API에서 멱등성을 보장하려면 컴포넌트를 만들어서 재사용 !

에러 코드시나리오
400 Bad Request멱등해야 하는 API 요청에 멱등키가 누락됐거나 형식에 맞지 않는 키값이 들어왔을 때
409 Conflict이전 요청 처리가 아직 진행 중인데 같은 멱등키로 새로운 요청이 올 때
422 Unprocessable Entity재시도 된 요청 본문(payload)이 처음 요청과 다른데 같은 멱등키를 또 사용했을 때



구현


먼저, 멱등 키를 기반으로 만료되지 않은 요청 이력이 존재하는지 확인

만약 유효한 요청 이력이 존재하면 : 해당 이력의 응답을 그대로 반환합니다.

만약 이력이 없거나 만료되었다면 : 외부 클라이언트를 호출하여 요청을 처리합니다.

  • 호출에 성공한 경우, 요청을 정상 수행하고 성공 이력을 저장합니다.
  • 호출에 실패한 경우, 실패 이력을 저장한 후 예외를 반환합니다.

@Component
class IdempotencyManager(
    private val idempotencyRepository: IdempotencyRepository,
): Loggable {

	fun execute(
		key: String,
		url: String,
		method: String,
		failResult: String,
		process: () -> String
	): ResponseEntity<String> {

		val now = LocalDateTime.now()
		val idempotency = idempotencyRepository.findByIdempotencyKey(key)

		if (idempotency != null && idempotency.expires_at.isAfter(now)) {

			log.info { "동일한 Idempotent 요청 감지됨 - 저장된 이전 응답 반환" }

			return ResponseEntity
				.status(idempotency.statusCode)
				.body(idempotency.responseBody)
		}

		try {
			val result = process()

			idempotencyRepository.save(
				Idempotency(
					idempotencyKey = key,
					url = url,
					httpMethod = method,
					responseBody = result,
					statusCode = 200,
					expires_at = now.plusMinutes(10)
				)
			)

			return ResponseEntity
				.status(200)
				.body(result)

		} catch (e: ReserveException) {

			val failResult = failResult

			idempotencyRepository.save(
				Idempotency(
					idempotencyKey = key,
					url = url,
					httpMethod = method,
					responseBody = failResult,
					statusCode = e.status.value(),
					expires_at = now.plusMinutes(10)
				)
			)
			
		throw e
		}
	}
}
/*
 * 멱등성 로직을 활용한 예약 로직
 */
fun reserveSeats(seatRequest: SeatRequest, token: String, idempotencyKey: String): ResponseEntity<String> {
    return idempotencyManager.execute(
        key = idempotencyKey,
        url = "/seat/reserve",
        method = "POST",
        failResult = "예약이 실패되었습니다."
    ) {
        doReserveSeats(seatRequest, token)
    }
}

@Transactional
fun doReserveSeats(seatRequest: SeatRequest, token: String): String {
    val username = jwtUtil.getUsername(token)

    val member = memberRepository.findByUsername(username)
        ?: throw ReserveException(HttpStatus.BAD_REQUEST, ErrorCode.MEMBER_NOT_FOUND)

    val screenInfo = screenInfoRepository.findById(seatRequest.screenInfoId)
        .orElseThrow { throw ReserveException(HttpStatus.BAD_REQUEST, ErrorCode.SCREEN_INFO_NOT_FOUND) }

    val totalSeatCount = (seatRequest.seats as List<String>).size
    val totalPrice = screenInfo.performance.price * totalSeatCount

    if (totalPrice > member.credit) {
        throw ReserveException(HttpStatus.BAD_REQUEST, ErrorCode.NOT_ENOUGH_CREDIT)
    }

    seatRequest.seats.forEach { seatNumber ->
        val seat = seatRepository.findByScreenInfoAndSeatNumber(screenInfo.id, seatNumber)
            ?: throw ReserveException(HttpStatus.BAD_REQUEST, ErrorCode.SEAT_NOT_FOUND)

        if (seat.is_reserved == true) {
            throw ReserveException(HttpStatus.CONFLICT, ErrorCode.SEAT_ALREADY_RESERVED)
        }

        seat.is_reserved = true
        seat.member = member
        seatRepository.save(seat)
    }

    member.credit -= totalPrice
    memberRepository.save(member)

    log.info { "예약 성공!" }
    return "이미 처리된 요청이거나, 좌석 예약이 완료되었습니다."
}

성공한 요청을 재요청 하는 경우

A4 좌석을 예약한 후, 멱등성 로직이 제대로 동작하는지 확인하기 위해 Postman을 사용해 동일한 idempotencyKey와 같은 요청 데이터를 다시 전송했으며, 이 결과 아래와 같이 idempotencyKey 테이블에 저장된 이전 요청의 성공 응답이 그대로 반환되는 것을 확인할 수 있었습니다.



실패한 요청을 재요청 하는 경우

사용자의 잔액 부족으로 실패한 요청을 재요청한 경우 idempotencyKey 테이블에 저장된 이전 요청의 실패 응답이 그대로 반환되는 것을 확인할 수 있었습니다.

따라서 처음에 설계했던 것처럼 로직이 실행 됨을 볼 수 있습니다.

profile
꾸준하게

0개의 댓글