들어가며

작년 말, IfKakao 2024에서 카카오페이에서 지연이체 개발기란 제목의 세미나가 공개됐습니다. Kafka를 활용해 지연이체 서비스를 설계한 과정이 담겨있었는데요. 개인적으로 굉장히 흥미롭게 봤기 때문에, 직접 설계 과정을 따라가보면 왜 카카오페이에서 세미나에 녹여냈던 선택들을 했는지 더 잘 이해할 수 있고 그 과정에서 공부가 많이 되겠다는 생각이 들어, 회사 동기들과 직접 Kafka를 활용해 예약이체 서비스를 사이드 프로젝트 수준에서 설계해봤습니다.

 

참고로 실제 이체 프로세스가 어떻게 정교하게 흘러가는지는 잘 몰랐기 때문에.. 이체 프로세스는 출금계좌의 돈을 빼고, 타행이체일 경우는 API호출을 가정하여 1초간 Thread.sleep()을 하는 수준으로만 가정하고 설계를 진행했습니다.

 

본 글은 다음 목차로 진행됩니다.

 

  1. 예약이체란?
  2. 개략적인 설계
  3. 세부 프로세스 설계
  4. 속도 높이기
  5. 소감
  6. Reference

 

1. 예약이체란?

 

이 글에선 은행 점검 시간에 송금 건을 예약하여 은행 점검 시간 이후 자동으로 예약된 송금 건이 실행되도록 하는 것을 말하도록 하겠습니다. 참고로 송금 예약을 눌렀을 때 어떻게 저장하는지는 여기서 다루지 않고, 저장된 예약이체 건을 실행하는 것에 대한 아키텍처와 프로세스만을 다뤘습니다.

 

2. 개략적인 설계

다음 내용들에 대한 개략적인 설계입니다.

 

  1. 데이터 스키마 설계
  2. API 설계
  3. 개략적인 아키텍처와 프로세스 

 

1) 데이터 스키마 설계

예약이체 테이블 (scheduled_transfer)

필드 설명 자료형
scheduled_transfer_id (PK) 예약이체 건 식별자 bigInt
from_account 출금계좌(source) varchar(20)
to_account 송금계좌(destination) varchar(20)
to_bank_code 송금은행코드 varchar(10)
transfer_amount 송금금액 decimal(15, 2)
transfer_available_dttm 송금가능일시 datetime
status 예약이체 건 상태 tinyInt
scheduled_dttm 예약일시 datetime

 

예약이체 건 상태값으론 0, 1, 2 등이 저장되며 각각 PENDING, COMPLETED, FAILED 등을 의미합니다. 신규 상태값이 추가될 수도 있음을 고려해 enum이 아닌 tinyInt타입을 사용하도록 했습니다. 마지막 수정일시 등의 컬럼도 필요하겠으나 위 표에서는 생략했습니다.

 

고객 정보, 계좌 정보, 은행 정보에 대한 스키마는 아주 간단한 수준(식별자, 고객명, 은행코드, 은행명 등만 있는 수준)으로만 설계했으므로 따로 작성하진 않겠습니다.

 

2) API 설계

앞서 말씀드렸듯 예약이체 건을 저장하는 프로세스는 다루지 않았기 때문에, 예약이체 건을 저장하는 API는 설계하지 않았습니다.

 

POST /v1/scheduled-transfers/:id/execute

예약 이체 건을 실행하는 API입니다. id는 예약이체 건의 식별자이며, API 호출 시 body에 담아 전달하는 인자들은 다음처럼 설계했습니다.

필드 설명 자료형
from_account 출금계좌(source) varchar(20)
to_account 송금계좌(destination) varchar(20)
to_bank_code 송금은행코드 varchar(10)
transfer_amount 송금금액 decimal(15, 2)

 

 

3) 개략적인 아키텍처와 프로세스

가장 초기에 구상한 아키텍처와 프로세스는 다음과 같습니다.

 

각 컴포넌트별 역할 및 프로세스

  1. 스케쥴러(Producer) : scheduled_transfer 테이블에 PENDING상태로 있는 예약이체 건들 중 송금시간이 다가온 것들(송금가능일시 <= 현재시간)을 5분 주기로 조회하여 Kafka로 발행하는 역할을 합니다.
  2. Kafka : 스케쥴러가 발행한 예약이체 건들에 대한 브로커 역할을 합니다. 예약이체 건들은 scheduled-transfer라는 토픽에 저장되며, 해당 토픽은 파티션 3개로 구성했습니다.
  3. Consumer : Kafka에 발행된 예약이체 건들을 가져와서 코어뱅킹 서버로 이체 실행 요청을 보내는 역할을 합니다. 각 Consumer들은 Kafka 파티션들과 1 : 1로 대응되도록 3개로 구성했습니다.
  4. 코어뱅킹 서버 : Consumer로부터 요청받은 예약 이체 건을 실행하는 내부 서버 역할을 합니다.

 

완료된 예약 이체 건의 상태 갱신은 누가 할까?

초기엔 단순하게 Consumer에서 코어뱅킹 서버로 이체 실행 요청을 보낸 후, 응답값에 따라 Consumer에서 예약 이체 건들의 상태를 갱신하는 프로세스를 구상했습니다. 그러나 다음과 같은 케이스들이 발생할 수 있었습니다.

 

  1. 코어뱅킹 서버에서 이체 실행은 완료됐으나 네트워크 오류 등으로 Consumer로 응답을 못 준 경우
  2. 코어뱅킹 서버로부터 응답은 왔으나 Consumer에서 예약 이체 건의 상태 갱신에 실패하는 경우
  3. 등등..

 

예약 이체 테이블(scheduled_transfer)과 계좌 테이블들이 같은 DB에 있었기 때문에, 코어뱅킹 서버에서 이체 실행과 예약 이체 건의 상태 갱신을 하나의 DB 트랜잭션에서 처리하도록 설계하여 위 문제를 해결할 수 있겠다는 생각이 들었습니다. 그러나 실무 상황이라면 예약 이체 테이블과 계좌 테이블이 다른 DB에 있다든가, 코어뱅킹 서버에서 예약 이체 테이블이 있는 DB로 접근할 수 없다든가 하는 제약들이 따를 수 있습니다. 따라서 코어뱅킹 서버에서 예약이체 건 식별자(scheduled_transfer_id)에 대한 멱등 처리를 통해 중복 이체 실행을 막고, 기 실행됐던 예약 이체 건에 대한 실행 요청을 받은 경우 이미 실행된 건임을 알리는 응답을 주도록 한 뒤 Consumer에서 해당 응답에 따라 예약 이체 건들의 상태를 갱신하도록 설계했습니다.

 

 

리소스 낭비 방지를 위한 Consumer 단 필터링 프로세스 추가

스케쥴러가 예약 이체 건들을 kafka로 5분 주기로 발행해주므로, 이전에 발행한 건이 아직 실행되지 않았다면 스케쥴러가 동일한 예약 이체 건을 Kafka로 여러 번 발행할 수 있습니다. 물론 코어뱅킹 서버에서 예약 이체 건 식별자(scheduled_transfer_id)에 대한 멱등 처리를 해줘서 실제로 이체가 중복으로 발생하지 않도록 처리했으나, 처리될 필요가 없는 예약 이체 건도 코어 뱅킹 서버로 전달되어 리소스를 낭비하게 되는 상황은 발생 가능하다고 생각했습니다. 만약 코어뱅킹 서버에서 멱등 처리가 제대로 기능하지 않게 된다면 이는 리소스 낭비에서 끝나지 않고 중복 이체 발생으로도 이어질 수 있습니다. 따라서 Consumer에서 Kafka에서 가져온 예약 이체 건들의 PENDING 여부를 확인 후 필터링된 건들만 이체 실행 요청을 보내도록 했습니다.

 

정리하면 다음과 같은 프로세스를 설계하게 됐습니다.

 

 

3. 세부 프로세스 설계

1) Consumer 단 필터링을 거쳐도 리소스 낭비가 가능했다

스케쥴러가 Kafka로 동일한 예약 이체 건을 중복으로 쌓아두는 상황은 여전히 가능한 상황이었습니다. Consumer에서 상태가 PENDING인지 체크하는 과정을 추가하여 처리할 필요가 없는 예약 이체 건에 대한 리소스 낭비를 막고자 했지만, 만약 동일한 예약이체 건들이 서로 다른 Consumer에서 동시에 실행된다면 다음과 같이 여전히 리소스 낭비가 발생할 수 있었습니다. 

 

위 그림에선 코어뱅킹 서버에서의 멱등 처리로 인해 한 쪽에서는 이체가 실패하여 리소스 낭비에서 끝나게 됐지만, 앞서 설명했듯 만약 코어뱅킹 서버에서 멱등 처리가 제대로 기능하지 않게 된다면 리소스 낭비에서 끝나지 않고 중복 이체 발생으로 이어질 수 있습니다. 이 문제를 해결하려면 동일한 예약 이체 건 여러 개가 동시에 실행되는 것을 제어할 수 있어야 한다고 생각했습니다.

 

구체적인 방법으로는 다음 방법들을 구상했습니다.

 

방법 1. 하나의 예약 이체 건을 정확히 한 번만 Kafka로 발행하자

애당초 동일한 예약 이체 건이 Kafka에 여러 번 중복으로 발행되지 않는다면 동일한 예약 이체 건들이 동시에 실행되는 상황 자체가 벌어지지 않는다는 점을 활용한 해결 방법입니다. 대표적인 구현 방법으로는 스케쥴러가 PENDING 상태인 예약 이체 건들을 조회해서 Kafka에 발행해줄 때, 해당 예약 이체 건들의 상태를 PUBLISHED로 바꿔주는 방법이 있습니다.

 

이 방법은 

 

  1. 스케쥴러가 DB에 해당 예약 이체 건의 상태를 PUBLISHED로 갱신하는 작업
  2. 스케쥴러가 Kafka에 해당 예약 이체 건을 발행하는 작업

 

으로 구분할 수 있으며, 하나의 예약 이체 건을 정확히 한 번만 Kafka로 발행한다는 제약을 지키려면 두 작업의 원자성이 보장되어야 합니다. DB 트랜잭션을 통해 예약 이체 건의 상태를 PUBLISHED로 갱신한 다음 Kafka로 예약 이체 건을 발행하는 작업의 성공/실패 여부에 따라 트랜잭션을 커밋 or 롤백하면 된다고 생각했으나, 다음과 같은 상황도 충분히 발생할 수 있었습니다.

 

 

즉 하나의 DB 트랜잭션만으로는 두 작업의 원자성을 완벽하게 보장할 수 없었습니다. 2 Phase Commit이나 트랜잭셔널 아웃박스 등을 활용하는 방법이 있겠지만, 결국 "어떤 상황이 닥쳐도 하나의 예약 이체 건을 정확히 한 번만 발행해준다"라는 것을 구현하기는 굉장히 어렵겠다는 생각이 들었습니다. 또한 어찌저찌 해서 정확히 한 번 발행에 성공한다고 해도 다음과 같은 상황들을 고려해야 했습니다.

 

  1. 정확히 한 번 발행된 예약 이체 건을 실행하다가 중간에 실패하면 재시도는 어떻게 할 것인가? 다시 예약 이체 건의 상태를 PENDING으로 바꾸게 할 것인가?
  2. 정확히 한 번 발행된 예약 이체 건을 Consumer에서 중복으로 consume하게 되는 상황이 오면 어떻게 할 것인가? 정확히 한 번 발행됐어도, 해당 예약 이체 건을 실행하는 것도 정확히 한 번만 실행되는 것을 어떻게 처리해줄 것인가?
  3. 등등..

 

즉 정확히 한 번 발행에 성공해도 논리적인 관점 등에서 데이터의 일관성이 깨질 수 있는 여러 샛길들이 많다고 생각했습니다. 따라서 하나의 예약 이체 건을 정확히 한 번만 발행한다는 제약 조건을 지키기 위해 그런 샛길들을 모두 고려하며 구현하는 것보다는, 같은 이체 건 여러 개가 동시에 실행되는 것을 제어할 수 있는 다른 방법을 적용하는 것이 더 낫다고 판단했습니다.

 

 

방법 2. 계좌 락을 걸어보자

두 번째로 구상한 방법은 동시성 제어에 보편적으로 많이 활용되는 "락"을 활용하는 것이었습니다. Consumer에서 예약 이체 건의 상태를 체크하는 부분부터 Consumer에서 예약 이체 건의 상태를 갱신하는 부분에 락을 걸어 같은 이체 건이 동시에 실행하는 것을 제어하자는 아이디어였습니다. 예약 이체 건의 상태 체크와 상태 갱신 모두 Consumer에서 수행되고 있기 때문에, Consumer에서 예약 이체 테이블(scheduled-transfer)에 SELECT FOR UPDATE를 통해 배타 락을 거는 방법을 우선적으로 고려했습니다. 그러나 이 방법은 락을 걸고 코어뱅킹 서버로 요청한 이체 실행의 응답이 오기까지 DB 커넥션이 늘어질 여지가 있었기 때문에, 다른 형태로 락을 걸어보자고 생각했습니다. 그 결과 예약 이체 건에 계좌 락을 거는 형태를 고려하게 됐습니다.

 

계좌 락을 얻는 방법으론 계좌 락을 위한 테이블을 별도로 만들어 활용하는 방법과 분산 락을 활용하는 방법이 있었습니다. 전자의 경우 예약 이체 테이블(scheduled-transfer)에 배타 락을 거는 방법과 동일하게 락을 걸고 코어뱅킹 서버로 요청한 이체 실행의 응답이 오기까지 DB 커넥션이 늘어질 여지가 있다고 판단했습니다. 또한  출금계좌에 락을 거는 것은 결국 전체 시스템에서 해당 계좌로 접근하는 스레드를 하나로 제한하기 위함이기도 한데, 실무에서는 계좌 정보를 하나의 DB에서 관리하지 않고 여러 시스템 또는 모듈들이 독립적인 DB를 사용 중일 수 있고 하나의 서버가 여러 서버로 분리될 수도 있기 때문에 테이블 기반의 계좌 락은 전체 시스템에서 해당 계좌로 접근하는 스레드를 하나로 제한하기엔 확장성이 낮다는 생각이 들었습니다. 따라서 분산 락을 활용해 계좌 락을 구현하는 것으로 결정했습니다.

 

분산 락을 통해 같은 이체 건들이 동시에 실행되는 것을 제어하게 되는 과정을 나타내면 다음과 같게 됩니다.

 

 

 

 

 

2) 예약 이체 건이 송금가능일시가 지나도 장기간동안 실행되지 않는 이슈가 발생

하지만 이렇게까지 해놓고 테스트를 돌려보니, 특정 예약 이체 건들이 송금가능일시가 지나도 1 ~ 2시간 이상 실행되지 않는 이슈가 발생했습니다. 

 

원인은 위 흐름도와 같이 출금계좌는 같지만 엄연히 서로 다른 이체 건들이 동시에 실행될 때 한 쪽에선 분산 락 획득을 실패하여 이체가 실행되지 않던 상황이 생길 수 있었다는 것이었습니다. 락을 획득할 때까지 대기시키기는 방법 등이 떠올랐으나 그렇게 되면 현재 구조에선 뒤에 쌓이는 예약 이체 건들이 밀릴 수 있었고, 대기시키는 방법도 이체 실행이 지연되는 건 마찬가지이니 근본적인 해결법은 아니라고 생각했습니다. 따라서 락 경합 자체를 줄일 수 있는 방법들을 고려하게 됐습니다.

 

 

방법 1. 분산 락 키를 바꾸자

현재는 출금계좌를 키로 해서 분산 락을 잡고 있었는데, 다른 값을 키로 잡으면 되지 않을까라는 아이디어였습니다.

 

출금계좌는 각 예약 이체 건들의 고유값이 아니기 때문에 출금계좌를 분산 락 키로 잡으면 서로 다른 이체 건들끼리도 락 경합이 발생 가능합니다. 반면 예약 이체 건 식별자(scheduled_transfer_id)는 각 예약 이체 건들의 고유값이므로 여기에 분산 락 키를 잡아주게 되면 같은 예약 이체 건들이 동시에 실행되는 상황에 한해서만 락 경합이 발생됩니다. 따라서 락 경합을 줄이려는 목적을 달성할 수 있다는 생각이 들었습니다.

 

다만 앞서 말했듯 출금계좌에 락을 거는 것은 시스템 전체에서 해당 계좌로 접근하는 스레드를 하나로 제한하는 효과를 줍니다. 예약 이체 건 식별자에 대해 락을 걸면 락 경합은 분명 줄어들겠지만 해당 계좌로 접근하는 스레드가 여러 개가 될 수 있어 예상치 못한 동시성 관련 문제를 안겨줄 수 있습니다. 이체는 결국 오류없이 수행되는 것이 가장 중요하다고 생각했기 때문에 분산 락 키는 출금계좌를 그대로 쓰게 하여 해당 계좌로 접근하는 스레드를 하나로 제한시켜 안전성을 높이고, 대신 락 경합을 줄일 수 있는 다른 방법을 고려해보기로 했습니다.

 

 

방법 2. 예약 이체 건들을 출금계좌별로 파티셔닝하자

두 번째 방법은 예약 이체 건들을 출금계좌별로 같은 파티션에 가게끔 설정하는 아이디어입니다. 같은 출금계좌를 가진 서로 다른 예약 이체 건들이 동시에 실행되는 이유는 이들이 서로 다른 Consumer에서 실행될 수 있기 때문인데요. 현재 구조(3개 파티션, 3개 Consumer)에서는 각 Consumer들이 서로 다른 파티션을 담당하고 있기 때문(Kafka는 기본적으로 파티션 하나에 같은 Consumer 그룹 내에선 단일 Consumer 스레드가 할당됨)에 같은 출금계좌를 갖는 예약 이체 건들을 같은 파티션으로 발행하게 되면 하나의 Consumer에서만 예약 이체 건들을 처리하게 됩니다. 또한 현재 하나의 Consumer 스레드에서는 담당하는 파티션에 발행된 예약 이체 건들을 순서대로 처리 중이므로 이 경우 같은 출금계좌를 갖는 예약 이체 건들이 동시에 실행되는 것이 방지되는 효과를 가져올 수 있습니다.

 

Kafka Producer(스케쥴러)에서 메시지를 발행할 때 어떤 파티션으로 발행할지는 Partitioner라는 컴포넌트가 담당하는데요. kafka-clients 3.6.2 기준으로, 메시지 발행 시 키를 지정하지 않는다면 다음 그림과 같이 Sticky partitioning전략을 통해 메시지를 파티션으로 발행하게 됩니다.

 

Sticky partitioning은 하나의 파티션을 유지하며 메시지를 발행하다가 특정 조건(배치 크기 초과, 시간 초과 등)이 충족되면 새로운 파티션을 선택하여 메시지를 발행하는 전략으로, 같은 파티션에 연속해서 메시지를 발행하므로 배치 최적화가 가능해지고 불필요한 파티션 변경을 줄여 네트워크 처리 비용 등을 감소할 수 있다는 장점이 있습니다. 다만 현재 설계 중인 예약 이체 시스템과 같이 동시에 실행되면 안 좋은 메시지들(ex: 같은 출금계좌를 가진 서로 다른 예약 이체 건들)이 각기 다른 파티션에 분배되어 동시에 실행되는 문제를 가져다 줄 수 있습니다.

 

반면 Kafka Producer(스케쥴러)에서 메시지를 발행할 때 키를 지정하게 된다면, Partitioner는 다음과 같이 키를 기반으로 파티션을 지정하여 메시지를 발행하게 됩니다.

 

이 경우 같은 출금계좌를 갖는 예약 이체건들은 같은 파티션에 할당되므로, 이들이 동시에 실행되는 상황이 방지되게 됩니다.

 

이렇게 해두고 보니.. 스케쥴러에서 동일한 예약 이체 건을 발행해도 이들은 동일한 파티션으로 할당되고(물론 리밸런싱 등이 발생하면 다른 파티션에 발행될 순 있음), 앞서 말씀드렸듯이 현재 하나의 Consumer 스레드에서는 담당하는 파티션에 발행된 예약 이체 건들을 순서대로 처리 중이므로 동일한 예약 이체 건들이 서로 다른 Consumer에서 동시에 실행되는 상황도 없어지게 됩니다. 즉, 동시성 문제 해결을 위해 분산 락을 사용할 필요가 없어지게 됩니다. 분산 락을 획득하고 해제하는 것도 네트워크 I/O가 수반되는 것이므로 "그러면 이제 분산 락을 굳이 할 필요가 없어졌는데?"라고 생각했으나, 역시나 앞서 말씀드렸듯이 출금계좌에 락을 거는 것은 시스템 전체에서 해당 계좌로 접근하는 스레드를 하나로 제한하는 효과를 줍니다. 예약 이체 시스템이 아닌 다른 시스템에서도 해당 계좌에 접근하는 로직 등이 있을 수 있고, 이들도 분산 락을 통해 계좌 락을 걸어주고 있다면 예약 이체 시스템을 넘어서 시스템 전체에서 해당 계좌로 접근하는 스레드가 하나가 된다는 얘기입니다. 따라서 동시성 문제를 제어할 필요성이 없어지긴 했어도 시스템 전체에서의 안전성을 높이기 위해 여전히 분산 락을 잡아주는 것은 유효하다고 판단하고, 계속해서 분산 락을 사용해도 된다고 생각하게 됐습니다.

 

 

4. 속도 높이기

1) Batch Read 설정

현재 설정은 Consumer들이 각자가 담당하는 파티션에 발행된 예약 이체 건들을 하나 하나 가져와서 처리하고 있는데요. 이런 상황에서 처리량을 높이고 싶을 땐 대표적으로 "일괄 처리"를 도입해볼 수 있고 Consumer에서도 Batch read를 설정하여 파티션에 발행된 예약 이체 건들을 일괄로 가져와서 처리할 수 있습니다.

 

다만 Consumer에서 특정 개수만큼 예약 이체 건들을 모을 때까지 기다렸다가 일괄로 가져가는 형태가 되기 때문에 각 예약 이체 건의 입장에서 보면 실시간성이 떨어질 수 있음을 고려해야 합니다. 예약 이체 시스템의 경우 송금 가능 일시가 되자마자 즉각적인 이체가 발생되어야 하는 서비스는 아니라고 판단하고 Batch Read를 도입해도 된다고 판단했습니다.

 

2) 병렬 처리 및 출금계좌별 스레드 지정

Batch Read로 예약 이체 여러 건을 한 번에 가져와도 기본적으로는 하나의 Consumer 스레드가 이들을 처리합니다. 따라서 처리량을 높이기 위해 복수의 스레드로 이들을 병렬 처리하는 방식을 도입할 수 있습니다.

 

다만 이 경우 같은 출금계좌를 가진 예약 이체 건들이 서로 다른 스레드에서 동시에 실행될 수 있고 이는 잦은 락 경합으로 이어질 수 있습니다. 이전에 출금계좌를 기준으로 특정 파티션에 발행되도록 설정했듯이, Consumer에서 출금계좌별로 처리할 스레드를 따로 지정주어 이 문제를 해결할 수 있습니다.

 

 

5. 소감

저랑 동기들이 설계했던 부분은 여기까지입니다. 막상 돌이켜보니 사실상 카카오페이 세미나에서 발표된 내용과 큰 차이가 없긴 합니다만.. 그래도 특정 문제(카카오페이에서 맞닥뜨린)가 생겼을 때 "이런 방법도 있었을 텐데 왜 이건 쓰지 않았을까?"라는 고민들이 드는 지점이 많았는데 직접 설계를 해보니 "아 이래서 그랬구나"라고 더 이해가 잘 되는 부분들이 많았던 것 같습니다. 예를 들면 "예약 이체 건들을 계좌별로 파티셔닝되게 했다면 분산 락이 필요없어진 것 같은데 왜 안 뺐을까? -> 아 안전성을 위해서였구나!" 등등.. 뭔가 다른 사람들이 설계하는 과정을 따라가보는 것은 처음이었는데 그 안에서 배운 것들이 굉장히 많네요. 또한 스스로 아직 많이 부족함을 느끼게 된 것 같기도 합니다.

 

준비하는 과정에서도 여러 레퍼런스(개인이 올린 블로그 글들은 다 비슷한 감들이 많아.. 테크블로그에서 발행한 컨텐츠들을 최대한 참고하려고 했습니다)들을 봤습니다. 신뢰성 보장을 위해 여러 고민 끝에 기술을 도입하고, 그 기술의 도입으로 인한 리스크들도 기술적인 고민들을 가미하며 해결하는 과정들이 상당히 흥미로웠습니다. 저도 그런 엔지니어가.. 그런 전문성을 가진 사람이 되고 싶다는 생각이 드네요. 더욱,, 정진해야겠습니다.

 

 

6. Reference

1. 지연이체 서비스 개발기 (카카오페이)

https://youtu.be/LECTNX8WDHo?si=8cn67Fbr4CbDtkaA

 

2. 분산 시스템에서 메시지 안전하게 처리하기 (강남언니)

https://blog.gangnamunni.com/post/transactional-outbox/

 

3. 풀필먼트 입고 서비스에서 분산락을 사용하는 방법 (컬리)

https://helloworld.kurly.com/blog/distributed-redisson-lock/

 

4. Kafka 메시지 중복 및 유실 케이스별 해결 방법 (올리브영)

https://oliveyoung.tech/2024-10-16/oliveyoung-scm-oms-kafka/

 

5. SLASH 22 - 애플 한 주가 고객에게 전달 되기까지 (토스증권)

https://youtu.be/UOWy6zdsD-c?si=iTtbPbEDpFaWdPt8

 

 

 

들어가며

작년 말 운좋게 금융권 인턴 취업에 성공하고, 올해 무사히 정직원 전환에 성공했습니다. 금융권에 재직 중이고, 돈을 벌기 시작하면서 금융과 경제에 대한 관심이 자연스럽게 많아지고 있는데요. 문득 출근길 지하철에서 금융 포스트들을 하나씩이라도 읽으면 좋겠다는 생각이 들었고, 매일 아침 출근길마다 읽기 좋은 금융 포스트들의 링크를 보내주는 서비스들을 만들면 어떨까라는 생각으로 이어졌습니다.

 

그와 동시에 실제로 이 서비스를 간단히 운영해보고 싶다는 생각이 들었고, 운영 과정에서 발생하는 비용을 최소화하는 걸 목표로 삼았는데요. 이 "출근길 금융 한 잔"이라는 간단한 서비스를 개발한 과정을 소개해드리려고 합니다.

 

 

1. 어떤 플랫폼의 포스트들을 보낼까?

첫 번째로는 어떤 글들을 보낼 것인가를 고민했습니다. 아무래도 스스로를 위한 서비스인만큼 제가 잘 읽힌다고 생각되는 플랫폼들을 선정했는데요. 고민 끝에, 카카오뱅크와 토스뱅크에서 운영하는 플랫폼의 글들을 보내주기로 결정했습니다.

 

https://brunch.co.kr/@kakaobank#articles

 

카카오뱅크의 브런치스토리

이미 모두의 은행. 카카오뱅크 공식 브런치입니다.

brunch.co.kr

https://blog.toss.im/

 

금융이 알고 싶을 때, 토스피드

콘텐츠도 토스가 만들면 다릅니다

blog.toss.im

 

 

 

2. 이 플랫폼의 포스트들을 어떻게 가져올까?

처음에는 크롤링을 통해 포스트들을 감싸고 있는 <ul>태그나 <ol태그>를 찾은 뒤, 그 안에 있는 목록들을 가져오는 방법을 구상했습니다. 그러다가 문득 "카카오뱅크나 토스뱅크도 처음에 블로그에 들어갈 때 특정 API를 통해서 포스트 목록들을 가져오지 않을까?"라는 생각이 들더라구요. 개발자 도구 등을 사용해서 그 API를 제가 딸수만 있다면 크롤링보다 훨씬 적은 공수로 포스트들을 가져와서 활용할 수 있겠다는 생각에 바로 개발자 도구를 켰습니다. 

 

 

Network 탭을 까본 결과, 카카오뱅크와 토스뱅크가 사용하는 API들을 어렵지 않게(?) 따올 수 있었습니다. 그러면 그 API의 응답값을 토대로 특정 포스트의 no값이나 key값을 딸수가 있었는데요, 카카오뱅크와 토스뱅크의 블로그들은 그 값들을 이용해서 다음과 같은 URL로 접근하면 해당 글들을 볼 수 있는 것까지 살펴볼 수 있었습니다.

 

카카오뱅크 : https://brunch.co.kr/@kakaobank/{no} 

토스뱅크 : https://blog.toss.im/article/{key} 

 

 

3. 포스트들의 링크를 어떤 매체로 전달할까?

포스트들의 링크를 얻는 방법을 알았으니, 이 링크를 어떤 매체로 보낼지가 고민됐습니다. 가장 대중적인 매체인 "카카오톡"으로 보내보자는 생각이 들었고, 바로 채널을 개설했습니다.

 

나름 채널 프로필도 포토샵으로 만들어봤습니다
채널 개설 후 테스트 메시지 발송도 해봤답니다

 

하지만, 알람톡을 보내려면 일반 채널이 아닌 비즈니스 채널로 만들어야 한다는 것을 알게 됐습니다. 비즈니스 채널 개설을 위해선 사업자 등록증이 필요한데, 이걸 발급받고 이것저것 하는 것은 공수가 많이 들 것 같다는 생각이 들었습니다.

 

결국 카카오톡 채널을 통한 발송은 포기하고, 다른 방법을 알아봤습니다. 눈에 들어온 방법은 SMS 전송이었는데요, 다음과 같이 SMS 전송을 지원하는 4개의 플랫폼들을 비교했습니다.

 

  가비아 CoolSMS AWS SNS 네이버 클라우드 Notification
비용 최소 충전 20,500원
(건당 20.5원)
최소 충전 10,000원
(건당 20원)
건당 약 30원
(24.05.18 기준)
사업자만 가능
API 지원 O O O
기타 발신번호 등록 필요 발신번호 등록 필요 발신번호 등록 필요 X

 

SMS 발송에 필요한 발신번호는 "아톡" 등의 플랫폼에서 발급받을 수 있지만 월 2,000원이라는 비용이 발생합니다. 하지만 저는 이 서비스를 어차피 저(아니면 주변 사람들 몇 명만 더해서)만 사용할 것이기 때문에 발신번호는 제 010번호를 쓰면 되므로, 발신번호 필요 여부는 그리 큰 문제가 되지 않겠다는 생각이 들었습니다.

 

그러면 비용만 비교하면 됐었는데요. 최소 충전 비용이 있다보니 1년도 채 안 지나서 서비스 운영을 종료할 예정이라면 AWS SNS를 쓰는게 이득이지만, 앵간하면 이 서비스는 제가 계속 쓸 것 같다는 생각이 들었습니다. 금융 관련 글은 읽으면 읽을수록 플러스지 마이너스는 안 될 거라고 생각하거든요. 결국, 건당 비용이 가장 낮은 CoolSMS를 사용하기로 결정했습니다.

 

그렇다면, 실제로 CoolSMS를 통해 SMS를 보낼 수 있다는 걸 검증해야겠죠. 제공되는 API 문서를 통해 간단히 테스트했고, 제가 원하는 내용을 보낼 수 있음을 다음과 같이 검증할 수 있었습니다.

성공적인 테스트 결과

 

참고 : 빠른 속도를 위해 Python을 사용해서 개발 및 테스트했습니다

 

4.  상세 로직은 어떻게 구성할까?

API를 통해 SMS를 보낼 수 있다는 것도 검증했으니, 이제 실질적으로 어떤 과정을 통해 포스트들의 링크를 SMS로 전달할지를 구상할 차례인데요. 우선 다음 3가지의 데이터는 가지고 있어야 한다고 생각했습니다.

 

1) SMS를 받을 사람(구독자들)의 휴대폰 번호

2) 메시지 내용 템플릿

3) 가장 최근에 보낸 포스트의 정보(카카오뱅크는 no, 토스뱅크는 key)

 

이를 바탕으로, 초기에는 다음과 같은 로직을 구상했습니다.

 

 

2번 과정에서 마지막(가장 최근)에 보낸 포스트들의 정보를 가져오고, 이를 토대로 어떤 포스트들을 보내야할지를 4번 과정에서 추린 다음 SMS를 전송하는 로직입니다. 근데 이렇게 되면, 네트워크 문제 등으로 6번 과정(SMS 전송)에 실패해도 이미 실행된 5번 과정에 의해 전송된 포스트 정보가 갱신된다는 문제가 있었습니다. 즉 특정 포스트는 건너 뛰게 되는 현상이 발생할 수 있었죠. 따라서 5번과 6번의 순서를 바꿔서, 다음과 같은 로직으로 구성했습니다.

 

 

 

5. 어떤 DB를 사용할까?

이제 데이터들을 담을 DB를 골라야 했습니다. 클라우드 서비스들을 알아봤는데요. 제 AWS 계정은 프리 티어를 사용할 수 있는 시기가 이미 지났기 때문에 AWS가 아닌 다른 서비스들을 찾아봤고, 서치를 통해 Supabase라는 BaaS(Backend as a Service)를 알게 됐습니다. 완전 무료형 서비스는 아니고 500MB만큼의 DB 용량을 무료로 사용 가능한데, 어차피 저는 DB에 insert가 아닌 update만 해줄 거라 용량제한을 넘길 일은 없고, 끽해야 하루에 한 번 read/write를 하기 때문에 이 녀석을 쓰는게 딱이겠다는 생각이 들었습니다. 

 

역시나 테스트를 해봐야겠죠? Supabase 콘솔에서 테이블들을 세팅해주고, 다음 코드들을 통해 select(휴대폰 번호 테이블에서 번호들 가져오기)와 update(가장 최근에 보낸 포스트 정보 갱신)가 정상 수행되는 걸 확인했습니다.

 

# SMS를 받을 사람들의 핸드폰 번호 가져오기
subscriber_phone_numbers = supabase.table(os.environ.get("PHONE_NUMBER"))\
    			.select("*").execute().data

print(subscriber_phone_numbers)

# [
#   {"phone_number": "010XXXXXXXX},
#   {"phone_number": "010XXXXXXXX},
#   {"phone_number": "010XXXXXXXX}
# ]


# 가장 최근에 보낸 포스트의 정보 갱신(토스뱅크)
supabase.table(os.environ.get("TOSS_BANK_POST"))\
                .update({"toss_last_send_key": key}).eq("id", id).execute()

 

 

6. 서버는 어디에 띄울까?

코드 작성은 끝났고, 이제 가장 중요한 단계죠. 서버를 어디에 띄울지 고민했습니다. 아무래도 매일 아침에만 코드가 실행되면 되게 때문에 서버리스를 도입하는게 궁극적인 목적인 "비용 절감"에 가장 적합하다고 생각했는데요. 그렇다고 처음부터 서버리스만 고려하기 보다는 EC2에 호스팅하는 방법을 포함해서 다음과 같이 4가지 방법을 조사했습니다.

 

  AWS EC2 + Scheduler AWS Lambda + EventBridge Supabase Edge Fuction + pg_cron 스크립트 페이지 호스팅 + cron_job.org
비용 1,586원/월
(스팟 인스턴스, t3.nano 기준)
3.55원/월
(0.5GB 메모리, 10초 실행 기준)
무료 무료
비고 1) EC2를 Public Subnet에 두면 보안 취약

2) EC2를 Private Subnet에 두면 NAT Gateway 비용 추가 발생
  TypeScript로만 작성 가능 다른 사람이 해당 페이지를 호출함으로써 스크립트 실행 가능

 

 

1) AWS EC2 + Scheduler

EC2 상에 서버를 띄우고, 자체적으로 스케쥴러(스프링의 @Scheduled 등)를 설정해 일마다 수행하는 방법입니다. EC2는 사용한 만큼 지불하는 On-demand 옵션 외에도 할인된 금액으로 선납입을 하는 예약 인스턴스, 가장 저렴한 금액으로 사용 가능하나 서비스가 중단될 가능성이 있는 스팟 인스턴스 등의 유형이 있는데요. 본 서비스는 서비스가 중단되도 큰 영향이 없기 때문에(다음날 수행되면 어차피 전날 안 보내진 것도 일괄 전송되므로) 스팟 인스턴스를 사용해도 무방하다고 판단했습니다.

 

 

24년 6월 4일 기준으로, t3.nano를 사용할 때 시간당 0.0016 달러를 내는데요. 24를 곱하고 30을 곱하면, 한달에 1.152달러 = 1,586원이 발생됩니다.

※ 참고 : On-demand의 경우, 이 케이스에선 4배 정도 더 비쌈

 

그리고 EC2는 최초 생성 시 Public Subnet에 둘 건지 Private Subnet에 둘 건지를 정해야 합니다. 해당 EC2는 외부로부터의 선접근이 없으므로 Private Subnet에 두는 게 보안상 안전하나, 카뱅 & 토뱅으로부터 최신 포스트 목록을 가져오는 과정, supabase에 접근해 데이터를 가져오는 과정 등에서 외부와의 통신이 필요하므로 NAT 게이트웨이 등을 별도 설치해야 하고, 이는 추가 비용을 야기합니다. 그렇다고 Public Subnet에 두자니, 외부로부터의 접근에 노출될 우려가 있습니다. 따라서 EC2와 Scheduler 조합을 선택할 경우, 이 부분을 좀 더 고려해서 선택해야 할 필요가 있었습니다.

 

 

2) AWS Lambda + EventBridge

AWS의 서버리스 서비스인 Lambda Function을 등록하고, EventBridge를 통해 일마다 Lambda를 호출하는 방법입니다. 제 로컬을 기준으로 1회 실행 시 3.2초 정도가 수행됐는데요, 아무래도 보수적으로 1회 실행에 10초가 걸린다고 잡고 512MB짜리 메모리를 쓴다고 가정했습니다. 

 

 

일마다 실행하므로 한 달에 30번만 실행되기 때문에, 30 x 10(10초) x 0.5(512MB = 0.5GB) x 0.0000166667 = 0.0025달러 = 3.5원 정도의 비용이 한 달마다 발생되게 됩니다.

 

그리고 EventBridge에 대한 비용도 고려해야 하는데요. 

 

 

100만건의 스케줄러 호출 당 1.15달러의 비용이 월마다 발생하는데요. 우리는 월 30번만 호출하니 0.0000345달러 = 약 0.05원의 비용이 달마다 발생합니다.

 

즉, 정리하면 AWS Lambda + EventBridge 사용 시, 3.55원의 비용이 월마다 발생합니다.

 

 

3) Supabase Edge Function + pg_cron

Lambda와 같은 서버리스 서비스인 Supabase Edge Function을 등록하고, pg_cron이라는 Supabase Extension을 통해 일마다 Edge Function을 호출하는 방법입니다. 24년 6월 4일을 기준으로 Edge Function은 TypeScript만으로만 작성가능하고, HTTP 요청을 통해 Edge Function을 호출하는 방식으로 동작합니다. 따라서 Edge Function과 + pg_cron을 선택하는 경우 HTTP 요청에 대한 별도의 인증을 구성하지 않는다면 제 3자의 Edge Function 호출로 SMS 발송이 야기되어 과금이 발생할 수 있으니 꼭 인증을 구성할 필요가 있고, TypeScript로 다시 코드를 작성해야 하는 공수가 발생함을 고려해야 합니다.

 

 

4) 스크립트 페이지 호스팅 + cron_job.org

우비나 닷홈 등의 무료 호스팅 서비스를 이용해 스크립트 페이지를 만들고, cron_job.org라는 무료 스케쥴러 사이트를 활용해 일마다 스크립트 페이지로 HTTP Request를 보내게 하는 방법입니다. 완전 무료로 사용 가능하지만, 호스팅된 스크립트 페이지를 다른 사람도 호출할 수가 있는데요. 이 경우 제가 원치 않는 타이밍에 SMS가 발송되고, 이것들이 모두 과금으로 이어질 수 있다는 치명적인 단점이 있습니다.

 

 

 

정리하면, EC2 사용은 월 1,500원 이상의 비용이 발생하니 고려 대상에서 제외했고, 스크립트 페이지 호스팅 & cron_job.org 사용은 제 3자의 스크립트 페이지를 요청으로 인한 과금 폭탄이 발생 가능해 제외했습니다. AWS Lambda와 Supabase Edge Function이 남았는데, Lambda 사용 시 발생하는 월 3원 가량의 비용은 사실상 없는 수준이라고 생각했고 Supabase Edge Function은 제가 사용해본 적 없는 TypeScript로 코드를 재구성해야 하는 점 등의 공수가 걸렸습니다. 따라서 최종적으로 Lambda와 EventBridge를 사용하는 것으로 결정하게 됐습니다.

 

 

7. AWS Lambda 함수 생성 & EventBridge 설정 (with. 트러블 슈팅..)

Lambda 함수 생성시, 파이썬 버전은 로컬에서와 동일하게 3.11버전으로 설정해줬습니다.

 

그 뒤 함수에서 사용될 환경변수들을 세팅해줬습니다.

값은 가렸습니다

 

그리고 Lambda 함수가 사용할 외부 라이브러리들을 세팅해줘야 했는데요. "레이어"를 통해 이것이 가능하고, 쉽게 설명하면 제가 세팅해준 레이어 위에서 Lambda함수가 돌아가는 개념입니다.(주로 이렇게 외부 라이브러리들을 미리 세팅할 용도로 활용한다고 합니다) 로컬에서 쓴 라이브러리들을 pip freeze >를 통해 requirements.txt로 빼준 뒤, pip install -t를 통해서 해당 라이브러리들을 따로 제가 지정한 폴더에 설치한 후 해당 폴더를 압축해서 레이어 생성에 활용할 수 있었습니다.

 

 

그 다음, 이렇게 생성한 레이어를 아까 만들어둔 Lambda 함수에 추가해줬습니다.

 

 

그리고 만들어둔 코드를 Lambda 코드에 복붙한 뒤 테스트 실행을 눌렀는데, 다음과 같이 pydantic_core라는 모듈을 찾을 수 없다는 오류를 만났습니다.

 

Response
{
  "errorMessage": "Unable to import module 'lambda_function': No module named 'pydantic_core._pydantic_core'",
  "errorType": "Runtime.ImportModuleError",
  "requestId": "Blah Blah ~",
  "stackTrace": []
}

 

관련된 스택 오버플로 글 

https://stackoverflow.com/questions/76650856/no-module-named-pydantic-core-pydantic-core-in-aws-lambda-though-library-is-i

 

No module named 'pydantic_core._pydantic_core' in AWS Lambda though library is installed for fast api based code

AWS lambda deployment of FastAPI gives the following error: [ERROR] Runtime.ImportModuleError: Unable to import module 'users_crud': No module named 'pydantic_core._pydantic_core' Traceback (most r...

stackoverflow.com

 

요약하면 Lambda는 특정 아키텍쳐에 맞는 패키지들을 필요로 하는데, 제가 레이어로 세팅해준 라이브러리들이 Lambda가 구동되는 아키텍쳐와 맞지 않아서 발생되는 문제였습니다. 실제로 제 로컬에서 requirements.txt에 적힌 라이브러리들에 대해 pip install을 해보면,

 

 

이렇게 특정 라이브러리들이 제 로컬 머신의 아키텍쳐(macos)에 맞는 버전으로 설치되는 걸 볼 수 있었습니다.(pip install을 할 때 라이브러리들이 머신의 아키텍쳐 버전에 맞춰 설치되는 걸 처음 알았네요... 모든 라이브러리가 이렇게 아키텍쳐별로 버전을 다르게 제공하는 건 아닙니다) 하지만 저는 Lambda함수를 생성할 때 아키텍쳐를 x86_64로 설정했으니 오류가 발생하는 거였죠.

 

따라서 다음 명령어를 통해 라이브러리들을 x86_64 아키텍쳐에 맞는 버전으로 설치하게끔 하고, 이걸 다시 압축해서 새로 레이어를 세팅해줬습니다.

 

pip install -r requirements.txt --platform manylinux2014_x86_64 --target ./python --only-binary=:all:

 

그 결과,

 

 

Lambda를 통해 제가 짰던 코드가 정상적으로 실행되게 할 수 있었습니다.

 

 

이제 EventBridge를 통해 생성한 Lambda함수를 스케줄링할 차례입니다. 이건 매우 간단했는데요. 생성된 Lambda 함수 콘솔에서 트리거 추가를 통해 설정해줄 수 있었습니다.

 

 

 

해당 cron 표현식은 월-금요일 매일 12시에 실행한다는 의미입니다. 다만 UTC가 기준이기 때문에, 한국 시각으로는 21시에 실행됩니다(KST가 UTC보다 9시간 앞서 있기 때문). 따라서 저렇게 트리거를 추가한뒤, 실제로 21시에 람다가 실행되는지를 봤습니다. 그 결과,

 

 

실제로 21시에 Lambda가 수행되어, SMS가 정상 전송된 것을 확인할 수 있었습니다! 

이제 매일 아침 08시에 람다가 실행되도록 EventBridge 규칙의 cron 표현식을 다음과 같이 변경해주고 마무리 지었습니다.

 

cron(0 23 ? * SUN-THU *)

 

 

마무리...

되게 간단한 서비스라고 생각했는데, 이것저것 고민하는 과정들이 많았습니다.. 만들었다 하기도 뭐하고 운영이라 하기도 뭐하지만, 그래도 생각해보니 제가 만든 서비스를 이렇게 운영 레벨로 옮겨온 건 처음이더라구요. 옛날에 이두희님이 코딩으로 자기가 만들고 싶은 것들을 뚝딱뚝딱 만드는 걸 보며 되게 멋있다고 생각했는데, 저도 제가 나한테 필요하겠다 싶은 서비스를 이렇게 중간에 멈추지 않고 처음 목표대로 실제 운영 단계까지 오는데 성공해서 굉장히 뿌듯...>< 합니다. 우선 저랑 여자친구한테만 이 글들이 가게 하고, 나중에 친구들 몇 명 해서 추가할 것 같네요. 일련의 과정들을 정리하고 공유하고 싶어 글을 이렇게 써봤는데, 난잡한 글 끝까지 봐주셔서 감사합니다.

 

로컬에서 spring boot로 작업중인 서버를 private subnet의 ec2에 띄워보고, public subnet에는 nginx를 띄워서 둘을 연결해본다. 즉 nginx로 보내는 요청이 spring boot로 가게끔 설정해본다. (즉 nginx를 프록시로 활용!)

 


private subnet에 띄운 ec2에 스프링부트 서버 배포

 

1. ubuntu베이스의 ec2를 생성한다 (amazon linux 베이스여도 됨). 보안그룹 적용시 public subnet에 둘 nginx로부터 오는 트래픽에 대한 인바운드를 열어야 하며, rds나 elastiCache를 사용하는 경우 그 놈들로 가는 트래픽에 대한 아웃바운드를 열어야 한다. (당연히 nginx쪽에서의 아웃바운드와 rds & elastiCache쪽에서의 인바운드도 열어야 함). 또한 bastion host를 통한 ssh접속을 위해 bastion host로부터 오는 22번 포트 인바운드도 열어둬야 한다.

 

2. ssh등을 활용해 private subnet에 띄운 ec2에 접속한다. ssh로 접속하는 경우 키가 필요하며, scp(secure copy)를 통해 로컬에 있는 키를 public subnet에 띄운 ec2로 보내서 ssh를 두 번 거치는 방법도 있으나 사실 이는 비밀번호가 걸린 현관문 바로 앞에 열쇠를 두는 행위나 마찬가지다. 따라서 나는 ssh jump라는 방식을 통해 ec2에 접속했다. 

Host somsatang-webserver-1
  HostName 13.124.18.245
  User ubuntu
  IdentityFile /Users/chosanghyun/Downloads/sst.pem

Host somsatang-was-1
  HostName 10.0.1.88
  User ubuntu
  IdentityFile /Users/chosanghyun/Downloads/sst.pem
  ProxyJump somsatang-webserver-1

 

 

3. 다음 공식문서 링크를 참고해 docker를 설치한다

https://docs.docker.com/engine/install/ubuntu/

 

Install Docker Engine on Ubuntu

Jumpstart your client-side server applications with Docker Engine on Ubuntu. This guide details prerequisites and multiple methods to install.

docs.docker.com

 

4. rds나 elastiCache를 사용한다면 application.properties에 적어준 설정에서 엔드포인트들을 적어줘야한다.

// localhost를 rds 엔드포인트로 바꿔준다. 실험결과 3306 포트는 따로 명시 안해도 되긴 함
spring.datasource.url=jdbc:mysql://localhost:3306/wooyoungsoo

// localhost를 elastiCache redis 엔드포인트로 바꿔준다
spring.data.redis.host=localhost

// 나머지 설정들은 생략했음

 

5. Intellij에서 다음과 같이 우측의 Gradle을 누르고 bootJar을 눌러 jar를 만들어준다

 

6. /build/libs에 jar파일이 생긴다. /build/libs, 즉 jar가 있는 디렉토리로 가서 Dockerfile을 만들어주고 다음과 같이 작성한다

FROM openjdk:17
ARG JAR_FILE=*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

openjdk는 프로젝트에서 사용중인 자바 버전에 맞게 11이나 17등을 써주면 된다.

 

  • FROM openjdk:17: Docker 이미지의 기본 이미지를 지정. 이 경우에는 OpenJDK 17 이미지를 사용하는 것이며, 이 이미지는 Java 17 환경을 제공한다
  • ARG JAR_FILE=*.jar: Docker 빌드 시에 사용할 변수를 정의. JAR_FILE이란 변수를 정의한 것이며, 기본값은 현재 디렉토리에 있는 모든 .jar 확장자를 가진 파일 중 하나
  • COPY ${JAR_FILE} app.jar: COPY 명령을 사용하여 호스트 머신에서 Docker 컨테이너로 JAR 파일을 복사하라고 하는 것. ${JAR_FILE} 변수는 방금 정의한 그 변수가 맞다. 이 파일은 컨테이너 내부에 app.jar로 복사된다.
  • ENTRYPOINT ["java","-jar","/app.jar"]: 컨테이너가 시작될 때 실행할 명령을 정의하는 것. 이 경우에는 Java를 실행하여 app.jar 파일을 실행한다는 의미다. 즉, Docker 컨테이너가 시작되면 Java 애플리케이션을 실행하고 jar 파일(= app.jar)을 실행한다.

 

즉 정리하면

 

"자바 17버전 환경에서 컨테이너를 실행할 건데",

"현재 디렉토리에서 .jar파일이 있으면 걜 JAR_FILE이란 변수에 저장하고",

"app.jar라는 이름으로 복사한 다음",

"컨테이너를 시작할 때 'java -jar'라는 커맨드를 활용해 app.jar를 구동할 거야"

 

라는 의미다.

 

 

7. 다음 커맨드를 입력해 도커 이미지를 빌드한다

docker build {옵션} {도커계정}/{프로젝트명}:{버전} {경로}
ex) docker build -t jofe/toyproject:0.1.0 .

-t는 --tag와 같은 의미이며 이미지에 부여할 이름과 태그를 지정할 수 있게끔 한다

 

참고로 맥북 m1이나 m2를 사용중인 유저라면 빌드 플랫폼이 arm기반이 되는데, aws에서 만든 ec2가 amd기반이면 호환성 문제가 생긴다. 따라서 만약 aws에서 만든 ec2가 amd기반이라면 다음과 같이 플랫폼을 지정해 빌드한다

docker build --platform linux/amd64 {옵션} {도커계정}/{프로젝트명}:{버전} {경로}

 

하지만 다음과 같이 ec2를 만들 때 arm기반으로 만들었다면 맥북도 방금 말한 거 안해도 된다. 

 

 

8. 도커허브에 방금 생성한 도커 이미지를 푸시한다

docker push {이미지명}
ex) docker push jofe/toyproject:0.1.0

 

도커허브로 접속해서 보면 내가 푸시한 이미지가 잘 올라와있는 걸 다음과 같이 확인 가능함

 

9. ssh로 접속한 private subnet ec2에서 docker pull로 이미지를 땡겨온다

sudo docker pull {이미지명}
ex) sudo docker pull jofe/toyproject:0.1.0

 

10. 다음 커맨드를 입력해 컨테이너를 돌린다

sudo docker run --name {컨테이너명} -d -p 8080:8080 {이미지명}
ex) sudo docker run --name "jofe-service" -d -p 8080:8080 jofe/toyproject:0.1.0

-p 옵션 : -p 호스트포트:컨테이너포트 형식으로 사용되며,  이 옵션을 통해 호스트 머신과 컨테이너 간의 포트 포워딩(매핑)을 설정한다. 즉 호스트 머신의 몇 번 포트로 보내면 컨테이너의 몇 번 포트로 보낸다 이런 의미.

 

-d 옵션 : 컨테이너를 백그라운드에서 돌리게끔 하는 역할

 

 

참고 : 호스트의 8080번 포트로 설정한 만큼, 보안그룹에서 nginx 쪽에서 8080포트로 보내는 아웃바운드와 spring boot쪽에서 8080으로 받는 인바운드를 열어야 한다.

 

 


public subnet에 nginx 띄우고 연결

1. ssh로 public subnet에 띄운 ec2에 접속한다. 당연히 외부에서 22번 포트로 오는 ssh접속에 대한 인바운드가 열려있어야 한다

 

2. nginx를 설치한다

sudo apt install nginx

 

3. nginx를 설정할 차례다. 우선 /etc/nginx/sites-available 디렉토리로 이동한다

cd /etc/nginx/sites-available

 

4. 이 디렉토리는 nginx의 설정 파일들이 위치했으며, 처음에 들어가면 default라는 기본적인 설정 파일이 있을 것이다. 우리가 사용할 설정파일을 새로 만들어준다. (vi로 해도 상관없음)

sudo nano {원하는 설정파일명}

 

5. 새로 만든 설정파일의 내용은 다음과 같이 설정한다

server {
        listen 80;
        server_name {nginx가 띄워진 ec2의 ip 주소 또는 도메인};

        location / {
                proxy_pass http://{spring boot를 배포한 서버의 ip 또는 도메인}:8080;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Host $http_host;
        }
}

 

  • listen 80 : 80번 포트로 웹서버를 서비스한다는 의미. 이를 통해 http://xxx.xxx.xxx.xxx:80이 아닌 http://xxx.xxx.xxx.xxx로 접속 가능하다 
  • server_name : 이 값으로는 도메인 or 호스트 or IP주소 작성. server 블록을 여러 개 둘 수 있으며, 클라이언트가 보낸 request의 header안에 있는 Host값이 이 server_name값과 일치하는 server 블록이 활성화된다. 만약 Host값과 매칭되는게 없다면 기본적으로 설정된 server블록 또는 첫 번째로 작성된 server 블록이 활성화된다
  • location / {블라블라..} : /에 해당하는 URL, 즉 모든 요청에 대한 설정을 {}안에 정의한다는 뜻 (만약 /api로 했다면 해당 경로로 오는 요청들에 대한 설정을 정의한다는 뜻이 되겠져)
  • proxy_pass : /에 해당하는 URL, 즉 모든 요청을 이 URL로 리다이렉트한다
  • proxy_set_header : 헤더 설정

 

6. 방금 작성한 설정파일을 nginx가 읽을 수 있도록 설정할 차례. /etc/nginx/sites-enabled로 이동한다.

cd /etc/nginx/sites-enabled

이 디렉토리는 /etc/nginx/sites-available에 있는 설정 파일 중 활성화할 설정 파일을 "링크(심볼릭 링크)"로 관리하는 디렉토리다. ls를 누르면 default가 링크돼있는 걸 확인할 수 있고, 다음 커맨드를 통해 default를 지운다

sudo rm default

 

7. 우리가 아까 만든 설정파일에 링크를 걸어준다

sudo ln -s /etc/nginx/sites-available/{아까 만든 설정파일명}

심볼릭 링크 : 파일 시스템에서 다른 파일이나 디렉토리를 가리키는 일종의 포인터. 링크된 파일의 내용을 복제하는 개념이 아닌 단순히 참조만 하는, 걍 포인터라고 생각하면 됨

 

8. nginx를 재시작해 설정 파일을 적용한다

sudo service nginx restart

 

9. nginx가 띄워진 ec2의 ip주소로 요청을 보내면 private subnet에 띄운 spring boot로 전달되는 걸 볼 수 있다

현재 내가 소마에서 진행 중인 프로젝트는 jwt를 활용중이며, rdb에 refresh token을 저장하고 재발급에 활용하고 있다. 시퀀스 다이어그램으로 보면 다음과 같은 구조다.

 

(참고 : refresh token을 db에 저장한 뒤 클라이언트가 재발급 요청 시 보내진 refresh token과 대조하는 이유 = 악의적 사용자가 지 맘대로 만든 refresh token으로 재발급 요청해서 access token을 받으려 하는걸 막을 수 있고, 악의적 사용자가 refresh token을 탈취했다고 해도 우리 쪽에서 db에 있는 refresh token을 없애준다든가 하는 식으로 조치를 취해줄 수도 있고,, 로그아웃 구현 시에도 편리하고.. 등등)

 

그러나 이 방법의 단점을 꼽자면 다음과 같은 부분들이 있다

 

  1. rdb에서 refresh token을 가져오는데 시간이 좀 걸린다
  2. rdb에 저장된 토큰데이터들 중 만료기간이 지난 토큰들을 개발자가 코드를 작성하든 뭐 하든 해서 직접 삭제시켜줘야한다

 

이를 인메모리 데이터베이스인 redis를 사용하면, 해당 단점들을 개선할 수 있다

 

  1. 우선 메모리에 저장하기 때문에 rdb대비 토큰을 가져오는 속도가 빠를 것이고
  2. ttl을 설정해주면 만료기간 지난 토큰들은 알아서 없어진다

 

따라서 refresh token을 관리하던 방법을 rdb에서 redis로 바꾸기로 했다. 물론 인메모리 데이터베이스인 만큼 꺼지면 다 날라가긴 하지만, refresh token은 날라가도 크리티컬한 피해는 없다. 번거로울 수 있지만 다시 로그인하면 되는 거니까.

 


우선 AWS에서 ElastiCache로 Redis를 쓸 거다. 문제는 ElastiCache는 같은 VPC에서만 접속하는걸 허용해서 로컬에서 접근할 수 없다는 것. 그러나 ssh 터널링 등을 통한 방법으로 로컬에서도 ElastiCache에 접근이 가능하다. 이건 내가 쓴 글이 있으니 링크를 달겠다.

 

https://jofestudio.tistory.com/110

 

[AWS] SSH 포트포워딩 및 SSM을 통해서 로컬에서 Private Subnet에 있는 RDS 접근하기

소마 프로젝트를 진행하면서, 인프라 쪽을 내가 어느 정도 담당하고 있어서(사실 담당한다고 하기에도 민망할 정도로 기본적인 부분들만 담당하긴 하지만) RDS를 띄워보기로 했다. Private Subnet에

jofestudio.tistory.com

 

Bastion Host를 통해 접근하는 방식이므로 ElastiCache에 달아주는 보안그룹에는 Bastion Host 쪽에서 오는 6379포트에 대한 인바운드를 열어줘야 한다.

 

스프링부트에선 다음과 같은 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

그 다음 application.properties에 다음과 같이 작성해준다(application.yml을 사용한다면 그에 맞춰 수정하면 됨)

spring.data.redis.host=localhost
spring.data.redis.port=6379

 

참고로 로컬에서 ssh 포트포워딩을 통해 elastiCache에 접속하는 중이므로 host는 localhost로 한 것이다.

그리고 다음과 같은 Configuration Class를 만들어준다.

 

@Configuration
public class RedisConfig {
    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate =  new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        // 일반적인 key:value의 경우 시리얼라이저
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

 

  • RedisConnectionFactory : Redis 서버와의 연결을 만들고 관리하는 빈
  • RedisTemplate : Redis와의 상호작용을 돕는 빈. 즉 얘를 핸들링하여 레디스와 짝짝꿍하는 것
  • Serializer설정 : RedisTemplate의 디폴트 Serializer는 JdkSerializationRedisSerializer인데, 문제는 redis-cli를 통해 개발자가 직접 redis안에 있는 애들을 볼 때 Jdk직렬화된 애들은 그 값을 도무지 알아먹을 수가 없어서, 편의를 위해 StringRedisSerializer로 설정(이렇게 하면 String값 그대로 Redis에 저장됨)

 

참고 : 직렬화(seriailize) = 객체 등을 바이트 스트림 형태의 연속적인 데이터로 변환하는 것. 반대는 역직렬화(deserialize)

 

그리고 다음과 같은 RefreshTokenService를 만들어준다.

@RequiredArgsConstructor
@Service
public class RefreshTokenService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final Long REFRESH_TOKEN_EXPIRE_TIME = 60 * 60 * 24 * 30L;

    public void saveRefreshToken(String email, String refreshToken) {
    	// 이메일을 key로, 토큰값을 value로
        redisTemplate.opsForValue().set(email, refreshToken, REFRESH_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
    }

    public String getRefreshToken(String email) {
        Object refreshToken = redisTemplate.opsForValue().get(email);
        return refreshToken == null ? null : (String) refreshToken;
    }

    public void deleteRefreshToken(String email) {
        redisTemplate.delete(email);
    }
}

 

나는 Refresh Token의 유효기간을 1달로 잡았기 때문에, 60 * 60 * 24 * 30 = 30일을 유효기간으로 설정해 저장하도록 구현했다. 분이 아니라 다른 시간 단위로도 저장 가능하니, 본인 취향따라 하면 된다.(TimeUnit.SECONDS 이쪽)

 

이제 이를 활용해 원래 rdb를 사용하던 로직을 바꿔줬다.

 

  • 로그인 후 jwt(access token, refresh token)을 생성하고 refresh token을 rdb에 저장하던 것 => refresh token을 redis로 저장. 이 때 redis에 기존에 쓰던 refresh token이 있으면 덮어씌운다
  • 토큰 재발급 요청시 rdb에서 refresh token가져오던 것 => redis에서 가져온다
  • 토큰 재발급 후 재발급된 refresh token을 rdb에 저장하던 것 => redis로 저장. 이 때 redis에 기존에 쓰던 refresh token이 있으면 덮어씌운다

 

이제 로그인 요청, 인가인증이 필요한 요청, 토큰 재발급 요청에 대한 수행과정들을 시퀀스 다이어그램으로 표현하면 다음과 같아진다.

 

 

ppt로 힘들게 만드러따..

아 그리고 이 과정에서 추가적으로 고민했던 게, 토큰재발급 요청을 인증된 유저만 받을지였다. 구글링해서 나오는 다른 예제들을 보면 재발급 요청은 인증을 안 해도 가능하게끔 하는 예제가 대부분이었는데, 나는 고민 끝에 재발급 요청도 인증을 한 유저만 할 수 있게끔 했다. 왜냐하면, 당연히 보안을 위해서. 인증할 필요없이 refresh token만 덜렁 보내서 재발급받을 수 있다면, refresh token이 탈취당하면 너도나도 내 refresh token으로 재발급을 무료로 시도할 수 있기 때문이다. 

 

인증은 access token을 통해 이뤄지며, 재발급 요청은 우리 플젝에선 앱 접속시 자동로그인을 위한 재발급을 위할 때가 아니라면 access token이 만료됐을 때 이뤄질 거다. 만료된 토큰은 검증과정에서 ExpiredJwtException을 뱉을 텐데, 그러면 만료된 토큰에 대한 인증을 어떻게 할 수 있는가?

 

처음에 생각한 것은 임시 통행증(Authentication)을 끊어주는 거였다. 스프링 시큐리티는 ContextHolder에 Authentication이 들어있다면 인증됐다고 판단하므로, ExpiredJwtException이 터졌을 때 재발급경로로 요청이 온 것이라면(이는 request객체를 까서 판단 가능) 임시로 Authentication을 만들어 ContextHolder에 넣어주면 되는거다. 

try {
	jwtProvider.validateAccessToken(accessToken);
} catch (ValidTimeExpiredJwtException ex) {
	// access token이 만료됐지만 재발급하는 요청에 대한 처리 - 임시로 인증됐다고 처리한다
	if (request.getRequestURI().equals(REISSUE_PATH)) {
		setTemporaryAuthenticationToContextHolder();
		return;
	}
	request.setAttribute("exception", ex.getMessage());
	return;
} catch (CustomJwtException ex) {
	request.setAttribute("exception", ex.getMessage());
	return;
}
    private void setTemporaryAuthenticationToContextHolder() {
        // 임시 권한 생성
        List<GrantedAuthority> temporaryAuthorities = new ArrayList<>();
        temporaryAuthorities.add(new SimpleGrantedAuthority(ROLE_USER.name()));
        // 임시 통행증 발급
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                "temporaryAuthentication", "", temporaryAuthorities
        );
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

 

이렇게 해도 잘 동작한다. 하지만! 나중에 소마 내 다른 연수생이 작성한 코드들을 몰래보다가, ExpiredJwtException의 인스턴스로부터 jwt클레임을 추출 가능하다는 놀라운 사실(!)을 깨달았다. 따라서 단순히 유효기간이 만료된 토큰이라면 정상적인 토큰에서 claim을 빼내는 것처럼 claim을 빼낼 수 있다.

    private String extractEmailFromToken(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(accessKey)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody()
                    .getSubject();
        } catch (ExpiredJwtException ex) {
            return ex.getClaims().getSubject();
        }
    }

역시 남의 코드 보면서 배우는게 참 많아.. 암튼, 이를 통해 ExpiredJwtException이 터지면 재발급 요청인지를 판단하고, 재발급 요청이라면 기존 인증 절차와 마찬가지로 동작시킬 수 있다.

 


이런 과정들을 거쳐 redis를 도입했다. 그러면 속도는 얼마나 개선됐을까?

 

RDB(AWS RDS)만 사용했을 땐 평균 180ms정도가 걸렸으나,

 

Redis(AWS ElastiCache)를 사용하고나선 평균 100ms정도가 걸리게 됐다. 약 80ms정도 속도가 빨라진 것이다.

 

아직 학생 수준인 내게 80ms 정도라는 숫자가 의미가 있는지는 모르겠다. 그러나 트래픽이 많아질수록, 단축된 시간 X 요청횟수가 실제적인 성능 개선이라고 생각한다면 의미있는 개선을 이뤄낸게 아닐까싶다. okky에 문의한(?) 결과 운영자분께서 elastiCache를 통해 80ms 정도 개선한 것이면 잘 한 것이라고 해서 나름 뿌듯함.

 

굳이 레디스를 도입했어야 하는가? 라는 질문도 받을 수 있다. 맞는 말이다. 일단 배포를 안 한 만큼 재발급 요청에 대한 트래픽이 현재 없는 상태인데 굳이 80ms 높이겠다고 레디스? + 사용자 입장에서도 체감되는 개선인가? 라는 질문도 추가적으로 받을 수 있겠다. 한마디로, 지금 레디스를 도입하는 건 오버 엔지니어링이 아니겠냐는 말로 정리할 수 있겠다

 

그러나 속도 측면 말고도 유효기간이 만료된 토큰이 개발자가 뭘 하지 않아도 레디스에서 날라간다는 점에서 개인적으로 나름의 의미는 있다고 생각한다. 또한 학생 신분인 내가 레디스를 이렇게 처음 접하게 된 것도 의미가 충분한다. 마지막으로 돈 문제. ElastiCache 그거 돈 들지 않느냐! 굳이 레디스 필요없는데 왜 레디스 달아서 돈 나가게 하냐! 

 

당연히 실무라면, 혹은 내가 실제로 이 서비스를 운영하고 있는 단계면 어떻게든 돈 적게 쓸라고 쥐어짰겠지만, 난 지금 소마에서 지원금을 받으면서 플젝을 하고 있는 상태. 돈이 좀 드는 AWS 서비스들을 이것저것 써보면서 학습하는게 스스로한테 좋을 터. 그렇기 때문에 Redis를 이렇게 써보는게 의미가 있다고 생각한다. 허허헣

 

 

 

 

 

 

현재 프로젝트를 하면서 소셜로그인 기능을 만드는 중이다. 구글 & 카카오 & 애플 로그인을 만들고 있으며 마지막에 개발하게 된 놈이 바로 애플.

 

애플 디벨로퍼 설정은 다음 블로그를 참고했다

 

https://dalgoodori.tistory.com/49

 

[Flutter] Apple Login ① - 준비

마지막으로 구현할 소셜로그인은 애플 로그인 입니다. 애플 로그인을 구현하기 위해서는 개발자 계정에 가입되어있어야하고 비용은 1년에 13만원 입니다. ㅠㅠ Apple Developer There’s never been a bette

dalgoodori.tistory.com

 

 

또한, 플러터에서 애플로그인하는 기능은 다음 패키지를 활용했다

 

https://pub.dev/packages/sign_in_with_apple

 

sign_in_with_apple | Flutter Package

Flutter bridge to initiate Sign in with Apple (on iOS, macOS, and Android). Includes support for keychain entries as well as signing in with an Apple ID.

pub.dev

 

설정방법도 꽤나 상세히 나와있으니, 플러터로 애플로그인 구현하시는 분들은 저 2개 링크타고 가서 보면 될듯

 


암튼 뭐 그렇게 해서 플러터에서 애플로그인하는 기능은 만들었다. 구글 & 카카오 로그인 만들 때는 access token을 바로 플러터 단에서 얻게 되는 방식이었는데, 애플은 access token이 아니라 authorization codeidentity token이란 걸 준다.

 

나는 현재 프론트 단에서 access token을 받아서 그걸 백엔드로 넘긴 뒤 백엔드에서 해당 토큰을 통해 유저 정보를 조회하는 방식으로 만들고 있었기 때문에, 발급받은 identity token으로 사용자 정보를 조회할 수 있는 엔드포인트가 있나를 먼저 살폈다(구글, 카카오는 access token 통해서 사용자 정보 조회하는 엔드포인트가 있기 때문..) 그러나! 애플은 그런 엔드포인트가 없었다.. 애플 개발자문서에도 지들은 그런 엔드포인트 없다고 말하고 있음. ㅋㅋ

 

그 대신에 처음에 로그인 성공했을 때 authorization code랑 identity token말고도 로그인한 유저의 정보(이메일 등)을 주긴 준다. 이 정보를 백엔드로 보낸다면? 이란 생각이 들었으나, 바로 철회했다. 지금 플젝에서 어차피 보내게 될 정보야 끽해야 이메일인데, 사실상 이메일과 플랫폼명(애플, 카카오 등)을 받아서 로그인해주는 api를 만드는 건 보안상 매우 위험하기 때문. 공격자들이 내 이메일을 안다면, 지들도 로그인하는 엔드포인트로 내 이메일이랑 플랫폼명만 보내면 로그인된다면 그 서비스를 누가 이용하겠는가. 사실 구글, 카카오 로그인 만들 때도 같은 이유로 프론트 단에서 회원정보가 아니라 플랫폼들로부터 받은 access token을 넘기게 했던 거고.

 

그러면 어떻게 해야 할까? 애플로부터 발급받은 identity token에는 공급자, 대상자, 유효시간, 사용자 이메일과 같은 정보들도 들어있다. 따라서 identity token을 백엔드로 보내고 백엔드에서 사용자 이메일을 꺼내서 사용해야겠다는 생각이 들었다.

 

 

전달해주는건 쉽지. 그러면 어떻게 꺼내야 하는가? identity token은 일단 jwt형식이어서, 이 토큰을 만들 때 사용했던 키를 몰라도 디코딩해서 내용을 볼 수 있다. 애플이 지들 키 써서 만든 토큰인데 당연히 내가 그 키를 알 방법은 없고, 직접 디코딩해서 보는 식으로 해야겠군! 이라는 생각이 들었다. split메서드와 Base64 decoder를 이용해 디코딩해서 금방 email값을 뽑아낼 수 있었다.

 

근데 이 방법, 생각해보니 보안적으로 위험하다. 우선 구글과 카카오에서 썼던 "토큰을 동봉해 유저정보를 조회하는 엔드포인트로 요청을 보내서 받는 방식"은 내부적으로 그 토큰이 유효한 토큰인지 검증해줄 것이다. 근데 지금 내가 애플에서 온 토큰을 직접 까는 이 방식은 요 토큰이 유효한 토큰인지, 즉 제대로 된 토큰인지 검증하지 않는다. 한마디로 악의적 사용자가 대충 내 이메일을 알아낸다음 내 이메일을 jwt로 만들어서 우리 백엔드의 애플로그인 엔드포인트로 요청을 보내면 로그인이 되는 말도 안 되는 상황이 연출될 수 있다.

 

한마디로, 넘겨받은 identity token이 합법적인 토큰인지 검증할 필요가 있다. 찾아보니 공식 문서에 해당 내용에 대한 글이 있었다. 다행쓰..

 

애플은 다음과 같이 5가지 스텝을 통해 identity token을 우리가 알아서(?) 검증하라고 한다

 

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user

 

애플의 공개키로 identity token의 서명을 확인하라는 내용을 볼 수 있다. 아니 근데 애플 양반 당신들이 쓰는 키를 내가 어떻게 알아!라고 말하고 싶지만 보다시피 공개키다 공개키. 

 

애플은 다음 링크에 있는 엔드포인트를 통해 자기들이 현재 시점에서 사용중인 키들의 정보를 제공한다. 허허허..

 

https://developer.apple.com/documentation/sign_in_with_apple/fetch_apple_s_public_key_for_verifying_token_signature

 

Fetch Apple’s public key for verifying token signature | Apple Developer Documentation

Fetch Apple’s public key to verify the ID token signature.

developer.apple.com

 

다음과 같은 형식일 것이다.

 

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "YuyXoY",
      "use": "sig",
      "alg": "RS256",
      "n": "1JiU4l3YCeT4o0gVmxGTEK1IXR-Ghdg5Bzka12tzmtdCxU00ChH66aV-4HRBjF1t95IsaeHeDFRgmF0lJbTDTqa6_VZo2hc0zTiUAsGLacN6slePvDcR1IMucQGtPP5tGhIbU-HKabsKOFdD4VQ5PCXifjpN9R-1qOR571BxCAl4u1kUUIePAAJcBcqGRFSI_I1j_jbN3gflK_8ZNmgnPrXA0kZXzj1I7ZHgekGbZoxmDrzYm2zmja1MsE5A_JX7itBYnlR41LOtvLRCNtw7K3EFlbfB6hkPL-Swk5XNGbWZdTROmaTNzJhV-lWT0gGm6V1qWAK2qOZoIDa_3Ud0Gw",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "fh6Bs8C",
      "use": "sig",
      "alg": "RS256",
      "n": "u704gotMSZc6CSSVNCZ1d0S9dZKwO2BVzfdTKYz8wSNm7R_KIufOQf3ru7Pph1FjW6gQ8zgvhnv4IebkGWsZJlodduTC7c0sRb5PZpEyM6PtO8FPHowaracJJsK1f6_rSLstLdWbSDXeSq7vBvDu3Q31RaoV_0YlEzQwPsbCvD45oVy5Vo5oBePUm4cqi6T3cZ-10gr9QJCVwvx7KiQsttp0kUkHM94PlxbG_HAWlEZjvAlxfEDc-_xZQwC6fVjfazs3j1b2DZWsGmBRdx1snO75nM7hpyRRQB4jVejW9TuZDtPtsNadXTr9I5NjxPdIYMORj9XKEh44Z73yfv0gtw",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "W6WcOKB",
      "use": "sig",
      "alg": "RS256",
      "n": "2Zc5d0-zkZ5AKmtYTvxHc3vRc41YfbklflxG9SWsg5qXUxvfgpktGAcxXLFAd9Uglzow9ezvmTGce5d3DhAYKwHAEPT9hbaMDj7DfmEwuNO8UahfnBkBXsCoUaL3QITF5_DAPsZroTqs7tkQQZ7qPkQXCSu2aosgOJmaoKQgwcOdjD0D49ne2B_dkxBcNCcJT9pTSWJ8NfGycjWAQsvC8CGstH8oKwhC5raDcc2IGXMOQC7Qr75d6J5Q24CePHj_JD7zjbwYy9KNH8wyr829eO_G4OEUW50FAN6HKtvjhJIguMl_1BLZ93z2KJyxExiNTZBUBQbbgCNBfzTv7JrxMw",
      "e": "AQAB"
    }
  ]
}

 

즉 정상적인 경우라면, 내가 아까 발급받은 identity token은 위에 있는 키 후보들(?) 중 하나로 만들어진 셈이다. 즉 위 후보들 중 실제로 내가 받은 identity token을 만들 때 써진 키를 찾아야 한다.

 

근데 어떻게 찾느냐? identity token은 아까 말했듯 jwt다. jwt의 header 부분에는 어떤 알고리즘을 썼는지에 대한 정보들이 있는데, identity token도 그와 마찬가지로 헤더에 관련 정보들이 있다. 구체적으로는 alg값과 kid값이 들어있다.

 

근데 위에 있는 후보 키들을 보면, 각각 kid값과 alg값을 갖고 있는 걸 볼 수 있다. 즉, identity token의 header에 들어있는 alg & kid와 동일한 값을 갖는 후보 키가 identity token을 만들 때 쓰인 키다. 

 

말은 쉽지~ 코드를 보여줘! 난 다음과 같이 구현했다. 전체 코드가 아닌 메서드들만..허헣

 

    // identity token의 헤더에서 alg, kid 추출해서 저장하는 메서드
    private Map<String, String> getAlgAndKidFromIdToken(String idToken) throws ParseException {
        Map<String, String> algAndKid = new HashMap<>();

        String header = idToken.split("\\.")[0];
        Base64.Decoder decoder = Base64.getUrlDecoder();
        JSONObject headerContent = (JSONObject) jsonParser.parse(
                new String(decoder.decode(header))
        );

        algAndKid.put("alg", (String) headerContent.get("alg"));
        algAndKid.put("kid", (String) headerContent.get("kid"));

        return algAndKid;
    }

    // 애플의 공개키들을 받는 엔드포인트로부터 키 후보들을 가져오는 메서드
    private JSONArray getAvailablePublicKeyObjects() throws ParseException {
        HttpEntity<String> httpEntity = new HttpEntity<>(new HttpHeaders());
        ResponseEntity<String> res = restTemplate.exchange(
                APPLE_PUBLIC_KEY_URL, HttpMethod.GET, httpEntity, String.class);
        JSONObject availablePublicKeysContent = (JSONObject) jsonParser.parse(res.getBody());
        return (JSONArray) availablePublicKeysContent.get("keys");
    }

    // 키 후보들과 identity token에서 뽑아낸 alg, kid를 비교해 매칭되는 키를 찾는 메서드
    private JSONObject findMatchedPublicKeyObj(JSONArray availablePublicKeyObjects, String alg, String kid) {
        if (availablePublicKeyObjects == null || availablePublicKeyObjects.size() == 0) {
            throw new AppleKeyInfoNotReceivedException();
        }

        for (JSONObject keyObj : (Iterable<JSONObject>) availablePublicKeyObjects) {
            String algFromKey = (String) keyObj.get("alg");
            String kidFromKey = (String) keyObj.get("kid");

            if (Objects.equals(algFromKey, alg) && Objects.equals(kidFromKey, kid)) {
                return keyObj;
            }
        }

        return null;
    }
    
    // 찾아낸 후보 키(jsonObject)를 공개키 인스턴스로 만드는 메서드
    private PublicKey generatePublicKey(JSONObject applePublicKeyObj) {
        if (applePublicKeyObj == null) {
            throw new MatchedKeyNotFoundException();
        }

        String kty = (String) applePublicKeyObj.get("kty");
        byte[] modulusBytes = Base64.getUrlDecoder().decode((String) applePublicKeyObj.get("n"));
        byte[] exponentBytes = Base64.getUrlDecoder().decode((String) applePublicKeyObj.get("e"));

        RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(
                new BigInteger(1, modulusBytes),
                new BigInteger(1, exponentBytes)
        );

        try {
            KeyFactory keyFactory = KeyFactory.getInstance(kty);
             return keyFactory.generatePublic(publicKeySpec);
        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new ApplePublicKeyNotGenerateException();
        }
    }

 

이렇게 만든 키를 활용해, identity token의 서명을 확인할 수 있을 것이다. 한 마디로 이 키로 합법적으로 토큰을 깔 때(?) 오류가 안 나야 한다는 말.

 

그 뒤에 nonce검증이 있는데, 이건 패쓰.. nonce값이 토큰에 안 담겨있어서,,허허

 

iss, aud 검증은 다음과 같이 했다. 단순히 equal 비교

 

    public void verifyAppleIdTokenClaim(Claims claims) {
        if (!claims.getIssuer().equals(issuer)) {
            throw new IssuerNotMatchedException();
        }

        if (!claims.getAudience().equals(clientId)) {
            throw new ClientIdNotMatchedException();
        }
    }

 

유효기간 만료의 경우 이미 키를 통해 claims를 얻는 과정이 잘 통과되면 검증된 것과 마찬가지여서 굳이 넣지는 않았다. 필요하다면 claims의 getExpiration과 before같은 걸 조합해서 만들 수 있을 것이다.

 

+ Recent posts