API 요청 횟수 제한하기

K_Gs·2025년 3월 29일
2
post-thumbnail

서론

저는 항상 앱을 개발하면서 API요청을 보낼 때 항상 "요청 횟수 제한"을 걸어두는 편이에요.

별건 아니지만, 과거 처음으로 진행한 프로젝트에서 실수로 무한으로 API요청을 하는 코드를 작성하여 문제가 생긴 뒤로 항상 신경써서 챙기는 부분이랍니다.

이후로도 다양한 프로젝트를 진행하면서 그 방식도 다양하게 시도해보게 되었는데요, 오늘은 그 과정에서 시도한 다양한 방식을 이야기해볼게요!

접근 방식

Button block

가장 처음으로 적용했었던 요청 제한 방식으로, 단순하게 버튼을 한번 누르면 특정 시간 동안 요청을 무시하는 방식이에요.

var btnClickTime by remember { muatableStateOf(0L) }
//...

Button{
	onClcik = {
    	val times = System.currentTimeMillis()
    	if(btnClcikTime + 500 < times){
	        btnClickTime = times
            viewModel.call()
        }	    
    }

후술할 ThrottlingView 버전이죠!

버튼이 여러개더라도 한번에 적용할 수 있고, 매우 간단하지만, 요청을 제한하는 로직이 View 영역에 있어 코드가 복잡해지고 중복이 많아졌어요.

그리고 해당 기능은 서로 다른 파일, 컴포저블에서 요청하는 경우에 적용할 수 없는 문제가 있었어요.

Throttling

위의 문제들은 View에서 요청제한을 적용하려해서 생긴 문제들이에요. 그래서 이번에는 요청을 받아들이는 뷰모델에서 직접 제한을 적용하기로 하였어요.

var lastTime = 0L

fun getA() {
	val tiems = System.currentTimeMillis() 
    //원래는 외부에서 주입받음
	if(lastTimes + 1000 > times) return
    lastTimes = times
  
    viewModelScope.launch{
    	//...
	}
}

이제 앞서 이야기한 중복코드나 서로 다른 컴포저블에서 시간을 공유하지 못하는 문제를 해결했어요.

하지만 쓰로틀링의 경우 결국 1초 마다 한번씩은 요청이 가요. 만약 무한 요청이 된다면 간격만 넓어졌지 무한 요청인 것은 동일한 문제가 있었어요.

Job Cancle

그래서 최종으로 온 요청 한가지만 받도록 코드를 수정하였어요.

기본적으로 viewModelScope는 코루틴 스코프를 열면서 Job 클래스의 객체를 반환해요. 이 Job은 코루틴 스코프의 실행 상태를 담고있기에 Job을 통해 코루틴이 실행중인지, 어떤 상태인지와 함께 취소를 시킬수도 있어요.

즉, Job을 저장하여 상태를 확인하고 실행중이라면 취소시키고 새로운 스코프를 여는거죠!

var job: Job? = null
fun getA() {
	if(job!=null&&job.isCompleted.not()) job!!.cancel()
	job = viewModelScope.launch{
    	//...
	}
}

이 방식은 상당히 괜찮다 생각해서 오래 사용했어요.

다만 문제는 잘 진행중이던 Job을 취소시킨다는 부분이에요. 즉 이미 요청도 갔고, 응답이 거의다 왔는데 취소되어서 다시 시작하게 되는 일이 생길 수 있었어요.

요청 자체를 막을 수 없는거죠!

또 AI를 쓰는 일이 많아지며 오래걸리는 작업이 늘어나 문제가 될 가능성이 있었어요.

Debounce

그래서 이번에는 기존에 작업을 취소하는 것이 아닌 특정 시간동안 들어온 요청에서 가장 마지막 것만 받아들여 수행하는 Debounce를 적용하기로 하였어요.

Debounce의 경우 flow에서 기본으로 제공해주기에 적용하기도 다른 방식에 비해 간단하답니다.

viewModelScope.launch{
	dataSource.getA()//flow
    	.debounce(1000)
    	.collect { value ->

    	}
}

FlowDebounce를 결합하는게 가장 쉽기도 하고, 깔끔하게 요청횟수를 제한하는 방법이라 판단하고 이후에는 계속 Debounce를 사용하고 있어요.

이제 요청 횟수 제한에 더 이상의 변경은 없을 줄 알았지요.

Interceptor

하지만 이후 점점 복잡한 요청과 여러 API 요청을 종합하여 뷰모델로 반환해야하는 일이 생겨났어요.

하나의 API를 무한 요청하는 것은 디바운스로 막을 수 있지만, A API 결과로 다시 A API가 호출되거나, A -> B -> A로 반복되는 것은 막을 수 없었어요.

그래서 이번에는 아예 API 요청에 횟수 제한을 걸기로 결정합니다.

Android에서 API 호출시 자주쓰는 RetrofitOkhttp를 기반으로 만들어져있어요.

그리고 이 OKhttp에는 API 호출시 이를 가로채 무언가 처리하거나, 붙이거나 할 수 있게 해주는 Interceptor를 추가할 수 있어요.

이 인터셉터를 통해 요청이 올때마다 카운트하고 초당 몇회의 요청이 왔는지 관리하는 거죠!

class RateLimitingInterceptor : Interceptor {
    private val requestTimestamps = mutableListOf<Long>()

    override fun intercept(chain: Interceptor.Chain): Response {
        val now = System.currentTimeMillis()

        synchronized(this) {
            requestTimestamps.removeAll { now - it > 1000 }

            if (requestTimestamps.size >= 5) {
                throw LimitExceedException("요청이 너무 자주 발생했습니다.")
            }

            requestTimestamps.add(now)
        }

        return chain.proceed(chain.request())
    }
}

이렇게 하면 1초이내에 5회이상 연산이 오게되면 LimitExceedException을 던집니다. 이후에 try catch를 통해 이를 잡아 탈출하면 되는거죠!

마무리하며

인터셉터까지 가니 조금 과하게 요청 횟수에 제한을 두려하는 것 같기도하네요...?!

하지만 디바운스까지는 모든 프론트 사람들이 적용했으면 좋겠습니다.

저는 가장 앞에서 이야기한 무한 요청때 30분 정도 동안에 150만회의 요청이 갔었습니다. Firebase 무료 요금제였기에 돈은 나가진 않았지만, 조심해서 나쁠건 없지요!

profile
아직도 모르는게 많으니, 알아가고 싶은 것도 많다

0개의 댓글