// 에드센스

혹시 제가 사용한 방법보다 더 간단하고 좋은 방법이 있다면 훈수 부탁드립니다.


1. 마주한 문제

클레이튼 기반의 dapp을 개발하며 블록체인의 트랜잭션과 서비스의 데이터베이스간의 싱크를 보장해야하는 상황을 마주했다.

 

 

성공한 트랜잭션이면 관련 정보를 데이터베이스에 반영해주고,

실패한 트랜잭션이면 관련 정보를 데이터베이스에 반영해주지 않는, 그런 요구사항이었다.

 

 

클레이튼 블록체인에서 자체적으로 실패한 트랜잭션에 대한 롤백은 시켜주기에 클레이튼의 성공여부를 먼저 확인하고 데이터베이스 반영 여부를 정해야했다. [클레이튼 트랜잭션] -> [데이터베이스 트랜잭션]의 플로우였기에 이 사이에 트랜잭션의 성공여부만 확인하고 데이터베이스의 트랜잭션을 발생시킬지 말지 결정하면 됐다. 

 

[클레이튼 트랜잭션] -> [트랜잭션이 성공했는지 확인] -> [데이터베이스 트랜잭션]

 

하지만 블록체인은 트랜잭션이 확정되기까지 걸리는 시간인 Finality라는 개념이 있기에 트랜잭션의 성공 여부를 조회하려면 이  Finality 시간 이후에 해야했다. 클레이튼의 경우 이 시간이 평균 1초라고 알려져있다. 평균이기에 실제 상황에서 약간의 오차는 있을 것 이다. 따라서 Finality만큼의 딜레이를 하고 트랜잭션 조회 -> 데이터베이스 트랜잭션 실행의 절차를 밟아야 했다. 

 

 

즉, 필요한 조건은

 

1. 트랜잭션 조회에 delay를 줘야한다. -> Finality 때문에

2. 적어도 2회 이상 retry를 하며 조회해야한다. -> Finality 시간이 일정하지 않기 때문에

3. 최대한 빠르게 트랜잭션의 성공 여부를 판단해야한다. -> 원활한 서비스 제공을 위해

 

 

 

 


2. 고려했던 선택지

세가지를 고려했었다. 참고로 NodeJS로 개발했다.

 

1. 스케줄러를 통해 짧은 간격으로 확정되지 않은 트랜잭션들의 성공 여부를 조회하기

  1. 트랜잭션을 발생시킬 때 transaction_history 테이블에 트랜잭션의 발생 여부를 기록해두고 is_confirmed는 false로 둔다.
  2. NodeJS의 스케줄러를 통해서 is_confirmed가 false인 기록들을 짧은 간격으로 주기적으로 조회하며 트랜잭션의 성공 여부를 확인한다.(트랜잭션 성공여부는 caverExtKAS 라이브러리의 getTransferHistoryByTxHash 함수를 사용했다)

 

문제점.

트랜잭션이 발생하지 않을때도 몇 초마다 항상 실행되기에 리소스 낭비라고 생각됐다.

 

 

 

2. 클레이튼 이벤트 리스너를 통해 트랜잭션이 수행될 때 발생되는 이벤트를 추적하여 판별하기

  1. 트랜잭션을 발생시킬 때 transaction_history 테이블에 트랜잭션의 발생 여부를 기록해두고 is_confirmed는 false로 둔다.
  2. 트랜잭션이 성공하면 열어둔 소켓을 통해 클레이튼 이벤트를 Listen할 수 있는데 트랜잭션이 성공하면 이벤트가 수신되므로(아마..?) 수신했다는 것은 해당 트랜잭션이 성공했다는 의미.

 

문제점.

가끔 성공한 트랜잭션의 이벤트가 리슨되지 않을때가 있었다. (왜일까..)

 

 

 

3. RabbitMQ를 통해서 딜레이와 재시도 기능을 구현하여 조회하기 (이 방법을 사용했다)

  1. 트랜잭션을 발생시킬 때 transaction_history 테이블에 트랜잭션의 발생 여부를 기록해두고 is_confirmed는 false로 둔다. 또 동시에 RabbitMQ로 발생한 트랜잭션의 정보를 publish한다.
  2. 컨슈머에서 이를 구독하고 이 시점에 트랜잭션 성공 여부를 조회한다.
  3. 트랜잭션이 성공이면 데이터베이스에도 관련 정보를 반영 후 ack으로 마무리하고, 실패면 retry_count를 1추가해서 DLX로 보낸다. 이곳에서 5000ms동안 대기하고 다시 작업큐로 메시지를 보내서 반복한다.(넉넉히 5초로 딜레이를 주었다)
  4. 3번에서 반복 횟수동안 모두 트랜잭션이 확정이 안됐거나 실패 판정이 나면 데이터베이스에 관련 정보를 반영하지 않고 ack을 반환한다.

 

이 방법을 선택한 이유

  • RabbitMQ를 통해서 사용자가 몰릴때 비동기적으로 안정적인 로직 수행이 가능하기에
  • RabbitMQ의 Durability를 통해 중요한 정보를 잃어버리지 않을 수 있기에
  • RabbitMQ의 DLX 기능을 통해 딜레이와 재시도를 구현할 수 있기에

 

 

 

 


3. RabbitMQ 구성

RabbitMQ는 위와같이 구성해보았다.

 

[1], [2]

클레이튼 트랜잭션을 발생시킴과 동시에 트랜잭션의 정보와 retry_count를 담은 메시지를 publish한다.

 

[3], [4]

지정한 익스체인지에서 지정한 큐로 메시지를 전달한다.

 

[5]

트랜잭션의 성공 여부를 확인하는 컨슈머에서 트랜잭션의 성공 여부를 확인한다.

성공이라면 서비스 로직을 실행하고 ack을 반환한다.

 

[6]

트랜잭션이 아직 확정되지 않았거나 실패했다면 WAITING 익스체인지로 같은 메시지를 그대로 전달한다.

이때마다 retry_count에 1 씩 더해서 전달한다.

 

[7]

전달된 메시지는 TX.Q.WAIT_CONFIRM_CHECK 큐로 보내지는데 이 큐는 DLX와 TTL 설정이 돼있다.

 

[8]

지정한 TTL 시간이 지나면 메시지가 x-dead-letter-exchange로 지정된 익스체인지로 x-dead-letter-routing-key 바인딩 키를 사용해서 전달된다.

즉, 이 큐에서 5000ms 동안 대기하고 TX.E.DIRECT 익스체인지로 가게된다.

이때 바인딩키는 TX를 가지고간다.

TX는 TX.E.DIRECT와 TX.Q.CONFIRM_CHECK의 바인딩키다.

따라서 TX.E.DIRECT 익스체인지에 도착한 메시지는 TX.Q.CONFIRM_CHECK 큐로 다시 보내진다.

 

 

 

[4], [5], [6], [7], [8]의 과정을 최대 3번까지 반복하고도 트랜잭션이 실패라고 판별되면 최종적으로 실패처리(데이터베이스에 반영하지 않기)를 한 후 ack를 반환한다.

 

 

 

DLX의 개념과 동작방식은 아래 아저씨가 쉽게 설명해준다!

https://www.youtube.com/watch?v=ovE8NKAwqTI&ab_channel=MikeM%C3%B8llerNielsen 

 

 

 

 


4. 세부 구성

메시지는 

[1] -> [2]에서 5000ms 대기 -> [3] -> [4]의 순서로 움직인다.

 

 

 

 

 

후기.

뭔가 긴 고민끝에 찾은 방법이지만 결국은 기본적인 RabbitMQ의 활용법이었다!

RabbitMQ가 참 유용한 도구임을 새삼스래 느꼈던 경험이었다.

 

 

참고:

더보기


1. 왜 RabbitMQ를 사용하는가?

RabbitMQ는 *AMQP를 따르는 오픈소스 메세지 브로커이다.

RabbitMQ는 데이터를 잠시 보관하고 나중에 비동기적으로 처리하고 싶을 경우 사용하는 일종의 데이터 저장소이다.

 

*AMQP: Advanced Message Queing Protocol의 약자로, 흔히 알고 있는 MQ의 오픈소스에 기반한 표준 프로토콜

 

실생활에서 예를 들어보자

스타벅스에서 손님들이 줄을 서서 커피를 주문한다.

 

1. 메시지 큐X

첫번째 손님이 커피를 주문하면 첫번째 손님의 커피가 완성될때까지 두번째 손님은 계속 줄을 서서 대기해야한다.

 

2. 메시지 큐O

첫번째 손님은 커피를 주문하고 자리로 간다. 두번째, 세번째 손님들도 주문서만 던져놓고 자리로 간다. 바리스타는 쌓여가는 주문서들을 보며 순서대로 커피를 만든다. 커피가 만들어지면 손님들이 받아간다.

 

이때 손님(프로듀서)들이 바리스타(컨슈머)에게 던져놓는 주문서가 메시지가 되고 주문서가 쌓여가는 곳이 메시지 큐가 된다.

 

이런 구조는 커피를 비동기적으로 만들기에 효율적이며,

주문서는 바리스타에게 전달될때 까지 잠시 저장되기에 바리스타가 까먹거나 하는 주문 누락이 발생하지 않는다.

 

 

 

정리하자면

  • 메시지를 많은 사용자에게 전달해야할 때
  • 요청에 대한 처리시간이 길어 해당 요청을 다른 API에 위임하고 빠른 응답처리가 필요할 때
  • 애플리케이션 간 결합도를 낮춰야 할 때

RabbitMQ를 사용한다

 

 

 

 


2. RabbitMQ는 어떻게 이루어져있는가?

[프로듀서 → 브로커(익스체인지+큐) → 컨슈머]

의 구조로 메시지를 전달해주는 메시징 서버

RabbitMQ는 다음과 같이 구성된다.

  1. Producer: 메시지를 보내는 놈
  2. Exchange: 메시지를 알맞은 큐에 전달해주는 놈
  3. Queue: 메시지를 차곡차곡 쌓아두는 놈
  4. Consumer: 메시지를 받는 놈
 

 

위 그럼처럼 Producer는 Queue에 직접 메시지를 전달하는 것이 아니다.

[프로듀서 → 익스체인지 → 큐 → 컨슈머]의 절차를 밟는다.

Exchange에서 알맞은 Queue로 메시지를 분배한다.(Exchange들과 Queue들은 바인딩되어있다)

무슨 기준으로 분배하느냐?

Exchange Type에 따라 다르다.

 

Exchange Type 4가지

Direct

메시지에 포함된 Routing Key를 기반으로 특정 Queue에 메시지를 하나씩 전달한다.

 

 

Fanout

Routing Key에 상관 없이 연결돼있는 모든 Queue에 동일한 메시지를 전달한다.

라우팅키를 평가할 필요가 없기때문에 성능적인 이점이 있다.

 

Topic

라우팅키 전체가 일치하거나 일부 패턴과 일치하는 모든 Queue로 메시지가 전달된다.

Topic Exchange 에서 사용하는 binding key 는 점(.)으로 구분된 단어를 조합해서 정의한다.

* 와 #을 이용해 와일드 카드를 표현할 수 있으며, * 는 단어 하나 일치 # 는 0 또는 1개 이상의 단어 일치를 의미한다.

 

다음과 같이 binding key 를 정의한 경우에 메시지의 routing key 가 quick.orange.rabbit 또는 lazy.orange.elephant 이면, Q1, Q2 둘 다 전달된다. lazy.pink.rabbit 는 binding key 2개와 일치 하더라도 1번만 전달된다.

quick.brown.fox, quick.orange.male.rabbit 는 일치하는 binding key 가 없기 때문에 무시된다.

 

Header

메시지 속성 중 headers 테이블을 사용해 특정한 규칙의 라우팅을 처리한다.

  • x-match = any 일 경우 헤더 테이블 값 중 하나가 연결된 값 중 하나와 일치하면 메시지 전달
  • x-match = all 일 경우 모든 값이 일치해야 메시지를 전달한다.

 

 

추가적인 사용 패턴들은 다음 블로그에 잘 설명이 되어있더라.

https://hamait.tistory.com/402

 

RabbitMQ 사용패턴들

역주: 메세지큐에 대한 글을 적기 전에 왜 메세지큐냐? 를 먼저 생각해봐야한다. 메세지큐는 그냥 메세지를 전달해주는 서버인건데, 기술자체에 집중할 필요는 나중에 생각해보고 , 처음 생각해

hamait.tistory.com

 

 

 

 


3. Connection과 Channel

Connection

  • RabbitMQ에서 지원하는 모든 프로토콜은 TCP 기반이다.
  • 효율성을 위해 긴 연결을 가정한다. (프로토콜 작업당 새 연결이 열리지 않음.)
  • 하나의 클라이언트 연결은 단일 TCP 연결을 사용한다.
  • 클라이언트가 연결을 성공하려면, RabbitMQ 대상 노드는 특정 프로토콜에 대한 연결을 허용해야 한다.
  • 연결은 오래 지속되어야 하기 때문에 일반적으로 구독을 등록하고, 폴링 대신에 메시지를 전달하여 소비한다.
  • 연결이 더 이상 필요하지 않은 경우, 리소스 절약을 위해 연결을 닫아야 한다. 이를 수행하지 못하는 클라이언트는 리소스의 대상 노드를 고갈시킬 위험이 있다.
  • 운영 체제는 단일 프로세스가 동시에 열 수 있는 TCP 연결(소켓) 수에 대한 제한이 있다. QA 환경에서는 충분한 경우가 있지만, Production 환경에서는 더 높은 제한을 사용하도록 구성해야 할 수도 있다.

 

Channel

  • 단일 TCP 연결을 공유하는 논리적인 개념의 경량 연결로 다중화된다.
  • 클라이언트가 수행하는 모든 프로토콜 작업은 채널에서 발생한다.
  • 채널 안에 연결할 Queue를 선언할 수 있으며, 채널 하나당 하나의 Queue만 선언이 가능하다.
  • 채널은 Connection Context에만 존재하기 때문에 Connection이 닫히면, 연결된 모든 채널도 닫힌다.
  • 클라이언트에서 처리를 위해 멀티 프로세스/스레드를 사용한다면, 프로세스/스레드 별로 새 채널을 열고 공유하지 않는 것이 일반적이다.

 

아무튼.

Connection: Application과 RabbitMQ Broker사이의 물리적인 TCP 연결

Channel: connection내부에 정의된 가상의 연결. queue에서 데이터를 손볼 때 생기는 일종의 통로같은 개념

 

한개의 Connection에 여러개의 Channel,
한개의 Channel에 한개의 Queue

https://hyos-dev-log.tistory.com/8

 

RabbitMQ의 Connection과 Channel

사내에서 진행되는 서비스를 개편하게 되면서, RabbitMQ를 사용하게 되었습니다. RabbitMQ을 사용하고, 운영하면서 Connection과 Channel에 대한 개념이 잡히지 않아서 정리하게 되었습니다. Connection 일반

hyos-dev-log.tistory.com

 

 

 

 


4. 적용하기

NodeJS에 적용하기

1. 초기 설정

amqp.connect함수를 통해서 Rabbitmq에 연결한다.

export async function makeConnection() {
  const connection = await amqp.connect({
    protocol: config.rabbitmq.protocol,
    username: config.rabbitmq.username,
    password: config.rabbitmq.password,
    hostname: config.rabbitmq.hostname,
    port: config.rabbitmq.port,
    vhost: '/',
    heartbeat: 0,
  });
}

 

2.  NodeJS에서 메시지 Subscribe하기

const messageQueueConnectionString = {
  protocol: config.rabbitmq.protocol,
  username: config.rabbitmq.username,
  password: config.rabbitmq.password,
  hostname: config.rabbitmq.hostname,
  port: config.rabbitmq.port,
  vhost: '/',
  heartbeat: 0,
};


export async function listenForResults() {
  try {
    // connect to Rabbit MQ (1)
    const connection = await amqp.connect(messageQueueConnectionString);

    // create a channel and prefetch 1 message at a time (2)
    const myChannel1 = await connection.createChannel();
    await myChannel1.prefetch(1);

    const myChannel2 = await connection.createChannel();
    await myChannel2.prefetch(1);

    // start consuming messages (3)
    console.log('start consuming messages from web service');
    await myController.myConsume1({ connection, myChannel1 });
    await myController.myConsume2({ connection, myChannel2 });
  } catch (e) {
    Raven.captureException(e);
  }
}

(1) connection 획득

(2) connection으로부터 channel을 생성

(3) consume 실행

 

위 1, 2, 3번의 과정은 서버가 최초 실행될때 실행된다.

*Prefetch란, 큐의 메시지를 컨슈머의 메모리에 쌓아놓을 수 있는 최대 메시지의 양

 

https://minholee93.tistory.com/entry/RabbitMQ-Prefetch

 

[RabbitMQ] Prefetch

이번 글에서는 RabbitMQ의 Prefetch에 대해 알아보겠습니다. 1. Prefetch란? Queue의 메세지를 Consumer의 메모리에 쌓아놓을 수 있는 최대 메세지의 양 입니다. 예를 들어 Prefetch가 250일 경우, RabbitMQ는 250..

minholee93.tistory.com

 

 

export function myConsume1({ connection, myChannel1 }) {
  return new Promise((resolve, reject) => {
    myChannel1.consume('q1', async function (msg) { // queue 이름
      console.log('myConsume1');
      
      // (4)
      const msgBody = msg.content.toString();
      const data = JSON.parse(msgBody);

      // (5)
      // 비즈니스로직

      // acknowledge message as received
      await myChannel1.ack(msg);
    });

    // handle connection closed
    connection.on('close', err => {
      return reject(err);
    });

    // handle errors
    connection.on('error', err => {
      return reject(err);
    });
  });
}

 

(4) 메시지 파싱

(5) 원래 수행하고자 했던 로직 실행

ack를 전송하지 않고 소비자가 죽거나 (채널이 닫히거나 연결이 끊어 지거나 TCP 연결이 끊어지는 경우),

RabbitMQ는 메시지가 완전히 처리되지 않았 음을 인식하고 다시 대기한다.

즉 컨슈머(소비자)가 사망했을 경우를 대비

 

3.  NodeJS에서 메시지 Publish하기

export let channel1;

// (1)
export async function makeConnection() {
  const connection = await amqp.connect({
    protocol: config.rabbitmq.protocol,
    username: config.rabbitmq.username,
    password: config.rabbitmq.password,
    hostname: config.rabbitmq.hostname,
    port: config.rabbitmq.port,
    vhost: '/',
    heartbeat: 0,
  });
  
  // (2)
  channel1 = await connection.createConfirmChannel(); // createConfirmChannel는 ack과 nack을 확인한다
}

// (3)
export async function publishToChannel(channel, { exchangeName, routingKey, data }) {
  await channel.publish(exchangeName, routingKey, Buffer.from(JSON.stringify(data), 'utf-8'), { persistent: true });
}

(1) 서버 최초 실행시 makeConnection 메소드를 사용하여 연결한다

(2) channel당 하나의 queue만 생성이 가능하기에 필요한 큐만큼의 채널을 생성하고 전역변수로 export해준다

(3) channel의 publish메소드를 사용하는 함수를 만든다

(1), (2)의 과정은 서버가 최초 실행될때 실행된다.

 

RabbitMQ가 종료되거나 충돌하면 사용자가 알리지 않는 한 대기열과 메시지를 잃게된다. 메시지가 손실되지 않도록하려면 큐와 메시지에 durable과 persistent 설정을 주어야한다.

즉, ack과 달리 RabbitMQ가 사망할 경우를 대비

 

https://skarlsla.tistory.com/13

 

[RABBITMQ] persistent, durable, ack

1. 메시지 수신 자동 확인(ack , noAck) 작업을 수행하는 데 몇 초가 걸릴 수 있습니다. 소비자 중 한 명이 긴 작업을 시작하고 부분적으로 만 수행되어 사망하는 경우 어떻게되는지 궁금 할 수 있습

skarlsla.tistory.com

 

 

 

// (4)
const result = await rabbitmq.publishToChannel(rabbitmq.channel1, {
  exchangeName: 'ex1', // exchange name
  routingKey: 'p1', // bind pattern
  data: { uid: uid },
});

 

(4) 이후 메시지를 publish할 곳에서 export한 channel과 설정값을 publish함수의 인자로 넣어주며 사용한다.

 

 

 

Spring에서 사용하기

1. 초기설정 - application.yml

spring:
  rabbitmq:
    host: somethingsomethingaddress.mq.region.amazonaws.com
    port: 5671
    username: myname
    password: mypassword
    ssl:
      enabled: true
  listener:
    simple:
      acknowledge-mode: manual

 

2. Spring에서 메시지 Subscribe하기

@RabbitListener(queues = ["sample.queue"])
public fun receiveMessage (message : Message){
    System.out.println("수신받은 메시지는 $message");
}

 

 

조금 더 자세한 설정을 하려면 어노테이션에 옵션을 추가해주면 된다.

@RabbitListener(bindings = [QueueBinding(
    value = Queue(value = "q1"),
    exchange = Exchange(value = "ex1"),
)])
fun receiveMessage (message : Message){
    System.out.println("수신받은 메시지는 $message");
}

 

https://cheese10yun.github.io/spring-rabbitmq/

 

Rabbit MQ 기초 사용법 - Yun Blog | 기술 블로그

Rabbit MQ 기초 사용법 - Yun Blog | 기술 블로그

cheese10yun.github.io

https://syaku.tistory.com/?page=7 

 

개발자 샤쿠 @syaku

Full Stack Web Developer.

syaku.tistory.com

 

 

3. Spring에서 메시지 Publish하기

// (1)
val rabbitTemplate: RabbitTemplate            

// (2)
val dto = mqDto(uid, req.amount)
rabbitTemplate.convertAndSend("ex1", "p2", objectMapper.writeValueAsString(dto)); // (exchangeName, routingKey, value)

(1) 생성자 주입

(2) 보낼 메시지를 dto로 포장해서 objectMapper.writeValueAsString 로 직렬화해서 전송한다.

 

 

 

 


5. 그 외 읽어보면 좋을 RabbitMQ 관련 이모저모

1. RabbitMQ에서 최적을 성능을 뽑는 팁?

https://kamang-it.tistory.com/627

 

[AMQP][RabbitMQ]RabbitMQ그리고 Work Queue의 사용법 - (3)

참고 [AMQP][RabbitMQ]RabbitMQ를 사용하는 이유와 설치방법 - (1) [AMQP][RabbitMQ]RabbitMQ아주 기초적이게 사용하기 - Java(feat.Hello World!) - (2) https://www.rabbitmq.com/tutorials/tutorial-two-java...

kamang-it.tistory.com

 

 

2. Spring boot Exchange와 Queue 자동 생성 이해

스프링 부트가 구동될때 exchange와 queue 설정에 따라 자동으로 생성되지 않는 다.

자동 생성 시점은 메시지가 발행이 될때와 메시지를 구독하기 위해 RabbitMQ 서버에 연결될때 생성된다.

아래 설정은 자동 생성 여부를 설정하기 위한 옵션이고 기본 값은 true 이다.

spring.rabbitmq.dynamic=true

https://syaku.tistory.com/?page=7 

 

개발자 샤쿠 @syaku

Full Stack Web Developer.

syaku.tistory.com

 

 

3. kafka vs RabbitMQ

앞선 내용들을 통해 각각의 정의 및 프로세스에 대해 면밀히(?) 살펴봤다.
위 내용을 통해서도 kafkaRabbitMQ의 차이에 대해 어느정도 이해할 수 있겠지만 본 글의 주제가 주제인만큼 다시 한번 간단히 정리해보도록 하겠다.

  1. kafka는 pub/sub 방식 / RabbitMQ는 메시지 브로커 방식
    kafka의 pub/sub방식은 생산자 중심적인 설계로 구성. 생성자가 원하는 각 메시지를 게시할 수 있도록 하는 메시지 배포 패턴으로 진행
    RabbitMQ의메시지브로커방식은 브로커 중심적인 설계로 구성. 지정된 수신인에게 메시지를 확인, 라우팅, 저장 및 배달하는 역할을 수행하며 보장되는 메시지 전달에 초점
  2. 전달된 메시지에 대한 휘발성
    RabbitMQ는 queue에 저장되어 있던 메시지에 대해 Event Consumer가져가게 되면 queue에서 해당 메시지를 삭제한다.
    하지만, kafka는 생성자로부터 메시지가 들어오면 해당 메시지를 topic으로 분류하고 이를 event streamer에 저장한다. 그 후, 수신인이 특정 topic에 대한 메시지를 가져가더라도 event streamer는 해당 topic을 계속 유지하기 때문에 특정 상황이 발생하더라도 재생이 가능하다.
  3. 용도의 차이
    kafka는 클러스터를 통해 병렬처리가 주요 차별점인 만큼 방대한 양의 데이터를 처리할 때, 장점이 부각된다.
    RabbitMQ는 데이터 처리보단 Manage UI를 제공하는 만큼 관리적인 측면이나, 다양한 기능 구현을 위한 서비스를 구축할 때, 장점이 부각된다.

 

 

https://velog.io/@cho876/%EC%B9%B4%ED%94%84%EC%B9%B4kafka-vs-RabbitMQ

 

카프카(kafka) vs RabbitMQ

오늘은 kafka와 RabbitMQ의 차이에 대해 알아보도록 하겠다. 1. 이해 1-1. 메시지 큐(MessageQuque : MQ) kafka와 rabbitMQ를 이해하기 위해선 우선 메시지 큐에 대한 이해가 선제적으로 필요하다. >메시지 큐(Mes

velog.io

  • 둘다 쩌는 솔루션이다.  -  RabbitMQ 가 좀더 성숙하다. (Written 12 Sep, 2012)
  • 철학은 좀 다른데, 기본적으로 RabbitMQ 는 브로커 중심적이며, 생산자와 소비자간의 보장되는 메세지 전달에 촛점을 맞추었다.  
  • 반면 Kafka 는 생산자 중심적이며, 엄청난 이벤트 데이터을 파티셔닝하는데 기반을 둔다. 배치 소비자를 지원하며, 온라인, 오프라인에 저 지연율(Low latency)을 보장하며 메세지를 전달해준다. 
  • RabbitMQ 는 브로커상에서 전달 상태를 확인하기위한 메세지 표식을 사용한다. 카프카는 그런 메세지 표식이 없으며 컨슈머가 전달(배달) 상태를 기억하는것을 기대한다.
  • 둘다 클러스터간의 상태를 관리하기위해 Zookeeper 를 사용한다. 
  •  RabbitMQ 는 커다란 크기의 데이터를 위해 디자인되지 않았으며 만약 컨슈머가 매우 느리다면 실패할것이다.그러나 post 2.0 에  RabbitMQ  는 느린 배치 컨슈머를 핸들링 되는게 요청되어졌다. 
  • Kafka 은 오직 토픽같은 exchanges 를 사용한다. RabbitMQ 는 다양한 exchanges 를 사용한다.
  • Kafka 는 파티션들 안에서 메세지 순서를 제공하며, 파티션들간에 엄격한 순서를 가진다. 카프카 컨슈머들은 충분히 스마트해야하며 , 그들 스스로 파티션간의 순서를 해결(resolve) 해야한다.
  • Kafka 는 디스크상에서 메세지를 저장하고 데이타 손실을 막기위해 클러스터로 그들을 복제한다. 각각의 성능에 큰 문제없이 브로커는 테라바이트를 핸들링할수있다. Kafka 는 쓰기에 초당  200k 메세지를 , 읽는데는 3M 메세지를  제공되도록 테스트되었다.

https://hamait.tistory.com/403?category=138704 

 

카프카(Kafka) vs RabbitMQ

생산자 (Sender) 테스트 결과 이유 카프카 생산자는 브로커로 부터의 ack 를  기다리지 않고 메세지를 보낸다.브로커가 핸들링 할수있는 만큼 빠르게 메세지를 마구 보낸다. 카프카는 좀더 효율

hamait.tistory.com

 

 

 

 

 

참고:

더보기

https://sg-choi.tistory.com/406

 

[RabbitMQ] RabbitMQ란?

RabbitMQ란? RabbitMQ는 AMQP를 따르는 오픈소스 메세지 브로커 AMQP는 클라이언트가 메시지 미들웨어 브로커와 통신할 수 있게 해주는 메세징 프로토콜 주요 개념으로 Producer, Exchange, Binding, Queue, Consu..

sg-choi.tistory.com

https://jin2rang.tistory.com/entry/RabbitMQ%EB%9E%80

 

RabbitMQ란?

RabbitMQ란? 서버간 메세지를 전달해주는 오픈소스 메세지 브로커이다. A → B에게 또는 A → B,C,D,E,F ... 등 메세지를 보내려고 할 때 RabbitMQ가 이 메세지를 받아서 전달 해주는 것으로 이해하면 된다

jin2rang.tistory.com

https://velog.io/@cckn/%EB%B2%88%EC%97%AD-RabbitMQ-%EB%B0%8F-Node.js%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%A7%88%EC%9D%B4%ED%81%AC%EB%A1%9C-%EC%84%9C%EB%B9%84%EC%8A%A4

 

(번역) RabbitMQ 및 Node.js를 사용한 비동기 마이크로 서비스

메세지와 이벤트를 통한 확장성과 내결함성.이 글은 Asynchronous Microservices with RabbitMQ and Node.js를 번역한 글입니다

velog.io

https://daily-coding-diary.tistory.com/13

 

[Node.js] RabbitMQ

목표 : RabbitMQ를 이용하여, 데이터를 임시로 저장하고 확인해봅시다. RabbitMQ는 디비 또는 데이터 처리에 문제가 생겼을때 데이터를 임시로 처리해줄때 많이 사용합니다. 예를 들면, 채팅방에 한

daily-coding-diary.tistory.com

https://www.joinc.co.kr/w/man/12/%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90/Message#sid_1

 

스타벅스로 살펴보는 Message 아키텍처

소프트웨어 시나리오

www.joinc.co.kr

 

아침에 노트북을 켜서 보니 갑자기 안뜨던 에러가 떴다.

 

npm install --g eslint

 

를 해주고 인텔리제이를 재실행했더니 해결됐다.

 

(권한문제가 발생하면 sudo를 사용하자)

nodejs를 통해 애플리케이션을 개발하며 테스트를 위해 Jest 프레임워크를 사용했다.

그런데 테스트 코드 작성이 처음이기도 해서 그런지 "mock"이라는 개념에서 많이 헤맸다.

첫 테스트 코드 작성이기에 다른 블로그의 글 보다 질 좋은 정보는 없겠지만 내가 어떻게 mock을 활용하여 단위/통합 테스트를 진행했는지 나중의 나를 위해 기록해놓는다.


1. Jest란

페이스북에서 개발한 JS를 위한 테스트 프레임워크이다.

npm install --save-dev jest

이렇게 설치해 주고 사용하자. --save-dev의 의미를 모르겠다면 마침 엊그제 쓴 좋은 글이있다. ㅎㅎ

https://llshl.tistory.com/40?category=961696 

 

[NodeJS] npm install option

노드자스를 통해 개발하면서 무지성 install들을 해온것 같다. 그래서 정리해보는 시간. 1. npm options 기본적으로 npm install은 ./node_modules 폴더에 패키지를 다운받는 명령어이다. 나는 주로 npm 쓸때

llshl.tistory.com

 

 

설치를 마치고 package.json의 script에 

"scripts": {
	...
    "test": "jest",
    ...
  },

이렇게 한 줄 넣어주자. 이제 우린 npm test라는 명령을 통해 테스트를 할 수 있게 됐다.

 

 

 

 

2. mock이란

기본적인 테스트 방법은 다른 블로그들이 아주 자세히 설명하고 있으니 여기서는 굳이 기록하지 않을려 한다.

 

"목", "목하다", "모킹하다"라고 표현하는 mock이란 간단히 말해서 "가짜로 대체하기" 기능이라고 말할 수 있다.

왜 가짜로 대체하는가?

 

  • 테스트 하고싶은 기능이 다른 기능들과 엮여있을 경우(의존) 정확한 테스트를 하기 힘들기 때문

 

예를들어 request body에 사용자의 id와 password를 넣어서 post요청을 보내면 컨트롤러에서 정보를 추출한 후 데이터베이스에 넣어주는 단위테스트를 하고 싶다고 하자.

데이터베이스에 저장 요청을 보내면 성공이든 실패든 응답이 반환될 것이고 반환된 응답을 기준으로 테스트의 성공과 실패를 구분한다.

 

 

 

 

이때 실제 데이터베이스에 사용자의 id, password를 넣는 방식으로 테스트를 하는 것은 좋은 방법이 아니다.

실제 트랜잭션이 일어나기에 IO 시간도 테스트에 포함되고, 데이터베이스 연결 상태에 따라 테스트가 실패할 수도 있기 때문이다.

테스트가 실패했을 경우 내가 작성한 컨트롤러 코드의 문제인지, 데이터베이스의 문제인지 알아차리기도 힘들기 때문에 올바른 단위테스트라고 할 수 없다.

 

따라서 실제 데이터베이스에 데이터를 넣는 것이 아니라 넣은 셈 치자는 개념이다.

데이터베이스가 잘 작동하는지는 데이터베이스 관련 테스트에서 확인하면 되고 우리는 지금 controller에 대한 테스트를 진행하고 있으니 데이터베이스가 잘 작동한다는 전제를 깔고 가자는 뜻이다. 

 

데이터베이스 mocking을 표현하면 다음 그림과 같다.

 

 

 

기존의 데이터베이스 저장 메소드를 mock 함수로 만든다. mock 함수가 되면 그 함수는 아무런 기능이 없어진다. mock 함수를 호출하면 

undefined를 반환한다. 그냥 껍데기만 남기고 속을 싹 비워낸 그런 느낌

 

이제 이 mock함수를 호출했을때 반환 받기 원하는 값을 우리가 직접 지정해 준다.

본래 데이터 저장 함수이기에 데이터 저장 성공 후 응답 값이 다음과 같다고 가정해보자.

{
	"result": "success",
	"id": "testUser@test.com"
}

이런 결과와 저장된 id를 json으로 응답 받았었다면 mock함수가 반환하길 원하는 값으로 저 json을 지정해주자.

우리는 controller의 로직에 집중해야하니 데이터베이스는 "대충 이런이런 값을 반환한다고 치자"라고 하고 넘어가는 개념이다.

 

 

 

 

3. 작성해본 테스트 코드

모킹을 하는 방법은 Jest에서 제공하는 fn(), spyOn(), mock() 함수를 사용하면 된다. 이 중에서 난 fn()을 쓰려고 했지만 원인 모를 에러로 인해 spyOn 함수를 사용했다! 

이 3가지 함수에 대한 설명은 이 블로그에서 친절하게 설명을 해두었다.

 

 

UserController에 있는 getPassword함수를 테스트해보자.

getPassword 함수는 다음과 같다.

export const getPassword = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const id: string = req.query.id ? req.query.id : req['claims']['email'];
    return res.status(200).json(await account.findPasswordById(id));
  } catch (e) {
    Logger.error('%o', e);
    return next(e);
  }
};

getPassword함수는 사용자 Id를 받아서 db에서 password를 조회하는 함수다.

이 함수의 기능은 두가지다.

  1. req로부터 사용자 id를 추출하고,
  2. findPasswordById함수에 사용자 id를 넘겨준다.

http 요청을 만들기 위해 httpMocks라는 모듈을 사용하였다.

 

 

 

작성한 테스트코드는 다음과 같다.

import * as model from '../models/student.model';

test('[DB 데이터 조회 테스트] 사용자 id를 통해 password 조회 테스트', async () => {
  const req = httpMocks.createRequest({
    method: 'GET',
    url: '/api/test?id=user@test.com',
  });
  const res = httpMocks.createResponse();
  const next = null;
  const expectedResult = { password: 'mypass123' };
  const mockFindPasswordById = jest.spyOn(model, 'findPasswordById');
  mockFindPasswordById.mockResolvedValue(expectedResult);

  await UserController.getPassword(req, res, next);

  expect(res.statusCode).toBe(200);
  expect(res._getJSONData()).toStrictEqual(expectedResult);
});

import한 model은 데이터베이스에 접근하는 함수들을 모아놓은 모듈이다.

 

 

 

11번째 줄의 spyOn함수를 통해서 mock 함수를 생성한다.

  const mockFindPasswordById = jest.spyOn(model, 'findPasswordById');

 

import된 model 모듈에 존재하는,

findPasswordById 함수를 spyOn을 통해서,

mockFindPasswordById 라는,

mock함수로 만들었다.

 

 

findPasswordById는 원래 데이터베이스에 접근하는 함수였지만 이제 아무런 기능이 없는 mock 함수가 되었다.

호출하면 undefined만 반환한다.

 

 

우리는 이 함수가 데이터베이스에서 password를 조회해 반환해주기를 원한다. 그렇기에 우리가 원하는 반환 값을 직접 달아준다.

  mockFindPasswordById.mockResolvedValue(expectedResult);

이제 mockFindPasswordById는 쿡 찌르면 우리가 지정해준 응답값을 반환해준다.

이는 "데이터베이스가 정상적으로 동작했다고 치자"라고 설정한것이다.

 

 

데이터베이스 관련 함수는 async/await으로 처리되기에 Promise를 resolve하는 mockResolvedValue를 사용했다. 

다른 여러 종류의 반환값에 대한 반환값 지정 함수는 다음 문서에서 확인하면 된다.

https://jestjs.io/docs/mock-function-api

 

Mock Functions · Jest

Mock functions are also known as "spies", because they let you spy on the behavior of a function that is called indirectly by some other code, rather than only testing the output. You can create a mock function with jest.fn(). If no implementation is given

jestjs.io

 

 

 

이제 우리는 UserController.getPassword(req, res, next)로 우리가 만들었던 req를 보내준다.

UserController.getPassword(req, res, next)에는 findPasswordById함수가 있는데 우리는 지금 이걸 mock으로 만들어 주었으니 무조건 우리가 지정한 응답을 줄 것이다.

  await UserController.getPassword(req, res, next);

  expect(res.statusCode).toBe(200);
  expect(res._getJSONData()).toStrictEqual(expectedResult);

UserController.getPassword에 잘못된 점이 없다면 테스트는 성공한다.

 

 

 

 

4. 결론

우리는 이제 무엇에 테스트를 성공한것인가?

 

 

UserController의 getPassword함수에서 

  1. req로부터 사용자 id를 추출하고,
  2. findPasswordById함수에 사용자 id를 넘겨준다.

이 두가지 로직에 대한 검증이 성공한 것이다.

(매우매우 간단한 테스트다)

 

 

이는 작은 단위의 단위테스트이고 supertest를 활용한 통합테스트도 작성하였다. 오히려 통합테스트가 더 직관적이라 테스트코드를 처음 작성해보는 나에겐 더 쉬웠다.

라고 말하지만 사실 통합테스트에서는 db의 endpoint를 test일 경우에만 local db로 동적으로 바꿔주는 작업 때문에 많이 고생했다.ㅠㅠ

다음 포스팅에서 하소연 해보겠다.

 

 

 

 

 

참고:

tsc --init

을 통해 생성하는 tsconfig.json파일.

타입스크립트를 시작한지 얼마되지 않았기에 대충 설정파일이구나 하고 넘겼다. 

여기 있는 설정들을 살펴보며 타입스크립트의 전체적인 그림을 살펴보자.


1. 멋모르고 사용해 온 설정

처음 init을 통해 파일을 생성하면 뭔가 엄청 많다.

이거처럼

더보기
{
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig.json to read more about this file */

    /* Projects */
    // "incremental": true,                              /* Enable incremental compilation */
    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
    // "tsBuildInfoFile": "./",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */
    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */
    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */

    /* Language and Environment */
    "target": "es5",                                     /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
    // "reactNamespace": "",                             /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */

    /* Modules */
    "module": "commonjs",                                /* Specify what module code is generated. */
    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
    // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
    // "typeRoots": [],                                  /* Specify multiple folders that act like `./node_modules/@types`. */
    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
    // "resolveJsonModule": true,                        /* Enable importing .json files */
    // "noResolve": true,                                /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */

    /* JavaScript Support */
    // "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */

    /* Emit */
    // "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
    "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
    "outDir": "dist",                                   /* Specify an output folder for all emitted files. */
    // "removeComments": true,                           /* Disable emitting comments. */
    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types */
    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
    // "stripInternal": true,                            /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like `__extends` in compiled output. */
    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
    // "preserveConstEnums": true,                       /* Disable erasing `const enum` declarations in generated code. */
    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */

    /* Interop Constraints */
    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */

    /* Type Checking */
    "strict": true,                                      /* Enable all strict type-checking options. */
    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied `any` type.. */
    // "strictNullChecks": true,                         /* When type checking, take into account `null` and `undefined`. */
    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
    // "strictBindCallApply": true,                      /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
    // "noImplicitThis": true,                           /* Enable error reporting when `this` is given the type `any`. */
    // "useUnknownInCatchVariables": true,               /* Type catch clause variables as 'unknown' instead of 'any'. */
    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
    // "noUnusedLocals": true,                           /* Enable error reporting when a local variables aren't read. */
    // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read */
    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
    // "noUncheckedIndexedAccess": true,                 /* Include 'undefined' in index signature results */
    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type */
    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */

    /* Completeness */
    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
  },
  "include": [
    "src/**/*"
  ],
  "exclude":[
    "node_modules"
  ]
}

 

이 많은 옵션들 중 내가 사용하고 있는 옵션들부터 살펴보겠다.

 

 

 

compilerOptions과 include/exclude

tsconfig.json 파일을 살펴보면 최상위 경로는 compilerOptions, include, exclude 프로퍼티로 구성돼있다.

 

  • compilerOptions은 말 그대로 어떤 컴파일 설정을 사용할지에 대한 속성이다.
  • include는 프로그램에 포함하고 싶은 파일들의 목록을 지정한다. 보통
{
    "include": ["src/**/*", "tests/**/*"]
}

이와 같은 형태로 표기하여 한번에 지정한다.

  • exclude는 include에 속한 파일들 중에서 제외시킬 파일들을 지정한다. 이때 프로그램에서 포함되지 않도록 제외시키는 메커니즘이 아니라 단순이 include 프로퍼티에서만 제외시킨다는 것에 주의하자.

 

 

"target"

여기서부터는 compilerOptions 프로퍼티 내부의 옵션들이다.

target은 어떤 버전의 자바스크립트로 컴파일할지 지정한다.

ts파일들을 tsc로 컴파일하여 js로 만드는 과정이 필요한 타입스크립트이기에 실제 런타임은 대부분 js다. 이 js의 버전을 지정한다.

 

 

"module"

프로그램에서 사용할 모듈 시스템을 결정한다. 즉, 모듈 내보내기/불러오기 코드가 어떠한 방식의 코드로 컴파일 될지를 결정한다.

 

 

"lib"

타입스크립트 파일을 자바스크립트로 컴파일 할 때 포함될 라이브러리의 목록이다. 이렇게만 하면 와닿지 않는데, async코드를 컴파일 할 때 Promise객체가 필요하므로 "es2017"과 같은 항목을 넣어준다고 한다.

 

 

"baseUrl"

비상대적 import의 모듈 해석시 기준이 되는 경로를 지정한다. 예시를 들자면, 프로젝트의 루트 디렉토리에 존재하는 src 디렉토리를 기준으로 각종 모듈들을 불러오고 싶다면 이 프로퍼티를 './src'로 지정한다.

 

 

"typeRoots'

기본적으로 @types 패키지들은 컴파일 목록에 포함된다. 하지만 만약 typeRoots 프로퍼티가 특정 경로들로 지정돼 있다면 오직 그 경로에 존재하는 패키지들만 컴파일 목록에 포함된다. 

 

 

"paths"

baseUrl 기준으로 상대 위치로 가져오기를 다시 매핑하는 항목 설정

 

 

"emitDecoratorMetadata"

"experimentalDecorators"

데코레이터 설정이다. 데코레이터란, 자바의 어노테이션과 비슷한 느낌의 기능으로, 데코레이터가 붙은 클래스, 메소드 등에 데코레이터에서 지정한 기능이 동작하도록 하는 기능이다.

 

 

"allowSyntheticDefaultImports"

불러오려는 모듈에 default export가 없어도 import * as XXX가 아닌 import XXX로 사용할 수 있게 해주는 설정이다.

 

 

"forceConsistentCasingInFileNames"

사용할 파일의 이름을 대소문자까지 정확하게 작성하도록 강제하는 설정이다.

 

 

"moduleResolution"

모듈 해석 전략을 결정한다. Nodejs 방식대로 모듈 해석을 하려면 "Node"를, 1.6버전 이전의 타입스크립트에서 사용하던 방식대로 모듈을 해석하려면 "Classic"을 입력한다.

 

 

"pretty"

에러와 메시지를 색과 컨텍스트를 사용해서 스타일을 지정하는 옵션

 

 

"sourceMap"

빌드시 map파일을 생성할지 설정한다. 생성된 소스맵 파일은 크롬 개발자 도구로 디버깅에 사용된다.

 

 

"allowJs"

js파일을 허용하는 옵션이다.타입스크립트는 .js확장자를 허용하지 않는다. 이에대한 예외를 허락하는 옵션.

 

 

"esModuleInterop"

"모든 가져오기에 대한 네임스페이스 객체 생성을 통해 CommonJS와 ES 모듈 간의 상호 운용성을 제공"이라고 돼있는데 이게 무슨소리인가?

피부에 와닿진 않지만 Commonjs방식으로 내보낸 모듈을 es모듈 방식의 import로 가져올 수 있게 해주는 기능 정도로 일단 이해하자.

 

 

"outDir"

컴파일 후 생성되는 js파일이 생성될 폴더를 지정한다

 

 

 

 

 

참고:

더보기

 

노드자스를 통해 개발하면서 무지성 install들을 해온것 같다. 그래서 정리해보는 시간.


1. npm options

기본적으로 npm install은 ./node_modules 폴더에 패키지를 다운받는 명령어이다.

나는 주로 npm 쓸때 접미사로 -D, -S, -g 옵션을 썼다. 왜냐? 블로그에서 이렇게 하라고 해서..

무슨 차이인지 모르고 써왔고 이거 때문에 문제 생긴적은 없었지만 찾아보니 나름의 의미가 있더라.

 

package.json을 보면 dependencies와 devDependencies가 있는데 얘네 중 어느쪽에 속하게 하도록 구분짓는 용도였다.

  • dependencies: 프로덕션 환경에서 응용 프로그램에 필요한 패키지.
  • devDependencies: 로컬 개발 및 테스트에만 필요한 패키지.

어떤 라이브러리가 빌드타임에 필요하면 devDependencies에 넣고, 런타임에도 필요하면 dependencies에도 넣어준다.

즉, 배포용 패키지와 개발용 패키지의 차이

 

--save는 package.json에 의존성 항목에 추가하도록 해주는 옵션인데 npm5부터는 굳이 --save 쓰지 않아도 된다고 한다.

 

 

 

-P/--save-dev (default)

암튼 그래서 

npm install [패키지명] -P
npm install [패키지명] --save-prod

이렇게 옵션을 주면 패키지를 설치하고 dependencies 목록에 추가한다.

사실 디폴트 옵션이라 아무 옵션도 안써주는 경우가 이 경우였다.

npm install [패키지명]

 

-D/--save-dev

그렇다면 얘는 devDependencies에 속하도록 하는 옵션이다.

npm install [패키지명] -D
npm install [패키지명] --save-dev

 

 

-g/--global

전역모드로 설치하면 시스템 폴더에 패키지를 설치하게 된다. -g를 통해서 설치하면 package.json의 의존성 목록에 기록되지 않는다.

-P나 -D는 지역설치옵션으로 루트 디렉토리의 node_modules에 설치된다. 이렇게 설치된 패키지는 해당 프로젝트 내에서만 사용 가능하다.

-g를 통한 전역설치를 하면 모든 프로젝트에서 사용 가능하다.

npm install [패키지명] -g
npm install [패키지명] -global

 

 

 

이 내용들을 어떤 분이 친절하게 정리해 두셨다.

npm install
// package.json의 dependencies에 있는 모든 패키지를 설치한다.
// 처음 프로젝트를 세팅했다면 이 명령어로 패키지를 설치하고 개발을 시작하면 된다.

npm i
// npm install 의 줄인 명령어. 

npm install [package]
// 현재 작업중인 디렉토리 내에 있는 ./node_modules에 [package]를 설치한다. 
// (예: npm install moment) -> ./node_modules에 moment 패키지를 설치 함

npm install [package] --save
// [package]를 설치 하면서 package.json파일에 있는 dependencies 객체에 지금 설치한 패키지 정보를 추가한다.

npm install [package] --save -dev
// --save옵션과 같이 package.json파일에 의존성 내용을 추가하지만
// dependencies가 아닌 devDepenencies 객체에 추가한다.
–save와 –save-dev의 차이는 의존성을 기본으로 추가할지, 개발용으로 추가할지의 차이이다.
--production로 빌드할 경우 devDepenencies에 있는 패키지들은 설치되지 않는다

npm install [package] --no-save
// dependencies에 패키지 정보를 추가하지 않는다.

npm install [package] --save-exact
// 정확히 일치하는 버전의 패키지를 추가한다.

npm install [package] --save-bundle
// 해당 패키지를 bundleDependencies에 추가한다.

npm install [package] --force
// 해당 패키지가 존재하더라도 원격 저장소에 있는 패키지를 가져온다.

 

 

 

 

 

참고:

 

거의 한달만에 글을 쓴다. 그간 참 많은 일이 있었다. 

취업을 했고 킥보드를 타다가 응급실도 갔다. 너무 정신없는 시간이었다고 변명을 해본다ㅠ

요즘은 TypeScript+Express를 통한 개발을 하고있는데 Express 사용시 헤더의 설정을 통하여 웹 취약점으로부터 서버를 보호해주는 보안 모듈인 Helmet이라는 것을 알게 됐고 간단하게 정리해보겠다.

(헤더에 씌운다고해서 이름이 헬멧인 것이 너무 귀엽다. 내 머리에 헬멧을 썻어야했는데 익스프레스에만 씌우고있다 아ㅋㅋ)

 


1. 사용법

그냥 한 줄만 추가해 주면 된다.

npm install helmet

헬멧을 설치하고,

 

 

const helmet = require('helmet');
const express = require('express');
const app = express();

app.use(helmet());

이렇게 붙혀주기만 하면 된다.

 

 

 

 


2. 무엇을 보호해주는가?

세부적인 미들웨어 함수들을 포함하고있는 헬멧은 다음과 같은 기능이 있다.

 

1. csp

csp는 Content-Security-Policy이다. 브라우저에서 사용하는 컨텐츠 기반의 보안 정책으로 XSS나 Data Injection, Click Jacking 등 웹 페이지에 악성 스크립트를 삽입하는 공격기법들을 막기 위해 사용된다.

 

2. hidePoweredBy

헤더에서 X-Powered-By를 제거한다. 이는 서버에 대한 정보를 제공해주는 역할로 나 같은 경우는 이 영역에 Express라고 표기됨을 확인할 수 있었다. 이 정보는 악의적으로 활용될 가능성이 높기에 헬멧을 통해서 제거해 주는 것이 좋다.

 

3. HSTS

HTTP Strict Transport Security의 약자로 웹 사이트에 접속할 때 강제적으로 HTTPS로 접속하게 강제하는 기능이다. 

사용자가 특정 사이트에 접속할 때 해당 사이트가 HTTPS를 지원하는지, 하지 않는지를 미리 모르는 경우가 대부분이다. 그렇기에 브라우저는 디폴트로 HTTP로 먼저 접속을 시도한다. 이때 HTTPS로 지원되는 사이트였다면 301Redirect나 302 Redirect를 응답하여 HTTPS로 다시 접속하도록 한다.

 

하지만 이때 해커가 중간자 공격을 하여, 중간에 프록시 서버를 두고

[나] <-> [해커] 사이에서는 HTTP 통신을 하고 [해커] <-> [웹사이트] 사이에선 HTTPS 통신을 한다면,

우리의 개인정보가 HTTP 프로토콜을 통해 해커에게로 전해지는 참사가 일어난다.

이러한 공격을 SSL Stripping이라고 하며 이런 공격을 방지하기 위해 HSTS를 설정한다.

 

4. IeNoOpen

IE8 이상에 대해 X-Download-Options를 설정한다. 이 옵션은 8 버전 이상의 인터넷 익스플로러에서 다운로드된 것들을 바로 여는 것 대신 저장부터 하는 옵션이다. 사용자는 저장부터 하고 다른 응용프로그램에서 열어야 한다.

 

5. noCache

클라이언트 측에서 캐싱을 사용하지 않도록 하는 설정이다. 

 

6. noSniff

X-Content-Type-Options 를 설정하여 선언된 콘텐츠 유형으로부터 벗어난 응답에 대한 브라우저의 MIME 스니핑을 방지한다. MIME이란 Multipurpose Internet Mail Extensions의 약자로 클라이언트에게 전송된 문서의 다양성을 알려주기 위한 포맷이다. 브라우저는 리소스를 내려받을 때 MIME 타입을 보고 동작하기에 정확한 설정이 중요하다.

 

MIME 스니핑이란 브라우저가 특정 파일을 읽을 때 파일의 실제 내용과 Content-Type에 설정된 내용이 다르면 파일로 부터 형식을 추측하여 실행하는 것인데, 편리함을 위한 기능이지만 공격자에게 악용 될 가능성이 있다.

 

7. frameguard

 X-Frame-Options 헤더를 설정하여 클릭재킹에 대한 보호를 제공한다.

클릭재킹이란 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 것을 클릭하도록 하여 속이는 해킹 기법이다. 속이기 위해 보이지 않는 레이어에 보이지 않는 버튼을 만드는 방법이 있다.

 

8. xssFilter

xss필터는 xss필터.

 

 

 

 

 

 

 

 

 

 

 

참고:

더보기


1. JWT란?

Json Web Token의 줄임말이다. 

두 개체에서 JSON객체를 사용하여 가볍고 자가수용적인 방식으로 정보를 안정성 있게 전달해 주는 인증 방식이다.

 

 


2. 세션과의 차이점

세션

보통 로그인을 구현할때 세션로그인을 많이 사용했다. 세션로그인이란, 

이런 흐름을 가지고 진행된다.

  1. 클라이언트에서 서버로 로그인 요청을 보낸다.
  2. 서버는 로그인 정보 확인 후 세션아이디를 응답한다. 이 세션아이디는 서버에서도 가지고있는다.
  3. 이후 클라이언트의 요청에는 2번에서 응답받은 세션아이디를 쿠키에 담아서 함께 요청한다.
  4. 서버는 함께 요청된 쿠키 속의 세션아이디를 확인하여 로그인된 사용자인지 확인한다.

 

이러한 세션 로그인 방식은 단점이 있다.

 

단점.

  1. 만약 여러대의 서버가 운영된다면, 서버측에서 저장하고있는 모든 세션을 공유해야한다.
  2. 사용자가 많아질수록 서버측에서 모든 사용자의 세션들을 저장하기 부담스럽다.

 

 

그러면 JWT는 이 문제를 어떻게 해결할까?

JWT

JWT방식은 다음과 같은 흐름을 가진다.

  1. 클라이언트에서 서버로 로그인 요청을 보낸다.
  2. 서버는 로그인 정보 확인 후 토큰을 응답한다. 이 토큰은 서버에서 보관하지 않는다.
  3. 이후 클라이언트의 2번에서 응답받은 토큰을 요청에 함께 보낸다.
  4. 서버는 토큰이 유효한지 검증하고 유효하면 로그인된 사용자라고 생각한다.

 

세션방식과 다른 가장 큰 포인트는, 로그인 후 무언가를 응답으로 보내주긴하는데 JWT는 서버측에서 그걸 기억하지 않는다는 것이다. 

세션방식의 문제점 중 하나가 기억해야할 세션 아이디가 너무 많다는 것이었는데 이 문제점을 해결하는 부분이다.

서버는 자신이 발행한 토큰을 기억하지 않고, 나중에 토큰을 받으면 그 토큰이 유효한지 검증만 하면 되는 것이다.

또한 여러 디바이스나 도메인에서도 토큰에 대한 인증만 하면 되니 여러 서버가 운영될 때에도 문제 없다.

 

 

JWT가 마냥 좋은것만은 아니다.

세션은 시간에 따라 바뀌는 값을 갖는 stateful한 값이므로 어떠한 장점이 있느냐, 세션 값을 가지고있는 대상들을 제어할 수 있다. 예를 들어서 한 기기에서만 로그인이 가능하도록 구현하려 한다고 가정해보자.

1번 기기에 로그인이 돼있는데 2번 기기에서 로그인을 하면, 1번 기기의 세션을 종료하면 된다.

하지만 JWT는 사용자의 상태를 모르기 때문에 (stateless) 이것이 불가능하다. 이미 줘버린 토큰을 다시 회수할 수도 없고, 그 토큰의 발급 내용이나 정보를 서버가 추적하고 있지도 않기 때문이다. 반면 세션은 서버측의 세션저장소에 있기에 가능하다.

 

 

여담으로 지금 말한 단일 기기 로그인을 JWT로 해결하기 위한 기법도 존재한다. 

  • 최초 토큰 발행시 refresh 토큰과 access 토큰, 총 2개의 토큰을 발행한다.
  • refresh토큰은 만료기한(수명)이 꽤 길고 access토큰은 매우 짧다. 
  • refresh토큰의 상응값을 데이터베이스에도 저장한다.
  • 사용자가 요청할때 access토큰을 사용하는데 access토큰의 수명이 끝나면 refresh토큰을 사용해서 요청을 보낸다.
  • 서버는 서버측의 refresh 토큰 상응값과 비교해보고 맞다면 새로운 access토큰을 발행해 준다.
  • 즉, refresh토큰만 안전하게 관리된다면 중간에 access토큰이 탈취당해도 어자피 수명이 짧기 때문에 보안상 위협을 줄일 수 있고, 로그인 유지도 가능하다. 

 

 


3. JWT의 구성

JWT토큰은 3개의 부분으로 구성된다.

실제로는

 

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

 

이렇게 생겼다. 각 부분을 디코딩해보면 JSON형태로 나온다.

 

 

헤더 (header)

헤더는 두가지 정보를 갖는다.

typ: 토큰의 타입을 지정. 여기는 JWT가 고정으로 들어간다. 여기가 JWT여야지만 JWT기 때문에

alg: 어떤 해싱 알고리즘을 사용할지 지정한다. 해싱 알고리즘으로는 보통 HMAC SHA256 혹은 RSA 가 사용되며, 이 알고리즘은, 토큰을 검증 할 때 사용되는 signature 부분에서 사용된다.

 

{ 
  "typ": "JWT", 
  "alg": "HS256" 
}

 

 

내용 (payload)

이 부분에는 토큰에 담을 정보가 들어있다. 이 정보의 한 조각을 "클레임(Claim)"이라고 부르고 key : value의 한 쌍으로 이루어져 있다. 클레임의 종류는 3가지로 분류된다.

 

등록된(registered) 클레임

등록된 클레임은 서비스에 필요한 정보가 아니라 토큰에 대한 정보를 담기위해 이름이 이미 정해진 클레임들이다. 등록된 클레임은 Optional하다.

  • iss : 토큰 발급자 (issuer)
  • sub : 토큰 제목 (subject)
  • aud : 토큰 대상자 (audience)
  • exp : 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370) 언제나 현재 시간보다 이후로 설정돼야한다.
  • nbf : Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념. 여기에도 NumericDate 형식으로 날짜를 지정하며, 이 날짜가 지나기 전까지는 토큰이 처리되지 않는다.
  • iat : 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있다.
  • jti : JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용된다. 일회용 토큰에 사용하면 유용.

 

공개(public) 클레임

공개 클레임들은 충돌이 방지된 (collision-resistant) 이름을 가지고 있어야 한다. 충돌을 방지하기 위해서는, 클레임 이름을 URI 형식으로 짓는다.

 

{
    "https://velopert.com/jwt_claims/is_admin": true
}

 

비공개(private) 클레임

등록된 클레임도아니고, 공개된 클레임들도 아니다. 양 측간에 (보통 클라이언트 <->서버) 협의하에 사용되는 클레임 이름들이다. 공개 클레임과는 달리 이름이 중복되어 충돌이 될 수 있으니 사용할때에 유의.

 

{
    "username": "velopert"
}

 

Payload의 예시

{
    "iss": "llshl.com",
    "exp": "1485270000000",
    "https://llshl.com/jwt_claims/is_admin": true,
    "userId": "11028373727102",
    "username": "llshl"
}

 

 

서명 (signature)

서명은 헤더의 인코딩값과, 정보의 인코딩값을 합친후 주어진 비밀키로 해쉬를 하여 생성한다.

서버에서 요청에서 토큰을 받으면 헤더와 페이로드의 값을 서버의 비밀키와 함께 돌려서 계산된 결과값이 서명값과 일치하는지 확인한다.

 

 


한 줄 요약

"세션은 서버에서 세션아이디 보관,

JWT는 보관 없이 인증만"

 

 

 

 

참고:

'Web' 카테고리의 다른 글

[Web] REST API  (0) 2021.07.03
[Web] 세션과 쿠키  (0) 2021.07.02

+ Recent posts