no image
엘라스틱 스택 - Elasticsearch
엘라스틱 스택(or ELK 스택)은 애플리케이션 지표의 오류 문제 해결부터 로그 보안 위협 조사, 웹 사이트 및 애플리케이션의 검색 상자 구현에 이르기까지 다양한 사용 사례에서 사용됩니다. Elasticsearch, Kibana, Beats 그리고 Logstash로 구성된 엘라스틱 스택은 모든 종류에 대한 검색 및 분석을 위해 유연하고 다양한 도구를 제공합니다. 각각의 기술을 소개하자면 다음과 같습니다. Elasticsearch는 다양한 용도로 사용가능한 데이터 저장소이자, 전문 검색 엔진입니다. 대용량 데이터를 저장하고 검색과 집계 작업을 신속하게 처리할 수 있습니다. Kibana는 엘라스틱서치용 사용자 인터페이스입니다. 이를 통해 시각화 자료를 검색하고 엘라스틱 서치를 관리할 수도 있습니다. Beat..
2023.09.15
no image
Zero Copy는 어떻게 Kafka의 속도를 높일까
Apache Kafka에서는 "Zero Copy"라는 기술을 통해 효율적인 데이터 전송과 I/O를 이뤄낸다. 본격적으로 Zero Copy를 이야기하기 전에 일반적인 운영체제에서 커널 공간을 활용하여 데이터를 전송하는 과정을 살펴보자. 먼저, 애플리케이션의 요청에 따라 디스크에서 데이터를 커널 읽기 버퍼에 복사한다. 첫 번째 복사는 디스크에서 파일 내용을 읽고 커널 주소 공간 버퍼에 저장하는 DMA(엔진)에 의해 수행된다. 애플리케이션이 요청이 이뤄지는 시점에서는 User mode였으나, DMA를 통해 복사가 이뤄지는 시점에서는 Kernel mode이다. 즉, 컨텍스트 스위치가 일어난다. (1 번의 복사, 1 번의 컨텍스트 스위치) 이후 데이터를 커널 공간에서 애플리케이션 읽기 버퍼로 복사한다. (1 번의..
2023.08.08
no image
L7 프로토콜 - HTTP
Application Layer, L7 먼저 Application Layer라고도 불리는 L7은 유저와 네트워크 간의 인터페이스 역할을 수행하는 계층이다. 해당 계층에서는 애플리케이션 및 사용자 간의 통신과 데이터 전송 방식을 다양한 방식으로 정의하게 되는데, 이를 프로토콜이라고 부르게 된다. L7 내에는 매우 다양한 프로토콜이 존재한다. 가장 대표적으로는 웹 애플리케이션의 통신을 가능하게 하는 HTTP와 HTTPS부터, 파일 전송에 활용되는 FTP, 이메일 메세지를 전송하는 SMTP, 이메일 수신에 사용되는 IMAP/POP3, google.com을 해석하게 해주는 DNS, 초기 주소 할당시에 사용되는 DHCP, 터미널 접속 시 사용되는 SSH 등 굵직한 프로토콜들이 L7 프로토콜로 분류된다. 모든 프로..
2023.08.08
CS
no image
IPv4에서 IPv6로의 전환 매커니즘
몇 일전 면접에서 받았던 질문이다. "그럼 IPv4에서 IPv6로의 전환은 어떻게 가능할까요? IoT 솔루션의 경우에는 많은 경우에서 IPv6를 사용하는 걸로 알고 있는데 혹시 이 부분에 대해서 답변 주실 수 있나요?" 워딩은 정확하진 않지만, 대략적으로 이런 워딩의 질문을 받았던 것으로 기억한다. 생각지도 못한 질문이였고, 솔직히 말해서 그게 가능하겠구나라는 생각도 면접장에서 처음 했었다. 사실 너무나 당연하게도 IPv4 주소체계를 사용하는 머신이 있고 IPv6 주소체계를 사용하는 머신이 있다면, 어떤 프로토콜을 사용하고 DHCP에서 어떻게 주소를 받아왔는지만 다르지 두 머신의 연결이 불가능할 이유는 당연히 없다. 질문 자체도 생각할 여지가 많았지만 그리 잘 답변하지는 못했던 것 같다. 그럼에도 이 질..
2023.08.06
CS
no image
게으른 로깅
로깅은 프로그래밍에서 가장 중요한 것 중 하나입니다. 기록을 하는 것은 비단 프로그래밍 뿐 아니라 다른 작업에서도 중요하지만 휘발성으로 계속해서 새로운 작업이 발생하는 프로그래밍에선 그 의미가 더욱 남다릅니다. 해당 글에서는 파이썬에 로그를 기록하는 방법을 간단히 소개하며 파이썬을 사용한다면 일상적으로 쉽게 구현할 수 있는 게으른 로깅 (Lazy logging)이라는 모범 사례를 소개합니다. 파이썬 로깅 본격적으로 게으른 로깅을 설명하기 앞서 간단하게 파이썬에서의 로깅을 이해해보겠습니다. 하단의 코드는 로깅을 수행하는 가장 간단한 코드입니다. warning이라는 로그 레벨과 함께 함께 출력될 메세지를 전달하고 있습니다. >>> import logging >>> logging.warning("Hello l..
2023.07.29
no image
DAG 작성
💡 이번 글에서는 DAG가 어떻게 구성되어 있는지를 중점으로 기본적인 Airflow의 사용방법을 소개합니다. 데이터 셋 본격적으로 실습에 들어가기 앞서 이번 실습에 사용할 데이터 셋을 소개합니다. 하단의 링크에서는 우주와 관련된 다양한 데이터를 오픈 API 형태로 제공합니다. 하루 요청 제한(시간 당 15 call) 하에서 누구나 사용가능하며 로켓의 발사, 엔진의 테스트, 우주 비행사의 기록 등과 같은 신기한 정보들을 쉽게 제공합니다. https://thespacedevs.com/llapi TheSpaceDevs - Home A group of space enthusiast developers working on services to improve accessibility of spaceflight in..
2023.07.09
no image
Airflow의 도입 배경
데이터 파이프라인을 구성하다보면 근거 없는 자신감이 들 때가 있다. 그럴싸한 아키텍처를 설계하고 나면 이러이러한 서비스를 뚝딱뚝딱 이어붙이면 문제 없이 동작하겠지라는 생각이 사실 매번 드는 것 같다. 그러나 어디까지나 이상이고 현실은 다르다. 데이터 파이프라인은 많은 이유로 실패한다. 문제 상황도 아주 제각각인데, 데이터의 소스가 매우 다양하기에 발생하는 문제들, 오픈소스 호환성 때문에 발생하는 문제들, 데이터 파이프라인들 간에 의존도에 대한 이해가 부족하기에 발생하는 문제들 등 수많은 문제가 그렇다. 특히나 데이터 소스 간에 의존성이 발생하기 시작한다면 더욱 문제는 복잡해진다. 예를 들어 마케팅 채널 정보의 업데이트가 안된다면 다른 모든 정보들의 업데이트가 이뤄지지 않는 상황에서는 마케팅 채널 정보와 ..
2023.06.28
no image
Airflow의 DAG
DAG (Directed, Acyclic Graph) DAG는 파이프라인을 실행하기 위한 단순한 알고리즘을 제공해준다는 의의를 가진다. 아래의 날씨 대시보드 파이프라인에서는 방향성 그래프 표현을 통해 전체 파이프라인을 직관적으로 표현하고 있다. 이때 DAG를 구성하는 파이프라인 요소 각각은 Node라는 이름 대신 태스크라고 불리게 되며, 보다 직관적인 의존관계 파악이 가능해진다. DAG의 또 다른 특징은 Cycle이 존재하지 않는다는 점이다. 위의 그림에서 볼 수 있 듯 두 개의 태스크 간에 Cyclic 한 의존 관계가 생기는 순간 해당 DAG는 끝까지 진행될 수 없다. 태스크 2를 실행하기 위해서는 태스크 3의 실행이 전제되어야 하지만, 태스크 3의 실행은 반대로 태스크 2의 실행을 전제한다. 때문에 ..
2023.06.28

엘라스틱 스택(or ELK 스택)은 애플리케이션 지표의 오류 문제 해결부터 로그 보안 위협 조사, 웹 사이트 및 애플리케이션의 검색 상자 구현에 이르기까지 다양한 사용 사례에서 사용됩니다. Elasticsearch, Kibana, Beats 그리고 Logstash로 구성된 엘라스틱 스택은 모든 종류에 대한 검색 및 분석을 위해 유연하고 다양한 도구를 제공합니다.

각각의 기술을 소개하자면 다음과 같습니다.

  • Elasticsearch는 다양한 용도로 사용가능한 데이터 저장소이자, 전문 검색 엔진입니다. 대용량 데이터를 저장하고 검색과 집계 작업을 신속하게 처리할 수 있습니다.

  • Kibana는 엘라스틱서치용 사용자 인터페이스입니다. 이를 통해 시각화 자료를 검색하고 엘라스틱 서치를 관리할 수도 있습니다.

  • Beats는 다양한 소스 시스템에서 데이터를 수집하고 Elasticsearch나 Logstash로 전송할 수 있습니다.

  • Logstash는 ETL 도구로서 다양한 소스의 데이터를 수집 후 가공해 Elasticsearch에게 제공해주는 역할을 담당합니다.

 


흔히들 많이 알고있는 ELK 스택은 엘라스틱 스택에서 Beats를 포함하지 않은 아키텍처를 의미합니다.
BeatsLogstash는 외부의 데이터 소스로부터 데이터를 수용(Ingest)하는 역할을 담당하는데 각자 가지는 강점이 있습니다. 만약 Beats를 사용하지 않는 아키텍쳐의 경우 ELK 스택이라고도 부를 수도 있겠습니다. 다만, Elasticsearch를 서비스하는 Elastic에서는 ELK라는 이름보단 엘라스틱 스택(Elastic Stack)이라는 이름으로 명명하기를 원하는 듯 합니다.

 

엘라스틱 스택의 진화


1. 루씬(Lucene)이라는 오픈소스 검색 엔진을 활용하여 Compass라는 트랜젝션 OSEM(Object/Search Engine Mapping)이 개발되었습니다. (https://github.com/kimchy/compass)

2. 그러나 Compass는 인프라 확장 면에서 한계를 보였습니다. 이 한계를 극복하기 위해 Elasticsearch라는 분산 검색 엔진이 개발되었고, JSON 기반의 HTTP RESTful API로 구현되어 자바 외의 다른 언어로도 쉽게 상호작용이 가능하게 되었습니다.

3. 이후, Logstash가 등장했습니다. 초기에 Logstash는 여러 데이터 목적지 중 하나로 Elasticsearch를 지원하였습니다.

4. Elasticsearch의 데이터 시각화를 위해 Kibana가 개발되었습니다. Kibana는 Elasticsearch의 REST API를 활용하여 데이터 검색 및 시각화를 중개하는 인터페이스를 제공합니다.

5. Elasticsearch를 관리하는 Elastic에서 Elasticsearch에 대한 클라우드 솔루션을 출시하였습니다.

6. 네트워크 패킷 데이터 수집에 특화된 Packetbeat라는 오픈소스 도구가 등장했으며, Packetbeat는 Elasticsearch를 위한 전용 솔루션으로 설계되었습니다. Packetbeat에 이어 다양한 유형의 데이터 처리를 위한 Beats들이 등장하였고, 이는 Beats 프로젝트로 확대되었습니다.

7. Elasticsearch에 있는 데이터에 대한 이상 탐지 유스케이스를 지원하기 위해 머신러닝 기능이 Elasticsearch와 Kibana에 추가되었습니다.

8. APM(Applicaton Performance Monitoring) 기능이 Elastic Stack에 통합되었습니다.

9. Kibana는 SIEM(Security Information and Event Management) 기능의 일부로 보안 분석 기능을 갖게 되었습니다.

10. 일종의 Elasticsearch의 확장팩 역할을 수행하는 X-Pack이 오픈소스화되었습니다.

11. EDR(Endpoint Detection and Response) 기능이 Elastic Stack에 추가되었습니다. EDR과 SIEM을 중심으로 한 보안 솔루션을 제공하게 되었습니다.

12. 웹 사이트와 애플리케이션, 내부 사이트에서 바로 사용가능한 검색 기능이 Enterprise Search 솔루션에 추가되었습니다.


Elasticsearch의 주요한 특징

  • 방대한 양의 데이터를 효과적으로 검색, 분석, 집계하는데 활용됩니다.

  • JSON 형태의 데이터를 저장하는 문서 기반의 NoSQL 저장소입니다.

  • 별도의 고정 스키마를 요구하지 않습니다.

  • Apache Lucene을 기반으로 하며, 이 위에 분산 처리와 다양한 기능을 추가하여 클러스터링, 복제 및 샤딩 같은 기능을 제공합니다.

  • 서로 IOPS 사양이 다른 노드 간에 데이터를 이동시켜, 느린 디스크 드라이브의 비용 절감 효과와 더 빠른 성능 효과를 동시에 얻을 수 있습니다.


프라이머리 샤드와 레플리카 샤드

1. 프라이머리 샤드 (Primary Shard)

  • 인덱스 생성 시점에 정의되며, 이후에는 프라이머리 샤드의 수를 변경할 수 없습니다.

  • 실제 데이터의 Write (CRUD 작업) 연산이 이 샤드에서 이뤄집니다.

  • 인덱스의 모든 데이터를 보유하므로, 대량의 데이터를 효과적으로 관리하기 위해서는 샤드의 수를 적절하게 분산하는 것이 필요합니다.

  • 프라이머리 샤드가 손실될 경우 (예: 노드의 다운), 해당 샤드의 레플리카는 프라이머리 샤드로 승격될 수 있습니다.

2. 레플리카 샤드 (Replica Shard)

  • 프라이머리 샤드의 복사본으로, 프라이머리 샤드의 데이터를 동기화하여 유지합니다.

  • 주로 Read 작업을 위해 사용되며, 실패한 노드의 프라이머리 샤드를 대체할 수도 있습니다.

  • 여러 레플리카를 통해 읽기 처리량을 늘릴 수 있습니다. 레플리카가 여러 개 있으면 동시에 여러 읽기 요청을 처리할 수 있게 됩니다.

  • 레플리카는 데이터의 무결성과 복구 능력을 강화하기 위해 사용됩니다. 프라이머리 샤드가 손실되거나 노드에 문제가 발생할 경우, 레플리카 샤드는 프라이머리로 승격되어 시스템의 안정성을 보장합니다.

 

레플리카 샤드는 READ 작업을 처리할 수 있지만, 실제 데이터의 Write 작업은 오직 프라이머리 샤드에서만 이루어집니다.

프라이머리 샤드에서 Write 작업이 완료되면, 변경사항은 레플리카 샤드로 비동기적으로 복제됩니다. 이로 인해 장애가 발생해도 데이터의 안정성이 보장됩니다.

Elasticsearch에서의 샤드의 복제는 상당히 어려운 주제입니다. 아래에서는 그림들을 통해 간단하게 Elasticsearch에서 데이터의 안정성을 어떻게 보장하는 간략하게 설명합니다.

5개의 프라이머리 샤드와 복제본이 4개의 노드에 분산되어 저장된 예

 

노드가 1개만 있는 경우 프라이머리 샤드만 존재하고 복제본은 생성되지 않습니다. Elasticsearch 는 아무리 작은 클러스터라도 데이터 가용성과 무결성을 위해 최소 3개의 노드로 구성 할 것을 권장하고 있습니다.

 

Node-3 노드가 유실되어 0번, 4번 샤드가 다른 노드에 복제본을 새로 생성한 예

 

프라이머리 샤드가 유실된 경우에는 새로 프라이머리 샤드가 생성되는 것이 아니라, 남아있던 복제본이 먼저 프라이머리 샤드로 승격이 되고 다른 노드에 새로 복제본을 생성하게 됩니다.

 

노드가 3개로 줄었을 때도 전체 데이터 유지

 

노드의 확장 뿐만 아니라 노드가 감소하는 경우에도 위의 예시처럼 전체 샤드 수를 유지하게 됩니다. 그 과정에서 레플리케이션 샤드의 승격과 삭제될 노드에 위치한 샤드의 이동과 같은 추가적인 작업이 수행됩니다.


다양한 IOPS를 가진 노드를 통한 비용 최적화

Elasticsearch에서는 디스크 성능과 관련된 가격 편차를 최적화하여 이용할 수 있습니다. 일반적으로 IOPS(입출력 연산 속도)가 높은 디스크는 그 성능에 비례하여 더 비싼 가격이 책정됩니다. 반대로 IOPS가 낮은 디스크는 상대적으로 저렴하게 구매할 수 있습니다.

Elasticsearch는 이런 특성을 활용하여 데이터를 효율적으로 관리합니다. 구체적으로, 자주 사용되지 않는 데이터나 장기간 보관되어야 하는 데이터는 성능이 낮아도 되는 저렴한 디스크(Cold Storage)로 이동시킬 수 있습니다. 반면에 자주 접근되거나 중요한 데이터는 성능이 높은 고가의 디스크(Hot Storage)에 위치시킬 수 있습니다.

이렇게 데이터의 성격과 용도에 따라 다른 스토리지 계층으로 이동시키는 전략을 사용함으로써, 더 비싼 저장 공간에 위치할 필요가 없는 'Cold Data'를 저렴한 'Cold Storage'로 옮기는 등의 최적화를 통해 전반적인 저장 비용을 효과적으로 절감할 수 있습니다.


역인덱스 (Inverted Index)

Elasticsearch는 현대의 데이터 기반 애플리케이션에서 빠른 검색 및 분석 능력을 제공합니다. 이러한 빠른 검색 능력은 "역인덱스"를 통해 달성할 수 있습니다.

Elasticsearch는 대량의 데이터에서 원하는 정보를 실시간으로 빠르게 검색하는 기능을 제공해야 합니다. 전통적인 데이터베이스의 인덱싱 방식만으로는 수백만, 수십억의 문서에서 특정 단어나 구문을 즉시 찾아내기 어렵습니다. 여기서 역인덱스의 역할이 중요하게 나타납니다.


빠른 검색

 

전통적 검색 방식에서는 문서를 하나씩 확인하며 원하는 키워드가 포함되는지 여부를 검사합니다. 이렇게 하면 문서의 수가 많아질 수록 검색하는 데 시간이 기하급수적으로 증가하게 됩니다.

그런데 역인덱스를 사용하면, 우리가 원하는 키워드에 대한 정보(어떤 문서들에 해당 키워드가 포함되어 있는지)를 미리 정리해놓기 때문에, 검색 시 해당 키워드에 대한 문서들을 즉시 알 수 있습니다. 즉, 문서의 수에 관계없이 일정한 시간 내에 원하는 키워드를 포함하는 문서들을 찾아낼 수 있게 됩니다.


공간 효율성

 

전통적인 방법으로 각 문서에 어떤 단어들이 포함되어 있는지를 저장하려면, 각 문서마다 모든 단어의 목록과 그 위치를 저장해야 합니다. 이렇게 되면 데이터의 중복이 상당히 많아지게 됩니다.

그러나 역인덱스를 사용하면, 단어별로 어떤 문서들에 포함되어 있는지를 저장하게 됩니다. 예를 들어 "Elasticsearch"라는 단어가 1,000개의 문서에 포함되어 있다면, "Elasticsearch"라는 항목 아래에 해당 문서의 ID만 저장하면 됩니다. 이렇게 하면 각 단어에 대해 한 번씩만 정보를 저장하므로, 공간을 효율적으로 사용할 수 있습니다.


역인덱스 테이블의 예시

 

예를 들어 다음 3개의 문서가 Elasticsearch에 저장되어 있다고 가정합니다.

I love Elasticsearch.
Elasticsearch is powerful.
People love powerful tools.

 

역인덱스는 각 토큰(단어 혹은 용어)을 기준으로 해당 토큰이 어떤 문서에 위치하는지를 지정합니다. 기존의 전통적인 인덱싱 방식은 문서를 중심으로 해당 문서에 어떤 단어들이 있는지를 리스트화하는 구조를 가집니다. 반면, 역인덱스는 이와 정반대의 구조를 가지며, 각 토큰을 중심으로 해당 토큰이 어떤 문서들에 위치하는지를 매핑합니다.

이러한 구조의 장점은 특정 단어나 구문을 검색할 때 해당 단어나 구문이 어떤 문서에 있는지 바로 알 수 있어서 검색이 매우 빠르게 수행될 수 있습니다.

즉, 역인덱스는 '어떤 단어가 어떤 문서들에 포함되어 있는가?'를 효율적으로 파악하기 위한 데이터 구조입니다. 이를 통해 검색 시스템은 사용자의 쿼리에 해당하는 문서들을 빠르게 찾아낼 수 있습니다.

예를 들어 "love" 라는 단어를 검색하게 되면, 역인덱스 구조를 통해 Elasticsearch는 해당 토큰이 문서 1과 문서 3에 포함되어 있음을 빠르게 파악할 수 있습니다. 이처럼 역인덱스는 검색 쿼리가 들어올 때 해당 키워드가 포함된 문서를 즉시 알아낼 수 있게 해주므로, 매우 빠른 검색 성능을 제공합니다.

토큰(Token) 문서 ID (Document IDs)
----------------------------------------------------------------------- -----------------------------------------------------------------------
I 1
love 1, 3
Elasticsearch 1, 2
is 2
powerful 2, 3
People 3
tools 3

 

 


참고. 위의 테이블을 전통적 방식의 인덱스로 표현한다면

 

문서 ID (Document IDs) 토큰(Token)
----------------------------------------------------------------------- -----------------------------------------------------------------------
1 I, love, Elasticsearch
2 Elasticsearch, is, powerful
3 People, love, powerful, tools

 

전통적인 인덱싱 방식과의 비교

데이터베이스에서 주로 사용되는 전통적인 인덱스는, 주로 B-tree, B+-tree, Hash 등의 구조를 사용합니다. 이때 인덱스를 사용하는 주된 목적은 특정 키 값을 기반으로 연관된 값을 빠르게 찾아내는 것입니다. 이는 범위 검색이나 정렬된 데이터의 검색에 유용합니다. 전통적인 인덱스의 경우에는 키 값을 기반으로하는 조회작업에 대해서 빠른 속도를 보장합니다. 단, 텍스트 기반 검색에 대해서는 역인덱스만큼의 최적화되어있지 않기 때문에, 복잡한 쿼리나 풀 텍스트 검색에 대해서는 비교적 느린 속도를 보입니다.

텍스트 기반의 검색에 대해서는 역인덱스 구조가 전통적 인덱싱 방식에 비해 많은 이점을 가집니다. 역인덱스 구조 자체가 대량의 텍스트 검색에 특화된 인덱싱 방식이라고 이해할 수 있겠습니다. 또한, 텍스트를 기반으로 한 데이터 구성 시, 역인덱스는 메모리 효율성 면에서 큰 장점을 가지게 됩니다. 이전에 제시한 텍스트 문서를 역인덱스 방식과 전통적인 인덱스 방식으로 표현한 예를 통해 이를 명확히 파악할 수 있습니다. 전통적인 인덱스 방식에서는 중복된 단어로 인해 데이터 저장 구조의 중복이 불가피합니다. 반면, 역인덱스에서는 각 단어(토큰)를 키로 활용해 해당 단어가 포함된 문서의 ID를 매핑합니다. 이는 방대한 양의 데이터를 저장함에 있어 메모리 측면에서 매우 강력한 이점으로 작용합니다.

결론적으로, 전통적인 인덱싱과 역인덱싱은 각각 특별한 장점을 가지고 있습니다. 전통적인 인덱스는 정렬된 데이터나 범위 검색 같은 특정 케이스에서 뛰어난 성능을 발휘합니다. 이와는 대조적으로, 역인덱싱은 텍스트 기반의 검색에서 매우 효과적이며, 대량의 문서나 데이터를 처리할 때 그 진가를 발휘합니다. 특히, 복잡한 쿼리나 풀 텍스트 검색에 있어서, 역인덱싱은 전통적인 인덱싱 방식보다 월등히 빠른 성능을 보여줍니다. 이는 역인덱싱이 텍스트 데이터를 토큰화하여 그 토큰들의 출현 위치나 빈도 등을 효율적으로 관리하기 때문입니다.

때문에 대용량의 텍스트 정보를 신속하게 검색하거나 분석하는 서비스, 예를 들면 Elasticsearch와 같은 검색 엔진이나 로그 분석 서비스에서는 역인덱스의 활용이 필수적으로 보입니다.


Elasticsearch의 한계

1.  관계형 데이터의 처리가 어렵다.

Elasticsearch는 RDB의 "조인" 같은 연산을 지원하는 메커니즘을 제공합니다. 그러나 Elasticsearch가 기본적으로 분산 시스템 위에 구축되어 있기 때문에, 전통적인 RDB의 조인만큼의 효율적인 성능을 기대하기는 어렵습니다.

  • Nested Type: 'nested' 타입은 문서 내에서 배열 형태의 복잡한 객체 정보를 저장하고, 이러한 내부 객체 간의 관계를 기반으로 "조인"과 유사한 조회를 실행할 수 있게 해줍니다. 즉, 하나의 문서 안에서 객체 간의 깊은 관계를 표현하고 조회하는 데 유용합니다.

  • Parent-Child Relationship: 이 관계를 이용하면, 한 문서를 '부모'로 설정하고, 다른 문서를 그 '자식'으로 지정할 수 있습니다. 이런 방식으로 구성된 문서 간의 관계는 상호 연관성을 가지며, 이를 기반으로 한 검색 쿼리가 가능해집니다.


  • Join Field: RDB의 조인 연산을 모방한 것입니다. 다양한 타입의 문서들을 공통 필드를 통해 연결하고, 이 연결된 관계를 통해 효과적인 검색을 수행할 수 있게 도와줍니다.

하지만, Elasticsearch에서 조인 연산을 너무 자주 사용하는 것은 권장되지 않습니다. 왜냐하면 분산 시스템의 특성 상 조인 연산을 수행하기 위해서는 RDBMS에 비해 추가적인 처리와 함께 부하를 야기하는 원인으로 작용할 수 있습니다. 따라서 가능한 데이터를 비정규화하여 조인의 필요성을 줄이는 전략을 취하는 것이 성능 향상에 더욱 도움이 됩니다.


2.  트랜젝션 단위의 ACID 보장이 어렵다.

Elasticsearch는 기본적으로 요청 단위의 ACID 특성을 지원하되, RDBMS처럼 사용자가 직접 트랜잭션을 정의하거나 제어할 수는 없습니다. 

또한 분산처리 저장소로서 Elasticsearch는 ACID를 보장하는 방식도 기존의 RDBMS보다는 NoSQL과 유사한 측면이 있습니다. 다음은 Elasticsearch에서 ACID를 어떻게 보장하는지에 대한 설명입니다.

원자성(Atomicity)

쓰기 요청은 모든 활성화된 샤드에 전달됩니다. 이러한 요청은 모든 샤드에 성공적으로 기록되거나, 어느 하나의 샤드에서라도 실패하면 전체 요청이 실패합니다.

일관성(Consistency)

Elasticsearch와 같은 분산 검색 및 분석 엔진에서의 일관성은 쓰기 작업 후 모든 노드나 샤드에서 동일한 데이터를 조회할 수 있는 상태를 말합니다. 여기서의 일관성이란 "최종적 일관성(eventual consistancy)"을 의미합니다. 이는 전통적인 RDBMS의 "강한 일관성 (strong consistancy)"와는 살짝 다른 의미로 사용됩니다. 

"최종적 일관성"이란, 어떠한 변경 작업 후에 시스템의 모든 노드나 샤드가 결국에는 동일한 데이터 값을 갖게 된다는 것을 의미합니다. 즉, 변경이 발생하고 나서 즉시 모든 노드나 샤드가 동일한 값을 가질 수는 없지만, 어느 정도의 시간이 지나면 모든 샤드가 동기화되어 동일해집니다.

예를 들어 다음의 상황을 생각해봅시다.

1. 사용자가 샤드 A에 쓰기 요청을 보냅니다.

2. A는 해당 쓰기 요청을 처리하고 성공적으로 완료되었다는 응답을 사용자에게 반환합니다. 

3. 이때, 새로운 유저 B와 C가 등장합니다. 유저 B, C는 샤드 A에 대해서 읽기 요청을 보냅니다. 단, 유저 B의 요청은 프라이머리 샤드에서 유저 C의 요청은 레플리카 샤드에서 처리됩니다.

4. 아직 프라이머리 샤드와 레플리카 샤드 간의 동기화 작업이 아직 일어나지 않아, 프라이머리 샤드에만 최신 내용이 기록되어있다고 가정합니다. 그렇다면 유저 B의 응답은 최신 내용을 담고 있겠으나, 유저 C의 요청은 최신 이전의 내용을 담게 됩니다. 

5. 그 이후 Elasticsearch 클러스터 내 샤드 A에 대한 동기화 작업이 백그라운드로 진행됩니다. 프라이머리 샤드에 반영된 최신 데이터가 레플리카 샤드들로 동기화되기 시작합니다.

6. 이번에도 동일하게 유저 B와 C가 샤드 A에 대해서 읽기 요청을 보냅니다. 마찬가지로 우연히 유저 B의 요청은 여전히 프라이머리 샤드에서 유저 C의 요청은 레프리카 샤드에서 처리되었다고 가정하겠습니다.

7. 동기화 작업이 완료되었기에, 두 유저가 받는 데이터는 동일합니다. 즉 모든 샤드에서 동일한 데이터를 조회할 수 있게 됩니다.

결론적으로 Elasticsearch는 "강한 일관성"을 보장하지는 않지만, 시간이 지나면 모든 샤드가 동일한 상태를 갖게되는 "최종적 일관성"을 보장합니다.

참고. 대략적인 쓰기 요청의 처리 과정

1. 쓰기 요청: 사용자로부터 인덱스, 업데이트, 삭제 등의 쓰기 요청이 Elasticsearch 클러스터로 전송됩니다.


2. 트랜잭션 로그 (Translog)에 기록: 쓰기 요청이 받아지면, 해당 변경 사항은 트랜잭션 로그 (또는 Translog)에 기록됩니다. Translog는 쓰기 요청이 빠르게 기록되도록 돕는 장치로, 빠른 응답 시간을 보장하는데 중요한 역할을 합니다.


3. 버퍼에 데이터 저장: 쓰기 요청은 메모리 버퍼에 임시로 저장됩니다.


4. 리프레시: refresh_interval 설정에 따라 (기본적으로 1초마다) Elasticsearch는 메모리 버퍼에 있는 데이터를 새로운 Lucene segment로 만듭니다. 이 시점에서 데이터는 검색 가능해집니다. 그러나 아직 데이터는 디스크에 영구적으로 저장되지 않았습니다.


5. 커밋: 주기적으로, 또는 조건에 따라 Elasticsearch는 Lucene segment를 디스크에 영구적으로 저장합니다. 이 때 Translog에서 해당 데이터에 대한 기록도 삭제됩니다.


6. 머지: 시간이 지나면서 여러 개의 작은 Lucene segment들은 큰 segment로 병합(머지)되기도 합니다. 이는 디스크 공간을 효율적으로 활용하고 검색 성능을 향상시키기 위한 작업입니다.


독립성(Isolation)

각 RESTful 요청 간에는 독립성이 보장됩니다. 이는 곧, 여러 요청이 동시에 수행된다고 했을때 각 요청끼리는 독립적으로 작용한다고 이해할 수 있겠습니다. 단, 이는 Elasticsearch만의 고유한 기능이라고 볼 수는 없으며 RESTful API 기반의 통신을 수행한다면 당연히 발생하는 특징(statelessness)이라고 이해하였습니다. 

 

지속성(Durability)

문서의 변경 사항은 즉시 Lucene Segment에 반영되지 않습니다. 대신, 이러한 변경 사항은 "translog"에 먼저 기록됩니다. 만약 어떠한 장애가 발생한다면, 이 "translog"를 통해 데이터를 복구할 수 있게 됩니다.

"translog"는 Elasticsearch에서 데이터의 지속성(Durability)을 보장하기 위해 사용하는 메커니즘 중 하나입니다. Lucene Segment는 변경 불가능하기 때문에, 문서가 새로 추가되거나 수정될 때마다 바로 Segment에 반영하는 것은 효율적이지 않습니다. 그래서, 이러한 변경 사항은 일단 translog에 먼저 기록됩니다.


이 translog는 Lucene의 Segment와는 다르게 변경이 가능합니다. 따라서 데이터가 변경될 때마다 빠르게 translog에 쓸 수 있습니다. 그리고 주기적으로, 또는 설정된 조건에 따라 메모리에 있는 변경 사항들과 translog에 있는 변경 사항들이 디스크의 Lucene Segment에 반영(flush)됩니다.


만약 Elasticsearch 노드에 장애가 발생하면, 가장 최근의 Lucene Segment 상태로 복구한 다음 translog에 남아있는 변경 사항을 적용함으로써 데이터의 일관성을 유지할 수 있습니다. 이렇게 함으로써, Elasticsearch는 데이터의 손실 없이 복구가 가능합니다.

추가적으로 Lucene Segment의 특징을 다음과 같이 정리할 수 있습니다.

Write segment

Lucene의 Segment는 변경이 불가능한(immutable) 데이터 구조입니다. 이는 한 번 생성된 Segment는 수정되거나 삭제될 수 없다는 것을 의미합니다.

새로운 문서가 추가되거나, 기존 문서가 수정/삭제될 때마다 바로 해당 Segment 파일에 반영되는 것이 아닙니다. 대신, 수정/삭제는 새로운 Segment를 생성하여 이를 반영하고, 원본 문서는 "삭제" 표시만 되는 방식으로 동작합니다.

여러 변경 작업들이 메모리에 쌓인 후 일정한 시점이나 크기에 도달하면, 그 변경들은 새로운 Segment로 디스크에 플러시(flush)됩니다.

Merging segment

시간이 지남에 따라 많은 작은 Segments가 생길 수 있습니다. 이렇게 되면 검색 성능에 부정적인 영향을 줄 수 있습니다.
Lucene는 백그라운드에서 자동으로 이런 작은 Segments를 큰 Segment로 병합하는 작업을 수행합니다. 이 과정을 "Segment Merging"이라고 합니다. 병합 과정에서 "삭제" 표시된 문서들은 실제로 제거되며, 최종적으로 하나의 큰, 최신 Segment가 생성됩니다.

Update segment

새로운 데이터가 쓰여질 때나, 기존 데이터가 수정될 때마다 Segment가 바로 업데이트되는 것은 아닙니다. 일반적으로는 변경 작업들이 일정한 버퍼(메모리)에 축적되고, 이 버퍼가 특정 크기에 도달하거나 일정 시간이 경과하면 해당 변경들이 Segment로 디스크에 플러시됩니다. Elasticsearch의 경우, refresh_interval 설정으로 이 시점을 조절할 수 있습니다. 기본값은 1초입니다.

Elasticsearch과 같은 분산처리 시스템에서는 "optimistic concurrency control" 이라는 추가적인 메커니즘을 통해 보다 높은 수준의 일관성을 보장을 지원합니다. 이때 해당 매커니즘이 적용되면 각 요청이 특정 버전의 문서를 참조하도록 하여, 동시에 여러 쓰기나 업데이트 요청이 있을 경우, 해당 버전의 문서에만 영향을 주게 됩니다.

https://www.elastic.co/guide/en/elasticsearch/reference/current/optimistic-concurrency-control.html

 

Optimistic concurrency control | Elasticsearch Guide [8.10] | Elastic

Optimistic concurrency controledit Elasticsearch is distributed. When documents are created, updated, or deleted, the new version of the document has to be replicated to other nodes in the cluster. Elasticsearch is also asynchronous and concurrent, meaning

www.elastic.co


만약 클라이언트가 업데이트하려는 문서의 버전과 Elasticsearch에 저장된 문서의 버전이 다르면, 업데이트 요청은 거부됩니다. 보다 자세한 내부 동작 방식은 다음과 같습니다.

버전 관리(Versioning): Elasticsearch의 버전 관리는 문서와 샤드, 두 레벨에서 발생합니다. 먼저 문서의 경우 "if_seq_no"를 통해 optimistic concurrency control을 수행합니다. 이때 "seq_no"는 "sequence number" 즉 순서를 기록한 번호를 의미하며 이는 쓰기 작업을 통해 문서의 생성, 수정, 삭제 등의 변화가 발생할 때마다 증가합니다.

동시에 모든 샤드는 "if_primary_term"을 통해 샤드 레벨에서 버전을 관리합니다. 이때 "primary_term"은 프라이머리 샤드의 변화가 발생하였을 경우 증가하게 됩니다. 이때의 변화라 함은 기존의 레플리카 샤드가 프라이머리 샤드로 승격되는 등의 동작을 의미합니다. 이때 "primary_term"은 프라이머리 샤드가 계속 바뀌더라도 샤드의 일관성을 보장하는 데 주요한 역할을 수행합니다.

PUT products/_doc/1567?if_seq_no=362&if_primary_term=2
{
  "product": "r2d2",
  "details": "A resourceful astromech droid",
  "tags": [ "droid" ]
}

 

해당 요청에서는 "seq_no"와 "primary_term"의 값이 현재 Elasticsearch 내부의 값과 일치하는 경우에만 작업이 수행됩니다. 만약 일치하지 않으면 해당 작업은 실패합니다.

Elasticsearch에서는 If_seq_no와 if_primary_term을 동시에 활용하여, 동시적으로 문서를 업데이트하는 경우 각 문서들을 올바른 순서로 관리하고 기존의 업데이트 내용을 실수로 덮어쓰는 등의 불상사를 방지해줍니다.

실제로 Elasticsearch에 GET 요청을 통해 다음과 예시와 같은 응답을 받을 수 있는데, seq_no와 primary_term이 포함되어 있음을 확인할 수 있습니다.

{
  "_index": "my-index-000001",
  "_id": "0",
  "_version": 1,
  "_seq_no": 0,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "@timestamp": "2099-11-15T14:12:12",
    "http": {
      "request": {
        "method": "get"
      },
      "response": {
        "status_code": 200,
        "bytes": 1070000
      },
      "version": "1.1"
    },
    "source": {
      "ip": "127.0.0.1"
    },
    "message": "GET /search HTTP/1.1 200 1070000",
    "user": {
      "id": "kimchy"
    }
  }
}

 

마치며

읽기와 쓰기 요청을 처리하는 Elasticsearch의 방식이 흥미로웠습니다. 배우기에 다소 난이도가 있지만, 이는 분산처리 솔루션이기에 발생하는 특징으로 다른 분산처리 데이터베이스를 학습할 때도 동일하게 발생하는 어려움이라고 생각합니다.

앞으로 Elasticsearch의 보다 구체적인 사용방법과 그 원리를 설명하는 글을 작성해볼 수 있을거 같습니다.

아래의 글은 Elasticsearch의 읽기와 쓰기가 어떻게 처리되는지에 대한 꽤나 자세한 내용을 담고 있는 공식 문서입니다. 이해에 도움이 되시면 좋겠습니다.

https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-replication.html#basic-write-model

 

Reading and Writing documents | Elasticsearch Guide [8.10] | Elastic

Reading and Writing documentsedit Introductionedit Each index in Elasticsearch is divided into shards and each shard can have multiple copies. These copies are known as a replication group and must be kept in sync when documents are added or removed. If we

www.elastic.co

 

 

Apache Kafka에서는 "Zero Copy"라는 기술을 통해 효율적인 데이터 전송과 I/O를 이뤄낸다. 본격적으로 Zero Copy를 이야기하기 전에 일반적인 운영체제에서 커널 공간을 활용하여 데이터를 전송하는 과정을 살펴보자.

 

 

  • 먼저, 애플리케이션의 요청에 따라 디스크에서 데이터를 커널 읽기 버퍼에 복사한다. 첫 번째 복사는 디스크에서 파일 내용을 읽고 커널 주소 공간 버퍼에 저장하는 DMA(엔진)에 의해 수행된다. 애플리케이션이 요청이 이뤄지는 시점에서는 User mode였으나, DMA를 통해 복사가 이뤄지는 시점에서는 Kernel mode이다. 즉, 컨텍스트 스위치가 일어난다. (1 번의 복사, 1 번의 컨텍스트 스위치)

  • 이후 데이터를 커널 공간에서 애플리케이션 읽기 버퍼로 복사한다. (1 번의 복사, 1 번의 컨텍스트 스위치)

  • 다시 데이터를 사용자 공간에서 다시 커널 공간의 소켓 버퍼로 복사한다. 복사된 데이터는 소켓 버퍼에 임시로 저장된다.
    (1 번의 복사, 1 번의 컨텍스트 스위치)

  • 마지막으로 데이터를 커널 공간의 소켓 버퍼에서 커널 공간의 네트워크 버퍼로 전송한다. (1 번의 복사)

     

강조하였듯, 운영체제의 전통적인 방식으로 데이터를 네트워크를 통해 전송하거나 데이터 I/O를 수행한다면 중복된 복사가 일어난다. 디스크에서 데이터를 네트워크를 통해 전송해야 한다면, 먼저 해당 데이터는 커널 버퍼로 복사된다. 그 후, 일정 작업을 수행한 이후 데이터는 애플리케이션의 사용자 공간 버퍼로 다시 복사된다. 이후 데이터는 네트워크를 통해 전달되기 위해 커널 공간의 네트워크 버퍼로 복사된다. 간략하게 표현하였지만, 눈에 띄는 복사 작업이 3번이나 발생했다. 


중복 복사는 기본적으로 CPU 오버헤드와 비교적 큰 메모리 대역폭을 사용한다. CPU의 경우에는 실제 애플리케이션 로직 처리보다 데이터를 복사하는데 더 많은 자원을 소모하기도 하며, 복사 작업을 수행하기 위해 전송하고자 하는 데이터의 양보다 훨씬 더 많은 메모리가 필요하기도 한다. 그리고 복사를 위해서 메모리에 I/O 작업을 수행해야 하는데, 이는 데이터 전송에 더 많은 작업을 소요하게 만들고 전체 시스템의 반응 속도를 느려지게 하는 원인으로 동작한다.


Zero Copy 기술은 이러한 비효율성을 최소화하기 위해 개발되었다. Zero Copy의 핵심 아이디어는 데이터의 실제 복사를 최소화함으로써 전체 경로의 비효율성을 줄이는 것이다. 이때 그림을 잘 살펴보면 "도대체 왜 유저 공간에 복사를 해야하지?"라는 궁금증이 들 수 있다. Zero Copy는 이러한 비효율을 제거한다. 


Zero Copy는 비단 Kafka만의 솔루션은 아니다. Java 클래스 라이브러리는 java.nio.channels.FileChannel에 위치한 transferTo() 메서드를 통해 Linux와 UNIX 시스템에서 제로 복사를 지원한다. Zero Copy를 사용하는 애플리케이션은 데이터를 User Space에 올리지 않고 디스크 파일에서 소켓으로 곧바로 복사하도록 커널에 요청한다. 이는 복사 횟수와 컨텍스트 스위치의 횟수의 감소로 이어진다. 그림을 통해 살펴보자.





그러나 여전히 Copy는 총 3번에 걸쳐 일어나며 이를 Zero Copy라고 부르기에는 무리가 있다. 


특정 네트워크 인터페이스 카드에서는 gather 작업을 지원한다. gather 작업은 파일의 내용이 여러 메모리 페이지에 걸쳐 분산되어 있을 때, 각 페이지의 주소와 크기에 대한 정보만을 NIC에 전달하면 이 정보를 기반으로 직접 메모리에서 데이터를 가져오는 형태로 네트워크에 데이터를 전송한다. 이 경우 중간에 데이터를 소켓 버퍼로 다시 복사하지 않아도 되므로 복사 작업이 줄어들게 된다. 문제는 각 페이지에 대한 주소와 크기에 대한 정보를 전달하는 방식인데, 이는 Linux 커널의 2.4 버전에서 지원하는 소켓 버퍼 스케터리스트(Socket Buffer Scatterlist)를 통해 가능하다. 소켓 버퍼 스케터리스트는 메모리의 여러 조각들에 대한 정보를 담고 있다. 스케터리스트에는 고유한 데이터 구조를 가지고 있는데, 이 데이터 구조에는 각각의 데이터 조각의 시작 주소와 크기에 대한 정보가 담긴다. 때문에 메모리에 분산되어 있는 여러 데이터 조각들을 연속적으로 저장하거나 접근할때 스케터리스트는 굳이 별도의 소켓 버퍼에 모든 데이터를 저장하지 않고 메타데이터만을 담는 정도로 복사 작업을 간소화시킨다. 




특히 NIC가 DMA 작업으로 인해 쪼개진 데이터들에 대해서 gather 작업을 지원한다면, 스케터 정보에 담긴 정보들을 바탕으로 NIC는 메모리에 여러 위치에 분산된 네트워크 조각들을 직접 읽어올 수 있다. 앞서 Read Buffer에서 Socker Buffer로의 복사는 CPU를 거치게 되는데, 이 과정을 없앰으로써 CPU의 관여를 없앤다. 특히나 그 이외의 작업들의 경우 DMA를 통해서 데이터가 전송되는데, DMA의 목적 자체가 데이터 전송과정에서 CPU의 개입을 최소화하는 것인 만큼, Zero Copy를 달성하게 되면 CPU의 개입이 현저하게 줄어들게 된다.


그림의 transferTo 메서드 또한 위의 설명을 따른다. 우선 맨 처음의 그림에 따라 유저 공간으로의 전환은 일어나지 않는다. 이때 추가적으로 소켓 버퍼에 대한 복사를 제거하는데 이는 소켓 버퍼 Descriptor(Scatter, 엄밀하게 말하면 다르다)에 의해 가능해진다. NIC의 경우 gather 기능을 가지고 있어야 하는데, gather 기능이 있다면 NIC는 Read Buffer 상의 페이지에 산발적으로 위치한 데이터들을 Descriptor에 기록된 각 페이지들의 시작 주소와 크기 등을 기반으로 DMA를 거친 데이터를 모아서 네트워크 카드에 전송하게 된다.

 

'Data Engineering' 카테고리의 다른 글

Kafka 환경에서의 Zookeeper의 Cluster Coordination  (0) 2023.05.18
Kafka와 Zookeeper의 상호작용  (0) 2023.05.18
Zookeeper의 znode  (0) 2023.05.18
Coursera 데이터 엔지니어링 강의 목록  (0) 2022.12.08
Spark  (0) 2022.11.20

L7 프로토콜 - HTTP

mydailylogs
|2023. 8. 8. 03:39

Application Layer, L7


먼저 Application Layer라고도 불리는 L7은 유저와 네트워크 간의 인터페이스 역할을 수행하는 계층이다. 해당 계층에서는 애플리케이션 및 사용자 간의 통신과 데이터 전송 방식을 다양한 방식으로 정의하게 되는데, 이를 프로토콜이라고 부르게 된다. L7 내에는 매우 다양한 프로토콜이 존재한다. 가장 대표적으로는 웹 애플리케이션의 통신을 가능하게 하는 HTTP와 HTTPS부터, 파일 전송에 활용되는 FTP, 이메일 메세지를 전송하는 SMTP, 이메일 수신에 사용되는 IMAP/POP3, google.com을 해석하게 해주는 DNS, 초기 주소 할당시에 사용되는 DHCP, 터미널 접속 시 사용되는 SSH 등 굵직한 프로토콜들이 L7 프로토콜로 분류된다.  


모든 프로토콜을 전부 다뤄볼 수는 없을 것 같고 오늘은 이 중 HTTP를 집중적으로 살펴보고자 한다.

 

HTTP (HyperText Transfer Protocol)


웹에서 데이터를 전송하는데 사용되는 프로토콜이다. 주로 HTML 문서나 이미지와 같은 웹 리소스의 전송에 사용되지만, 다양한 종류의 데이터를 전송하는데 활용될 수도 있다. 다음은 HTTP의 주요한 특징이다.


  • Stateless: HTTP는 별도의 세션 연결을 사용하지 않는다. 즉, 각 요청은 독립적이며 서버 측에서는 클라이언트와의 이전 정보를 기억하지 않는다. 때문에 서버에서는 사용자의 상태를 유지하기 위해 쿠키나 세션과 같은 테크닉을 사용하게 되었다.


  • Request/Response 모델: HTTP는 클라이언트와 서버 간의 요청과 응답을 기반으로 수행된다. 클라이언트(e.g. 웹 브라우저)는 서버에 특정 리소스를 요청하고 서버는 해당 리소스를 찾아 응답으로 반환한다.


  • Method: HTTP는 다양한 메서드(or "Verb")를 제공한다. 주요 메서드로는 GET, POST, PUT, DELETE가 있다.

  • Versioning: HTTP는 시간이 지남에 따라 여러 버전이 개발되었다. (e.g. HTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3)


  • Header: HTTP 요청과 응답에는 '헤더'가 포함되어 있다. 헤더는 요청 및 응답에 대한 메타데이터를 포함하며, 캐싱, 인코딩, 인증 등의 다양한 목적으로 사용된다.


  • Non-securable: 기본 HTTP는 암호화되지 않은 텍스트로 데이터를 전송하기 때문에 중간에서 데이터를 가로채는 공격에 취약하다(Man in the Middle Attack). 이를 해결하기 위해 HTTPS(SSL/TLS를 사용하는 HTTP)가 도입되었다.


  • Port: 기본적으로 80 포트를 사용한다.

 

각각의 특성들을 조금 더 자세히 이해해보자.


Stateless



Stateless라는 단어의 의미는 다르게 말해서 각각의 요청이 서로 독립적이며, 서버가 클라이언트의 이전 요청에 대한 정보를 저장하지 않는다는 것을 의미한다. 즉, 한 번의 클라이언트 요청과 이에 대한 서버의 응답이 전송되면 서버는 그 연결에 대한 모든 정보를 잊어버린다. 이렇게 설계된 이유는 원래 웹이 정적인 문서를 공유하기 위해 만들어진 시스템이기 때문이다. 클라이언트가 서버에 문서를 요청하면, 서버는 해당 문서를 찾아 응답으로 보내고 그 연결을 종료한다. 이러한 시나리오에서는 굳이 클라이언트의 이전 상태를 기억할 필요가 없다.


그러나 웹이 발전하면서 사용자들은 로그인, 장바구니, 그 외의 다양한 개인화된 페이지 등 상태를 유지해야 하는 다양한 기능들을 웹 환경에서 사용할 수 있게 되었고, 이에 대한 기술적 요구사항을 만족시키기 위해서 쿠키와 세션이라는 기술들이 도입되었다.


쿠키(Cookies)는 클라이언트 측에서 정보를 저장하는 작은 텍스트 파일이다. 쿠키는 먼저 웹사이트에 의해서 클라이언트(웹 브라우저)에 전송된다. 그 후 웹 브라우저는 쿠키를 통해 자신의 정보들을 웹 사이트에 보내는 형태로 웹사이트에게 자신의 상태를 전달한다. 즉 쿠키를 통해 서버는 Stateless한 패킷을 처리하기에 부담이 경감되지만, 클라이언트 쪽의 자원을 소모하여 Stateful한 것처럼 클라이언트 정보를 관리할 수 있게 된다.


다만 쿠키의 경우에는 클라이언트 측에 저장되기 때문에 보안 상 문제가 발생할 수 밖에 없다. 당장 유저만 하더라도 웹 브라우저의 개발자 도구를 통해서도 쿠키의 내용을 살펴보거나 수정할 수 있다. 이와 관련한 주요한 보안 문제로는 XSS(크로스 사이트 스크립팅), CSRF(크로스 사이트 요청 위조)가 있다.


세션(Session)은 서버 측에서 사용자 정보를 저장하는데 사용한다. 앞서 설명한 쿠키에 세션 ID를 첨부하여 전달하는 형태로 특정 사용자의 세션을 추적할 수 있게 된다. 이후 클라이언트의 모든 요청에는 서버로부터 전달받은 세션 ID가 포함된다. 서버는 이 세션 ID를 통해 사용자를 인식하고 상태를 유지할 수 있게 된다.


HTTP는 분명 Stateless한 프로토콜이지만 앞서 설명한 쿠키을 통한 세션 관리를 통해 마치 Stateful한 시나리오도 처리할 수 있게 되었다.


참고. XSS & CSRF


먼저, XSS는 대략 Stored XSS, Reflected XSS, DOM-based XSS 3종류로 나뉜다.


Stored XSS는 공격자가 악의적인 스크립트를 웹 애플리케이션의 DB에 삽입하는 것으로 이뤄진다. 예를 들어 댓글 기능이 있는 웹 사이트에서 사용자가 악성 스크립트를 포함한 글을 작성하게 되는 경우 다른 사용자가 그 댓글을 확인할 때마다 악성 스크립트를 실행하는 형태로 전개될 수 있다.


Reflected XSS는 사용자에게 악의적인 URL을 클릭하도록 유도하는 것으로 이뤄진다. 이때 말하는 URL에는 악의적인 스크립트가 내장되어 있으며 해당 URL을 클릭하면 웹 애플리케이션은 해당 스크립트를 실행시키는 형태로 공격이 전개된다. 주로 피싱 공격에 사용되는 것으로 알려져있다.


DOM-based XSS는 웹 애플리케이션의 클라이언트 측 스크립트가 DOM(Document Object Mode)의 구조나 속성을 변경하는 경우 발생한다. 공격자는 악의적인 입력을 통해 DOM을 수정하여, 수정된 스크립트가 실행되게 하는 것으로 공격을 전개한다.


이에 대한 가장 기본적인 방어 방법은 결국, 서버 측에서 들어오는 사용자 입력에 대해 신뢰를 하지 않는 것이다. 모든 입력에 대해 의심하고 검증하며 출력 시에는 특수 문자를 적절하게 인코딩해야 한다. 또한 콘텐츠 보안 정책(CSP)를 적절하게 설정하는 것으로도 어느 정도의 방어가 가능하다. CSP를 통해 웹 브라우저에게 특정 소스에서만 콘텐츠를 로드하도록 지시한다면, 어느정도는 공격자가 악의적인 스크립트를 삽입하는 것을 막을 수 있다. 


마지막으로, XSS를 방어하는 대표적인 방법으로 알려진 HttpOnly 플래그 설정이 있다. 특히 쿠키를 사용하여 공격을 전개하는 경우 효과적인데, HttpOnly 플래그가 설정된 쿠키의 경우 JavaScipt에서 접근을 막아버린다. 때문에 해당 스크립트가 동적으로 실행될 여지를 막는다는 점에서 XSS 공격을 통한 쿠키 도용을 방지한다.


이어서 CSRF는 사용자가 로그인 상태에서 악의적인 웹 사이트를 방문하게 되면, 그 사이트가 사용자 몰래 다른 사이트에 요청을 보내도록 하는 공격이다. 예를 들어 사용자가 은행 웹 사이트에 로그인한 상태에서 CSRF 공격이 포함된 악의적인 웹 페이지를 방문하면 그 페이지는 사용자의 은행 계좌에서 돈을 전송하는 요청을 은행 웹 페이지에 보낼 수 있다. 이는 SameSite 플래그를 설정하는 것으로 어느정도 방지가 가능하다.


예를 들어 mybank.com이라는 정상적인 은행 사이트가 있고, 악의적인 malicious.com이라는 사이트가 있다고 하자.

  1. 사용자는 은행 웹 사이트에 로그인하여 사용자의 브라우저에 로그인 상태를 나타내는 쿠키를 전송한다.


  2. 이후 사용자가 악의적인 웹사이트 (malicious.com)에 방문한다.


  3. malicious.com에서는 사용자의 브라우저를 이용하여 mybank.com에 악의적인 요청을 보내려고 시도한다.


  4. 이때 사용자의 브라우저가 이전에 설정된 mybank.com의 로그린 쿠키를 함께 전송하려고 시도한다.


  5. 만약 은행 웹 사이트 쿠키에 SameSite 플래그가 설정되어 있지 않다면 쿠키는 요청과 함께 전송된다. 은행의 웹사이트는 마치 악의적인 사이트에게서 발생한 요청이 사용자에게서 온 것처럼 착각하게 되며, 이는 CSRF 공격으로 이어진다.

이때 SameSite 플래그가 쿠키에 설정되어 있다면 mybank.com에서 발생한 쿠키는 오로지 mybank.com에 대한 요청에만 사용될 수 있다. malicious.com에서는 사용자가 은행과의 연결에 사용된 쿠키를 확인하고 싶어하지만, SameSite 플래그가 설정되어 있다면 해당 쿠키는 오로지 mybank.com 도메인과의 연결에서만 첨부되기에 malicious.com과의 요청에서는 해당 쿠키가 활용되지 않는다.

 

 Request/Response 모델



간단하게 설명하면, 클라이언트가 원하는 정보나 서비스를 요청하면 서버는 그에 따른 적절한 응답을 반환하는 방식을 의미한다.

Request (요청)



클라이언트(웹 브라우저)가 웹 서버에 특정 정보나 서비스를 요청하는 것을 의미한다.


요청 메서드: HTTP에는 여러 요청 메서드가 정의되어 있으며, 대표적인 메서드로는 GET, POST, PUT, DELETE 등이 있다.


  • GET

    • 정보를 조회하기 위한 요청이다.

    • 웹 페이지의 내용이나 이미지 등의 리소스를 요청하는 경우 주로 사용된다.

    • 데이터를 변경하는데 사용되어서는 안된다.

    • e.g. 웹 브라우저 주소창에 URL을 입력하고 페이지를 로드하는 경우, 검색 엔진이 웹 페이지를 크롤링하는 경우


  • POST

    • 정보를 서버에 전송하기 위한 요청이다.

    • 새로운 리소스를 생성하거나 기존 리소스에 데이터를 추가할 때 사용된다.

    • 다만, 서버의 데이터나 상태가 변경되는 상황에 주의해야 한다.

    • e.g. 회원가입, 로그인, 댓글 작성, 파일 업로드 등의 동작


  • PUT

    • 특정 리소스를 수정하거나 대체할 때 사용된다.

    • PUT 요청을 사용하여 리소스를 업데이트할 경우, 해당 리소스는 기존 데이터에서 완전 새로운 데이터로 대체된다.

    • 때문에 부분적인 업데이트를 원하는 경우, PUT은 권장되지 않는다.
      (e.g. 사용자 정보 중 '주소'만 변경하고 싶은 경우, '이름'과 '전화번호' 등의 기존 정보도 사라질 수 있다.)

    • e.g. 사용자 프로필 수정, 게시물 내용 수정


  • PATCH

    • 특정 리소스의 일부만 수정하는 경우 사용된다.

    • PUT 요청과 달리 리소스의 전체 내용을 대체하는 것이 아니라, 특정 부분만 변경한다.

    • e.g. 사용자 정보 중 '주소'만 변경하고 싶은 경우


  • HEAD

    • 헤더만을 가져오고 싶은 경우 사용된다.

    • 리소스를 가져오는 GET 요청과 유사하지만, 본문(Body)를 제거한다는 특징이 있다.

    • e.g. 리소스의 변경 변경 여부 확인, 리소스의 크기 확인, 지원하는 HTTP 메서드 확인하고 싶은 경우


  • OPTIONS

    • 특정 URL에 대해 어떤 HTTP 메서드가 허용되는지를 알아보기 위해 사용된다.

    • e.g. CORS(Cross-Origin Resource Sharing) 설정 검사 등에서 사용된다.

 

Response(응답)

서버가 클라이언트의 요청에 대한 결과를 반환하는 것을 의미한다. 요청의 처리 결과에 대한 상태코드, 응답 헤더, 응답 본문으로 구성된다.


상태 코드: 서버가 클라이언트의 요청을 어떻게 처리했는지 나타내는 코드

  • e.g. 200 OK는 성공적인 응답, 404 Not Found는 요청한 리소스를 찾을 수 없는 경우를 나타낸다.


헤더: 응답에 대한 메타데이터를 담고 있다. 콘텐츠의 유형(MIME), Set 쿠키, 서버 정보등의 내용을 포함할 수 있다.


본문: 요청한 리소스의 내용. HTML 페이지, 이미지, 동영상 등의 데이터를 포함할 수 있다.


이때 헤더 정보는 요청 메세지와 응답 메세지 모두에 들어가는 내용으로  해당 메세지의 메타데이터를 담는다.



HTTP 헤더


HTTP 헤더를 5가지 종류로 분류할 수 있다. 각 종류와 해당 헤더에는 어떤 종류들이 들어갈 수 있는지 알아보자


  1. General Headers: 요청과 응답 양쪽에서 모두 사용될 수 있는 헤더이다.

    • Cache-Control: 캐싱 동작을 제어한다.

    • Date: 메시지가 생성된 날짜와 시간을 나타낸다.

    • Connection: 네트워크 연결에 대한 제어를 위한 지시를 의미한다.


  2. Request Headers: 클라이언트에서 서버로의 요청에 사용되는 헤더이다.

    • Accept: 클라이언트가 이해할 수 있는 컨텐츠의 유형을 명시한다.

    • Host: 요청 대상의 도메인 이름을 명시한다.

    • User-Agent: 요청을 생성하는 클라이언트의 정보를 나타낸다. (e.g. 브라우저, 버전 등)


  3. Response Headers: 서버에서 클라이언트로의 응답에서만 사용된다.

    • Server: 응답을 생성하는 웹 서버의 정보이다.

    • Location: 3xx 응답의 경우, 클라이언트가 이동해야할 URL이다.

    • WWW-authenticate: 401 Unauthorized 응답에 사용되며, 클라이언트에게 어떤 인증 방식을 사용해야 하는지 알린다.


  4. Entity Headers: 요청이나 응답의 본문에 대한 정보를 포함한다.

    • Content-Type: 본문의 미디어 유형 (e.g. 'text/html', 'application/json' 등)

    • Content-Length: 본문의 길이 (Byte 단위)

    • Content-Encoding:  본문이 어떻게 인코딩되는지 (e.g. gzip)


  5. Custom Headers: 표준화된 헤더 외에 개발자는 필요에 따라 사용자 정의 헤더를 추가할 수 있다.

    • 일반적으로 'X-' 접두사를 사용한다. (e.g. X-Requested-With)

    • 현대의 애플리케이션의 경우 굳이 접두사를 붙이지 않고 사용자 정의를 생성하지 않기도 한다.

       

HTTP 요청을 보내는 경우 필요에 따라 여러 헤더들을 모두 포함하여 보낼 수 있다.


예를 들어 POST 요청을 통해 JSON 데이터를 서버에 보낼 때 User-Agen와 Host와 같은 요청 헤더와 함께 'Content-Type: application/json', 'Content-Length'와 같은 엔티티 헤더를 동시에 포함하여 보낼 수 있다.

 

HTTP의 역사 (버전 별 변화 내용)

 

HTTP의 경우 1996년의 초기 버전 HTTP/1.0에서부터 2019년 이후 초안이 등장한 HTTP/3에 이르기까지 꾸준히 새로운 버전을 업그레이드하며 기존의 한계와 문제점을 개선하고 있다. 


해당 챕터에서는 각각의 HTTP 버전을 이해하고, 서로 비교하는 데에 초점을 둔다.


먼저 기본적으로 HTTP/1.0 방식의 커뮤니케이션에서는 각 요청에 대해 새로운 TCP 연결을 생성되며 요청이 완료되는 경우 생성된 TCP 연결이 바로 닫히는 형태로 구현되었다. 이는 Stateless라는 HTTP의 설계 원칙에는 부합하였으나, 만약 여러 요청이 있을 때 매번 TCP의 Handshake 과정을 수반하기에 연결을 설정하고 해제하는 과정에서 오버헤드를 야기했다. 뿐만 아니라 HTTP/1.0을 설계할 당시에는 효율적이였던 솔루션들보다 더욱 효율적으로 연결을 수행할 수 있는 방법들이 생성되었다. 1997년에 정의된 HTTP/1.1은 이러한 내용에 대한 개선을 담고 있다.


HTTP/1.1이 HTTP/1.0에 비해 어떤 장점을 갖는지, 하나하나 따져보자


HTTP/1.0 vs HTTP/1.1


지속적인 연결 (Persistent Connection)

  • 여러 요청을 하나의 TCP 연결에서 처리할 수 있게 되었다.

  • General Header에 'Connection: Keep-alive' 헤더를 사용하여 연결을 열린 상태로 유지할 수 있게 되었다.

 

파이프라이닝 (Pipelining)

  • 지속적인 연결을 통해 클라이언트가 이전 요청의 응답을 기다리지 않고도, 연속적으로 여러 요청을 보낼 수 있게 되었다.

  • 그러나 응답은 순서대로 받아야한다는 점에는 변화가 없다.

 

캐싱 메커니즘 개선


ETag, If-Modified-Since, If-Unmodified-Since, If-None-Match 등의 헤더를 도입하여 캐싱 기능이 크게 향상되었다. 이러한 헤더들은 웹 브라우저나 중간의 캐시 저장소(e.g. proxy caches)에 최신 버전의 웹 컨텐츠가 있는지, 굳이 새로운 버전을 가져올 필요가 있는지 등을 판단하는데 도움이 된다. 이를 통해 네트워크 대역폭을 절약하고, 불필요한 데이터 전송을 줄여 웹의 전체적인 퍼포먼스를 개선할 수 있었다.


아래는 앞서 소개한 HTTP/1.1의 추가된 헤더의 목록들이다.


  • Etag(Entitiy Tag): 리소스의 버전을 식별하는 고유한 문자열
    • 서버는 리소스가 변경된다면 해당 값을 업데이트해야 한다.
    • 클라이언트가 마지막으로 받은 버전과 현재 서버의 버전 간에 차이가 있는지 비교하는데 활용된다.
    • ETag를 활용하면 리소스의 내용이 실제로 변경이 이뤄졌는지 여부를 확인할 수 있으므로, 불필요한 데이터 전송을 피할 수  있다.


  • If-Modified-Since: 마지막으로 리소스를 받은 이후의 날짜/시간을 지정한다.
    • 서버는 이 날짜 이후에 리소스가 수정되었는지를 확인한다.
    • 만약 수정이 없었다면, 304 Not Modified 응답을 반환하여, Body는 보내지 않는다.


  • If-Unmodified-Since: 직전과 반대로 특정 날짜 이후 리소스가 수정되지 않은 경우만 요청을 수락하도록 서버에 지시한다.
    • 리소스가 해당 날짜 이후에 수정되었다면 서버는 412 Precondition Failed 응답을 반환한다.


  • If-None-Match: 요청 헤더에서 ETag 값과 함께 사용되며, 클라이언트가 가진 리소스의 ETag 값과 서버의 리소스 Etag 값이 일치하지 않을때만 리소스를 전송하도록 서버에 요청한다.
    • ETag 값 간에 불일치가 발생한다면, 서버는 304 Not Modified 응답을 반환하여 불필요한 데이터 전송을 피한다.

 

호스트 헤더(Host Header)의 등장


HTTP/1.1에는 호스트 헤더('Host')가 추가 되었다. 호스트 헤더는 각 HTTP 요청이 어떤 웹사이트를 타겟으로 하는지를 명시한다.


예를 들어 A라는 사람이 동시에 운영하는 example.com과 another-example.com이라는 사이트가 있다고 하자.  HTTP/1.1에는 호스트 헤더가 추가되어, 헤더에 'Host: example.com' 혹은 'Host: another-site.com'이라는 정보를 담는다. 이를 통해 해당 요청이 어떤 사이트를 대상으로 수행되는지를 알 수 있다.


그럼 왜 호스트 헤더라는 개념이 추가되어야 했을까? 바로 가상 호스팅이라는 개념을 도입할 수 있기 때문이다.


가상 호스팅은 하나의 IP 주소에 여러 도메인을 연결하는 기술이다. HTTP/1.0에서는 하나의 DNS 주소에는 하나의 IP 주소가 매핑되어야 했다. 때문에 A가 2개의 사이트를 운영하기 위해서는 2개의 IP가 필요했다. 


HTTP/1.1에서는 굳이 2개의 IP 주소 없이도 2개의 DNS 주소를 사용할 수 있다. 요청 헤더에 목표로 하는 DNS 호스트 이름을 명시하게 되면 굳이 IP 주소를 통해 목표를 식별할 필요없이 호스트 헤더를 통해 식별이 가능해진다.


앞의 예시에서 A는 123.123.123.13이라는 가상의 IP 주소를 사용한다고 가정하자. 해당 주소는 example.com과 another-site.com라는 두 사이트 모두를 호스팅한다. 대신 사용자가 요청을 보낼때, 123.123.123.13이라는 주소에 'host:example.com'이라는 호스트 헤더를 포함한 요청을 보낸다. 서버는 호스트 헤더를 활용하여 해당 요청이 어떤 사이트를 대상으로 하는지를 알 수 있다. 이를 가상 호스팅이라고 부른다.


가상 호스팅은 결국 IP 주소의 경감으로 이어진다. 또한 굳이 각 웹사이트마다 별도의 서버나 IP 주소를 사용할 필요가 없기에 호스팅 비용을 절감할 수 있다.


1997년 첫 HTTP/1.1 버전이 정의된 이래로 HTTP/1.1은 위의 개선 사항들을 포함하는 변화들을 이어왔다. 그러다 2015년 경 중요한 변경점들과 함께 HTTP/1.1 대신 HTTP/2.0이라는 새로운 이름을 갖게 되었다. 



HTTP/1.1 vs HTTP/2.0


바이너리 기반 데이터 교환


먼저, HTTP/1.1까지의 데이터 교환은 텍스트 기반이였다. 개발자는 굳이 별도의 도구 없이도 디버깅 도구를 통해 메세지를 읽을 수 있는 것이 당연하다고 여겨왔다. HTTP/2.0의 메세지는 바이너리 기반이다. 인코딩이라는 추가적인 작업을 요구하지만, 바이너리를 통한 데이터 교환은 더욱 빠르고, 굳이 텍스트 형식을 유지하기 위한 내용을이 필요없기에 효율적으로 동작했다.



한 번의 연결에서 동시에 여러 요청외에도 여러 응답까지 교환할 수 있게 되었다. HTTP/1.1의 파이프라이닝의 경우 클라이언트는 여러 요청을 연속적으로 보낼 수 있게 되었다. 그러나 파이프라이닝에서는 여러 요청을 연속적으로 보내도, 서버는 순차적인 응답을 보장해야 한다. 즉, 첫 번째 요청의 응답이 완료되기 전에는 두 번째 요청의 응답을 시작할 수 없다. 이런 구조는 첫 번째 요청이 특히 오래 걸릴 경우 나머지 요청도 블락되는 문제인 HOL(Head-of-Line) 블로킹 문제를 야기한다.


멀티플랙싱


HTTP/2.0의 멀티플랙싱(Multiplexing)은 HOL 문제를 해결한다. HTTP/2.0에서는 단일 연결 위에 여러 개의 동시 스트림을 구축하는데, 이 각각의 스트림이 독립적인 요청과 응답을 처리할 수 있다. 각 스트림은 독립적으로 요청과 응답을 처리하며, 이로 인해 여러 요청의 응답은 독립적으로 도착하게 된다. 그 결과 특정 요청에 대한 응답이 지연되더라도 다른 요청에 대한 응답은 계속해서 도착할 수 있게 되어, HOL 블로킹 문제가 해결되었다.


스트림에 대해서 궁금증이 생겨서 더 찾아보니, 스트림을 관리하는 주체는 클라이언트와 서버 양쪽이라는 정보를 얻을 수 있었다. 클라언트와 서버는 1) 스트림 생성, 2) 스트림의 우선 순위 조정, 3) 플로우 제어(데이터 전송 속도 제어), 4) 스트림 종료, 5) 에러 처리 의 순서대로 각 주체는 스트림을 관리한다. 개인적으로 플로우 제어가 상당히 중요한 개념이라고 느꼈는데, "특정 스트림이 너무 많은 데이터를 전송하여 다른 스트림의 전송을 방해하는 것을 막기 위해 클라이언트나 서버는 각 스트림에 대한 데이터 전송 속도를 제어하는 기술" 정도로 이해할 수 있었다.

헤더의 압축


HTTP/2.0은 헤더를 HPACK 알고리즘을 통해 압축하여 헤더의 크기를 줄인다. 줄여진 헤더의 크기는 형태로 네트워크 오버헤드를 감소를 의미한다. 내용을 담고 싶었으나 설명이 다소 복잡하여 추후에 다시 이해해보는 걸로 넘어가고자 한다. 일단은 HPACK이라는 존재를 아는 것만으로도 충분해보인다. 대신 RFC 공식 문서를 첨부한다.

https://httpwg.org/specs/rfc7541.html

 

RFC7541

HPACK: Header Compression for HTTP/2

httpwg.org

 

마지막으로 HTTP/2.0과 HTTP/3.0의 차이를 비교하며 글을 마무리하려 한다.

 

HTTP/2.0 vs HTTP/3.0



HTTP/3.0의 가장 주요한 특징은  QUIC(Quick UDP Internet Connections)라는 새로운 전송 프로토콜을 사용한다는 점이다. HTTP/2.0까지의 전송은 당연하게도 TCP를 기반으로 한다. 신뢰성 있는 데이터 전송이라는 목적을 달성하기 위해 TCP의 사용은 필수적이였고, TCP의 3-way 핸드쉐이크 과정에서 발생하는 오버헤드는 당연했다.


이름에서 알 수 있듯, QUIC은 UDP를 기반으로 한다. UDP의 주요한 특징은 연결 지향적이지 않다는 것이다. 이는 곧 TCP에 비해서 별도의 연결 과정이 없기 때문에 속도가 빠르다는 장점으로 이어진다. 다만, 단점은 확실하다. 중간에 패킷이 손실되는 경우 UDP는 이를 재전송하는 매캐니즘이 없다. 또한 네트워크 지연 등으로 특정 패킷 간에 순서가 뒤바뀐다고 해도, UDP 통신으로는 해당 패킷들의 순서를 알 수 없다. 이는 모두 TCP의 특징이자 장점으로 작용한다.


QUIC은 UDP를 기반으로 하지만 연결지향적인 특징을 갖는다. 그럼에도 속도 측면에서 TCP보다 훨씬 빠른 속도를 보장한다. 이는 최초 연결이 이뤄진 이후의 연결에서 QUIC이 연결 정보를 캐시하여, 한번 연결이 된 클라이언트와 서버에 대해서는 연결 과정이 생략되기에 가능하다. 다만, 최초 연결 과정에서는 QUIC과 TCP의 핸드 쉐이킹은 상당히 유사하다. QUIC은 UDP 위에서 구현된다는 특징을 가지지만, 사실 UDP 위에 교묘하게 TCP의 기능을 섞은 매커니즘에 가깝다. QUIC은 UDP를 활용한 전송 매커니즘이다. 때문에 빠르다는 특성을 갖는다. 여기서 그럼 어떻게 전송하는 데이터의 신뢰성을 보장할 수 있는지가 핵심이다. 


QUIC은 데이터 전송의 신뢰성과 순서를 유지하기 위해 TCP에서 활용되는 몇몇 개념을 차용하지만 그 구현 방식이 조금 다르다. 우선 TCP는 연속적인 바이트 스트림으로 데이터를 보낸다면, QUIC은 메세지를 기반으로 데이터를 전송한다. 이는 각각의 메세지가 독립적으로 전송되고 심지어 다른 순서로 도착해도 문제가 없다는 것을 의미한다. 즉, 경계가 모호한 바이트 기반의 데이터 대신, 명확한 경계의 메세지 기반 데이터를 통해 다른 메세지들과의 독립성이 보장된다. 이때, QUIC은 HTTP/2.0의 멀티플랙싱과 마찬가지로 여러 스트림을 활용하는데, 경계가 모호한 메세지이기에 다양한 메세지가 독립적인 구조로 동작하여 다른 스트림에서 처리한 메세지의 의도치 않은 손실이나 다른 스트림의 병목에 대해서 영향을 받지 않는다.


그러나 QUIC의 최초 연결과 TCP 핸드쉐이킹은 암호화 연결을 어떻게 수행하는지에서 큰 차이를 갖는다.. TCP의 핸드쉐이크는 두 시스템 간의 연결을 초기화하고 서로가 데이터 전송을 위해 준비하는 과정을 의미한다. TCP는 별도의 암호화 과정이 없기에 TLS 프로토콜을 추가적으로 활용하는데, TLS를 통한 암호화 연결을 위해서는 추가적인 TLS 핸드쉐이킹이 필요하다. QUIC 또한 TLS 1.3을 활용한다. 다만 QUIC에서는 별도의 핸드쉐이크 과정을 필요로 하는 대신 통합된 형태로 TLS 1.3을 사용한다. 클라이언트는 암호화 정보와 함께 초기 연결 요청을 보내고 서버는 해당 정보를 기반으로 응답한다. 즉, TLS를 사용하되 이를 위한 핸드쉐이킹 과정을 생략함으로서 연결을 효율적으로 처리한다.
 


QUICK의 최초 연결 과정에 대한 순서이다.

 

  1. Client Initial(0.5 RTT): 최초 연결 시 클라이언트는 서버에 연결 설정을 위한 초기 메세지를 보낸다. 이 메세지에는 연결 ID, 버전 정보, 암호화 정보 등이 담겨있다.

  2. Server Response(0.5 RTT): 클라이언트의 메세지에 대해 서버는 이를 받아들여 자신의 연결 ID와 함께 클라이언트에 응답한다.

  3. Client Response: 클라이언트는 서버의 키를 사용하여 암호화된 메시지를 전송하고, 이를 통해 양쪽 모두 상대방의 키를 올바르게 받았음을 확인한다.


그림에서 살펴보듯, TCP에 비해 그 절차가 훨씬 간소화되었으며, TCP + TLS가 붙는 경우 2-RTT까지 증가하는 초기 연결 비용을 1-RTT로 끌어내린다.

 

https://commons.wikimedia.org/wiki/File:Tcp-vs-quic-handshake.svg

 


또한 QUIC은 한번 연결이 설정되는 경우, 이를 캐싱하여 이후 두 호스트 간에 연결이 발생하는 경우 굳이 1-RTT의 연결을 사용하지 않고도 0-RTT를 통해서 연결이 이뤄진다. 0-RTT의 핵심 아이디어는 클라이언트가 서버와의 연결에서 얻은 정보(세션 티켓과 키)를 재사용하여 다음 연결 시에 왕복 없이 바로 데이터 전송을 시작한다는 것이다.


예를 들어 웹 브라우징 시에 사용자가 웹 페이지를 방문한 이후 다시 해당 웹페이지를 방문하려고 할때 0-RTT를 활용하면 연결 설정 시간이 크게 줄어든다.


그러나 0-RTT에는 2가지 문제가 존재한다.


먼저, 네트워크가 지연되거나 기타 이유로 0-RTT 요청 패킷이 소실되는 경우 해당 세션의 초기 설정 상태에 문제가 발생한다. 초기 0-RTT 요청 패킷이 도달하지 않으면 서버는 클라이언트가 캐싱한 세션 정보를 사용하여 연결을 시도하고 있음을 인지하지 못하며, 이러한 상황 속에서 클라이언트가 추가적인 데이터 패킷을 보내더라도 서버는 해당 패킷들을 적절한 연결 컨텍스트 내에서 해석할 수 없게 된다. 때문에 0-RTT의 요청 패킷이 문제가 된다면 이후의 데이터들도 정상적으로 전달되지 못한다. 물론 요청이 제대로 도달하지 않았을 경우를 추적하는 여러 매커니즘이 있다. 먼저 서버 측 관점에서는 Packet Numbering 방식을 통해 각 패킷에 고유한 번호를 할당하고 패킷의 순서를 보장할 수 있다. 만약 초기 연결 설정에 대한 패킷이 누락된다면, 서버는 해당 패킷이 손실되었다는 것을 인지하고 클라이언트에게 재전송을 요청할 것이다. 만약 이를 서버 측에서 처리해내지 못한다고 해도 클라이언트 측에서는 타임아웃을 활용하여 일정 시간이 지나도 돌아오지 않는 응답에 대해서, 다시 요청을 보낸다.


두 번째 잘 알려진 문제로는 Replay Attack의 위험성이 크다는 것이다. 만약 악의적인 공격자가 네트워크 상에서 0-RTT를 가로챈 후 이를 변조하여 다시 전송한다면 이는 크나큰 보안 위협으로 작용한다. 이러한 이유로 0-RTT 데이터는 일회성의 요청, 즉 "GET" 요청과 같은 것에만 권장된다.


이어서 QUIC은 연결을 마이그레이션하는 경우이라는 큰 장점을 갖는다. 사용자가 네트워크 환경을 전환할 때 (LTE에서 Wifi) QUIC 연결은 기존의 연결을 유지하면서 빠르게 새로운 IP 주소로 마이그레이션할 수 있다. 이로 인해 연결을 재설정한다거나 새로운 핸드쉐이크 없이도 네트워크를 전환할 수 있다.


 

여기까지 HTTP를 살펴보았다. 사실 추가적으로 더 넣고 싶었던 내용도 있었고 편집 과정에서 너무 과하다고 생각되는 내용들도 있었다. 특히 HTTPS를 설명하지 못한게 아쉽다. HTTPS의 경우 사실 TLS와 SSL에 대한 설명이 주가 되기 때문에 네트워크에 대한 글보다는 보안이론에 대한 설명이 주가 될 거 같다. 어쩌다보니 꼬박 하루 동안 글을 쓰고 있는데, 얻어가는게 많아 시간이 아깝지는 않았던 것 같다.

 


'CS' 카테고리의 다른 글

IPv4에서 IPv6로의 전환 매커니즘  (0) 2023.08.06
정적 라이브러리  (0) 2022.12.05
실행가능한 목적파일  (0) 2022.12.03
Linking(링킹) 소개  (0) 2022.12.02
시간 지역성을 위한 캐시 재배치  (0) 2022.12.01

몇 일전 면접에서 받았던 질문이다.


"그럼 IPv4에서 IPv6로의 전환은 어떻게 가능할까요? IoT 솔루션의 경우에는 많은 경우에서 IPv6를 사용하는 걸로 알고 있는데 혹시 이 부분에 대해서 답변 주실 수 있나요?"


워딩은 정확하진 않지만, 대략적으로 이런 워딩의 질문을 받았던 것으로 기억한다. 생각지도 못한 질문이였고, 솔직히 말해서 그게 가능하겠구나라는 생각도 면접장에서 처음 했었다. 사실 너무나 당연하게도 IPv4 주소체계를 사용하는 머신이 있고 IPv6 주소체계를 사용하는 머신이 있다면, 어떤 프로토콜을 사용하고 DHCP에서 어떻게 주소를 받아왔는지만 다르지 두 머신의 연결이 불가능할 이유는 당연히 없다. 질문 자체도 생각할 여지가 많았지만 그리 잘 답변하지는 못했던 것 같다. 그럼에도 이 질문이 계속해서 뇌리에 남는 것은, 질문의 난이도도 난이도이지만 내가 뭔가 공부를 잘못하고 있었을 수도 있겠다는 충격을 줬던 질문이였기 때문인 것 같다.


지금까지의 나 자신을 돌아보면 '신입이니까', '학부생이니까' 등의 선입견과 함께 지식을 배우거나 받아들일 때에도 스스로 무의식적인 필터링을 걸어왔던 것 같다. 조금만 사고의 틀이 넓어지면, 나의 내면에서부터 지식을 받아들이는 것을 거부해왔던 걸지도 모르겠다는 생각이 든다. 오늘의 주제도 사실 당연히 고민을 한번쯤은 해볼 수 있는 주제들인데도 '왜 이런 생각을 그동안 한번도 해보지 못했을까'라는 생각이 계속해서 머리 속에 맴돈다. 그 간의 공부는 아무래도 조금은 수정의 여지가 있어보인다.


주제와 동떨어진 얘기는 여기까지 마무리하고, 오늘의 주제를 관통하는 IP가 무엇인지부터 이해해보며 글을 시작해보고자 한다.

 

인터넷 프로토콜

 

그 이름부터 친숙한 IP는 Internet Protocol의 약자로서, 네트워크 프로토콜 중의 하나로서 드넓은 네트워크 세계에서 주소를 지정하고 라우팅하는 방식을 지정한다. 즉, 상호 연결된 네트워크 상에서 패킷을 라우팅하기 위한 프로토콜이다. 


여기서, "상호 연결된 네트워크"라는 말은 말 그대로 서로 통신이 가능하다라는 것 의미한다. 인터넷을 생각해보자. 내 컴퓨터가 인터넷에 연결되어 있다면, 우리는 자유롭게 인터넷에 연결된 다른 컴퓨터로 연결을 전송할 수 있다. 반대로 인터넷에 연결하지 않고, 내 노트북과 학교 컴퓨터를 연결하려고 하면 당연히 연결이 안된다. IP는 상호 연결된 네트워크에서 작용한다.


그 다음 '패킷'이라는 용어는 네트워크 상에서 데이터를 전송할 때 사용하는 기본 단위 정도로 이해할 수 있다. 특히 L3의 네트워크 계층에서는 그 의미가 조금 더 확장되어 IP를 거친 데이터 단위를 의미한다. 만약 우리가 1GB 정도의 데이터를 전송하는 상황을 예로 들어보자.  1GB의 크기는 상당히 큰 크기이다. 컴퓨터가 1GB를 그대로 뚝 떼어다가 옮기고자 하는 컴퓨터에 전선을 따라서 뚝 떼어주면 좋겠지만 현실은 그렇게 동작하지 않는다. 우선 우리의 컴퓨터는 네트워크를 통해 전송을 시작하기 이전에 일정 크기 이상의 데이터를 작게 쪼개서 전송한다. 이때 쪼개지는 단위 하나하나가 패킷이다. 쪼개진 패킷은 목적지에서 다시 합쳐지는데 이를 통해 물리적으로 전송하기엔 너무 거대한 데이터 뭉치를 여러 패킷으로 나눠서 전송할 수 있게 되었다.


IP 패킷은 크게 '헤더(Header)'와  '페이로드(Payload)'라는 두 부분으로 이뤄지는데, 헤더 부분에는 패킷이 전송되는데 필요한 메타데이터가 들어오고, 페이로드 부분에는 전송 계층에서 쌓여져 내려온 데이터가 들어오게 된다.


본격적으로 헤더 이야기를 하기 전에 페이로드에 대해서 잠깐만 이야기를 더 해보고자 한다. 우리가 네트워크 상에 데이터를 전송하게 되면 가장 먼저 Application Layer(L7)를 거치게 된다. 예를 들어 웹 브라우저를 통해 웹페이지에 접속하거나 이메일을 보낸다면 HTTP/HTTPS 기반의 L7 관련 프로토콜을 통해 해당 요청이 처리되기 시작한다. 이때 우리가 전송하고자 하는 정보는 Header라는 메타데이터를 품고 있는 정보와 합쳐진다. 합쳐진 정보는 그렇게 다음 Layer로 전송된다.


그 후, L4에 해당하는 Transport Layer에서는 생성된 데이터를 세그먼트 형태로 분할하고 각 패킷에 헤더를 추가한다. 헤더에는 출발지 포트, 목적지 포트, 순서 정보(Seq), 오류 검출 코드 등의 정보가 포함된다. 위에서와 마찬가지로, 전달된 데이터에 추가적으로 Header가 합쳐진 다음 다음 Layer로 전송된다. 결국 L3의 IP에서 마주하는 정보는 순수한 데이터가 아니다. 위에서부터 내려오면서 헤더가 여러번 붙여진 정보를 처리해야 한다. 마찬가지로 IP 또한 이러저러한 메타 데이터를 헤더를 통해 추가한다. 그렇게 IP 또한 IP 헤더 정보와 위에서부터 여러 헤더로 감싸진 페이로드를 더하여 그 다음 L2로 넘겨주게 된다.

이런 IP에는 IPv4와 IPv6라는 두가지 버전이 존재한다. 인터넷이 등장하고 수많은 PC가 도입됨에 따라 IPv4만으로 모든 컴퓨터를 표현하기에는 곧 한계에 다다를 것이라는 의견들이 발생했다. IPv6는 그러한 문제를 해결하기 위한 해결책이다. IPv4의 경우 약 42억개의 주소를 표현할 수 있다. IPv6가 표현가능한 주소의 개수는 2^128로 이를 숫자로 표현하면 마치 무한대에 가까운 주소를 표현할 수 있음을 알 수 있다.
(총 가능한 주소의 수: "
340,282,366,920,938,463,463,374,607,431,768,211,456")


그러나 두 버전의 차이는 여기에서 그치지 않는다. 헤더의 필드에서도 두 구조 간에는 주요한 차이가 있다.



먼저 IPv4의 헤더 대한 설명이다.



IPv4의 헤더 구조



출처: Wikipedia



Header는 패킷의 전송에 필요한 메타데이터를 품는다.
도대체 어떤 정보들이 필요하길래 Data를 보내는데 저렇게 많은 필드가 필요할까? 각각의 필드를 이해해보자.


Version: IP 프로토콜의 버전을 나타낸다. IPv4의 경우이기에 4가 들어간다.


Header Length(IHL): IPv4의 헤더의 길이를 담는 필드로, 기본 길이는 20 Bytes로 시작하여 추가적인 옵션이 붙는 경우 60 Bytes까지도 늘어난다.

  • 여담으로 IPv6의 경우 헤더의 길이가 40 Bytes으로 고정이며, 추가적으로 확장 헤더라는 개념을 사용한다. 이는 아래에서 IPv6 구조도를 설명하며 다시 설명한다.

  • 4비트의 크기로 표현되는데, 헤더의 길이는 해당 비트로 표현된 이진수에 4배를 곱한 값이다.


ToS(Type of Service): 원래 QoS 정보를 담기 위해 도입된 필드였으나, 시간이 지나면서 DiffServ 및 ECN을 위한 용도로 활용된다고 한다. DiffServ가 6 MSB, ECN이 2 LSB로 표현된다. 용어가 다소 생소한데, 하단에 설명을 자세하게 적어두었으니 참고하시길 바란다.


Total Length: 전체 IP 페킷의 바이트 길이(Header + Payload)가 들어간다. 최대 65,535 바이트까지 표현가능하다 (2^16).


Identification: 16 비트로 구성되어 있으며 원본 데이터그램(Datagram)이 파편화(Fragmentation)되었을 때, 각 파편(Fragment)을 구별하기 위해 사용된다. 

  • 출발지 호스트에서 MTU의 제약 때문에 여러 패킷으로 쪼개진 세그먼트는 목적지 비트에서 Identification을 기준으로 다시 합쳐져서 하나의 세그먼트로 Layer 4에 전달된다.


Flags: 3비트로 구성되어 있으며 (LSB 2개만 사용), 패킷의 Fragmentation 동작을 제어하는데 활용된다.

  • DF(Don't Fragment), MF(More Fragment) 비트가 존재하며, 기본값은 000이다.
  • DF: 중간 라우터에 의해서 Fragmentation되지 않을 것을 표현, 001
  • MF: 계속해서 추가적인 패킷들이 따라온다는 것을 표현한다. 마지막 패킷에서는 표시되지 않는다. 010
  • DF와 MF는 동시에 사용될 수 있으며 (011), 패킷이 쪼개지지 말아야하며, 이후 쪼개진 값이 아직 들어오지 않았음을 의미한다.


Fragment Offset: 쪼개진 파편들의 Offset이다. 만약 패킷의 순서가 뒤죽박죽으로 들어왔다고 해도 Offset을 통해 올바른 순서로의 조립을 보장하는 필드이다.


TTL(Time to Live): 당초 패킷이 네트워크 내에서 살아있을 수 있는 시간을 의미했으나, 지금은 얼마나 많은 라우터를 거칠 수 있는지를 표시한다. 라우터를 거칠 때마다 TTL 값은 1씩 감소하며, 값이 0이 된다면 패킷은 삭제된다.


Protocol: Layer 4, 전송계층의 프로토콜명을 담는다. TCP의 경우 6, UDP의 경우 17라는 값이 담기게 된다.


Header Checksum: 헤더의 손상 여부를 가리는데 활용된다. 만약 출발지에서 계산되어 Checksum에 담긴 값과 목적지에서 계산한 Checksum 값이 다르다면 해당 패킷은 손상된 것으로 간주하여 폐기한다. 주의할 점은 IP 헤더에 대한 무결성을 검증할 뿐, TCP, UDP 세그먼트 값에 대한 무결성까지는 검사하지 않는다. 즉 헤더를 제외한 페이로드에 대해서는 무결성이 보장되지 않는다.


Chceksum의 계산 과정은 다음과 같다

  1. Header Checksum 필드를 0으로 설정
  2. IP 헤더의 모든 필드를 16 비트 단위로 쪼갬 (32 비트로 표현된 한 줄 -> 16 + 16)
  3. 분할된 모든 16 비트의 값을 더함. 만약 덧셈 중 오버플로우가 발생한다면 이는 LSB에 더한다.
  4. 더해진 값의 보수 값을 계산한다.
  5. 최종적으로 보수 값이 Header Checksum 필드에 저장된다.
  6. 목적지에 패킷을 수신할 때, 위의 계산 과정은 다시한번 반복되면, 계산된 값이 헤더에 위치한 체크섬과 일치하는지 확인한다.

Source Address & Destination Address:

  • IPv4의 주소를 표현하는 필드로 각각 32 비트로 표현된다.


Options: 다양한 추가적인 기능에 대한 값들이 들어간다.


Padding: IP 헤더의 길이를 32비트의 배수로 맞추기 위해 사용되는 값이다. 특별한 의미 없이 포맷을 맞추기 위해 사용되며 필요한만큼 추가된다.



아래는 위에서 설명하지 못한 개별적인 용어에 대한 설명이다.




QoS(Quality of Service): 네트워크에서 여러 유형의 트래픽(e.g. 음성, 비디오, 데이터)이 전송되는 경우, QoS는 각 트래픽 유형에 대해서 서로 다른 우선 순위를 지정하여 네트워크 자원을 최대한 효율적으로 사용하도록 돕는다. 예를 들어 실시간 음성 통화의 경우에는 데이터 다운로드의 경우보다 높은 우선 순위를 보장받을 수 있다. 중요한 패킷이 더욱 빨리 처리된다고 생각할 수 있겠다.

DiffServ(Differential Services): 트래픽을 여러 클래스로 분류하여 각 클래스 간에 다른 처리 수준을 제공하는 기술이다. 그러나 DiffServ의 우선순위를 매핑해둔 DSCP값들을 어떻게 처리할 것인지에 대해서는 별도의 규약이 없기 때문에 강제성은 없다. 때문에 네트워크 장비를 관리하는 운영 정책에 따라 네트워크 관리자의 판단 하에, 일반적으로 우선 순위가 높다고 알려진 DSCP를 후순위로 미루는 것도 충분히 가능하다.

DiffServ의 PHB, 다음과 같은 종류의 클래스를 가지고 있다. 어디까지나 요약 정도의 느낌으로 봐주시면 좋겠다. 실제 정의는 훨씬 복잡하다.


  1. Default Forwarding (DF) - PHB (Per-Hop Behavior) Class:  기본 클래스로 별도의 QoS 구성 없이 전달되는 트래픽에 사용된다. (PHB: 패킷이 네트워크 장비에서 어떻게 처리될 것인지를 지정하는 '프로파일'의 일종이다. 예를 들어 PHB의 우선 순위가 높은 경우 동일한 라우터에서 패킷의 우선 순위가 다르게 처리된다.)

  2. Expedited Forwarding (EF) - PHB Class: 주로 낮은 지연, 패킷 손실을 요구하는 실시간 애플리케이션 (e.g. VoIP <- 음성 통화)에 사용되는 클래스이다. 가장 높은 우선 순위를 가지는 PHB이다.

  3. Class Selector (CS) - PHB Class: 레거시 ToS와의 호환성을 위해 도입되었으며, CS0부터 CS7까지 총 8개의 클래스를 가지고 있다. 숫자가 커질 수록 높은 우선 순위를 나타낸다.

  4. Assured Forwarding (AF) - PHB Class: 여러 우선 순위의 트래픽을 나타낼 수 있는 4개의 클래스로 구성되며, 각 클래스는 다시 3개의 drop precedence로 나뉜다(e.g. AF11, AF12, AF13, ... , AF43). 이때 클래스의 크기는 커질 수록, drop precedence는 낮을 수록 우선순위가 커진다. (drop precedence는 번역이 마땅치 않아 그대로 기재한다. 내림차순의 우선 순위 정도로 이해해도 좋을 것 같다.)

  5. Scavenger Class: 중요하지 않은 서비스들에 대해서 남은 네트워크 대역폭(bandwidth)를 할당하고 관리하기 위한 목적으로 생성. 필수적이지 않은 애플리케이션이나 시간에 민감하지 않은 서비스에서 메세지를 대량전송하고 싶은 경우 사용할 수 있다. 남은 대역폭을 사용하다보니, 중요한 프로그램의 성능을 최대한 건드리지 않으면서 추가적으로 가능한 만큼에 대해서만 자원을 사용하고 싶은 서비스에 적합하다고 한다.


ECN(Explicit Congestion Notification): 패킷의 손실을 줄이기 위해 네트워크의 혼잡을 조기에 감지하고 대응하는데 활용된다. 혼잡이 감지된다면, ECN은 트래픽을 조절하기 위해 송신자에게 별도의 알림을 전송하는 것을 통해 트래픽의 손실을 방지하거나 최소화할 수 있다.

Fragmentation(파편화): 처리가능한 단일 패킷 크기보다 크다면, 패킷을 쪼갠다.

  • Datagram: 전송 계층(Transport Layer, Layer 4)에서 네트워크 계층(Layer 3)으로 전달되는 정보 단위

  • 데이터 링크 계층(Data Link, Layer 2)에서 데이터를 보내기 위해서는 최대 보낼 수 있는 프레임의 수를 넘어서는 안된다. 이때 최대 보낼 수 있는 프레임의 크기를 MTU(Maximum Transmission Unit)라고 부른다.

  • 만약 전송하고자 하는 패킷의 크기가 MTU를 넘는다면, 이를 파편화, 즉 쪼개야 한다.
    (e.g. 4000 바이트를 보낼 예정, MTU가 1500 -> 총 3개의 Fragment 생성 )

  • Layer 3, 네트워크 계층에서 수행된다. (TCP/UDP의 전송 계층을 지나온 세그먼트가 IP에 의해 여러 개의 작은 IP 패킷으로 쪼개짐)

  • 출발지 호스트 혹은 라우터에서 쪼개진다. 라우터의 경우 다음 홉이 처리할 수 있는 MTU보다 크다면 이를 쪼갠다.
  • IPv6의 경우에는 출발지 호스트에서만 쪼개는 것이 가능하다. 이는 Fragmentation에 의한 복잡성과 그에 따른 오버헤드를 줄이기 위함이라고 한다. 때문에 IPv6의 Layer 3에서는 경로 MTU 탐색 기술을 활용하여 최적의 MTU를 결정하여 패킷의 크기를 설정하는 과정이 추가적으로 요구된다.

 

IPv6의 구조도


그렇다면 IPv6는 어떨까? 버전이 늘어났으니까 필드에 대한 복잡성도 늘어나지 않았을까? 예상과 달리 IPv6의 헤더는 IPv4의 헤더보다 훨씬 간단하다. 다만, 헤더의 크기만큼은 대부분의 IPv6 패킷이 IPv4의 패킷보다 크다. 어떻게 헤더의 구조를 간략화시키면서 IPv4의 기능을 확장시켰을까? 결론부터 얘기하자면 이는 확장 헤더라는 개념을 통해 가능하다. 아래 필드 목록을 이해해보며 보다 자세하게 이해해보자.


출처:&nbsp;https://www.networkacademy.io/ccna/ipv6/ipv4-vs-ipv6

 

 

  • Version: 4 Bits, IPv4의 경우와 동일하다. IPv6의 경우 6이 들어간다.


  • Traffic Class: 8 Bits, IPv4의 ToS에 해당한다. 정확하게는 QoS 방식이 아닌 DiffServ 아키텍처의 ToS 필드와 일치한다.

    • Tos와 동일하게 6 MSB는 DSCP를 표현하는데 활용되고, 나머지 2비트는 ESN을 표현하는데 활용된다. (위의 용어 설명 참고)

  • Flow Label: 20 Bits, 동일한 "흐름(Flow)", "세션(Session)"에 속하는 패킷들을 식별하는데 활용한다.

    • 간단하게 말해서 흐름이라는 것은 패킷을 구분짓는 단위로서, 어떤 특정 세션, 연결 혹은 동일한 응용프로그램의 일부로 동작하는 것을 나타내는 용어이다.  

    • 해당 흐름에 대해서 한번 경로가 결정되면, 그 흐름에 속하는 모든 패킷들은 동일한 패킷으로 전송된다. 이는 특정 응용 프로그램 패킷들의 일관된 지연시간을 보장한다.
    • 이때, 흐름 단위를 결정하는 요소로는 1) 출발지 주소:포트, 2) 목적지 주소:포트, 3) (Layer 4, 전송 계층의) 프로토콜 타입, 4) Flow Label 등이 있다.

    • 예를 들어 VoIP(Voice over IP, 음성 통화)의 경우, 패킷을 일관되게 처리하는 것이 중요하다. Flow Label을 통해 VoIP 트래픽을 선별해낼 수 있다면 네트워크 장비로 하여금, VoIP 흐름에 속하는 패킷들에 대해서 우선 순위를 부여하거나 특정 경로를 선택하도록 도움을 줄 수 있다.

    • 마지막으로 Flow Label의 생성은 오직 Source에서만 이뤄진다. 패킷의 전송 과정 중에 변경되는 것은 허용되지 않는 행동이다. 


Payload Length: 20 Bits, 페이로드의 길이를 나타낸다.

  • IPv4와 달리 IPv6의 경우 헤더의 길이가 40 Bytes로 고정이다. 때문에 기존의 Total Length 필드가 Payload Length만 기록하는 것으로 변경되었다. 

Next Header: 16 Bits, 다음 확장 헤더의 위치를 나타낸다.

  • IPv6의 헤더의 필드들을 간소화시키는 핵심 필드이다. IPv6의 헤더는 크기가 고정적이다. 다만 이것이 표현해야할 헤더의 양이 줄어들었다는 것은 아니다. 오히려 다양한 기술의 도입으로 헤더는 더욱 무거워졌다. IPv6에서는 이를 확장 헤더를 통해 해결한다.

  • 확장 헤더의 개념 자체는 어렵진 않다. Next Header 필드의 경우 다음에 오는 확장 헤더를 표시한다.

  • Next Header 필드에 TCP나 UDP와 같은 전송 계층의 프로토콜 값이 들어가는 경우, 해당 확장 헤더가 마지막이라는 것을 의미한다.

  • 대략적인 구조: | 기본 IPv6 헤더 (40바이트) | 확장 헤더 1 | 확장 헤더 2 | ... | 상위 계층에서 온 페이로드 |

  • 예를 들어 다음의 헤더들이 존재한다.

    • Hop-by-Hop Header: 패킷 경로 상의 모든 노드가 확인해야 하는 정보, e.g. 우선순위

    • Authentication Header: 패킷의 인증 정보, 주로 IPsec에서 활용

    • ESP(Encapsulating Security Payload) Header: 보안이 설정된 페이로드 데이터

    • Fragment Header: Fragmentation된 패킷들에 대한 정보

    • Routing Header: 패킷의 중간 경로에 대한 정보, 특정 라우터를 거치도록 강제할 수 있다.

    • Destination Header: 최종 목적지에만 전달되는 정보, e.g. 모바일 환경에서 Source Destination에 대한 정보가 별도로 필요한 경우, 해당 헤더에 포함시킬 수 있다.

  • 헤더 목록들을 살펴보면 대부분은 IPv4에서도 도입된 개념들이 대부분이다. 헤더 정보에 대한 표현 방식의 차이 정도로 이해할 수 있을 것 같다.

  • 추가적인 예시를 들어보자.

    • e.g. 송신자 A가 수신자 B에게 IPv6 패킷을 전송하려고 한다. 이 패킷은 라우팅 확장 헤더를 포함하고 있고, 그 다음에 TCP 세그먼트를 담고 있다. 패킷은 다음과 같이 구성된다.

    • 먼저, IPv6의 기본 헤더가 온다. Next Header 값은 43(라우팅 확장 헤더)이다.

    • 그 다음은 명시한 대로 라우팅 확장 헤더가 온다. Next Header 값은 6(TCP)이다.

    • Next Header 값이 상위 계층의 프로토콜을 나타내므로 확장 헤더는 끝이다. 대신 IP 패킷의 페이로드, 즉 TCP의 세그먼트가 오게된다.


Hop Limit: 8 Bits, IPv4의 TTL 필드에 해당한다.

  • 중간에 노드를 만날때마다 Hop Limit을 하나씩 줄인다.

  • 만약 0이 된다면, 해당 패킷은 폐기한다.

Source Address & Destination Address: 128 Bits, IPv4와 동일하되 필드의 길이가 늘어났다. 아래는 IPv6 주소 체계에 대한 짧은 요약이다.

  • IPv6의 주소는 16비트의 8개 그룹으로 나뉜다.

  • 각 그룹은 4자리의 16 진수로 표현되며 각 그룹 간에는 콜론(:)을 통해 구분된다. (IPv4의 경우에는 8비트의 4개 그룹이다)

  • IPv6에서는 연속된 0으로 표현된 그룹의 경우 ::로 축약이 가능하다.

    • e.g. 2001:0db8:85a3:0000:0000:8a2e:0370:7334

      -> 2001:0db8:85a3:0:0:8a2e:0370:7334

      -> 2001:0db8:85a3::8a2e:0370:7334
  • 특수한 주소 
    • '::1': Loop Back 주소. IPv4의 Localhost이다.
    • '::': 주소가 지정되지 않은 인터페이스를 의미한다.


IPv4와 IPv6 간의 헤더 비교


이렇게 IPv4와 IPv6에 대한 헤더의 필드를 살펴보았다. 사실 하나하나가 별도의 주제로 글을 쓸 만큼 가벼운 주제가 아닌 필드들이 많기에 더욱 정확한 이해를 위해서는 추가적인 공부가 필요하지 싶다.

IPv4와 IPv6는 IP 프로토콜의 버전이므로 많은 유사점이 있을 수 밖에 없다. 그럼에도 불구하고 구체적인 헤더 구조와 필드는 상당히 다르다고 보는 것이 맞을 것이다. 특히 시각적으로 보여지는 두 버전 간의 차이는 극명해보인다.

그러나 필드 각각을 이해해보면, 사실 이름만 바뀌었다고 느껴지는 필드들도 몇몇 보인다. 예를 들어 IPv4의 TTL 필드는 원래 패킷이 네트워크 내에서 존재할 수 있는 시간을 나타내기 위한 것이었다. 그러나 현재는 패킷이 지나가는 라우터의 횟수를 제한하는 역할을 한다. 이러한 변화에도 불구하고 필드의 이름은 'Time to Live'로 유지되었는데, 이는 이름만 바뀌고 기능은 유지된 필드 중 하나로 볼 수 있다. IPv6에서는 이를 Hop Limit이라는 보다 직관적인 이름으로 변경하였다. 결국 의미는 동일하되, 이름만 변경되었다.

 

위의 이론을 기반으로 다음과 같이 두 버전의 헤더를 비교해보았다. 검증을 거치지 않은 개인적인 의견이기에 어디까지나 이런 의견이 있구나 정도로만 봐주시면 좋겠다.


IPv4 Header Field IPv6 Header Field 설명
Version Version IP 프로토콜의 버전을 나타냅니다.
Header Length (IHL) (IPv6에는 존재하지 않음; 40 Bytes로 고정) 헤더의 길이를 나타냅니다.
Type of Service (ToS) Traffic Class 서비스 타입 또는 트래픽 클래스를 나타냅니다.
Total Length Payload Length 전체 패킷의 길이를 나타냅니다.
Identification (확장 헤더를 통해 Fragmentation 처리됨) Fragmentation를 위한 식별자입니다.
Flags (확장 헤더를 통해 Fragmentation 처리됨) Fragmentation 옵션을 나타냅니다.
Fragment Offset (확장 헤더를 통해 Fragmentation 처리됨) Fragmentation을 통해 쪼개진 패킷의 순서를 나타냅니다.
Time to Live (TTL) Hop Limit 패킷이 네트워크에서 존재할 수 있는 최대 횟수를 나타냅니다.
Protocol Next Header
(TCP/UDP인 경우 다음 패킷이 페이로드)
상위 프로토콜을 나타냅니다.
Header Checksum (IPv6에는 존재하지 않음; 효율성을 위해 제거됨) 헤더의 체크섬입니다. IPv6에서는 이 필드가 제거되었습니다.
Source Address Source Address 패킷의 출발지 주소를 나타냅니다.
Destination Address Destination Address 패킷의 목적지 주소를 나타냅니다.
Options (if IHL > 5) (확장 헤더를 통해 Fragmentation 처리됨) 추가적인 옵션을 나타냅니다. IPv6에서는 확장 헤더로 처리됩니다.

 


그래서 IPv4와 IPv6의 호환은 어떻게 이뤄질까

IPv6는 기본적으로 IPv4와 많은 유사점을 공유한다. 그러나, 확장 헤더로 대표되는 IPv6의 패킷을 다루는 방식은 IPv4와 분명히 다르다. 확장 헤더는 IPv6가 IPv4에 비해 보다 유연하고 확장 가능하도록 설계된 주요한 특징 중 하나이다. 특히, 다양한 네트워크 상황과 요구사항에 대응할 수 있는 다양한 확장 헤더 타입들이 IPv6에 도입되었다. 이를 통해, IPv6는 보안, 트래픽 흐름, 라우팅 옵션 등 다양한 기능을 제공하며, 이에 그치지 않고 추후 새로운 기능이 도입되더라도 무한한 가능성을 열어둔다는 점에서 확장성이 무궁무진하다.


그럼에도 IPv4와 IPv6를 연결시키는 것은 쉽지 않다. 이를 가능하게 하는 기술로 해당 글에서는 Dual Stack, Tunneling, Translation까지 총 세가지 기술을 소개하고자 한다.



1. Dual Stack (이중 스택)

Dual Stack이라는 용어는 하나의 장비나 시스템에서 IPv4와 IPv6 두 가지 IP 버전을 동시에 지원하는 방식을 의미한다. 이 기술을 사용하면 하나의 NIC(네트워크 인터페이스 카드)에서 IPv4와 IPv6 주소를 동시에 할당받아 사용할 수 있다.


Dual Stack이 처리되는 순서는 대략적으로 이렇다.




  1. 주소 할당

    • Dual Stack 환경의 장비에서는 IPv4와 IPv6 주소를 동시에 할당받는다.

    • 물리적으로는 동일한 인터페이스이지만 버전에 따른 두 개의 주소를 가지고 있는 셈이다.


  2. 네트워크 스택

    • 장비 내부의 네트워크 스택은 IPv4와 IPv6 프로토콜을 모두 처리할 수 있는 로직과 라이브러리를 포함한다.

    • 들어오는 패킷의 버전을 확인하고 적절한 프로토콜 로직으로 패킷을 처리한다.


  3. 패킷 전송

    • 데이터 전송 이전에 전송하고자 하는 목적지의 IP 주소 타입(IPv4 or IPv6)을 확인한다.

    • 주소 타입에 따라 적절한 IP 프로토콜 스택을 사용하여 패킷을 생성하고 전송한다.


  4. DNS 조회

    • 주소가 두개이기에 DNS 서버를 거칠 경우에도 동작이 두 버전에서 일어난다.

    • 일반적으로는 A 레코드(Address record, IPv4)와 AAAA 레코드(Quad-A Record, IPv6)를 모두 반환받게 된다.


  5. 통신의 방향성

    • IPv4와 IPv6의 패킷은 서로 직접 통신할 수 없다.

    • Dual Stack이라 하더라도 IPv4 패킷은 IPv6 네트워크를 거칠 수 없고, IPv6의 경우에도 IPv4 네트워크를 거칠 수 없다.


  6. 풀백 매커니즘

    • 만약 IPv6 통신이 실패한다면, Dual Stack 환경에서는 자동으로 IPv4를 활용하여 통신을 재시도할 수 있다.




Dual Stack 방식은 기존의 IPv4 네트워크 인프라를 크게 수정하지 않고도, IPv4와 IPv6를 모두 지원하는 장비를 통해 IPv6의 도입을 가능하게 한다. 해당 장비는 IPv4를 사용하면서도 IPv6로의 점진적 전환을 가능하게 한다는 점에서 기존 장비와의 호환성을 보장한다.


그러나 Dual Stack은 단점이 명확한 방식이다. 


너무나도 자연스럽게 넘어갔지만, 2가지 주소 체계를 모두 관리하는 것은 쉬운 일이 아니다. 이는 운영 상의 복잡성을 야기한다. 또한 두 가지 IP 주소를 관리하고, 두 네트워크 스택을 유지하기 위한 추가적인 리소스도 만만치 않을 것이기에 하드웨어의 부담도 가중된다.


그럼에도 현재 많은 웹사이트와 온라인 서비스 또한 Dual Stack을 활용한 IPv6 지원을 실제 지원하고 있다고 알려져있다. 뿐만 아니라 대형 ISP나 데이터 센터에서도 Dual Stack 방식을 활용한 IPv6 전환이 이뤄지고 있다고 한다.


Dual Stack은 IPv4와 IPv6를 동시에 처리할 수 있는 하드웨어를 통해 IPv6로의 원활한 전환을 지원하는 기술라고 요약할 수 있을 듯 하다.



2. Tunneling (터널링)

앞서 언급한 Dual Stack 방식은 하드웨어가 IPv4와 IPv6를 동시에 지원하게 함으로써 문제를 해결하는 반면, 터널링은 별도의 하드웨어 없이 IPv6 패킷을 IPv4로 감싸 새로운 패킷 형태로 전송함으로써 호환성 문제를 극복한다. 즉, Dual Stack이 보다 하드웨어적인 해결에 가깝다면, 터널링은 하드웨어와 함께 소프트웨어적인 해결을 적용한다고 이해할 수 있겠다.


이때 감싸는 작업은 캡슐화(encapsulation)라고 불리는데, 캡슐화를 거친 IPv6 패킷은 IPv4 헤더로 감싸졌기에 마치 IPv4로 인식되어 라우터들을 거치게 된다. 최종적으로 목적지에 도착한 패킷은 다시 IPv4 헤더를 제거하는 과정을 거치는데 이를 비캡슐화(decapsulation)이라고 부른다. 이 과정을 거쳐 수신 측에서는 원본 IPv6 패킷을 그대로 처리할 수 있게 된다. 패킷 내의 데이터는 적절한 애플리케이션 또는 서비스로 전달된다.


터널링에는 여러 방식이 있는데, 오늘은 그 중 대표적인 6to4라는 기술을 소개해보고자 한다. 이는 6to4에서의 캡슐화는 다음의 단계를 거쳐 완성된다.


  1. IPv6 패킷 준비: 송신 측에서는 일반적인 방법으로 IPv6 패킷을 생성한다. 다만, 한 부분에서 평범한 IPv6 패킷과 차이가 있다.

    • Prefix(CIDR): 바로 6to4을 사용하는 IPv6 패킷의 경우에는 주소의 첫 16비트가 "2002"라는 접두어로 고정된다.

    • IPv4 Address: 이후 원본 IP 주소가 32비트 형태로 추가된다.

    • SLA(Subnet ID):그 다음 16비트는 SLA로 채워진다. 만약 큰 조직에서 내부 서브넷을 쪼개야 한다면, 해당 비트를 활용할 수 있다.

    • Interface ID: 마지막 64비트는 인터페이스 식별자로 사용된다. 일반적이라면 NIC(Network Interface Card)에 기록된 MAC 주소를 기반으로 생성된다.

       
  2. IPv4 헤더 생성: 송신 측에서는 IPv4 헤더를 준비한다. 이때 송신 측 주소는 6to4 기능을 수행하는 송신 측 장비의 IPv4 주소로 설정되고, 목적지 주소는 6to4 릴레이 주소(IPv6 네트워크 환경) 또는 IPv4의 형태의 목적지 주소(IPv4 네트워크 환경)로 구성된다.


    • 어떤 목적지 주소 형태를 사용할지는 현재 보내고자 하는 네트워크가 어떤 IP 버전을 사용하는지에 기반한다.


    • 만약 IPv6 네트워크 환경을 사용한다면, 해당 환경의 라우터들은 IPv4로 위장한 6to4를 처리할 수 없다. 때문에 6to4를 지원하는 별도의 중개자가 추가적으로 필요하다. 이를 6to4 릴레이라고 부른다.


  3. 캡슐화(Encapsulation): 준비된 IPv6 패킷을 위에서 생성한 IPv4 헤더의 데이터 부분에 넣는다. 즉, IPv6 패킷 전체가 IPv4 패킷의 페이로드가 된다. 6to4를 지원하는 IPv4 패킷 헤더의 필드와 IPv6 헤더의 필드 간에 일대일 변환이 이뤄지는 것은 아니다.


    그럼에도 불구하고 앞서 살펴보았던 몇몇 필드들에 대해서는 두 버전 간에 어느정도의 유사성이 존재함을 확인하였다. 예를 들어 다음의 IPv6 헤더는 다음과 같이 IPv4 헤더로 변환된다.


    • Version: 캡슐화가 수행되며, IPv4의 헤더에 4가 기록된다. 페이로드 내부의 IPv6 패킷은 6이 기록되어 있다.

    • Source, Destination Address: 6to4 방식에 따라 앞서 설명한 터널링을 위한 IPv4 주소가 설정된다.

    • Payload Length: IPv4의 Total Length 필드로 변환되기 위해서는 IPv4의 헤더와 캡슐화된 IPv6 패킷의 전체 길이가 더해진다.

    • Next Header: Protocol 필드의 값이 상위 계층의 프로토콜(TCP/UDP)을 표현하지 않는다. 대신 IPv6를 표현하는 41이라는 값이 들어간다.
      (참고: https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml)


    • Hop Limit: TTL 필드에 그대로 복사된다.


  4. 패킷 전송: 완성된 IPv4 패킷은 일반적인 방법으로 IPv4 네트워크를 통해 전송된다.

    • 도착 이후 6to4 패킷은 비캡슐화를 수행해야 한다. 때문에 목적지의 호스트 또한 6to4를 지원할 수 있도록 하드웨어와 소프트웨어를 지원해야 한다.

 

이렇게 전송된 6to4 패킷은 IPv4 환경에서는 기존 IPv4 라우터를 거쳐 처음부터 IPv4였다는 듯이 전송되고, IPv6 환경에서는 6to4를 지원하는 릴레이 라우터를 거쳐 적절하게 전송된다.



다만 6to4 방식의 경우 개인적으로 장점보다는 단점이 많다고 생각된다. IPv6 호스트를 IPv4 네트워크에 통합시킨다는 아이디어 자체는 문제가 없다. 때문에 별도의 IPv6 인프라 없이도, 기존 IPv4 인프라를 통해서 IPv6 주소체계를 활용하는 호스트를 IPv4 환경에서도 활용할 수 있게 만들어준다.


다만, 앞서 Dual Stack의 단점으로 언급했던 하드웨어 업그레이드는 여전히 6to4 방식에서도 해결되지 않는다. 오히려 캡슐화를 위한 소프트웨어에 대한 부담만 늘 수 있겠다는 생각도 든다. 그리고 캡슐화와 비캡슐화 과정 또한 추가적인 리소스를 활용하기에 호스트 머신의 리소스 활용 측면에서도 부정적인 결과를 초래한다는 점도 고려해야 한다.


두 방식을 간단하게 테이블로 비교해보자.

 

Method Advantages Disadvantages
Dual Stack
  • 간단하고 직접적
  • 최고의 성능
  • 장비 및 리소스 필요
6to4 (Turnneling)
  • IPv4 네트워크 통합
  • 별도의 IPv6 인프라 구축 불필요
  • 추가적인 지연 및 복잡성
  • 통신 문제

 

 

3.  Translation (번역)

앞선 터널링과 달리 IPv4와 IPv6 패킷을 직접 번역하여, 두 프로토콜의 직접적인 통신을 가능하게 하는 기술이다. 구체적으로 NAT64, DNS64, SIIT(Stateless IP/ICMP Translation)이라는 세가지 기술이 대표적이다.


NAT64

  • IPv6-only 클라이언트가 IPv4 서비스에 액세스할 수 있게 하는 변환 매커니즘이다.


  • 세션을 통해 상태를 유지하며(Stateful), 저장된 연결 상태 정보를 통해 IPv4와 IPv6 주소를 서로 매핑시켜놓는다. 


  • 실제 네트워크 환경에서 단독으로 사용되기보다는 DNS64와 함께 사용되어, IPv6 클라이언트가 IPv4 웹사이트에 접근할 수 있도록 도와준다.

  • IPv4-only 주소를 IPv6 형태로 변환하는데는 활용되지 않기 때문에, 이는 별도로 464XLAT라는 기술을 활용해야 한다.



DNS64

  • IPv6-only 클라이언트가 IPv4-only 목적지의 DNS 질의를 수행할 때 IPv4의 응답을 받아 IPv6 주소로 변환해 반환하는 기술이다.


  • 실제 웹서비스 환경에서는 클라이언트는 대체로 DNS 질의를 통해 목적지의 IP 주소를 확인한다. IPv6-only 클라이언트가 DNS 질의를 실행하는 경우 만약 DNS에 저장된 응답이 A 레코드 (IPv4)라면, DNS64는 이를 AAAA 레코드 (IPv6) 형태로 변환하여 클라이언트에게 반환한다.


  • 반환된 AAAA 레코드는 패킷의 목적지 주소에 담긴다. 그대로 보내는 경우 IPv6 인프라가 아니라면 목적지를 찾을 수 없을 것이다.


  • 이 경우 NAT64 기술이 활용된다. AAAA 레코드는 NAT64를 거쳐 원래의 A 레코드로 다시 변환되며 그 후 변환된 A 레코드를 기반으로 IPv4 패킷을 원래의 IPv4 목적지로 전송한다.



SIIT (Stateless IP/ICMP Transition)

  • 상태가 없는 변환을 제공한다. 이는 직전의 NAT64와 정확히 반대되는 내용이다.


  • 별도의 세션을 활용하지 않기 때문에 각 패킷은 개별적이다.


  • A 레코드를 AAAA 레코드로 변화시키거나 그 반대의 경우도 가능하다.


  • 별도의 세션 관리가 없다는 점은 곧 장점이자 단점으로 작용한다.


    1. 예를 들어 대규모의 네트워크의 경우, 굳이 세션을 사용해도 되지 않아도 되는 서비스라면 이는 메모리 사용량 측면에서 큰 이점을 가져다준다. 또한 세션 테이블의 관리가 필요없다는 점은 운용과 관리 측면에서 훨씬 유리하다.


    2. 그러나 만약 별도의 세션을 통해 관리가 필요한 애플리케이션이라면 SIIT를 사용할 수 없다.


      • e.g. 동적 포트 할당


        • Stateful 변환에서는 세션의 상태를 추적하므로, 클라이언트나 서버에서 동적으로 할당된 포트에 대한 매핑을 유지할 수 있다.


        •  만약 포트 할당이 새롭게 필요한 경우, 해당 포트 번호에 대한 변환을 동적으로 수행한 후 이를 세션 테이블에 저장하게 지속적으로 추적할 수 있다.

 


여기까지해서 내용이 마무리되었다. 다른 분야도 마찬가지지만 네트워크 분야야 깊게 파고들어갈 수록 정말 끝이 없다는 느낌을 많이 받는다. 사실 간단하게 주제만 정리하고 글을 마무리했었어도 됐지만, 내 스스로가 IPv6에 대해서 이해를 잘 하고 있지 못하고 있다는 느낌을 받게되어 글이 쓸데 없이 길어지고 말았다.


아무쪼록 주제 자체도 흥미롭고, 내용이 (너무) 많은 거 말고는 이해가 그렇게 어렵지 않기에 한 번 익혀두고 다시 읽어보면 금방 기억이 날 듯 하다. 부족한 네트워크 지식이 조금은 늘었다고 생각하니 행복하다.

 

 

'CS' 카테고리의 다른 글

L7 프로토콜 - HTTP  (0) 2023.08.08
정적 라이브러리  (0) 2022.12.05
실행가능한 목적파일  (0) 2022.12.03
Linking(링킹) 소개  (0) 2022.12.02
시간 지역성을 위한 캐시 재배치  (0) 2022.12.01

로깅은 프로그래밍에서 가장 중요한 것 중 하나입니다. 기록을 하는 것은 비단 프로그래밍 뿐 아니라 다른 작업에서도 중요하지만 휘발성으로 계속해서 새로운 작업이 발생하는 프로그래밍에선 그 의미가 더욱 남다릅니다.

 

 

해당 글에서는 파이썬에 로그를 기록하는 방법을 간단히 소개하며 파이썬을 사용한다면 일상적으로 쉽게 구현할 수 있는 게으른 로깅 (Lazy logging)이라는 모범 사례를 소개합니다. 

 

 

파이썬 로깅

 

본격적으로 게으른 로깅을 설명하기 앞서 간단하게 파이썬에서의 로깅을 이해해보겠습니다.

 

 

하단의 코드는 로깅을 수행하는 가장 간단한 코드입니다. warning이라는 로그 레벨과 함께 함께 출력될 메세지를 전달하고 있습니다.

 

 

>>> import logging
>>> logging.warning("Hello logging")
WARNING:root:Hello logging

 

 

warning을 로그 레벨로 설정한 후 지정된 "Hello logging"이라는 에러메세지를 출력하고 있습니다. Python의 기본 로깅 레벨은 warning이며 이는 해당 로그 레벨 이상의 로그들만 출력 혹은 파일에 기록됨을 의미합니다. 때문에 다음과 같은 실행에서는 아무런 결과를 얻을 수 없습니다.

 

 

>>> logging.info(“Hello logging”)

 

 

info 레벨은 warning 레벨보다 아래 단계에 위치하며 앞서 설명한대로 해당 코드는 로그를 기록하지 않습니다.

 

 

참고: 파이썬 로깅 레벨

 

 

우리는 파이썬을 통해 로그를 기록하며 자연스럽게 logging 모듈을 가져옵니다. 우리가 가져오는 logging 모듈은 기본으로 제공되는 로깅 모듈이므로 별도의 설정을 통해 로그 레벨을 바꿀 수 있습니다. 

 

 

로그 레벨을 변경하는 방법에는 2가지 방법이 있습니다. 이를 이해해기 위해서는 logger라는 존재에 대해 이해해야 합니다. 

 

 

logging 모듈 내의 getLogger라는 메서드는 logger를 반환합니다. 

 

 

logger = logging.getLogger("my_logger")

 

 

이때 logger는 로깅을 하는데 있어 설정 가능한 다양한 구성들을 담고 있는 하나의 인스턴스입니다. 우리가 설정하고자 하는 로그 레벨도 바로 logger를 통해 설정할 수 있구요.

 

 

logger를 이해하셨다면 로깅을 할 수 있는 첫번째 방법을 이해하실 수 있습니다.

 

 

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("my_logger")

 

 

가장 기본적인 설정 방법이며 basicConfig가 수행된다면 그 이후에 만들어지는 모든 logger의 로깅 레벨이 basicConfig에 명시된 대로 설정되는 것을 알 수 있습니다.

 

 

>>> logging.basicConfig(level=logging.DEBUG)
>>> logger = logging.getLogger("my_logger")
>>> logger.info("Hello logging")
INFO:my_logger:Hello logging

 

 

basicConfig는 편리하게 루트 Logger를 설정하는 방법을 제공하지만 한 번 설정된 루트 Logger의 설정은 변경되지 않습니다. 쉽지만 그만큼 자유도가 떨어지기에 사용방식이 다소 제한적입니다. (한 번 적용이 된다면 해당 런타임에 영구적입니다)

 

 

>>> logging.basicConfig(level=logging.DEBUG)
>>> logger = logging.getLogger("my_logger")
>>> logger.info("Hello logging")
INFO:my_logger:Hello logging
>>> logging.basicConfig(level=logging.ERROR)
>>> logger.info("Hello logging")
INFO:my_logger:Hello logging

 

 

두 번째 방식은 setLevel 메소드를 통한 설정 변경입니다.

 

 

앞서 설명한 문제는 setLevel이라는 메소드를 통해 해결할 수 있습니다. 먼저 basicConfig의 방식과의 가장 근본적인 차이는 로깅 설정을 일괄적으로 적용시키냐(basicConfig), 개별 logger에 적용시키냐의 차이입니다. 코드로 확인해보시죠.

 

 

# 1. basicConfig
>>> logging.basicConfig(level=logging.INFO)
>>> logger.info("Hello logging")
INFO:my_logger:Hello logging

# 2. logger 생성
>>> logger1 = logging.getLogger('my_logger_1')
>>> logger2 = logging.getLogger('my_logger_2')
>>> logger1.info('This is an informational message from logger1')
INFO:my_logger_1:This is an informational message from logger1
>>> logger2.info('This is an informational message from logger2')
INFO:my_logger_2:This is an informational message from logger2

# 3. logger1에 대해서 로깅 레벨 변경
>>> logger1.setLevel(logging.ERROR)
>>> logger1.info('This is an informational message from logger1 - it will not be logged')
>>> logger1.error('This is an error message from logger1')
ERROR:my_logger_1:This is an error message from logger1

# 4. logger2는 로깅 레벨 유지
>>> logger2.info('This is another informational message from logger2')
INFO:my_logger_2:This is another informational message from logger2

 

 

먼저 코드에서는 기본적으로 모든 logger에 적용될 기본 레벨을 basicConfig를 통해 명시합니다. 때문에 해당 라인 이후의 모든 logger는 error 로깅 레벨로 설정될 것입니다.

 

 

생성된 2개의 logger 중에서 logger1의 경우 중간에 설정을 변경해보았습니다. info 단계에 대한 출력이 이뤄지지 않았으며 설정이 성공적으로 변경되었음을 확인할 수 있습니다. logger2의 경우에는 여전히 Info 단계에 대한 출력이 이뤄짐을 확인할 수 있습니다.

 

 

로깅 레벨외에도 로깅을 통해 출력을 할지, 파일에 기록할지, 로그 메세지는 어떻게 구성할지 등 다양한 설정을 logger에 담을 수 있습니다.

 

 

게으른 로깅

 

로깅을 수행할 때 활용하는 warning 함수는 다음의 구조를 갖습니다 (다른 로깅 레벨도 동일합니다).

 

 

def warning(msg, *args, **kwargs):

 

 

warning에서는 기본적으로 msg를 첫 번째 인수로 요구하는 구조이며 추가적으로 더 많은 인수를 추가할 수 있도록 구성되어 있습니다.

 

 

이번에는 별도의 설명없이 코드를 먼저 보여드리면서 글을 이어나가보겠습니다.

 

 

>>> import logging
>>> class RaiseWhenPrinted:
...     def __str__(self):
...         raise Exception(“I was printed”)
>>> raise_when_printed = RaiseWhenPrinted()

 

 

현재 로깅 레벨은 warning 단계입니다. 앞서 설명한 별도의 로깅을 설정하지 않았기에 루트 logger의 기본설정을 따른 결과입니다.  

 

 

이어서 클래스의 __str__ 함수를 재정의합니다. 해당 클래스에서는 별도로 __str__ 에 접근하는 경우 에러를 발생시킬 것입니다.

 

 

>>> logging.info(“Logging {}”.format(raise_when_printed))

 

 

해당 코드를 실행하면 다음과 같은 결과를 얻습니다.

 

 

Traceback (most recent call last):
File “<input>”, line 1, in <module>
File “<input>”, line 3, in __str__
Exception: I was printed

 

 

앞서 설명하였듯 루트 logger의 레벨은 warning입니다. 그러나 info 레벨의 로깅에도 __str__ 함수에 접근하여 예외가 발생한 것을 확인할 수 있습니다.

 

 

이어서 게으른(Lazy) 방식으로 문자열을 전달해보겠습니다.

 

 

>>> logging.info(“Logging %s”, raise_when_printed)

 

 

해당 코드에서는 예외가 발생하지 않았습니다.

 

 

이는 __str__ 메소드가 호출되지 않았다는 것을 의미함과 동시에 게으른 로깅을 수행하는 경우 실제로 __str__이 요구되지 전까지는 해당 메소드가 사용되지 않음을 의미합니다.

 

 

만약 로깅 레벨이 warning으로 설정된 로깅을 수행하면 다음과 같은 결과를 얻을 수 있습니다.

 

 

>>> logging.warning("Logging %s", raise_when_printed)
--- Logging error ---
Traceback (most recent call last):
  File "/Users/mook/anaconda3/lib/python3.11/logging/__init__.py", line 1110, in emit
    msg = self.format(record)
          ^^^^^^^^^^^^^^^^^^^
  File "/Users/mook/anaconda3/lib/python3.11/logging/__init__.py", line 953, in format
    return fmt.format(record)
           ^^^^^^^^^^^^^^^^^^
  File "/Users/mook/anaconda3/lib/python3.11/logging/__init__.py", line 687, in format
    record.message = record.getMessage()
                     ^^^^^^^^^^^^^^^^^^^
  File "/Users/mook/anaconda3/lib/python3.11/logging/__init__.py", line 377, in getMessage
    msg = msg % self.args
          ~~~~^~~~~~~~~~~
  File "<stdin>", line 3, in __str__
Exception: I was printed
Call stack:
  File "<stdin>", line 1, in <module>
Message: 'Logging %s'
Arguments: (<__main__.RaiseWhenPrinted object at 0x10a891ed0>,)

 

 

실행 결과를 통해 예외가 발생하였으며 실제 __str__에 접근이 이뤄짐을 확인할 수 있습니다.

 

 

게으른 로깅의 원리

 

로그 레벨 함수의 정의를 다시 확인해보겠습니다.

 

 

def warning(msg, *args, **kwargs):

 

 

만약 우리가 f-string이나 format 메소드를 통해 문자열을 작성하는 경우 인터프리터는 msg 인수를 런타임에 즉시 해석하여 결과를 내놓게 됩니다.

 

 

logging.info(f"Logging {raise_when_printed}")

 

 

때문에 다음과 같은 호출은 사용여부와 관계없이 즉시 평가되어 메모리에 올라가게 될 것입니다.

 

 

여기서 대신 게으른(Lazy) 방식으로 문자열을 처리한다면

 

 

logging.info("Logging %s", raise_when_printed)

 

 

인터프리터는 해당 코드를 런타임 실행 당시에 바로 해석하지 않고 이후에 등장하는 인수가 인스턴스임을 인터프리터에가 알리기만 합니다. 때문에 해당 시점에서는 __str__ 메소드는 실행되지 않습니다.

 

 

프로그램이 실제 해당 라인을 실제로 표준 출력으로 내보야하는 경우 내부적으로 로그 수준이 어떻게 구성되어있는지를 기반으로 호출 여부가 결정됩니다. 만약 호출이 일어나지 않았다면 아무 일도 일어나지 않습니다. 호출이 발생했다면 그제서야 __str__ 메소드에 접근하여 예외가 발생할 것입니다.

 

 

퍼포먼스 테스트

 

퍼포먼스를 확인하기 위해  timeit 라이브러리를 사용합니다. 해당 라이브러리는 명령을 실행하는데 걸리는 시간을 계산하는데 활용됩니다.

 

 

>>> import timeit
>>> lazy = “logging.info(‘lazy %s’, ‘logging’)”
>>> not_lazy = “logging.info(‘not lazy {}’.format(‘logging’))”
>>> setup = “import logging”

 

 

두 개의 로깅 명령을 생성하고 추가적으로 로깅에 필요한 환경인 logging 모듈을 환경으로 구성합니다.

 

 

이를 통해 다음의 테스트를 수행하였습니다.

 

 

>>> timeit.timeit(not_lazy, setup=setup)
1.3170759610000005
>>> lazy = "logging.info('lazy %s', 'logging')"
>>> timeit.timeit(lazy, setup=setup)
1.0643428150000034

 

 

간단한 로깅 작업이지만 약 30%의 속도 차이가 발생하는 것을 확인할 수 있습니다. 그리 크게 느껴지는 단위가 아닐 수도 있지만, 구현에 비해 얻을 수 있는 이점이 상당하다는 점에 주목해볼 수 있겠습니다.

 

 

결론

 

지연 로깅 자체는 공식 문서에 기록은 되어있지만, 그와 별개로 파이썬의 일반적인 관행이라고 보기에는 모두가 동의하지 않을 수도 있는 방식입니다.

 

 

앞서 살펴보았듯 구현은 쉽고 이점은 확실한 방식이라고 할 수 있습니다. 미세한 차이이기에 속도에 민감하지 않다면 수많은 로깅 메소드 호출에 대한 리펙토링을 굳이 수행해야 할까는 의문입니다. 다만 새로 작성될 로깅에서는 지연된 로깅을 쓰는 것이 대부분의 상황에서 옳을 것으로 예상됩니다.  다른 부분에서의 Trade-Off가 있을 수도 있겠지만 어떠한 단점이 있을지에 대해서는 아직 떠오르는 바는 없으며 대부분의 상황에서 모범 사례로 동작할 것이라고 생각합니다.

'Programming Language > Python' 카테고리의 다른 글

[pytest] @pytest.fixture  (0) 2023.05.02
logging 사용법과 관련 이슈  (0) 2023.03.27

DAG 작성

mydailylogs
|2023. 7. 9. 17:06

💡 이번 글에서는 DAG가 어떻게 구성되어 있는지를 중점으로 기본적인 Airflow의 사용방법을 소개합니다.



데이터 셋

본격적으로 실습에 들어가기 앞서 이번 실습에 사용할 데이터 셋을 소개합니다.

하단의 링크에서는 우주와 관련된 다양한 데이터를 오픈 API 형태로 제공합니다. 하루 요청 제한(시간 당 15 call) 하에서 누구나 사용가능하며 로켓의 발사, 엔진의 테스트, 우주 비행사의 기록 등과 같은 신기한 정보들을 쉽게 제공합니다.

https://thespacedevs.com/llapi

 

TheSpaceDevs - Home

A group of space enthusiast developers working on services to improve accessibility of spaceflight information.

thespacedevs.com

 

Example을 한번 들어가보시면 로켓의 카운트 다운이 보입니다. 마음이 왠지 모르게 두근두근해집니다 !


아래의 링크는 해당 API에 대한 문서인데요. 다양한 REST API 리소스를 제공하니 한번 살펴보시고 원하는 데이터를 살펴보시면 좋을 것 같습니다. 해당 글에서는 발사가 임박한 로켓의 정보를 활용하고자 합니다. 다음의 URL을 통해서 기본적인 요청을 수행하며 테스트를 먼저 진행해보겠습니다.

 

$ curl -L "https://ll.thespacedevs.com/2.2.0/launch/upcoming"
{
  "count": 299,
  "next": "https://ll.thespacedevs.com/2.2.0/launch/upcoming/?limit=10&offset=10",
  "previous": null,
  "results": [
    {
      "id": "5d3e11d7-5d13-4c8c-b94a-c73da97c39a5",
      "url": "https://ll.thespacedevs.com/2.2.0/launch/5d3e11d7-5d13-4c8c-b94a-c73da97c39a5/",
      "slug": "falcon-9-block-5-starlink-group-5-13",
      "name": "Falcon 9 Block 5 | Starlink Group 5-13",
      "status": {
        "id": 3,
        "name": "Launch Successful",
        "abbrev": "Success",
        "description": "The launch vehicle successfully inserted its payload(s) into the target orbit(s)."
      },
...
			"webcast_live": false,
      "image": "https://spacelaunchnow-prod-east.nyc3.digitaloceanspaces.com/media/launch_images/falcon2520925_image_20230522091938.png",
...


JSON을 살펴보시면 로켓의 ID와 이름, 이미지 URL을 통해 로켓 발사 정보가 주어집니다.

REST API 콜 관련해서 파라미터가 궁금하시다면 아래 문서를 확인해보시길 바랍니다.

https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_LL2.md

참고) "next": "https://ll.thespacedevs.com/2.2.0/launch/upcoming/?limit=10&offset=10" 처럼 filter를 통해 paginate가 가능합니다. 명시하지 않았다면 기본적으로 첫 페이지에서부터 10개의 로켓 정보를 가져옵니다. limit=<number>를 늘려주면 최대 100개의 로켓 정보까지 가져올 수 있으며, offset=<number>를 통해 pagination이 가능하다고 하니 참고하시기 바랍니다.


사실 위의 curl을 수행해보셨다면 의문이 드실 수 있습니다. “저렇게 간단하게 데이터를 뽑을 수 있다면 bash script를 사용해서 가져오면 될 껄 굳이 Airflow까지 써야하나 …” (넵 바로 제가 들었던 첫 번째 의문입니다.)

물론 Bash 스크립트 형태만으로 데이터를 가져와도 좋겠지만, 가장 먼저 발생할 문제는 이전 글에 명시하였던 Incremental update시에 발생하는 문제에 대해 능동적인 대처가 어렵다는 점일 것이다. 주기적으로 실행하는 추출 작업이 어느날 갑자기 문제가 생겼는데 중간에 문제가 된 부분만을 다시 추출해야한다면? 기존 스크립트의 추출로는 절대 쉽지 않다.

Airflow는 태스크를 DAG 형태로 제공하며 파이프라인을 세분화합니다. 문제가 발생한 태스크가 보다 명확해집니다. 또한 이를 시각적으로 확인까지 가능합니다. 또한 태스크 형태로 제공하다보니 의존성이 없다면 병렬화까지도 훨씬 용이해질 것입니다.

물론 Airflow가 무조건 옳을 수는 없겠습니다. 위에서 휘황찬란하게 서술했지만 현업에서 사용하게 되면 Airflow만큼 데이터 엔지니어를 괴롭히는 서비스도 없다고 합니다. 그럼에도 불구하고 Airflow는 기존 방식에 비해서는 확실한 개선이라고 말할 수 있겠습니다.

 

혹여 CeleryExecutor를 활용하신다면…

Airflow가 CeleryExecutor를 사용하고 있다면 저장되는 위치가 호스트 머신이 아니라 Airflow의 컨테이너들이 공유하는 볼륨이이기에 확인이 참 어렵습니다. 예를 들어 특정 컨테이너 (Airflow-trigger-1)에서 폴더를 만들어도 이는 개별 컨테이너의 변경 사항일 뿐이며 전체 Airflow 클러스터에는 반영이 되지 않습니다. 예를 들어 분명 데이터를 API에서 받아서 /tmp에 저장을 했지만, 도무지 확인할 방법이 없습니다. 특정 컨테이너의 터미널에 접근하여 ls를 수행해볼 수는 있겠지만, CeleryExecutor에서 사용하는 볼륨은 특정 컨테이너에 종속되는 볼륨이 아니기에 분명 Web UI에서는 파이프라인이 성공적으로 실행되었지만 아무리 찾아봐도 데이터를 찾을 수가 없습니다.

우리가 DAG를 작성하며 “/tmp/data”라는 경로에 데이터를 저장하고 싶다면 특정 컨테이너에 접근하여 “/tmp/data”라는 폴더를 만드는게 일반적인 리눅스 파일시스템의 접근이지만 CeleryExecutor는 컨테이너 환경 하에서 분산 처리라는 특수한 상황을 가정하기에 접근이 조금 달라야만 합니다.

가장 간단한 해결을 데이터를 저장하고자 하는 호스트 파일시스템의 경로를 Airflow 클러스터에서 사용하는 볼륨에 마운트시켜주는 것입니다. 혹여 Docker-compose로 빌드를 진행하신 상황에서 CeleryExecutor를 사용하고 있는 환경을 사용 중이신 경우, airflow 컨테이너들이 공유하는 x-airflow-common라는 Placeholder 아래의 volume이라는 필드를 통해 호스트 볼륨을 airflow 클러스터에 마운트할 수 있습니다.

version: '3.8'
x-airflow-common:
  &airflow-common
  # In order to add custom dependencies or upgrade provider packages you can use your extended image.
  # Comment the image line, place your Dockerfile in the directory where you placed the docker-compose.yaml
  # and uncomment the "build" line below, Then run `docker-compose build` to build the images.
  image: ${AIRFLOW_IMAGE_NAME:-apache/airflow:2.6.2}
  ...
  volumes:
    - ${AIRFLOW_PROJ_DIR:-.}/dags:/opt/airflow/dags
    - ${AIRFLOW_PROJ_DIR:-.}/logs:/opt/airflow/logs
    - ${AIRFLOW_PROJ_DIR:-.}/config:/opt/airflow/config
    - ${AIRFLOW_PROJ_DIR:-.}/plugins:/opt/airflow/plugins
    - /mnt/c/Users/admin/data/:/data/ # 여기!


별 다른 점은 없고, 직전의 정의에서 가장 아래줄의 형태로 호스트 볼륨을 airflow-common 내부의 볼륨에 마운트해주시면 됩니다. 제 경우에는 윈도우 환경의 wsl 파일 시스템을 사용하고 있으니, 혹여 다른 설정이 필요하신 분들께서는 호스트 파일시스템의 권한만 잘 확인해주신 후 마운트해주신다면 이후 문제 없으시리라 생각합니다.

Docker-compose.yaml을 찾고 계신분이 있다면 다음 사이트를 참고해보세요.

https://airflow.apache.org/docs/apache-airflow/2.6.2/docker-compose.yaml


Airflow에서 공식적으로 제공하는 yaml 파일입니다. 다른 것은 없고 볼륨 쪽에서 마운트 부분의 경우에는 기본 정의에는 없는 사항이니 상황에 맞게 추가해주시면 됩니다. (부디 제 설명이 직관적으로 잘 전달되었길 …!)

 

DAG 작성

본격적으로 Airflow를 통해 로켓 발사 데이터셋에 대한 파이프라인을 구성해보겠습니다.

파이프라인의 목표는 최대한 간단하게 설정해보겠습니다. 바로 로켓의 이미지를 제 호스트 컴퓨터에 저장하는 것입니다.

본격적으로 작성하기 앞서 제 환경을 다시 한번 설명드리면

1. 저는 Airflow를 Docker compose를 통해 컨테이너 환경에서 실행 중에 있습니다.

2. 저는 Docker를 Window 10의 wsl 환경 아래에서 실행 중에 있습니다.

3. 제 Airflow 클러스터는 CeleryExecutor를 기반으로 실행되고 있으며, 별도의 볼륨 마운트를 통해 호스트의 경로와 Airflow 클러스터 내부의 경로를 마운트해놓은 상황입니다.

앞서 API 호출을 수행한 결과를 바탕으로 Image 필드의 정보를 바탕으로 데이터를 호스트 머신에 저장해보겠습니다.

앞서 설명하였듯, DAG는 여러 태스크로 구성되며 여느 아키텍처가 그렇듯 태스크를 어떻게 쪼개는 것이 정답인지는 대부분 명확하지 않습니다. 다양한 DAG 작성 경험을 통해 각 시나리오 상에서 발생할 수 있는 장단점을 익혀가면 OK라고 생각합니다.

해당 글에서는 세 태스크로 나뉜 DAG를 작성하고자 합니다.

 

 

Task 1: curl 을 통해 로켓 API를 통해 데이터를 가져와 내부에 저장합니다.


본격적으로 각 태스크들을 작성하기에 앞서서 DAG를 정의해줍니다. 앞으로 정의할 태스크들은 해당 DAG에 속할 예정입니다.

dag = DAG(
    dag_id="download_rocket_launches", # DAG 구분자
    start_date=airflow.utils.dates.days_ago(14), # DAG의 처음 실행 시작 날짜
    schedule_interval=None, # DAG 실행주기
)


BashOperator를 통해 앞서 수행했던 curl 명령어를 별도의 태스크를 통해 구성합니다. Operator는 태스크를 사용하기 쉽도록 미리 구성된 템플릿이라고 생각해주시면 됩니다. (Operator 이야기를 하다보면 끝이 나지 않을 것 같아 추후 다른 글로 찾아오도록하겠습니다)

download_launches=BashOperator(
    task_id="download_launches", # task 구분자
    bash_command="curl -o /data/launches.json -L 'https://ll.thespacedevs.com/2.2.0/launch/upcoming'", 
    dag=dag, # 속하는 DAG
)


parameter 자체가 상당히 직관적이기 때문에 별도의 설명없이도 코드 내부의 주석을 통해서도 무리없이 이해하실 수 있으리라 생각합니다.

여기까지 태스크가 수행이 된다면 /data 경로 바로 아래에 launches.json이라는 파일이 생성되어야 합니다. 좀 더 아래쪽에서 파이프라인을 실행해보며 정말 생성이 된 건지 확인해보겠습니다.

 

 

Task 2: 내부 Image라는 필드를 통해 해당 URL에서 이미지를 다운받아 호스트에 저장합니다.

여기까지 왔다면 각 Image의 URL 정보를 가져올 수 있게 되었으니 실제 주소에 가서 이미지를 다운받아야겠죠. 앞선 작업과는 달리 JSON 필드에 접근하여 각 주소로부터 API 요청을 보내는 작업이 필요합니다. 작업이 조금 더 세밀하니 이번에는 파이썬 코드를 통해 태스크를 표현합니다.

def _get_pictures():
    pathlib.Path("/data/images").mkdir(parents=True, exist_ok=True)

    with open("/data/launches.json") as f:
        launches=json.load(f)
        image_urls=[launch["image"] for launch in launches["results"]]
        for image_url in image_urls:
            try:
                response=requests.get(image_url)
                image_filename=image_url.split("/")[-1]
                target_file=f"/data/images/{image_filename}"
                with open(target_file, "wb") as f:
                    f.write(response.content)
                print(f"Downloaded {image_url} to {target_file}")
            except requests_exceptions.MissingSchema:
                print(f"{image_url} appears to be an invalid URL.")
            except requests_exceptions.ConnectionError:
                print(f"Could not connect to {image_url}.")


먼저 pathlib.Path는 해당 경로에 폴더가 존재하는 지를 확인하고 만약 해당 경로에 폴더가 없다면 mkdir을 수행하여 경로를 생성해줍니다.

 pathlib.Path("/data/images").mkdir(parents=True, exist_ok=True)


그 다음에는 앞서 저장한 json 파일에 접근하여 데이터를 읽어옵니다. 이때 앞서 테스트 호출을 수행하면서 보셨듯 results의 하위 필드에 위치한 image 정보를 가져와야 합니다. 이미지의 이름의 경우에는 URL의 맨 마지막 부분을 잘라오며 정의합니다.

마지막에는 데이터를 저장해야겠죠. 데이터를 쓸때는 “wb (write binary)” 권한을 부여하는데 이는 현재 저장하고자 하는 데이터가 이미지(Non-text) 데이터이기에 Binary 형태로의 Write를 의미한다고 보시면 됩니다.

또 중요한 점은 Data Pipeline의 경우 에러 처리에 대해서 상당히 민감해야 한다는 점입니다. 해당 글에서는 간단히 try-catch를 사용하며 URL 구성이 이상하다던지, URL에 접근했는데 정작 이미지가 없다던지 등의 에러를 만났을 때 로그에 표시해줄 뿐입니다. 향후 에러 처리에 대해서도 별도의 글로 다뤄보도록 하겠습니다. (어째 써야할 글이 점점 늘어나는 것 같네요)


마지막으로 태스크의 동작 정의가 끝났다면, DAG에 등록해주며 마무리합니다. 직전의 BashOperator와 달리 파이썬 함수를 태스크로 옮기기 위해서는 PythonOperator를 사용합니다.

get_pictures=PythonOperator(
    task_id="get_pictures",
    python_callable=_get_pictures, # 등록할 Python 함수
    dag=dag,
)

 

 

Task 3: 실제로 잘 저장되었는지 확인합니다.

이후에는 간단하게 데이터 저장 경로에 접근하여 몇 개의 파일이 저장되었는지를 확인합니다.

notify=BashOperator(
    task_id="notify",
    bash_command='echo "There are now $(ls /data/images | wc -l) images."',
    dag=dag,
)


명령어는 중간의 파이프(’|’) 를 통해 ls의 결과를 wc에 넘기고 있습니다. 혹여 wc -l이 생소하시다면, wordcount -line이 줄여져있다고 기억하시면 바로 이해가 되실 것 같습니다.

여기까지 왔다면 DAG 내의 태스크 간의 실행 순서를 명시해줘야 합니다. 매우 직관적인 형태임을 확인하실 수 있습니다.

download_launches >> get_pictures >> notify


이렇게 정의한 DAG를 정리해보면 다음과 같습니다. 경로 쪽에서만 잘 신경써주시면 딱히 문제가 될 부분이 없는 코드입니다.

import json
import pathlib

import airflow
import requests
import requests.exceptions as requests_exceptions
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator

dag = DAG(
    dag_id="download_rocket_launches",
    start_date=airflow.utils.dates.days_ago(14),
    schedule_interval=None,
)

download_launches=BashOperator(
    task_id="download_launches",
    bash_command="curl -o /data/launches.json -L 'https://ll.thespacedevs.com/2.2.0/launch/upcoming'",
    dag=dag,
)

def _get_pictures():
    pathlib.Path("/data/images").mkdir(parents=True, exist_ok=True)

    with open("/data/launches.json") as f:
        launches=json.load(f)
        image_urls=[launch["image"] for launch in launches["results"]]
        for image_url in image_urls:
            try:
                response=requests.get(image_url)
                image_filename=image_url.split("/")[-1]
                target_file=f"/data/images/{image_filename}"
                with open(target_file, "wb") as f:
                    f.write(response.content)
                print(f"Downloaded {image_url} to {target_file}")
            except requests_exceptions.MissingSchema:
                print(f"{image_url} appears to be an invalid URL.")
            except requests_exceptions.ConnectionError:
                print(f"Could not connect to {image_url}.")

get_pictures=PythonOperator(
    task_id="get_pictures",
    python_callable=_get_pictures,
    dag=dag,
)

notify=BashOperator(
    task_id="notify",
    bash_command='echo "There are now $(ls /data/images | wc -l) images."',
    dag=dag,
)

download_launches >> get_pictures >> notify

 


DAG 실행

무사히 Docker compose를 통해 Airflow가 띄워졌다면, http://localhost:8080/home에서 Airflow의 Web UI에 접근할 수 있습니다.

참고로 Airflow 공식 문서에서 제공하는 정의의 경우 ID와 PW가 airflow로 동일합니다.

로그인에 성공하셨다면 DAGs라는 제목과 함께 DAG들의 목록을 확인하실 수 있습니다.


지금 제 환경에서는 위에서 정의한 DAG가 실행이 완료되어 있는 상태인데요. 아마 방금 실행을 하셨다면 아직 DAG를 제출하지 않았으므로 인식이 안되는게 맞습니다.

docker compose up을 해주셨던 저장소를 확인해보시면 dags라는 폴더가 생성되는 것을 확인할 수 있습니다. 이름에서 알 수 있듯 DAG를 제출하는 폴더이고 아래 그림처럼 파이썬 코드 형태로 DAG 정의를 제출해주시면 됩니다.


여기까지 DAG의 추가까지  완료되었다면 UI에서 실행해주시면 됩니다.

실행하는 방법은 여러가지인데, 일단 해당 DAG로 들어가서 우측 상단에 있는 시작 버튼을 눌러주겠습니다.


만약 실행해서 문제가 없다면 제 실행처럼 초록색으로 완료가 되어야 합니다.

혹여 문제가 생겼을 시 로그를 추적해볼 수 있을텐데, 각 테스크 실행에 대한 로그는 제 화면 기준 초록색의 긴 사각형 아래의 개별 태스크를 의미하는 정사각형들을 눌러주셔야 합니다. 빨간색으로 표시되거나 주황색으로 표시되어 정상 실행이 되지 않은 태스크를 눌러주시면 되겠습니다. (저는 정상 실행이기에 빨간색이나 주황색은 없습니다.)


그러면 Details, Graph 옆에 친절하게 Logs를 제공하는 페이지 링크가 존재합니다.

제 경우 문제 없이 실행되어 이러한 메세지를 받을 수 있었습니다.


마지막으로 실제 호스트 PC의 경로에도 데이터가 저장된 것을 확인하였습니다.

 


마치며

저 또한 이제 막 Airflow를 공부하는 입장에서 거창한 형용사들로 글을 꾸며보았는데, 사실 그리 어려운 내용은 아닌지라 혹여 이해가 안되시는 부분이 있다면 제 글솜씨가 아쉬운 탓이라고 양해해주시면 감사하겠습니다 ㅠㅠ 아무쪼록 이해가 안되시는 부분이 있다면 최대한 제가 아는 선에서 도움드리겠습니다. 피드백도 환영합니다. 언제든 답글 주세요!

'Data Engineering > Airflow' 카테고리의 다른 글

Airflow의 도입 배경  (0) 2023.06.28
Airflow의 DAG  (0) 2023.06.28

 

데이터 파이프라인을 구성하다보면 근거 없는 자신감이 들 때가 있다. 그럴싸한 아키텍처를 설계하고 나면 이러이러한 서비스를 뚝딱뚝딱 이어붙이면 문제 없이 동작하겠지라는 생각이 사실 매번 드는 것 같다. 그러나 어디까지나 이상이고 현실은 다르다. 데이터 파이프라인은 많은 이유로 실패한다. 문제 상황도 아주 제각각인데, 데이터의 소스가 매우 다양하기에 발생하는 문제들, 오픈소스 호환성 때문에 발생하는 문제들, 데이터 파이프라인들 간에 의존도에 대한 이해가 부족하기에 발생하는 문제들 등 수많은 문제가 그렇다. 특히나 데이터 소스 간에 의존성이 발생하기 시작한다면 더욱 문제는 복잡해진다. 예를 들어 마케팅 채널 정보의 업데이트가 안된다면 다른 모든 정보들의 업데이트가 이뤄지지 않는 상황에서는 마케팅 채널 정보와 다른 정보들 간의 의존 관계가 형성되게 된다. 문제를 따라가다보면 쌓여있던 문제들이 왜 이제 왔냐며 나를 기다려왔다는 듯이 반겨준다. 물론 나는 반갑지 않다.

Airflow 서비스의 본질적인 필요성을 이해하기 위해서는 Incremental Update 환경에서의 Backfill에서 발생하는 여러 문제 상황들을 이해해야 한다. Incremental update는 데이터 저장소에 데이터를 업데이트할 경우 모든 데이터를 처음부터 다시 쓰는 것이 아니라 새롭게 추가된 부분만을 뒤에 이어붙이는 방식이다. Incremental update의 반대되는 개념으로는 Full Refresh가 있다. 이는 매번 소스의 내용을 전부 읽어오는 방식이다.

Full Refresh와 Incremental update에는 각각의 장단점이 있다. 먼저, Full Refresh를 통해 데이터를 업데이트해나간다면 데이터를 처음부터 읽어오기 때문에 매우 비효율적이다. 특히나 데이터가 커질 수록 Full Refresh를 통해 매번 데이터를 처음부터 써야하는 것은 불가능에 가깝게 들린다. 반면 Incremental update는 daily나 hourly 주기로 추가할 데이터만 쓰므로 비교적 효율적이다. 여기까지만 본다면 Incremental update 방식에는 아무런 문제가 없다.

다만 Inremental update는 유지보수가 매우 어렵다. 만약 소스 데이터 상에 문제가 생기거나, 중복 데이터가 쌓이게 되면 이를 일일히 확인해가며 데이터를 정기적으로 정제해주는 추가작업이 불가피하다. Full Refresh의 경우 매번 소스의 내용을 전부 읽어오므로 중복의 문제에서는 자유롭다. 혹여 데이터를 가져오는데 있어 중간에 문제가 발생하더라도 다시 처음부터 데이터를 추가하면 되므로 그리 큰 문제가 아닐 것이다. 그럼에도 데이터 엔지니어는 Incremental update를 지향해야 한다. Full Refresh는 매우 안전한 방법이지만 데이터의 크기가 커지는 환경을 고려하지 않기 때문이다. 

데이터가 작을 경우 가능하면 Full Refresh 방식의 데이터 업데이트를 진행한다. 만약 Incremental update만이 가능하다면 대상 데이터소스가 갖춰야할 필드의 조건이 몇가지 있다.

1. Created: 각 레코드가 처음 생성된 시점이 기록되어 있어야 한다. 이는 Incremental update에서 필수적이지는 않지만, 데이터의 수명 주기를 추적하거나 특정 시점 이후 생성된 레코드를 조회할 때 유용하다.

2. Modified: 각 레코드가 마지막에 수정된 시점을 나타낸다. 해당 필드가 있다면, 마지막 업데이트 이후  변경된 레코드만 선택하여 처리할 수 있다. 해당 필드를 통해 데이터 처리의 효율성을 높이고 불필요한 작업을 수행하지 않을 수 있다.

3. Deleted: 해당 필드는 레코드의 삭제 여부를 나타낸다. "Soft delete"라는 개념과 연관이 있는데, 이는 레코드를 데이터베이스에서 물리적으로 제거하는대신 레코드를 "Deleted"라고 표시함으로서 삭제하는 방법을 의미한다. 이렇게 하면, 삭제된 레코드와 그렇지 않은 레코드를 구분할 수 있으며 필요에 따라 레코드를 복구할 수도 있게 된다.

4. 만약 대상 소스가 API라면 특정 날짜를 기준으로 새로 생성되거나 업데이트된 레코드들을 읽어올 수 있어야 한다.

데이터 파이프라인이라면, Incremental update라면 특히나 문제가 발생할 여지가 많으며 데이터가 중복되거나 누락되었을때 Backfillng하는 것이 데이터 엔지니어의 주요한 책무이다. 다시 말해 멱등성(Idempotency)를 보장하는 것이 파이프라인 구축의 가장 중요한 문제이다. 이를 달성하기 위해서는 결국 다음 요소들이 요구된다.

1.  실패한 데이터 파이프라인을 재실행하는데 용이해야 한다.

2.  과거 데이터를 채우는 과정(Backfill)이 쉬워야 한다.

이러한 문제에서 보다 자유롭기 위해서 Airflow는 도입되었다.

먼저 Airflow는 Backfill이 용이하다. 앞서 설명하였듯 DAG로 구성된 태스크들을 시각적으로 쉽게 확인할 수 있다. DAG가 중간에 실패하였다면, 어떤 태스크에서 문제가 발생하였는지를 쉽게 대시보드 형태로 모니터링 할 수 있다. 즉 실패한 데이터 파이프라인의 재실행을 용이하게 만들어주자는 것이 Airflow의 의의이다.

스크립트 기반의 다음의 작업을 수행하는 파이프라인이 있다고 하자.

from datetime import datetime, timedelta

# 지금 시간 기준으로 어제 날짜를 계산
y = datetime.now() - timedelta(1)
yesterday = datetime.strftime(y, '%Y-%m-%d')

# yesterday에 해당하는 데이터를 소스에서 읽어옴
# 예를 들어 프로덕션 DB의 특정 테이블에서 읽어온다면
sql = f"SELECT * FROM table WHERE DATE(ts) = '{yesterday}'"

 

이는 매우 이상적인 파이프라인에서만 유효하다. 앞서 설명했듯 파이프라인은 문제가 발생할 수 밖에 없고 데이터 소스의 복잡성이 늘어날 수록, 운영 기간이 늘어날 수록 문제는 복잡해진다.  만약 올 초의 1월 1일 문제가 생겨서 해당 일자 정보에 대한 Backfill을 수행해야한다면 다음의 코드로 수행을 할 것이다.

간단해보이는 코드이지만, 만약 파이프라인이 많고 복잡해질 수록 이러한 형태의 관리는 실수의 가능성이 높을 수 밖에 없다. 특히 연도를 잘못 기입한 후 파이프라인을 실행해버리면 말그대로 대참사가 발생해버릴 수 있다.

from datetime import datetime, timedelta

# 코드를 직접 수정
yesterday = '2023-01-01'

sql = f"SELECT * FROM table WHERE DATE(ts) = '{yesterday}'"

 

Airflow의 DAG에서는 catchup 파라미터가 True이면서 start_date와 end_date가 적절하게 설정된 경우 손쉽게 Backfill이 가능하다. Airflow에서는 날짜 별로 Backfill의 결과를 기록하고 성공 여부를 기록한다. 이를 통해 이후에도 쉽게 결과를 확인할 수 있게 된다. 해당 성공과 실패의 날짜들은 시스템에서 ETL의 인자로 제공된다. 때문에 데이터 엔지니어는 데이터의 날짜를 계산하지 않고 시스템이 지정해준 날짜('execution_date')를 사용만 하면 된다.

 

from airflow.operators.python_operator import PythonOperator

def sample_task(**context):
    # context로부터 실행 시점을 불러온다
    execution_date = context['execution_date']

    # 시스템에 의해 관리된 execution_date에 의해 어떤 데이터를 업데이트할지를 정할 수 있다.
    ...

task = PythonOperator(
    task_id='sample_task',
    python_callable=sample_task,
    provide_context=True,  # execution_date와 같은 context 변수를 넘길 때 필요하다.
    dag=my_dag,
)

 

파이썬의 datetime등을 통해 업데이트 대상을 선택하는 것은 안티 패턴이다. 대신 Airflow에서 지원하는 execution_date를 활용하여 레코드가 업데이트되는 날짜 혹은 시간을 알아낼 수 있도록 코드를 작성해야 한다.

 

물론 이 경우에도 수많은 하위 문제들이 발생한다. 

때문에 아무리 Airflow를 사용한다고 하더라도 데이터 파이프라인의 입력과 출력을 명확히 하고 이를 문서화하는 것이 중요하다. 특히 테이블이 늘어나고 데이터가 복잡해지는 경우 분석팀에서 데이터를 찾아내는 데 어려움을 겪을 수 있다. 소위 데이터 디스커버리 문제라고도 불리는데, 분석을 하고 싶어도 방대한 데이터에 압도되어 어디서부터 시작해야할지 모르는 경우가 종종 발생한다. 이때 잘 정리된 문서는 큰 도움이 된다.

또한 주기적으로 쓸모없는 데이터를 삭제하는 과정은 불가피하다. 아무리 잘 만든 파이프라인이라도 관리가 필요하다. 예를 들어 주기적으로 사용하지 않는 테이블과 파이프라인을 삭제한다거나 필수적으로 빠른 접근이 요구되는 데이터만을 DW(Hot storage)에 남기고 그렇지 않은 데이터를 DL(Cold Storage)에 옮기는 일은 인간의 개입이 필요할 가능성이 높다. 물론 이조차도 자동화할 수 있겠으나 이는 자동화에 따른 위험성을 고려해야 한다.

그 외에도 데이터 파이프라인의 사고가 발생하면 사고 리포트를 작성하여 이후의 비슷한 사고가 발생하였을 때 이를 방지하도록 한다거나 중요한 데이터 파이프라인의 경우에는 입력과 출력 데이터의 체크가 필요할 것이다. 간단하게는 입력 레코드의 수와 출력 레코드의 수를 체크하는 것에서부터 중복 레코드 체크를 수행한다거나 Summary 테이블을 만들고 PK가 존재한다면 정말로 PK의 Uniqueness가 보장이 되는지를 체크하는 것이 필요하다(Big query 같은 빅데이터 스토리지에서는 PK의 유일성이 보장되지 않는 것이 일반적). 

'Data Engineering > Airflow' 카테고리의 다른 글

DAG 작성  (0) 2023.07.09
Airflow의 DAG  (0) 2023.06.28

Airflow의 DAG

mydailylogs
|2023. 6. 28. 04:44

DAG (Directed, Acyclic Graph)

 

DAG는 파이프라인을 실행하기 위한 단순한 알고리즘을 제공해준다는 의의를 가진다.


아래의 날씨 대시보드 파이프라인에서는 방향성 그래프 표현을 통해 전체 파이프라인을 직관적으로 표현하고 있다.

 

DAG 구성을 사용하여 데이터 파이프라인 상의 정해진 Task를 실행하게 됨

 

 

이때 DAG를 구성하는 파이프라인 요소 각각은 Node라는 이름 대신 태스크라고 불리게 되며, 보다 직관적인 의존관계 파악이 가능해진다.

 

DAG의 또 다른 특징은 Cycle이 존재하지 않는다는 점이다. 

 

태스크 2와 태스크 3 간에 의존성이 존재하므로 Cyclic 하다면 Task 2는 절대 실행이 될 수 없다.

 

위의 그림에서 볼 수 있 듯 두 개의 태스크 간에 Cyclic 한 의존 관계가 생기는 순간 해당 DAG는 끝까지 진행될 수 없다. 태스크 2를 실행하기 위해서는 태스크 3의 실행이 전제되어야 하지만, 태스크 3의 실행은 반대로 태스크 2의 실행을 전제한다. 

 

때문에 태스크 2와 태스크 3가 실행될 수 없게 되고 마치 프로세스 Deadlock과 같은 상태에 머물게 된다.

 

DAG를 통한 파이프라인 그래프 실행

 

DAG는 파이프라인 실행을 위한 단순한 알고리즘을 제공한다.

 

그래프 내부의 각 태스크는 최초에 Open(미완료) 상태에 놓이게 되며, 태스크의 실행은 다음의 단계를 따른다.

 

1. 화살표의 끝점에 해당하는 태스크를 실행하며 다음 태스크를 실행하기 전에 이전 태스크가 완료되었는지를 먼저 확인한다.

 

2. 태스크가 완료되었다면 다음에 실행해야할 태스크를 대기열에 추가한다. 이때 완료된 태스크의 경우에는 완료 표시를 남기게 되고, 대기열에 있는 태스크는 실행된다.

 

3. 모든 태스크가 완료될 때까지 해당 단계가 반복되며, 모든 태스크가 완료되었다면 DAG의 실행이 비로소 종료되게 된다.

 

날씨 데이터 파이프라인을 통해 알아본 DAG의 작동 단계

 

스크립트 기반 파이프라인과의 비교

 

DAG 대신 스크립트를 통해 파이프라인을 구성할 수도 있다. 간단한 파이프라인 작업의 경우에는 태스크들을 하나의 스크립트를 통해 표현해도 문제가 없지만 복잡한 설계가 요구되는 경우 DAG는 다음의 이점을 갖는다.


1. 태스크의 병렬적 수행이 가능해진다.



DAG에서는 의존관계가 보다 명확하다. 때문에 의존성이 존재하지 않는 태스크들에 대해서는 병렬적인 수행이 가능하다.

 

https://www.qubole.com/tech-blog/apache-airflow-tutorial-dags-tasks-operators-sensors-hooks-xcom

2. DAG는 파이프라인을 점진적인 태스크로 분리한다.

 

스크립트 기반 파이프라인은 마치 Monolith 구조의 아키텍처처럼 하나의 프로세스를 통해 모든 파이프라인이 표현된다. 때문에 중간에 실패하는 경우가 발생하면, 실패 지점이 어디인지와 상관없이 모든 작업을 처음부터 다시 수행해야한다.

 

DAG는 반대로 파이프라인을 마치 MSA 아키텍처처럼 점진적인 태스크로 분리한다. 때문에 중간에 실패한 지점에서부터 실행이 가능하며 이는 효율성 증가로 이어진다.

 

Backfill: 하나의 파이프라인에서 이미 지나쳐간 특정 기간을 기준으로 다시 파이프라인을 수행하는 것을 의미한다. 태스크가 몇일 동안 실패하거나 새롭게 만든 파이프라인을 이전 시점부터 실행하고 싶은 경우 사용된다. Backfilling은 데이터 파이프라인 운영에 있어 매우 중요한 주제이며 데이터 파이프라인의 실패로 인한 Backfill이야말로 데이터 엔지니어의 삶을 고단하게 만드는 원흉라고 볼 수 있다.

 

Airflow의 특징

 

 

 

위의 그림에서는 파이썬 파일로 제출된 DAG의 특징을 보여준다.  유저는 파이썬 파일을 통해 태스크들의 의존 관계, DAG의 실행 주기 등을 쉽게 표현할 수 있다.

 

Airflow에서 파이썬 파일이 제출될 경우 일반적인 프로세스는 다음과 같다. 먼저 엔지니어가 DAG 파일을 파이썬의 형태로 Airflow에 제출한다. (자세한 구현은 이후의 글에서 소개한다) 제출이 완료되었다면 해당 파일을 Airflow에서는 파싱(serialize) 후 읽어들인다.

 

이후 각 DAG 내의 각 태스크는 스케쥴링을 거쳐 대기열(Queue)에 추가되고 대기열의 순서에 따라 Worker에서 실행된다. 해당 결과는 Airlow 내부의 데이터베이스로 이동하고, 유저는 데이터베이스에 기록된 DAG의 실행결과를 웹에서 대시보드 형태로 확인한다. 

 

 

사용자가 DAG를 제출하면, 스케쥴러는 DAG 파일을 분석하여 각 DAG의 태스크, 의존성, 예약 주기를 확인한다.

마지막 DAG까지 확인을 했다면, 이제 DAG의 예약 주기를 확인해가며 해당 예약 주기가 이미 경과했는지 여부를 확인한다. 예약 주기가 경과하였다면 실행할 수 있도록 예약한다. 예약된 각 태스크에 대해 스케쥴러는 해당 태스크의 의존성(upstream task)을 확인한다. 만약 의존성이 있는 태스크를 실행하기 이전이라면 실행 대기열에 추가한다.


이후 Airflow의 워커 풀(worker pool)에서는 특정 태스크를 처리할 수 있는 워커 그룹을 정의하는 방식으로, 워커 풀에 속하는 워커들은 병렬적으로 태스크를 실행하게 된다. 이때 워커 풀은 태스크의 병렬 처리 가능성을 관리하며, 동시에 특정 태스크에 대한 동시 실행 제한을 관리한다. 주의할 점은 워커 풀이나 워커가 태스크를 선택하는 점은 아니라는 것이다. 워커는 실행의 주체일 수는 있으나 무엇이 실행될지, 즉 실행 순서에 대해서는 철저하게 스케쥴러의 책임이다.

 

워커 풀(worker pool)은 Airflow의 분산 환경을 구성하는 중요한 개념이다. 특히 Celery Executor는 여러 서버에 걸쳐 Airflow 작업을 분산시킬 수 있게 도와준다. 이런 환경에서 워커 풀은 여러 대의 서버에 분산되어 있는 워커들을 관리하는 데 사용된다. 워커 풀을 이용하게 되면 특정 풀에 속한 워커들에게만 작업을 할당한다거나, 각 풀에 할당된 동시 작업의 수를 제한하는 등의 작업의 제밀한 제어가 가능해진다.

 

 

 

'Data Engineering > Airflow' 카테고리의 다른 글

DAG 작성  (0) 2023.07.09
Airflow의 도입 배경  (0) 2023.06.28