ecsimsw

신규 가입자를 놓치지 않기 위한 고민, 이벤트 발행 보장과 포기 본문

신규 가입자를 놓치지 않기 위한 고민, 이벤트 발행 보장과 포기

JinHwan Kim 2024. 6. 1. 15:12

배경 : 회원 가입이 실패되는 것이 옳을까?

Picup 프로젝트에서 회원 가입이 요청되면 Member 서버에서 가입 내용을 기록하고, Storage 서버로 유저 타입과 함께 스토리지 생성을 요청한다.

 

기존에는 쉽게 가입을 실패시켰다. Storage 서버에서 처리에 실패하면 회원가입은 실패되었다. 외부 API 호출과 원자성, 서버 간 정합성이라는 키워드에만 집착해 기술로만 풀이하려고 했던 것 같다, 나라면 회원가입 폼을 열심히 작성했는데, 마지막 최종 제출에서 가입에 실패하면 그 서비스 안 쓸 것 같다. 

 

가입 실패를 최소화하기 위해 외부 API 가 포함된 신규 가입 로직에서 필수적인 이벤트와 그렇지 않은 이벤트 분리를 고민했다. 그리고 각각의 이벤트 처리에서 발생할 수 있는 문제와 해결을 위한 고민을 정리해보았다.

 

이벤트 처리가 보장되어야 하는 경우 / 동기식

이벤트의 정상 처리가 보장되어야 하는 경우 또는 처리 결과가 필요한 경우라면 동기식으로 처리할 수 있을 것 같다. 외부 서버에 스토리지 생성을 요청하고 응답받는다. 만약 API 호출에 실패하거나, 비정상 응답을 확인한다면 회원 가입에 실패했음을 알린다.

 

API 호출이 DB 트랜잭션 밖에서 처리될 수도 있고, 안에서 처리될 수도 있을 것 같다. DB 트랜잭션과 다른 트랜잭션에서 API 요청과 응답이 처리된다면 그 결과에 따라 이미 커밋된 트랜잭션을 롤백하는 보상 트랜잭션이 필요할 것이다. 아래 그림을 예시로 API 호출 (스토리지 생성)이 실패한다면 이미 커밋된 회원 정보를 삭제 처리가 필요할 것이다.

 

반대로 DB 트랜잭션 안에서 이벤트를 처리한다면 예외가 발생했을 때 DB 트랜잭션 자체가 롤백되기에 추가적인 보상 트랜잭션이 필요하지 않는다. 다만, 이벤트 처리 시간 (API 요청~응답) 동안 해당 트랜잭션이 커넥션을 점유하고 있어야 한다. 

 

 

 

이런 동기식 처리는 '이벤트 처리 성공 = 회원 가입 성공'을 완전히 보장하기에 가장 안전할 수 있다. 다만, API 요청, 응답 시간과 외부 서버의 요청 처리 시간을 고스란히 대기해야 하기에 요청 처리 주기가 길어지고 외부 서버에 의존도가 크다. 

 

이벤트 처리 결과를 확인하지 않으면 어떨까? / 비동기식

'스토리지 생성이 실패'가 '회원 가입 실패'로 이어져도 괜찮을까? 차라리 일단 회원 가입은 성공 처리하고, 생성 실패한 스토리지 공간을 이용하는 경우 잠시 문제가 있음을 알리고 이후에 처리하는 식은 어떨까. 회원 가입은 우선 성공시키는 것이, 사용성으로나 비즈니스적으로나 더 낫다고 판단했다.

 

회원 생성 트랜잭션이 정상 커밋되면 스토리지 서버에 유저가 생성되었다는 이벤트만 발행하고 가입 성공을 응답하는 것이다. 이 경우 외부 API 처리 결과를 대기하지 않아 응답 속도가 빠르고 결과에 영향을 받지 않는다. 

 

 

다만 이벤트 발행이 항상 성공한다는 것과는 얘기가 다르다. 만약 이벤트 발행 자체에 실패하는 경우, 이는 다시 전체의 실패 (회원 가입 실패) 로 이어지게 된다. 예를 들어 외부 서버의 다운이나 이벤트 발행 로직 자체의 문제가 전체 요청 처리 실패로 이어진다. 이는 외부 서버로의 이벤트 발행이 회원 트랜잭션보다 앞서도 같다. 외부 서버의 상태가 가입 로직 전체에 영향을 준다.

 

 

그림에는 외부 서버 (Storage server)로 표시했지만 이는 서버 간 통신의 문제만이 아니다. HTTP 요청 대신 파일 처리 이벤트일 수도 있고, Http 요청 대신 Redis, Message queue로 메시지 전달 일 수도 있다. 

 

이벤트 발행을 보장하는 방법

Transaction outbox pattern 은 이벤트 발행 내역을 DB 트랜잭션에 함께 포함시킨다. 이벤트 발행을 다른 DB 처리와 한 트랜잭션으로 묶기 때문에 회원 생성 DB 트랜잭션과 이벤트 발행의 원자성을 지킬 수 있게 된다. 회원 생성 트랜잭션 자체의 문제로 예외가 발생하는 경우가 아니라면, 외부 서버의 상태에 전혀 영향을 받지 않고 회원 가입 로직을 성공시킬 수 있다.

 

이렇게 기록한 이벤트 발행 내역은 주기적으로 읽고 전달하길 반복한다. 이벤트 내용이 휘발되지 않아 장애가 발생해도 복구될 때까지 재시도 가능하다. 외부 서버의 장애로 회원 가입 로직이 실패하는 경우를 없애면서 동시에 언젠간 이벤트가 발행됨을 보장할 수 있었다. 

 

 

위와 같은 문제 예시로 회원 가입 로직에서 Storage 서버가 다운되었다면, 이전에는 이벤트 발행 (비동기 API 콜) 자체를 실패하고 가입 전체를 실패했을 것이다. 또는 가입은 성공시킨다고 할지라도 이벤트는 자체가 휘발되어 무시될 것이다.

 

Transaction outbox pattern 을 적용했을 때는 회원 가입은 성공하면서도, Storage 서버가 다시 복구되면 발행에 실패했던 이벤트가 발행된다.

 

모든 이벤트 발행을 보장해야 할까

가입 로직에서 필수적으로 수행되어야 하는 처리와 그렇지 않은 처리를 잘 구분해야 한다. Picup 에서는 앞선 스토리지 생성 이벤트를 제외한 나머지 이벤트의 발행은 보장하지 않는다.

 

예를 들어 회원 가입 시 로그인 토큰을 발급하는데, 이와 관련된 로직은 트랜잭션에서 분리하여 발생하는 예외를 의도적으로 무시한다. 회원 가입을 성공했으나 로그인 처리에 실패한다면 사용자가 다시 로그인 시도하면 되지, 가입 자체를 놓쳐선 안된다고 생각했다.

 

 

메시지 큐를 활용한 재시도 처리, 데드 레터 처리

위 그림에선 모두 Storage 서버에 직접 요청하는 것처럼 표시했지만 실제로는 Storage에 직접 요청 대신, 메시지 큐를 사용하고 있다.

 

이벤트 발행 자체는 Transaction outbox pattern으로 재시도/보장하면서도, 발행된 메시지가 어떤 Consumer 에 의해 처리되고, 어떻게 재시도되는지는 MQ에게 맡긴다. Prefetch, Durable, Timeout, Retry, 관리 페이지 등 직접 처리했으면 꽤나 귀찮았을 부분을 MQ에서 대신한다.

 

특히 DLQ를 사용하여 재시도 반복에도 실패한 메시지를 보관하고, 문제 해결 후 DLQ의 메시지들을 다시 Recover 결정하거나 메시지 자체에 문제가 있는 경우 수기 삭제 처리하는 등, 재시도 처리에 실패한 메시지를 보관하고 관리하는데 유용했다.

 

 

Rabbit MQ 사용 옵션

Exchange 타입별 라우팅 방식

 

- Direct Exchange : Queue 의 routing key 에 따라 전달된다. 한 개의 queue 가 여러 key 를 가질 수 있고, 여러 queue 가 같은 키를 가질 수도 있다.

- Fanout Exchange : Binding 되어 있는 모든 queue 에 전달된다.

- Topic Exchange : routing key 의 패턴을 만족하는 모든 queue 에 전달한다.

- Headers Exchange : 메시지 헤더의 속성에 만족하는 모든 queue 에 전달한다.

 

Durability

 

Message 가 queue에 저장될 때 디스크에 저장될지, Memory에 저장될지를 결정할 수 있다.

 

디스크에 저장하는 경우 메시지 큐가 다운되었다가 복구되었을 때도 이전 큐 내용을 보존하고 있을 수 있을 것이고,

메모리에 저장하는 경우 유실이 크게 중요하지 않은 이벤트에 적은 비용으로 운영할 수 있을 것이다.

 

Dispatch strategy

 

Queue 에서 consumer 에게 메시지를 반드시 하나씩 전달해야 하는 것은 아니다.

MQ는 설정된 dispatch 알고리즘에 따라 번갈아 메시지를 전달하고 consumer 는 이를 메모리에 저장해 뒀다가 하나씩 꺼내 처리한다.

 

이렇게 consumer 가 메모리에 쌓아둘 수 있는 메시지 최대 개수를 prefetch 옵션으로 설정할 수 있다.

Rabbit MQ의 기본 prefetch 값은 20이다.

 

Prefetch 를 크게 하는 것이 좋을까?

 

prefetch 가 크면 메시지 전달에 패킷 전달 횟수가 줄어 메시지 전달에 효율적이다.

그렇다고 prefetch를 너무 크게하는 경우 작업 노드 간 불균형이 발생해 처리 효율이 떨어진다.

 

작업의 처리 주기가 길고 작업 간 처리 시간이 불균등한 경우 prefetch를 작게, 그렇지 않다면 prefetch를 크게 하는 것이 좋다.

 

Options for cost

 

- Auto delete : Consumer 가 없다면 Queue 를 자동 삭제한다.

- Message TTL : 각 메시지에 TTL 를 적용하여 메시지 삭제 기간을 지정할 수 있다.

- Length limit : 메시지 크기에 제한을 둬 그 이상의 메시지는 유실 처리하거나 Dead letter 로 유도할 수 있다.

- Time out : 처리 노드가 너무 많은 시간을 메시지에 사용하고 있으면 이를 문제 사항으로 여길 수 있다.

 

Dead letter queue

 

메시지 처리 중 문제가 생긴다면 재시도 정책에 따라 재시도 처리된다.

만약 처리 자체가 불가능한 경우는 어떨까. 재시도 중 문제 발생 -> 재시도 처리를 무한 반복하게 될 것이다.

 

이렇게 재시도 처리 후에도 실패하는 메시지를 Dead letter 라고 한다.

Rabbit MQ에선 메시지의 헤더에 "x-dead-letter-exchange", "x-dead-letter-routing-key" 를 키로 Dead letter 발생 시 어떤 exchange 에서, 어떤 라우팅 키로 처리될 것인지 설정하게 된다.

Comments