Corca
Published in

Corca

BEAT (Bidding Engine for AdTech) 아키텍처 (2편)

Written by 이태호 (Tech Lead)

지난 BEAT (Bidding Engine for AdTech) 아키텍처 (1편)에 이어 (2편)에서는 저희의 니즈에 맞는 아키텍처를 설계할 수 있도록 고민한 측면에 대해 소개하고자 합니다.

1. 의존성 관리: 변동성이 높은 컴포넌트(Actuator, Light Trainer)들의 자유로운 변경

2. 내결함성: 장애의 발생 가능성을 인지하고 장애 확장을 방지하는 안정성

3. 확장성: 트래픽의 확장에 맞춘 자유로운 인프라 확장

1. 의존성 관리 (Dependency)

이번 챕터에서는 서비스 운영과 유지보수의 효율성을 높이고, 배포의 난이도를 낮춰줄 수 있는 컴포넌트 의존성 관리에 대해 알아보겠습니다.

의존성 관리는 코드 레벨만이 아니라 컴포넌트 레벨에서도 굉장히 중요합니다.

의존성 관리의 기본 원칙은 “안정성의 방향으로 의존하라” 입니다.

컴포넌트는 항상 변경될 수 있기 때문에 모든 컴포넌트의 변동 가능성을 아키텍처 설계에 반영해야 합니다.

만약 변동성이 작은 컴포넌트가 변동성이 큰 컴포넌트에 의존하게 된다면 어떻게 될까요?

코드 레벨의 예시로는, Interface가 concrete class의 method 구현 디테일에 의존하는 상황이 있습니다.

이 경우 매번 Interface가 변할 것이며, 이는 매우 바람직하지 않습니다. Interface는 변동성이 적어야 하며, concrete class가 Interface에 의존하여 최소한의 변경만이 있어야 합니다.

컴포넌트 레벨에서도 동일하게, 낮은 변동성의 컴포넌트가 높은 변동성의 컴포넌트에 의존하게 되면 후자가 변경될 때마다 다방면의 수정 작업이 동시다발적으로 요구됩니다.

그렇기 때문에 컴포넌트 응집도를 극대화함으로써

  1. 컴포넌트 간의 의존성을 최소화하고
  2. 높은 변동성의 컴포넌트가 낮은 변동성의 컴포넌트에 의존하게 할 때

자유로운 컴포넌트 변경이 가능해질 것입니다.

지금까지 설명드린 컴포넌트 중 가장 자주 변경될 것 같은 컴포넌트는 무엇인가요?

또 변경이 제일 적을 것 같은 컴포넌트는 무엇인가요?

  1. Light Trainer
  2. Actuator
  3. Inference
  4. Receiver
  5. Data Stream
  6. Supervisor

정답은 1, 2번 / 6번(4, 5번도 정답)입니다.

광고의 성과를 결정짓는 요소는 Actuator와 Light Trainer이며, 이 컴포넌트들은 반복적인 수정을 통해 계속 성능을 향상시켜야 합니다.

그렇기에 그 어떤 컴포넌트도 이 2개의 컴포넌트에 의존하지 않도록 설계하였습니다.

다시 말해, Actuator, Light Trainer의 코드, 언어, 배포 방식이 바뀌더라도 다른 컴포넌트에는 전혀 영향을 미치지 않습니다.

Actuator와 Light Trainer에 의존하는 컴포넌트는 없으니까요.

반대로 가장 변동성이 낮은 컴포넌트는 Supervisor입니다.

(Receiver, Data Stream도 비슷하게 변동성이 거의 없습니다.)

Supervisor는 state, checkpoint를 관리하고 Inference에 전달하는 역할을 하므로, 한번 제대로 개발된 이상, 로직이 변경될 가능성이 낮습니다.

위 그림을 자세히 보면 이전 글의 그림과 차이가 있는데요, 바로 화살표 방향입니다.

이전 그림에서는 데이터의 흐름을 화살표로 나타냈다면, 이 그림에서는 컴포넌트간 의존성을 화살표로 나타내었습니다.

(A → B 이면 A 컴포넌트가 B 컴포넌트에 의존한다는 뜻입니다.)

변동성이 적은 Receiver, Data Stream, SupervisorFan-in(들어오는 의존성)이 Fan-out(나가는 의존성)보다 훨씬 더 많습니다.

이들은 전부 안정적인(stable) 컴포넌트입니다.

변동성이 큰 Actuator, Light Trainer의 경우, Fan-out이 Fan-in보다 많습니다. 사실, 아예 Fan-in이 존재하지 않습니다.

이들은 BEAT에서 가장 불안정하고 변동성이 큰 2개의 컴포넌트이기 때문이죠.

참고)

‘의존한다’의 의미를 풀어서 설명하면 다음과 같습니다.

현재 BEAT에서는 Actuator가 Data Stream의 주소를 갖고 있고, Data Stream이 준비해둔 데이터를 Actuator가 가져옵니다.

Actuator가 Data Stream의 데이터를 사용하기 때문에, Data Stream이 변경되면 Actuator도 같이 변해야 합니다.

이 경우, Actuator가 Data Stream에 의존한다고 말합니다.

반대로 BEAT의 의존성 설계가 잘못되어 Data Stream이 Actuator에 의존하게 된다면 어떻게 될까요?

Data Stream이 Actuator 주소 데이터를 바로 Actuator에게 전달합니다.

만약 Actuator에서 사용하는 데이터를 누락시킬 경우, Data Stream에서 Actuator에게 전달하는 데이터의 형식이 맞지 않아 에러가 발생할 것입니다.

2. 내결함성 (Fault Tolerance)

이번 챕터에서는 내결함성을 갖는 아키텍처를 만들기 위한 개발 철학, 배포 방식, LMA스택(Logging, Monitoring, Alerting), 리커버리 테스트까지 알아보겠습니다.

우리의 목표는 시스템의 일부 구성 요소가 작동을 멈춰도 전체 모델은 계속 작동할 수 있도록 유지하는 것이었습니다.

애드테크의 대규모 데이터와 인프라를 다루다보면, 아무리 완벽한 모델이라도 언제든 문제가 발생할 수 있습니다.

네트워크 이슈로 메시지가 전달이 되지 않을 수도 있고, 지연이 생길 수도 있으며, 예기치 못한 이유로 서비스가 중단될 수도 있습니다.

그렇기 때문에 가장 중요한 것은 장애의 발생을 막는 것보다 중요한 것은 장애가 발생해도 모델이 원활히 작동하게 하는 것이었습니다.

장애는 어디에서 발생할 수 있을까요?

  1. 서비스 중단

아래와 같은 다양한 상황에서 서비스 중단이 발생할 수 있습니다.

  • 개발 과정의 오류로 인한 로직 에러 발생
  • 네트워크 지연으로 인한 초기화 실패
  • DB의 과부하로 인한 연결 오류
  • 스팟 인스턴스 중지
  • 재배포

2. 마이크로서비스들 간의 연결 중단 / 송수신 메시지의 누락 또는 중첩

항상 연결을 보장해주는 네트워크는 없으며, 주고받는 메시지들이 언제나 exactly-once로 전달된다는 보장 또한 없습니다.

메시지가 누락되거나 중첩되는 경우에도, 명확한 예외 처리를 통해 서비스 중단을 방지해야 합니다.

장애 대응을 위한 개발 및 배포 방식

코르카는 모든 컴포넌트들에 대해 이 2가지 장애를 다루는 것을 목표했습니다.

장애를 효과적으로 다룬다는 것은 무엇일까요?

현실적 예시로는 화재 상황과 비교할 수 있습니다.

화재는 우리 삶에 큰 피해를 줍니다. 그러나 이 세상 어디에도 화재를 100% 예방하지 못합니다. 다만 화재가 발생했을 때의 대처 과정에서의 차이는 존재합니다.

5분 내로 소방차가 도착해 화재를 진압하는 경우가 있는 반면, 1시간이 지나서도 소방차가 도착하지 않아 진화에 어려움을 겪는 경우도 있습니다.

코르카는 화재가 발생하더라도 이를 신속하게 진압할 수 있도록, 일종의 소방 교육을 통해 개개인이 자체적으로 진화 작업을 진행할 수 있도록 만들고자 했습니다.

다시 개발 세상으로 돌아오겠습니다.

우리의 목표는 다음과 같습니다.

  1. 컴포넌트 내에서 자체적으로 장애를 다루는 환경
  2. 장애 발생 시 주변 컴포넌트로의 확산 방지
  3. 장애 발생 시 개발자가 빠른 조치를 취할 수 있도록 alert 전달

1. 컴포넌트 내에서 자체적으로 장애를 다루는 환경

기본적으로 어떤 컴포넌트들은 언제든 중단될 수 있으며, 컴포넌트가 중단될 때마다 매번 재배포를 진행하는 것은 비효율적입니다.

그렇기에 컴포넌트가 중단되더라도 다시 가동시킬 수 있는 서비스를 사용하고자 했습니다.

AWS의 Elastic Container Service, ECS를 사용하면, 컨테이너가 몇 개 중단되더라도, 즉시 동일 개수의 컨테이너가 되살아나며 목표로 설정한 컨테이너의 개수를 항상 유지할 수 있게 됩니다.

2. 장애 발생 시 주변 컴포넌트로의 확산 방지

모든 컴포넌트는 최소 하나의 다른 컴포넌트와 의존 관계를 지닙니다.

그러나 해당 컴포넌트의 생존 여부와는 절대 의존성을 가져서는 안됩니다.

Actuator에서 Supervisor로 state를 보낼 때 Supervisor가 죽어있다면 로그만 남기고 다음 일을 처리해야 합니다. 그렇게 되면 ECS를 통해 Supervisor가 다시 살아나고 그 다음의 메시지를 원활히 전달받게 됩니다.

BEAT의 모든 컴포넌트 간의 연결은 이러한 weak connection으로 구성되며, 개발 과정에 있어 이러한 부분의 예외처리는 특히 주의깊게 확인해야 할 부분입니다.

3. 장애 발생 시 개발자가 빠른 조치를 취할 수 있도록 alert 전달

예기치 못한 크리티컬한 장애가 발생할 경우, 반드시 개발자에게 alerting을 즉각적으로 전달해야 합니다.

나아가, 장애가 발생할 가능성이 높아졌을 때 warning alert를 전달하고, 장애가 발생했을 때 error alert를 전달하면 개발자가 문제를 사전에 방지하는 데 도움이 됩니다.

그렇기에 코르카는 모든 인프라에서 warning, error 기준을 설정하여 자동적으로 슬랙 alert가 전달되도록 개발했고, 이를 통해 문제가 발생하더라도 개발자가 즉각적으로 해결할 수 있었습니다.

일반적으로 CPU utilizationmemory utilization 지표를 많이 쓰고 response time, bytes per second 등의 지표도 사용합니다.

그 다음은 메시지 처리에 관한 대처 방법입니다.

Inference와 Supervisor 사이의 연결은 추론부에 직접적인 영향을 주기 때문에 매우 자세히 예외처리를 하였습니다.

그에 반해 다른 연결은 비교적 모델에 미치는 영향이 약했고, state나 checkpoint가 한번씩 전달이 되지 않더라도 다음 순서가 곧바로 전달되어 문제가 되지 않기 때문에 at-most-once 방식으로 메시지를 처리하였습니다.

이제 모델이 제대로 개발됐는지 확인하기 위한 테스트 과정이 필요합니다.

BEAT에서는 모든 컴포넌트마다 “Recovery Test”를 진행합니다.

모든 컴포넌트를 로컬에서 띄운 후, 임의의 컴포넌트를 하나씩 꺼보는 방식으로 진행됩니다.

모든 컴포넌트를 번갈아가면서 꺼보고, 전부 다시 켰을 때 아무런 문제 없이 모델이 돌아가면 Recovery Test를 통과합니다.

만약 하나의 컴포넌트를 껐을 때 다른 컴포넌트에서 에러가 발생하면서 연쇄적으로 장애가 확산된다면, 개발에 오류가 있는 것이라고 볼 수 있습니다.

3. 확장성 (Scalability)

이번 챕터에서는 확장성이 있는 아키텍처를 만들기 위해 고려한 컴포넌트별 트래픽 양의 영향 정도, stateless한 컴포넌트, auto scaling까지 알아보겠습니다.

애드테크 프로젝트의 특성상, 트래픽의 양이 일정하지 않고 수시로 변동하기 때문에 어느 날 갑자기 트래픽이 4–5배가 증가할 수도 있습니다.

갑자기 트래픽이 감소하면 인프라 비용 절약을 위해 인프라를 감소시킬 수 있어야 하고, 인프라 레벨에서 트래픽의 변화에 자동 대응하여 트래픽의 증가에도 인프라가 robust하게 버텨야 합니다.

스케일을 키우는 방법에는 scale-out, scale-up 2가지 방법이 있습니다.

  • scale-up: 기존의 서버를 보다 높은 사양으로 업그레이드
  • scale-out: 장비를 추가해서 확장하는 방식

IDC를 사용할 땐 2가지 방법 모두 쉽지 않지만, cloud service를 사용하면 두 가지 모두 적용 가능합니다.

다만 인스턴스 사양의 최대치는 정해져 있기에, scale-up은 성능 향상에 한계를 가져옵니다. 이런 이유로 코르카에서는 성능 향상을 위해 scale-out이 가능하도록 설계하였습니다.

scale-out이 가능한 컴포넌트를 만들 때에는 고려해야 할 사항들이 여러가지 있습니다.

1. Impact of Traffic

가장 먼저 트래픽 양이 미치는 영향의 정도를 확인해봐야 합니다.

각 컴포넌트의 특성에 따라 어떤 경우에는 트래픽의 증가에 직격탄을 맞을 수도 있고, 다른 경우에는 아무 영향을 받지 않을 수도 있습니다. 아무 영향을 받지 않는 컴포넌트는 scale-out 설계가 불필요합니다.

BEAT의 예시를 들어보겠습니다. (T는 traffic의 개수를 의미합니다.)

1) Actuator: O(log T)

Actuator는 Data Stream에서 aggregate된 정보를 전달받기 때문에 트래픽의 양과 연관성이 낮습니다.

다만 key 개수에는 영향을 받게 되는데, 대략적으로 log (# of Traffic)과 비례한다고 가정합니다.

2) Light Trainer: O(T)

Light Trainer는 트래픽의 모든 데이터를 그대로 사용합니다. 데이터가 많아서 다운샘플링을 진행하지만, 비례해서 다운샘플링을 하므로 트래픽과 비례한다고 볼 수 있습니다.

3) Inference: O(T)

하나의 트래픽당 하나의 request에 응답해야 하므로 트래픽과 비례합니다.

4) Receiver: O(T)

모든 트래픽을 받아서 Data Stream으로 전달하기에 트래픽과 비례합니다.

5) Data Stream: O(T)

모든 트래픽을 aggregate하여 Actuator에 전달하므로 트래픽과 비례합니다.

6) Supervisor: O(T)

Supervisor는 직접 트래픽을 다루지 않습니다. 그러나 Supervisor와 연결을 맺고 있는 Inference의 개수가 트래픽에 비례하므로 Supervisor의 연산량 또한 트래픽에 비례합니다.

이렇게 코르카는 모든 컴포넌트를 scale-out이 가능하도록 개발하게 되었습니다.

쉽지 않은 과정이었지만 갑작스럽게 트래픽이 10배가 증가하더라도, 인프라 비용을 더 지불하면 안정적으로 감당할 수 있게 되기에 엄청난 확장성을 가지게 되었습니다.

2. Stateless

(여기에서 말하는 state는 Actuator가 Supervisor에게 전달하는 state와 다르며, 일반적인 서비스의 상태 정보를 의미합니다.)

서버가 추가로 확장되더라도 확장된 서버가 즉시 정상적인 동작을 할 수 있어야 합니다. 여기서 정상적인 동작이라는 것은, 기존 서버와 정확히 같은 일을 하는 것을 의미합니다.

이를 위해서는 기존 서버에 남는 state를 아예 삭제하거나, state를 전달받아야 합니다.

Stateful한 서비스는 scale-out이 불가능하며, scale-out이 가능하려면 stateless해야 합니다.

  • Stateless: Receiver는 state가 없고 Inference는 state를 Supervisor로부터 주입받아서 stateless합니다.
  • Stateful: Actuator, Supervisor, Light Trainer의 경우에는 state가 전부 있었기에 state를 어딘가에 저장해야 합니다.

따라서 ActuatorSupervisor에,

SupervisorSupervisor DB에,

Light Trainermaster node에 저장하는 방식으로 해결하였습니다.

3. Auto Scaling

앞에서 scale-out이 필요한 컴포넌트를 찾아 scale-out이 가능하도록 개발을 했습니다.

그렇다면 이제 scale-out을 언제 어떻게 해야 할지 알아야 합니다.

AWS에는 자동으로 scale-out을 해주는 auto scaling이라는 기능이 있습니다.

EC2, ECS 모두 갖고 있으며 이 2개의 서비스에서 모두 auto scaling을 적용하고 있습니다.

auto scaling을 설정할 때는 scale-out, scale-in을 할 기준을 설정해야 합니다. 이론적으로 정해진 기준은 없으며 보통 실험을 통해 기준을 설정합니다.

코르카에서는 CPU utilization을 기본으로 auto scaling을 설정하고, 서버의 부하 정도를 확인하면서 auto scaling policy를 설정하였습니다.

Conclusion

코르카는 정해진 예산 안에서 광고의 효율을 극대화하는 프로젝트를 풀고 있습니다. 이 프로젝트의 대표적인 3가지 특징은 다음과 같습니다.

  1. 초당 만 개씩 쌓이는 데이터
  2. 24/7 돌아가는 서비스
  3. 매일매일 격변하는 세상

이 특징을 반영할 수 있도록 고려한 부분들에 추가로 일반적으로 아키텍처를 설계할 때 고려해야 되는 것들을 하나로 정리하면 아래와 같습니다.

  1. Monolithic Architecture vs Microservice Architecture
  2. stream data와 batch data 처리 방법의 차이
  3. 컴포넌트 응집도를 높이기 위한 아키텍처 설계
  4. 변동성이 높은 컴포넌트가 변동성이 낮은 컴포넌트를 의존하도록 하는 의존성 관리
  5. 장애 확장을 방지하는 안정적인 아키텍처
  6. 트래픽 양의 확장에 맞춘 자유로운 인프라 확장

코르카에서는 우선적으로 목표를 명확히 제시하고, 이를 풀어나가기 위해 알맞은 기술을 적용할 때 기술의 본질을 온전히 실현 가능하다고 생각합니다.

그렇기에 우리가 문제를 푸는 과정은 대부분 다음 단계를 통해 진행됩니다.

  1. 문제 정의
  2. 문제를 풀 수 있는 수단 조사
  3. 실험과 검증 과정을 통해 잠재적인 문제와 실질적인 효과를 체계적으로 확인
  4. 적용

이번 설계에도 위의 과정이 녹아들어 최종적인 BEAT 아키텍처가 탄생하게 되었습니다.

물론 이 외에도 고민했던 지점들이 굉장히 많습니다.

  1. supervisor가 inference에게 state와 checkpoint를 어떻게 전달해야 하는가?
  • pulling vs push
  • 연결 방법 (http? socket? pub/sub?)

2. 왜 Stream Data를 처리할 때 Kafka를 선택하였는가?

  • win, bid call matching 을 어디에서 해야 하는가? (DB? pub/sub? etc.)
  • order of message guarantee 여부
  • exactly-once vs at-most-once vs at-least-once
  • 위 3가지를 고려하여 kafka kinesis rabbitmq sqs 등등 여러 후보 중 kafka 선택

3. 복잡한 아키텍처 속에서 테스트는 어떻게 구조화하였는가?

  • 환경: 리서치 환경, 개발 환경, 컨테이너 환경, 오프라인 프로덕션 환경, 온라인 프로덕션 환경
  • 테스트 커버리지: 실험, 컴포넌트 유닛테스트, 컴포넌트 E2E 테스트, 통합테스트, E2E 비기능 테스트(신뢰성, 복구, 스트레스), E2E 기능 테스트

4. CI/CD 파이프라인 구축

  • 모노레포 vs 멀티레포 중 모노레포를 선택한 이유
  • 빌드, 테스트, 배포를 어떻게 자동화했는가?
  • nx, pulumi 란?

5. AWS Resource 관리

  • 어떤 서비스를 어떤 인프라를 사용하여 배포하였는가
  • 트레이너가 학습하기 위해 필요한 빅데이터를 어디에 저장해야 하는가 (S3, EFS, EBS)
  • Kafka의 security 설정

6. Logging, Monitoring, Alerting

  • Logging, Monitoring, Alerting 기준을 어떻게 세우는가
  • 어떤 모니터링 방식을 사용하였는가

각 토픽에 대해 개별적 포스팅을 작성할 수 있을 정도로 많은 고민들을 하였습니다.

지금까지 다룬 내용 외에도 제어 모델과 머신러닝 모델에 대한 수많은 고민의 흔적이 아키텍처에 담겨있으며, 이 2개의 모델에 대해서는 더 많은 이야기가 남아있기 때문에 추후 포스팅으로 돌아오도록 하겠습니다.

--

--

코르카는 AI 기술로 더 나은 솔루션을 찾아가는 전문 AI 컨설팅 팀입니다.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store