티스토리 뷰

Server

따닥 issue

조용한스택 2023. 6. 20. 22:50

개요

커머스 개발을 하다가 결제 부분에서 따닥 이슈를 만나서 해결하는 과정을 적어본다.

😱 문제

멤버십 구독을 할 때 유저가 n 번 누르면 요청이 n 번 들어와서 결제가 n번 된다.

🤔 해결 방안

1. 요청 시 클라이언트 헤더에 멱등 키를 받아서 여러 요청이 하나의 요청인지 아닌지 구분한다. (execute at most once)

  • 멱등 키는 서버에서 해당 구독 건에 대해 특정할 수 있는 key를 조합하여 사용하는 것으로 한다.
  • 멱등 키는 헤더에 설정. (IETF 표준)
  • 결제 요청 전에 DB에서 멱등 키를 확인하여 이미 저장이 되어 있으면 중복 요청으로 208 Already Reported 응답
  • 멱등 키와 요청한 endpoint 로 구별

어디에 저장하지

  1. DB
    멱등 키 확인/생성하는 로직을 트랜잭션을 분리시켜서 구현한 다음, 해당 클래스를 결제 로직 첫 부분에서 사용하게 한다.
  2. redis

새로 도입해야 함.
DB보다 빠른 접근과 만료 시간을 지정할 수 있어서 데이터가 적재되는 것에 대해 고려하지 않아도 된다.

Data Model

`id` int(11)
`request_method` varchar(256)
`request_endpoint` varchar(256)
`idempotent_key` int(11)
`created_at` datetime

2. 결제 요청이 들어오면 MQ 를 사용해서 queueing 해서 순서를 보장한다.

MQ 도입해야함. 한 번에 한 번의 요청만 처리하기 때문에 동시성에 대한 고민을 하지 않아도 된다.

3. 로직 앞에 @Lock(LockModeType.PESSIMISTIC_WRITE) 트랜잭션 비관적 락으로 막아두기

for update가 붙는 쿼리가 발생한다. 업데이트를 하기 위해 select 하는 것이 목적이고 해당 업데이트가 되는 트랜잭션이 완료되기 전까지 다른 트랜잭션에서 락을 걸 수 없다.

1) 첫 번째 request가 1 번 thread로 처리되는 중이다. select … for update로 비관적 락을 잡았다.

1

1번 스레드가 락을 잡고 다음 로직을 실행 중이다.

2

2) 2 번째 request가 2 번 thread로 처리되는 중이다. select … for update로 비관적 락을 잡으려고 시도중이다.
대기를 하며 다음 요청을 처리하기 위해 3번 스레드가 시작된다.

3

3) 1 번 스레드의 결과를 작성한다. status=200 OK

2번과 3번 스레드는 1번 스레드가 락을 놓은 후 해당 row에 대한 select를 실행하여 로직을 시작하기에 적합하지 않은 값임을 확인하였다. status=403 FORBIDDEN

4

첫 가입 시 membership_subscriptions에 row가 없는 경우가 있을 수 있는데 이러면 상황에 따라 존재하지 않는 같은 record를 insert하는 것은 deadlock 혹은 duplicate key error를 유발할 수 있다. 즉 다른 세션이 해당 record를 insert하는 것은 막을 수 있지만, 지금 세션도 insert하지 못할 수 있다.

db에 결제 transaction이 저장되기 전에는 멱등키와 글로벌 캐시로 요청을 무효화할 수 있지만, 그 후에는 db에 저장된 transaction을 확인하여 중복을 방지하는 과정도 필요할 것 같다.

4. 미리 주문서 같은 데이터를 만들어두고 select ... for update 이용하기

🧐 Test

  1. JUnit Test
@Test
    @DisplayName("row가 존재하지 않는 경우, 멤버십 가입(멀티 스레드) 테스트")
    void nonExistsMembershipSubscriptionsRowForMultiThreadTest() throws InterruptedException {
        long userId = 4;

        MembershipJoin membershipJoin = new MembershipJoin();
        membershipJoin.setMembershipUuid("7265812d-0dfd-493f-ad6f-a21cfa2a6bfd");
        membershipJoin.setPaymentId(3L);
        membershipJoin.setPaymentType(PaymentType.CARD);

        AtomicInteger successCount = new AtomicInteger();
        int numberOfExcute = 10;
        ExecutorService service = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(numberOfExcute);

        for (int i = 0; i < numberOfExcute; i++) {
            service.execute(() -> {
                try {
                    joinMembershipService.createSubscription(membershipJoin, userId);
                    successCount.getAndIncrement();
                    System.out.println("성공");
                } catch (Exception e) {
                    System.out.println("[DATA ERROR] " + e.getMessage());
                }
                latch.countDown();
            });
        }
        latch.await();

        assertThat(successCount.get()).isEqualTo(1);
        assertThat(subscriptionRepository.findByUserIdAndCreatorId(userId, 3L).isPresent()).isTrue();
        assertThat(subscriptionRepository.findByUserIdAndCreatorId(userId, 3L).get().getStatus()).isEqualTo(MembershipSubscriptionStatus.SUBSCRIBED);
    }

    @Test
    @DisplayName("row가 존재하는 경우, 멤버십 가입(멀티 스레드) 테스트")
    void existsMembershipSubscriptionsRowForMultiThreadTest() throws InterruptedException {
      // 위와 같음
    }

기존에 row가 존재하는 경우 한 번만 실행되는 것이 보장되고, 존재하지 않는 경우 동시에 insert 를 실행한다.
unique index가 있으면 하나만 생성되는 것을 보장한다.

  1. postman으로 연속 요청을 재현

다음은 test script 이다.

const echoPostRequest = {
  url: 'http://localhost:8080/api/v1/user/membership/join',
  method: 'POST',
  header: {
    "Authorization": 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyaXZlckB0dW1ibGJ1Zy5jb20iLCJpYXQiOjE2NjQzMzIzNzYsImV4cCI6MTY5NTg2ODM3NiwiZWlkIjoiYjE3YjVkZDNjMDE4NjljMjBiMGJmMjM5MjY3NDk0MDAiLCJtYWNoaW5lX2lkIjoiOTA0YTYxZWUzOWEwMDE2ZSJ9.QbWLspwdv5BwXTtMv0eDqeVRy1SxaunxvHziUvr9nk0',
    "Content-Type": "application/json"
  },
  body: {
    mode: 'raw',
    raw: JSON.stringify({
    "membershipUuid": "03e36383-40f4-43cd-bac9-cdfb3da26bbe",
    "noticeAgreement": true,
    "paymentId": 332,
    "paymentType": "CARD",
    "recurringPaymentAgreement": true,
    "thirdPartyPrivacyForCreatorAgreement": true
    })
  }
};

function sendRequest() {
    return new Promise((resolve, reject) => {
        pm.sendRequest(echoPostRequest, (err, res) => {
            if (err) {
                return reject(err);
            }

            return resolve(res);
        })
    });
}

pm.test('', (async function main() {
    const result1 = sendRequest();
    const result2 = sendRequest();
    const result3 = sendRequest();

    console.log('result1:', result1.code);
    console.log('result2:', result2.code);
    console.log('result3:', result3.code);
})());

1

'Server' 카테고리의 다른 글

ProObejct Framework  (0) 2022.03.20
What is Servlet Container?  (0) 2021.12.22
jenkins troubleshooting  (0) 2021.03.30
ProObject 설정  (0) 2021.01.22
xml 파일 ^M?  (0) 2020.09.23
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함