가상 면접 사례로 배우는 대규모 시스템 설계 기초 2 - 광고 클릭 이벤트 집계를 정리한 내용입니다.

1. Introduction

온라인 광고의 핵심적 혜택은 실시간 데이터를 통해 광고 효과를 정량적으로 측정할 수 있다는 점이다. 디지털 광고의 핵심 프로세스는 RTB(Real-Time Bidding), 즉 실시간 경매라 부른다. 이 경매 절차를 통해 광고가 나갈 지면(inventory) 를 거래한다.

RTB 프로세스에서 속도와 데이터 정확성이 중요하다. 광고 클릭 이벤트 집계는 온라인 광고가 얼마나 효율적인지 측정하는 데 결정적인 역할을 하며, 클릭 집계 결과에 따라 예산 조정, 타깃&키워드 변경해 광고 전략을 수정할 수 있다. 핵심 지표로는 CTR(Click-Through Rate, 클릭률), CVR(Conversion Rate, 전환율) 등이 있으며, 집계된 광고 클릭 데이터에 기반하여 개선된다.

 

2. 문제 이해 및 설계 범위 확정

  1. 입력 데이터 형태
    1. 여러 서버에 분산된 로그 파일이며, 수집 시 로그 파일의 끝에 추가된다.
    2. 클릭 이벤트 필드 : ad_id, click_time-stamp, user-id, ip, country
  2. 데이터 양
    1. 광고 클릭 : 매일 10억개
    2. 광고 : 2백만 회 게재
    3. 광고 클릭 이벤트 수 : 매년 30% 증가
  3. 지원 질의(query)
    1. M분 간 클릭 이벤트 수
    2. 질의 기간과 광고 수 (집계는 매분)
    3. ip, user_id, country 등의 속성을 기준으로 상위 2개 질의 결과 필터링
  4. 엣지 케이스(edge case)
    • 예상보다 늦게 도착하는 이벤트
    • 중복 이벤트
    • 시스템 복구 고려
  5. 지연 시간 요건
    1. 모든 처리가 수분 이내 완료
    2. RTB 지연 시간 : 일반적 1초 미만
    3. 광고 클릭 이벤트 집계 : 수 분 지연 허용

 

3. 데이터 모델 (원시 데이터 vs 집계 데이터)

  • 원시 데이터 보관
    • 장점
      • 원본 데이터 손실 없이 보관
      • 데이터 필터링 및 재계산 지원
    • 단점
      • 막대한 데이터 용량
      • 낮은 질의 성능
  • 집계 결과 데이터 보관
    • 장점
      • 데이터 용량 절감
      • 빠른 질의 성능
    • 단점
      • 원시 데이터 손실

 

어떤 데이터를 저장하는 것이 적합할까? 뻔할 수 있지만 원시, 집계 데이터 데이터 모두 저장하는 것을 추천한다. 데이터의 사용 용도에 따라 적합한 데이터가 다르기 때문이다. 원시 데이터는 디버깅, 백업 데이터로 활용하는데 적합한 반면 집계 데이터는 효율적인 질의를 위한 튜닝 목적으로 사용된다. 광고 클릭 이벤트 데이터 특성상 시계열 데이터이며 쓰기, 읽기 연산이 상당히 높은 특성을 고려해야 한다.

데이터 베이스 선택의 기준은 아래와 같다.

  1. 데이터 형태(e.g. 관계형, 문서, 이진 대형 객체)
  2. 작업 흐름 (e.g. 읽기, 쓰기 중심)
  3. 트랜잭션 지원 여부
  4. 질의 과정에서 온라인 분석 처리(OLAP) 함수 사용 빈도 여부 (e.g. SUM, COUNT)

 

4. 계략적 설계안

광고 클릭 데이터는 동기 처리에 한계가 있다. 비즈니스 특성상 트래픽에 유연하게 대응해야 하기 때문이다. 또한 소비자 처리 용량을 넘어서는 경우와 같은 예기치 않은 문제가 발생할 수 있으므로 비동기 처리가 핵심이다. 비동기 처리를 위한 방법으로는 카프카와 같은 풀 방식의 메시지 큐를 통해 생산자와 소비자의 결합을 끊는 것이다. 푸쉬 방식은 생산자의 메시지 전송 속도에 따라 소비자의 스펙이 갖춰줘야 하지만 풀 모델은 메시지 구독 속도를 조절할 수 있기 때문이다.

또한 집계 결과를 DB 에 바로 기록하지 않는데 이는 정확하게 한 번 데이터 처리를 위해 원자적 연산(atomic commit) 을 보장하기 위함이다.

 

(1) 집계 서비스

  • 맵 노드 : 데이터 출처에서 읽은 데이터를 필터링하고 변환하는 역할을 한다.
  • 집계 노드 : 광고별 클릭 이벤트 수를 매 분 메모리에 집계한다.
  • 리듀스 노드 : 모든 집계 노드가 산출한 결과를 최종 결과로 축약한다.

 

5. 상세 설계

(1) 스트리밍 vs 일괄처리

스트리밍 처리데이터를 오는 대로 처리하고 거의 실시간으로 집계된 결과를 생성하는 데 사용하고, 일괄 처리처리 경로를 백업하기 위해 활용한다.

일괄 처리 및 스트리밍 처리 경로를 동시에 지원하는 시스템의 아키텍처를 람다(lambda) 이다. 람다 아키텍처의 단점은 유지 관리해야 코드가 분리된다는 점이다. 반면, 카파 아키텍처(kappa) 는 일괄 처리와 스트리밍 처리 경로를 하나로 결합하여 이 문제를 해결해야 한다.

(2) 시간

이벤트 타임 스탬프는 이벤트 시각과 처리 시간으로 구분해서 관리할 수 있다. 이벤트 발생 시각에 타임 스탬프를 처리한다면 광고 클릭 시점을 정확히 파악할 수 있어 집계 결과가 정확하다. 하지만 생성 주체가 클라이언트에서 고의로 조작할 수 있는 문제가 있다. 처리 시각은 서버에서 타임 스탬프를 관리하기 때문에 보다 신뢰할 수 있지만 네트워크 지연, 비동기 처리로 인한 지연에 의한 집계 결과가 부정확 해질 수 있는 단점이 있다.

집계 처리를 1분 단위로 끊어지는 방식을 텀블링 윈도우(tumbling window) 라 한다. 텀블링 윈도우는 이벤트 발생 시각과 처리 시각의 차이가 큰 경우 이벤트가 집계되지 못할 수 있는 위험이 있다. 이와 같은 문제는 워터마크를 이용해 해결할 수 있다. 워터마크(water mark)는 윈도우에 속하는 모든 데이터가 도착할 것으로 예상하는 시점을 나타내는 기준점을 두어 지연 데이터를 윈도우에 포함시키는 방식을 말한다. 윈도우에는 텀블링 윈도우, 고정 윈도우, 호핑 윈도우, 슬라이딩 윈도우, 세션 윈도우 등이 있다.

 

Reference

1. Spring Batch??

스프링 배치는 한마디로 이야기 하자면 개발자가 정의한 작업을 일괄처리를 지원하는 모듈을 의미한다. 일정 주기로 실행하는 로직을 별도의 어플리케이션을 적용함으로써 하나의 어플리케이션의 부하를 분산시키고 비즈니스 로직에 집중할 수 있는 장점이 있다. 그리고 스프링에서 제공하는 Spring-Batch-Docs 에서는 아래와 같이 배치가 필요한 경우에 대한 예시를 정의하고 있다.

 

출처 : spring-batch-docs

 

  1. 특정 유저와의 상호작용이 없는 대용량 자동화 프로세스, 주로 시간 기반 이벤트(월말 정산 처리)가 필요한 경우
  2. 대용량 데이터 셋을  반복, 주기적으로 프로세싱하는 어플리케이션의 경우 (보험 혜택을 정하거나 보험료를 조정하는 일)
  3. 내 / 외부 시스템으로 부터 받은 정보를 통합하는 일. 이 내용에는 포맷팅, 유효성 검사, 트랜잭션 방식의 처리를 필요로 하는 경우도 포함한다

 

2. Batch? Schedular?

 

배치와 스케줄러는 상호보완적인 역할을 한다. 배치 대용량 데이터를 일괄처리하는 비즈니스 위한 목적으로 사용하는 모듈이고, 스케줄러는 일정 시간에 지정한 동작을 하는 것을 말한다. 스케줄러 Quartz는 일정 시간에 특정 동작을 수행하도록 하지만 대용량 배치 처리에 관한 기능은 없다.

 

 

3. Spring Batch Architecture

스프링 배치 아키텍처는 Application, Batch Core, Batch Infrastructure 로 구성되어 있다.

  • Application : 개발자가 Spring Batch를 사용하여 작성한 모든 배치 작업과 사용자 정의 코드가 포함되어 있다.
  • Batch Core : 배치 작업을 시작하고 제어하는 데 필요한 JobLauncher, Job 및 Step 와 같은 런타임 클래스가 포함하고 있다.
  • Batch Infrastructure : 개발자가 모두 사용하는 공통 읽기 및 쓰기 및 서비스(e.g. RetryTemplate)를 포함되어 있다.

spring-batch architecture

 

4. 스프링 배치 메타 데이터

스프링 배치의 실행, 관리를 위한 목적의 여러 도메인 정보들을 저장, 업데이트, 조회할 수 있는 스키마 제공한다. 별도의 스프링 배치 메타 데이터를 DB 유형별로 제공하며, 프로퍼티를 통해 스키마 생성을 설정할 수 있다.

  • 스프링 배치 메타 데이터 위치
    • /org/springframework/batch/core/schema-*.sql
  • 스키마 생성 설정 (spring.batch.jdbc.initialize-schema)
    • ALWAYS : 스크립트 항상 실행
    • EMBEDDED : 내장 DB 일 경우에만 실행 (default)
    • NEVER : 스크립트 항상 실행 안함 (운영 환경에서 권장)

출처 : https://docs.spring.io/spring-batch/reference/schema-appendix.html#metaDataSchema

Job 관련 테이블

  1. BATCH_JOB_INSTANCE
    • Job 이 실행될 때 JobInstance 정보를 저장한다.
    • job_name과 job_key를 키로 하여 하나의 데이터가 저장하므로 동일한 job_name 과 job_key 로 중복 저장될 수 없다.
  2. BATCH_JOB_EXECUTION_PARAMS
    • Job과 함께 실행되는 JobParameter 정보를 저장한다.
  3. BATCH_JOB_EXECUTION
    • JobExecution 관련 데이터가 저장되며 Job 생성, 시작, 종료 시간, 실행상태, 메시지 등을 관리한다.
  4. BATCH_JOB_EXECUTION_CONTEXT
    • Job 의 실행동안 여러가지 상태정보, 공유 데이터를 직렬화 (Json 형식) 해서 저장한다.
    • Step 간 서로 공유할 수 있다.

 

Step 관련 테이블 

  1. BATCH_STEP_EXECUTION
    • Step 의 실행정보가 저장되며 생성, 시작, 종료 시간, 실행상태, 메시지 등을 관리한다.
  2. BATCH_STEP_EXECUTION_CONTEXT
    • Step 의 실행동안 여러가지 상태정보, 공유 데이터를 직렬화 (Json 형식) 해서 저장한다.
    • Step 별로 저장되며 Step 간 서로 공유할 수 없다.

 



 

 

Reference

1. REST level ?

흔히 사용하는 REST API 는 URL 을 통해 자원에 대해 표현하고 HTTP method 를 통해 자원의 행위를 정의한다. 하지만 이와 같은 방법은 클라이언트가 서버에서 제공하는 선택사항을 클라이언트의 선택에 의해 주도된다는 점이다. 우리가 아는 REST API는 해당 응답에 대한 다음 스텝에 대해 알려주지 않는다.

 

RESTful WEB API에 설명된 Richardson Maturity Model(RMM)에서 제공하는 4가지 레벨을 살펴보자.

  1. level0
    • API 구현은 HTTP protocol 을 사용하지만 모든 기능을 활용하지 않는다.
    • 리소스에 관한 고유 주소를 제공하지 않는다.
  2. level1
    • 리소스에 대한 고유 식별자가 있지만, 리소스에 대한 각 행동(action)에도 고유한 URL이 있습니다.
  3. level2
    • URL에 행동(action) 을 설명하는 동사 대신 HTTP Method 를 사용한다.
    • 현재 가장 많이 활용되고 있는 REST API 형태이다.
  4. level3
    • 리소스에 하이퍼미디어(Hypermedia) 를 도입하였다.
    • 이를 통해 가능한 작업에 대해 알려주는 링크를 응답에 배치하여 API 를 탐색할 수 있는 기능을 추가할 수 있다.

출처 : How to Build Hypermedia API with Spring HATEOAS

 

2. HATEOAS ?

HATEOAS(Hypermedia as the Engine of Application State) 는 RMM 에서 제공하는 모델 중 level3 에 해당하는 방법이다. HATEOS 는 REST API 를 쉽게 탐색할 수 있도록 해주는 서비스이다. HATEOS 를 사용하면 다른 리소스를 가리키는 임베디드 URI 에 따라 API 를 탐색하고 상호 작용할 수 있다.

  • HAL(Hypertext Application Language) 는 JSON 또는 XML 코드 내에서 외부 리소스에 대한 링크와 같은 하이퍼미디어를 정의하기 위한 규칙이다.
HTTP/1.1 200 OK
Content-Type: application/hal+json

{
   "status" : "SUCCESS",
   "data" : {
      "studentId" : 1,
      "name" : "홍길동",
      "birthDate" : "2006-01-01",
      "links": [
         {
             "rel": "self",
             "href": "<http://localhost:8080/api/v1/students/0>"
         },
         {
             "rel": "9th grade Link",
             "href": "<http://localhost:8080/api/v1/students/0/grades/9>"
         },
         {
             "rel": "10th grade Link",
             "href": "<http://localhost:8080/api/v1/students/0/grades/10>"
         },
         {
             "rel": "11th grade Link",
             "href": "<http://localhost:8080/api/v1/students/0/grades/11>"
         }
      ]
   }
}

 

3. Spring HATEOS 를 제공한다.

Spring 에서는 Spring HATEOS 의존성을 제공하며 Representation models 을 통해 링크를 추가할 수 있는 기능을 제공한다.

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-hateoas'
}
@RestController
class EmployeeController {

	...

	@GetMapping("/employees/{id}")
	ResponseEntity<Resource<Employee>> findOne(@PathVariable long id) {
		return repository.findById(id)
			.map(employee -> new Resource<>(employee, getSingleItemLinks(employee.getId())))
			.map(ResponseEntity::ok)
			.orElse(ResponseEntity.notFound().build());
	}
}

 

HATEOAS 가 진보적인 API 방식인가??

 아직 주변에서 HATEOAS 를 실무에서 사용하는 경우를 보지 못했다. GraphQL and REST Level 3 (HATEOAS) 에서는 고객의API 사용 패턴과 호출 방식을 고려할 때 HATEOAS 보다 더 나은 대안(e.g. SDK, GraphQL API) 이 있다는 점이다. 고객들은 특정 사용 사례에 필요한 일부 API 만을 사용하므로 인라인 문서화가 큰 가치를 제공하지 못한다고 한다. 이 또한 트레이드 오프일 수 있다고 생각한다. 비즈니스 특성과 상황에 맞게 HATEOAS 를 도입하는 방법 또한 괜찮은 대안일 것이다.

 

Reference

가상 면접 사례로 배우는 대규모 시스템 설계 기초 2 - 지표 모니터링 및 경보 시스템을 정리한 내용입니다.

 

1. 시스템 설계 시의 질문 & 고려 사항

  • 지표 데이터 보관 기간
  • 장기 보관 저장소 이동 시 지표의 해상도(resolution) 을 낮춰도 괜찮은지?
  • 경보 채널에 관한 지원? (.e.g. 이메일, 전화, 웹훅)
  • 분산 시스템 추적 기능(distribution system tracing function) 제공 여부

 

2. 개략적 설계안 제시

지표 모니터링 시스템을 구축하기 위해서는 데이터 수집, 데이터 전송, 데이터 저장소, 경보, 시각화를 담당하는 컴포넌트가 필요하다. 데이터 수집은 여러 출처로 부터 데이터를 수집하고, 지표 데이터를 지표 모니터링 시스템으로 전송해 데이터 저장소에 저장한다. 전송된 데이터를 분석하여 이상 징후를 감시하여 경보를 발생한다. 발생한 경보는 다양한 채널을 통해 발송한다.

 

(1) 데이터 모델

지표 데이터는 통상 시계열(time series) 데이터 형태로 기록한다. 시계열 데이터는 시간의 순서에 따라 관측된 데이터를 말한다. 시계열 데이터에는 타임스탬프(timestamp), 레이블(label) 이 포함된다. 최근 시장에는 많은 모니터링 소프트웨어 공통 형식을 준수한다. (e.g. 프로메테우스, OpenTSDB)

 

(2) 데이터 접근 패턴

쓰기 연산은 꾸준하게 많은 연산 부하를 감당해야 하고, 읽기 연산은 일시적으로 증가했다가 잠잠해진다.

 

(3) 데이터 저장소 시스템

데이터 저장소 시스템은 설계안의 핵심이다. 이전에 언급한 데이터 모델과 접근 패턴을 다시 살펴보면 시계열 데이터이고 높은 쓰기 연산, 일시적으로 높은 읽기 연산이다.

RDB 높은 쓰기와 읽기 연산에 좋은 성능을 보이지 못하며 시계열 데이터를 시계열 데이터 연산에 최적화되어 있지 않다. (e.g. 여러 태그, 레이블에 관한 지원 지수 이동에 따른 평균을 지속적으로 갱신 및 쓰기 연산)

NoSQL 에서 시계열 데이터에 최적화된 데이터 베이스가 많이 존재한다. 뿐만 아니라 데이터 보관 기간, 데이터 집계 기능을 제공한다. 대표적인 DB 로는 InfluxDB, 프로메테우스가 있다.

 

3. 개략적 설계안

  • 지표 출처 : 지표 데이터가 만들어지는 역할
  • 지표 수집기 : 지표 데이터를 수집하고 시계열 데이터에 기록하는 역할
  • 시계열 데이터베이스 : 지표 데이터를 시계열 데이터 형태로 보관하는 저장소
  • 질의 서비스 : 시계열 데이터베이스에 보관된 데이터를 질의하고 가져오는 과정을 돕는 서비스
  • 경보 시스템 : 경보 알림을 전송하는 역할
  • 시각화 시스템 : 지표를 다양한 그래프/차트로 시각화 하는 기능을 제공하는 시스템

출처 : [bytebytego] metric monitoring

 

4. 구체적 설계안

(1) 지표 수집

지표가 수집되는 흐름은 풀 모델, 푸시 모델이 있다. 풀 모델은 지표 수집기가 지표 출처로 부터 지표 데이터를 가져오는 방식이다. 대표적인 사례로는 프로메테우스가 있다.

 풀 모델은 지표 출처에 관한 메타 데이터를 관리하기 위해 서비스 탐색 서비스(Service Discovery Service, SDS) 이다. 각 서비스는 SDS 에게 가용성 정보를 전달하고, SDS 는 서비스 엔드포인트의 목록 변화가 발생하면 지표 수집기에 통보한다.

 풀 모델에서 지표 수집기에서 발생할 수 있는 문제데이터 중복해서 가져올 수 있다는 점이다. 이를 방지하기 위해 중재 매커니즘이 필요하며 이에 관한 방안이 안정 해시 링(consistent hash ring) 이다. 안정 해시 링은 해시 링 구간마다 해당 구간에 속한 서버로 부터 생산하는 지표의 수집을 담당하는 수집기 서버를 지정하는 것이다.

 푸시 모델은 서비스(지표 출처)에 수집 에이전트를 통해 지표 수집기에 지표 데이터를 주기적으로 전달하는 방식이다. 대표적인 사례로는 아마존 클라우드와치(cloudwatch), 그래파이트(graphite) 가 있다.

 푸시 모델에서 고려해야 할 사항은 자동 규모 확장(auto-scaling)환경이다. 서버의 동적 추가, 삭제과정에서 지표 데이터가 소실될 수 있기 때문에 이를 방지하기 위해 지표 수집기에 대해서도 자동 규모 확장(auto-scaling) 을 고려해 볼 필요가 있다.

 

출처 : [bytebytego] Push vs pull in metrics collecting systems

 

(2) 데이터 집계 시점

  1. 수집 에이전트가 집계
    • 특정 카운터 값을 분 단위로 집계하여 지표 수집기에 전달할 수 있다.
    • 클라이언트에 수집 에이전트는 복합한 집계 로직은 지원하지 않는다.
  2. 데이터 수집 파이프라인에서 집계
    • 데이터 저장소 기록 이전에 집계하기 위해서는 플링크(Filnk) 같은 스트림 프로세싱 엔진이 필요하다.
    • 데이버 베이스에는 계산 결과만 기록하므로 저장 내용이 많이 줄어든다.
    • 계산 결과만 기록하므로 정밀도, 유연성 측면이 아쉬울 수 있다.
  3. 질의 시에 집계
    • 로우 데이터를 보관한 다음 질의시에 필요한 시간 구간에 맞게 집계한다.
    • 데이터 손실 문제는 없으나 전체 데이터 세트를 대상으로 집계 결과를 계산해야 하므로 속도가 느릴 수 있다.

 

(3) 질의 서비스

질의 서비스는 시계열 데이터 베이스에 존재하는 데이터를 통해 시각화, 경보 시스템의 접수된 요청을 처리하는 역할을 담당한다. 질의 처리 전담 서비스를 구축하면 클라이언트(e.g. 시각화, 경보 시스템) 와 시계열 데이터베이스를 자유롭게 다른 제품으로 교체할 수 있다.

 

(4) 저장소 계층

  1. 저장 용량 최적화
    1. 데이터 인코딩 및 압축 : 데이터를 인코딩, 압축하면 크기를 상당히 줄일 수 있다.
    2. (1610087371, 1610087381 → 1610087371, 10)
    3. 다운 샘플링 : 데이터의 해상도를 낮춰 저장소 요구량을 줄이는 방법이다. 보관 기간이 오래된 데이터에 대해 해상도를 줄이는 방식으로 선택한다.
    4. 냉동 저장소 : 잘 사용되지 않는 비활성 상태의 데이터를 저장하는 곳이다.

 

Reference

가상 면접 사례로 배우는 대규모 시스템 설계 기초 2 - 분산 메시지 큐 설계를 정리한 내용입니다.

 

분산 메시지 큐 기능, 비기능 요구사항

분산 메시지 큐 기능 요구사항은 메시지 반복 수신, 오래된 이력 데이터 삭제, 메시지 전달 방식이다. 비기능 요구사항으로는 높은 대역폭과 낮은 전송, 규모 확장성, 지속성 및 내구성이다.

 

point-to-point vs publish-subscribe

메시지 큐는 소비 방식에 따라 point-to-point, publish-subscribe 로 구분된다. point-to-point 는 한 소비자가 메시지를 소비하고 큐에게 알리면(acknowledment) 메시지가 삭제된다. 반면, publish-subscribe 는 topic 개념을 도입해 topic 을 구독하는 소비자에게 메시지를 전달하는 방식이다.

출처 :&nbsp;Point-to-Point and Publish/Subscribe Messaging model

 

출처 : Point-to-Point and Publish/Subscribe Messaging model

 

partition

파티션은 “토픽에 보관되는 데이터 양이 커져 서버 한 대로 감당하기 힘든 상황이 벌어지면 어떻게 될지?” 에 관한 해결 방법이다. 파티션은 토픽의 메시지를 큐 클러스터의 파티션으로 균등하게 분할한다. 파티션을 브로커로 분산하면 높은 확장성을 제공한다.

각 토픽의 파티션은 FIFO 로 동작하므로 메시지 순서가 유지된다. 파티션 내의 메시지 위치는 오프셋(offset) 을 통해 관리한다. 생산자 보낸 메시지는 토픽의 파티션 중 하나로 보낸다. 만약 메시지의 키 값이 같다면 같은 파티션으로 보낸다. 만약 키가 없다면 무작위로 파티션을 선택해 저장된다.

각 구독자는 해당 토픽을 구성하는 파티션의 일부를 담당하며, 해당 토픽을 구독하는 소비자 그룹을 이룬다. 소비자 그룹은 토픽의 메시지를 소비하기 위해 서로 협력한다. 하나의 소비자 그룹은 여러 토픽을 구독하고, 오프셋을 별도로 관리한다.

 

개략적 설계, 상세 설계

브로커 : 파티션을 유지한다.

저장소

  • 데이터 저장소 : 메시지 저장 공간 (파티션 별로 관리된다.)
  • 상태 저장소 : 소비자 상태를 저장하는 공간
  • 메타데이터 : 토픽 설정, 토픽 속성 등을 저장 공간

조정 서비스

  • 서비스 탐색 : 브로커의 healthcheck 하는 서비스
  • 리더 선출 : 브로커 중 하나를 컨트롤러 역할을 당담하고, 파티션 배치를 책임진다.
  • 주키퍼 : 컨트롤러 선출을 담당한다.

 

상세 설계

  1. 하드웨어의 회전 디스크의 디스크 캐시 전략, 디스크 기반 자료 구조 활용
  2. 메시지가 생산자로 부터 소비자에게 까지 전달하는 순간까지 불변 상태가 가능하도록 자료구조를 설계해야 한다.
  3. 일괄 처리 우선 시스템으로 설게한다. 소규모의 I/O 를 최소화해 높은 대역폭을 제공하기 위함이다.

 

데이터 저장소

메시지 큐의 소비 패턴

  1. read, write 가 빈번하게 일어난다.
  2. update, delete 연산이 발생하지 않는다.
  3. 순차적인 read, write 가 대부분이다.

 

데이터를 관리하는 방법

데이터 베이스는 read, write 를 빈번한 형태를 제공하는데 한계가 있어 병목 현상이 발생할 우려가 있어 좋은 선택지가 아니다.

위와 같은 메시지 소비 패턴을 해결하기 위한 방법으로 쓰기 우선 로그 (WAL : Write-Ahead Log) 가 도입되었다. 쓰기 우선 로그는 새로운 항목이 추가되기만 하는 일반 파일이다. 비슷한 예시로는 MySQL redo log 이다.

로그 파일은 점진적으로 증가하고 일반적으로 순차적으로 적재된다. 디스크는 순차 읽기, 쓰기는 좋은 성능을 보이기 때문에 디스크에 보관할 것을 권장한다.

로그 파일은 또한 점진적으로 증가하므로 세그먼트 단위로 나눠 관리하는 형태이다. 하지만 로그 파일은 한없이 증가할 수 없으므로 세그먼트 단위로 나눠 관리한다. 메시지는 활성 상태의 세그먼트 파일에 추가한다.

 

메시지 자료 구조

메시 구조는 높은 대역폭 달성이 열쇠다. 메시지 정의 시, 계약(contract) 가 필요한 이유는 계약이 없을 경우 메시지를 받아들일 수 없어 메시지를 변경해야 하고 이 경우 값 비싼 복사가 일어날 수 있다는 점이다. 이와 같은 문제 해결을 위해 분산 메시지 큐는 메시지 형태를 계약(contract) 로 정의되어 있다.

메시지 키는 파티션을 정의할 때 사용되며, 파티션은 hash(key) % numPartition 으로 결정된다. 메시지 데이터 스키마는 아래와 같다.

출처 : 대규모 설계2 - 메시지 데이터 스키마

 

소비자의 작업 흐름

소비자 모델은 푸시 모델풀 모델이 있다. 모델 차이는 메시지를 전달하는 주체의 차이인데, 브로커가 메시지를 전달한다면 푸시 모델, 소비자가 메시지를 가져가는 것을 풀 모델이라 한다.

 푸시 모델의 장점은 낮은 지연이다. 메시지를 받는 즉시 소비자에게 보낼 수 있다. 단점은 주로 생산자의 데이터 전송 속도에 따라 부하가 걸릴 가능성이 있어 그에 맞는 처리 가능한 자원을 소비자가 준비되어 있어야 한다.

 풀 모델의 장점은 처리 속도를 소비자가 결정할 수 있고, 일괄 처리가 적합하다. 푸시 모델의 경우 소비자가 메시지 처리 속도를 알지 못해 전송 속도가 높을 경우 소비자의 버퍼에 쌓아 처리를 기다린다. 반면 풀 모델은 소비자가 마지막으로 가져간 로그 위치 다음의 모든 메시지를 한 번에 가져갈 수 있어 메시지 일괄 처리에 적합하다.

 하지만 풀 모델 또한 단점이 존재한다. 처리할 메시지가 없는 경우에 불필요한 데이터 요청으로 소비자 컴퓨팅 자원이 낭비된다. 이를 해결하기 위해 많은 메시지 큐가 롱 폴링 모드를 지원한다. 롱 폴링 모드는 일정 시간을 기다리도록 하는 것을 말한다.

 

사본 동기화

 한 노드의 장애로 인한 메시지 소실을 막기 위해 여러 파티션을 두고, 각 파티션은 여러 사본으로 복제한다. ISR(In-Sync Replication) 은 리더와 동기화된 사본을 일컫는 말이다. ISR 은 리더와 동기화된 메시지 차이를 확인해 사본 여부를 판단한다. ISR 은 성능과 영속성의 타협점이다. 메시지를 관리하는 가장 안전한 방법은 메시지 수신 응답(acknowledment, ACK) 을 전달하기 전에 모든 사본을 동기화 하는 것이지만 사본 중 하나라도 동기화가 늦으면 파티션이 모두 느려지거나 못 쓸 수 있어 주의해야 한다.

 

메시지 수신 응답(ACK)

  • ACK=all : 생산자는 모든 ISR 이 메시지 수신한 뒤에 ACK 응답을 받는다. ISR 응답을 기다려야 하므로 메시지 보내기 위한 시간이 길어지지만 영속성 측명에서 장점이 있다.
  • ACK=1 : 생산자는 리더가 메시지를 저장하고 나면 바로 ACK 응답을 받는다. 데이터 동기화될 때까지 기다리지 않아 응답 지연은 개선되지만 ACK 응답 전달 후, 리더에 장애가 발생하면 사본을 반영하지 못해 복구할 수 없는 단점이 있다.
  • ACK=0 : 생산자는 보낸 메시지에 대한 ACK 를 기다리지 않는다. 어떤 재시도를 하지 않으며 낮은 응답 지연을 달성하며 메시지 손실을 감수하는 구성이다. 

메시지 수신 응답 (출처 : 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2)

 

 

메시지 전달 방식

최대 한 번(at-most once) : 메시지가 전달 과정에서 소실되더라도 다시 전달되는 일이 없다. 생산자가 토픽에 비동기적으로 메시지를 보내고 수신 응답을 기다리지 않는다.(ACK=0) 메시지 전달이 실패해도 다시 시도하지 않는다. 소비자는 메시지를 읽고 처리하기 전에 오프셋부터 갱신한다.

 

최소 한 번(at-least-once) : 같은 메시지가 한 번 이상 전달될 수 있으나 메시지 소실은 발생하지 않는 전달 방식이다. 생산자는 메시지가 브로커에게 전달되었음을 반드시 확인하고, 실패하거나 타임아웃이 발생할 경우 계속 재시도한다. 소비가는 데이터를 성공적으로 처리한 뒤에만 오프셋을 갱신한다. 메시지는 브로커나 소비자에게 한 번 이상 전달될 수 있다.

 

정확히 한 번(exactly once) : 브로커나 소비자에게 정확히 한 번 전송하는 것을 보장하는 방식이다. 구현하기 까다로운 전송 방식이며, 시스템 성능 및 구현 복잡도 측면에서 난이도가 높다.

 

출처 : [ably] messaging-semantics

 

Refefence

 고전적인 메시지 큐는 단순히 메시지를 발행하고 소비하는 형태였지만 현대 메시지 큐는 EDA(Event Driven Architecture) 를 지원하기 위해 메시지를 영구 저장하여 반복 소비하는 형태로 변화하고 있다. 이와 같이 메시지를 영구 저장하는 메시지 서버를 event broker 라 부른다. 가장 대중적으로 사용하는 플랫폼으로는 kafka 이며 고전적인 Message Queue 들도 메시지를 저장, 재소비하는 방향으로 개발되고 있다. (RabbitMQ 는 메시지를 저장해 재시작할 때 메시지 유실을 방지하지만 아직은 영구 저장이 되지는 않는 것으로 알고 있다.)

 

Message Queue 의 장점

 메시지 큐의 공통적인 장점은 fan-out(팬-아웃), asynchronous processing(비동기 처리)decoupling(결합도 완화) 인 것 같다.

 

 fan-out 는 논리 회로에서 하나의 논리 게이트의 출력이 얼마나 많은 논리 게이트의 입력으로 사용되는지 서술할 때 쓰인다고 한다. 즉, 생산자가 메시지를 대기열에 발행하면 여러 소비자는 각각의 다른 목적으로 메시지를 사용할 수 있다는 의미이다. 예를 들어, 결제 서비스는 결제, 알림, 분석의 다운스트림 서비스로 데이터를 전송하여 사용하는 것을 의미한다. 

fan-out example(source : Why Do We Need a Message Queue?)

 

 

asynchronous processing 는 로직을 대기할 필요가 없다는 의미이다. 만약, Tomcat 기반의 WAS 에서 외부 API call 을 사용한다면 응답 올 때까지 해당 스레드가 블로킹 되어야 하지만 메시지 큐를 활용한다면 대기열에 메시지를 발행하고 대기열을 구독하여 로직을 처리한다면 스레드를 블로킹하지 않아 성능 향상을 기대할 수 있다. 예로 결제 지시가 대기열에 배치되고 결제가 완료되면 고객에게 알림을 전송하는 방식을 들 수 있다.

 

decoupling 의 의미는 각 서버를 독립적으로 수정, 배포 가능한 구조가 될 수 있다는 점이다. 서비스는 서로 긴밀하게 의존하지 않고 잘 정의된 메시지 인터페이스를 사용해 서로 상호 작용하며 아키텍처 설계에 유연성을 제공한다. horizontal scalability, availability 은 decoupling 으로 인한 부수 효과이다. 수요에 따라 독립적으로 서비스를 확장 가능해 다수 요청에 대한 성능 향상을 기대하고 서버의 안정성에 기여할 수 있다.

특정 서비스를 API server 로 분리되는 것 또한 decoupling(결합도 완화) 으로 볼 수 있을 것인가?? YES (당연한 이야기지만 내부 코드 또한 독립적으로 배포 가능한 구조여야 할 것이다.)

 

RabbitMQ vs Kafka

RabbitMQ vs Kafka (출처 : bytebytego)

 

 

 RabbiqMQ 와 Kafka 는 성능(performance) 와 확장성(scalability) 에서 차이가 있다. RabbitMQ 는 초당 수만 개의 메시지(tens of thousands/sec)를 처리하지만 scale-up 을 하더라도 처리량에 개선에 크게 영향을 미치지 않는다고 한다. 반면 kafka 는 높은 확장성으로 초당 수백만 개의 메시지(million/sec)를 처리할 수 있다.

 

Kafka 는 ecosystem 지원이 잘 되어 있다. Kafka 는 대용량 로그 처리를 위해 구축되어 있기 때문에 최신 빅데이터와 스트리밍 애플리케이션과 통합할 수 있다. RabbitMQ 는 Kafka 만큼의 ecosystem 지원을 잘하지 않는다.

 

RabbitMQ 와 Kafka 의 가장 큰 차이점 중 하나는 메시지를 소비하는 형태이다. RabbitMQ 는 메시지를 발행자가 보내는 즉시 소비자에게 전달하는 푸시 모델(push model) 이다. 소비자에게 메시지를 전달하면 메시지를 삭제한다. 푸시 모델은 즉시 소비자에게 바로 보내기 때문에 지연이 낮은 장점이 있지만, 발행자의 메시지 전송량이 좌우되므로 소비자가 그에 맞는 처리량에 대한 자원 스펙을 맞춰야 한다.

Kafka 는 풀 모델(pull model) 이다. RabbitMQ 와 반대로 메시지 소비 속도를 소비자가 결정한다. 풀 모델은 소비자가 메시지가 만료될 때까지 메시지를 보관하고 소비자가 자신의 속도에 맞춰 메시지를 가져올 수 있어 일괄 처리에 적합하다. 반면, broker 에 메시지가 없어도 소비자에게 계속 데이터를 가져가려고 시도해 소비자의 자원을 낭비할 수 있다.

 

push model (출처 : Hookdeck)

 

pull model (출처 : Hookdeck)

 

 

사용 사례를 비교해보자. Kafka 는 로그 처리 및 분석, 추천을 위한 데이터 스트리밍, 시스템 모니터링 + 알람, 데이터 변경 캡처(CDC), 애플리케이션 마이그레이션 과 같은 대용량 메시지 처리하는데 활용되고 있다. 자세한 내용은 해당 링크에서 확인해볼 수 있다. RabbiMQ 는 메시지 지연과 기능을 제공해 발행자가 메시지 처리 지연 시간을 헤더(x-delay) 를 통해 결정할 수 있다.

 

출처 : [bytebytego] How to Choose a Message Queue? Kafka vs. RabbitMQ - Log Processing and Analysis
출처 : [bytebytego] How to Choose a Message Queue? Kafka vs. RabbitMQ - Delayed Messages

 

 

Reference

 컴퓨터는 실수를 표현하기 위한 방법은 다양하지만 대표적으로 고정 소수점(fixed point)  부동 소수점(floating point) 이다. 고정 소수점은 10진수를 2진수의 거듭 제곱 합으로 표현해 그대로 비트에 담아 사용하고, 부동 소수점은 2진수의 표현 방식과 비트를 사용하는 체계가 다르며 가장 널리 쓰이는 표준은 IEEE754 이다. IEEE754 는 normalization, 지수부 bias 추가 과정이 포함된다. 자바의 float 는 IEEE754 방식으로 관리되며, 1bit는 부호(sign), 8bit는 정수부(exponent), 23bit는 가수부(mantissa)  로 실수를 관리한다.

고정 소수점 (출처 : CS50 - 고정 소수점)
부동 소수점 (출처 : CS50 - 고정 소수점)

 

 

 하지만 이 방식에는 단점이 존재한다. 고정 소수점, 부동 소수점 실수 부분에 관한 비트를 모두 사용하면 나머지 비트는 버리기 때문에 정확한 값 대신 근사치 값만 표현한다. 10진수를 2진수로 표현하는 과정에서 무한 소수 또는 가수 부분을 초과하는 소수가 변환되면 비트 외 부분은 사라져 근사치 값만 표현되기 때문에 금융권이나 공학 계산과 같은 정밀도가 높은 연산이 필요한 비즈니스에서는 부적합하다.

class Main {
	public static void main(String[] args) {
    	System.out.println(1.00 - 9 * 0.10); // 0.09999999999999998
    }
}

 

 

effective java 에서는 3가지 요소들을 권장한다.

  1. 소수점의 정밀한 연산을 위해서 BigDecimal 을 사용하자.
  2. 정수를 사용하는 경우, int(21억 이하), long(90경 이하) 을 사용하자.
  3. float 과 double 을 사용할 때는 == 을 사용하지 말자.

 

그리고 BigDecimal 을 사용할 때는 아래를 주의하자!

  1. BigDecimal 을 생성할 때, double 을 사용해서 생성하지 말자.
  2. 소수점을 표현한다면 RoundingMode 와 scale 를 고려해서 사용하자.

 

Reference

'java > summary' 카테고리의 다른 글

Garbage Collector simple summary  (0) 2024.03.04

 올라가는 AWS 서버 비용으로 인해 nGrinder 를 로컬 환경에서 진행하기로 했다. 네트워크 환경이 클라우드 환경과는 달라 차이가 있을 수 있지만 부하는 대응하기 위한 테스트를 하기 위해서는 테스트 작업으로 로컬 환경에서 환경을 구성해보기로 했다. ngrinder 에서는 로컬 환경에서 구성 방법과 에러 기록들을 간단히 기록해놓는다. 설치했던 버전은 현재 최신 버전인 ngrinder-controller ver.3.5.9 을 사용했다.

 

1. ngrinder-controller & ngrinder-agent 설치 

[1] download ngrinder-controller

(1) download ngrinder-controller file

 

(2) Users/[username]/.ngrinder/system.conf - controller.ip 설정하기

controller.host=127.0.0.1

 

(3) ngrinder-controller 실행

  • Djava.io.tmpdir 을 사용하지 않으면 에러 메세지 출력
  • --port : 실행할 port number 설정
java -Djava.io.tmpdir=/Users/Cooper/study/ngrinder/temp -jar ngrinder-controller-3.5.9.war --port=8200

 

 

[2] download ngrinder-agent

(1) download ngrinder-agent 

  • ngrinder-controller 실행 : http://localhost:[port number]
  • 우측 상단에 admin > 에이전트 다운로드 를 통해 설치해서 압축 해제하기
tar -xvf ngrinder-agent-3.5.5-localhost.tar

 

 

(2) .ngrinder-agent/agent.conf 파일 호스트 설정

  • 파일 위치 : /Users/[username]/ngrinder-agent/agent.conf
  • 변경 필드 : agent.controller_host
agent.controller_host=127.0.0.1

 

 

(3) ngrinder-agent 실행

  • 압축 해제 안 파일 필드로 선언된 run_agent.sh 실행

압축 해제한 ngrinder-agent 디렉토리 내부 파일들

 

정상 동작 테스트

  1. script 에 http://127.0.0.1 을 추가해서 스크립트에서 verify 테스트
  2. 실제 테스트 실행해서 agent 도 정상 동작하는지 확인하기
    1. smoke test 와 같이 기본 동작이 정상적으로 되는지 확인해보기
      1. VUser 1 = 1 process * 1 thread

 

파란불이 뜨면 됐다!

 

스크립트

import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

	public static GTest test
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = [:]
	public static List<Cookie> cookies = []

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "Test1")
		request = new HTTPRequest()
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		test.record(this, "test")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

	@Before
	public void before() {
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void test() {
		HTTPResponse response = request.GET("http://127.0.0.1:8080", params)

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
}

 

 

2. 설치 과정에서 만났던 에러 기록 🚨

1. ~/.ngrinder-agent/agent.conf 에서 host 미설정으로 인해 발생한 Setting of DNS provider failed 에러

  • 만약 테스트 실행 시 에러 로그에 이와 같은 내용이 발생하면 ~/.ngrinder-agent/agent.conf 파일 변경하면 된다!
2024-03-29 02:47:01,707 ERROR Error running worker process
net.grinder.engine.common.EngineException: Setting of Local DNS provider failed
	at net.grinder.engine.process.GrinderProcess.<init>(GrinderProcess.java:154)
	at net.grinder.engine.process.WorkerProcessEntryPoint.run(WorkerProcessEntryPoint.java:78)
	at net.grinder.engine.process.WorkerProcessEntryPoint.main(WorkerProcessEntryPoint.java:60)
Caused by: java.lang.ClassNotFoundException: sun.net.spi.nameservice.NameService
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
	at java.base/java.lang.Class.forName0(Native Method)
	at java.base/java.lang.Class.forName(Class.java:421)
	at java.base/java.lang.Class.forName(Class.java:412)
	at org.ngrinder.dns.NameServiceProxy.set(NameServiceProxy.java:59)
	at net.grinder.engine.process.GrinderProcess.<init>(GrinderProcess.java:151)
	... 2 common frames omitted
agent.controller_host=127.0.0.1

 

2. ~/.ngrinder/system.conf 에서 ip 미설정으로 인해 발생한 Setting of DNS provider failed 에러

  • 스크립트 실행 시, Setting of DNS provider failed 에러가 발생하면 해당 경로로 가서 설정 변경하자!
  • 혹시 아래와 같은 설정이 되어도 같은 현상이 반복된다면 java version 을 확인해보자. (java version = 1.8 or 11)
2024-03-29 02:47:01,707 ERROR Error running worker process
net.grinder.engine.common.EngineException: Setting of Local DNS provider failed
	at net.grinder.engine.process.GrinderProcess.<init>(GrinderProcess.java:154)
	at net.grinder.engine.process.WorkerProcessEntryPoint.run(WorkerProcessEntryPoint.java:78)
	at net.grinder.engine.process.WorkerProcessEntryPoint.main(WorkerProcessEntryPoint.java:60)
Caused by: java.lang.ClassNotFoundException: sun.net.spi.nameservice.NameService
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
	at java.base/java.lang.Class.forName0(Native Method)
	at java.base/java.lang.Class.forName(Class.java:421)
	at java.base/java.lang.Class.forName(Class.java:412)
	at org.ngrinder.dns.NameServiceProxy.set(NameServiceProxy.java:59)
	at net.grinder.engine.process.GrinderProcess.<init>(GrinderProcess.java:151)
	... 2 common frames omitted
controller.host=127.0.0.1

 

3. controller 실행 시, Djava.io.tmpdir 옵션 추가하라는 에러

  • controller 를 실행할 때 아래와 같은 메세지를 본다면 쿨하게 Djava.io.tmpdis 을 추가해주도록 하자.
ERROR
Please set `java.io.tmpdir` property like following. tmpdir should be different from the OS default tmpdir.
`java -Djava.io.tmpdir=${NGRINDER_HOME}/lib -jar ngrinder-controller.war`
java -Djava.io.tmpdir=/Users/Cooper/study/ngrinder/temp -jar

 

4. 그리고 만약 실행하고픈 프로젝트가 java version > 11 이라면 ?

ngrinder 는 3.5.9 버전 기준으로 Java 11 까지 지원한다. 만약 실행하고 싶은 프로젝트가 맥을 사용한다면 사용하고 싶은 Java version 의 bin 파일을 이용해서 실행해보자. (인텔리제이를 사용해서 그런걸 수도?)

  • 기준 경로 : /Users/[username]/Library/Java/JavaVirtualMachines/[자바 버전]/Contents/Home/bin/java
  • 예시 : /Users/[username]/Library/Java/JavaVirtualMachines/openjdk-21/Contents/Home/bin/java

 

실행 명령어

/Users/cooper/Library/Java/JavaVirtualMachines/openjdk-21/Contents/Home/bin/java -jar \
-javaagent:/Users/cooper/Cooper/study/pinpoint/pinpoint-agent-2.5.3/pinpoint-bootstrap-2.5.3.jar \
-Dpinpoint.agentId=member-api \
-Dpinpoint.applicationName=member-api \
-Dspring.profiles.active=local \
-Dpinpoint.config=/Users/cooper/Cooper/study/pinpoint/pinpoint-agent-2.5.3/pinpoint-root.config \
/Users/cooper/Cooper/study/infra-workshop/member/build/libs/member-0.0.1-SNAPSHOT.jar

'인프라 공방 > summary' 카테고리의 다른 글

nGrinder tutorial  (0) 2024.03.19
pinpoint simple summary  (0) 2024.03.08
[인프라공방] HTTP Cache  (0) 2024.02.28

가용성

  • 가용성은 시스템이 서비스를 정상적으로 제공할 수 있는 상태를 의미한다.
  • 가용성을 높이기 위해서는 SPOF(Single Point of Failure) 를 없애고 확장성 있는 서비스를 만들어야 한다.

 

1. 성능의 기준 3가지 : users, tps, time

users : 얼마나 많은 사람들이 동시에 사용할 수 있는지
tps : 일정 시간동안 얼마나 많이 처리할 수 있는지
time : 서비스가 얼마나 빠른지

 

[1] user

  1. concurrent user & active user
    • concurrent user : 계속해서 요청을 보내지 않는 사용자를 말한다. 즉, 웹 페이지를 그저 띄워두고 있는 사용자를 말한다.
    • active user : 계속해서 요청을 보내는 사용자를 말한다. 성능 테스트에서 VUser 는 Active User 와 유사하다.
  2. 유의 사항
    • 성능 테스트는 사용자의 요청이 증대되는 ramp-up 구간이 존재한다. 따라서 실제 트래픽 대응은 테스트 결과보다 더욱 보수적으로 구성하는 것을 권장한다. (보통은 peak time 의 1.5배, 서비스 성격, 비즈니스 임팩트에 따라 3배 넘게 목표 값을 설정하는 경우도 존재한다.)

 

[2] 처리량

  1. 처리량??
    • 장비 스펙의 한계로 더 이상 처리하지 못하는 지점이 발생한다. 서비스는 일정 시기까지는 사용자들에게 일정 수준의 응답 시간으로 서비스를 제공하지만 사용자가 많아져 처리해야 할 일들이 스펙에 비해 과할 경우 응답시간이 길어진다.
    • 서버가 처리할 수 없어 대기하는 작업들이 누적되면 응답시간이 급격히 증가한다. 이를 확인하기 위해 필요한 테스트가 스트레스 테스트이다. 스트레스 테스트는 성능의 임계점을 확인하기 위한 테스트이다.
  2. 처리량을 늘리기 위해서는 ??
    1. scale up & scale out
      1. 단일 사용자에 대한 응답속도 자체가 느리다면 scale out 하더라도 단일 서버의 스펙은 같으므로 유의미한 개선이 되지 않는다. 이 경우에는 scale up 하는 것이 합리적이다.
      2. 단일 사용자에 대한 응답속도는 괜찮지만 다중 사용자의 요청이 많을 때 응답속도가 느리다면 이 경우에는 scale out 을 통해 대응하는 것이 합리적이다.
    2. 무작정 서버를 늘린다고 처리량을 보장하지 않는다.
      1. WAS 가 증가하여 애플리케이션의 TPS 가 일시적으로 증가하는 듯 보이나 DB의 과부하로 인해 처리하지 못해 대기하는 쿼리가 발생할 수 있다. 이로 인해 지연이 발생하고 결국 TPS(Trasnsaction Per Second) 가 감소하는 현상이 발생한다. 
      2. 그러므로 각 구간별로 병목을 제거하고 성능을 개선해야 한다.

[3] 시간

  1. 사용자에겐 응답 시간(response time) 만 존재한다.
    • 사용자 중에서는 화면을 띄우고 있는 사용자도 존재하는데 이 때 화면을 띄우고 있는 시간을 think time 이라 부른다.
  2. request time, response time 은 client, network, server 등의 구간의 시간을 포함하고 있다.
    • request time
      • client(TTI 등)
      • network connection open
      • request(network)
    • response time
      • server response
      • response(network)
      • network connection close
      • client(browser rendering 등)
    • 성능 테스트로 주로 개선하는 부분은 server response 구간이다. 원인은 주로 서버 리소스 문제, 프로그램 로직상 문제, DB 혹은 서비스와의 연결 문제 등으로 지연이 발생한다.
  3. 진단 방법
    • network connection(인터넷 구간 지연) : 웹페이지 테스트, 페이지 테스트 등
    • server response (서버 구간 지연) : smoke test, load test, stress test
  4. 성능 테스트 진행 시 vuser 값을 통제한다. 이 때, 응답시간(time) 을 달성한다면, 대상 시스템은 목표한 처리량(TPS) 를 달성한다고 이해할 수 있다.

 

2. 성능 테스트

성능 테스트 종류 (smoke test, load test, stress test)

 

  1. smoke test : 최소한의 부하(VUser 1~2) 로, 테스트 시나리오 오류를 검증한다. 최소 부하상태에서 시스템의 오류(로직)가 없는지 확인한다.
    1. smoke test 에서 응답 시간을 달성하지 못하면 이후의 테스트를 진행하기 이전에, 진단 및 개선을 먼저 해야 한다.
  2. load test : 평소 최대 트래픽의 요청에서도 기능이 정상 동작하는지 검증한다.
    1. 서비스 최대 트래픽은 보수적으로 최대 트래픽의 1.5배 정도로 구성한다.
    2. 배포 혹은 인프라 변경(scale out, DB failover 등) 시 처리량 변화, 실패건수 등을 확인한다.
    3. 부하테스트는 30min ~ 2hr 등, QA 를 하는데 필요한 시간을 적절히 할당하도록 한다.
    4. 테스트 중 자원을 효율적으로 사용 여부에 관해서도 파악한다. (CPU/memory 등 리소스, thread 상태, connection 상태 등) 프로그램 로직상 알고리즘 개선, 데이터 캐시, 비동기 처리, 조회 성능 개선, 협력하는 서비스들 관계(서킷 및 다른 서비스에서의 지연) 등에 대한 조치를 하고 다시 테스트를 해보아야 한다
  3. stress test : 점진적으로 부하를 증가하도록 구성해 최대 사용자, 최대 처리량 등 한계점이 어디인지를 파악하는 테스트이다.
    1. 만약 각 구간에서 목표 응답시간을 모두 달성하고 있다면, 부하를 더 주어 한계점을 파악해야 한다.
    2. 스트레스 테스트를 통해 확인하고 싶은 정보는 기존에는 VUser 몇까지는 안정적으로 응답했으나, 개선 후 어느 지점까지 안정적으로 운영되었다는 기록을 남기도록 하자. 혹은 현재 몇 대의 서버로 rps 가 몇까지 가능한데, n대 증설시엔 rps가 몇까지 안정적으로 대응 가능하다.

 

[2] 성능 테스트 준비

성능 테스트 준비 과정 : 목표 값 설정 - 시나리오 작성 - 테스트 환경 구성 - 테스트 수행 및 결과 분석

 

 [1] 목표 값 설정

  • pinpoint 또는 scouter 등의 apm 도구를 사용하고 있다면 현재 시스템의 rps(response per second) 를 확인한다.
  • 신규 오픈 서비스로 수집된 정보가 없다면 경쟁사를 벤치마킹하여 DAU, peak time 집중률(최대 평소 트래픽), 1명당 1일 평균 요청 수 등의 정보를 찾아본다.
  • 조회 방법 : 해당 회사 포스트 또는 기사 내용 등등 (e.g. 지하철 노선 조회 : 카카오맵, 서울교통공사...) 설령 못 찾을 경우, 일단 가설을 세우고 테스트를 진행하도록 하자.
(1) 1일 사용자 수(DAU), 1일 평균 rps, 1일 최대 rps
   - DAU * 1명당 1일 평균 접속 수 = 1일 총 접속수
   - 1일 총 접속수 / 86400(seconds per day) = 1일 평균 rps
   - 1일 평균 rps * (최대 트래픽 / 최소 트래픽) = 1일 최대 rps

(2) VUser 설정
   - 특정 시나리오의 요청 수에 따른 응답 시간 = T (VU iteration) = (요청수 * http_req_duration) + a
   - 목표 rrps = (VUser * 요청 수) / 시나리오별 목표 응답시간(T)
   - VUser = (목표 rps * T) *  요청수

만약 시나리오에서 2번의 요청, 요청별 응답 시간 목표 값을 0.2s 라고 가정한다면
T = (2 * 0.2) + 0 = 0.4s
VUser = (1000 * 0.4) / 2 = 200

즉, VUser 값을 200 을 두고 테스트하여 대부분의 요청이 http_req_duration 이 0.2s 를 유지한다면 대상 시스템은 1000 rps 의 처리량을 보장한다고 생각할 수 있다.

 

[2] 시나리오 작성

  • 모든 시나리오를 검증하면 좋지만 테스트를 하는데 모두 비용이므로 현재 시스템이 안정적인 가정하에 접속 빈도가 높은 기능, 서버 리소스 소비량이 높은 기능, DB 사용 기능, 외부 시스템과 통신하는 기능을 위주로 테스트하자

[3] 성능 테스트의 어려움

  • 테스트 허들
    • 실제 사용자와 접속하는 환경과 유사해야 한다. 만약, 내부 네트워크에서 부하를 발생시킬 경우에는 네트워크 구간에 해당하는 시간이 누락된다.
    • 클라이언트 내부 처리시간 배제하면 안된다. 성능 테스트에서는 클라이언트 내부 처리시간이 배제되어 있다. 운영환경에서 서비스 요청 외에 별도로 수행되는 배치(cron job) 이나 후속 작업이 있다면 서버에 일정 부하를 주어 유사하게 구성하거나 테스트 시나리오에 포함해야 한다.
    • 결제 등으로 외부 요청을 하는 경우 시스템과 분리된 별도의 서버로 구성해야 한다. 이런 여러가지 구성이 어려울 경우, VUser 계산식의 a 값에 예상 지연시간을 두어 도출한다.
  • DB 데이터 수준
    • test db 의 경우, 데이터 양이 실제 운영 DB 와 동일한 수준이어야 한다.
      • 웹 서비스의 경우, 처리 성능의 상당 부분을 DB 조회에 영향을 받기 때문이다.
      • 데이터가 소량이라면 disk i/o 가 발생하지 않으므로 모두 메모리에서 로드되어 성능이 빠른 것으로 착각할 수 있다.
    • 추가적으로 test db 는 개인정보동의 항목이 아니므로, 스냅샷을 뜬다면 개인 정보는 모두 마스킹 처리를 해두어야 한다.
      • 테스트 환경 구성할 요소가 너무 많다면 최소한 test db 구성만큼은 해두어야 한다.
      • 저자는 나머지의 경우 alpha 로 생각해서 구성해야 한다고 생각한다.

 

 

이번 기록은 nGrinder 에 대해 알아보고 연습해보는 과정에 관한 기록이다. 

 

 

 nGrinder 는 jpython(python running on JVM) 으로 작성된 네이버에서 제공하는 오픈소스 부하 테스트 도구이다. 크게 Controller 와 Agent 로 구성되어 있어 여러 개의 동시 테스트가 가능하도록 여러 기능을 확장이 가능하고 TPS over VUser, Reliability over Accuracy 와 같은 철학을 가지고 있다. naver 에서 말하는 nGrinder 에서 제공하는 기능은 아래와 같다.

 

nGrinder 에서 이야기하는 기능 제공에 관한 내용을 번역한 내용
  • Jython 스크립트를 사용하여 테스트 시나리오를 생성하고 여러 에이전트를 사용하여 JVM에서 스트레스를 생성하세요.
    사용자 정의 라이브러리(jar, py)로 테스트를 확장하세요. 사실상 무제한입니다.
  • 프로젝트 관리, 모니터링, 결과 관리 및 보고서 관리를 위한 웹 기반 인터페이스를 제공합니다.
  • 여러 테스트를 동시에 실행하세요. 사전 설치된 여러 에이전트를 할당하여 각 에이전트의 활용도를 극대화합니다.
  • 여러 네트워크 리전에 에이전트를 배포합니다. 다양한 네트워크 위치에서 테스트 실행
  • 스크립트를 관리하기 위해 Subversion을 포함하세요.
  • 스트레스를 발생시키는 에이전트와 스트레스를 받는 대상 머신의 상태를 모니터링할 수 있습니다.
  • NHN에서 1억 명 이상의 사용자를 보유한 대규모 시스템을 테스트하는 데 사용된 검증된 솔루션입니다.

 

 

General Architecture

ngrinder general architecture

 

nGrinder 에서 주된 컴포넌트는 컨트롤러와 에이전트다. 컨트롤러는 에이전트를 관리하는 역할이다. agent 가 시작되면 controller 에 연결되며, controller 는 agent pool 형태로 구성된다. 테스트가 실행될 때 agent 를 제어하는 새로운 콘솔이 생성되고 필요한 에이전트 수가 AgentControllerServer 로 부터 전달된다. 해당 콘솔은 테스트 스크립트와 리소스를 할당된 여러 에이전트에 전송하고 테스트가 끝날 때까지 테스트 흐름을 제어하기 시작한다. 테스트가 완료되면 사용된 에이전트는 나중에 다른 테스트에서 사용할 수 있도록 AgentControllerServer 로 반환한다.

 

  • Controller
    • 테스트 수행을 위한 web interface 를 제공테스트 통계 수집에 대한 정보 조회할 수 있다.
    • 스크립트를 생성 및 수정해 테스트 프로세스를 조정할 수 있다.
  • Agent
    • target matchine 에 부하를 주는 프로세스 및 스레드를 실행한다. (running in agent mode)
    • 대상 시스템의 성능(CPU/memory) 모니터링 가능하다. (running in monitoring mode)

 

install nGrinder

편리하고 빠른 튜토리얼을 위해 docker container 를 사용해보았다. docker hub 에서 controller, agent 에 관한 이미지를 제공한다. 

 

[1] controller

# install ngrinder/controller image
$ docker pull ngrinder/controller:3.5.9

# start controller
docker run -d -v ~/ngrinder-controller:/opt/ngrinder-controller --name controller -p 80:80 -p 16001:16001 -p 12000-12009:12000-12009 ngrinder/controller:3.5.9
  • port
    • 80 : web UI port
    • 16001 : agent connection 을 위한 controller 포트
    • 9010-9019 : controller cluster 로 agent 와 연결하기 위한 포트
    • 12000-12029 : controller 가 stress test 를 할당하기 위한 포트

[2] agent

# Pull the ngrinder/agent image.
$ docker pull ngrinder/agent:3.5.9

# start agent
docker run -d --name agent --link controller:controller ngrinder/agent:3.5.9

 

 

각 컨테이너 설치가 완료된 후, controlller web UI 를 접근해본다.

nGrinder web UI

 

 

에이전트 관리

 

Performance Test

  • Agent : 사용하는 agent 수
  • Vuser per agent : 각 agent 가상 사용자 수에 프로세스 수와 스레드 수를 곱한 값
  • Processes : 각 에이전트가 시작하는 worker process 수
  • Threads : 각 worker process 가 시작해야 하는 worker thread 수
  • Script : 사용하는 script
  • Target Host : 대상 호스트
  • Duration : 테스트 실행 시간
  • Run Count : 테스트 실행 횟수
  • Ignore Sample Count : TPS 샘플링 중 무시할 샘플 수

(참고 :  https://github.com/naver/ngrinder/wiki/Test-Configuration )

 

Groovy Script Structure

  • @BeforeProcess : 프로세스가 호출되기 전에 실행되어야 하는 동작 정의 (예시 : 스레드가 공유할 resource file 로딩)
  • @AfterProcess : 프로세스가 종료된 이후에 동작할 내용 정의 (예시 : resource file 종료)
  • @BeforeThread : 각 스레드가 실행되기 이전에 수행할 작업 정의 (예시 : target system login, cookie)
  • @AfterThread : 각 스레드가 실행되기 이후에 수행할 작업 정의 (예시 : target system logout)
  • @Before : 매 @Test 실행되기 이전에 수행할 작업 정의 (예시 : @Test 실행 시점 이전에 공유되어야 할 로직)
  • @After : 매 @Test 실행된 이후 시점에 수행할 작업 정의 (예시 : 잘 사용하지 않음..)
  • @Test : 수행할 테스트 작업을 정의 (예시 : test body)

 

[1] sample script

  • language : groovy
  • 시나리오 : 로그인 이후, 자기 정보 확인
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
import net.grinder.scriptengine.groovy.junit.annotation.AfterThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

import groovy.json.JsonSlurper

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

	public static GTest me
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = [:]
	public static List<Cookie> cookies = []
	
	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		me = new GTest(1, "API /api/auth/me")
		
		request = new HTTPRequest()
		grinder.logger.info("before process.")
		
		//login (단일 사용자에 한하여 multi-thread 환경을 구성하기 위해 임의로 @BeforeProcess 에 구성
		HTTPResponse response = request.POST("[LOGIN URL]", [email : "id", password : "password"])
		
		grinder.logger.info("response body : {}", response.getBodyText())
		def responseBody = new JsonSlurper().parseText(response.getBodyText())
		
		headers.put("Content-Type", "application/json")
		headers.put("Authorization", "Bearer " + responseBody.accessToken)
	}

	@BeforeThread
	public void beforeThread() {
		me.record(this, "me")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}
	
	@Before
	public void before() {
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void me() {
		HTTPResponse response = request.GET("[request URL]")

		grinder.logger.info("headers : {}", headers)
		grinder.logger.info("body : {}", response.getBodyText())

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
}

 

 

[2] performance test config

  • VUser : 100 (process(1) * thread(100))
  • Agent : 1
  • Duration : 20s

[3] Result

performance test result

 

Reference

 

'인프라 공방 > summary' 카테고리의 다른 글

nGrinder 로컬 테스트 구성을 위한 에러 기록  (0) 2024.03.29
pinpoint simple summary  (0) 2024.03.08
[인프라공방] HTTP Cache  (0) 2024.02.28

+ Recent posts