OpenAPI 유료화 : 차감 트랜잭션 위치 고민, 벌크 연산과 영속성 컨텍스트 본문

OpenAPI 유료화 : 차감 트랜잭션 위치 고민, 벌크 연산과 영속성 컨텍스트

JinHwan Kim 2025. 9. 8. 00:29

배경

회사 서비스 중 하나로, IoT 기기를 코드로 조회, 제어할 수 있는 API를 제공하고 있다.

최근 과금 정책을 도입하고 과하게 리소스를 사용하는 사용자를 제한하려는 시도를 하고 있다.

나는 잔여 포인트를 확인하여 요청을 제한하거나, 포인트를 차감하는 트랜잭션 처리를 개발한다.

개발 요구 사항은 아래와 같다.

 

1. API 호출 시 지정 크레딧 차감

- 크레딧 부족 시 요청을 제한한다.

- 서버 에러 시 차감하지 않는다.

 

2. 요청 처리 결과 내역 저장

- 트랜잭션, 응답 시간, 응답 결과, 요청 정보, 소모 크레딧를 기록한다.

- 이를 검색할 수 있는 API가 필요하다.

 

언제 차감하는 게 좋을까

크레딧은 요청 처리 전에는 단순 잔액 확인만 하고, 요청 처리 후에 차감하기로 결정했다.

선 차감 시에는 꼼꼼한 요청 제한이 가능하지만, 서버 에러 시 다시 환불해 주는 보상 로직이 필요하다.

이 경우 사용 내역에 차감과 환불이 모두 남아 로그가 지저분해진다.

반대로 최종 결과만 기록하자니, 환불로 크레딧이 남아있으면서 동시에 크레딧 부족으로 요청이 실패한 경우가 발생할 수 있다.

이 경우 역시 동시성을 제한하는 것으로 풀 수 있으나, 클라이언트 입장에선 성능이 떨어진다.

 

그래서 후 차감 방식을 선택했다.

요청 처리 전에는 잔액만 확인하고, 처리 후 결과에 따라 차감 여부를 결정한다.

동시 요청 시 잔액이 부족함에도 일부가 통과하는 문제가 있을 수 있겠다.

대신 로그가 깔끔하고 구현이 쉽다.

 

또 환불 로직으로 사용자들의 혼란을 만드는 경우를 피할 수 있겠다.

사실 경계 상황에서 일부 요청이 허용되는 문제는 서비스 상 큰 손해는 아니었고,

잔여 크레딧이 남아있는데도 처리가 안되거나 성능이 떨어지는 상황은 꼭 피해야 할 큰 문제였다.

 

선차감 후환불, 포인트가 존재하지만 포인트 부족으로 요청 실패

 

차감 로직을 어디에 둘 것인가

컨트롤러의 각 핸들러에 @Credit 어노테이션을 붙여 요청마다 필요한 크레딧 정보를 정의할 수 있도록 구현했다.

요청이 호출되면 이 정보를 읽어 사용자의 잔액과 비교하고, 요청을 마치면 그만큼을 차감한다.

그 비교와 차감 로직을 어디에 둬야 코드가 깔끔해질지 고민이 필요했다.

 

1. Filter

Filter는 Servlet 레벨에서 동작한다.

요청이 어떤 핸들러로 매핑되는지 아직 결정되지 않은 시점이다.

요청별 필요한 크레딧이 다르고 이를 쉽게 확인하기 위해선 Filter는 적절하지 않았다.

 

2. AOP 

핸들러를 감싼 AOP 구현도 생각해 볼 수 있다.

메서드 시작 전에 잔액 확인을, 종료 후에 잔액을 차감하는 로직을 만들 수 있겠다.

또 joinPoint.proceed() 전후로, 코어 로직의 처리 시작과 후의 시간 차이를 계산하기 쉽다.

대신 예외 시 어떤 응답이 나갈지를 알 수 없다.

ControllerAdvice 안쪽의 컨트롤러의 핸들러를 감싸기에, 예외 핸들러가 반환하는 최종 HTTP status를 알 수 없다.

 

3. Interceptor

인터셉터가 가장 적절했다.

HandlerMappings 이후에 호출되어 핸들러 정보를 알고 있고, API 호출에 필요한 크레딧 값을 쉽게 찾을 수 있다.

afterCompletion()은 ControllerAdvice 다음에 호출되기에, 예외 처리 후 결정되는 응답 결과도 확인 가능하다.

대신 Filter, AOP와 달리, 인터셉터는 처리 전후의 메서드가 별도로 존재하여 로컬 변수 공유가 불가능하다.

HttpServletRequest.setAttribute()로 시작 시간을 저장하고, 처리 후엔 이를 꺼내 코어 로직의 처리 시간을 계산했다.

setAttribute()는 서버 내부 메모리에만 저장되고 HTTP 응답에 포함되지 않는다.

 

FYI. 스프링 MVC의 요청 처리 흐름 (예외 시)

1. Filter: 요청
2. DispatcherServlet: 요청 수신
3. HandlerMapping: 요청에 맞는 핸들러 확인
4. Interceptor: PreHandle
5. HandlerAdapter: 요청 파라미터 준비, 핸들러 메서드 호출
6. AOP Aspect: Before,Around-Before
7. Controller: 로직 실행, 예외 발생 가정
8. AOP Aspect: AfterThrowing / Around-AfterThrowing
9. HandlerAdapter: 핸들러에서 던져진 예외를 DispatcherServlet에 전달
10. ControllerAdvice: 예외 처리
11. View 렌더링: 응답 생성.
12. HandlerInterceptor.afterCompletion()
13. Filter: 응답

 

메타데이터 위치, 응답 바디 vs 헤더

크레딧 차감 위치는 Interceptor로 결정됐다.
이제 차감된 크레딧, 처리 시간, 트랜잭션 ID를 응답에 어떻게 포함할 것인가가 남았다.

처음에는 이런 처리 결과 메타데이터를 응답 바디에 포함하려 했다.

그런데 이 구조를 만드는 게 예상보다 까다로웠다.

{
  "result": { 
  },
  "transactionId": "uuid",
  "credit": 1,
  "durationMs": 123
}

 

핵심 문제는 Servlet API의 설계에 있다.
HttpServletResponse의 출력 스트림은 write-only이기에, 한번 쓴 응답 바디를 다시 읽어오는 API가 스펙에 없다.

그래서 Controller의 처리가 끝난 응답 바디를 수정하는 것은 보통 두 가지 방법을 사용한다.

 

1. ResponseBodyAdvice

AOP나 ControllerAdvice처럼 응답 바디가 쓰이기 전에 이를 수정하는 로직을 공통으로 분리할 수 있다.

그래서 Controller가 결과를 반환하기 전, 즉 HttpServletResponse가 쓰이기 전에 응답 포맷을 수정하는 데 사용된다.

문제는 이 조차 Interceptor의 afterCompletion() 이전에 호출된다는 것이다.

처리 시간이나 사용 크레딧 정보는 Interceptor의 afterCompletion()에서 결정되기에, 이를 응답으로 담기에 적절치 않다.

 

2. ContentCachingResponseWrapper

HttpServletResponse를 한번 감싸서 Tomcat 대신 OutputStream을 직접 관리한다.

Write 하는 위치를 직접 결정하기에 Controller 반환이 끝난 Filter에서도 응답 바디를 수정할 수 있다.

대신 원래는 Tomcat의 내부 버퍼에 저장될 바디 값(Controller 반환 객체)이 JVM 내부 메모리를 사용하게 된다.

그렇기에 수정할 수 있는 자원이 되지만, 동시에 OOM의 위험이 될 수도 있다.

그래서 보통 응답 사이즈가 크지 않은 api의 반환에 한정하여 사용된다.

 

결론. 응답 헤더로 이동

여기서 한 발 물러서 생각했다.

처리 시간이나 사용 크레딧은 처리 결과의 메타데이터였고, 바디에 들어가서 사용자의 응답 포맷을 번거롭게 만들어야 할까 생각했다.

HTTP 헤더에 넣으면 앞선 바디를 억지로 수정하기 위한 문제가 사라진다.

오히려 API 설계를 잘못해서 잘못된 방향으로 고민했던 것 같다.

 

 

원자적 쿼리로 동시성 문제 방지

크레딧을 차감하는 쿼리에는 동시성 문제를 주의한다.

조회 후 업데이트가 이어지는 트랜잭션은 갱신 분실 문제의 대표 예시이다.

서로 다른 트랜잭션이 동시에 조회하고 각각 데이터를 변경할 때, 마지막 수정 값만 남게 되는 문제가 그것이다.

주로 조회 쿼리에도 배타락을 걸어버리거나, 엔티티에 버전 정보를 표시해서 경합 여부를 확인하는 방법,

또는 아예 전체 로직에 락을 걸어 '조회 -> 수정'이 꼬이지 않게 한다.

 

아래와 같은 원자적 쿼리로 동시성 문제를 방지할 수 있다.

남은 크레딧이 소비치보다 크다면 차감, 그렇지 않다면 0으로 업데이트한다.

JPA를 사용하는 경우에선 @Query 를 이용해서 JPQL을 직접 사용하거나, QueryDSL으로 타입 안정성을 챙길 수 있겠다.

UPDATE member SET credit = 
CASE 
    WHEN credit < :cost THEN 0 
    ELSE credit - cost
END 
WHERE id = memberId

 

영속성 컨텍스트를 우회한 쿼리 

JPQL, QueryDSL, Native를 사용한 UPDATE, DELETE 쿼리에 놓치기 쉬운 포인트가 있다.

이들은 영속성 컨텍스트 처리를 우회하여 DB 데이터를 직접 변경하는 연산을 수행한다.

이를 생각하지 않으면 영속성 컨텍스트의 객체 상태와 실제 데이터의 불일치를 만들기 좋다.

이들을 벌크 연산이라고 한다. 

@Modifying
@Query("UPDATE Member m SET m.credit = m.credit - :cost WHERE m.id = :id")
int subCredit(@Param("cost") int cost, @Param("id") Long id);

 

위와 같이 JPQL을 사용한 Update 메서드를 만들었다고 가정하자.

Service 레이어에서 아래 두 메서드를 수행했을 때, 동일한 동작처럼 보이지만 응답 값은 서로 다르다.

sub1에서는 영속성 컨텍스트에 캐시 된 값으로 변경과 읽기가 일어나 DB와 메모리상 객체 값이 동일하지만,

sub2에서는 벌크 연산으로 DB가 직접 변경되었지만, 이전 캐시에서 객체를 가져와 DB와 값이 서로 다르게 된다.

@Transactional
public int sub1(int cost, Long id) {
    Member m = memberRepository.findById(id).orElseThrow();
    m.setCredit(m.getCredit() - cost);
    Member findMember = memberRepository.findById(id).orElseThrow();
    return findMember.getCredit();
}

@Transactional
public int sub2(int cost, Long id) {
    var m = memberRepository.findById(id).orElseThrow();
    memberRepository.subCredit(cost, id);
    Member findMember = memberRepository.findById(id).orElseThrow();
    return findMember.getCredit();
}

 

이런 불일치 문제를 해결하기 위해 벌크 연산 후 영속성 컨텍스트를 비워주었다.

서비스 레이어에서 영속성 컨텍스트를 주입받아 직접 Clear() 해도 되고,

아래처럼 @Modifying 어노테이션에 메서드 수행 전 / 후 영속성 컨텍스트의 FLUSH와 CLEAR를 명시할 수도 있다.

FLUSH는 쿼리 수행 전, 영속성 컨텍스트에 기록되어 아직 DB에 반영하지 않은 값 변경을 미리 반영하는 역할을,

CLEAR는 쿼리 수행 후, 실제 DB와 불일치가 발생한 영속성 컨텍스트의 데이트를 비우는 역할을 한다.

@Modifying(
    flushAutomatically = true,
    clearAutomatically = true
)
@Query("UPDATE Member m SET m.credit = m.credit - :cost WHERE m.id = :id")
int subCredit(@Param("cost") int cost, @Param("id") Long id);

 

정리

작업 :

   - OpenApi 서비스 유료화를 위한 크레딧 조회, 차감 로직 구현

처리 : 

   - 선 차감 vs 후 차감

      - 서비스 특성을 고려한 트랜잭션 위치 고민

   - 차감 로직 위치 고민

       - Filter vs AOP vs Interceptor

       - Interceptor에서 시작 시점을 공유했던 방법

   - 요청 처리 메타데이터 위치 고민

      - 응답 바디를 수정하는 방법

      - 응답 헤더 활용

   - 동시성 문제에 유의 

      - 원자적 쿼리를 활용하여 갱신 분실 문제 피하기

      - 벌크 연산과 영속성 컨텍스트 불일치 문제 유의

 

 

Comments