AWS SQS를 도입하면서 했던 고민들

AWS SQS를 도입하고 운영하며 느낀 점들

Dugi 🎈

  • 테크 인사이트

안녕하세요 👋 채널톡 엔지니어 두기입니다. 이번 글에서는 저희 팀에서 messaging queue로 AWS SQS를 도입하기까지의 과정과, 실제 운영하면서 있었던 일들에 대해 다뤄보겠습니다.

Why messaging queue?

나날이 성장하는 팀에서는 항상 많은 트래픽이 들어올 때마다 조마조마합니다. 12만 개가 넘는 고객사에서 사용 중인 채널톡의 인프라에서 받아내야 하는 요청 수도 어마어마한데요, 가장 요청이 많은 end-user를 대상으로 하는 서비스의 경우 월간 약 50억 건, 낮 시간에는 초당 4~5천 건의 요청을 받고 있어요.

💡 또, 채널톡은 채팅을 중심으로 하는 제품이기 때문에 WebSocket을 통한 실시간 업데이트의 트래픽도 엄청난데요, 이것을 해결하는 여정을 다루는 다른 테크블로그 글도 읽어보시면 어떨까요?

채널톡도 사용자 수가 얼마 없던 시절이 있었는데요, 어떤 서비스던 맨 처음 개발하고 배포할 때는 instance 하나에 어플리케이션 서버를 띄우는 것으로 충분할 것입니다. 대부분의 사이드 프로젝트에서는 이 단계를 넘어서는 일이 잘 없죠 :) 하지만, 제품이 인기를 끌고, 사용자 수가 많아질수록 하나의 instance에서 감당할 수 있는 트래픽을 점점 넘어서게 됩니다.

이제부터 어플리케이션 서버 앞에 load balancer를 달고, auto scaling group을 이용하여 트래픽이 수준에 따라 instance를 더 띄우거나 내리는 방법을 사용하게 됩니다. AWS 인프라를 사용하고 있는 저희 팀처럼, EC2 Auto scaling group을 통해 어플리케이션 서버를 관리하면 편리하죠.

EC2 instance에 올라가는 어플리케이션 서버 외에, 다른 자원들도 비슷한 관리를 해주어야 하는 경우가 있습니다. 저희 팀에서 많이 활용되는 AWS DynamoDB를 예로 들어보겠습니다. DynamoDB는 on-demand와 provisioned, 2가지 mode로 활용할 수 있습니다. Provisioned mode는 미리 정해진 양의 instance를 띄워놓는 것에, On-demand mode는 auto scaling group처럼 과거 트래픽 양에 따라 인프라가 조절되어 수용 가능한 트래픽 용량이 조절되는 것에 비유할 수 있어요.

그렇다면.. auto scaling group이나, on-demand mode를 사용하면, AWS에게 장애 대응에 대한 모든 것을 맡기고 편히 잠들 수 있는 것일까요?

아쉽게도 그렇지 않아요. 대표적으로 spike 형태의 트래픽이 들어왔을 때 겪는 문제가 있습니다. 갑자기 많은 양의 요청이 들어오면, auto scaling에 의해 EC2 instance를 더 띄울 수는 있지만, 인프라가 갖춰지는 데 시간이 걸립니다. 그러면 이미 띄워져 있는 instance에서 당장 받아버린 많은 요청을 처리해야 하고, 이미 받아버린 요청을 새롭게 올라오고 있는 다른 instance로 routing할 수단도 없으니 장애를 겪게 됩니다.

Spike 트래픽이 일정한 시점마다 예상된다면 그 전에 미리 많은 instance를 띄워놓는 방법도 있지만 (예를 들어, 많은 인기를 끌 것으로 예상되는 신기능 출시 전, 미리 서버를 올려둔다던지) 항상 트래픽을 예상할 수 있는 것은 아니에요. 항상 많은 instance를 올려두는 것은 비용 면에서 낭비이기도 합니다.

아래 그림에서 spike traffic이 들어왔을 때의 상황을 설명하고 있습니다. 두 번째 그림은 production 환경에서 저희 DynamoDB가 받는 읽기 요청을 발췌한 것인데요, 갑자기 많은 요청이 들어왔을 때 on-demand mode에 의해 요청 수용량이 수 분 안에 늘어납니다. 하지만 이미 받은 요청은 처리 용량 초과 (ProvisionedThroughputExceededException) 에러를 받게 됩니다.

물론 DynamoDB의 경우, 용량 초과 에러를 받았을 때 클라이언트 SDK 내에서 일정 간격을 두고 재시도하거나, DynamoDB에서 spike traffic을 처리할 수 있는 여유를 제공(burst capacity)하는 등, 이 문제에 대한 해결책을 제시하고 있습니다. 하지만 더 일반적인 해법이 필요합니다.

다시 문제로 되돌아가 봅시다. 식당에서 테이블이 꽉 차 있는데 손님들이 들어오고 있다면, 해법은 두 가지입니다.

  1. 사람들을 앞에서 기다리게 하고, 테이블이 비워지는 대로 차례로 들어오게 합니다.

  2. 테이블이 꽉 찼다고 하고 바로 사람들을 돌려보냅니다.

이것을 요청을 받는 서버 문제에 대입해 보면, 다음과 같습니다.

  1. 요청을 일단 buffering 해두고 (queue에 쌓아두고), 요청을 buffer에서 빼면서 차례대로 처리하기

  2. 허용된 용량 이상의 요청은 즉시 거절하기

1번 방법을 선택하면, 모든 요청은 언젠간 처리될 것이라는 보장을 얻을 수 있습니다. (모든 손님은 결국 밥을 먹을 수 있다.) 하지만 비동기적으로 처리되기 때문에, 손님들이 20~30분씩 기다렸다가 밥을 먹어야 할 수도 있습니다.

반면, 2번 방법에서는 테이블이 꽉 찬 후 온 손님은 돌아가야 합니다. 하지만 적어도 손님들이 기다리지는 않고, 식당에 도착하는 즉시 밥을 먹을 수 있습니다.

두 방법 모두 장단점이 있기 때문에 문제의 특성에 따라 접근 방법을 선택해야 합니다. 저희 팀에서 있었던 사례를 소개하자면:

  • 내부 서비스 요청 로깅: 로깅은 즉시 완료되지 않아도 되는 task이고, 추후 디버깅을 위해 개발자가 로그를 확인할 수만 있으면 됩니다. 따라서 queue에 요청 로그를 쌓아두고, queue를 구독하는 worker가 storage로 로그를 옮기는 architecture를 선택했습니다.

  • Open API: 채널톡 Open API는 이미 동기적인 response를 제공하고 있는 상태였습니다. Open API를 비동기적으로 처리하려면, 요청을 받는 즉시 request id를 발급하여 즉시 반환하고, request id로 요청의 결과를 polling할 수 있도록 제공해야 합니다. 이렇게 되면 Open API의 스펙에 breaking change가 생기기 때문에, 이 정책을 즉시 적용할 수 없습니다. 따라서 기존 스펙을 유지하되, 허용된 limit 이상의 요청은 즉시 거부하는 방향으로 선택했습니다.

정리하면, 저희 팀에서는 많은 양의 트래픽을 감당하기 위한 해결책 중 하나로 중간에 message queue를 두어 buffering하는 것을 고려하고 있습니다. 저희 팀은 message queue로 활용할 수 있는 여러 가지 component 중, AWS SQS를 선택했는데, 이에 대해 더 자세히 살펴보도록 하겠습니다.

AWS SQS

SQS는 publisher와 consumer 입장에서 사용하게 됩니다. 각각의 입장에서 SQS를 사용하는 interface는 아래와 같아요.

Publisher:

  • 메시지 발행하기 (SendMessage)

Consumer:

  • 메시지 가져오기 (ReceiveMessage)

  • 메시지 삭제하기 (DeleteMessage)

  • 메시지 가져오기 취소 (ChangeMessageVisibility(visibilityTimeout=0))

Publisher 쪽은 간단한데, consumer 쪽에서 3가지 액션이나 가능한 것이 복잡하죠? 이것은 consumer가 아래와 같은 메커니즘으로 동작할 것을 의도하기 때문입니다.

메시지를 queue에서 빼오는 것만으로는 SQS에서 메시지가 삭제되지 않습니다. 이 메시지를 처리한 후, 처리가 완료되었다(ACK)는 것을 SQS에 다시 요청해야 queue에서 메시지가 삭제되는 것입니다. 반대로, 메시지 처리가 실패한다면, 이 메시지 처리를 재시도할 수 있도록 SQS에 취소 요청(NACK)합니다.

AWS Kinesis, Apache Kafka와 같은 다른 messaging queue는 “어디까지 처리했다”와 같은 checkpoint 방식으로 메시지의 처리를 관리하는데, 메시지 하나하나에 대해 개별적으로 ACK/NACK를 보낼 수 있다는 점이 SQS의 특징입니다.

Writing a safe consumer

SQS consumer가 receive, ack, nack 3가지 액션을 할 수 있다는 사실을 알았으니, 이제 이것을 가지고 queue에서 지속적으로 메시지를 처리할 수 있는 worker를 만들어 봅시다!

손 가는대로 작성하면, 아래와 같은 pseudo-code에 도달하게 되어요.

JavaScript

이 구현에는 어떤 문제가 있을까요?

process(message), sqs.ack(message), sqs.nack(message) 도중에 consumer process가 어떤 이유로 죽어버린 상황에는 어떤 일이 일어날까요? SQS는 receive 요청에 의해 메시지를 내려줬지만, 이 메시지에 대해 ack도, nack도 받지 못한 채 계속 기다리고 있을 것입니다.

따라서, 메시지를 받을 때는 consumer가 대답해주는 것에 의존하지 않고 이 메시지를 어떻게든 처리할 수 있는 fallback 수단이 필요함을 알 수 있습니다. SQS에서는 메시지를 받을 때 VisibilityTimeout parameter를 넣을 수 있는데요, 이것은 이 메시지에 대해 ack 또는 nack을 받지 않더라도, VisibilityTimeout 이 지나면 이 메시지는 기다리고 있는 상태에서 해제된다는 것을 의미합니다. 그러면, 이 메시지를 처리하던 process가 죽어버려도 timeout이 지난 다음에 다시 살아난 process가 재시도할 수 있겠죠.

JavaScript

Timeout이 들어가면서 유의해야 할 점이 하나 더 생겼는데, process(message) 가 timeout 이상으로 시간이 걸리는 경우입니다. 이런 경우라면, 메시지는 처리 중이지만 이 메시지는 다시 다른 worker에서 처리를 시도할 수 있게 되어 한 메시지를 두 번 처리하게 되는 문제가 생깁니다.

따라서, process(message) 에도 같은 timeout을 걸어주는 것이 안전합니다.

Plaintext
function worker() {
  while (true) {
    const message = sqs.receive({ timeout })

    try {
      withTimeout(process(message), timeout)
      sqs.ack(message)
    } catch (e) {
      if (e instanceof TimeoutException) {
        /* just pass, no need to do something. */
        continue
      }
      sqs.nack(message)
    }
  }
}

이렇게 SQS로부터 계속해서 메시지를 polling하면서 각 메시지를 동기적으로 처리하는 worker를 작성할 수 있습니다. 저희 팀에서 AWS SQS를 도입할 때, 간단한 구현부터 instance에 in-memory buffer를 유지하면서 consume 속도를 조절하는 똑똑한 구현까지, 여러 가지 worker 구현을 참고했습니다. 하지만 많은 경우에 위 구현만으로 충분했기 때문에 이 원리를 유지하고 있습니다.

Consumer 프로세스가 죽는 경우를 고려하면, 메시지 하나를 받고 처리하는데 process(message) 를 끝내고 죽어버리거나, process(message) 도중에 죽는 경우가 있습니다. 비즈니스 로직에서 의도했던 side effect가 모두 성공했지만 이 메시지가 ACK 되지 못해 다시 처리되거나, side effect 중 일부만 처리되었을 수 있다는 의미입니다. 메시지를 처리하는 비즈니스 로직과 SQS에 ACK을 보내는 것을 atomic하게 묶지 못하기 때문에 발생하는 일입니다. (이것을 완벽하게 처리하려면, 2PC와 같은 메커니즘이 필요하죠) 즉, 비즈니스 로직은 한 메시지를 여러 번 처리할 수도 있음을 염두에 두어야 합니다. 데이터베이스에 item을 upsert하는 행위는 멱등성 있으므로, 중복해서 메시지가 처리되어도 관계 없습니다. 하지만 사용자에게 푸쉬 메시지를 보내는 행위는 그렇지 않습니다. 이 경우에는 중복 제거 필터를 통해 한 메시지가 여러 번 queue에서 consume되어도 관계 없도록 대응해주어야 합니다.

Publisher deduplication

앞선 그림을 보면 push service 앞에 중복 제거 필터가 있어 같은 내용의 푸쉬가 여러 번 나가지 않도록 방지해주는 안전장치가 있었어요. SQS 스스로도 네트워크 문제로 인해 retry되는 상황에서 메시지가 여러 번 publish되지 않도록 하는 메커니즘을 가지고 있습니다.

메시지가 publish되다가 실패했을 때, 이것은 SQS에 메세지가 도달하지조차 못한 것인지, 아니면 SQS는 메시지를 받아서 publish하는 데 성공했지만 response를 받지 못한 것인지 구별할 수 없기 때문입니다. 이것은 네트워크를 통해 통신해야 하는 서비스 간에 흔히 발생하는 문제입니다.

원인을 알 수 없기 때문에, 요청이 실패하면 publisher는 재시도할 수밖에 없습니다. (AWS 클라이언트 SDK도 요청이 실패하면 대부분 몇 번 재시도하는 정책을 내부적으로 구현하고 있습니다.) 재시도가 이어지면서, 이 메시지는 중복 발행될 수 있는 것입니다.

이것을 방지하기 위해, SQS에 메시지를 publish할 때는 MessageDeduplicationId parameter를 추가할 수 있습니다. 네트워크 오류로 인해 메시지 발행을 재시도할 때는, 같은 MessageDeduplicationId 를 지정하는 것입니다. 그러면 SQS는 수신한 publish 요청이 이전에 시도된 적 있는 것임을 인지하고 중복 요청을 무시할 수 있습니다.

Message ordering

다른 messaging queue와 마찬가지로, AWS SQS도 메시지 간의 순서를 관리하는 message group이라는 개념이 있습니다. AWS Kinesis의 shard, Apache Kafka의 topic과 비슷합니다만, SQS의 message group은 미리 등록할 필요 없이 사용할 수 있습니다. SQS에서 같은 message group id를 가지는 메시지끼리는 처리 순서를 보장합니다. 어떻게 순서를 지킬 수 있는지, 아래 그림을 통해 더 살펴봅시다.

위 그림은 SQS의 mental model입니다. Topic (message group) 마다 논리적인 queue가 존재한다고 생각하면 편리합니다. 하지만, 실제로 물리적인 queue가 provisioning되는 것은 아닙니다. SQS는 따로 등록 없이 message를 publish할 때 새로운 message group을 지정할 수 있고, message group은 무한히 많이 만들 수 있기 때문입니다.

SQS에서 같은 message group에 있는 메시지는 논리적인 FIFO queue에 묶여 있다고 언급했습니다. 따라서, 한 message group에서는 가장 앞에 있는 메시지 하나만 읽을 수 있습니다.

가장 앞에 있는 메시지를 가져왔다고 해서 뒤에 있는 메시지를 바로 볼 수 있는 것 또한 아닙니다. 가장 앞에 있는 메시지를 가져온 후, 처리 완료 (ACK) 까지 되어야 그 뒤에 있는 메시지를 가져올 수 있게 됩니다. 맨 앞의 메시지를 처리 실패하면 (NACK), 뒤에 있는 메시지는 가져올 수 없습니다. 그래서, 어떤 이유로 인해 맨 앞의 메시지를 계속 처리할 수 없다면 그 뒤에 있는 메시지들은 모두 영원히 처리할 수 없기 때문에 문제가 생깁니다! 그래서 SQS는 메시지 처리를 retry할 수 있는 최대 횟수를 두고, 이 이상으로 실패하면 해당 메시지를 dead-letter queue로 보내는 기능을 제공합니다.

저희 팀은 SQS message group의 특징을 분산 락 (distributed lock) 을 일부 대체하는 데 활용하고 있어요. 같은 message group에 속한 메시지 중에는 동시에 하나만 처리 중일 수 있다는 조건을 만족하기 때문이에요. 여기에 더해 메시지의 순서가 유지된다는 추가적인 보장도 있습니다.

💡 혹시 궁금하시다면, 저희 팀에서 redis를 이용해 분산 락을 구현한 이야기도 읽어보시면 어떨까요?

이 때문에 SQS message group을 다음과 같은 곳에 활용할 계획을 가지고 있어요.

  • 사용자 이벤트 추적: 저희 팀은 채널톡 플러그인이 붙은 웹사이트에서 페이지 방문, 스크롤 내리기 등 사용자의 행동을 추적하는 이벤트를 관리하고 있어요. 이벤트는 특히 발생한 순서가 중요한데요, 한 사용자로부터 발생한 이벤트 발행 메시지를 message group으로 관리하면, 여러 서버 instance에서 동시에 이벤트를 처리해도 lock 경합이 발생하지 않습니다. 그러면서 이벤트의 순서는 보존되니 딱 맞는 usecase에요.

  • 채팅 메시지 스트림: 채팅도 메시지의 순서가 유지되는 것이 중요한 곳 중 하나입니다. 사용자는 채널톡 앱 안에서 채팅상담을 열고 메시지를 작성할 수도 있지만, 문자메시지, 이메일, 카카오톡 등 외부 메신저로부터 들어오는 문의도 채널톡 안에서 채팅으로 만들어져요. 다양한 웹훅으로 외부 연동이 이루어져 있는데, 같은 채팅으로부터 발생하는 웹훅 요청을 message group으로 관리하면 메시지의 순서를 유지하면서 많은 요청을 처리할 수 있어요.

메시지 처리 속도

SQS를 도입하기 전 저희 팀에서 많은 어려움을 겪었던 지점은 메시지를 처리하는 속도를 조절하는 것이었습니다. 갑자기 많은 요청이 들어와서 서버가 힘들어 할 때, 보통 저희는 다음과 같은 대응을 합니다 😅

  1. 일단 auto scaling에 의해 서버가 더 올라갑니다. 이러면 지금부터 새로 들어오는 요청들은 새롭게 올라오는 서버로도 routing되기 때문에 기존 서버로 인입되는 부담이 줄어듭니다.

  2. 이미 들어온 요청들은 인스턴스 내부의 job queue에 쌓여 있습니다. 이 queue가 천천히 빠질 때까지 조마조마 하면서 기다립니다.

  3. Queue가 무사히 다 빠지면 안심합니다.

보시다시피, 이 대응 프로세스는 문제가 많았습니다 😭

  • 이미 인스턴스 안의 job queue까지 들어온 요청은 어떻게든 이 인스턴스가 다 처리해줘야 합니다. 이 인스턴스가 죽기라도 하면 job이 모두 유실되어 난감합니다.

  • 그래서, job이 많이 쌓인 상황에서 서버를 더 올린다고 해도 처리 속도가 그만큼 늘어나지는 않습니다.

이 프로세스를 개선하기 위해서는 인스턴스 내부의 queue를 바깥으로 빼내는 것이 필요했습니다. 그러면 이 인스턴스가 죽더라도 문제가 없고, 서버를 더 띄웠을 때 queue를 함께 consume할 수 있으므로 속도가 서버를 띄운 만큼 빨라질 것입니다. AWS SQS는 여러 consumer가 한 queue를 같이 polling할 수 있으므로 이 문제를 잘 해결할 수 있는 성격을 가지고 있었습니다.

(반면 AWS Kinesis는 이러한 특징을 가지고 있지 않습니다. Kinesis data stream은 미리 shard라고 불리는 partition으로 나뉘어져 있어야 하고, shard 하나에는 consumer 하나만 붙을 수 있기 때문에 이 shard를 갑자기 더 빠르게 읽어나갈 수는 없고, shard를 나누는 방식으로 해결할 수밖에 없습니다.)

Consumer worker를 더 띄우면 속도가 빨라지는지 확인해보기 위해, 실험도 해보았어요.

먼저 아무 일도 하지 않고 SQS로부터 메시지를 받아서 consume하기만 하는 worker를 구성했습니다. 그리고 worker thread의 개수에 따라 최대 메시지 처리 속도를 측정해보았습니다. Worker의 처리 속도는 worker 개수에 관계 없이 300 TPS 정도로 유지되는 것을 관찰할 수 있었습니다. Queue를 polling하는 worker는 다른 worker의 간섭 없이 독립적으로 작동하니 worker가 n개가 되면, queue에서 메시지가 빠져나가는 속도 또한 n배가 되는 것이죠.

이 실험을 해보고 나니, queue에 메시지가 빠르게 들어오고 있을 때 아래와 같이 대응할 수 있게 되었습니다.

  • 일단, 서버 인스턴스를 더 올리는 것으로 바로 트래픽에 대응할 수 있게 되었습니다.

  • 인스턴스를 정확히 얼마나 더 올려야 할지도 예상할 수 있게 되었습니다.

    평소 4개의 서버가 queue를 polling하고 있었는데, 평소의 3배 정도의 트래픽이 들어와 queue에 점점 메시지가 쌓이고 있다면, 8개를 더 띄우는 판단을 할 수 있게 되었다는 의미입니다. 개발 환경에서 먼저 적은 트래픽으로 어느 정도의 worker가 필요한지 실험해보고, production 환경의 트래픽에 맞춰 worker를 적절하게 세팅하는 판단도 가능합니다.

운영 사례 - 서비스 요청 로깅

저희 팀은 외부 서비스의 webhook이나, 채널톡 Open API 등 여러 서비스 요청에 대한 로그를 기록하고 있습니다. 이 로그를 AWS DynamoDB에 기록하는 경우, 갑자기 많은 로그를 write하려는 경우 DynamoDB의 provisioned throughput을 넘어서 문제가 발생합니다. 어떤 로그의 경우 쉬운 분석을 위해 RDS로 보내는데, 쿼리가 거부되지는 않겠지만 DB에 부담이 될 수 있죠. 따라서 중간에 버퍼링을 위해 SQS를 넣어두고 queue로부터 일정한 속도로 메시지를 읽어나가며 storage로 보내는 디자인을 선택했어요.

이러한 구조를 선택하니 운영하면서 아래와 같은 장점이 있었어요.

  • SQS가 중간에서 버퍼 역할을 하기 때문에, 각각의 어플리케이션 (API Server와 log-writer) 을 배포할 때 선택지가 훨씬 넓었습니다. SQS는 별도로 설정하지 않아도 메시지를 무한히 많이 저장할 수 있고, 14일까지 보관할 수 있기 때문에 먼저 publish하는 쪽만 배포해놓고, 나중에 consume하는 쪽을 배포하는 것도 가능합니다. 실제로 메인 api server를 배포하면서 여러 문제가 있어 팀이 급하게 대응하는 상황에서, 상대적으로 덜 중요한 log-writer 쪽은 스케줄에 맞춰 배포되지 않아도 상관없다 보니 더 여유있게 대응할 수 있었죠.

  • SQS에서 메시지를 읽다가 어플리케이션이 죽어도 안전하게 재시도할 수 있는데, 이 특징 때문에 log-writer는 AWS EC2 Spot instance에 띄울 수 있었습니다. Spot instance는 일반 EC2 instance에 비해 엄청나게 저렴한 대신 예고 없이 죽을 수 있다는 위험성이 있는데요, 이 때문에 일반 어플리케이션 서버에서는 사용하기 어렵지만, 이 경우에는 사용할 수 있었습니다. (DevOps팀에서 정말 많은 도움을 주셨습니다!)

마무리

휴! 정말 설명이 길었죠? 이렇게 저희 팀에서 메시지 큐 시스템의 일부로 AWS SQS를 선택하게 된 이유와, SQS의 특징, 실제로 SQS를 이용한 시스템 디자인을 운용하면서 있었던 일들에 대해 길게 풀어보았습니다.

백엔드 팀에서 사용하고 있는 메시지 큐로 AWS SQS가 유일한 것은 아니에요. 필요한 용도에 따라 AWS Kinesis Data Stream을 활용할 때도 있고, DynamoDB와 함께 결합하여 메시지 전달을 몇 주~몇 달 까지 지연시킬 수 있는 저희만의 메시지 큐 서비스를 사용하고 있는 곳도 있어요. (이 부분에 대해서는 기회가 되면 다른 글에서 다뤄볼게요.)

이번 글에서 다룬 고민은, 서비스가 성장함에 따라 많은 트래픽을 받고, 팀이 커지면서 거대한 서버가 여러 서버로 쪼개져 나가면서 자연스럽게 마주치는 문제를 해결해나가는 과정이라고 생각해주시면 감사하겠습니다! 혹시 저희와 함께 도전적인 과제를 해결해보고 싶으시다면? 저희 팀에 꼭 지원해주세요 👋

[이런 글도 추천드려요]

We Make a Future Classic Product

채널팀과 함께 성장하고 싶은 분을 기다립니다

사이트에 무료로 채널톡을 붙이세요.

써보면서 이해하는게 가장 빠릅니다

회사 이메일을 입력해주세요