레이블이 Tour인 게시물을 표시합니다. 모든 게시물 표시
레이블이 Tour인 게시물을 표시합니다. 모든 게시물 표시

10/25/2024

Bull을 사용한 예약 취소

여행을 예약하면 정해진 시간 예를 들어 15분 혹은 20분 정도 잠금을 걸어서 다른 사용자 중복으로 예약을 할 수 없도록 해야한다. 그 다음 해당 시간이 지나면 다른 사용자가 예약을 할 수 있어야 하는데 이를 구현하는 방법은 여러 가지가 있어 이에 대해 알아본다. 알아보기 전에 프로젝트에서 만료 시간을 미래의 어느 시점의 타임스탬프로 구현했기에 15분을 대기한다는 의미는 실제로 기다리는 것이 아니라 타임스탬프 값이 과거로 바뀔 때까지 기다리는 것을 뜻한다. 즉, 타임스탬프가 나타내는 시간이 되면 만료 완료 이벤트를 발생시킨다.


setTimeout() 메서드

가장 간단하고 기본적인 방법으로 주어진 시간이 지나면 콜백함수에서 만료 완료 이벤트를 발행한다. 하지만 매우 큰 단점이 있는데 setTimeout() 메서드가 바로 메모리에 타이머를 저장한다는 것이다. 만약 만료 서비스가 어떤 이유로든 다시 시작된다면 그때 실행 중이던 모든 타이머가 사라지게 되는 참사가 발생한다. 따라서, 이 방법은 매우 간단하지만 치명적인 단점을 가진다.


이벤트 재전송

이 방법은 예약 생성 이벤트에 대한 구독자를 설정하는 것으로 이벤트가 들어오면 이벤트의 만료 시간을 나타내는 expiration 시간이 과거인지 확인한다. 만약 과거라면 만료 완료 이벤트를 발생시키고 아니면 아무런 행위를 하지 않는다. 아무런 행위를 하지 않으면 NATS Streaming은 설정 시간(e.g., 5초) 후에 자동으로 이벤트를 전달한다. 이러한 재전송 매커니즘으로 만료 완료 이벤트를 발행한다. 하지만 큰 단점이 존재하는데 애플리케이션 내에서 반복적으로 실패하는 이벤트를 추적하는 한 가지 방법은 이벤트가 재전송된 횟수를 추적하는 것이다. 횟수를 기준으로 이벤트를 처리할 수 없다는 일종의 경고를 전송할 수 있다. 따라서, 재전송 메커니즘은 로깅(logging) 목적으로도 사용되고 핵심 비즈니스 논리로도 사용된다면 상황이 매우 복잡해질 수 있다.


이벤트 버스

이 방법은 NATS Streaming 서버가 지원하지 않는 방법으로 메시지 브로커의 이벤트 버스에 내재하는 기능을 사용한다. 즉, 예약 생성 이벤트가 발생하면 만료 서비스에 수신한 후 지체 없이 만료 완료 이벤트를 발행하는데 대기할 시간을 이벤트 버스에 요청하는 것이다. 다시 말해, 이벤트 버스가 대기 시간만큼 기다린 후 만료 완료 이벤트를 전송하는 것이다. 이러한 방식을 스케줄된 메시지/이벤트라고 한다. 안타깝게도 NATS Streaming 서버는 이를 지원하지 않는다. 해당 기능이 지원되면 만료 서비스 자체가 필요 없는데 예약 서비스가 15분 후에 예약을 만료해야 한다는 알림을 자체적으로 설정할 수 있기 때문이다.


Bull

프로젝트에 구현한 방법으로 만료 서비스에서 Bull이라는 Redis 기반 패키지를 사용하는 것이다. Bull은 일종의 장기 타이머를 설정하거나 알림을 받을 수 있게 해준다. 즉, Redis에 데이터를 저장하고 처리하며 필요에 따라 스케줄링하는 기능을 제공한다. 구체적으로 만료 서비스에서 Bull을 통해 특정 시간 후 작업을 수행하라는 명령을 전달하고 Bull은 이를 Redis에 저장한다. Redis에는 작업 목록 및 특정 시점 후 수행될 작업들이 저장되며 특정 시간 후 Bull은 Redis로부터 알림을 받고 알림을 전달한다. 이 때, 만료 완료 이벤트를 발행한다.

일반적으로 Bull의 사용 방식은 현재 구현하려는 만료 완료와 다르다. 예를 들어, 사용자가 MP4와 같은 형식의 비디오 파일을 MKV로 변환하고자 요청을 한다고 가정하면 요청을 처리하는데 많은 처리 능력이 필요하다. 이와 같은 변환 작업을 웹 서버에서 처리하는 대신 별도의 작업 서버에서 처리하는 것이 일반적이다. 사용자가 웹 서버에 요청을 보내면 웹 서버는 작업(job)을 대기열에 삽입한다. 작업은 JavaScript 객체로 처리해야 할 내용을 설명한다. 작업 객체는 Redis 서버로 전송되고 Redis 서버는 이를 작업 목록에 저장한다. 여러 작업 서버는 Redis 서버를 지속적으로 확인하며 작업이 나타나면 이를 처리한 후 Redis에 처리 완료 메시지를 전송한다. 이것이 Bull의 일반적인 사용법으로 초기 작업 설정부터 처리, 완료, 알림까지 모든 과정을 처리한다.

Bull의 이러한 역할을 고려하면 사실 서비스 간의 비동기 메시지 처리에 사용하는 것을 적절치 않다. 즉, Bull은 주로 개별 작업을 처리하는 데 사용되며 대규모 메시지 처리를 위해 설계된 것이 아니다. 하지만, Bull에는 큐라는 것이 존재하는데 큐(queue)는 처리할 메시지의 흐름을 나타낸다. 큐 생성 시, 큐를 통해 흐르는 메시지를 어떻게 처리할 지 명시할 수 있다. 별도 작업 서버는 존재하지 않으면 모든 것은 만료 서비스에서 처리된다. 즉, 웹 서버와 작업 서버가 하나의 서버로 통합되어 있다. 만료 서비스가 멈추어도 서버는 계속 작동할 수 있다.

Bull을 사용하여 예약 생성 이벤트가 발생할 때 만료 완료 이벤트를 처리하는 코드는 다음과 같다.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/* 예약 생성 이벤트 */
export class BookingMadeSubscriber extends CoreSubscriber<BookingMadeEvent> {
  readonly subject = Subject.BookingMade;
  queueGroup = queueGroup;
 
  async onMessage(
    data: BookingMadeEvent['data'],
    message: Message,
  ): Promise<void> {
    try {
      const delay = new Date(data.expiration).getTime() - new Date().getTime();
 
      console.log(`대기 시간(ms): ${delay}`);
 
      /* 새로운 작업(예약 생성 이벤트)을 생성하고 큐에 추가한다. */
      await expirationQueue.add(
        {
          bookingId: data.id,
        },
        { delay },
      );
 
      message.ack();
    } catch (error) {
      console.error(error);
    }
  }
}
 
 
type BookingPayload = {
  bookingId: mongoose.Types.ObjectId;
};
 
/* 예약 만료를 다루는 큐를 생성한다. */
export const expirationQueue = new Queue<BookingPayload>('booking:expiration', {
  redis: {
    host: process.env.REDIS_HOST,
  },
});
 
/* 정해진 시간이 지나면 만료 완료 이벤트를 발행한다. */
expirationQueue.process(async (job) => {
  new ExpirationCompletedPublisher(natsInstance.client).publish({
    bookingId: job.data.bookingId,
  });
});
cs

update: 2024.10.25

MSA와 동시성 문제(OCC, NATS Streaming)

MSA에서 비동기적 통신 방식인 이벤트 기반 통신을 사용하면 발생하는 문제가 여러 가지가 있는데 그 중 하나가 바로 동시성 문제이다. 예를 들어, 계좌에서 입출금을 하는데 만약 여러 번 하는데 입금 이벤트를 처리하지 못하고 출급 이벤트만 처리하면 사용자는 출금을 할 수 없는 황당한 상황에 직면한다. 동시성 문제를 해결하는 방법 중 하나가 낙관적 동시성 제어(OCC)이다. 이에 대해 알아본다.


이벤트 버스

OCC에 대해 알아보기 전 이벤트 버스를 알아야 한다. 이벤트 버스는 각 서비스에서 발생하는 알림이나 이벤트를 처리한다. 알림이나 이벤트는 애플리케이션 내에서 어떤 일이 일어났거나 일어나야 할 일을 설명하는 메모와 같다. 각 서비스는 이벤트 버스에 연결되며 이벤트를 발생시키거나 이벤트 버스로부터 이벤트를 수신할 수 있다. 여기서 알 수 있는 점은 모든 서비스가 하나의 공통된 것에 연결되어 있다는 것이다. 즉, 이벤트 버스는 단일 장애 지점이다. 따라서, 이벤트 버스를 배포할 때는 시스템이 강건하고 다운되지 않도록 하여 서비스 간의 통신을 제한하지 않도록 해야한다. 이벤트 버스는 자동으로 들어오는 이벤트를 처리하고 해당 이벤트를 처리할 수 있는 다른 서비스로 라우팅할 수 있다. 이벤트는 어떤 종류의 이벤트가 발생했는지 설명하는 유형이 있을 수 있으며 이와 관련된 데이터나 컨텍스트 정보도 포함될 수 있다.


NATS Streaming

메시지 브로커는 시스템, 애플리케이션 또는 서비스 간의 통신을 조정하는 소프트웨어로 메시지를 번역, 라우팅, 변환 그리고 저장하는 작업을 처리하여 적절한 수신자에게 전달한다. 생산자와 소비자를 분리할 수 있으며 비동기적으로 통신이 가능해져 확장성과 내결함성을 높일 수 있다. NATS Streaming은 일종의 메시지 브로커로 가벼운 NATS 메시징 시스템을 기반으로 한다. 물론 대표적인 메시지 브로커는 단연 Apache Kafka와 RabbitMQ인데 학습 장벽이 높다고 하여 일단 간단하게 사용할 수 있는 NATS Streaming을 프로젝트에 적용했다.

Channel

NATS Streaming의 채널에 구독한 구독자(subscriber)는 이벤트 발생 시 메시지를 받을 수 있으며 모든 이벤트를 기본적으로 인-메모리에 저장한다. 플랫(flat) 파일로 디스크나 MySQL, PostgreSQL과 같은 데이터베이스에 이벤트를 저장할 수 있다. 예를 들어, 리뷰 서비스가 NATS Streaming 서버로 이벤트를 전송하면 그 이벤트는 메모리에 저장되는 동시에 이벤트의 복사본이 관련 서비스들에게 전송된다. 다른 관련 서비스가 연결되면 NATS Streaming 서버에 접속하여 놓쳤던 모든 이벤트의 복사본을 요청할 수 있다. NATS Streaming 서버는 해당 서비스가 놓친 관련 이벤트들을 모두 응답으로 보내준다. 이러한 방식은 플랫 파일, MySQL, PostgreSQL에 이벤트를 저장할 때도 동일하게 적용된다.

Queue Group

만약 동일한 서비스의 여러 인스턴스가 존재할 때 이벤트를 여러 개 전달하면 어떻게 될까? 이는 당연히 데이터 중복 혹은 충돌의 문제로 이어진다. 따라서 NATS Streaming은 큐 그룹이라는 기능을 사용해서 이를 해결한다. 큐 그룹은 채널 안에 생성되며 하나의 채널에 여러 개의 큐 그룹을 연관시킬 수 있으며 각 큐 그룹에 원하는 이름을 지정할 수 있다. 요약하면, 큐 그룹은 채널 안에서 서비스의 여러 인스턴스가 동일한 이벤트를 처리하지 않고 한 인스턴스만 이벤트를 받도록 조정하는 기능이다.

ACK

이벤트를 수신하고 처리한 다음 정보를 저장하려고 하는데 데이터베이스 문제가 발생하면 어떻게 될까? 그러니까 데이터베이스 연결이 일시적으로 끊기거나 데이터베이스가 업그레이드 중이라서 다운될 수 있다. 또는 저장하려는 정보 자체가 잘못되어 데이터베이스에서 요청을 거부할 수 있다. NATS Streaming은 기본적으로 이벤트를 수신하면 자동으로 이벤트를 처리한다. 그래서 오류가 발생하더라도 이벤트는 사실상 손실되며 다시 처리할 기회는 주어지지 않는다. 옵션으로 이를 변경할 수 있으며 변경해야 하는 이유는 해당 이벤트에 중요한 정보가 담겨 있을 수 있기 때문이다. 여행를 예약하고 결제를 진행하는데 이벤트에 사용자가 방금 결제한 정보가 들어 있다면 그 서비스는 결제 정보가 생성되었다는 것을 반드시 알아야 한다. 따라서 이벤트 수신 시 오류가 발생할 경우 이벤트를 다시 처리하거나 다시 시도해야 한다. 이를 위해 수동 확인 모드(manual acknowledgment mode)를 설정한다. 

수동 확인 모드를 활성화하면 자동으로 처리 완료를 알리지 않고 직접 이벤트를 처리한 후 데이터베이스에 정보를 저장하고 모든 처리가 완료된 후에야 메시지를 수동으로 확인한다. 만약 들어온 이벤트를 확인하지 않으면 NATS Streaming 서버는 기본적으로 30초 정도 대기하고 그 후 30초가 지나도 확인이 없으면 해당 이벤트를 큐 그룹 내 다른 인스턴스에게 보내거나 그룹이 없다면 동일한 인스턴스 다시 이벤트를 전달해 재처리할 기회를 준다. 즉, 메시지를 정상적으로 처리한 후 ack() 메서드를 호출하면 NATS Streaming 서버는 더 이상 해당 이벤트에 대해 신경쓰지 않는다. 요약하면, 수동 확인 모드에서 ack() 메서드 호출을 통해 메시지를 처리 완료 상태로 만들지 않으면 NATS Streaming 서버는 계속해서 동일한 이벤트를 재전송하여 성공적으로 처리될 때까지 기회를 준다. 이 방식은 메시지 처리 중 오류가 발생해도 재시도를 통해 안정적으로 데이터 처리가 이루어지도록 보장한다.


동시성 문제

이벤트 버스와 메시지 브로커 중 하나인 NATS Streaming에 대해서 알아봤기에 동시성 문제에 들어간다. 동시성 문제와 관련해서 1번째로 고려해야 할 점은 구독자가 들어오는 이벤트를 처리하지 못하는 경우이다. 포스트의 도입에서 등장한 입출금 예시에 대해 다시 알아본다.
 

이벤트 처리 실패

2번의 입금(10만원 30만원)과 1번의 출금(50 만원)을 사용자가 요청한다고 가정한다. 10만운 입금이 발생하고 입금 이벤트가 계좌 서비스에 할당된다. 그런 다음 계좌 서비스가 이 이벤트를 처리하려고 시도하지만 여러 가지 문제 중 하나로 이벤트가 처리되지 않는다. 어떤 이유든 간에 구독자에 문제가 발생하면 이벤트를 승인하지 않기에 결국 이벤트를 재처리 해야한다. 하지만 NATS Streaming 서버가 이벤트를 재처리하고 다른 서비스에 전달하기까지 30초가 걸리는데 이 시간에 출판자(publisher)는 남은 두 개의 이벤트를 계속 발행할 수 있다. 구독자는 30만원 입금 이벤트를 발행시키고 이벤트가 성공적으로 처리된다. 바로 이어서 50만원 출금 이벤트가 발행하고 여기서 문제가 발생한다. 계좌에 40만원 밖에 없는데 50만원을 출금하려고 하기 때문에 오류가 발생하는 것이다. 따라서 어떤 이벤트라도 처리에 실패하면 애플리케이션의 비즈니스 논리에 치명적인 오류를 일으킬 수 있다. 

처리 속도 차이

또 다른 동시성 문제는 한 구독자가 다른 구독자보다 더 빠르게 실행되는 경우이다. 예를 들어 30만원을 입금하는 이벤트를 계좌 서비스에 보냈는데 해당 구독자는 이벤트 대기열이 처리해야 할 이벤트로 쌓여 있을 경우(e.g., 100개의 이벤트) 실행 중 인 기계가 과부하 상태에 처해 있을 것이다. 따라서 30만원 입금 이벤트는 승인까지 대기하는데 그 사이에 계좌 서비스에 20만원 입금이라는 다른 이벤트를 발행한다. 따라서, 2개의 이벤트가 처리되고 승인되기를 기다려야 한다. 이 상황에서 40만원을 출금하는 이벤트를 계좌 서비스에 발행하고 이 이벤트는 계좌 서비스의 다른 인스턴스에 전송된다. 마침 이 인스턴스는 처리할 다른 이벤트가 없어서 들어오는 이벤트를 즉시 처리할 수 있다. 그 결과 40만원을 출금하려다가 잔액이 부족하게 되어 잔액이 마이너스로 떨어지는 상황이 발생한다.

메시지 브로커의 실수

NATS Streaming은 클라이언트가 종료되더라도 HB 설정에 따라 10~20초 정도 동안 해당 클라이언트를 실제로 종료된 것으로 간주하지 않을 수 있다. 계좌 서비스의 인스턴스가 갑작스럽게 완전히 종료되었다고 가정한다. 즉, 정상적으로 종료되지 않고 어떤 이유로 갑자기 끝났다. 하지만 10~20초 동안 NATS Streaming 서버는 여전히 해당 서비스의 인스턴스가 살아있다고 생각한다. 이 상황에서 NATS Streaming는 20만원 입금 이벤트를 종료된 서비스에 할당하려고 시도할 수 있는데 NATS Streaming는 서비스가 여전히 실행 중이라고 생각하기 때문이다. 이어서 30만원 입금 이벤트와 40만원 출급 이벤트도 계좌 서비스에 전달되는데 30만원은 같은 인스턴스 40만원은 계좌 서비스의 다른 인스턴스에 할당된다. 입금의 경우 인스턴스가 종료되어 처리할 수 없지만 출금은 인스턴스가 실행하고 있어 또 다시 잔액이 부족한 상황이 발생한다.

같은 이벤트

앞의 모든 예시는 입금과 출금이 거의 동시에 이루어진다고 가정했다. 다시 말해, 모든 이벤트가 거의 동시간대 NATS Streaming 서버로 전송된다는 것이다. 이번에는 좀 더 현실적인 상황을 살펴본다. 1번째 50만원 입금을 월요일에 2번째 60만원 입금을 화요일, 그리고 100만원 출금을 금요일에 할 수 있다. 이 시나리오에서는 각 이벤트가 처리되는 시간 사이에 상당한 시간이 있을 수 있다. 임급 이벤트 처리가 실패해도 서비스의 다른 인스턴스에 할당할 수 있고 충분한 시간이 있기 때문에 이벤트를 처리하는 데 문제가 없다. 이는 화요일에도 마찬가지이다. 출금을 하는 금요일에 디스크에 저장된 파일이 매우 느려져서 NATS Streaming의 기본 시간이 30초에서 29.999초에 파일을 열고 읽고 수정하는데 1초가 걸리면 NATS Streaming은 이벤트가 처리되지 못했다고 생각하여 다른 인스턴스로 재할당한다. 하지만 현재 인스턴스는 이벤트를 성공적으로 처리하고 있다. 다시 말해, 30초가 지나도 처리 타임아웃이 없기 때문에 인스턴스는 정상적으로 작동한다. 조금 뒤 인스턴스가 100만원 출금을 완료하고 10만원을 파일에 저장한다. 하지만 이미 NATS Streaming는 이벤트를 다른 인스턴스로 재할당했다. 여기서 다시 100만원을 출금하려고 시도하면 잔액이 부족하다는 오류가 다시 발생한다.

동시성 문제는 애플리케이션 내에서 실제로 발생할 가능성이 매우 높다. 이러한 문제들은 이벤트가 순서대로 처리되지 않거나, 서비스의 처리 속도가 다르거나, 서비스 중단, 한 이벤트가 두 번 처리되는 상황 등으로 인해 발생할 수 있다.


해결책

동시성 문제의 유형 중 일부에 대해 알아봤다. 발생할 수 있는 동시성 문제는 당연히 이보다 훨씬 더 많을 것이다. 가능한 해결책을 살펴본다.

서비스 당 인스턴스 하나

동시성 문제를 살펴보면 많은 문제가 서비스 두 개의 인스턴스가 이벤트를 처리하는 방식에서 발생한다는 걸 알 수 있다. 왜냐하면 인스턴스 간의 속도 차이, 파일 저장소와의 통신 문제 등이 원인일 수 있기 때문이다. 따라서 하나의 서비스 인스턴스만 실행하는 것이다. 즉, 복수의 인스턴스 대신 하나의 서비스 인스턴스만 실행해서 모든 이벤트를 처리하도록 만드는 것이다. 입출금을 예로 들면, 계좌 서비스의 한 인스턴스가 입금과 출금과 같은 모든 이벤트를 순차적으로 처리한다. 하지만 이 경우에도 여전히 실패할 가능성은 존재한다. 예를 들어, 1번 이벤트에서 디스크의 일시적인 문제로 파일을 열 수 없는 문제가 발생해서 이로 인해 1번 이벤트를 처리하지 못하며 다시 NATS Streaming이 이벤트를 전달해야 한다. 더 큰 문제는 바로 병목 현상이다. 만약 하나의 인스턴스만 실행한다면 애플리케이션에서 데이터를 처리하는 속도가 매우 제한될 것이고 확장성이 크게 떨어진다. 또한 인스턴스를 단 하나만 실행하기 때문에 수평적 확장이 불가능하다. 애플리케이션이 인기를 끌고 트래픽이 증가할수록 큰 치명적인 문제가 될 수 있다. 따라서 이 해결책은 작동하지 않는다.

가능한 모든 오류 원인 파악

간단하게 말해서 애플리케이션 내 발생할 수 있는 모든 가능한 동시성 문제를 찾아내어 이를 전부 코드로 해결하려는 시도라고 볼 수 있다. 참고로 애플리케이션 내부에는 무한에 가까운 동시성 문제들이 존재할 수 있다. 따라서, 모든 가능성을 찾아내어 각각의 문제를 해결하는 코드를 작성하는 것은 현실적으로 불가능에 가깝다. 물론, 예외가 있을 수 있다. 우주선과 같은 중요한 시스템을 만들 때는 항상 작동해야 하기 때문에 모든 상황을 처리하는 코드가 필요할 수 있지만 여러분이 트위터 클론 같은 애플리케이션을 만든다고 가정할 때 트윗이 순서가 어긋나는 게 정말 큰 문제가 될 수 있을까? 두 개의 트윗이 중복된다거나 그게 정말 중요한 문제일까? 즉, 이러한 문제를 해결하기 위해 엄청난 시간과 비용을 투자하는 게 과연 현명할까? 대부분의 경우 이러한 문제들은 애플리케이션에 큰 영향을 미치지 않으며 현실적으로 발생 가능성이 매우 낮다. 또한, 많은 경우 그 문제를 해결하려면 너무 많은 시간과 자원이 소모된다. 따라서 "이 문제가 정말 해결할 가치가 있는가?"라는 질문을 스스로 던져야 한다. 대부분의 경우, 해결하는 데는 너무 많은 비용이 들고 실제로는 별로 중요하지 않다는 결론에 도달한다. 따라서 항상 모든 경우를 처리하려는 시도는 비효율적이며 실제로 발생 가능성이 낮다면 해결할 필요가 없을 수 있다.

공유 상태를 사용하여 마지막으로 처리된 이벤트를 서비스 간에 공유

이 해결책에서 다시 한 번 계좌 서비스의 여러 인스턴스(A와 B)를 실행한다. A와 B가는 공유 상태(shared state)를 가지고 있다고 가정한다. 공유 상태는 Redis 서버일 수도 있고 공유 데이터베이스일 수도 있으며 또는 네트워크 연결일 수도 있다. 공유 상태는 각 이벤트의 시퀀스 번호(sequence number)를 기록한다. 시퀀스 번호는 A와 B가 처리한 모든 이벤트를 추적한다. 다시 말해, 이벤트 1 인스턴스 A로 들어오고 A는 이 이벤트를 처리한 후 40만원을 입금한다. 그 다음 시퀀스 번호 1을 공유 데이터 저장소에 기록한다. 이벤트 2가 인스턴스 B로 들어오고 B는 공유 데이터 저장소에서 이전 시퀀스 번호(1번)가 이미 처리되었는지 확인하고 B는 이벤트 2를 처리하여 10만원을 입금하고 시퀀스 번호 2를 저장한다. 마지막으로 이벤트 3이 인스턴스 A로 가서 시퀀스 번호 2가 처리되었는지 확인 후 20만원을 출금하고 시퀀스 번호 3을 기록한다. 이 해결책의 장점은 모든 이벤트가 정확히 한 번만 처리되며 이벤트들이 올바른 순서로 처리되도록 보장한다. 하지만 단점은 모든 이벤트를 순차적으로 처리해야 한다는 제약 때문에 성능 상 큰 문제가 될 수 있다. 예를 들어, 만약 사용자 A와 사용자 B가 각각 다른 계정을 가지고 있고 각자의 계정에서 입출금을 하려고 한다고 가정한다. 이벤트 1은 사용자 A의 입금 이벤트, 이벤트 2는 사용자 B의 입금 이벤트이다. 이제 인스턴스 A가 이벤트 1을 처리하려고 하지만 이 과정에서 파일 저장소와의 연결이 끊기거나 서비스 자체가 지연되는 상황이 발생하여 이벤트 1의 처리가 지연된다. 인스턴스 B는 이벤트 2를 처리하려고 하지만 공유 데이터 저장소에서 이전 시퀀스 번호(1번)가 처리되지 않았기 때문에 이벤트 2의 처리를 할 수 없다. 사용자 A와 B의 계정이 서로 독립적임에도 불구하고 사용자 A의 문제로 인해 사용자 B의 처리가 지연된다. 즉, 이 방식은 전역으로 단 하나의 수정만 처리할 수 있도록 제한되며 이는 애플리케이션의 처리 속도를 심각하게 저하시킬 수 있다. 따라서 이 해결책은 모양새는 좋지만 성능 문제로 인해 실용적이지 않다는 것을 알 수 있다.

자원 아이디별로 마지막 처리된 이벤트를 추적

이 해결책에서는 이전 방법에서 언급된 문제를 사용자별 독립적인 시퀀스 번호를 사용하는 방식을 도입하여 해결한다. 자원 소유자마다 별도의 시퀀스 풀을 만들어 한 자원 소유자의 이벤트 처리가 다른 자원 소유자에게 영향을 미치지 않도록 한다. 각 사용자는 자신만의 독립적인 시퀀스 번호를 가진다. 사용자1은 시퀀스 번호 1로 100만원을 입금하고 사용자2는 시퀀스 번호 1로 50만원을 입금한다. 사용자별로 시퀀스 번호를 추적하여 한 사용자의 이벤트가 다른 사용자의 이벤트와 겹치지 않도록 한다. 이전 해결책에서 인스턴스 A와 B가 공유 저장소에 있는 전역 시퀀스를 참조했기 때문에 어느 한 사용자의 이벤트 처리 중 지연이나 실패가 발생하면 다른 사용자들의 이벤트 처리도 차단되는 문제가 있다. 하지만 이 방식에서는 사용자1의 이벤트가 처리되는 동안 사용자2의 이벤트가 처리되지 않아도 사용자1의 이벤트가 계속 처리될 수 있다. 사용자1의 시퀀스 2와 3이 차례대로 처리되면서 사용자2의 1번 이벤트는 별개로 처리된다. 이 방식은 단순히 사용자뿐만 아니라 자원 소유자(e.g., 계정, 주문, 상품 등)에 대해서도 적용할 수 있으며 각 자원에 대한 이벤트가 개별적으로 관리되기 때문에 서로 간섭 없이 처리할 수 있다. 각 자원이 독립적인 시퀀스 번호를 가지므로 하나의 자원 이벤트 처리 실패가 다른 사용자에게 영향을 미치치 않아 병렬 처리가 가능하며 이벤트의 순서가 정확하게 보장되면서도 각 자원에 대한 독립적인 처리가 가능해 동시성 문제를 최소화 한다. 자원마다 개별적인 채널을 만들어야 하기에 오버헤드가 심하게 발생할 수 있다. 또한, 자원에 대해 별도의 채널을 생성하면 채널 수가 많아질수록 성능 저하가 발생할 수 있다. 이벤트 버스에서 자원에 대해 독립적인 시퀀스 번호를 관리하려면 해당 이벤트 버스가 많은 처리 오버헤드를 감수해야 한다. 결론적으로, 자원 간의 동시성 문제를 해결하는 좋은 방법처럼 보이지만 채널 수 증가로 인한 오버헤드가 발생하기 때문에 실용적이지 않을 수 있다. 이는 확장성을 고려해야 하는 대규모 애플리케이션에서는 더 큰 문제가 될 수 있다.

자원에 대한 이벤트를 처리할 때 각 이벤트의 마지막 처리된 시퀀스 번호를 추적

발행자는 각 이벤트를 발행할 때마다 이벤트를 기록하는 데이터베이스를 가지고 있다. 이를 통해 발행자는 자신이 발행한 이벤트에 대한 정보를 유지할 수 있다. 이벤트가 NATS Streaming 서버로 전송되면 NATS Streaming 서버는 각 이벤트에 고유한 시퀀스 번호를 할당하고 이를 발행자에게 다시 반환한다. 발행자는 이 시퀀스 번호를 자신의 데이터베이스에 기록한다. 각 자원은 마지막 처리된 이벤트 시퀀스 번호를 기록하여 이후에 발생하는 이벤트가 처리되기 전에 이 번호를 검증한다. 예를 들어, 사용자1의 경우 마지막 이벤트 시퀀스 번호가 1이라면 새로운 이벤트는 이전 시퀀스 번호와 비교하여 처리 여부를 결정한다. 서로 다른 자원의 이벤트가 동시에 발생할 수 있으므로 각 지원의 이벤트는 독립적으로 처리된다. 예를 들어, 사용자1이 100만원을 입금하고 동시에 사용자2가 50만원을 입금하는 경우 두 이벤트는 각각의 마지막 처리된 이벤트 번호를 검증하여 독립적으로 처리한다. 각 자원의 이벤트 처리가 독립적으로 이루어질 수 있도록 하는 것이 이 접근 방식의 핵심 목표이다. 단점은 NATS Streaming의 기술적 한계와 관련이 있는데 NATS Streaming 서버에서 발행된 이벤트의 시퀀스 번호는 발행자가 알 수 없기 때문에 발행자는 직접적으로 이 번호를 확인할 수 없다. 이는 발행자가 시퀀스 번호를 기준으로 이벤트를 처리하는 데 어려움을 초래한다. 프로젝트에 적용한 방법은 이 해결책과 유사하다.


이벤트 재전송

서비스가 오프라인 상태일 때는 발행되는 이벤트를 처리할 수 없어서 서비스가 다시 온라인 상태로 돌아올 때 놓친 모든 이벤트의 목록을 제공할 수 있어야 한다. NATS Streaming은 이벤트를 발행할 때마다 해당 이벤트를 채널 내에 내부적으로 저장한다. 예를 들어, 리뷰 생성 같은 이벤트를 발행할 수 있다. 그러면 이 이벤트는 서비스에 전달되고 동시에 해당 이벤트의 복사본이 저장된다. 채널 내에서 발행하는 모든 이벤트는 자동으로 저장되며 나중에 특정 시점에 구독을 생성할 때 이 구독을 사용자 정의하여 과거에 발행된 이벤트 목록을 가져오도록 설정할 수 있다. 

NATS Streamingd은 구독을 설정하는 옵션을 제공하는데 그 중 하나가 setDeliverAllAvailable() 메서드이다. 이 메서드는 구독이 생성될 때 NATS Streaming은 구독이 생성되기 전 또는 구독이 다운된 동안 놓친 모든 이벤트를 전송한다. 유일한 단점은 애플리케이션 실행 후 저장된 이벤트의 방대한 목록을 다시 전달하는 것이다. 저장된 이벤트는 수천 개, 수십만 개, 심지어 수백만 개에 이를 수 있다. 따라서 장기적으로는 매번 구독할 때마다 발행된 모든 이벤트를 요청하는 것은 실용적이지 않다. 이를 보완하는 메서드가 setDurableSubscription()이다. 지속적인(durable) 구독은 구독에 식별자를 부여할 때 생성되며 이벤트를 놓치지 않도록 보장해주며 향후 이벤트를 잘못 처리하지 않도록 하는 데 정말로 유용하다. 두 메서드는 같이 사용해야 하는데 setDeliverAllAvailable() 메서드는 지속적인 구독이 생성될 때, 처음으로 이벤트가 생성될 때 중요한 역할을 한다. 서비스가 처음으로 온라인에 올라올 때 setDeliverAllAvailable() 메서드를 사용하면 NATS Streaming은 과거의 모든 이벤트를 가져와 전달한다. 이는 구독이 처음 생성될 때만 발생한다. 따라서, setDeliverAllAvailable() 메서드는 서비스가 처음 온라인에 올라올 때 과거에 발행된 모든 이벤트를 받을 수 있게 해주기 때문에데이터 손실을 방지하는 데 큰 도움이 된다.


이벤트 발행 실패

이벤트 발행에 실패하면 데이터 일관성에 어떤 문제가 발생할까? 즉, 원래 서비스에서는 데이터베이스에 데이터가 성공적으로 반영되었지만 네트워크 연결이 갑자기 끊어져 이벤트 발행이 실패하는 것이다. 사용자는 입금이 성공했다는 정보를 확인했음에도 불구하고 실제로 잔액에는 변화가 없는 것을 보고 당혹을 금치 못할 것이다. 여기서 핵심 문제는 단순히 이벤트 발행이 실패했다는 사실을 사용자에게 어떻게 전달할 것인가가 아니라 이벤트 발행에 실패한 사실을 어떻게 기록하고 해결하여 서비스 간의 데이터 무결성을 유지하는 것이다.

해결하는 방법은 데이터를 데이터베이스에 저장할 때 이벤트를 바로 이벤트 버스에 발행하는 것이 아니라 발행할 이벤트도 동시에 데이터베이스에 저장한다. 이때 이벤트가 아직 발행되지 않았다는 플래그를 설정한다. 다음으로 이벤트 테이블/컬렉션을 감시하는 별도의 프로세스 혹은 코드 새로운 이벤트가 저장되면 이를 NATS Streaming에 발행한다. 성공적으로 발행된 후에는 발충 플래그를 업데이트하여 이벤트가 이미 발행되었음을 표시합니다. 이 방법을 사용하면 NATS Streaming 서버나 네트워크 장애가 발생해도 데이터 일관성을 유지할 수 있다. NATS Streaming 서버에 연결할 수 없는 상황에서도 데이터와 이벤트는 데이터베이스에 안전하게 저장되고 연결이 복구되면 이벤트 발행이 가능하다.


OCC

해결책에 등장한 여러 방법 중에서 마지막 방법인 각 이벤트의 마지막 처리된 시퀀스 번호를 추적하는 방법이 프로젝트에 적용한 방법과 유사하다고 말했다. MongoDB의 도큐먼트는 모두 버전이라는 필드를 가지는데 이 필드를 시퀸스 번호라고 생각할 수 있다. 즉, 도큐먼트 생성 시 0 혹은 1을 필드에 부여하고 도큐먼트를 수정하려고 하면 이벤트에서 보낸 아이디와 버전을 데이터베이스의 아이디와 버전을 비교해서 일치하면 수정을 진행한다. 그렇다면 이 버저 필드를 어떻게 관리해야 할까? 다행히도 MongoDB와 Mogooose는 이를 자동으로 처리해준다.

일단 MongoDB와 Mongoose가 일반적으로 도큐먼트를 어떻게 수정하는지 살펴본다. Mongoose는 MongoDB에서 도큐먼트를 가져온 다음 수정을 하고 저장을 한다. 중요한 부분은 Mongoose가 MongoDB에게 접근하여 수정 실행 요청을 전달한다. 요청 안에는 아이디에 맞는 도큐먼트를 수정하라는 내용이 포함되어 있으며 MongoDB는 해당 컬렉션에서 아이디에 해당하는 도큐먼트를 수정을 실행한다. 이제 버전 필드를 사용하여 수정을 실행하는 과정을 살펴본다.

흐름은 동일하지만 버전 필드가 통합되어있는데 이 과정은 낙관전 동시성 제어(OCC)라고 불린다. OCC는 MongoDB 뿐만 아니라 다른 유형의 데이터베이스에도 적용 가능하다. OCC는 여러 트랜잭션이 서로 간섭하지 않고 자주 완료될 수 있다고 가정한다. 트랜잭션이 실행되는 동안 트랜잭션은 데이터 자원을 잠금(lock) 없이 사용한다. 커밋하기 전 각 트랜잭션은 다른 트랜잭션이 자신이 읽은 데이터를 수정하지 않았는지 확인한다. 만약 이 확인 과정에서 충돌하는 수정이 발견되면 커밋 중인 트랜잭션은 롤백되고 다시 시작할 수 있다. 버전 필드에 이를 적용하면 도큐먼트를 가져오고 수정을 하고 저장한다. 내부적으로 Mongoose에서 설정을 활성화하면 Mongoose가 버전 필드를 자동으로 수정하낟. 즉, Mongoose가 버전 필드를 1→2→3→4로 자동으로 증가시킨다. 그런 다음 Mongoose는 MongoDB에 수정 요청을 전달한다. 버전 관리 기능이 활성화되면 도큐먼트를 가져올 때 아이디뿐만 아니라 버전 필드의 번호까지 일치하는지 확인한다. 따라서 MongoDB가 도큐먼트를 수정하려고 할 때 실제로 버전 번호를 비교하지 않으며 Mongoose가 아이디와 버전이 일치하는 도큐먼트를 찾는다. 이러한 방식이 정확한 버전을 가진 도큐먼트를 선택하도록 보장하는 핵심이다. 주의할 점은 이 모든 과정이 도큐먼트 수정 연산과 관련있다. 삽입의 경우 버전이 항상 0 혹은 1로 설정된다고 가정한다. 따라서, 삽입 연산과 관련된 큰 문제는 발생하지 않는다. 이제 Mongoose가 이 기능을 어떻게 구현하는지 살펴본다.

Mongoose는 mongoose-update-if-current 패키지를 사용해서 OCC를 적용한다. 패키지는 도큐먼트의 __v 필드를 기본적으로 수정하는데 이는 맞춤 설정으로 변경할 수 있다. 참고로 반드시 Mongoose의 save() 메서드를 사용해야 버전 필드가 자동으로 수정된다. 예시로 여행 모델 코드를 보면 다음과 같다.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/*
 * 여행 도큐먼트가 가지는 속성을 기술하는 인터페이스.
 * 여행 도큐먼트가 여행 인스턴스를 생성할 때 전달하는 속성이 도큐먼트에 존재하는 속성과 다를 때 발생하는 문제를 해결한다.
 * sequence는 OCC에 사용되는 필드.
 */
interface TourDocument extends mongoose.Document {
  _id: mongoose.Types.ObjectId;
  coverImage: string;
  difficulty: string;
  discount: number;
  ...
  bookingId?: mongoose.Types.ObjectId;
  sequence: number;
}
 
interface TourModel extends mongoose.Model<TourDocument> {
  build(attrs: TourAttribute): Promise<TourDocument>;
}
 
const tourSchema = new mongoose.Schema(
  {
    coverImage: {
      type: String,
      required: [true'표지 이미지가 있어야 합니다.'],
    },
    description: {
      type: String,
      trim: true,
    },
    difficulty: {
      type: String,
      required: [true'난이도가 있어야 합니다.'],
      enum: {
        values: ['상''중''하'],
        message: '난이도는 상, 중, 하 중 하나입니다.',
      },
    },
    ...
    createdAt: {
      type: Date,
      defaultDate.now(),
      select: false,
    },
    updatedAt: {
      type: Date,
      select: false,
    },
    deletedAt: {
      type: Date,
      select: false,
    },
  },
  {
    /*
     * https://mongoosejs.com/docs/api/document.html
     * MVC 패턴 관점에서 보면 모델에서 뷰의 논리를 작성하는 것은 적합하지 않다.
     * 왜냐하면 뷰가 출력과 관련되어 있기 때문이다.
     */
    toJSON: {
      virtuals: true,
      versionKey: false,
      /*
       * 결과 객체를 변환해야 할 수도 있다.
       * 예를 들어, 민감한 정보를 제거하거나 사용자 정의 객체를 반환해야 할 때가 있다.
       * 이 경우 선택적인 변환 함수를 설정한다.
       */
      transform(document, pojo) {
        delete pojo._id;
        delete pojo.secret;
        delete pojo.createdAt;
      },
    },
    /* 도큐먼트를 일반 JavaScript 객체(POJO)로 변환한다. */
    toObject: { virtuals: true, versionKey: false },
  },
);
 
// OCC를 Mongoose에 적용한다.
tourSchema.set('versionKey''sequence');
tourSchema.plugin(updateIfCurrentPlugin);
cs

그렇다면 버전 필드는 언제 누가 수정해야 할까? 모든 이벤트가 항상 버전 번호를 증가시키거나 포함할 필요는 없다. 도큐먼트에 대한 버전 번호를 증가시키거나 포함하는 것은 오직 그 도큐먼트에 대해 주된 책임을 지는 서비스가 해당 도큐먼트를 생성, 수정, 또는 삭제하는 것을 설명하기 위해 이벤트를 발생시킬 때만 고려한다.

mongoose-update-if-current 패키지가 어떻게 버전 번호를 수정하는지 살펴보았다. 간편하고 편하지만 큰 단점이 있다. 일단 이 패지키를 사용한다는 의미는 이벤트가 어디에서 오고 있는지를 알고 어떻게 버전 번호가 수정되는지 안다는 것을 뜻한다. 하지만 만약 한 서비스가 Spring 프레임워크에 MySQL을 데이터베이스로 사용할 수 있다. 그러면 mongoose-update-if-current 패키지로 버전을 관리할 수 없다. 게다가, 이벤트에서 버전 번호를 적용하는 방식이 완전히 다를 수 있다. 즉, 0 혹은 1부터 시작하지 않고 10 혹은 100부터 시작해서 10 혹은 100씩 증가할 수 있다. 또는, 번호가 아니라 수정이 발생한 시간을 저장하는 타임스탬프를 사용할 수 있다. 따라서, 이러한 문제점을 반드시 고려해야 한다.

update: 2024.10.24

10/22/2024

Kubernetes 배포(Skaffold)

Kubernetes는 컨테이너 오케스트레이션 서비스로 오케스트레이션이라는 단어에서 알 수 있듯이 컨테이너화된 애플리케이션을 하나 이상의 노드로 구성된 클러스트에서 실행한다. 분산 컴퓨팅과 Docker 기반 컨테이너의 확산으로 Kubernetes를 비롯한 많은 컨테이너 오케스트레이션 서비스가 개발되었다. 마이크로서비스 아키텍처의 특성을 고려하면 Kubernetes와 찰떡궁합이라고 말할 수 있다. 이를 적용하면서 배운 내용을 정리한다.


Deployment

디플로이먼트는 포드를 관리하는 오브젝트로 설정 YAML 파일을 작성할 때 여러 속성들 때문에 조금 헷갈렸다. 설정 YAML 파일에서 spec은 생성하려는 오브젝트에 적용하는 속성을 명시하는 부분이다. selector 속성은 생성할 모든 포드를 찾는 방법을 디플로이먼트에 알려주며 matchLabels 속성은 selector 속성이 일치할 레이블을 명시한다.  template 속성은 디플로이먼트가 생성할 각 포드의 설정을 정의한다. 즉, metadata 속성과 spec 속성을 다시 명시하는 부분이다. 리뷰 서비스의 디플로이먼트 YAML 파일은 다음과 같다.
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
32
33
34
35
36
37
38
39
40
41
apiVersion: apps/v1
kind: Deployment
metadata:
  name: review
spec:
  replicas: 1
  selector:
    matchLabels:
      app: review
  template:
    metadata:
      labels:
        app: review
    spec:
      containers:
        - name: review
          image: csup96/tour-review
          env:
            - name: NODE_ENV
              value: 'development'
            - name: PORT
              value: '3000'
            - name: MONGO_URI
              value: 'mongodb://review-mongo:27017/review'
            - name: REDIS_HOST
              value: 'review-redis'
            - name: REDIS_PORT
              value: '6379'
            - name: JWT_ACCESS_SECRET
              value: 'personal-tour-project-in-nodejs-typescript-access'
            - name: JWT_REFRESH_SECRET
              value: 'personal-tour-project-in-nodejs-typescript-refresh'
            - name: NATS_URL
              value: 'http://nats:4222'
            - name: NATS_CLUSTER_ID
              value: 'tour'
            - name: NATS_CLIENT_ID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
 
cs


Service

포드를 관리하는 디플로이먼트 설정 파일을 작성하는데 크게 어려운 점이 없었지만 서비스의 경우 달랐다. 서비스는 오브젝트의 일종으로 서로 다른 포드 간의 통신을 설정하거나 클러스터 외부에서 포드에 접근할 수 있는 URL을 제공한다. 따라서, 네트워킹이나 통신을 생각할 때는 항상 서비스를 염두에 두어야 한다. 다시 말해, 생성하는 거의 모든 포드나 디플로이먼트는 항상 관련된 서비스를 가진다고 볼 수 있다. Kubernetes는 크게 4개의 서비스를 제공한다. Cluster IP는 클러스터 내에서만 접근 가능한 포드에 쉽게 기억할 수 있는 URL을 설정한다. Node Port는 개발 목적으로만 사용되는 경우가 많으며 포드를 클러스터 외부에서 접근 가능하게 한다. Load Balancer도 포드를 클러스터 외부에 노출한다. External Name은 클러스터 내 요청을 CNAME URL로 리다이렉트 한다.

일단 클러스터 내의 포드 간 통신은 당연히 Cluster IP를 사용하면 된다. 서비스 A 포드가 서비스 B 포드와 이벤트로 통신을 할 경우 서비스 A가 이벤트를 방출하면 이벤트 버스 포드의 Cluster IP로 요청을 보내고 이벤트 버스는 서비스 A 포드의 Cluster IP로 응답을 전송한다. 예를 들어, 리뷰 서비스의 서비스 YAML 파일을 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
  name: review
spec:
  selector:
    app: review
  ports:
    - name: review
      protocol: TCP
      port: 3000
      targetPort: 3000
  type: ClusterIP
cs


클러스터 외부와의 통신의 경우 Node Port와 Load Balancer중 하나를 골라야 하는데 Node Port의 큰 단점은 생성 시 기본적으로 무작위 포트를 열기 때문이다. 정확한 포트를 지정할 수 없고 클러스터 내에서 임의의 포트를 할당받게 받기 때문에 Node Port를 변경하게 되면 할당받는 포트가 다를 가능성이 있다. Load Balancer는 이러한 고민을 없애준다. Load Balancer는 클러스터 전체에 대한 단일 진입 지점을 설정하고 내부에서 요청을 적절한 포드로 라우팅 한다. 다시 말하지만 포드로 라우팅한다는 것은 실제로는 포드에 대해 생성할 Cluster IP를 참조하는 것이다. 그렇다면 라우팅은 어떻게 처리할까? 바로 Ingress 포드이다!


Ingress

이제 문제는 Load Balancer로 들어온 요청을 어느 포드로 전달하는 것이다. 즉, 라우팅 규칙을 설정해야 하는데 이를 관리하는 것이 Ingress 포드이다. 여러 Ingress 포드 중에서 Ingress NGINX 포드를 사용하기로 결정했다. 사실 Load Balancer는 AWS, GCP 그리고 Azure와 같은 클라우드 서비스를 통해 제공받아야 하는데 Ingress-Nginx는 Ingress 포드뿐만 아니라 Load Balancer도 지원한다.


호스트 파일

하나의 Kubernetes 클러스터 내에서 여러 다른 애플리케이션을 다양한 도메인에 호스팅할 수 있는데 예를 들어, 애플리케이션 A는 blog-app.com 애플리케이션 B는 tasty-delivery.org에 있을 수 있다. Ingress NGINX는 여러 애플리케이션을 다양한 도메인에서 호스팅할 수 있음을 전제로 설정된다. 그런데 개발 환경에서는 큰 단점이 있다. 바로 모든 실행 중인 서버에 localhost로 접근한다. 그렇다면 라우팅 규칙이나 특정 도메인을 사용하는 아이디어가 Ingress NGINX와 어떻게 맞아 떨어질까? 개발 환경에서는 도메인을 localhost와 동일하게 인식하도록 속여야 한다. 만약 호스트가 blog-app.com이면 연결하려고 할 때 인터넷에 존재하는 실제 blog-app.com이 아닌 현재 컴퓨터에 연결하도록 속이는 것이다. 이를 위해 호스트 파일의 설정을 변경해야 한다. Windows는 C:\\Windows\System32\Drivers\etc\hosts, MacOS와 Linux는 /etc/hosts 파일을 수정한다.


ingress.yaml

NGINX는 와일드카드를 지원하지 않기 때문에 정규 표현식을 경로에 사용해야 한다. 경로의 순서는 매우 중요하다. 위에서부터 밑으로 경로를 순서대로 확인하기 때문에 OAuth 2.0 HTML 파일과 일치하는 정규 표현식이 가장 밑에 위치해야 한다. 만약 이 경로를 맨 위에 두면 경로 일치가 미리 발생해서 백엔드 경로를 사용할 수 없다. 또한, 리뷰나 예약과 같이 여행을 지칭하는 tours로 시작하는 API의 경우 tours 경로보다 위에 있어야 한다.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tour-ingress
  annotations:
    nginx.ingress.kubernetes.io/use-regex: 'true'
spec:
  ingressClassName: nginx
  rules:
    - host: tour.xyz
      http:
        paths:
          - path: /api/v1/auth/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: auth
                port:
                  number: 3000
          - path: /api/v1/oauth2/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: auth
                port:
                  number: 3000
          - path: /api/v1/users/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: auth
                port:
                  number: 3000
          - path: /api/v1/admin/users/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: auth
                port:
                  number: 3000
          - path: /api/v1/tours/?(.*)/bookings
            pathType: ImplementationSpecific
            backend:
              service:
                name: booking
                port:
                  number: 3000
          - path: /api/v1/bookings/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: booking
                port:
                  number: 3000
          - path: /api/v1/admin/bookings/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: booking
                port:
                  number: 3000
          - path: /api/v1/payments/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: payment
                port:
                  number: 3000
          - path: /api/v1/tours/?(.*)/reviews
            pathType: ImplementationSpecific
            backend:
              service:
                name: review
                port:
                  number: 3000
          - path: /api/v1/reviews/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: review
                port:
                  number: 3000
          - path: /api/v1/admin/reviews/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: review
                port:
                  number: 3000
          - path: /api/v1/tours/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: tour
                port:
                  number: 3000
          - path: /api/v1/admin/tours/?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: tour
                port:
                  number: 3000
          - path: /?(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: client
                port:
                  number: 3000
cs


Skaffold

애플리케이션을 실제 운영 환경에서 배포할 경우 애플리케이션 수정이 발생하면 Docker 이미지를 빌드하고 Docker Hub에 푸시한 후 kubectl rollout restart 명령어를 사용해서 배포를 진행한다. 하지만 개발 환경에서 이 과정은 너무 번거로운데 이를 도와주는 도구가 바로 Skaffold이다. Skaffold는 Kubernetes 개발 환경에서 다양한 작업을 자동으로 처리해주는 CLI 도구이다. 실행 중인 포드의 애플리케이션 코드를 매우 쉽게 수정할 수 있고 프로젝트와 관련된 모든 오브젝트들을 매우 빠르게 생성하고 삭제할 수 있다. Skaffold를 다운로드하고 설정하는 것은 홈페이지에서 쉽게 할 수 있다.
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
32
33
34
35
36
37
apiVersion: skaffold/v4beta3
kind: Config
manifests:
  rawYaml:
    - ./k8s/**/*
build:
  local:
    push: false
  artifacts:
    - image: csup96/tour-auth
      context: auth
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-booking
      context: booking
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-expiration
      context: expiration
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-payment
      context: payment
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-review
      context: review
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-tour
      context: tour
      docker:
        dockerfile: Dockerfile
    - image: csup96/tour-client
      context: client
      docker:
        dockerfile: Dockerfile
cs

update: 2024.10.22