메세징 대행사를 A 대행사(기존)에서 B 대행사(신규)로 전환하는 작업을 진행했습니다. 대행사로 메세지 전송을 요청하고 응답받는 과정에서 대행사간의 차이가 있어서 문제가 발생했습니다.
(기존) A 대행사의 방식:
(신규) B 대행사의 방식:
A 대행사의 경우 처리 결과를 상세하게 알려줍니다. 알림톡으로 요청했을 경우 실제로 알림톡이 전달되었는지, 아니면 어떤 이유에 의해서 SMS 혹은 LMS로 전달되었는지 Response로 즉각 응답을 해줍니다.
반면에 B 대행사는 처리 결과를 자세하게 알려주지 않습니다. 메세지 템플릿, 파라미터에 대한 검증 정도만 이뤄지고 실제 발송 유무와 관계없이 B 대행사와의 Connection이 정상적으로 이뤄지게되면 무조건 성공했다고 response가 오도록 되어있었습니다. 전송 수단에 대해서도 전혀 알 수 없었습니다. 대신 B 대행사는 리포트를 추후에 조회할 수 있습니다. 리포트는 상세한 처리 결과에 대해서 응답을 내려줍니다.
발송된 메세지를 정산할 때 DB에 저장된 발송 내역을 합산하여 청구합니다. 기존 A대행사를 사용할 때는 즉각적인 Response가 왔기 때문에 DB 값을 신뢰할 수 있었지만 B 대행사로 변경하게 되면서 DB 값을 항상 신뢰할 수 없게 되었습니다. 추가로 Report를 조회하는 작업이 필요했고 이를 기반으로 DB 값을 업데이트하는 과정이 필요해졌습니다.
Java + Spring 환경에서 이 문제를 해결하려면 @Async
와 ScheduledExecutorService
를 사용해서 메세지 발송 요청이 이뤄지고 난 뒤에 특정 시간 뒤에 report를 조회해서 DB를 업데이트 하는 방식으로 구현했을 것 같습니다. 하지만 저희는 Ruby On Rails 환경의 코드가 작성되어있기 때문에 같이 사용하고 있는 SideKiq을 이용해서 구현했습니다.
SideKiq은 Ruby On Rails에서 백그라운드 작업을 처리하는 라이브러리입니다. 멀티 스레딩을 지원하여 효율적으로 서버를 활용할 수 있도록 도와주고, 복잡하거나 시간이 오래 걸리는 작업을 백그라운드에서 처리할 수 있게 됩니다.
Redis를 같이 이용해 백그라운드 작업을 작업큐로 저장합니다. 개별 작업은 Ruby 객체로 구성되며, 이 객체는 Redis에 저장되기 전에 JSON으로 직렬화됩니다. 반대로 작업이 처리될 때, 이 JSON 데이터는 다시 Ruby 객체로 역직렬화되어 작업이 수행됩니다.
Sidekiq의 작업 큐는 Redis의 리스트 데이터 구조를 사용하며, 다양한 Sidekiq 작업자가 이 큐를 모니터링하고 작업을 가져와 처리합니다. 또한, Sidekiq은 실패한 작업을 추적하고 재시도하기 위해 Redis의 세트 데이터 구조를 사용합니다.
A 대행사와 마찬가지로 B 대행사에 메세지 발송 요청을 보냅니다. 반환되는 Response를 이용해서 연결에 성공했을 경우 Job을 생성하는 메서드를 만들어줬습니다. Job이란 SideKiq의 큐에서 처리하는 작업의 단위입니다.
코드를 전부 보여드릴 수는 없지만 MessageSender
는 대략 아래와 같은 흐름으로 작동하게 됩니다.
def send_message
response = execute_request(get_headers, params)
process_response(response)
end
def process_response(response)
message_log = create_message_log(response)
UpdateMessageLogJob.perform_in(10.seconds, message_log.id, Time.now.strftime("%Y-%m-%d"))
end
메세지 전송을 요청에 성공하면 message_log를 생성하고 UpdateMessageLogJob
이라는 ActiveJob을 큐에 생성합니다. 메세지가 실제로 발송하는데까지 약간의 딜레이가 있기 때문에 10초의 딜레이를 주고 실행할 수 있도록 설정했습니다.
기존에는 message_log
에서 메세지 발송 성공 여부를 MessageSender
에서 알 수 있었지만 B 대행사로 바뀌게 되면서 상태를 여기서 결정할 수 없게 됐습니다. 따라서 메세지 발송중
이라는 상태로 초기에 설정해놓고 Job에서 결과를 조회한 뒤에 업데이트를 해주는 방식으로 변경했습니다.
UpdateMessageLogJob
을 통해서 리포트를 조회하고, 필요한 정보를 추출합니다. 그리고 성공 여부에 따라서 messag_log
를 업데이트하는 작업을 수행합니다.
class UpdateMessageLogJob
include Sidekiq::Job
def perform(message_log_id, request_date)
response = fetch_report()
sent_message_info = extract_sent_message_info(response)
update_message_log_with_report(sent_message_info, message_log)
end
end
하지만 이 Job은 실패할 가능성이 있습니다. fetch_report()
메서드를 호출할 때 B 대행사에 문제가 발생해서 요청이 정상적으로 이뤄지지 않을 수도 있고, 처리가 지연돼서 리포트가 안만들어졌을 수도 있습니다. SideKiq의 Job에서는 이런 상황에 재시도할 수 있도록 하는 기능을 지원합니다.
작업 중 예외가 발생하면 Sidekiq은 자체적인 재시도 메커니즘을 이용해 해당 작업을 재실행합니다. 따라서 실패한 작업도 안전하게 복구할 수 있습니다.
하지만 만약 최대 재시도 횟수동안 문제가 해결되지 않는다면, Sidekiq은 재시도를 멈추고 작업을 'Dead set'으로 이동시킵니다. 이후에는 수동으로 작업을 재시도할 수 있습니다. 단, 6개월 동안 재시도되지 않은 작업은 Sidekiq에 의해 자동으로 삭제됩니다.
class UpdateMessageLogJob
include Sidekiq::Job
sidekiq_options retry: 5
sidekiq_retries_exhausted do |job|
message_log_id = job['args'].first
MessageLog.find(message_log_id).timeout!
end
def perform(message_log_id, request_date)
response = fetch_report()
sent_message_info = extract_sent_message_info(response)
update_message_log_with_report(sent_message_info, message_log)
end
end
재시도 횟수의 기본 값은 25회인데 너무 많아서, 5회로 줄였습니다. 추가로 재시도 횟수가 초과됐을 경우에는 MessageLog가 계속 발송중
인 상태로 남아있게 되는 문제가 생기기 때문에 시간초과
라는 상태로 업데이트 할 수 있도록 기능을 추가했습니다.
Sidekiq에서는 재시도 횟수가 초과되면 정의된 sidekiq_retries_exhausted
Hook을 호출합니다. 이 Hook은 큐에 있는 작업 해시를 인수로 받아, Sidekiq이 작업을 Dead set
으로 이동시키기 직전에 호출됩니다. 이를 통해 재시도 횟수가 초과된 작업에 대해 추가적인 작업을 수행할 수 있습니다.
익숙한 Java와 Spring으로 계속 개발을 하다 Ruby로 개발을 시작하게 되면서 시야가 넓어짐을 느끼고 있습니다. 이번에 작업을 진행하면서 SideKiq의 다양한 기능을 사용해볼 수 있었고 재시도 처리를 하면서 Rails와 SideKiq의 작동방식에 대해서 파악할 수 있는 계기가 되었습니다.
마지막 재시도 횟수 초과시 함수를 호출하는 과정에서 Rails쪽 메서드를 사용하게되어 비정상적으로 작동하고 있었는데, 이를 모르고 이틀동안 왜 안될까 고민하면서 어쩌다보니 SideKiq의 Job, Queue의 구조를 파악하면서 공부할 수 있었습니다. 알면 알수록 Ruby라는 언어와 Rails의 생태계는 정말 잘 만들어졌다는 생각이 드는 것 같습니다.