Now Loading ...
-
📘 Flink 1.19 및 1.20에 MiniClusterWithClientResource 종속성 제거 Backport
✏️ 1. 서론
이전 포스팅에서 소개한 것처럼, Iceberg Flink Catalog v2.0에서 MiniClusterWithClientResource 종속성 제거에 성공했습니다. 이에 따라 해당 이슈의 연장선이자 메인테이너의 요청에 따라, 동일한 변경 사항을 Flink 1.19와 1.20 버전에도 백포팅하기로 했습니다.
(현재 Iceberg는 1.9.1 버전을 사용 중이며, Flink Catalog는 1.19, 1.20, 그리고 2.0 버전을 지원합니다.)
이번 작업은 이전 포스팅인
📘 Iceberg Flink Catalog v2.0 MiniClusterWithClientResource 종속성 제거 의 연장선이자, 유사한 내용이 많아, 비교적 짧게 정리해보려 합니다.
◈
✏️ 2. 본론
💻 2.1. PR: MiniClusterWithClientResource 종속성 제거 Backporting
이번 작업 역시 이전 PR의 연장선에 있기 때문에, 이슈 선정 과정에 대한 자세한 설명은 생략합니다.
아래와 같이, 백포팅에 대한 요청이 있었다는 내용을 남깁니다.
따라서 이번 포스팅에서는 곧바로 PR 과정부터 다루겠습니다.
이슈 선정 배경이나 PR 전체 흐름이 궁금하신 분들은 이전 포스팅인
📘 Iceberg Flink Catalog v2.0 MiniClusterWithClientResource 종속성 제거 을 참고해주시기 바랍니다.
1️⃣ COMMIT : v1.19 Backporting
기존 2.0의 TestIcebergSourceFailover 클래스와는 크게 다른 부분이 없었습니다. 중간에 구현된 코드가 약간은 다르긴 하였지만, 제가 수정해야하는 범위와는 무관한 부분이었기 때문에 넘어갔습니다.
해당 코드의 이전 버전은 아래와 같았습니다. ( v2.0 과 동일 )
Before:
import org.apache.flink.test.util.MiniClusterWithClientResource;
import org.apache.flink.util.function.ThrowingConsumer;
// ...
@Test
public void testBoundedWithTaskManagerFailover() throws Exception {
runTestWithNewMiniCluster(
miniCluster -> testBoundedIcebergSource(FailoverType.TM, miniCluster));
}
@Test
public void testBoundedWithJobManagerFailover() throws Exception {
runTestWithNewMiniCluster(
miniCluster -> testBoundedIcebergSource(FailoverType.JM, miniCluster));
}
// ...
@Test
public void testContinuousWithTaskManagerFailover() throws Exception {
runTestWithNewMiniCluster(
miniCluster -> testContinuousIcebergSource(FailoverType.TM, miniCluster));
}
@Test
public void testContinuousWithJobManagerFailover() throws Exception {
runTestWithNewMiniCluster(
miniCluster -> testContinuousIcebergSource(FailoverType.JM, miniCluster));
}
// ...
private static void runTestWithNewMiniCluster(ThrowingConsumer<MiniCluster, Exception> testMethod) throws Exception {
MiniClusterWithClientResource miniCluster = null;
try {
miniCluster = new MiniClusterWithClientResource(MINI_CLUSTER_RESOURCE_CONFIG);
miniCluster.before();
testMethod.accept(miniCluster.getMiniCluster());
} finally {
if (miniCluster != null) {
miniCluster.after();
}
}
}
그래서 아래와 같은 코드로 수정을 동일하게 진행하였습니다.
After:
import org.apache.flink.test.junit5.InjectMiniCluster;
import org.junit.jupiter.api.AfterEach;
// ...
@BeforeEach
protected void startMiniCluster(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
if (!miniCluster.isRunning()) {
miniCluster.start();
}
}
@AfterEach
protected void stopMiniCluster(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
miniCluster.close();
}
// ...
@Test
public void testBoundedWithTaskManagerFailover(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
testBoundedIcebergSource(FailoverType.TM, miniCluster);
}
@Test
public void testBoundedWithJobManagerFailover(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
testBoundedIcebergSource(FailoverType.JM, miniCluster);
}
// ...
@Test
public void testContinuousWithTaskManagerFailover(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
testContinuousIcebergSource(FailoverType.TM, miniCluster);
}
@Test
public void testContinuousWithJobManagerFailover(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
testContinuousIcebergSource(FailoverType.JM, miniCluster);
}
이렇게 코드를 수정하게 된 이유와, Minicluster 의 역할 그리고 InjectMiniCluster 와의 차이에 대해서 역시 이전 포스팅에 작성 되어있습니다.
거듭 말씀드리지만, 이전 포스팅을 꼭 참고하시기 바랍니다. :D
2️⃣ COMMIT : v1.20 Backporting
v1.20 백포팅 작업은, v1.19 와 완전 동일하게 진행되었습니다.
코드 역시 완전 동일하게 수정하였습니다.
그리하여, 최종적으로는 아래와 같은 PR 을 전송하였고, 성공적으로 Merge 가 되었습니다.
👉 My PR: Backporting Removal of MiniClusterWithClientResource from Iceberg Flink Catalog v1.19, v1.20
🍹 중간에 마주한 약간의 이슈
이전 포스팅에서 Local 테스트와, CI 테스트를 성공하기 위하여 필요한 몇 가지 Gradle 명령어를 말씀드렸습니다.
이번에 역시 동일한 과정을 거쳤고, 코드 스타일을 맞추기 위해 ./gradlew :iceberg-flink:iceberg-flink-1.19:spotlessApply 와 ./gradlew :iceberg-flink:iceberg-flink-1.20:spotlessApply 명령어를 사용하였습니다.
그랬더니 아래와 같은 오류가 발생하게 됩니다.
에러를 보면, Gradle이 :iceberg-flink:iceberg-flink-1.20:spotlessApply 라는 태스크(혹은 모듈)를 실행하려 했지만, iceberg-flink 프로젝트 아래에 iceberg-flink-1.20 이라는 서브프로젝트가 존재하지 않다는 내용입니다. 존재하는 서브프로젝트 후보는 iceberg-flink-2.0 이 있다고 친절하게 알려주고 있지만, 우리에게 필요한건 1.19 와 1.20 버전입니다.
저는 처음에는 2.0 으로 해도, 자동으로 1.19 와 1.20 코드 스타일도 맞춰질 줄 알았는데 그게 아니었습니다.
그래서 아래와 같이 질문을 하였습니다.
그래서 ./gradlew properties 명령어를 통해 찾아보았더니, 역시 systemProp.defaultFlinkVersions: 2.0 으로, 되어있었습니다.
그래서 vi 로 gradle.properties 를 열어주었고, enableFlink1.19=true 과, enableFlink1.20=true 을 추가해준 뒤 spotlessApply 명령어를 실행해주었습니다.
그랬더니 정상적으로 성공하였고, PR 후 Merge 까지 성공하였습니다.
◈
✏️ 3. 결론
이번 작업은 Iceberg Flink Catalog v2.0에서 진행했던 MiniClusterWithClientResource 제거 작업을 Flink 1.19 및 1.20 버전에 성공적으로 백포팅한 사례였습니다.
코드 변경 자체는 이전과 거의 동일했지만, Gradle 설정 및 로컬 테스트 환경 설정에서 발생한 작은 이슈를 해결하면서, Iceberg 프로젝트의 Gradle 구성과 서브모듈 활성화 방식에 대해 더 깊이 이해할 수 있는 계기가 되었습니다.
이번 경험을 바탕으로 앞으로도 오픈소스 프로젝트에 더욱 적극적으로 기여하며, 다양한 문제 상황을 주도적으로 해결해나갈 수 있도록 꾸준히 역량을 키워가겠습니다. 💪
-
📘 Iceberg Flink Catalog v2.0 MiniClusterWithClientResource 종속성 제거
✏️ 1. 서론
작년 10월경, 첫 오픈 소스 멘토링 프로그램에 참여하면서 Spring Kafka 프로젝트의 Contributor 가 되는 소중한 경험을 했습니다. 당시에는 오픈 소스 생태계가 처음이었기 때문에, 다소 낯설고 어렵게 느껴졌지만, 운 좋게도 입문자들을 위한 비교적 단순한 이슈를 통해 기여를 시작할 수 있었습니다. 그 이슈는 문서(Docs)의 정정과 일부 간단한 코드 수정 정도였지만, 저에게는 오픈 소스에 첫 발을 내딛는 데 매우 의미 있는 기회였습니다.
그리고 올해, 다시 한 번 같은 멘토링 시스템에 참여하게 되었습니다. 하지만 이번에는 멘토링을 받는 입장이 아니라, 멘토링을 지원하고 운영하는 입장으로 참여하게 되었습니다. 멘티들의 이슈 선정과 PR 과정에 조언을 주면서, 자연스럽게 저도 다시 한 번 오픈 소스 기여를 도전해보고 싶은 의욕이 생겼습니다. ( 이번 포스팅은 ICeberg 기여 포스팅이기 때문에, 멘토링에서 운영진으로서 활동한 내용은 최소화 하였습니다. 결론에만 살짝 포스팅하겠습니다. :D )
그 과정에서 눈길이 간 프로젝트가 바로 Apache Iceberg였습니다. 이 프로젝트는 단순히 관심만 있었던 것이 아니라, 회사 내부에서 신규 솔루션 도입 시 직접 Iceberg를 제안하여 실제로 적용되기도 했고, 해당 기술을 바탕으로 사내 컨퍼런스 발표까지 진행했던 만큼, 개인적으로도 의미가 깊고 애착이 있는 프로젝트입니다.
그래서 이번에는 단순한 문서 수정에 머무르지 않고, 실질적인 코드 수준의 기여를 목표로 삼았습니다. Apache Iceberg의 구조를 더 깊이 있게 이해하고, 실제 동작을 분석하면서 프로젝트에 기여할 수 있는 포인트를 찾고자 했습니다. 이번 기회를 통해 더 큰 도전과 성장의 계기를 만들고 싶었습니다.
◈
✏️ 2. 본론
🤔 2.1. 이슈 선택 과정
이번 이슈 선정 과정은 AI의 도움 50%, 그리고 저의 판단 50%로 이루어졌습니다.
먼저, 기여하고 싶은 프로젝트로는 단연 Apache Iceberg가 1순위였습니다. Iceberg 외에도 Spark, Airflow, Hadoop, Flink 등의 Apache 재단 프로젝트를 후보군에 두고, 기여 가능한 이슈들을 하나씩 탐색하기 시작했습니다.
이 과정에서 Google의 Gemini를 활용해 Iceberg의 GitHub 저장소에서 이슈 리스트를 수집했고, AI에게 “기여하기 좋은 이슈의 기준”을 프롬프트로 제시하여 필터링 작업을 진행했습니다.
그 결과, 여러 이슈 중에서 good first issue 라벨이 붙어 있는 항목들을 선별해낼 수 있었고, 그중에서도 단번에 눈에 띄는 이슈 하나를 발견하게 되었습니다.
이처럼 이번에는 수작업으로만 탐색했던 과거와 달리, AI 도구를 병행하여 시간을 절약하면서도 효율적으로 기여 대상 이슈를 선정할 수 있었습니다.
이렇게 이슈를 선택한 저는, 이슈를 올린 Maintainer 와의 소통을 통하여 해당 이슈를 맡겠다고 요청하였습니다.
이러한 과정을 통하여 Exclude JUnit4 dependency from classpath 에서 Remove JUnit4 dependency from Flink 까지 이어지는 해당 이슈에 기여를 하기로 확정하였고, 회사에서도 관심을 가지고, 저 또한 최근 공부를 하고 있던 Apache Iceberg 에 기여할 수 있는 기회를 잡게 되었습니다.
📚 2.2. 이슈: Iceberg Flink Catalog 의 Junit4 의존성 제거
Exclude JUnit4 dependency from classpath 와,
Remove JUnit4 dependency from Flink 이슈 를 보면,
Apache Iceberg의 Maintainer는 전체 코드베이스, 특히 Flink 관련 모듈에서 JUnit4 의존성을 완전히 제거하려는 명확한 의지를 드러내고 있었습니다.
두 이슈 모두 공통적으로, 테스트 코드가 JUnit5 기반으로 통일되길 원하며, 오래된 테스트 유틸이나 라이브러리에 묶여 있는 잔존 JUnit4 종속성을 제거하는 데 목적이 있었습니다.
먼저 #12937 이슈에서는 iceberg-flink 모듈 내에서 JUnit4 관련 종속성을 제거하고, JUnit5 스타일로 마이그레이션한 일부 작업 내역이 공유되어 있었습니다.
예를 들어 Assume.assumeFalse(…)와 같은 코드는 assumeThat(…).isEqualTo(…)와 같은 JUnit5 스타일로 대체되고 있었으며, build.gradle 파일에서는 junit 그룹을 명시적으로 exclude 처리하고 있었습니다.
하지만 아직도 완전히 제거하지 못한 부분들이 존재했습니다. 예를 들어, TestIcebergSourceFailover 테스트는 내부적으로 Flink의 MiniClusterWithClientResource를 사용하고 있었고, 이 유틸리티는 JUnit4에 의존하고 있기 때문에 완전한 제거에는 제한이 있었습니다.
또 다른 이슈인 #13049에서는 프로젝트 전체 build.gradle의 classpath 레벨에서 JUnit4를 명시적으로 제외하자는 제안이 담겨 있었습니다. 이 역시 Flink 모듈뿐만 아니라, Testcontainers 라이브러리의 일부 클래스(GenericContainer)도 여전히 JUnit4에 의존하고 있어, 완전한 제거를 위해서는 외부 라이브러리의 업데이트를 기다려야 하는 상황이었습니다.
이러한 배경에서, 저는 해당 이슈를 기반으로 구체적인 기여 방향을 정하고 작업을 시작하게 되었습니다. 단순한 문서 수정이나 설정 변경을 넘어서, 직접 테스트 코드의 구조를 개선하고, Gradle 의존성을 다루며, 라이브러리 호환성 문제까지 고민해야 하는 도전적인 과제였기에 더욱 의미가 컸습니다.
👉 해당 이슈는 위에서 설명한, Apache Iceberg 의 #13049 와, #12937 이슈 에 있는 내용입니다.
💬 2.3. Maintainer 와의 소통 과정을 통한, PR 방향성 확립
저는 Apache Iceberg 프로젝트의 Maintainer인 @nastra 님과 주요 기여자 중 한 분인 @tomtongue 님과 바로 소통을 시작했습니다.
먼저, 해당 작업에 참여하고 싶다는 의사를 댓글로 남겼고, Maintainer님께서는 매우 환영해 주시며 자유롭게 작업을 진행해도 된다는 긍정적인 답변을 주셨습니다.
또한, 저보다 먼저 같은 이슈에 관심을 보인 tomtongue 님과 협업 가능성도 열어두었는데, 그분은 Flink 마이그레이션 작업에는 시간이 좀 더 필요할 것 같다고 말씀해 주셨습니다.
이에 자연스럽게 제가 먼저 작업을 진행하는 방향으로 역할이 정리되었습니다.
구체적인 작업 계획으로는, 우선 JUnit4에 의존하고 있던 TestIcebergSourceFailover 클래스를 JUnit5로 마이그레이션하는 것과, Gradle 설정에서 JUnit4 종속성을 제거하는 작업을 포함했습니다.
처음에는 Flink 2.0 버전부터 작업을 시작한 뒤, 이후 1.20 및 1.19 버전으로 순차적으로 백포팅(backport)하겠다는 계획을 Maintainer님께 공유했습니다.
Maintainer님도 이 계획이 적절하다며, 우선 Flink 2.0 모듈에서 작업을 진행한 뒤 결과를 검토받는 것이 좋겠다는 피드백을 주셨습니다.
이후 저는 첫 번째 PR인 #13016을 제출하였고, 기존에 사용되던 MiniClusterWithClientResource 의존성을 제거하면서 테스트 코드와 Gradle 설정을 함께 수정했습니다.
하지만 Gradle 설정을 수정하는 과정에서 예상치 못한 문제가 발생했습니다.
여러 가지 방법을 시도했음에도 불구하고, JUnit4에 대한 종속성을 완전히 제거하는 것이 쉽지 않았고, 빌드와 테스트 과정에서 지속적으로 오류가 발생했습니다.
이에 Maintainer님과 다시 소통한 결과, Flink 내부 유틸리티 중 하나인 InternalMiniClusterExtension이 여전히 JUnit4를 필요로 하기 때문에 완전한 제거는 현실적으로 어렵다는 점에 함께 동의하게 되었습니다.
그럼에도 불구하고, Iceberg 코드베이스 전체에서는 JUnit4 의존성을 최소화하고, 가능하면 JUnit5 중심으로 통일하는 방향이 바람직하다는 의견을 주셔서, 저 역시 이 가이드라인에 맞춰 TestIcebergSourceFailover 클래스를 중심으로 PR을 지속해서 정제해 나갈 수 있었습니다.
이와 같은 소통 과정을 통해, 단순한 코드 수정뿐만 아니라 프로젝트 내부 상황과 제약을 이해하며 협력하는 경험을 쌓을 수 있었습니다.
💻 2.4. PR: JUnit4 의존성 최소화 및 MiniClusterWithClientResource 종속성 제거
해당 소스를 수정하기 전, 저는 Iceberg에 대해서는 사내 컨퍼런스를 진행할 만큼 충분히 공부한 상태였지만, 카탈로그 엔진과 관련해서는 Spark와 JDBC 기반의 몇 가지에 대해서만 주로 알고 있었습니다.
반면, Flink에 대해서는 상대적으로 익숙하지 않았습니다. Flink는 주로 실시간 데이터 스트리밍 처리를 위해 사용되는 분산 처리 엔진으로, Iceberg에서는 Flink 커넥터를 통해 대용량 데이터를 효율적으로 처리하는 데 활용되고 있다 정도로만 알고 있었습니다.
( 저희 회사는 Flink 를 쓰고 있지는 않지만, Iceberg 를 공부할 때 참고하였던 Kakao 기술 블로그의 louis 님 글 덕분에 Flink 에 대하여 공부가 되었습니다. Apache Iceberg와 Flink CDC 심층 탐구, 아파치 플링크와 CDC의 만남. 플링크 CDC 맛보기 )
하지만 Flink 내부에서 테스트를 위해 사용되는 ‘MiniCluster’라는 개념에 대해서는 전혀 알지 못했습니다.
이에 Flink 공식 문서와 커뮤니티 자료, 그리고 GitHub 저장소 등을 참고하여 ‘MiniCluster’가 무엇인지 공부하기 시작했습니다.
MiniCluster는 Flink 클러스터를 로컬 환경에서 가볍게 실행할 수 있도록 해주는 일종의 미니 버전 클러스터로, 테스트 환경에서 실제 분산 환경과 유사한 조건을 만들어 Flink 작업을 검증할 수 있도록 돕는 역할을 합니다.
즉, 실제 클러스터를 띄우지 않고도 로컬에서 Flink 잡을 실행해볼 수 있게 해주기 때문에 테스트 자동화와 디버깅에 매우 유용합니다.
이처럼 MiniCluster가 Flink 테스트 환경에서 어떤 핵심적인 역할을 하는지 이해하는 것이, 이번 작업을 진행하는 데 중요한 첫걸음이 되었습니다.
그 다음은, 드디어 코드를 수정하기 시작하였습니다.
순서는 Junit4 를 어쩔 수 없이 의존해야하는 부분은 살리고, 이전 커미터가 미처 수정하지 못한 Junit5 로 바꿀 수 있는 부분을 수정하는 작업을 먼저 진행하였습니다.
이후에는, TestIcebergSourceFailover 클래스에서 Junit4 를 의존하고 있는 부분이 어디인지를 찾고, 그 부분을 Junit5 형태로 변환하는 작업을 진행하였습니다.
1️⃣ COMMIT : GenericAppenderHelper 의 생성자 @Deprecated 제거
첫 번째로, GenericAppenderHelper.java 코드에서, 생성자가 @Deprecated 되어있었습니다. 과거에 JUnit에서 흔히 쓰던 TemporaryFolder 객체를 인자로 받습니다. TemporaryFolder는 테스트 중 임시 파일/디렉토리를 자동 생성하고 정리해주는 도구입니다. 아마, Junit4 의존성을 완전히 걷어내기 위한 과정에서 @Deprecated 를 해둔 거 같습니다.
하지만 위에서 이야기하였듯 현재 Junit4 를 완전히 걷어내는 것은 어렵다고 판단하였기 때문에, 아직까지 GenericAppenderHelper 클래스를 필요로 하는 소스들을 위해서, @Deprecated 를 삭제해주었습니다. ( 삭제하지 않으면, CI 테스트 시 에러가 발생합니다. )
Before:
@Deprecated
public GenericAppenderHelper(Table table, FileFormat fileFormat, TemporaryFolder tmp, Configuration conf) {
this.table = table;
this.fileFormat = fileFormat;
this.temp = tmp.getRoot().toPath();
this.conf = conf;
}
@Deprecated
public GenericAppenderHelper(Table table, FileFormat fileFormat, TemporaryFolder tmp) {
this(table, fileFormat, tmp, null);
}
After:
public GenericAppenderHelper(Table table, FileFormat fileFormat, TemporaryFolder tmp, Configuration conf) {
this.table = table;
this.fileFormat = fileFormat;
this.temp = tmp.getRoot().toPath();
this.conf = conf;
}
public GenericAppenderHelper(Table table, FileFormat fileFormat, TemporaryFolder tmp) {
this(table, fileFormat, tmp, null);
}
2️⃣ COMMIT : Junit4 의 Assume 을 걷어내고, AssertJ 방식 활용
두 번째로 이전에 Junit4 에서 Junit5 로 의존성 변경을 시도했던 커미터분이 미처 수정하지 못한 부분을 수정하는 과정을 거쳤습니다.
Flink 2.0 의 TestIcebergSink 클래스에서, 아래 코드처럼 Junit4 방식의 Assume.assumeFalse(...) 을 사용하는 부분이 있어서, 다른 버전의 TestIcebergSink 클래스들과 동일하게 AssertJ 라이브러리의 assumeThat 방식으로 변경해주었습니다.
Before:
import org.junit.Assume;
// ...
@TestTemplate
void testErrorOnNullForRequiredField() throws Exception {
Assume.assumeFalse(
"ORC file format supports null values even for required fields.", format == FileFormat.ORC);
// Next Code ...
}
After:
import static org.assertj.core.api.Assumptions.assumeThat;
// ...
@TestTemplate
void testErrorOnNullForRequiredField() throws Exception {
assumeThat(format)
.as("ORC file format supports null values even for required fields.")
.isNotEqualTo(FileFormat.ORC);
// Next Code ...
}
3️⃣ COMMIT : MiniClusterWithClientResource 종속성 제거
마지막이 드디어, 이번 PR 의 꽃인, TestIcebergSourceFailover 클래스에서 MiniClusterWithClientResource 종속성을 제거하는 것이었습니다.
이 부분에 대한 설명은 조금 길어질 수 있으니, 먼저 ASIS 코드를 보겠습니다.
Before:
import org.apache.flink.test.util.MiniClusterWithClientResource;
import org.apache.flink.util.function.ThrowingConsumer;
// ...
@Test
public void testBoundedWithTaskManagerFailover() throws Exception {
runTestWithNewMiniCluster(
miniCluster -> testBoundedIcebergSource(FailoverType.TM, miniCluster));
}
@Test
public void testBoundedWithJobManagerFailover() throws Exception {
runTestWithNewMiniCluster(
miniCluster -> testBoundedIcebergSource(FailoverType.JM, miniCluster));
}
// ...
@Test
public void testContinuousWithTaskManagerFailover() throws Exception {
runTestWithNewMiniCluster(
miniCluster -> testContinuousIcebergSource(FailoverType.TM, miniCluster));
}
@Test
public void testContinuousWithJobManagerFailover() throws Exception {
runTestWithNewMiniCluster(
miniCluster -> testContinuousIcebergSource(FailoverType.JM, miniCluster));
}
// ...
private static void runTestWithNewMiniCluster(ThrowingConsumer<MiniCluster, Exception> testMethod) throws Exception {
MiniClusterWithClientResource miniCluster = null;
try {
miniCluster = new MiniClusterWithClientResource(MINI_CLUSTER_RESOURCE_CONFIG);
miniCluster.before();
testMethod.accept(miniCluster.getMiniCluster());
} finally {
if (miniCluster != null) {
miniCluster.after();
}
}
}
먼저, JUnit 5에서는 @BeforeAll, @AfterAll, @BeforeEach, @AfterEach를 사용해 리소스 생명 주기를 다루는 게 일반적입니다. 하지만 해당 코드는 테스트마다 miniCluster.before() / after()를 수동으로 호출합니다. 이부분이 JUnit 4 스타일이라고 할 수 있습니다.
또한, MiniClusterWithClientResource 는 보통 JUnit4의 ExternalResource 를 상속해서 구현합니다. JUnit4에서는 리소스 초기화와 정리를 위해 @Rule 어노테이션을 통해 ExternalResource 를 사용합니다. 이는, Flink 개발 초기 시점과 JUnit5 도입 시점의 시기적 차이 때문에, 기존에 잘 만들어진 테스트 유틸리티들이 JUnit4 스타일로 많이 남아 있습니다. 이런 이유 때문에, 사실 완전히 Junit4 를 당장 걷어내는 것이 어렵다고 판단한 거 같습니다. 즉, Flink 의 Minicluster 를 활용한 테스트코드 자체가 완전히 JUnit5로 완전히 마이그레이션되지 않은 상태이기 때문에, Iceberg 에도 당장 적용하기 어렵다는 것으로 생각됩니다.
계속 이어서 설명하자면, 여기서는 테스트가 실행될 때마다 MiniClusterWithClientResource 인스턴스를 직접 생성하고, 수동으로 before()와 after()를 호출해서 클러스터 시작과 종료를 명시적으로 제어하고 있습니다. 테스트 메서드들은 MiniCluster를 직접 파라미터로 받지 않고, 람다 ThrowingConsumer에 넘겨서 간접적으로 처리합니다. 즉, 리소스 관리를 테스트 코드 내부에서 직접 제어하는 형태입니다. 보통 이런 방식은 JUnit4에서 @Rule 같은 자동 리소스 관리가 없거나 커스텀 상황에서 쓰입니다.
그래서 아래와 같은 코드로 수정을 진행하였습니다.
After:
import org.apache.flink.test.junit5.InjectMiniCluster;
// ...
@Test
public void testBoundedWithTaskManagerFailover(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
testBoundedIcebergSource(FailoverType.TM, miniCluster);
}
@Test
public void testBoundedWithJobManagerFailover(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
testBoundedIcebergSource(FailoverType.JM, miniCluster);
}
// ...
@Test
public void testContinuousWithTaskManagerFailover(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
testContinuousIcebergSource(FailoverType.TM, miniCluster);
}
@Test
public void testContinuousWithJobManagerFailover(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
testContinuousIcebergSource(FailoverType.JM, miniCluster);
}
중점은, 리소스 관리면에서, MiniClusterWithClientResource 수동 생성 및 before()/after() 호출 하는 부분을 @InjectMiniCluster를 통해 자동으로 주입 및 관리를 할 수 있게끔 변경했다는 부분입니다.
그리고 테스트 코드 작성 시, 람다 방식으로 테스트 실행, 리소스 관리를 직접 제어하는 방식에서 테스트 메서드 파라미터로 리소스 주입 받아 사용하도록 변경하였고,
ExternalResource 기반 (JUnit4 Rule) 방식에서 ParameterResolver 또는 Extension 기반 (JUnit5) 방식으로 변경하였다는 점이 있겠습니다.
이러한 방식에 MainTainer 들은 토론을 하였고, 괜찮은 방식이라는 결론에 다다르게 됩니다.
그리고, 다른 컨트리뷰터들 역시, 괜찮다는 리뷰를 달아주었습니다.
🔧 MiniCluster 주입 후 실행 오류 발생 및 수동 생명주기 관리 코드 추가
그러나, 이부분에 대하여 처음에는 CI 테스트에서 문제가 발생하였습니다.
테스트가 120초 안에 끝나지 않고 Timeout으로 실패하는 문제가 발생한 것입니다.
@InjectMiniCluster를 통해 MiniCluster를 테스트 메서드에 주입받았지만 주입만 된 상태이지, 명시적으로 start()를 호출하지 않으면 MiniCluster가 실행되지 않았던 것으로 보입니다. 결국 테스트 로직에서 MiniCluster를 사용하려 했을 때, 클러스터가 “비활성 상태”이므로, 작업이 시작되지 않거나, 실행 중 hang 이 발생한 것으로 보입니다.
좀 더 원인을 찾아보니. JUnit5의 확장 모델에서는 @Inject… 같은 어노테이션으로 객체 주입은 가능하지만, 해당 객체의 생명주기(start/stop)는 자동으로 보장되지 않을 수 있다고 합니다. 또한 MiniCluster는 명시적으로 .start() 호출이 필요하고, 주입은 단순히 생성만 해주는 것이며, 실행은 별도 단계라는 원인을 찾을 수 있었습니다.
결과적으로 테스트가 MiniCluster에 작업을 제출하려 했지만, MiniCluster가 시작되지 않았고, 이는 테스트 timeout (120초) 초과로 실패까지 이어지게 된 것입니다.
그래서 TestIcebergSourceFailover 클래스에 아래와 같은 코드를 추가 커밋하였습니다.
Add Code:
import org.junit.jupiter.api.AfterEach; // AfterEach 추가
@BeforeEach
protected void startMiniCluster(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
if (!miniCluster.isRunning()) {
miniCluster.start();
}
}
@AfterEach
protected void stopMiniCluster(@InjectMiniCluster MiniCluster miniCluster) throws Exception {
miniCluster.close();
}
@BeforeEach와 @AfterEach를 통해 MiniCluster의 시작과 종료를 명시적으로 제어함으로써, 테스트 실행 시 필요한 Flink 클러스터 환경이 정상적으로 초기화되고 정리될 수 있게 하였습니다.
그리고 그 결과, 최종적으로는 Iceberg 에 PR Merge 를 성공하여, Apache 재단의 Iceberg 프로젝트에 기여할 수 있었고, Contributor 가 될 수 있었습니다.
👉 My PR: Iceberg Flink Catalog v2.0 Remove the MiniClusterWithClientResource dependency
🍹 Local Test 및 CI 통과를 위한 절차 안내
처음 Iceberg 프로젝트에 기여하면서 가장 헷갈렸던 부분 중 하나가, 로컬 테스트와 CI 통과 방식이었습니다.
혹시 저처럼 Iceberg에 기여하고자 하는 분들이 계시다면, 아래 절차를 참고하시면 도움이 될 것입니다.
✅ 코드 스타일 정리: spotlessApply
Iceberg는 Spotless를 사용하여 코드 포맷팅을 검사합니다. 따라서 PR을 생성하기 전 반드시 아래 명령어를 실행하여 코드 스타일을 자동으로 정리해야 합니다:
./gradlew :iceberg-flink:iceberg-flink-2.0:spotlessApply
이 명령어는 Java 코드의 정해진 스타일 가이드에 맞게 들여쓰기, 정렬, 공백 등을 자동으로 수정해줍니다. 이를 적용하지 않으면 CI에서 spotlessCheck 단계에서 실패하게 됩니다.
✅ 로컬 테스트 수행: test –stacktrace
CI에 올리기 전에, 로컬 환경에서 먼저 유닛 테스트를 실행하여 이상이 없는지 확인해야 합니다:
./gradlew :iceberg-flink:iceberg-flink-2.0:test --stacktrace
–stacktrace 옵션은 만약 테스트 실패 시 상세한 오류 메시지를 확인하는 데 도움이 됩니다.
테스트를 통과하지 못하면 CI 단계에서도 동일하게 실패하므로, 로컬에서 반드시 먼저 확인하는 것이 좋습니다.
참고로, 저는 flink 2.0 에 대하여 테스트를 진행하는 것이기 때문에 위와 같이 하였습니다.
./gradlew projects 를 통하여 테스트가 가능한 프로젝트 목록을 확인한 후에, 원하는 프로젝트를 가지고 진행해주면 되겠습니다.
◈
✏️ 3. 결론
이번 기여를 통해 기술적인 역량뿐만 아니라, 오픈소스 프로젝트에서의 커뮤니케이션의 중요성을 깊이 체감할 수 있었습니다.
단순히 코드를 작성하고 제출하는 것을 넘어, Maintainer 및 다른 기여자들과의 지속적인 소통, 피드백 수용, 그리고 협업 방향 조율이 얼마나 중요한지 직접 경험하게 되었습니다.
특히 이슈를 공유하고, 작업 계획을 명확히 전달하며, 리뷰어의 피드백을 빠르게 반영하는 과정을 통해 오픈소스 생태계의 협업 문화가 신뢰와 효율을 만들어내는 구조임을 배웠습니다.
첫 번째 PR을 통해 긍정적인 피드백을 받은 뒤, Maintainer로부터 요청받았던 Flink 1.20 및 1.19 버전에 대한 backport 작업 역시 빠르게 진행하고 싶다는 동기부여가 생겼고, 기술적 기여뿐 아니라 프로젝트 요구에 신속히 대응하는 태도의 중요성도 느꼈습니다.
또한, 오픈소스 멘토링에서 운영진 역할을 맡아 저의 기여를 하면서 동시에 많은 멘티들이 오픈소스에 도전하는 과정을 지켜보고, 그분들께 답글로 방향성을 제시하는 경험은 저 자신의 성장에도 큰 자극이 되었습니다.
비록 오늘 포스팅에서는 멘토링 내용이 자세히 다뤄지지 않았지만, 저는 앞으로도 이 멘토링 활동과 오픈소스 기여를 꾸준히 이어갈 것이며, 우리나라에 올바르고 건강한 오픈소스 기여 문화가 자리잡도록 노력하는 1세대가 되고자 합니다.
앞으로도 단순한 코드 제공을 넘어, 신뢰를 바탕으로 커뮤니케이션하고, 프로젝트에 실질적인 가치를 더하는 기여자가 되기 위해 꾸준히 노력하겠습니다.
-
📘 하둡 클러스터, YARN, 기본 에코시스템 실습
저번 포스팅까지는 하둡의 기본과 YARN, MapReduce 그리고 에코시스템 등을 포스팅하였습니다.
오늘은 공부한 내용을 토대로, 하둡의 기본적인 구성을 설치한 내용을 포스팅해보고, 하둡의 기본 공부에 대한 내용을 마무리하고자 합니다.
오늘 하둡 포스팅 이후에는, 하둡의 심화적인 부분들을 하나씩 나눠가며, 실무적으로 겪었던 사례들을 위주로 조금씩 정리해보겠습니다.
📌 참고로 오늘 실습은 정말 기본적인 실습 내용을 정리하였습니다. 실무적으로 쓰이거나, 심화된 내용은 추후 공부하여 추가로 정리 할 예정입니다.
📌 에코시스템 실습 역시, 기본적인 동작 실습만 간단하게 집에서 구현한 부분을 정리하였습니다. 에코 시스템도 추후 공부하여 더 자세하게 정리 할 예정입니다.
📘 분산 시스템의 이해와 하둡의 등장 배경
📘 하둡의 핵심 구성요소와 이론
📘 YARN(Yet Another Resource Negotiator) 기본 이론
📘 Map Reduce 기본 이론과 실습
📘 하둡 에코시스템과 운영 및 관리
◈
🐘 1. 실습 환경 구성
🐘 1.1. Version
JDK 8
Hadoop 3.1.3
Hive 3.1.3
🐘 1.2. VM & CentOS 설치
🐘 1.3. 방화벽 및 포트 설정
🐘 1.4. JDK 설치 및 환경변수 설정
🐘 1.5. Hadoop 설치 및 환경변수 설정
🐘 2. 의사분산 모드 구축
🐘 2.1. xml파일 설정
🐘 3. 완전 분산 모드 구축
🐘 3.1. 네트워크 및 호스트 설정
🐘 3.2. SSH 설정
🐘 3.3. xml파일 설정
🐘 3.4. 이슈 : DataNode Cluster ID 충돌 문제 해결
🐘 4. HA 완전 분산 모드 구축
🐘 5. Observer NameNode 실습
🐘 6. Yarn 실습
🐘 7. 하둡 에코시스템 Hive 기본 실습
🐘 7.1. MySQL 설치
Cent OS 지원 종료로 인한, yum repository 변경
🐘 7.2. 메타 스토어 초기 등록
schematool -dbType mysql -initSchema
🐘 8. 하둡 에코시스템 Hbase 기본 실습
✏️ 결론
◈
📚 공부 참고 자료
-
📘 하둡 에코시스템과 운영 및 관리
저번 포스팅에서는, Map Reduce 를 공부하였습니다.
이번에는 하둡의 여러 에코시스템들에 대해 공부하고 정리해보도록 하겠습니다.
📌 Hadoop 을 공부하고 정리한 파트이기 때문에, 에코시스템에 대한 기본적인 내용을 담고 있습니다. 각 에코시스템의 디테일한 내용은 추후 공부하고 따로 포스팅하겠습니다. :D
📘 분산 시스템의 이해와 하둡의 등장 배경
📘 하둡의 핵심 구성요소와 이론
📘 YARN(Yet Another Resource Negotiator) 기본 이론
📘 Map Reduce 기본 이론
◈
🐘 1. 하둡 에코시스템과 관련 프로젝트
하둡은 단순한 분산 저장 및 처리 프레임워크로 출발했지만, 현실 세계의 복잡한 데이터 처리 요구사항을 만족시키기 위해 수많은 에코시스템 구성 요소들로 확장되었습니다.
이러한 에코시스템들은 하둡의 기본적인 기능(HDFS와 MapReduce)만으로는 해결하기 어려운 실질적인 문제들을 보완하고, 다양한 사용 시나리오에 대응하기 위해 등장하게 되었습니다.
옛날로 돌아가보면, 초기 하둡은 HDFS를 통해 데이터를 저장하고, MapReduce를 이용해 데이터를 처리하는 구조로 시작되었습니다.
이 구조는 대규모 배치 처리에는 적합했지만, 여러가지 제한점이 존재했습니다.
몇 가지 예시를 들어보면,
MapReduce는 배치 처리에 최적화되어 있어, 실시간 로그 분석, 이벤트 기반 처리등 스트리밍 데이터나 실시간 분석에는 적합하지 않았으며,
모든 처리를 Java 기반으로 MapReduce 로직을 작성해야 했기 때문에, SQL을 선호하는 데이터 분석가들에게는 진입장벽이 매우 높았습니다.
또한, 배치 처리 외에 ETL, 머신러닝, 검색, 그래프 분석 등 다양한 워크로드를 유연하게 지원하기 어려웠습니다.
이러한 한계점을 보완하기 위해, 하둡 기반 위에 전문적인 기능을 수행하는 다양한 서브 프로젝트들이 자연스럽게 등장했습니다.
하둡은 하나의 거대한 일체형 소프트웨어가 아니라, 서로 다른 역할을 수행하는 여러 프로젝트들이 느슨하게 결합된 모듈형 생태계(Modular Ecosystem)로 구성되어 있습니다.
각각의 도구들은 특정 문제 영역을 해결하도록 설계되었고, 서로 연동되면서 보다 유연하고 확장 가능한 분산 시스템을 구성합니다.
아래에서 더 자세히 알아보겠지만, 지금 몇 가지만 간략하게 이야기해보면 이해가 쉬울겁니다.
Pig, Hive → SQL 혹은 DSL로 MapReduce 추상화 → 사용자 편의성 향상
HBase → HDFS 위에 컬럼 기반 DB 구축 → 실시간 조회 기능 제공
Zookeeper → 분산 환경에서의 구성 관리 및 락 처리 → 고가용성 보장
Oozie, Airflow → 워크플로우와 작업 스케줄링 → 자동화된 데이터 파이프라인
YARN → 자원 관리의 중앙 집중화 → 다양한 애플리케이션 실행 지원
Spark, Tez, Flink → MapReduce를 대체하거나 보완하는 고속 처리 엔진
이처럼 각각의 프로젝트는 독립적으로도 활용 가능하지만, 하둡이라는 공통된 기반 위에서 상호 보완적인 관계로 작동합니다. 이러한 설계는 하둡이 단순한 프레임워크를 넘어, 범용 분산 데이터 처리 플랫폼으로 진화하게 된 핵심 배경이기도 합니다.
위와 같은 이유와 사상을 가지고, 하둡 에코시스템은 오픈소스를 기반으로 하며, 누구나 참여하고 확장할 수 있도록 설계되어 있습니다. 이로 인해 다양한 기업과 커뮤니티에서 자신들의 요구에 맞춰 하둡을 커스터마이징하고, 새로운 컴포넌트를 추가할 수 있게 되었습니다.
대표적인 예로, Cloudera, Hortonworks, MapR 등 상용 디스트리뷰터들은 하둡을 기반으로 한 통합 플랫폼을 제공하면서, 보안, 모니터링, 통합 관리 도구 등 엔터프라이즈 환경에서 필요한 기능들을 추가해 생태계를 더욱 풍성하게 만들었습니다.
그럼 지금부터 대표적인 에코시스템 몇 가지를 알아보도록 하겠습니다.
🐘 1.1. Apache Avro 와 Parquet 그리고 ORC
하둡은 방대한 양의 데이터를 효율적으로 저장하고 처리할 수 있는 플랫폼이라는 점은 많은 분들이 이미 알고 있을 것입니다. 또한, 하둡의 분산 파일 시스템(HDFS)에 저장된 데이터는 정형, 반정형, 비정형 여부에 관계없이 Spark, Hive, Pig, Impala, Presto 등 다양한 처리 엔진을 통해 가공하고 분석할 수 있다는 점도 잘 알려져 있습니다.
하지만 하둡과 그 에코시스템을 하나하나 학습하다 보면, 자연스럽게 “하둡에서 데이터를 어떤 형식으로 저장해야 효율적인가?” 라는 질문에 직면하게 됩니다. 실제로, 데이터 저장 형식은 하둡 환경에서 성능과 자원 효율성에 직결되는 핵심 요소입니다.
우리가 기존에 많이 사용해왔던 CSV, JSON, XML과 같은 친숙한 파일 포맷은 하둡 환경에서는 그리 선호되지 않습니다.
이유는 명확합니다. 이러한 형식은 하둡의 가장 큰 강점 중 하나인 병렬 처리와 스토리지 최적화에 불리하기 때문입니다.
하둡을 사용하는 주요 목적 중 하나는 대용량 데이터를 분산 저장하고 병렬로 처리하여 높은 처리량과 확장성을 확보하는 것입니다. 그런데 CSV나 JSON 같은 포맷은 구조상
스키마 정보가 외부에 별도로 존재해야 하고
압축이나 컬럼 단위 접근에 비효율적이며
데이터 분할(partitioning) 및 병렬 처리에 제약이 있습니다.
이러한 한계를 극복하기 위해, 하둡 기반 시스템에서는 보통 Avro, Parquet, ORC(Optimized Row Columnar)와 같은 최적화된 바이너리 포맷을 활용합니다.
이들 포맷은 공통점도 있지만, 각기 다른 특징과 사용 목적, 장단점이 존재합니다.
이제부터 이 세 가지 대표적인 저장 포맷을 비교 분석하면서, 어떤 상황에 어떤 포맷을 선택해야 하는지 정리해보겠습니다.
✅ 공통점
하둡에 최적화된 저장 형식
세 포맷 모두 HDFS 등 하둡의 분산 스토리지 환경에서 높은 성능을 내도록 설계되었습니다.
내장 압축 기능 제공
기본적으로 압축을 지원하며, 저장 공간을 절약하고 I/O 성능을 향상시킵니다.
기계가 읽는 바이너리 형식
사람이 읽기 편한 JSON, XML과 달리 이들 포맷은 바이너리 형태로 저장되어 기계가 읽기에 최적화되어 있습니다.
이로 인해 하둡에서 JSON/XML을 사용하려 한다면, 하둡을 사용하는 이유 자체를 재고해야 할 정도입니다.
병렬 처리 및 확장성
세 가지 형식 모두 파일을 여러 디스크에 분산 저장할 수 있어 병렬 처리와 확장성이 뛰어납니다.
반면 JSON, XML은 파일 단위로 처리되기 때문에 분할 처리에 제약이 있습니다.
스키마 내장 지원
세 포맷 모두 데이터 스키마를 함께 저장합니다.
따라서 파일만으로도 어떤 데이터인지 알 수 있어, 시스템 간 데이터 이동 시 유리합니다.
Wire Format으로 사용 가능
단순한 저장 포맷이 아니라, 네트워크를 통한 전송에도 사용할 수 있는 형식입니다.
즉, Hadoop 클러스터 내에서 노드 간 데이터를 주고받는 데에도 활용됩니다.
🔍 Wire Format이란?
데이터를 메모리나 디스크에 저장할 수 있는 형식을 말하며, 이 데이터는 결국 네트워크를 통해 전송되기 때문에 “wire(선 위의)” 포맷이라고 부릅니다.
✅ Avro, Parquet, ORC의 차이점: 저장 방식과 활용 목적의 차이
세 포맷의 가장 큰 차이는 데이터를 저장하는 방식에 있습니다.
Avro는 행(Row)-기반 포맷인 반면, Parquet와 ORC는 열(Column)-기반 포맷입니다.
열 기반 포맷 (Columnar Format)은 분석 쿼리처럼 일부 열만 선택적으로 조회하는 작업에 유리합니다.
예: 리포트 생성, 집계 분석 등
행 기반 포맷 (Row Format)은 전체 행 데이터를 자주 읽거나, 쓰기 작업이 빈번한 경우에 적합합니다.
예: 트랜잭션 기록, 로그 수집 등
✅ 예시로 이해하는 저장 방식의 차이
▣ 예시 1: 열 기반 포맷이 유리한 경우
시나리오: 100만 명의 직원을 둔 대기업의 HR팀이 지역별 급여 데이터를 분석하는 경우
쿼리 목적: 급여, 지역 열만 조회
Parquet/ORC처럼 열 기반 포맷은 필요한 열만 선택적으로 조회 가능하므로 읽기 I/O가 적고 매우 효율적입니다.
반면, Avro는 행 전체를 읽고 그 중 필요한 열만 필터링해야 하므로 비효율적입니다.
▣ 예시 2: 행 기반 포맷이 유리한 경우
시나리오: 항공사가 사용자에게 특정 시간대(오후 7시~자정) 모든 항공편 정보를 제공
쿼리 목적: 모든 열 데이터 필요
이 경우는 전체 행을 통째로 읽는 작업이므로 Avro 같은 행 기반 포맷이 훨씬 효율적입니다.
Parquet/ORC는 열 단위 저장으로 인해 전 열을 모으는 데 오히려 I/O 비용이 증가합니다.
✅ 압축률 차이
Parquet와 ORC는 열 기반 구조 덕분에 압축률이 높습니다.
열 단위로 저장되므로, 유사한 값이 반복되는 열(예: 성별, 국가 코드, 상태 플래그 등)을 묶어 압축할 수 있기 때문입니다.
이 구조는 특히 IoT처럼 대량의 센서 데이터를 저장해야 할 때 스토리지 효율을 극대화할 수 있습니다.
반면, Avro는 행 전체를 묶어 저장하기 때문에 데이터의 다양성이 커지고, 결과적으로 압축률이 낮아지는 경향이 있습니다.
✅ 스키마 진화 지원 (Schema Evolution)
Avro는 스키마 진화에 매우 강력합니다.
데이터의 구조를 기술하는 메타데이터는 JSON으로 표현하고, 실제 데이터는 바이너리로 저장하므로 스키마 변경에 유연하게 대응할 수 있습니다.
ORC는 Hive와의 긴밀한 통합 덕분에 Parquet보다 더 나은 스키마 진화 지원을 제공합니다.
Hive 메타스토어와의 호환성도 뛰어나며, 필드 추가나 변경에 대해 상대적으로 잘 대응합니다.
✅ 주요 사용 사례 및 연결 기술
Avro
Kafka에서 메시지를 직렬화하는 포맷으로 널리 사용됩니다.
또한 전송 포맷(wire format)으로도 적합하며, 데이터 교환에 최적화되어 있습니다.
Parquet
Cloudera 환경(CDH)에서 Impala와 자주 함께 사용되며, Spark, Drill, Hive 등 다양한 분석 도구에서 폭넓게 지원됩니다.
ORC
Hortonworks 기반(HDP)의 Hive에서 기본 포맷으로 사용되며, Presto 등에서도 지원됩니다.
❗ Apache Spark는 이 세 포맷 모두를 지원하므로, 사용 목적에 따라 가장 적합한 포맷을 선택하는 것이 중요합니다.
✅ 포맷별 요약 비교
구분
Avro
Parquet
ORC
저장 방식
행 기반 (Row-based)
열 기반 (Column-based)
열 기반 (Column-based)
최적화 대상
쓰기 작업, 전송, Kafka
읽기 작업, 분석 쿼리
Hive 최적화, 압축 효율
압축 효율
보통
높음
매우 높음
스키마 진화
강력함
제한적
상대적으로 우수
활용 환경
Kafka, 전송 포맷
Impala, Spark, Drill
Hive, Presto, HDP
✅ Hadoop 에서 각 포멧 사용 방법 ( Hive 예시 )
📁 1. TEXTFILE**
CREATE TABLE tb_text (
ymd STRING,
tag STRING,
cnt INT
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY '\t'
STORED AS TEXTFILE;
압축 설정 (INSERT 전에 적용):
SET hive.exec.compress.output=true;
SET mapred.output.compression.codec=org.apache.hadoop.io.compress.GzipCodec;
-- 또는
SET mapred.output.compression.codec=org.apache.hadoop.io.compress.SnappyCodec;
단순 텍스트 저장에 적합하지만, 대규모 분석에는 비효율적임.
📁 2. PARQUET
CREATE TABLE tb_parquet (
ymd STRING,
tag STRING,
cnt INT
)
STORED AS PARQUET;
압축 설정:
SET parquet.compression=SNAPPY;
-- 또는
SET parquet.compression=GZIP;
SET parquet.compression=UNCOMPRESSED;
열 기반 포맷으로, 분석 쿼리 및 Spark 환경에 최적화됨.
Parquet 의 경우 기존에는 아래처럼 등록하였음.
CREATE TABLE tb_parquet (
ymd STRING,
tag STRING,
cnt INT
)
ROW FORMAT SERDE 'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe'
STORED AS
INPUTFORMAT 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat'
OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat';
그러나 0.14부터는 Parquet 를 지원해주어서, STORED AS PARQUET; 으로 등록하면 내부적으로 아래와 같이 등록됨.
ROW FORMAT SERDE 'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe'
STORED AS
INPUTFORMAT 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat'
OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat'
📁 3. ORC
CREATE TABLE tb_orc (
ymd STRING,
tag STRING,
cnt INT
)
STORED AS ORC
TBLPROPERTIES ("orc.compress"="ZLIB");
-- 또는
-- TBLPROPERTIES ("orc.compress"="SNAPPY");
-- TBLPROPERTIES ("orc.compress"="NONE");
Hive에서 권장하는 포맷이며, 높은 압축률과 성능 제공.
📁 4. AVRO
CREATE TABLE tb_avro (
ymd STRING,
tag STRING,
cnt INT
)
STORED AS AVRO;
압축 설정:
SET hive.exec.compress.output=true;
SET avro.output.codec=snappy;
-- 또는
SET avro.output.codec=deflate;
SET avro.output.codec=bzip2;
스키마 진화가 자주 일어나는 환경 (예: Kafka) 에 적합.
| 포맷 | Hive 권장 여부 | 압축 설정 방식 | 특징 요약 |
|————-|—————-|—————-|———–|
| TEXTFILE | 낮음 | SET 방식 | 단순, 비효율적 |
| PARQUET | 높음 | SET 방식 | 분석 쿼리 최적 |
| ORC | 매우 높음 | TBLPROPERTIES | Hive 최적화 |
| AVRO | 중간 이상 | SET 방식 | 스키마 진화 |
❗ 해당 글은, 하둡 에코 시스템에 대한 간단한 내용을 공부하고 다룹니다. 각 에코시스템의 보다 더 자세한 내용은 추후 공부하여, 따로 포스팅 할 예정입니다.
🐘 1.2. Apache Flume
🐘 1.3. Apache Sqoop
🐘 1.4. Apache Kerberos
🐘 1.5. Apache Hive
🐘 1.6. Apache Impala
🐘 1.7. Apache HBase
🐘 1.8. Apache Spark
🐘 1.9. Apache Oozie
🐘 1.10. Apache Zookeeper
🐘 1.11. Apache Crunch
🐘 1.12. 그 외 프로젝트와, 프로젝트의 큰 종류
◈
🐘 2. 하둡의 운영과 관리
🐘 2.1. 하둡의 주요 설정 파일과, 설정 값
🐘 2.2. 실제 하둡 클러스터 구축과 보안
🐘 2.3. 현업에서의 하둡을 관리하는 방법
◈
🐘 3. 기타 하둡에 대해 알아야 할 몇 가지
🐘 3.1. 하둡 I/O
🐘 3.2. Schema on Read vs Schema on Write
🐘 3.3. 하둡의 세 가지 모드
🐘 3.4. CDH
🐘 3.5. Cascading
🐘 3.6. 쿠버네티스 환경에서는 하둡이 되지 않는 이유?
◈
🐘 4. 실제 하둡 관련 면접 질문 사례 모음
❓ 질문: Hadoop을 사용하는 데 있어 흔히 발생하는 과제는 무엇인가요?
✅ 1. NameNode의 단일 장애 지점 문제
Hadoop의 초기 구조에서는 NameNode가 클러스터 전체의 메타데이터를 단독으로 관리합니다.
따라서 NameNode가 장애를 일으키면 전체 클러스터의 운영이 중단되고, 최악의 경우 데이터 손실이 발생할 수 있습니다.
이를 보완하기 위해 High Availability(HA) 구성이 도입되었지만, 기본 구조 자체가 갖는 위험성은 여전히 존재합니다.
✅ 2. 보안 취약성
기본 Hadoop은 보안 기능이 제한적입니다. 그래서 데이터 접근 제어가 세분화되어 있지 않고, 누가 데이터를 조회하거나 변경했는지 추적하기 어렵습니다.
Kerberos 같은 인증 시스템이 있지만, 설정이 복잡하고 유지 비용이 크다는 단점이 있습니다.
추가 보안 계층(Sentry, Ranger 등)을 도입해야 실질적인 데이터 보호가 가능하지만, 이는 운영 복잡도를 높이는 요인이 됩니다.
✅ 3. 실시간 처리 미지원
Hadoop의 핵심 처리 모델인 MapReduce는 배치 처리 기반입니다.
이 방식은 대용량 로그 분석 등에는 효과적이지만, 실시간 스트리밍 데이터 처리에는 부적합합니다.
그래서 Spark, Flink 같은 별도의 실시간 처리 프레임워크가 이를 보완합니다.
✅ 4. 작은 파일 처리 비효율
Hadoop은 기본적으로 대용량 데이터를 다루는 데 최적화되어 있습니다.
각 파일마다 메타데이터가 NameNode에 저장되며, 파일 수가 많아질수록 NameNode 메모리 사용량 급증합니다.
결과적으로 작은 파일들의 갯수가 늘어나면 응답 지연 및 성능 저하가 발생하게 됩니다.
이를 해결하기 위해 Hadoop Archive 등의 접근이 필요합니다.
❓ 질문: Hadoop에서 분산 캐시(Distributed Cache)의 목적은 무엇인가요?
Hadoop의 분산 캐시(Distributed Cache)는 MapReduce 작업 실행 시 모든 노드에 필요한 읽기 전용 파일(예: JAR 파일, 설정 파일, 참조용 작은 데이터 파일 등)을 미리 로컬에 배포하여 성능을 향상시키는 기능입니다.
HDFS에서 반복적으로 읽는 대신, 작업 시작 시 노드 로컬 디스크에 캐시되어 파일을 반복 다운로드하지 않도록 방지하고,
작은 참조 파일을 빠르게 읽을 수 있어 처리 속도가 개선됩니다.
또한 Mapper/Reducer가 동일한 참조 파일을 활용 할 수 있게 되기 때문에 성능이 향상됩니다.
예를 들어, MapReduce 작업 40개가 있고, 각 작업이 동일한 작은 참조 파일을 반복적으로 읽어야 한다면, 이 파일이 HDFS에만 존재할 경우 매번 네트워크 I/O가 발생합니다.
하지만 분산 캐시에 등록하면 해당 파일이 각 노드 로컬 디스크에 복사되어, 모든 작업이 빠르게 접근할 수 있습니다.
❓ 질문: Hadoop의 분산 캐시에 있는 파일에 적용된 변경 사항을 어떻게 동기화할 수 있나요?
함정 질문입니다.
분산 캐시는 Hadoop에서 작업 실행 전에 모든 노드에 동일한 파일을 미리 복사해두는 기능입니다. 이때 복사된 파일은 읽기 전용(read-only) 으로 처리되며, 실행 중에는 변경하거나 업데이트할 수 없습니다.
즉, 어떤 노드에서 분산 캐시에 있는 파일을 수정하더라도, 다른 노드의 캐시에는 전혀 반영되지 않습니다. 각 노드가 가지고 있는 파일은 작업이 시작될 때의 상태 그대로 유지됩니다.
❓ 질문: Hadoop에서 “데이터 지역성(Data Locality)”이란 무엇인가요?
Hadoop에서 데이터 지역성이란, 데이터를 처리할 때 데이터를 네트워크를 통해 다른 위치로 옮기지 않고, 계산 작업(Task)을 데이터가 저장된 위치로 이동시키는 방식을 의미합니다. 이는 대용량 데이터를 다루는 환경에서 네트워크 전송 비용을 줄이고 성능을 높이기 위한 핵심 개념입니다.
빅데이터 환경에서는 처리해야 할 데이터의 양이 매우 방대하기 때문에, 데이터를 네트워크를 통해 전송하는 데 걸리는 시간과 비용이 상당히 큽니다. 따라서 Hadoop은 데이터를 이동시키는 대신, 계산을 데이터가 위치한 서버(데이터 노드)에서 수행함으로써 전체 처리 시간을 줄이고 시스템 효율을 높입니다.
Hadoop의 작업 스케줄러는 다음과 같은 우선순위 규칙에 따라 작업을 스케줄링합니다
로컬(local): 먼저, 데이터가 저장된 같은 노드에서 작업을 실행할 수 있는지 확인합니다. 이것이 가장 이상적인 경우입니다.
랙(local rack): 만약 해당 노드에 작업을 실행할 수 없다면, 같은 랙(Rack) 내에 있는 다른 노드에서 실행을 시도합니다. 같은 랙은 네트워크 지연이 적기 때문입니다.
원격(other rack): 위의 방법들이 모두 불가능할 경우, 다른 랙에 위치한 복제본이 저장된 노드에서 작업을 실행합니다.
예를들면, 어떤 회사에서 고객 로그 데이터를 분석하기 위해 Hadoop 클러스터를 사용하고 있다고 가정해보겠습니다. 이 로그 데이터는 logs.csv라는 이름으로 HDFS에 저장되어 있으며, 총 300GB 크기로 세 노드에 분산 저장되어 있습니다.
Node1에는 logs.csv의 블록 1이 저장되어 있고, Node2에는 블록 2, Node3에는 블록 3이 저장되어 있습니다. 각 블록은 복제(replication factor = 3) 되어, 세 노드 중 두 곳에 추가로 복사본이 존재합니다.
Hadoop은 먼저 각 블록을 저장하고 있는 노드에서 해당 블록을 처리하는 작업을 실행하려고 합니다. 예를 들어, Block 1을 처리하는 작업은 우선 Node1에서 실행됩니다. 이 경우, 데이터는 디스크에서 바로 읽히므로 네트워크를 사용하지 않습니다.
만약 Node1의 리소스가 부족해서 작업을 실행할 수 없다면, Hadoop은 Block 1의 복제본이 있는 다른 노드에서 실행을 시도합니다. 예를 들어, Block 1의 복제본이 Node2에도 있다면, Hadoop은 Node2에서 작업을 실행합니다. 이 경우에도 여전히 로컬 디스크 접근이 가능하므로 성능이 좋습니다.
만약 Block 1을 가진 어떤 노드에서도 작업을 실행할 수 없다면, Hadoop은 같은 랙(rack) 내의 다른 노드에서 작업을 실행합니다. 이 경우, 데이터는 네트워크를 통해 전달되지만, 랙 내부 통신은 상대적으로 빠르기 때문에 손실이 크지 않습니다.
가장 나쁜 경우는, 해당 데이터를 가진 노드가 모두 바쁘고, 다른 랙에 있는 노드에서 데이터를 네트워크로 받아와야 할 때입니다. 이 경우는 네트워크 병목이 발생할 수 있고, 성능 저하로 이어집니다.
❓ 질문: MapReduce가 권장되지 않는 경우는?
반복 처리 작업에는 MapReduce 가 권장되지 않습니다.
MapReduce는 많은 양의 데이터를 한 번에 처리하는 데는 매우 효과적인 방식입니다. 하지만 동일한 데이터를 반복적으로 여러 번 처리해야 하는 작업에는 잘 맞지 않습니다.
MapReduce는 작업 하나가 끝날 때마다 중간 결과를 HDFS 같은 디스크에 저장하고, 다음 작업은 그것을 다시 디스크에서 읽는 방식으로 동작합니다. 디스크 입출력은 네트워크보다도 훨씬 느리고, CPU나 메모리에 비해서는 비교할 수 없을 정도로 느립니다. 이런 구조 때문에, 같은 데이터를 여러 번 반복해서 읽고 처리해야 하는 작업은 매번 디스크 I/O 비용이 발생하므로 매우 비효율적입니다.
◈
🐘 5. 점차 사라지는 하둡, 그러나 하둡을 얕게라도 공부해야 하는 이유
◈
✏️ 결론
◈
📚 공부 참고 자료
https://ngela.tistory.com/111
-
📘 Map Reduce 기본 이론과 실습
저번 포스팅에서는, YARN 에 대하여 공부하였습니다.
이번에는 하둡의 핵심 요소 중 하나인 Map Reduce 에 대하여 공부한 내용을 포스팅하겠습니다.
📘 분산 시스템의 이해와 하둡의 등장 배경
📘 하둡의 핵심 구성요소와 이론
📘 YARN(Yet Another Resource Negotiator) 기본 이론
◈
🐘 1. Map Reduce
MapReduce는 대용량 데이터를 분산 환경에서 효율적으로 처리하기 위한 병렬 처리 프로그래밍 모델이자 프레임워크입니다.
이 모델은 원래 2004년 구글에서 발표되었으며, Apache Hadoop은 이를 오픈소스 프레임워크로 구현하였습니다.
Hadoop의 MapReduce는 특히 HDFS와 함께 사용되어, 수백 GB에서 수 PB 규모의 데이터를 여러 대의 컴퓨터에 나눠 처리할 수 있도록 설계되었습니다.
MapReduce는 데이터를 Key-Value 형태로 가공하며, 처리 과정은 크게 Map 단계와 Reduce 단계로 나뉩니다.
Map 단계에서는 데이터를 분할하고 병렬로 처리하며, Reduce 단계에서는 그 결과를 수집하고 통합합니다.
이 구조 덕분에 개발자는 Map과 Reduce 함수만 정의하면 되고, 나머지 작업(Job 분할, Task 배정, 장애 복구 등)은 Hadoop이 자동으로 처리합니다.
또한 Java를 기본으로 하지만, C++, Python 등 다양한 언어도 지원됩니다 (예: Hadoop Streaming 사용 시).
따라서 개발자는 인프라 걱정 없이 비즈니스 로직 구현에 집중할 수 있습니다.
🐘 2. MapReduce 알고리즘과 구동방식
MapReduce는 크게 두 가지 함수로 구성되어 있습니다.
📌 Map Function
입력 데이터를 (key1, value1) 형태로 받아, 이를 (key2, value2) 형태의 중간 결과로 변환합니다.
ex : (key1, value1) → (key2, value2)
📌 Reduce Function
Map 단계에서 생성된 중간 결과들을 key 기준으로 그룹화한 뒤, 같은 key를 갖는 값들의 리스트를 받아 최종 결과 (key3, value3)을 생성합니다.
ex : (key2, List<value2>) → (key3, value3)
이러한 두 단계를 통해 데이터는 분산 환경에서 병렬로 처리되며, 최종적으로 집계 및 가공된 결과를 얻게 됩니다.
또한 MapReduce 의 구동 방식은 크게 세 가지입니다.
🔹 Local
단일 JVM 에서 전체 Job 을 실행하는 방식으로, 테스트 용도 정도로만 쓰이지 실제로는 이러한 방식으로 운영하지 않습니다.
🔹 Classic
Hadoop 1.0 버전때까지 유지하던 Map Reduce 분산 처리 방식이며, 1개의 Job Tracker 와 여러개의 Task Tracker 를 사용하는 초기 Map Reduce 방식입니다.
🔹 YARN
Hadoop 2.0 YARN 도움 이후의 Map Reduce 방식입니다. Map Reduce 이외의 워크로드도 수용이 가능한 버전입니다.
이러한 Map Reduce 는, 단순하고 사용이 편리하며, 특정 데이터 모델이나 스키마에 의존적이지 않은 유연성을 가지고 있고, 저장 구조도 독립적입니다. 또한 데이터 복제에 기반한 내구성과 자체 재시도 로직을 통한 내고장성도 확보하고 있으며, 하둡을 활용한 높은 확장성을 가지고 있다는 장점이 있습니다.
그러나, 고정된 단일 데이터 흐름을 가지고 있고, Hive 등이 있지만 여전히 DBMS 보다는 불편한 스키마 질의, 그리고 작업 데이터를 처리하기에 적합하지 않고, 기술 지원이 어렵다는 단점등을 가지고 있습니다.
그럼 지금부터, MapReduce 에 대해 좀 더 자세히 공부한 내용을 정리해보겠습니다.
🐘 3. Map Reduce 1.0 & 기본
파트를 1.0 과 2.0 으로 나누기는 하였지만, Map Reduce 1.0 과 2.0 이 크게 차이가 있지는 않습니다.
어느정도 단점을 보완해주고, YARN 을 통해 구동하는가 안하는거 정도라고 할 수 있습니다.
그래서 현재 1.2 파트에서는 1.0과 현재 Map Reduce 의 기본이 되는 부분을 공부하고 정리하였습니다.
다음 파트인 MapReduce 2.0 에서는 변동사항만 정리할 예정입니다.
🐘 3.1. Hadoop 1.x에서의 MapReduce 구성 요소
Hadoop 1.x에서는 클래식 MapReduce 모델을 사용하며, 다음과 같은 주요 컴포넌트로 구성됩니다.
JobTracker (1개):
클러스터 전체에서 Job의 실행을 총괄하는 마스터 역할을 합니다.
클라이언트로부터 Job을 받아 Task로 나누고, 적절한 TaskTracker에게 분배합니다.
TaskTracker (여러 개):
각각의 노드에 존재하며, JobTracker로부터 Task를 할당받아 실제로 Map 또는 Reduce 작업을 수행합니다.
Client:
사용자가 직접 작성한 MapReduce Job을 hadoop jar 명령어로 실행하면, 이 Job을 제출하는 주체가 됩니다.
HDFS:
Job 실행 중 사용되는 입력 파일, 중간 파일, 출력 파일 등은 모두 HDFS 상에 저장됩니다.
Mapper/Reducer 간의 데이터 공유도 HDFS를 통해 수행됩니다.
🐘 3.2. MapReduce는 언제 동작하는가?
MapReduce는 단순한 파일 업로드, 다운로드, 삭제와 같은 명령어에서는 동작하지 않습니다.
즉, hdfs dfs -put, -get, -ls, -rm 같은 명령어를 사용할 때는 MapReduce가 관여하지 않으며,
이 경우 HDFS 클라이언트가 직접 NameNode와 DataNode에 요청을 보내 처리합니다.
반면, 다음과 같은 경우에는 MapReduce 엔진이 실제로 Job을 실행합니다.
hadoop jar로 실행한 MapReduce 프로그램
Hive, Pig, Oozie 등에서 MapReduce 백엔드를 사용하는 쿼리 실행
Sqoop import/export 작업
기타 사용자 정의 분석 Job
이처럼 MapReduce는 “분산 처리 Job”이 필요한 경우에만 실행되며, 쉽게 풀어보면 Hadoop 작업 자체에서 동작하려면 MapReduce 프레임워크를 사용한 코딩이 필요하고, Hive 와 같은 엔진에서는 Hive에서 쿼리를 작성하면, Hive가 내부적으로 이 쿼리를 MapReduce 작업(또는 Spark, Tez 같은 다른 실행 엔진)으로 변환해 주게 됩니다. ( Hive 설정이나 환경에 따라 MapReduce가 기본 실행 엔진이거나 다른 엔진일 수 있습니다. )
🐘 3.3. MapReduce Task 구동 절차
출처 : Map Reduce 1.0 상세 구동 절차
사용자 애플리케이션 실행
사용자가 클라이언트에서 MapReduce 프로그램을 실행하면, 내부적으로 JobClient가 생성되고 실행됩니다.
Job ID 발급 요청
JobClient는 클러스터의 JobTracker에 연결하여 새로운 Job ID를 요청합니다.
Job 리소스 HDFS 업로드
실행에 필요한 코드, 설정 파일, 라이브러리 등을 HDFS에 업로드합니다.
JobTracker에 Job 제출
JobClient는 JobTracker에게 Job을 제출하며, 이후 작업 스케줄링은 JobTracker가 관리합니다.
Job 초기화
JobTracker는 제출된 Job에 대한 초기화 작업을 수행합니다 (메타정보 구성, 디렉터리 생성 등).
InputSplit 검색
HDFS에서 입력 데이터를 블록 단위로 나눈 후, 각 블록의 위치 정보를 기준으로 InputSplit을 구성합니다.
InputSplits 는, 물리적 Block 들을 논리적으로 그루핑 한 개념이라고 볼 수 있습니다.
InputSplit 은 Mapper 의 입력 데이터를 분할하는 방식을 제공하기 위해, 데이터 위치와 읽어 들이는 길이를 정의합니다.
Task 할당 요청 (Heartbeat)
TaskTracker는 주기적으로 JobTracker에 하트비트를 보내며, 실행 가능한 Task가 있는지 확인합니다.
Job 리소스 로컬 복사
Task가 할당되면, TaskTracker는 HDFS에서 필요한 Job 리소스를 자신의 로컬 디렉터리로 복사합니다.
Child JVM 실행
TaskTracker는 자식 JVM 프로세스를 생성하여, Map 또는 Reduce Task를 실행할 준비를 합니다.
Map 또는 Reduce Task 실행
할당받은 Task를 실행하여 실제 데이터 처리(Map 또는 Reduce 로직)를 수행합니다.
💬 참고: 위 절차는 Hadoop 1.x (MapReduce v1) 기준입니다.
Hadoop 2.x 이상에서는 YARN이 도입되며 JobTracker/TaskTracker가 ResourceManager/NodeManager로 대체됩니다.
🐘 3.4. MapReduce 동작 과정
MapReduce 는 다음 6가지 동작 과정을 가집니다.
입력 데이터 분할 (Input Splitting)
대용량 입력 파일을 여러 개의 Input Split으로 나눕니다.
각 Split은 병렬로 처리할 수 있는 최소 단위입니다.
보통 HDFS 블록 크기(예: 128MB)를 기준으로 나뉩니다.
맵(Map) 단계
각 Input Split에 대해 Map 함수가 병렬로 실행됩니다.
Map 함수는 입력 데이터를 키-값 쌍 (key-value pair)으로 변환합니다.
예를 들어, 텍스트 파일을 줄 단위로 읽어 단어별로 (단어, 1) 쌍을 만듭니다.
Map 함수 결과는 메모리 버퍼에 임시 저장됩니다.
중간 정렬 및 병합 (Sort and Merge)
Map 출력 결과는 내부에서 키를 기준으로 정렬됩니다.
정렬된 결과는 디스크에 임시 저장되며, 여러 Map 결과를 병합(merge)합니다.
이 단계는 후속 작업인 Shuffle을 원활하게 만듭니다.
셔플(Shuffle)
각 Mapper에서 정렬된 데이터를 Reducer로 전송합니다.
동일한 키를 가진 모든 값이 하나의 Reducer에 모이도록 데이터를 네트워크로 이동시킵니다.
이 과정은 Map과 Reduce 작업의 중간 단계로 네트워크 I/O를 많이 발생시킵니다.
리듀스(Reduce) 단계
Reducer는 전달받은 키별로 그룹화된 값을 입력받아 집계 연산을 수행합니다.
예를 들어, (단어, [1,1,1,...]) 값을 받아 단어 등장 횟수를 합산합니다.
Reduce 함수 결과는 최종 출력(key-value 쌍)으로 생성됩니다.
결과 저장 (Output)
Reduce 작업의 결과는 HDFS 같은 분산 파일 시스템에 저장됩니다.
출력 파일은 여러 파티션으로 나누어 저장될 수 있습니다.
요약해보면, 아래 표처럼 볼 수 있겠습니다.
단계
설명
1. 입력 분할
데이터를 여러 Split으로 나눔
2. 맵(Map)
각 Split에 대해 Map 함수 실행
3. 정렬 및 병합
Map 결과를 키별로 정렬, 병합
4. 셔플(Shuffle)
키별로 데이터를 Reducer에 전송
5. 리듀스(Reduce)
키별로 집계 작업 수행
6. 출력 저장
최종 결과를 분산 파일 시스템에 저장
🐘 3.5. MapReduce 예시
출처 : Map Reduce 예시
입력 데이터 분할 (Input Splitting)
100GB짜리 텍스트 파일이 있다고 합시다. 이 파일은 HDFS에 저장되고, 128MB 크기 단위로 쪼개집니다.
각 128MB 조각 하나가 하나의 Input Split이 됩니다.
맵(Map) 단계
각 Input Split은 별도의 Map Task에 할당되어 병렬로 처리됩니다.
Map 함수는 텍스트를 줄 단위로 읽고, 각 줄에서 단어를 분리해 (단어, 1) 쌍을 생성합니다.
예를 들어, 한 줄에 “Andy and Bob”이 있으면 (Andy, 1), (and, 1), (Bob, 1) 이런 식입니다.
이렇게 모든 단어에 대해 1씩 붙여서 중간 결과를 만듭니다.
중간 정렬 및 병합 (Sort & Merge)
각 Map Task가 만든 (단어, 1) 쌍들은 키(단어) 기준으로 내부 정렬됩니다.
메모리 버퍼가 차면 디스크에 spill 하여 임시 저장되고, 여러 번 spill된 데이터는 하나로 병합됩니다.
이 과정 덕분에 후속 작업인 Shuffle이 효율적으로 진행됩니다.
셔플(Shuffle)
각 Map Task가 정렬한 단어별 데이터를, 동일한 단어를 처리할 Reduce Task로 네트워크를 통해 전송합니다.
즉, “Andy”에 해당하는 모든 (Andy, 1) 데이터는 특정 Reduce Task로 모입니다.
이 단계는 Map과 Reduce 사이에서 데이터를 재분배하는 중요한 과정입니다.
리듀스(Reduce) 단계
Reduce Task는 한 단어에 대한 모든 1들의 리스트를 전달받아 합계를 계산합니다.
예를 들어, (Andy, [1, 1, 1, 1])이면 최종적으로 (Andy, 4)가 됩니다.
이 계산을 모든 단어에 대해 수행합니다.
결과 저장 (Output)
최종 (단어, 총횟수) 결과들은 HDFS 같은 분산 파일 시스템에 저장됩니다.
결과 파일들은 여러 개의 파티션으로 나뉠 수 있습니다.
MapReduce 의 해당 과정을 통해, 수백 GB 텍스트 파일에서도 단어별 등장 횟수를 효율적으로 셀 수 있습니다.
🐘 3.6. MapReduce 예시 구현을 위한 인터페이스
MapReduce는 다음과 같은 인터페이스 흐름을 통해 입력 데이터를 처리하고 출력까지 생성합니다:
Input → Mapper → Combiner(선택 사항) → Partitioner → Shuffle/Sort → Reducer → Output
🔹 Input: TextInputFormat
입력 데이터를 (k1, v1) 형태로 읽어오는 역할을 합니다. 보통 k1은 byte offset, v1은 텍스트 줄(String)입니다. 입력 포맷은 데이터 구조에 따라 달라질 수 있습니다.
🔹 Mapper: (k1, v1) → (k2, v2)
핵심 비즈니스 로직이 구현되는 부분입니다. 입력 데이터를 사용자가 정의한 방식으로 (k2, v2) 형식으로 변환합니다.
예시를 워드 카운트로 들어보면, (0, "Hello world") → ("Hello", 1), ("world", 1) 가 됩니다.
🔹 Combiner (선택 사항): (k2, list(v2)) → (k2, v2')
로컬에서 중복된 key를 먼저 합쳐 Shuffle 트래픽을 줄이는 역할을 합니다. 작은 로컬 reduce 역할을 하며, 결과 정확성에 영향을 주지 않는 경우에만 사용됩니다.
예시를 들어보면, ("word", [1,1,1]) → ("word", 3) 이라고 할 수 있습니다.
🔹 Partitioner: (k2, v2') → #Reducer
각 key를 어떤 Reducer에 보낼지 결정합니다. 기본은 hash(k2) % numReducers 방식이며, 사용자 정의도 가능합니다. 동일한 key는 항상 같은 Reducer로 가야 하므로 정합성에 중요합니다.
🔹 Shuffle/Sort
각 Mapper의 출력 데이터를 네트워크를 통해 Reducer로 전송합니다. 이 과정에서 key별로 데이터를 정렬(Sort)하고, 같은 key를 하나로 병합(Merge)합니다.
MapReduce 전체에서 가장 많은 트래픽이 발생하는 구간입니다.
🔹 Reducer: (k2, list(v2')) → (k3, v3)
정렬되고 그룹화된 데이터를 입력받아 최종 결과를 생성합니다. 사용자가 정의한 reduce() 함수가 실행됩니다.
예시를 들어보면, 다음과 같이 됩니다.("Hello", [1,1,1]) → ("Hello", 3)
🔹 Output: TextOutputFormat
최종 결과를 (k3, v3) 형식으로 출력 파일에 저장합니다. 보통 HDFS에 텍스트 파일 형태로 저장됩니다.
🔸 Combiner와 Partitioner는 성능 최적화에 큰 역할을 하며, 네트워크 병목을 줄이는 데 매우 중요합니다.
🐘 3.7. Combiner 와 Reducer 의 차이
Combiner는 Hadoop MapReduce에서 성능을 최적화하기 위한 선택적인 처리 단계입니다.
Reducer처럼 동작하지만, Map Task가 끝난 후 로컬 노드에서 실행되어 중간 데이터를 간략화하는 역할을 합니다.
즉, Reduce 작업을 미리 일부 수행함으로써, 전체적인 데이터 처리량을 줄여주는 보조적인 도구라고 볼 수 있습니다. 이 때문에 종종 “로컬 Reducer(Local Reducer)”, “세미 Reducer(Semi-Reducer)”라고도 불립니다.
MapReduce에서 가장 병목(Bottleneck)이 심한 단계는 Map → Reduce 사이의 Shuffle 단계입니다.
Map Task는 (key, value) 쌍들을 생성하며, 이 결과는 네트워크를 통해 Reducer로 전송되며, 이 과정에서 매우 많은 양의 데이터 전송이 발생할 수 있습니다.
Combiner는 이 중간 결과를 로컬에서 먼저 집계하여, Shuffle 단계로 넘어가는 데이터의 양을 줄여줍니다.
예를 들어 다음과 같은 문장이 있다고 가정해봅시다.
Hello Hello Hello World World Bye
Combiner 없이 처리할 경우 Map Task는 각 단어를 만나면 다음과 같은 (단어, 1) 형식의 출력을 생성합니다
("Hello", 1)
("Hello", 1)
("Hello", 1)
("World", 1)
("World", 1)
("Bye", 1)
이 모든 값은 그대로 Shuffle 단계를 거쳐 Reducer에게 전달되며, 네트워크로 6개의 레코드가 전송됩니다.
하지만 Combiner가 있을 경우 Combiner는 Map Task의 출력값을 받아 로컬에서 먼저 집계를 수행합니다.
그러면 다음과 같이 합쳐지게 됩니다.
("Hello", 3)
("World", 2)
("Bye", 1)
이제 Shuffle을 통해 Reducer로 보내야 할 데이터는 단 3개입니다. 네트워크로 전송되는 데이터가 절반으로 줄어든 셈입니다.
그림으로 보면, 아래와 같습니다.
출처 : Combiner 사용 전, 후 이미지
단, Combiner 를 사용 할 때에는 주의할 점이 있습니다.
먼저 Combiner는 실행이 보장되지 않습니다.
Hadoop이 내부적으로 최적화 여부를 판단해서 실행할 수도 있고, 하지 않을 수도 있습니다.
그리고 Reducer와 동일한 로직을 사용하지만, 항상 안전한 것은 아닙니다.
Combiner가 안전하게 사용되려면 연산이 다음 두 조건을 만족해야 합니다
결합 법칙(Associative): (a + b) + c == a + (b + c)
교환 법칙(Commutative): a + b == b + a
예를들면, Sum, Count, Min, Max는 안전하다고 할 수 있습니다.
하지만 Average, TopK, Percentile 등은 부정확한 결과를 낼 수 있기 때문에 사용하면 안 됩니다.
그렇기 때문에 Combiner 는 데이터 중복이 많고, 중간 결과의 크기를 줄이고 싶을 때, 그러면서 연산이 결합 가능하고 교환 가능할 때
즉, Word Count, Histogram, Sum, Max/Min 계산 등 누적 합산 또는 단순 통계성 작업일 때 사용하는 것이 좋으며, 하둡 내부적으로도 그렇게 판단하고 동작합니다.
🐘 3.8. MapReduce 실행 흐름 및 상태 갱신
MapReduce 프로그램이 실행되면, 클라이언트 노드에서 사용자가 작성한 Job이 JobClient를 통해 JobTracker에게 제출됩니다.
JobTracker는 전체 Job을 여러 개의 Task로 분할하고, 클러스터 내 적절한 노드에 Task를 분배하여 실행하도록 합니다.
각 Task는 워커 노드에서 실행되며, 해당 노드의 TaskTracker가 이를 관리합니다. 실제 Map 또는 Reduce 작업은 Child JVM에서 수행되며, 이는 각 Task가 서로 영향을 주지 않도록 하기 위한 구조입니다.
Task는 실행 도중에 현재 진행 상황이나 카운터 값 등을 TaskTracker를 통해 JobTracker에게 주기적으로 보고합니다. 또한 TaskTracker는 일정한 주기로 heartbeat를 보내어 자신의 정상 동작 여부를 JobTracker에게 알립니다.
사용자는 JobClient를 통해 Job의 상태를 지속적으로 확인할 수 있으며, JobTracker는 TaskTracker로부터 받은 정보를 바탕으로 전체 작업의 상태를 관리합니다.
만약 Task가 실행 중 장애가 발생하거나, TaskTracker가 일정 시간 동안 heartbeat를 보내지 않으면, JobTracker는 해당 Task를 실패로 간주합니다. 이후 동일한 데이터를 처리할 수 있는 다른 노드에 해당 Task를 재실행하도록 명령합니다.
이러한 방식으로 일부 노드에 장애가 발생하더라도 전체 Job은 자동으로 복구되며, 일정 시간의 지연이 있을 수는 있지만 대부분 정상적으로 완료됩니다.
이처럼 MapReduce는 장애 허용성을 갖춘 구조로 설계되어 있어, Job 상태 관리와 복구가 사용자 개입 없이 자동으로 처리됩니다.
🐘 3.9. 분산 캐싱
분산 캐시는 하둡 맵리듀스에서 맵(Map)과 리듀스(Reduce) 작업에 필요한 정적 파일(참조 데이터, 라이브러리, 설정 파일 등)을 클러스터 내 모든 노드에 자동으로 배포하여, 각 노드의 로컬 디스크에서 빠르게 접근할 수 있게 하는 기능입니다.
이를 통해 매번 HDFS에서 해당 파일을 읽지 않고, 로컬에 캐시된 파일을 사용함으로써 I/O 성능을 향상시키고 네트워크 부하를 줄입니니다.
주로 작은 크기의 참조 데이터, 공통 라이브러리, 설정 파일 등을 배포하는 데 사용됩니다.
기본적으로 맵리듀스 프레임워크는 분산 캐시에 등록된 파일을 작업 실행 시점에 클러스터 내 모든 노드에 자동으로 배포하고, 로컬에 캐싱하는 작업을 자동으로 수행합니다.
각 TaskTracker(NodeManager) 노드는 분산 캐시에 등록된 파일을 자동으로 HDFS에서 다운로드하여 로컬 디렉터리에 저장하며, 작업 중인 태스크는 분산 캐시에 등록된 파일을 로컬 경로를 통해 접근할 수 있습니다.
즉, 분산 캐시에 등록된 파일의 ‘배포 및 로컬 캐싱’은 자동으로 처리되며, 이 부분을 개발자가 직접 구현할 필요는 없습니다.
하지만 분산 캐시 기능이 자동 배포를 지원하더라도, 다음 부분은 개발자가 직접 수행해야 합니다.
분산 캐시에 사용할 파일을 사전에 HDFS에 업로드해야 합니다.
당연한 소리이긴 하지만, 분산 캐시는 HDFS에 저장된 파일을 클러스터 전체로 배포하는 개념이기 때문입니다.
맵리듀스 작업 실행 시 분산 캐시에 사용할 파일을 명시적으로 등록해야 합니다.
예를 들어 Java API에서는 아래와 같이 분산 캐시에 파일을 등록합니다.
DistributedCache.addCacheFile(new URI("/path/in/hdfs/file.txt"), job.getConfiguration());
🐘 4. Map Reduce 2.0 변동사항
MapReduce 1.0은 일괄 처리(batch processing)만을 지원하며, 데이터 간의 상호작용이 없는 비대화형 처리 방식이었습니다.
그리고 MapReduce Job을 작성하는 것이 복잡하고, 이를 처리할 수 있는 숙련된 개발자가 부족하다는 문제가 있었습니다.
또한, MapReduce Job은 다양한 비즈니스 요구사항에 부합하지 않는 경우가 많았고, 기업에서 필요로 하는 보안성이나 고가용성(high availability) 같은 기능이 부족하다는 점도 큰 제약이었습니다.
즉, MapReduce 1.0은 단순하고 정적인 데이터 처리에는 적합했지만, 유연하고 다양한 처리 요구를 수용하기에는 구조적으로 한계가 있었습니다.
그렇지만 Hadoop 2.x 과 함께 들어온 MapReduce 2.0은 기존 MapReduce 1.0 구조의 한계를 극복하기 위해 JobTracker의 역할을 분리하는 아키텍처 개선을 도입하였습니다.
기존에는 JobTracker가 자원관리(Resource Management)와 작업 스케줄링, Job 생명주기 관리 등 모든 역할을 수행했으나, 2.0에서는 이를 분리하여 새로운 YARN (Yet Another Resource Negotiator) 구조를 도입했습니다.
YARN은 클러스터 전체의 자원을 중앙에서 관리하고, 다양한 형태의 애플리케이션 실행을 가능하게 합니다.
즉, 기존 MapReduce 외에도 실시간 처리, 대화형 쿼리, 그래프 처리, 스트리밍 등 다양한 워크로드를 지원할 수 있게 되었으며, 이를 통해 Hadoop이 일괄처리 플랫폼에서 범용 데이터 처리 플랫폼으로 진화할 수 있었습니다.
MapReduce 2.0은 다음과 같은 이점을 가집니다.
자원 관리와 Job 생명주기 관리의 분리로 인한 구조적 효율성 향상
여러 형태의 분산 Job 생명주기 관리 지원
다양한 애플리케이션 프레임워크 실행 가능 (예: Spark, Tez, Flink 등)
대화형 처리 및 실시간 처리 지원
MapReduce 코드를 직접 작성하지 않아도 되는 프레임워크 사용 가능
더 많은 비즈니스 모델에 적합
보안성과 고가용성 개선
분산 캐시 기능 향상
특히, 분산 캐시 기능이 향상됨에 따라, DistributedCache 는 더이상 사용되지 않게 되었으며, 아래처럼 addCacheFile 을 사용합니다.
Job job = Job.getInstance(conf, "Example Distributed Cache Job");
job.addCacheFile(new URI("/path/in/hdfs/file.txt#alias")); // alias 는 심볼릭 링크입니다. 저 alias 를 통해 추후 파일을 쉽게 찾아 읽을 수 있습니다.
항목
MapReduce 1.0
MapReduce 2.0 (YARN 기반)
처리 방식
일괄 처리만 지원
일괄 + 실시간 + 스트리밍 등 다양
실행 구조
JobTracker 단일 구성
ResourceManager + ApplicationMaster로 분리
확장성
낮음
높음
유연성
정적
동적, 다양한 프레임워크 지원
보안 / 가용성
부족함
보안성과 고가용성 강화
지원 애플리케이션
MapReduce만 지원
Spark, Tez, Flink 등 다양한 엔진 지원
Job 작성
복잡한 MapReduce 코드 필요
다양한 API 사용 가능 (Java, SQL, Scala 등)
기업 적용성
낮음
다양한 비즈니스 모델에 적용 가능
MapReduce 1.0은 단일 JobTracker 구조로 인해 처리 유형, 확장성, 유연성에서 한계가 있었으며, 다양한 기업 요구사항을 충족하기 어려웠습니다.
반면 MapReduce 2.0은 YARN 기반으로 아키텍처를 개선하여 자원 관리와 Job 관리 기능을 분리하고, 다양한 처리 방식과 프레임워크를 지원함으로써 보다 확장 가능하고 유연한 데이터 처리 플랫폼으로 발전하였습니다.
🐘 5. Map Reduce 기능과 프로그래밍 실습
🐘 6. Map Reduce 프로그래밍의 한계
🐘 6.1 프로그래밍 시간이 오래 걸림
Java 프로그래밍으로 MapReduce 프레임워크와 인터페이스에 맞추어 대용량 분산 데이터를 처리할 수 있는 것은 큰 발전입니다.
하지만, MapReduce 작업을 하려면 데이터 파싱부터 처리, 정렬, 조인까지 모든 과정을 Java로 직접 구현해야 합니다.
예를 들어, 단순한 로그 파일에서 특정 조건의 데이터를 뽑아 집계하려면, 로그 형식을 파싱하는 코드부터 시작해서, 원하는 집계 로직을 모두 Java 코드로 작성해야 합니다.
이 과정은 복잡하고 시간이 많이 소요됩니다.
따라서 SQL이나 Pig, Hive와 같은 데이터 조작을 쉽게 할 수 있는 도구들이 많이 등장한 이유가 바로 여기에 있습니다.
이들은 복잡한 MapReduce 코드를 자동으로 생성해 주므로 개발 시간을 크게 줄여 줍니다.
🐘 6.2 데이터 모델과 스키마의 부재
MapReduce에서는 HDFS에 저장된 파일의 형식만 지정할 수 있을 뿐, 파일 안의 내용을 해석하는 책임은 전적으로 개발자에게 있습니다.
즉, 데이터를 어떻게 파싱하고, 어떤 필드를 읽어들일지 모두 직접 코딩해야 합니다.
예를 들어, CSV 파일이 조금이라도 형식이 바뀌거나 새로운 필드가 추가되면, 파싱 코드를 수정해야 하며, 이는 유지보수를 어렵게 만듭니다.
또한, 복잡한 데이터 타입이나 계층적 데이터 모델을 표현하는 것도 쉽지 않습니다.
예외 처리 또한 개발자가 직접 처리해야 하므로, 잘못된 데이터가 섞여 있을 때 발생하는 오류를 모두 감당해야 합니다.
이처럼 스키마와 데이터 모델이 없으면 데이터의 변화에 유연하게 대응하기 어렵고, 복잡한 데이터 처리가 힘듭니다.
🐘 6.3 고정된 데이터 흐름
MapReduce 작업은 기본적으로 Map 단계와 Reduce 단계라는 고정된 두 단계의 데이터 흐름을 따릅니다.
예를 들어, “단어 수 세기” 같은 작업은 입력 데이터를 Map에서 키-값 쌍으로 변환하고, Reduce에서 집계하는 구조로 매우 명확합니다.
하지만 더 복잡한 데이터 처리 로직이나 여러 단계가 필요한 경우, 여러 MapReduce 작업을 체인처럼 연결해야 하며, 각 작업마다 컴파일과 실행 과정을 반복해야 합니다.
이는 개발과 테스트 사이클이 길어지고, 빠르게 아이디어를 검증하기 어렵게 만듭니다.
또한, MapReduce 자체는 동적 데이터 흐름이나 분기 처리와 같은 유연한 작업 흐름을 지원하지 않으므로, 복잡한 워크플로우 구현에는 한계가 있습니다.
🐘 6.4 낮은 효율성과 느린 속도
MapReduce는 장애 허용(fault tolerance)과 확장성(scalability)을 보장하기 위해 중간 결과를 HDFS에 저장하는 체크포인트 방식을 사용합니다.
예를 들어, Map 작업이 끝난 후 결과를 디스크에 저장하고, 그 결과를 Reduce 작업이 읽어 처리합니다.
이 때문에 네트워크와 디스크 입출력이 많아지고, 전체 작업 속도가 느려집니다.
또한, 데이터 그룹핑을 위해 외부 정렬과 병합 과정을 거치는데, 이 과정 역시 많은 자원을 소모합니다.
때문에 논리적으로 병렬로 처리할 수 있는 작업도 외부 정렬 등으로 인해 순차적인 단계가 생겨 전체 처리 속도가 제한됩니다.
복잡한 작업이나 긴 데이터 처리 흐름에서는 이런 비효율성이 크게 누적되어, 최신 분산 처리 시스템들이 MapReduce 대신 DAG 기반의 워크플로우를 사용하는 이유가 되기도 합니다. ( Hive 의 Tez 도 DAG 기반입니다. )
이처럼 MapReduce는 대용량 데이터 처리를 가능하게 했지만,
직접 코드를 작성해야 하는 번거로움, 고정된 처리 흐름, 그리고 성능상의 한계로 인해
현대 데이터 처리 환경에서는 이를 보완하거나 대체할 수 있는 다양한 프레임워크와 도구들이 함께 사용되고 있습니다.
◈
✏️ 결론
MapReduce를 공부하면서 느낀 점은, 처음 접할 때는 그 구조와 흐름이 복잡하고 어렵게 느껴졌지만, 실제로는 매우 직관적이고 체계적인 분산 처리 모델이라는 것입니다. 대량의 데이터를 작은 단위로 나누고, 각 단위 작업을 병렬로 처리한 뒤 결과를 모아 최종 결과를 만드는 방식은 대규모 데이터 처리에서 매우 효과적이라는 걸 새삼 깨달았습니다. 특히, 자원 관리와 작업 스케줄링이 자동화되어 있어 개발자가 복잡한 분산 환경의 문제를 직접 관리하지 않아도 된다는 점이 인상적이었고, 이런 점이 MapReduce가 오랫동안 빅데이터 처리의 표준으로 자리 잡을 수 있었던 이유라고 생각합니다.
하지만 동시에, MapReduce가 모든 작업에 최적화된 해법은 아니라는 점도 알게 되었습니다. 복잡한 연산이나 반복적인 작업에서는 성능 한계가 있고, 최근 등장한 다양한 분산 처리 기술들과 비교했을 때 유연성에서 부족한 부분도 있다는 점에서 MapReduce의 한계를 인정할 수밖에 없습니다. 그럼에도 불구하고, 분산 데이터 처리의 기본 개념을 이해하는 데 있어 MapReduce만큼 좋은 출발점은 드물다고 생각합니다.
결국 이번 공부를 통해 데이터 엔지니어로서 분산 처리 시스템의 본질을 이해하는 데 큰 도움이 되었고, 앞으로 더 복잡한 시스템이나 최신 기술을 접할 때 MapReduce에서 배운 기본 원리와 설계 철학을 바탕으로 더욱 깊이 있는 이해와 응용이 가능할 것이라는 자신감을 얻었습니다. 데이터 처리 시스템을 설계하거나 최적화할 때 이 모델이 가진 강점과 한계를 모두 고려하는 균형 잡힌 시각이 필요하다는 점도 다시 한 번 마음에 새겼습니다.
◈
📚 공부 참고 자료
📑 1. 패스트 캠퍼스 - 한 번에 끝내는 데이터 엔지니어링 초격차 패키지 Online.
📑 2. OREILLY 하둡 완벽 가이드 4판
📑 3. Apache Hadoop 공식 Docs
📑 4. Hadoop MapReduce Combiner 알아보기
📑 5. MapReduce 2.0 등장 배경, 특징
📑 6. MapReduce 의 이해
-
📘 YARN(Yet Another Resource Negotiator) 기본 이론
저번 포스팅에서는, 하둡의 핵심을 공부하였습니다.
이번에는 Hadoop V2 의 핵심인 YARN 에 대하여 공부한 내용을 포스팅 하도록 하겠습니다.
📘 분산 시스템의 이해와 하둡의 등장 배경
📘 하둡의 핵심 구성요소와 이론
◈
🐘 1. YARN
2012년 이전, Hadoop을 이용해 대용량 처리를 하려면 MapReduce 모델을 기반으로 Java, Python, Ruby, 혹은 Pig 프레임워크를 사용해 개발해야 했습니다.
하지만 Hadoop 2.0과 함께 등장한 Yarn은 MapReduce 프로그래밍 모델의 제약에서 벗어나, 다양한 멀티프로세싱 프로그램을 Hadoop 자원을 활용해 자유롭게 실행할 수 있게 해주었습니다.
Yarn은 대용량 멀티프로세싱 처리에서 성능 향상과 유연한 실행 엔진(execution engine)을 제공한다는 점이 큰 장점입니다.
또한, 시간이 지나면서 Spark와 같은 다양한 분산 처리 프레임워크도 Yarn을 지원하게 되면서, 하둡 생태계에서 필수적인 리소스 관리 플랫폼으로 자리잡았습니다.
이러한 YARN 에 대하여, 지금부터 공부하고, 정리한 내용을 포스팅 해보도록 하겠습니다.
🐘 1.1. YARN 이란
출처 : YARN
Yarn은 Hadoop 2.0부터 도입된 클러스터 자원 관리 시스템(Resource Manager)입니다.
이전 Hadoop 버전에서는 MapReduce라는 특정 프로그래밍 모델에만 의존해서 작업을 처리했는데, Yarn이 도입되면서 MapReduce에 국한되지 않고 다양한 종류의 분산 처리 작업을 실행할 수 있게 되었습니다.
Yarn의 주요 역할은 클러스터 내 여러 노드의 자원을 효율적으로 관리하고 할당하여, 여러 애플리케이션이 동시에 안정적으로 실행될 수 있도록 조율하는 것입니다.
즉, 여러 사용자가 다양한 작업을 동시에 실행해도 자원이 과도하게 집중되거나 낭비되지 않고, 적절하게 분배되어 전체 시스템의 효율성과 안정성을 높입니다.
Yarn은 또한 실행 환경을 유연하게 제공하여, Apache Spark, Flink, Tez 같은 다양한 분산 처리 프레임워크도 지원합니다.
이로 인해 빅데이터 처리의 범위가 단순한 배치 작업에서 실시간 스트리밍, 머신러닝, 그래프 처리 등으로 확장될 수 있었습니다.
🐘 1.1.1. Yarn의 대표적인 Use-Cases
Yarn이 등장하면서 Hadoop 기반 클러스터는 다음과 같은 장점과 가능성을 갖게 되었습니다.
🔍 배치 작업뿐 아니라 반복 작업과 실시간 스트리밍 처리 지원
과거 Hadoop은 일괄 처리(batch processing)에 초점이 맞춰져 있었으나, Yarn 덕분에 지속적으로 데이터를 처리하는 스트리밍 작업과 주기적으로 반복 실행되는 작업들도 효율적으로 수행할 수 있게 되었습니다.
예를 들어, 실시간 로그 분석, 실시간 추천 시스템 등 웹 서비스에 필요한 빠른 데이터 처리가 가능해졌습니다.
🔍 클러스터 자원 활용률 극대화
클러스터 내 자원을 한 애플리케이션이 모두 사용하지 않을 때, Yarn이 자원을 다른 애플리케이션에 재할당해 줌으로써 자원 활용률을 크게 향상시켰습니다.
이는 비용 절감과 클러스터 운영 효율을 높이는 데 중요한 역할을 합니다.
🔍 통합 클러스터 운영
데이터 저장(HDFS), 데이터 처리(MapReduce, Spark 등), 데이터 조회 등 다양한 작업을 단일 클러스터에서 함께 처리할 수 있어 운영 관리가 간편해졌고, 데이터 이동에 필요한 시간과 비용도 줄일 수 있었습니다.
🔍 다양한 애플리케이션 동시 실행 지원
한 클러스터에서 여러 종류의 애플리케이션이 동시에 안정적으로 실행되어, 여러 팀이나 서비스가 하나의 클러스터를 공유할 수 있습니다.
🐘 1.1.2. Container 란?
Docker 를 사용해보신 분들이라면, Container 의 개념을 어느정도 알고 계실 것입니다.
컨테이너는 소프트웨어 실행 환경을 하나의 패키지로 묶은 경량화된 가상화 기술입니다.
여기에는 애플리케이션이 동작하는 데 필요한 코드, 라이브러리, 설정 파일 등이 포함되어 있어, 어디서든 동일한 환경에서 실행할 수 있습니다.
컨테이너 기술은 리눅스의 cgroup과 namespace 같은 기능을 활용하여, 하나의 물리적 서버 내에서도 각각의 컨테이너가 독립적으로 CPU, 메모리, 네트워크 자원을 사용할 수 있도록 격리합니다.
이 격리 덕분에 서로 다른 컨테이너가 충돌하거나 간섭하지 않고, 안정적으로 동시에 실행될 수 있습니다.
컨테이너를 사용하는 주요 이유와 장점은 다음과 같습니다
📦 책임 분리(Separation of responsibility)
개발자는 애플리케이션 코드와 그에 필요한 의존성만 신경 쓰면 되고, 배포 환경의 세부 사항(운영체제 버전, 라이브러리 충돌 등)은 크게 신경 쓸 필요가 없습니다.
덕분에 개발과 운영 간 협업이 원활해지고, 배포 및 테스트 과정에서 환경 문제로 인한 오류가 줄어듭니다.
📦 높은 이식성(Portability)
컨테이너는 운영체제 수준에서 가상화되므로, 리눅스 기반 서버라면 어느 환경에서든 동일하게 실행할 수 있습니다.
단, 컨테이너가 정상적으로 작동하려면 해당 시스템이 컨테이너 실행을 지원해야 하며, 일반적으로 Linux/amd64 아키텍처에서 가장 원활합니다.
📦 애플리케이션 격리(Application isolation)
컨테이너마다 CPU, 메모리, 스토리지, 네트워크 같은 자원을 할당하고 제한할 수 있기 때문에, 하나의 서버에서 여러 컨테이너가 동시에 실행되어도 서로 간섭 없이 안정적으로 작동합니다.
이는 시스템 안정성과 보안성을 높이는 데도 도움이 됩니다.
최근에는 Kubernetes 같은 오케스트레이션 도구와 함께 컨테이너를 사용해, 대규모 분산 시스템을 효율적으로 운영하는 사례가 많아지고 있습니다.
Yarn도 이런 컨테이너 개념을 차용해 클러스터 자원 관리에 적용함으로써, 더욱 세밀하고 유연한 자원 할당과 작업 관리를 가능하게 합니다.
🐘 1.2. YARN 아키텍처
출처 : YARN 과 HDFS
Yarn 과 HDFS 자체는 완전히 독립적입니다.
Yarn은 CPU, 메모리와 같은 컴퓨팅 자원을 할당하고 관리하는 자원 관리 소프트웨어입니다.반면, HDFS는 분산 파일 시스템으로 데이터 저장(스토리지) 역할만 수행합니다. 따라서 Yarn과 HDFS는 완전히 독립적인 시스템이며, 서로 다른 역할을 맡고 있습니다.
즉, Yarn은 저장소와 관계없이 클러스터의 컴퓨팅 자원 관리에 집중합니다.
그러한 Yarn 의 Architecture 는 크게 3가지 역할을 하는 컴포넌트로 구성 되어있습니다.
Resource Manager: 클러스터 전체 자원 관리 및 스케줄링
Application Master: 개별 애플리케이션의 자원 요청 및 관리
Node Managers: 각 노드에서 자원 할당 및 작업 실행
출처 : YARN 아키텍처
그럼 YARN 의 기본과 핵심 아키텍처를 하나하나 알아보도록 하겠습니다.
🐘 1.2.1. Resource Mananger (RM)
Resource Manager(RM)는 YARN 클러스터에서 가장 중요한 마스터 컴포넌트입니다.
클러스터 내 모든 컴퓨팅 자원(CPU, 메모리 등)을 총괄 관리하는 관리 본부 역할을 합니다.
예를 들어, 회사 내 여러 부서가 공유하는 컴퓨터 자원을 중앙에서 효율적으로 배분하는 총무팀이라고 생각할 수 있습니다.
RM은 클러스터 내에 존재하는 모든 노드들의 상태와 자원 정보를 알고 있으며, Rack Awareness(서로 물리적으로 가까운 서버 그룹 간의 네트워크 우선순위)도 고려해 자원 할당을 최적화합니다.
RM 내부에는 여러 서비스가 존재하지만, 그 중에서도 Scheduler가 핵심입니다. Scheduler는 각 애플리케이션이 요청한 자원 요구사항을 분석해, 누가 언제 얼마나 자원을 쓸지 결정합니다.
대표적인 Scheduler로는 Capacity Scheduler, Fair Scheduler 등이 있습니다. 이들은 클러스터 자원을 공평하게 나누거나, 우선순위에 따라 할당하는 정책을 구현합니다. 이렇게 RM은 단순히 자원을 할당하는 역할뿐 아니라, 리소스 할당 정책을 적용하고, 자원 사용 현황을 모니터링하며 장애 대응을 위한 정보도 관리합니다.
YARN 의 SCheduler 에 대해서는 아래 3번 챕터에서 좀 더 자세하게 정리하겠습니다.
🐘 1.2.2. Application Mananger (AM)
Application Master(AM)는 YARN에서 실행되는 각각의 애플리케이션에 대해 별도로 실행되는 매니저 프로세스입니다.
클러스터 전체를 관리하는 RM과 달리, AM은 특정 애플리케이션 단위의 프로젝트 팀장 역할을 합니다.
애플리케이션이 시작될 때 가장 먼저 실행되는 컨테이너(Container)로서, 자신의 작업에 필요한 리소스를 RM에 요청하고 할당받은 자원을 활용해 작업을 수행합니다.
예를 들어, 데이터 분석 작업 팀의 팀장이라면, RM에게 ‘CPU 4개, 메모리 8GB를 달라’고 요청하고, 할당받으면 그 자원을 기반으로 작업을 진행하는 식입니다.
AM은 할당받은 자원 내에서 작업 단위를 관리하고, 필요에 따라 추가 자원 요청, 작업 상태 모니터링, 오류 처리 등을 담당합니다. AM이 실패하면 해당 애플리케이션 작업 자체가 실패할 수 있으므로 안정성과 복구 메커니즘도 중요합니다. 또한 AM은 각기 다른 분산 처리 프레임워크(예: MapReduce, Spark, Tez 등)에 맞게 구현되며, 프레임워크별로 자원 관리 및 작업 조정 방법이 다를 수 있습니다.
🐘 1.2.3. Node Manager
Node Manager 는 클러스터 내 여러 노드(서버)에서 실행되는 에이전트입니다.
각 Node Manager 는 ‘서버 담당 기술자’로 비유할 수 있으며, 해당 노드의 상태와 리소스를 RM에 주기적으로 보고(heartbeat)합니다.
Node Manager 는 CPU 코어 수, 사용 가능한 메모리 양 등 자신의 노드 자원 용량(capacity)을 관리합니다.
YARN Scheduler가 할당을 결정하면 NM은 해당 자원 일부를 컨테이너(Container) 단위로 배정하고, 클라이언트가 제출한 작업을 실행합니다.
컨테이너는 리소스 격리를 위해 독립적인 실행 공간을 제공하며, CPU, 메모리, 네트워크 사용량을 제한할 수 있습니다. 또한 NM은 자원 사용량 모니터링, 컨테이너 시작 및 종료, 장애 감지와 복구 등 작업을 수행합니다.
Node Manager 는 Resource Mananger 의 지시에 따라 컨테이너 상태를 주기적으로 보고하며, RM은 이를 통해 클러스터 자원 현황을 정확히 파악할 수 있습니다.
NodeManager 내부에는 Container Manager, NodeStatusUpdater, NodeHealthChecker 등의 서비스가 있어, 각각 컨테이너 관리, 상태 업데이트, 노드 건강 체크 기능을 수행합니다.
이처럼 NM은 클러스터 내 물리적 하드웨어 자원을 추상화하여 YARN 애플리케이션에 안정적으로 제공하는 역할을 합니다.
🐘 1.3. YARN 의 작업 흐름
출처 : YARN 전체적인 흐름
작업의 흐름을 먼저 간단히 정리하고, 세부적으로 들여다보겠습니다.
클라이언트가 어플리케이션 실행 요청을 보냅니다.
사용자가 작업을 실행하고 싶을 때, 클라이언트 프로그램이 Yarn API를 통해 실행 요청을 합니다.
ResourceManager가 이 요청을 받고 유효하다고 판단하면, 클라이언트에게 고유한 Application ID(작업 식별 번호)를 할당합니다.
ResourceManager가 NodeManager에게 Application Master 실행을 요청합니다.
ResourceManager는 클러스터 내 노드들 중 하나의 NodeManager에게 Application Master라는 특별한 프로그램을 실행하라고 지시합니다.
Application Master는 해당 어플리케이션의 작업 진행을 총괄 관리하는 역할을 합니다.
NodeManager가 컨테이너 안에서 Application Master를 실행합니다.
NodeManager는 ResourceManager의 요청을 받고, 새 컨테이너(작업 실행 공간)를 만들어 JVM(Java Virtual Machine)을 실행하여 Application Master를 띄웁니다.
Application Master가 ResourceManager에게 필요한 리소스를 요청합니다.
Application Master는 작업에 필요한 리소스(메모리, CPU, 네트워크, 컨테이너 개수 등)를 ResourceManager에 요청합니다.
ResourceManager는 클러스터 전체 리소스 상황을 파악해, 사용할 수 있는 NodeManager 목록을 Application Master에 전달합니다.
Application Master가 할당받은 NodeManager에 컨테이너 실행을 요청합니다.
받은 NodeManager들에게 실제 작업을 수행할 컨테이너 실행을 요청합니다.
NodeManager들이 컨테이너를 실행하고 어플리케이션을 동작시킵니다.
NodeManager들은 각 컨테이너 안에 JVM을 새로 띄워 어플리케이션을 실행합니다.
작업이 끝나면 Application Master도 종료되고, ResourceManager는 종료된 Application Master가 사용하던 자원을 해제합니다.
이제 좀 더 세부적으로 들여다보겠습니다.
🐘 1.3.1. client ▸ RM : Application 실행 요청
출처 : YARN Application 실행 요청 단계
YARN 클러스터에서 어플리케이션을 실행하려면 클라이언트가 ResourceManager에게 일련의 요청을 순서대로 보내야 합니다. 이 과정에서 어플리케이션을 식별할 수 있는 ID를 발급받고, 실행 계획을 제출하며, 실행 상태를 모니터링할 수 있는 정보를 얻게 됩니다.
클라이언트가 Application ID를 요청합니다
클라이언트가 YARN 클러스터에서 새로운 어플리케이션을 실행하려면 먼저 Application ID가 필요합니다.
이를 위해 ClientRMService의 createNewApplication() 메소드를 호출합니다.
💡 예시:
사용자가 Spark 작업을 제출하면, Spark 클라이언트가 내부적으로 createNewApplication()을 호출하여 YARN에 새 작업 ID를 요청합니다.
YARN이 Application ID와 리소스 정보를 반환합니다
ClientRMService는 요청을 받고, 새로운 Application ID와 함께 클러스터에서 사용 가능한 최대 리소스 정보를 포함한 GetNewApplicationResponse 객체를 클라이언트에 전달합니다.
예: 최대 사용 가능한 메모리, 최대 CPU 코어 수 등
💡 예시:
클러스터 전체에서 한 컨테이너당 8GB 메모리, 4코어까지 할당 가능하다는 정보가 함께 전달됩니다.
클라이언트가 Application 제출 요청을 보냅니다
클라이언트는 Application ID가 정상적으로 발급되었는지 확인한 후, ClientRMService의 submitApplication() 메소드를 호출하여 어플리케이션 실행 요청을 보냅니다.
이때 ApplicationSubmissionContext 객체를 파라미터로 전달합니다. 이 객체에는 실행에 필요한 다양한 정보가 포함되어 있습니다:
Application ID
Application Name (예: “ETL Job for 2025-06-03”)
Queue name (예: “default”, “etl”)
Application Priority
필요한 리소스 (메모리, CPU 등)
Application Master 실행 명령이 담긴 ContainerLaunchContext
💡 예시:
“spark-etl-job”이라는 작업을 etl 큐에 제출하며, 메모리 4GB, CPU 2코어, 실행 명령은 spark-submit으로 설정합니다.
클라이언트가 Application 상태를 요청합니다
클라이언트는 어플리케이션이 정상적으로 등록되었는지 확인하기 위해 getApplicationReport()를 호출합니다.
ResourceManager가 ApplicationReport를 반환합니다
ResourceManager는 요청에 따라 해당 어플리케이션의 상태 및 실행 정보를 담은 ApplicationReport 객체를 반환합니다.
이 보고서는 어플리케이션의 실행 상태를 모니터링할 때 유용합니다.
ApplicationReport에 포함된 정보:
항목
설명
Application ID
어플리케이션의 고유 ID
User
실행한 사용자 이름
Queue
실행된 큐 이름
Application Name
어플리케이션 이름
ApplicationMaster Host
ApplicationMaster가 실행 중인 노드
RPC Port
ApplicationMaster의 통신 포트
Tracking URL
웹 UI에서 상태 추적 가능 링크
YarnApplicationState
어플리케이션 상태 (예: SUBMITTED, RUNNING, FINISHED 등)
Diagnostic Info
오류 발생 시 메시지
Start Time
어플리케이션 시작 시각
Client Token
보안 설정이 활성화된 경우 인증 토큰
💡 예시:
클라이언트는 이 정보를 주기적으로 조회해 작업 진행 상태나 실패 여부를 확인하고, 대시보드나 모니터링 시스템에서 시각화할 수 있습니다.
🐘 1.3.2. RM ▸ NM : Application Master 실행 요청
출처 : YARN Application Master 실행 요청 단계
클라이언트가 어플리케이션 제출을 완료하면, 이제 YARN은 클러스터 내부에서 ApplicationMaster를 실행하여 본격적으로 어플리케이션 실행을 시작합니다. 이 과정은 ResourceManager의 내부 구성 요소들과 NodeManager 간의 협업을 통해 이뤄집니다.
RMAppManager가 ApplicationMaster 실행을 위한 컨테이너를 요청합니다
ResourceManager의 구성 요소 중 하나인 RMAppManager는 YARN 내부 스케줄러에게 어플리케이션 등록 요청과 함께 ApplicationMaster 실행을 위한 컨테이너 할당을 요청합니다.
ApplicationAttemptId가 큐에 등록됩니다
YARN은 해당 어플리케이션 실행 시도를 고유하게 식별하기 위해 ApplicationAttemptId를 생성하고, 이를 큐에 등록합니다.
그리고 RMAppAttemptEventType.ATTEMPT_ADDED 이벤트를 발생시켜 등록이 완료되었음을 알려줍니다.
💡 예시:
하나의 Spark 작업이 실패 후 재시도되는 경우, 새로운 ApplicationAttemptId가 생성되어 큐에 다시 등록됩니다.
스케줄러에게 컨테이너 할당을 요청합니다
RMAppManager는 스케줄러에게 ApplicationMaster 실행에 필요한 컨테이너를 할당해달라고 요청합니다.
스케줄러가 컨테이너 할당 후 START 이벤트를 발생시킵니다
스케줄러는 적절한 NodeManager에서 사용할 수 있는 리소스를 기반으로 컨테이너를 할당하고, ApplicationMaster 실행을 위한 RMContainerEventType.START 이벤트를 발생시킵니다.
RMAppManager가 ApplicationMasterLauncher를 실행합니다
컨테이너가 할당되면, RMAppManager는 ApplicationMasterLauncher를 통해 실제 ApplicationMaster 실행을 시작합니다.
ApplicationMasterLauncher가 AMLauncher를 실행합니다
ApplicationMasterLauncher는 내부적으로 AMLauncher를 구동하여 ApplicationMaster를 실행합니다.
AMLauncher가 NodeManager에 ApplicationMaster 실행을 요청합니다
AMLauncher는 컨테이너 실행에 필요한 정보를 담은 ContainerLaunchContext를 준비하고, 이를 NodeManager에게 전달하여 컨테이너 실행을 요청합니다.
ContainerLaunchContext에 포함되는 정보:
항목
설명
ContainerId
컨테이너 고유 식별자
Resource
할당된 메모리 및 CPU 등 리소스
User
해당 컨테이너에 할당된 사용자
Security Tokens
보안이 활성화된 경우 필요한 토큰
LocalResources
실행에 필요한 jar, 바이너리, shared-objects, side files 등
Service Data
(선택) 어플리케이션 전용 서비스 데이터
Environment Variables
실행 시 필요한 환경 변수
Launch Command
컨테이너 실행 명령어
Retry Strategy
컨테이너가 실패할 경우 재시도 전략
💡 예시:
Spark의 ApplicationMaster는 내부적으로 spark-submit 명령을 launch command로 전달하고, 실행에 필요한 spark-assembly.jar 등을 local resource로 포함합니다.
NodeManager가 컨테이너를 실행하고 결과를 반환합니다
NodeManager는 전달받은 컨텍스트를 기반으로 컨테이너를 실행하고, 그 결과를 StartContainersResponse에 담아 AMLauncher에 반환합니다.
💡 예시:
컨테이너가 정상적으로 실행되면, NodeManager는 SUCCESS 상태와 함께 컨테이너의 상태 정보를 포함한 응답을 반환합니다.
🐘 1.3.3. NM ▸ AM : JVM으로 Application Master 실행
NodeManager는 ResourceManager의 요청에 따라 지정된 컨테이너 안에서 ApplicationMaster를 실행합니다.
이 과정은 실제로 새로운 JVM 프로세스를 생성해서 ApplicationMaster를 구동시키는 방식으로 이루어집니다.
ApplicationMaster를 실행하기 위해 NodeManager는 ContainerLaunchContext라는 실행 컨텍스트 정보를 사용합니다.
이 컨텍스트에는 다음과 같은 실행에 필요한 정보들이 포함되어 있습니다:
컨테이너 ID
할당된 리소스 정보 (메모리, CPU 등)
컨테이너를 실행할 사용자 정보
바이너리나 JAR 파일 등 로컬 리소스
환경 변수
실행 명령어 (command)
보안 토큰 (필요한 경우)
실패 시 재시도 전략 등
💡 예시:
ContainerLaunchContext의 command 필드에는 "java -Xmx1024m com.example.MyAppMaster" 와 같은 실제 실행 명령어가 설정됩니다.
NodeManager는 이 컨텍스트를 바탕으로 JVM을 생성하고 ApplicationMaster를 구동합니다.
ApplicationMaster가 정상적으로 실행되면, 이후 단계에서 ResourceManager에 등록 요청을 하게 됩니다.
🐘 1.3.4. AM ▸ RM : 필요한 리소스 요청, NM 목록 반환 및 Application Master 등록
NodeManager가 ApplicationMaster를 성공적으로 실행한 후에는, 해당 ApplicationMaster가 반드시 ResourceManager에 등록되어야 합니다.
이 과정을 통해 ResourceManager는 ApplicationMaster에게 적절한 자원을 할당하거나 상태를 모니터링할 수 있게 됩니다.
등록 과정에 들어가기 전에, 먼저 ApplicationMaster와 ResourceManager가 어떻게 통신하는지 간단히 살펴보겠습니다.
두 컴포넌트는 ApplicationMasterProtocol이라는 인터페이스를 통해 상호작용합니다.
YARN은 이 인터페이스를 구현한 기본 클라이언트로 다음 두 가지를 제공합니다.
AMRMClient
AMRMClientAsync
필요하다면 사용자가 직접 ApplicationMasterProtocol을 구현하여 커스터마이징할 수도 있습니다.
ApplicationMasterProtocol에서 제공하는 주요 메소드는 아래와 같습니다:
메소드
설명
registerApplicationMaster(request)
ApplicationMaster를 ResourceManager에 등록
allocate(request)
자원 요청 및 상태 보고 (heartbeat 포함)
finishApplicationMaster(request)
실행 완료 후 종료 처리 요청
이제 본격적으로 ApplicationMaster가 어떻게 등록되는지 구체적인 과정을 살펴보겠습니다.
출처 : YARN Application Master 등록 단계
ApplicationMaster가 ResourceManager에 등록 요청
ApplicationMaster는 registerApplicationMaster() 메소드를 호출하여 ResourceManager에 자신을 등록합니다.
이때 전달되는 RegisterApplicationMasterRequest 안에는 다음 정보가 포함됩니다.
ApplicationMaster가 실행 중인 호스트 이름
RPC 포트
사용자가 어플리케이션 상태를 확인할 수 있는 Tracking URL
💡 예시:
Spark의 ApplicationMaster는 spark://worker-node1:7077 같은 RPC 포트와 Web UI 주소를 등록합니다.
ResourceManager가 등록 처리 및 응답
ResourceManager의 ApplicationMasterService는 등록 요청을 받은 후, 해당 ApplicationMaster를 내부 목록에 추가합니다. 그리고 RegisterApplicationMasterResponse를 반환합니다.
이 응답은 AllocateResponse 형태로 구성되며 다음 정보를 포함합니다.
중복 응답 방지를 위한 Response ID
AM에게 전달되는 명령어 (e.g., 재시작, 종료 등)
새롭게 할당된 컨테이너 목록
종료된 컨테이너들의 상태
클러스터에서 현재 사용 가능한 리소스 양 (headroom)
변경된 노드 상태 목록
전체 노드 수
반환 요청된 리소스
(보안 활성화 시) AMRMToken
리소스가 증가 또는 감소된 컨테이너 목록
ApplicationMaster가 allocate 메소드로 리소스를 요청
ApplicationMaster는 이후 주기적으로 allocate() 메소드를 호출하여 다음을 수행합니다.
필요한 리소스 요청 (예: 컨테이너 개수, 위치, 사양 등)
어플리케이션의 현재 진행률 보고
사용하지 않는 컨테이너 반환
실행 중인 컨테이너의 리소스 변경 요청
💡 allocate() 메소드는 상태 유지를 위한 heartbeat 역할도 수행합니다.
AM은 필요한 컨테이너를 한 번에 못 받아도 이 메소드를 통해 점진적으로 리소스를 받을 수 있습니다.
AMRMClientAsync를 사용할 경우 기본 호출 간격은 1초입니다.
ResourceManager가 요청을 스케줄러에게 위임
ApplicationMasterService는 받은 allocate 요청을 내부 스케줄러에게 전달합니다.
스케줄러는 요청된 자원이 가용한지 확인하고, 가능할 경우 해당 리소스를 할당합니다.
결과는 다시 AllocateResponse에 담겨 ApplicationMaster로 반환됩니다.
💡 예시:
스케줄러는 사용자의 큐, 우선순위, 현재 클러스터 상태 등을 기반으로 자원 할당 여부를 판단합니다.
🐘 1.3.5. AM ▸ NM : 컨테이너 실행
출처 : YARN 컨테이너 실행 및 Application 실행 단계
ApplicationMaster는 자신이 할당받은 컨테이너에서 어플리케이션을 실행해야 합니다. 이를 위해 ApplicationMaster는 NodeManager와 상호작용합니다.
ApplicationMaster의 클라이언트는 NodeManager에게 컨테이너 실행을 요청합니다.
이때 사용되는 StartContainersRequest에는 다음 정보가 포함되어 있습니다.
할당된 리소스 (메모리, CPU 등)
보안 토큰 (보안이 활성화된 경우)
컨테이너를 시작하기 위한 실행 명령어
프로세스 환경 변수
필요한 바이너리, JAR 파일, 공유 라이브러리 등
NodeManager의 ContainerManager는 startContainer 메소드를 처리합니다.
ContainerManager는 ApplicationMaster가 요청한 대로 컨테이너를 실행하고, 실행 결과를 StartContainersResponse로 반환합니다.
StartContainersResponse는 다음과 같은 정보를 포함합니다.
getAllServicesMetaData() : 실행된 서비스의 메타데이터
getFailedRequests() : 실패한 컨테이너 실행 요청 목록
getSuccessfullyStartedContainers() : 성공적으로 시작된 컨테이너 목록
💡 예시:
ApplicationMaster가 NodeManager에 컨테이너 실행을 요청했을 때,
NodeManager가 할당된 메모리 4GB, CPU 2개를 가진 컨테이너를 시작하고, 실행 명령어로 java -jar app.jar를 실행합니다.
실행이 성공하면 StartContainersResponse에서 성공적으로 시작된 컨테이너 목록에 해당 컨테이너가 포함되어 클라이언트에 반환됩니다.
🐘 1.3.6. NM ▸ Container : Application 실행
사실 위 컨테이너 실행 과정과 Application 실행 과정을 하나로 통합하여 봐도 좋습니다.
컨테이너가 성공적으로 실행된 이후, ApplicationMaster는 getContainerStatuses()를 주기적으로 호출하여 각 컨테이너의 어플리케이션 상태를 모니터링합니다.
NodeManager의 ContainerManager는 ApplicationMaster가 요청한 컨테이너 상태를 GetContainerStatusesResponse로 반환합니다. 이 응답에는 컨테이너의 상태 정보가 포함되어 있습니다.
💡 예시:
ApplicationMaster가 주기적으로 컨테이너 상태를 조회할 때,
NodeManager는 컨테이너가 현재 RUNNING 상태임을 응답하고, 만약 오류가 발생했으면 오류 정보를 포함하여 전달합니다.
이를 통해 ApplicationMaster는 각 컨테이너의 상태를 실시간으로 관리할 수 있습니다.
🐘 1.3.7. Application Master 종료
출처 : YARN Application Master 종료 단계
컨테이너에서 실행했던 어플리케이션들이 종료되면, ApplicationMaster도 종료되어야 합니다.
ApplicationMaster가 종료되면 하나의 어플리케이션 라이프사이클이 완전히 종료되는 것입니다.
ApplicationMaster의 클라이언트는 ResourceManager에게 ApplicationMaster 종료를 요청합니다.
ResourceManager의 ApplicationMasterService는 해당 ApplicationMaster를 클러스터에서 해제하고, FinishApplicationMasterResponse를 반환합니다.
FinishApplicationMasterResponse에는 getIsUnregistered()라는 boolean 메소드가 있어서, 정상적인 해제와 종료 가능 여부를 알려줍니다.
만약 getIsUnregistered()가 true가 되기 전에 Application이 먼저 멈춘다면, ResourceManager는 해당 어플리케이션을 재시도합니다.
💡 예시:
어떤 어플리케이션이 정상적으로 작업을 마치고 종료 신호를 보내면, ResourceManager는 이 요청을 받아 ApplicationMaster를 해제하고, getIsUnregistered()가 true인 응답을 반환합니다.
반면, 작업이 갑자기 중단되거나 비정상 종료되면, ResourceManager는 재시도를 위해 ApplicationMaster를 다시 실행시킬 수 있습니다.
🐘 1.3.8. Auxiliary Service
YARN에서 Auxiliary Service(보조 서비스)는 NodeManager 간에 데이터를 주고받거나 서비스 제어를 가능하게 하는 기능입니다.
특히 하둡 맵리듀스 작업에서, 맵 태스크와 리듀스 태스크 사이의 데이터 전달 과정인 ‘셔플(Shuffle)’을 원활히 수행하는 데 중요한 역할을 합니다.
맵 태스크는 각 노드의 NodeManager가 관리하는 컨테이너(Container) 내에서 실행됩니다. 맵 태스크가 작업한 중간 결과 데이터는 리듀스 태스크가 실행되는 다른 노드의 컨테이너로 전달되어야 합니다. 그런데, NodeManager는 컨테이너 내 애플리케이션이 종료되면 해당 컨테이너를 즉시 종료합니다. 만약 맵 태스크가 끝나면서 컨테이너가 종료되면, 맵 태스크가 만든 중간 데이터를 저장하거나 리듀스 태스크에 전달할 수 없게 됩니다. 그 결과, 리듀스 태스크가 필요한 데이터를 받지 못해 리듀스 작업을 수행할 수 없게 됩니다.
즉, 맵 태스크가 데이터를 전달하기 전에 컨테이너가 사라지면, 리듀스 작업을 수행할 수 없게 되어 전체 작업 흐름이 멈추게 됩니다.
이런 문제를 해결하기 위해 YARN은 Auxiliary Service를 통해 NodeManager 간에 데이터를 안정적으로 전송할 수 있는 별도의 서비스 채널을 제공합니다. 이를 통해 컨테이너가 종료되더라도 맵에서 리듀스로의 데이터 전달(셔플)이 원활히 이루어질 수 있습니다.
따라서 Auxiliary Service는 맵리듀스 작업의 핵심 단계인 셔플 작업을 성공적으로 수행하기 위한 필수적인 보조 기능입니다.
출처 : YARN Application Master 종료 단계
동작 과정은 아래와 같습니다.
클라이언트가 ResourceManager에게 어플리케이션 실행을 요청합니다.
ResourceManager는 해당 어플리케이션의 ApplicationMaster를 실행합니다.
YARN은 맵리듀스 어플리케이션의 ApplicationMaster로 MRAppMaster를 제공합니다.
ResourceManager가 ApplicationMaster 실행을 요청하면 NodeManager가 컨테이너에서 MRAppMaster를 실행합니다.
MRAppMaster는 다른 NodeManager에게 맵 태스크 실행을 요청합니다.
NodeManager는 컨테이너에서 맵 태스크를 실행합니다.
맵 태스크가 수행한 결과는 셔플 과정을 통해 리듀스 태스크에 전달됩니다.
셔플(Shuffle)을 담당하는 기본 클래스는 하둡의 mapred 패키지에 구현된 ShuffleHandler입니다.
💡 예시
예를 들어, 맵 태스크가 노드 A의 컨테이너에서 실행되고, 리듀스 태스크가 노드 B에서 실행될 때,
노드 A의 NodeManager는 Auxiliary Service를 통해 중간 데이터를 안전하게 노드 B의 NodeManager로 전달합니다.
이 과정에서 ShuffleHandler가 셔플 데이터를 중계하며, 맵과 리듀스 사이 데이터 전달이 끊기지 않도록 보장합니다.
🐘 1.3.9. Pluggable Shuffle 및 Pluggable Sort 설정
Pluggable Shuffle 및 Pluggable Sort는 이 Auxiliary Service 위에서 동작하는, 셔플 단계의 데이터 처리 방식을 사용자 정의(customize)할 수 있도록 하는 기능입니다.
즉, 셔플 데이터를 어떻게 수집(ShuffleConsumerPlugin)하고 정렬(MapOutputCollector)할지를 플러그인 형태로 바꿀 수 있습니다.
다시 말해,
Auxiliary Service는 셔플 데이터를 전달하는 ‘기반 인프라’이고, Pluggable Shuffle/Sort는 그 위에서 작동하는 ‘동작 방식을 커스터마이징하는 기능’입니다.
Auxiliary Service는 맵리듀스의 셔플과 정렬 동작을 다음 두 가지 방식으로 커스터마이징할 수 있습니다.
Job 제출 시 설정
Job configuration에 인터페이스 구현체(예: ShuffleConsumerPlugin, MapOutputCollector)를 포함한 클래스를 지정합니다.
이 클래스들은 해당 Job 패키지 내에 위치해야 합니다.
예를 들어, job-conf.xml 혹은 프로그램 코드 내에서 관련 클래스를 지정하여 특정 셔플 동작을 커스터마이징합니다.
yarn-site.xml 설정
클러스터에 배포된 모든 노드의 yarn-site.xml 파일에 auxiliary-services 및 관련 서비스 설정을 추가합니다.
이를 통해 클러스터 전체에 커스텀 Auxiliary Service를 적용할 수 있습니다.
예를 들어 아래와 같습니다.
<property>
<name>yarn.app.mapreduce.aux-services</name>
<value>shuffle</value>
</property>
<property>
<name>yarn.app.mapreduce.aux-services.shuffle.class</name>
<value>org.apache.hadoop.mapred.ShuffleHandler</value>
</property>
💡 예시
만약 특정 회사에서 자체 개발한 셔플 최적화 로직을 적용하고 싶다면,
Job 제출 시에 ShuffleConsumerPlugin 인터페이스를 구현한 클래스를 제공하거나,
클러스터 전반에 적용할 경우 yarn-site.xml에 해당 aux-service 설정을 배포해 셔플 동작을 커스터마이징할 수 있습니다.
참고
해당 내용은 YARN 공식 매뉴얼 자료를 참고하였습니다.
🐘 2. YARN 아키텍처 심화
위에서는 YARN 의 흐름에 대해서 알아보았습니다.
그 중 중요한 Resource Manager 와, Node Manager 에 대한 좀 더 심화적인 내용을 몇 가지 공부하여, 정리하고 가고자 합니다.
🐘 2.1. Resource Manager 심화
출처 : YARN 심화 Resource Manager
YARN의 ResourceManager는 클러스터 전체의 두뇌 역할을 합니다. 이 컴포넌트는 클러스터의 모든 리소스를 중앙에서 통합적으로 관리하며, 애플리케이션이 실행되는 데 필요한 자원을 적절히 분배하고 조정합니다.
🐘 2.1.1. RM ↔ Client 상호작용하는 Component
🔹 ClientService
ClientService는 ResourceManager(RM)와 클라이언트 사이의 통신 인터페이스입니다. 클라이언트가 YARN 클러스터에 애플리케이션을 제출하거나, 상태를 조회하거나, 애플리케이션을 중지하는 등의 요청을 보낼 때 이 컴포넌트가 이를 받아 처리합니다.
이 인터페이스는 application submission, termination, queue 정보 조회, 클러스터 통계 조회 등의 기능을 담당합니다.
💡 예시
사용자가 yarn jar MyApp.jar 명령어로 애플리케이션을 제출하면, 해당 요청은 ClientService를 통해 RM에 전달됩니다.
🔹 AdminService
일반 사용자와 관리자의 요청을 구분하기 위해 존재하는 컴포넌트입니다. AdminService를 통해 오는 요청은 보통 시스템 운영자가 클러스터 구성을 변경할 때 사용됩니다.
일반적인 client 요청보다 우선순위가 높게 처리됩니다.
💡 예시
클러스터 운영자가 특정 노드를 클러스터에서 제외하고 싶을 때, AdminService를 통해 node list를 refresh합니다.
🐘 2.1.2. RM ↔ NM 상호작용하는 Component
🔹 ResourceTrackerService
ResourceTrackerService는 ResourceManager와 각 NodeManager 간의 통신을 담당하며, 각 노드의 상태(예: 가용 자원, 실행 중인 컨테이너 등)를 주기적으로 수신합니다.
이 컴포넌트는 노드 등록/삭제, 상태 보고(heartbeat) 등을 처리하며, YarnScheduler, NMLivelinessMonitor, NodesListManager와 밀접하게 연동됩니다.
💡 예시
어떤 NodeManager가 새로 시작되면 자신을 RM에 등록하기 위해 ResourceTrackerService에 RPC를 전송합니다.
🔹 NMLivelinessMonitor
이 컴포넌트는 모든 노드의 상태를 생존(live) 또는 죽음(dead) 으로 판단합니다. 각 노드가 일정 시간(기본 10분) 이상 heartbeat를 보내지 않으면 해당 노드를 dead 상태로 간주합니다.
💡 예시
특정 노드가 하드웨어 문제로 정지되어 heartbeat를 보내지 못하면, 이 컴포넌트가 이를 감지하고 RM은 해당 노드를 제외하고 리소스를 스케줄링합니다.
🔹 NodesListManager
이 컴포넌트는 클러스터에 포함된 노드들의 목록을 관리합니다. host inclusion/exclusion 목록을 기반으로 유효한 노드를 판단하고, 관리자가 제외시킨 노드도 지속적으로 모니터링합니다.
💡 예시
운영자가 dfs.exclude 파일에 노드 A를 추가하면, NodesListManager는 이를 감지하여 해당 노드를 클러스터에서 제거합니다.
🐘 2.1.3. RM ↔ AM 상호작용하는 Component
🔹 ApplicationMasterService
이 서비스는 모든 AM(ApplicationMaster)으로부터 오는 요청을 처리합니다. 애플리케이션이 실행 중인 동안, 컨테이너 요청, 할당 해제, 등록 및 종료 요청 등을 처리합니다.
💡 예시
ApplicationMaster가 3개의 컨테이너를 요청할 때 이 요청은 ApplicationMasterService를 통해 RM에 전달되고, 이후 YarnScheduler가 해당 요청을 검토해 자원을 할당합니다.
🔹 AMLivelinessMonitor
AMLivelinessMonitor는 각 ApplicationMaster의 생존 상태를 주기적으로 모니터링합니다. AM이 heartbeat를 보내지 않으면, RM은 해당 AM을 실패로 간주하고 복구 작업을 수행합니다.
💡 예시
AM이 오작동하거나 JVM 크래시로 인해 죽으면, 이 컴포넌트가 이를 감지하고 RM은 새로운 AM을 재실행할 수 있도록 준비합니다.
🐘 2.1.4. RM 핵심: 스케줄러와 리소스 관리자
🔹 ApplicationsManager
ApplicationsManager는 클러스터에 제출된 모든 애플리케이션을 관리합니다. 애플리케이션 실행 정보, 상태 추적, 완료된 애플리케이션 캐시 등을 유지합니다.
💡 예시
yarn application -status 명령어로 애플리케이션 상태를 확인할 수 있는 것은 이 컴포넌트 덕분입니다.
🔹 ApplicationACLsManager
Application 단위의 접근 제어(Authorization) 를 담당합니다. 각 애플리케이션마다 접근이 허용된 사용자 목록을 ACL 형태로 관리합니다.
💡 예시
특정 사용자가 다른 사용자의 애플리케이션 상태를 조회하려고 할 때, 이 컴포넌트가 ACL을 확인하여 접근 권한이 있는지를 판단합니다.
🔹 ApplicationMasterLauncher
새로운 애플리케이션이 RM에 제출되면, 이 컴포넌트가 ApplicationMaster 컨테이너를 실행시킵니다. 내부적으로 스레드 풀을 이용하여 비동기적으로 ApplicationMaster를 시작하고 관리합니다.
💡 예시
애플리케이션 제출 후 AM이 실행되지 않는 경우, 이 컴포넌트가 적절한 NodeManager에 AM 컨테이너를 배치하지 못했을 가능성이 있습니다.
🔹 YarnScheduler
YARN에서 자원 할당을 실제로 수행하는 핵심 컴포넌트입니다. 각 애플리케이션의 요구사항에 맞춰 메모리, CPU, 디스크, 네트워크 등의 자원을 스케줄링합니다.
💡 예시
ApplicationMaster가 2GB 메모리와 2 vCore가 필요한 컨테이너를 요청하면, YarnScheduler는 현재 가용한 자원을 확인하고 적절한 NodeManager에 컨테이너를 할당합니다.
🔹 ContainerAllocationExpirer
이 컴포넌트는 할당된 컨테이너가 실제로 사용되지 않으면 만료(expire) 시키는 역할을 합니다. AM이 컨테이너를 할당받았지만 사용하지 않을 경우, 해당 컨테이너를 회수합니다.
💡 예시
AM이 테스트 목적으로 컨테이너를 요청한 후 사용하지 않고 그대로 두면, 이 컴포넌트가 일정 시간 이후 이를 회수하여 자원을 낭비하지 않도록 합니다
🐘 2.1.5. TokenSecretManagers 보안 컴포넌트
YARN에서는 모든 통신을 인증된 RPC 로 수행합니다. 이를 위해 여러 종류의 토큰 기반 인증 시스템을 제공하며, 각 컴포넌트가 적절한 인증 정보를 통해 서로를 식별합니다.
🔹 ApplicationTokenSecretManager
ApplicationMaster가 RM과 통신할 수 있도록 인증 토큰을 발급하고 관리합니다. 애플리케이션별로 권한을 구분하기 위해 사용되며, 유효한 토큰을 가진 AM만이 요청을 보낼 수 있습니다.
💡 예시
악의적인 사용자가 애플리케이션 토큰 없이 RM에 자원 요청을 하면, 이 컴포넌트가 이를 거부합니다.
🔹 ContainerTokenSecretManager
AM이 NodeManager에 컨테이너 실행 요청을 보낼 때 필요한 Container Token을 발급하고 검증합니다. 해당 토큰을 통해 컨테이너가 합법적으로 실행되었는지를 확인할 수 있습니다.
💡 예시
RM이 AM에게 컨테이너 실행 정보를 주면서 함께 Container Token을 전달합니다. 이 토큰이 없다면 NodeManager는 해당 요청을 거부합니다.
🔹 RMDelegationTokenSecretManager
클라이언트가 RM과 인증된 통신을 하기 위해 사용하는 Delegation Token을 생성하고 관리합니다. 주로 클라이언트가 장시간 인증 없이 통신을 유지할 수 있도록 돕습니다.
💡 예시
HDFS에서 long-running job이 실행 중일 때, 중간에 재인증 없이 지속적으로 리소스를 요청하려면 delegation token이 필요합니다.
🐘 2.2. Node Manager 심화
출처 : YARN 심화 Node Manager
NodeManager는 YARN 클러스터의 각 워커 노드에서 실행되는 컴포넌트로, 컨테이너의 라이프사이클을 관리하고, 로컬 자원을 모니터링하며, 리소스 상태 정보를 ResourceManager(RM) 에게 주기적으로 보고합니다.
( 리소스 매니저는 중앙 서버 1곳에서만 실행되지만, 노드 매니저는 클러스터 내 모든 워커 노드에서 실행됩니다. )
NodeManager는 다음과 같은 핵심 구성 요소로 이루어져 있습니다.
🐘 2.2.1 NodeStatusUpdater
NodeStatusUpdater는 NodeManager가 시작될 때 ResourceManager에 자신을 등록하고, 해당 노드에서 사용 가능한 리소스 정보를 전달하는 컴포넌트입니다. 이후 주기적으로 컨테이너 상태, 자원 사용 현황, 노드 상태 등을 RM에 전송합니다.
RM은 NodeStatusUpdater를 통해 컨테이너 종료 명령 등을 전달할 수 있습니다.
💡 예시
NodeManager가 “서버-01”에서 시작되면, NodeStatusUpdater가 서버의 메모리 32GB, CPU 16코어 등의 정보를 RM에 전송하고, 이후 해당 노드의 컨테이너 상태를 주기적으로 보고합니다.
🐘 2.2.2 ContainerManager
ContainerManager는 NodeManager의 핵심 컴포넌트로, 컨테이너의 생성, 실행, 모니터링, 종료를 담당합니다. 다음의 하위 구성 요소들로 이루어져 있습니다:
🔹RPC Server
ApplicationMaster(AM)의 요청을 수신하여 새로운 컨테이너의 실행을 시작하거나 중지하는 서버입니다. 요청은 인증 절차를 거치며, 로그는 감사용으로 저장됩니다.
💡 예시
AM이 job-1234를 위해 컨테이너 실행 요청을 보내면, RPC Server가 이를 수락하고 해당 작업을 실행합니다.
🔹ResourceLocalizationService
컨테이너 실행에 필요한 파일이나 라이브러리를 다운로드하고, 디스크에 배포하여 컨테이너가 리소스를 안전하게 접근할 수 있도록 구성합니다.
접근 권한 및 사용 제한 정책도 함께 적용됩니다.
💡 예시
Spark 실행 시 필요한 JAR 파일들을 미리 다운받아 컨테이너 디렉토리에 배치하고, 해당 컨테이너만 접근 가능하도록 설정합니다.
🔹ContainersLauncher
컨테이너 실행을 위한 스레드 풀을 관리하며, 컨테이너를 신속하게 시작하고, 종료 요청 시 프로세스를 정리합니다.
💡 예시
RM이 job-5678의 컨테이너를 종료하라고 명령하면, ContainersLauncher는 해당 프로세스를 찾아 종료합니다.
🔹AuxServices (보조 서비스)
특정 프레임워크(Hive, Spark 등)에서 요구하는 추가 기능을 플러그인 방식으로 지원하는 구조입니다. NodeManager 시작 전에 설정해야 하며, 각 응용 프로그램의 컨테이너 시작/종료 시 알림을 받습니다.
💡 예시
ShuffleHandler는 MapReduce의 Reduce 작업을 위한 데이터를 전달하는 보조 서비스로, AM과 연동하여 동작합니다.
🔹ContainersMonitor
실행 중인 각 컨테이너의 메모리, CPU 등의 자원 사용량을 지속적으로 모니터링합니다. 지정된 할당량을 초과할 경우 해당 컨테이너를 종료합니다.
💡 예시
Container-XYZ가 4GB 할당을 받았지만, 6GB를 사용하는 경우 ContainersMonitor는 RM의 정책에 따라 해당 컨테이너를 종료합니다.
🔹LogHandler
컨테이너 실행 중 생성되는 로그를 로컬 디스크에 저장하거나 압축하여 업로드하는 기능을 제공합니다. 로그 핸들링은 플러그인 구조로 되어 있어 교체가 가능합니다.
💡 예시
컨테이너가 출력한 stdout/stderr 로그를 /logs/job123/container456.log 경로에 저장하거나, HDFS에 업로드할 수 있습니다.
🐘 2.2.3 ContainerExecutor
ContainerExecutor는 OS와 직접 상호 작용하여, 컨테이너 실행을 위한 디렉토리/파일 배치, 보안 방식으로 프로세스 시작/종료, 실행 후 정리(clean-up) 작업을 수행합니다.
컨테이너의 격리를 보장하기 위해 user-per-container 실행 모델이나 Docker 등과 함께 사용되기도 합니다.
🐘 2.2.4 NodeHealthCheckerService
이 서비스는 노드 상태를 확인하기 위한 스크립트를 주기적으로 실행하며, 디스크의 상태를 점검하기 위해 임시 파일을 생성하고 삭제합니다.
상태 변화가 감지되면 NodeStatusUpdater를 통해 RM에 전달됩니다.
💡 예시
/tmp 디렉토리에 파일을 생성해보고, 생성/삭제 속도를 통해 디스크 상태를 판단할 수 있습니다. 디스크 I/O 지연이 크면 노드를 “비정상 상태”로 표시합니다.
🐘 2.2.5 Security: ContainerTokenSecretManager
컨테이너 실행 요청이 정상적인 ResourceManager로부터 온 것인지 확인하는 보안 컴포넌트입니다.
Token 기반 인증을 통해 위조된 실행 요청을 방지합니다.
💡 예시
악의적인 사용자가 RM을 통하지 않고 직접 NodeManager에 컨테이너 실행 요청을 보내더라도, 유효한 토큰이 없으면 실행이 거부됩니다.
◈
🐘 3. YARN scheduler 와 Queue
출처 : YARN Scheduler
위에서는 애매해서 한 번에 정리하지 못하고, YARN Scheduler 는 이렇게 따로 정리합니다.
YARN Scheduler는 클러스터의 리소스를 어떻게, 얼마나 할당할지를 결정하는 리소스 할당 알고리즘입니다. 다양한 설정 값을 통해 클러스터 자원을 효율적으로 활용할 수 있도록 구성할 수 있습니다.
YARN은 기본적으로 Hadoop 플랫폼에서 실행되는 애플리케이션에 리소스를 할당하고 관리하는 역할을 담당합니다. 애플리케이션이 제출되면 먼저 Application Master(AM) 이 생성되고, 이후 아래와 같은 절차가 진행됩니다.
이 과정에서 AM은 ResourceManager(RM) 에게 필요한 리소스를 요청하며, RM은 Scheduler를 통해 적절한 양의 리소스를 어떤 노드에 할당할지 결정합니다.
Scheduler의 종류로는 아래 3가지가 존재합니다. 이 중 Capacity scheduler가 기본 default 값으로 설정되어 있습니다.
🐘 3.1. FIFO scheduler
출처 : YARN FIFO scheduler
FIFO 스케줄러는 작업이 클러스터에 제출된 순서대로 리소스를 할당합니다. 즉, 가장 먼저 제출된 작업이 가장 먼저 실행되고, 그 작업이 완료되기 전까지는 뒤에 대기 중인 작업들은 실행되지 않습니다.
동작 방식은 매우 단순합니다. 리소스 매니저(ResourceManager)는 작업이 들어온 순서대로 작업 큐에 쌓고, 자원이 충분할 때마다 큐에서 가장 앞에 있는 작업에 리소스를 할당해 실행을 시작합니다.
예를 들어, A 작업이 먼저 제출되어 클러스터의 자원을 모두 사용 중이라면, 그 작업이 완료될 때까지 B 작업은 대기 상태에 있습니다. 이렇게 되면 작업 우선순위 조정이 없고, 긴 작업이 앞에 있을 경우 뒤에 있는 짧은 작업이 긴 시간 기다려야 하는 단점이 있습니다.
🐘 3.2. Fair scheduler
출처 : YARN Fair Scheduler
Fair Scheduler는 여러 사용자와 작업들이 클러스터 자원을 공정하게 나누어 쓰도록 설계되었습니다.
작업들은 각각 Pool이라는 자원 그룹에 배정됩니다. 각 Pool은 최소 보장 리소스를 설정할 수 있어, 작업 그룹이나 사용자가 적어도 일정량의 자원을 확보할 수 있도록 보장합니다. 만약 어떤 Pool이 현재 할당받은 자원을 다 사용하지 않으면, 남는 자원을 다른 Pool이 일시적으로 더 쓸 수 있도록 허용합니다.
예를 들어, Pool A와 Pool B가 각각 30%, 70%의 최소 자원을 보장받고 있는데, Pool B가 현재 50%만 사용하고 있다면 Pool A가 최대 50%까지 확장해서 사용할 수 있습니다.
Fair Scheduler는 주기적으로 클러스터 내 작업들의 자원 사용량을 체크하며, 자원 배분을 조정합니다. 이 과정에서 자원을 많이 쓴 작업은 잠시 대기시키고, 적게 쓴 작업에 더 많은 자원을 배분해 전체 사용자의 공정성을 유지합니다.
이 스케줄러는 작업이 끝나지 않아도 자원을 다시 재분배할 수 있기 때문에, 긴 작업과 짧은 작업이 혼재된 환경에서 효율적인 자원 활용과 작업 공정성을 보장할 수 있습니다.
🐘 3.3. Capacity scheduler
출처 : YARN Capacity Scheduler
Capacity Scheduler는 YARN의 대표적인 스케줄링 정책 중 하나로, 하나의 클러스터 자원을 여러 사용자나 팀이 공정하고 효율적으로 공유할 수 있도록 설계되었습니다. 이 스케줄러는 기본적으로 Queue(큐) 단위를 통해 클러스터 자원을 분배하며, 각 Queue마다 최소 및 최대 리소스 비율을 설정할 수 있습니다.
Capacity Scheduler는 root 큐를 기준으로 계층적 구조의 Queue를 구성할 수 있으며, 각 Queue는 부모 Queue를 기준으로 최소(min)와 최대(max) 용량(capacity)을 지정할 수 있습니다. Leaf Queue들(가장 하위 큐)은 합쳐서 100%의 min capacity를 갖도록 설정해야 합니다.
또한, Capacity Scheduler는 Queue 간의 리소스 유연성(Elasticity)을 지원합니다.
예를 들어, A 큐의 설정 용량이 20%, B 큐가 80%인 경우, B 큐의 리소스가 사용되지 않는다면 A 큐가 일시적으로 전체 리소스를 사용할 수 있습니다. 이로 인해 리소스 활용률을 극대화할 수 있습니다.
✅ 주요 설정 옵션
Property
설명
yarn.scheduler.capacity.root.queues
root Queue 하위에 생성할 Queue 목록
yarn.scheduler.capacity.root.{Queue 이름}.queues
특정 Queue의 하위 Queue 목록
yarn.scheduler.capacity.root.{Queue 이름}.capacity
해당 Queue의 최소(min) 리소스 비율
yarn.scheduler.capacity.root.{Queue 이름}.maximum-capacity
해당 Queue의 최대(max) 리소스 비율
✅ Minimum User Percentage & User Limit Factor
하나의 Queue 내에서 다수 사용자의 리소스 할당 제어를 위한 설정입니다.
minimum-user-limit-percent : 여러 사용자가 동시에 접근 시 각 사용자에게 최소 보장되는 리소스 비율입니다. 예시로 10%로 설정 시, 10명의 사용자가 있을 때 각자는 최소 10%를 보장받습니다.
user-limit-factor : 한 사용자가 사용할 수 있는 최대 리소스의 배수입니다. 계산식은 최대 사용량 = min capacity × user-limit-factor 입니다. 예를들면, min capacity가 10%, user-limit-factor가 3이면 한 사용자가 최대 30%까지 사용 가능.
✅ 관련 설정 옵션
Property
설명
yarn.scheduler.capacity.root.{Queue 이름}.user-limit-factor
해당 Queue의 사용자 최대 사용 배수
yarn.scheduler.capacity.root.{Queue 이름}.minimum-user-limit-percent
해당 Queue에서 사용자당 최소 보장 비율
✅ 기타 옵션
Property
설명
yarn.scheduler.capacity.maximum-am-resource-percent
전체 클러스터에서 AM이 사용할 수 있는 최대 리소스 비율
yarn.scheduler.capacity.{Queue 이름}.maximum-am-resource-percent
특정 Queue에서 AM이 사용할 수 있는 최대 리소스 비율
💡 참고: AM(Application Master)에 할당되는 리소스가 많을수록, 작업을 처리하는 컨테이너에 할당되는 리소스는 줄어듭니다.
사용자 또는 그룹별 Queue 매핑
사용자나 그룹을 특정 Queue에 1:1로 매핑할 수 있습니다.
매핑이 많으면 자동 매핑 규칙을 사용할 수 있습니다.
설정 방식
예시
설명
사용자 1:1 매핑
u:alice:analytics
alice 사용자를 analytics Queue에 매핑
그룹 매핑
g:dev:devqueue
dev 그룹을 devqueue에 매핑
자동 매핑 (사용자)
u:%user.%user
사용자 이름과 동일한 이름의 Queue에 자동 매핑
자동 매핑 (그룹)
g:%user.%primary_group
사용자 기본 그룹 이름과 동일한 Queue에 자동 매핑
설정 위치: yarn.scheduler.capacity.queue-mappings
컴포넌트(엔진)별 Queue 지정 방법
컴포넌트
설정 방법
MapReduce
실행 시 -D mapreduce.job.queuename={Queue 이름} 옵션 사용
Spark
spark-submit 시 --queue {Queue 이름} 옵션 사용
Hive
쿼리 내에서 set {엔진이름}.queue.name={Queue 이름} 명령어 사용
Hive는 Tez, MR 등 여러 엔진을 사용할 수 있으므로, 사용하는 엔진 이름을 정확히 명시해야 합니다.
◈
🐘 4. YARN TimeLine Service
YARN Timeline Service는 YARN 클러스터에서 실행되는 애플리케이션들의 상태, 이벤트, 그리고 다양한 메타데이터를 수집하고 저장하는 핵심 컴포넌트입니다. 단순한 실행 상태뿐 아니라, 각 태스크(Task)의 상세 실행 내역, 오류 발생 기록, 자원 사용 통계 등 정밀한 데이터를 중앙 저장소에 기록함으로써, 운영자나 개발자가 문제를 쉽게 분석하고 성능을 최적화할 수 있도록 돕습니다.
YARN 환경에서 수십~수천 개의 애플리케이션이 동시에 실행되고, 각 애플리케이션은 수백 개의 컨테이너와 태스크를 포함하는 경우가 많습니다. 이런 상황에서 단순히 ResourceManager의 UI만으로는 실행 내역을 파악하기 어렵습니다.
예를 들어, 어떤 MapReduce 작업이 실패했는지, Spark 애플리케이션에서 어느 컨테이너가 자원 부족으로 종료되었는지, 또는 태스크 수행 시간이 평균보다 비정상적으로 길어진 경우가 어디인지 등을 파악해야 할 때, Timeline Service는 필수적입니다.
ARN Timeline Service는 이러한 요구를 충족시키기 위해 설계된 컴포넌트로, 처음에는 v1 버전으로 단순한 단일 서버 구조에서 시작되었습니다. Timeline Service v1은 모든 이벤트 데이터를 JSON 형태로 수집한 뒤, HDFS에 저장하는 방식을 사용했습니다. 구조가 단순하고 설정이 비교적 쉬웠기 때문에 소규모 또는 테스트 환경에서는 적합했지만, 대규모 클러스터에서는 여러 한계가 드러났습니다.
가장 큰 문제는 확장성 부족과 병목현상이었습니다.
읽기와 쓰기 요청이 모두 하나의 서버 인스턴스로 집중되다 보니, 작업이 몰릴 경우 이벤트 유실, 응답 지연, 시스템 과부하 등의 문제가 빈번하게 발생했습니다. 또한 UI나 REST API를 통해 이벤트를 조회할 때도 성능 저하가 잦았습니다.
이러한 한계를 극복하기 위해 Hadoop 3.0부터 Timeline Service v2 (TSv2)가 도입되었습니다. TSv2는 단순한 리팩토링 수준이 아니라, 아키텍처 전반을 수평 확장 가능한 분산 구조로 재설계한 것이라고 볼 수 있습니다.
출처 : Yarn TimeLine V2
TSv2 아키텍처의 주요 특징은 다음과 같습니다.
쓰기와 읽기 역할을 완전히 분리
이벤트 수집기(Collector)는 각 애플리케이션 노드에서 분산 배치되어 데이터를 수집하고 백엔드 스토리지로 전달합니다.
반면 읽기는 Timeline Reader라는 독립 프로세스가 전담하여 REST API 쿼리를 처리합니다.
이 구조는 읽기/쓰기 병렬 처리를 가능하게 하여 시스템 병목을 제거하고 성능을 높입니다.
분산 수집기 구조 (Distributed Collector)
각 애플리케이션 마스터(ApplicationMaster)와 동일 노드에 수집기가 동작하며, 이 수집기가 이벤트를 직접 기록합니다.
예를 들어, Spark나 Tez 애플리케이션이 실행되면 해당 노드의 수집기가 컨테이너 실행, 태스크 실패, 자원 요청 등의 이벤트를 직접 받아 기록합니다.
또한 다른 NodeManager도 해당 노드의 수집기로 이벤트를 전송합니다.
백엔드 저장소로 HBase 채택 (기본)
TSv2는 기본적으로 Apache HBase를 이벤트 데이터 저장소로 사용합니다.
HBase는 대용량 데이터 처리와 실시간 쿼리에 강점이 있어, 다수의 애플리케이션 이벤트를 빠르게 저장하고 읽어오는 데 적합합니다.
Flow 단위의 데이터 집계 및 분석 기능
TSv2는 단일 애플리케이션이 아닌 논리적 작업 흐름 단위의 이벤트를 집계하는 기능을 지원합니다.
예를 들어, 하나의 BI 분석 워크플로우가 여러 Spark 애플리케이션으로 구성되었다면, 이들을 하나의 “Flow”로 묶어서 추적하고 메트릭을 집계할 수 있습니다.
사용자는 전체 워크플로우의 실행 시간, 자원 사용량, 오류 빈도 등을 통합적으로 확인할 수 있습니다.
실무 예시를 하나 들어보겠습니다.
예를 들어, 하나의 데이터 파이프라인이 다음과 같은 구조로 동작한다고 가정해봅시다.
Sqoop을 통해 외부 RDBMS에서 데이터를 추출하고, Spark로 데이터를 변환한 후, 결과를 Hive에 저장합니다.
이 작업은 여러 YARN 애플리케이션으로 분산되어 실행되지만, 사용자 입장에서는 하나의 “업무 흐름(Flow)”으로 인식됩니다.
이때 Timeline Service v2는 각 단계별 자원 사용량, 태스크 실패 횟수 및 오류 메시지, 전체 흐름의 실행 시간과 병목 지점 등을 통합적으로 수집하여 제공합니다.
운영자는 단일 API 호출 또는 UI 대시보드를 통해 이 전체 흐름의 상태를 확인할 수 있으며, 특정 단계에서 오류가 발생하거나 자원 사용이 급증한 부분을 빠르게 진단할 수 있습니다.
Timeline Service v2를 사용하려면 yarn-site.xml에 다음과 같은 설정이 필요합니다.
<property>
<name>yarn.timeline-service.enabled</name>
<value>true</value>
</property>
<property>
<name>yarn.timeline-service.version</name>
<value>2</value>
</property>
<property>
<name>yarn.timeline-service.storage.type</name>
<value>hbase</value>
</property>
해당 설정은 Timeline Service를 활성화하고, v2 버전을 사용하며, 저장소로 HBase를 선택하도록 지정합니다.
추가로 HBase 클러스터와 연결이 올바르게 구성되어 있어야 하며, timeline.reader.webapp.address, timeline.collector.bind-host 등 네트워크 관련 속성도 환경에 맞게 설정해야 합니다.
마지막으로, TimeLine Service 2는 보안과 멀티테넌시를 지원합니다.
Timeline Service v2는 YARN의 멀티테넌시 환경을 고려하여 사용자별 접근 제어 및 데이터 분리를 지원합니다.
예를 들어, 특정 사용자의 애플리케이션 실행 정보는 다른 사용자가 조회할 수 없도록 ACL이 적용됩니다. 또한 Time To Live 정책을 설정하여 오래된 이벤트 데이터를 자동으로 삭제함으로써, 저장 공간 관리도 효율적으로 수행할 수 있습니다.
정리해보면,
Timeline Service는 단순한 로그 수집기가 아닙니다.
애플리케이션의 행동 이력을 시간 축으로 정렬하여 저장하고, 이를 분석 가능한 형태로 제공하는 통합 이벤트 분석 플랫폼이라고 보는 것이 더 정확합니다.
특히 v2는 분산 수집 구조, HBase 저장소, 흐름 기반 집계 기능을 통해 YARN 환경에서의 운영 안정성과 관측 가능성(observability)을 크게 향상시킵니다.
실제 클러스터에서 Timeline Service를 활성화해보고 다양한 애플리케이션들을 실행하며 데이터를 시각화해보면,
장애 분석, 자원 최적화, SLA 준수 여부 판단 등 여러 측면에서 운영 품질을 비약적으로 높일 수 있음을 체감할 수 있게 되었습니다.
◈
✏️ 결론
오늘은 YARN의 전체적인 구조와 동작 원리에 대한 개요부터 시작해, 애플리케이션이 어떻게 실행되고 자원이 어떻게 스케줄링되는지, 그리고 각 실행 엔진별로 큐를 어떻게 설정하는지까지 전반적인 흐름을 살펴보았습니다.
특히 YARN의 스케줄링 정책인 FIFO Queue와 Fair Queue의 차이점, 그리고 Spark, Hive, MapReduce 등 컴포넌트별로 큐를 지정하는 실제 방법들을 예제와 함께 확인하며, 실무에서 어떻게 응용할 수 있는지 정리해보았습니다.
또한, YARN Timeline Service에 대해서도 기술적인 관점에서 깊이 있게 알아보았는데, 애플리케이션 실행 중 발생하는 이벤트와 메타데이터를 수집하고 저장함으로써 클러스터 내 작업들의 상태를 추적하고 분석할 수 있는 핵심 기능임을 확인할 수 있었습니다.
실패 이력, 자원 사용량, 작업 시간과 같은 운영에 중요한 정보를 한 곳에 통합해 관리할 수 있기 때문에, 클러스터 운영자 입장에서는 반드시 알아야 할 기능 중 하나입니다.
YARN의 기본 개념부터 실무에 필요한 포인트까지 한 번에 정리한 오늘 내용을 바탕으로, 향후 직접 실습해보고 얻은 인사이트도 포스팅할 계획입니다.
(이미 실무에서는 사용하고 있지만, 집에서도 처음부터 설치부터 설정, 잡 실행, 모니터링까지 A to Z로 다시 정리해볼 예정입니다.)
긴 글 읽어주셔서 감사합니다. :D
◈
📚 공부 참고 자료
📑 1. 패스트 캠퍼스 - 한 번에 끝내는 데이터 엔지니어링 초격차 패키지 Online.
📑 2. 데이터엔지니어링 Yarn
📑 3. Hadoop YARN 공식 매뉴얼
📑 4. YARN Capacity scheduler 특징 및 Queue 옵션
📑 5. OREILLY 하둡 완벽 가이드 4판
-
📘 하둡의 핵심 구성요소와 이론
저번 포스팅에서는, 하둡이 어떻게 등장했는지를 공부하고 정리해봤습니다.
이번에는 그러한 하둡의 핵심 요소들에 대하여 공부하고 포스팅하겠습니다.
📘 분산 시스템의 이해와 하둡의 등장 배경
◈
🐘 1. HDFS
HDFS 는 Hadoop 의 핵심 아키텍처 중 하나입니다.
HDFS 의 아키텍처에도 따로 Block File System, NameNode, DataNode 등이 있습니다.
HDFS 가 무엇인지를 본격적으로 알아보기 전에, HDFS 가 어떤 철학과 목표를 가지고 만들어졌는지, 그 특징은 무엇인지를 간단하게 공부하고 넘어가겠습니다.
① HDFS 는 하드웨어 장애에 대처할 수 있어야 합니다.
HDFS를 구성하는 분산 서버에는 다양한 장애가 발생할 수 있습니다.
예를 들면 하드디스크에 오류가 생겨서 데이터 저장에 실패하는 경우, 디스크 복구가 불가능해 데이터가 유실되는 경우, 네트워크 장애가 생겨 특정 분산 서버에 네트워크 접근이 안되는 경우 등이 있을 수 있습니다.
HDFS는 이런 장애를 빠른 시간에 감지하고 대처할 수 있게 설계되어 있습니다.
HDFS에 데이터를 저장하면, 복제본도 함께 저장되어 데이터 유실을 방지하며, 분산 서버 사이에는 주기적으로 health check 를 통해 빠른 시간에 장애를 감지하고 대처할 수 있게 됩니다.
② HDFS 는 Streaming 식 데이터 접근에 최적화 되어있습니다.
HDFS는 사용자 요청을 빠르게 처리하는 것보다 동일 시간 내에 더 많은 데이터를 안정적으로 처리하는 데 중점을 둡니다.
따라서 중간부터 읽고, 쓰는 Random Access 방식보다는 처음부터 순차적으로 읽어가는 Streaming 방식에 최적화되어 있습니다.
가끔 이 Streaming 방식이 카프카처럼 실시한 데이터 스트리밍과 헷갈릴 수 있는데, 그런 스트리밍을 의미하는것이 아니라, 처음부터 끝까지 순차적으로 데이터를 읽는데에 특화되었다는 의미입니다.
이런 방식으로 인해 사용자와 상호작용이 많은 트랜잭션 기반 서비스(예: 인터넷 뱅킹, 쇼핑몰 등)보다는 대규모 데이터를 일괄 처리하는 배치 처리(batch processing)에 더 적합합니다.
③ HDFS 는 대용량 데이터셋 처리에 유리합니다.
HDFS는 하나의 파일이 수 기가바이트(GB)에서 수 테라바이트(TB)에 이르는 크기로 저장될 수 있도록 설계되어 있습니다.
이를 통해 높은 데이터 전송 대역폭(bandwidth)을 지원하며, 수백 대의 노드로 구성된 클러스터를 효율적으로 운영할 수 있습니다. HDFS는 단일 인스턴스에서 수천만 개 이상의 파일을 관리할 수 있습니다.
여기서 말하는 데이터 전송 대역폭은, 한 번에 얼마나 많은 데이터를 빠르게 보낼 수 있는가를 의미합니다.
④ HDFS 는 심플하고, 일관성이 있습니다.
데이터 무결성을 유지하기 위해, HDFS는 한 번 저장한 데이터는 수정하지 않고 읽기만 가능한 모델을 따릅니다. 이를 write-once-read-many 모델이라 하며, 데이터가 저장된 후 변경되지 않음으로써 무결성을 보장합니다.
기존에는 저장 후 수정이 전혀 불가능했으나, 현재는 파일의 끝부분에 데이터를 append 하는 방식은 지원됩니다.
이러한 단순한 일관성 모델은 데이터 처리량을 높이며, 특히 MapReduce와 같은 처리 방식에 큰 장점을 제공합니다.
⑤ HDFS 는 데이터를 직접 옮기지 않고, 데이터가 위치한 노드에서 연산을 실행합니다.
컴퓨팅 처리를 위해 데이터를 이동시키는 것보다, 연산 작업을 데이터가 위치한 곳으로 이동시키는 것이 비용과 성능 면에서 더 유리합니다. 이는 특히 데이터 양이 방대할수록 더욱 중요합니다. 네트워크 혼잡을 줄이고 시스템 전체의 처리량을 높일 수 있기 때문입니다.
HDFS는 이를 고려하여, 데이터가 위치한 노드에서 연산이 실행되도록 처리합니다. 이러한 접근 방식은 전체 클러스터의 효율성을 높이는 데 크게 기여합니다.
⑥ HDFS 는 여러 플랫폼 간의 이식성을 가지고 있습니다.
HDFS는 다양한 하드웨어 및 운영체제 환경에서 동일한 기능을 제공할 수 있도록 설계되어 있습니다. 인텔이나 AMD 칩이 설치된 서버에서도 문제없이 동작하며, CentOS나 Red Hat Linux와 같은 다양한 리눅스 배포판에서도 동일하게 사용할 수 있습니다.
이는 HDFS의 서버 코드가 Java 언어로 구현되어 있기 때문에 가능한 일입니다. Java의 플랫폼 독립성이 HDFS의 높은 이식성과 호환성을 보장해 줍니다. 이러한 특성은 HDFS가 대용량 데이터 저장 플랫폼으로 널리 채택되는 중요한 이유 중 하나입니다.
🐘 1.1. Block File System
HDFS는 블록 구조로 동작하는 분산 파일 시스템입니다.
HDFS에 저장되는 모든 파일은 일정 크기의 블록 단위로 분할되어 여러 서버에 분산 저장됩니다.
기본 블록 크기는 128MB이며, 설정을 통해 조정이 가능합니다. ( 하둡 V1 에서는 64MB였습니다. )
이러한 블록 기반 구조 덕분에 로컬 디스크의 제한을 넘어서 페타바이트(PB) 단위의 대용량 데이터 저장이 가능하게 됩니다.
hdfs-site.xml 에서 직접 변경은 가능하지만, 추천을 하지는 않습니다.
<property>
<name>dfs.blocksize</name>
<value>134217728</value> <!-- 128MB -->
</property>
✅ HDFS에서 파일과 블록은 다음과 같은 규칙을 따릅니다
구분
설명
예시
파일과 블록 관계
하나의 파일은 하나 또는 여러 개의 블록에 저장됩니다.
파일 A → 블록 1개 또는 여러 개로 분할 저장
블록 관리
어떤 파일이 어떤 블록에 저장되는지는 Namenode가 메모리 내 메타데이터로 관리합니다.
-
블록 독립성
하나의 블록에는 여러 파일이 저장되지 않으며, 항상 단일 파일 전용입니다.
블록 B에는 오직 파일 A 일부만 저장 가능
블록 분할
파일 크기가 블록 크기를 초과하면, 파일은 여러 블록에 나뉘어 저장됩니다.
파일 크기: 128MB + 10byte → 블록 1: 128MB, 블록 2: 10byte
블록 크기 미만의 파일
파일 크기가 블록보다 작아도 전체 블록을 할당받지만, 사용된 용량만 실제 디스크에 저장됩니다.
파일 크기: 1MB, 블록 크기: 128MB → 블록은 1MB만 사용
블록이 나눠떨어지지 않는 경우
파일이 블록 크기로 나눠떨어지지 않으면, 마지막 블록은 남은 크기만큼 점유합니다.
파일 크기: 129MB, 블록 크기: 128MB → 블록 1: 128MB, 블록 2: 1MB
디스크 점유 공간
실제 디스크에서 점유하는 공간은 블록 전체가 아닌, 파일의 실제 크기만큼입니다.
블록 크기: 128MB, 파일 크기: 1MB → 디스크 사용: 1MB
✅ 이렇게 Block System 을 유지하므로써 다음과 같은 장점을 가질 수 있게 되었습니다.
① Seek Time 감소
HDFS는 블록 크기를 고정하여 디스크 탐색 시간을 줄일 수 있습니다. 디스크 탐색 시간은 데이터 위치를 찾는 탐색 시간(seek time)과 해당 위치로 디스크 헤드를 이동시키는 검색 시간(search time)의 합입니다. 하둡이 처음 개발될 당시 일반적인 하드디스크의 평균 탐색 시간은 약 10ms였고, 디스크 I/O 대역폭은 100MB/s였습니다. HDFS는 탐색 시간이 전체 처리 시간의 1%를 넘지 않도록 하는 것을 목표로 했으며, 이를 위해 100MB를 넘지 않고 2의 제곱수에 가까운 64MB를 블록 크기로 선택했습니다.
그러나, 큰 파일을 처리하기 위한 효율성과 그 동안 하드웨어 및 네트워크의 성능도 향상되는 과정을 통하여 V2 에서는 128MB 로 변경 되었습니다. ( + 클러스터의 효율성 때문이기도 합니다. -> 메타데이터 관리가 더 수월해짐. )
② 메타데이터 크기 감소
Namenode는 블록 위치, 파일 이름, 디렉토리 구조, 권한 정보 등의 메타데이터를 메모리에 상주시키고 관리합니다. 블록 크기를 크게 설정하면 하나의 파일을 저장하는 데 필요한 블록 수가 줄어들며, 이에 따라 관리해야 할 메타데이터의 양도 줄어듭니다. 예를 들어 블록 크기가 128MB인 경우, 200MB 파일은 두 개의 블록으로 나뉘며, Namenode는 해당 블록에 대한 정보만 관리하면 됩니다.
반면 일반적인 파일 시스템은 4KB 또는 8KB 크기의 블록을 사용하기 때문에, 동일한 크기의 파일을 저장하려면 수만 개의 블록과 그에 대한 메타데이터가 필요합니다.
예를 들어 블록 크기가 4KB인 경우, 200MB 파일은 약 5만 개의 블록으로 나뉘고, 5만 개의 메타데이터 항목을 Namenode가 관리해야 합니다. Namenode는 단일 서버에서 메타데이터를 처리하므로, 메타데이터가 많아질 경우 성능 저하 및 안정성 문제로 이어질 수 있습니다. 일반적으로 Namenode는 100만 개의 블록을 저장할 때 약 1GB의 힙 메모리를 사용합니다.
③ 클라이언트와 Namenode 간 네트워크 통신 비용 감소
클라이언트가 HDFS에 저장된 파일에 접근할 때는 먼저 Namenode에 해당 파일을 구성하는 블록의 위치를 조회합니다. 이때 블록 크기가 작아 블록 수가 많으면, 조회해야 할 메타데이터가 많아지거나 조회 횟수가 증가하게 됩니다.
하지만 블록 크기를 크게 설정하면 파일당 블록 수가 줄어들어 조회에 필요한 정보가 간소화되고, 통신 횟수도 줄어듭니다. 클라이언트는 데이터를 스트리밍 방식으로 처리하기 때문에, 초기 조회 이후에는 특별한 경우가 아니면 Namenode와의 추가적인 통신 없이 작업을 이어나갈 수 있습니다. 이로 인해 전체적인 통신 비용이 줄어듭니다.
🐘 1.2. NameNode 와 DataNode
출처 : HDFS 아키텍처
✅ NameNode
NameNode는 HDFS의 핵심 구성요소로서, 블록의 위치, 권한 등과 같은 메타데이터를 관리하는 역할을 합니다. 모든 메타데이터는 기본적으로 메모리에 유지되며, 영속성을 위해 다음의 두 가지 파일 형태로도 디스크에 기록됩니다.
하나는 File System Image 입니다.
NameNode가 처음 시작된 이후부터의 HDFS 네임스페이스 전체 정보를 포함합니다.
디렉터리 구조, 파일명, 파일크기, 생성시간, 수정시간, 블록 매핑 정보, 접근 제어 정보, 복제 수, 파일 상태 등을 포함하고 있습니다.
다른 하나는, Edit Log 입니다.
NameNode가 처음 시작된 이후부터 발생한 모든 변경사항을 기록한 로그 파일입니다.
하둡의 NameNode 는 시작된 이후의 네임스페이스 정보를 File System Image 에 기록하고, 그 정보를 메모리에 올림으로써 빠르게 조회 할 수 있도록 합니다. 그리고 변경된 사항들은 전부 Edit Log 에 저장합니다. 이후 NameNode 를 내렸다가 올리게 되면, Secondary NameNode 에 의해서 디스크에 있는 Edit Log 와 File System Image 가 병합을 하며 새로운 File System Image 가 만들어지게 됩니다. 그리고 그걸 다시 메모리에 올림으로써, 최신 정보를 유지하고 빠르게 조회 할 수 있도록 합니다.
이러한 NameNode 의 역할은 크게 다섯 가지가 있습니다.
① 메타데이터 관리
파일 시스템을 구성하는 메타데이터를 관리합니다. 메타데이터는 파일 이름, 디렉토리 구조, 파일 크기, 접근 제어 정보(access/auth control) 및 파일과 블록 간의 매핑 정보를 포함합니다. 클라이언트 요청에 신속하게 응답하기 위해 메타데이터는 메모리 상에서 유지되며, 위에서 이야기한 것처럼 디스크에 기록하기도 합니다.
② 데이터노드 관리
클러스터 내의 모든 DataNode 리스트를 유지하고 관리합니다. 관리자에 의한 명시적인 명령 또는 모니터링 결과에 따라 DataNode 상태 정보를 업데이트하거나 변경합니다.
③ 데이터노드 모니터링
각 DataNode는 3초마다 NameNode에 heartbeat를 전송합니다. heartbeat에는 데이터노드 상태 정보와 데이터노드에 저장된 블록 목록(block report)이 포함됩니다. NameNode는 heartbeat 정보를 바탕으로 DataNode의 가용성, 디스크 용량 상태 등을 판단합니다. 일정 시간 동안 heartbeat를 수신하지 못한 DataNode는 장애가 발생한 노드로 간주됩니다.
④ 블록 관리
NameNode는 각 블록의 위치, 복제 상태, 소유 관계 등을 관리합니다. 장애가 발생한 DataNode가 발견되면, 해당 노드에 있던 블록을 다른 정상 노드로 복제합니다. 디스크 용량이 부족한 노드가 있을 경우, 블록을 용량이 여유 있는 노드로 이동시킵니다. 블록의 복제본 수를 유지하여, 기준 복제 수보다 부족하거나 많은 경우 적절히 추가 복제 또는 삭제 작업을 수행합니다.
⑤ 클라이언트 요청 처리
클라이언트가 HDFS에 접근할 때는 반드시 NameNode를 통해 먼저 접속합니다. 파일을 저장할 경우, 파일 존재 여부 확인, 저장 권한 검사 등을 수행합니다. 파일을 조회할 경우, 블록이 저장된 DataNode의 위치 정보를 반환합니다.
✅ DataNode
DataNode 는 클라이언트가 HDFS에 저장하는 파일을 디스크에 유지하는 Node 입니다.
저장하는 파일은 크게 두 종류인데, 하나는 실제 데이터(Raw Data) 다른 하나는, checksum 이나 created time 등 메타데이터가 설정된 파일입니다.
이러한 DataNode 는 다음과 같은 기능을 합니다.
① 실제 데이터 처리
클라이언트로 부터 실제 데이터의 read/write 요청을 받아 처리합니다.
② block 생성,복제,삭제
NameNode 로부터 명령을 받아서, 자신의 디스크에 있는 block 을 생성, 복제, 삭제를 수행합니다.
③ heartbeat 전송
HDFS 의 상태를 NameNode 에게 heartbeat 로 전송합니다.
④ block 정보 전송
NameNode에게 자신이 가진 데이터 block 들의 리스트와 상태를 전송합니다.
🐘 1.3. Secondary NameNode
출처 : Secondary Namenode
Secondary NameNode 는 이름만 들어보면, 이중화 구성을 통해 NameNode 가 다운되면, Active 되는 Standby Node 라고 생각할 수 있습니다. 그러나, Secondary NameNode 는 클러스터 환경에서의 고가용성 역할을 담당하지 않습니다.
Secondary NameNode 는 NameNode 가 수행하는 File System Image 를 생성하는 역할을 분담합니다.
왜냐하면, NameNode 는 위에서 설명한 것처럼 HDFS 의 Entrypoint 로써 클라이언트에게 File 과 관련된 기능을 제공하고 있는데, NameNode 의 이러한 본연의 역할에 집중할 수 있도록 Secondary NameNode 는 NameNode 의 Edit Log 를 Merge 하여 새로운 New File System Image 를 생성하는 역할을 수행합니다.
Secondary NameNode 는 NameNode 가 올라오는 과정에서 현재까지의 Edit Log 을 File System Image 와 병합하여 최신의 File System Image 를 생성하는 작업을 합니다. 그러기 위해서 Secondary NameNode 는 주기적으로 NameNode 의 Edit Log 을 조회합니다. 그리고 Edit Log 의 변경 사항에 대한 기록과 File System Image 를 병합하여 새로운 New FsImage 를 생성합니다.
마지막으로 새롭게 생성된 File System Image 는 NameNode 로 전달됩니다. ( 즉, NameNode 가 내려갔다 올라올 때마다, 병합 작업을 수행해줌. )
🐘 1.4. Replication
HDFS는 Fault Tolerance 를 위해 각 블록을 여러 개 복제하여 저장합니다. 이 복제 개수를 복제 수(Replication Factor)라고 하며, 파일을 생성할 때 애플리케이션에서 직접 지정할 수 있습니다. 기본 복제 수는 일반적으로 3개입니다.
예를 들어, 하나의 블록이 있다면 HDFS는 이를 세 개의 다른 서버에 나누어 저장함으로써 한 서버가 고장 나더라도 데이터 유실이 발생하지 않도록 합니다.
복제 수는 파일이 생성된 이후에도 변경이 가능하며, HDFS의 파일은 일반적으로 하나의 writer만 동시에 접근할 수 있도록 설계되어 있습니다. 단, append나 truncate와 같은 일부 예외적인 연산에서는 파일에 대한 부분적 접근이 허용됩니다.
HDFS에서는 이러한 블록의 복제를 관리하는 주체가 네임노드(NameNode)입니다. 네임노드는 각 데이터노드(DataNode)로부터 주기적으로 하트비트(heartbeat)와 블록 리포트(block report)를 받습니다. 네임노드는 이 정보를 바탕으로 고장이 발생한 노드를 파악하고, 해당 노드에 저장된 블록이 손실되지 않도록 다른 노드에 복제본을 자동으로 생성합니다. 또한, 특정 노드의 저장 용량이 부족할 경우 블록을 여유 공간이 있는 다른 노드로 옮기기도 하며, 복제 수가 지정된 값보다 적거나 많은 경우 이를 자동으로 조정합니다.
HDFS는 데이터를 여러 개의 복제본(replica)으로 저장하여, 신뢰성과 성능을 높이는 구조입니다. 그런데 단순히 복제본을 여러 개 만든다고 해서 성능과 안정성이 자동으로 좋아지는 것은 아닙니다. 복제본이 어디에 저장되느냐, 즉 replica의 위치는 HDFS의 전체 성능과 안정성에 큰 영향을 미칩니다.
이처럼 HDFS는 복제본의 배치 전략(replica placement policy)을 매우 중요하게 생각하며, 이 전략은 기존의 다른 분산 파일 시스템과 HDFS를 차별화하는 핵심 요소 중 하나입니다. HDFS는 이 기능을 위해 실제 운영 환경에서 수많은 경험과 실험을 통해 정책을 지속적으로 개선해왔습니다.
장애가 발생하더라도 다른 노드에 저장된 복제본을 통해 데이터를 안전하게 복구할 수 있도록 설계하는 데이터 신뢰성과, 데이터가 여러 위치에 분산되어 있기 때문에 일부 서버나 랙이 실패하더라도 데이터를 읽고 쓸 수 있는 가용성 향상, 그리고 복제본이 네트워크 내에서 효율적으로 분산되도록 하여, 불필요한 네트워크 트래픽을 줄이고 성능을 높이기 위함. 이렇게 세 가지를 중점으로 개선되어왔습니다.
이 정책 중 가장 대표적인 것이 바로 랙 인식 복제 배치(Rack-aware Replica Placement)입니다.
출처 : 하둡 클러스터 이미지와 랙 구조
대규모 HDFS 클러스터에서 컴퓨팅 인스턴스들은 여러 개의 서버 랙(rack)에 나뉘어 배치됩니다. 서로 다른 랙에 위치한 서버끼리는 랙 내 스위치를 통해 통신하기 때문에, 같은 랙 내 통신보다 더 많은 네트워크 대역폭이 필요합니다. 이런 구조적인 특성을 고려하여 Hadoop은 Rack Awareness 기능을 제공합니다. 이 기능을 통해 NameNode는 각 DataNode의 랙 ID를 인식하고, 이를 바탕으로 복제본의 위치를 결정합니다.
HDFS의 기본 복제 수는 일반적으로 3입니다. 이때 기본 복제 배치 정책인 BlockPlacementPolicyDefault는 다음과 같은 방식으로 복제본의 위치를 결정합니다. 첫 번째 복제본은 가능한 한 데이터를 쓰고 있는 클라이언트(writer)와 같은 랙에 있는 DataNode에 저장됩니다. 만약 writer가 특정 DataNode 위에서 직접 실행 중이라면, 해당 노드 자체에 데이터를 저장합니다. 반면 writer가 일반 노드라면, 같은 랙 내에서 임의의 DataNode를 선택해 데이터를 저장합니다.
두 번째와 세 번째 복제본은 writer와는 다른 랙에 있는 서로 다른 DataNode에 저장되도록 합니다. 이를 통해 총 두 개의 랙에 복제본이 분산되며, 하나의 랙에 장애가 발생하더라도 다른 랙에 데이터가 존재하기 때문에 안정성을 보장할 수 있습니다.
이러한 정책은 여러 가지 장점을 가집니다. 먼저 첫 번째 복제본을 writer와 동일한 랙에 저장함으로써 랙 간 네트워크 트래픽을 줄일 수 있어 쓰기 성능이 향상됩니다. 또한 대부분의 장애가 랙 전체보다는 개별 노드에서 발생하기 때문에, 복제본을 두 개의 랙에만 분산시켜도 충분한 신뢰성과 가용성을 확보할 수 있습니다.
반면 단점 역시 존재합니다. 복제본을 완전히 다른 랙에 각각 분산시키는 방식에 비해 전체 네트워크 대역폭 사용량을 줄이지는 못합니다. 또한 복제본이 1:2의 비율로 분포되기 때문에 랙 간에 데이터가 균등하게 분산되지 못하는 문제가 발생할 수 있습니다.
만약 복제본 수가 3보다 많아지는 경우, 네 번째 복제본부터는 무작위로 랙과 노드를 선택하되, 랙당 복제본 수가 일정 상한선 이하가 되도록 조정합니다. 이때 상한선은 (복제본 수 - 1) / 랙 수 + 2로 계산됩니다.
NameNode는 기본적으로 하나의 블록에 대해 동일한 DataNode에 여러 복제본을 저장하는 것을 허용하지 않기 때문에, 하나의 블록이 가질 수 있는 최대 복제본 수는 클러스터 내 DataNode의 총 수와 같습니다.
블록 복제본이 위치할 노드를 선정하는 순서는 다음과 같습니다.
랙 인식 규칙을 기준으로 복제본이 배치될 후보 노드를 선정.
그런 다음 해당 후보 노드가 정책에서 요구하는 스토리지 타입을 가지고 있는지 확인.
만약 후보 노드가 요구되는 스토리지 타입을 가지고 있지 않다면, NameNode는 다른 후보 노드를 탐색.
위의 절차로도 적절한 노드를 찾을 수 없다면, fallback 스토리지 타입을 사용하는 노드를 대상으로 복제본을 배치하려고 시도.
조금 풀어서 설명하면, 랙 인식 규칙 기준으로 후보 Node 들을 선정하고, 정책을 SSD 스토리지 타입으로 정했다고 가정한다면, SSD 스토리지 타입을 가지고 있는지 후보 Node 들을 탐색합니다. 그러다가 결국 없다면, fallback 으로 지정한 Disk 에 복제본을 배치한다고 할 수 있겠습니다.
그 외에도 네 가지 정책이 추가로 있습니다.
① BlockPlacementPolicyRackFaultTolerant
해당 정책은 블록 복제본을 최소 3개의 서로 다른 랙에 분산시켜, 2개의 랙이 동시에 장애가 발생하더라도 데이터의 가용성을 유지하도록 돕습니다. ( Github Code Link )
-- hdfs-site.xml
<property>
<name>dfs.block.replicator.classname</name>
<value>org.apache.hadoop.hdfs.server.blockmanagement.BlockPlacementPolicyRackFaultTolerant</value>
</property>
② BlockPlacementPolicyWithNodeGroup
해당 정책은 노드 그룹 개념을 통해, 동일한 물리적 호스트에서 실행되는 가상 머신(VM)들 간의 장애 전파를 방지합니다. 즉, 동일한 노드 그룹에는 둘 이상의 복제본이 배치되지 않으며, 이는 가상화된 환경에 적합하다고 할 수 있습니다. ( Github Code Link )
-- core-site.xml
<property>
<name>net.topology.impl</name>
<value>org.apache.hadoop.net.NetworkTopologyWithNodeGroup</value>
</property>
<property>
<name>net.topology.nodegroup.aware</name>
<value>true</value>
</property>
-- hdfs-site.xml
<property>
<name>dfs.block.replicator.classname</name>
<value>org.apache.hadoop.hdfs.server.blockmanagement.BlockPlacementPolicyWithNodeGroup</value>
</property>
③ AvailableSpaceBlockPlacementPolicy
해당 정책은 데이터 노드의 디스크 사용률을 고려하여, 가능한 한 덜 사용된 노드를 선택해 블록을 배치할 수 있도록합니다. 이는 공간 균형을 유지하고 특정 노드에 과도하게 부하가 집중되는 것을 방지합니다. ( Github Code Link )
-- hdfs-site.xml
<property>
<name>dfs.block.replicator.classname</name>
<value>org.apache.hadoop.hdfs.server.blockmanagement.AvailableSpaceBlockPlacementPolicy</value>
</property>
<property>
<name>dfs.namenode.available-space-block-placement-policy.balanced-space-preference-fraction</name>
<value>0.6</value>
</property>
<property>
<name>dfs.namenode.available-space-block-placement-policy.balanced-space-tolerance</name>
<value>5</value>
</property>
<property>
<name>dfs.namenode.available-space-block-placement-policy.balance-local-node</name>
<value>false</value>
</property>
④ AvailableSpaceRackFaultTolerantBlockPlacementPolicy
해당 정책은 AvailableSpaceBlockPlacementPolicy 의 방식과 RackFaultTolerant 특성을 결합한 방식입니다. 즉, 가능한 최대 랙 수로 블록을 분산시키면서, 낮은 디스크 사용률을 가진 노드를 우선적으로 선택하려고 합니다. ( Github Code Link )
-- hdfs-site.xml
<property>
<name>dfs.block.replicator.classname</name>
<value>org.apache.hadoop.hdfs.server.blockmanagement.AvailableSpaceRackFaultTolerantBlockPlacementPolicy</value>
</property>
<property>
<name>dfs.namenode.available-space-rack-fault-tolerant-block-placement-policy.balanced-space-preference-fraction</name>
<value>0.6</value>
</property>
<property>
<name>dfs.namenode.available-space-rack-fault-tolerant-block-placement-policy.balanced-space-tolerance</name>
<value>5</value>
</property>
✅ Replica 데이터 읽는 순서
HDFS에서는 하나의 블록을 여러 개의 노드에 복제하여 저장합니다. 이때 데이터를 읽는 과정에서 성능을 높이기 위해, HDFS는 클라이언트와 물리적으로 가까운 위치에 있는 복제본(replica)으로부터 데이터를 읽으려 시도합니다. 이를 복제본 선택(Replica Selection)이라고 부릅니다.
예를 들어, 클라이언트가 DataNode와 동일한 노드에서 실행되고 있다면, 해당 노드에 저장된 복제본을 바로 읽습니다. 만약 동일한 노드에 복제본이 없다면, 같은 랙(Rack)에 있는 노드를 우선적으로 선택하여 데이터를 읽습니다. 만약 이 또한 불가능하다면, 최종적으로는 다른 랙이나 다른 데이터센터에 있는 복제본을 선택하게 됩니다.
특히, HDFS 클러스터가 여러 데이터센터에 걸쳐 있는 경우에는 같은 데이터센터에 있는 복제본을 우선적으로 읽습니다. 이렇게 하는 이유는 글로벌 네트워크 대역폭(bandwidth)의 사용을 줄이고, 데이터 접근 지연 시간(latency)을 최소화하기 위한 전략이라고 할 수 있습니다.
✅ SafeMode
Safemode는 NameNode가 기동될 때 진입하는 일종의 보호 상태입니다. 이 모드에서는 블록 복제와 같은 쓰기 작업이 일어나지 않으며, 전체 클러스터가 안정적인 상태에 도달할 때까지 읽기 전용 상태로 유지됩니다.
Safemode 동안 NameNode는 각 DataNode로부터 block report를 받아, 현재 클러스터에 존재하는 모든 블록들이 제대로 복제되어 있는지를 확인합니다. 이때, 모든 블록 중 일정 비율 이상이 정상적으로 복제되어 있다고 판단되면, Safemode에서 벗어나게 됩니다. 이 비율(%)은 환경 설정을 통해 조정할 수 있습니다.
Safemode에서 벗어난 이후에는, 아직 복제본 개수가 부족한 블록들에 대해 복제 작업이 자동으로 수행됩니다. 즉, Safemode는 클러스터가 완전히 동작하기 전까지 데이터를 보호하고, 일관성을 유지하기 위한 중요한 초기 상태라고 할 수 있습니다.
hdfs dfsadmin -safemode get # SafeMode 상태 확인
hdfs dfsadmin -safemode enter # SafeMode 켜기
hdfs dfsadmin -safemode leave # SafeMode 끄기
🐘 1.5. Read 와 Write 시 내부에서 벌어지는 일
✅ HDFS Write
출처 : HDFS Write
새로운 파일 생성은 클라이언트가 DistributedFileSystem 에서 create() 메소드 호출합니다.
DistributedFileSystem 는 RPC로 namenode에 연결하고, 새로운 파일 생성을 시작합니다. 이때 Namenode 은 새로운 파일 생성 요청에 대한 verification을 진행합니다. verification 은 파일이 이미 존재하는지, 해당 경로에 대한 권한 등을 확인합니다. 이 때 verification 에서 실패하면 client에서는 IOException이 발생합니다. verification에 성공하면 namenode 에서 해당 파일에 대한 record 가 생성됩니다.
namenode 에서 파일 record 가 생성되면 클라이언트에 FSDataOutputStream 가 리턴 됩니다. FSDataOutputStream 은 write 를 수행합니다.
FSDataOutputStream 은 datanode 와 namenode 와 상호작용하는 DFSOutputStream 객체를 가지고 있습니다. DFSOutputStream 는 클라이언트가 데이터를 write 하기 위한 packet을 만듭니다. 해당 packet 은 DataQueue에 들어갑니다.
DataStreamer 가 NameNode에 새 블록 할당을 요청하고, 복제에 사용할 바람직한 DataNode를 선택하는 과정을 가집니다.
복제 과정은 DataNode들로 파이프라인을 생성하면서 시작합니다. 기본적으로는 복제 수준이 3이기 때문에 파이프라인에 3개의 DataNode가 있게 됩니다.
DataStreamer 는 DataQueue 로부터 데이터를 consume 해서 파이프라인의 첫번째 datanode 에 저장할 패킷을 전송합니다.
하나의 파이프라인으로 묶인 모든 데이터노드는 저장을 위해 받은 packet 을 모두 저장하고, 이것을 파이프라인의 다음 데이터노드로 foward 합니다.
DFSOutputStream의 AckQueue는 DataNodes로부터 ‘가능’ 이라는 승인을 받으면 저장되는 queue입니다.
파이프라인의 모든 데이터노드로부터 Ack 가 Queue 에 들어오면, AckQueue 는 삭제됩니다. 만약 하나의 datanode 라도 데이터 저장과 ack 전송에 실패하면, Ack Queue 에 받은 패킷정보를 보고 재시작을 할 수 있습니다.
클라이언트의 write 작업이 끝나면, close() 메소드가 호출됩니다. close() 는 모든 남은 data packet 을 flush 하고 ack 를 기다립니다.
마지막 ack까지 도착하면 클라이언트는 namenode에 write 작업이 끝났음을 알리며, 마무리 됩니다.
✅ HDFS Read
출처 : HDFS Read
클라이언트가 DistributedFileSystem 의 open() 메소드로 HDFS 파일을 읽겠다는 요청을 시작합니다.
DistributedFileSystem은 RPC로 namenode 에 연결합니다.
open 대상이 되는 파일의 메타데이터를 조회합니다. 이 때 메타데이터 안에는 해당 파일이 저장되어있는 block의 location 정보 등이 있습니다. (한 번에 모든 블록정보를 리턴하지 않고 처음 몇개의 블록의 주소를 리턴합니다.)
메타데이터 요청에 대한 응답으로 해당 블록(copy본 포함)을 가진 Datanode 들의 주소가 리턴됩니다.
받은 DataNode 주소정보로 FSDataInputStream 객체를 만들어 client에게 전달됩니다. FSDataInputStream는 DataNode 와 NameNode 와 상호작용할 수 있는 DFSInputStream 객체를 가지고 있습니다. client가 DFSInputStream에 대해 read() 메소드를 호출하고 대상 파일의 첫 번째 블록이 있는 datanode 와 connection 을 맺습니다. 이때 연결하는 대상은 primary datanode로, 가장 가까운 데이터 노드를 찾게 됩니다.
a. 예1 - Local Block Firstblock A가 datanode 1 에 primary 버전이 있고 datanode 2,3 에 replica 버전이 있다고 했을때, datanode2 에 위치한 client 에서 block A 에 대해 read 요청이 온다면, 자신의 로컬인 datanode 2의 replica 버전에서 데이터를 읽습니다.
b. 예2 - Rack Awareness block A가 rack a에 위치한 datanode 1 에 primary 버전이 있고 rack b에 위치한 datanode 2, rack b에 위치한 datanode 3 에 replica 버전이 있다고 했을때, rack2 에 위치한 datanode 4에 있는 client 에서 block A 에 대해 read 요청이 온다면, 자신과 같은 rack2 에 위치한 datanode 2의 replica 버전에서 데이터를 읽습니다.
데이터는 read() 메소드를 반복해서 호출할때마다 stream 형태로 리턴됩니다. read 과정은 end of block 에 도달 할 때까지 지속됩니다.
end of block 에 도달하면, DFSInputStream 은 datanode 와의 연결을 끊고, 해당 파일의 다음 블록이 위치한 데이터 노드와 연결을 맺습니다. 이 과정은 해당 파일의 모든 블록을 읽을 때까지 반복 됩니다.
read 과정이 끝나면 client 는 close() 로 모든 연결과 스트림을 닫고 마무리 됩니다.
◈
🐘 2. Hadoop H/A 클러스터
Hadoop v1.x 버전 까지는 NameNode 는 SPOF(single point of failure)였습니다. 즉, 하나의 NameNode 가 망가지면 서비스 전체가 망가지는 구조였습니다.
Hadoop 의 기본 아키텍처는 NameNode를 Master, Datanode 들을 Slave 로 하는 Master-Slave 구조입니다. 이 중 namenode 는 하나의 인스턴스, datanode 는 수평적 확장이 가능했으므로 namenode는 bottleneck 이 되기 쉬웠습니다.
namenode 가 이용 불가능한 상태라면, datanode가 아무리 많더라도 클러스터 전체가 이용 불가능해집니다.
초기 버전에서는 namenode의 데이터 유실을 방지하는 secondary namenode 가 있었지만 Availability 문제를 완전히 해결하지는 못하였습니다. 이러한 상태라면 예상치 못한 장애 뿐만 아니라, 계획된 업그레이드나 업데이트를 위해서 downtime 이 발생할 수 밖에 없었습니다.
그래서 등장한 것이 H/A 아키텍처입니다.
HA 클러스터에서는 하나의 NameNode가 Active 상태로 동작하고, 다른 하나는 Standby 상태로 대기하고 있습니다. 이 두 NameNode는 항상 실행 중이며, Active NameNode에 장애가 발생하면 Standby NameNode가 자동으로 Active 상태로 전환되어 서비스를 지속할 수 있게 합니다. 이를 통해 다운타임을 최소화할 수 있습니다.
Standby NameNode는 단순한 대기 상태가 아니라, Hadoop 클러스터의 자동 장애 조치(Failover) 기능을 수행하며, 백업 NameNode로서의 역할도 담당합니다. 이로 인해 관리자가 수동으로 전환할 필요 없이 자동화된 장애 복구가 가능하며, 유지보수 작업 중에도 Graceful Failover를 기대할 수 있습니다.
✅ HA 환경에서 데이터를 안정적으로 관리하기 위해 해결해야 할 두 가지 주요 이슈
첫 번째 : Active와 Standby NameNode는 항상 동기화되어야 합니다.
두 NameNode가 같은 상태를 유지하지 않으면 장애 발생 시 데이터 손실이 발생할 수 있습니다.
두 번째 : 동시에 두 개의 Active NameNode가 존재하면 안 됩니다.
두 개의 Active NameNode 가 있으면, 각 NameNode 가 관리하는 데이터의 일관성이 깨질 수가 있습니다. ( 실제로 네트워크가 단절되면 일시적으로 2개의 Active NameNode 가 생길 수 있습니다. )
이런 상황을 Split-brain이라고 부르며, 두 NameNode가 각각 다른 메타데이터를 관리하게 되어 심각한 데이터 충돌이 발생할 수 있습니다. 이를 방지하기 위해 Fencing이라는 절차를 통해 한쪽 NameNode의 접근을 차단하고, 단 하나의 Active NameNode만 존재하도록 보장합니다.
✅ HA Architecture의 구현 방식
Hadoop에서 HA 환경을 구현하는 방법은 크게 두 가지가 있습니다.
첫 번째 : Quorum Journal Nodes 사용
여러 개의 JournalNode를 통해 NameNode 간 로그를 공유하고 동기화합니다. 다수결(Quorum) 방식을 통해 데이터 일관성과 장애 복구를 보장합니다.
두 번째 : NFS 기반의 공유 저장소 사용
Active와 Standby NameNode가 같은 스토리지를 바라보도록 구성합니다. 이 방식은 구현이 상대적으로 단순하지만, NFS 자체의 안정성과 성능에 의존하게 됩니다.
이 둘에 대해서는 아래에서 더 자세히 설명하도록 하겠습니다.
그리고 그 전에, 이러한 H/A 클러스터를 구성 할 수 있게 해준 핵심 엔진인 Zookeeper 와 Hadoop 의 관계를 조금 짚어보고 가겠습니다.
🐘 2.1. 클러스터 구성 위해 잠시 짚어갈 Zookeeper
Apache Zookeeper 에 대한 명확한 이론은 다른 포스팅에서 알아볼 예정이며, 지금은 이 Zookeeper 가 대략 무엇인지, 그리고 Hadoop 의 H/A Cluster 를 구성하기 위해 왜 필요한지만 간략하게 작성하겠습니다.
Apache ZooKeeper는 분산 시스템에서 서버들 간의 상태를 관리하고 조율하는 역할을 수행하는 중앙 집중형 코디네이션 서비스입니다. Hadoop 클러스터에서는 NameNode의 고가용성(High Availability, H/A)을 구성할 때 필수적으로 사용됩니다.
기본적으로 Hadoop은 하나의 NameNode만을 사용합니다. 이 NameNode가 다운되면 전체 HDFS가 작동을 멈추기 때문에, 서비스 안정성을 위해 NameNode를 Active/Standby 구조로 이중화하는 것이 중요합니다. 이때 어느 NameNode가 Active가 될지 결정하고 상태를 감시하는 역할을 ZooKeeper가 맡습니다.
ZooKeeper는 Ephemeral 노드와 Watch 기능을 통해 두 NameNode의 상태를 감시합니다. 두 NameNode는 ZooKeeper에 연결되며, 서로 중복되지 않도록 하나만 Active 상태가 될 수 있습니다. Active로 선출된 NameNode는 ZooKeeper 내 ZNode를 선점하고, 나머지 하나는 Standby 상태로 대기합니다. 만약 Active NameNode가 비정상 종료되면 ZooKeeper는 이 연결이 끊긴 것을 감지하고, 대기 중이던 Standby NameNode를 자동으로 Active로 승격시킵니다. 이를 통해 서비스 중단 없이 NameNode를 전환할 수 있습니다.
해당 과정을 살펴보면, 다음과 같습니다.
첫번째, NameNode 상태 정보를 ZooKeeper의 ZNode에 등록합니다.
HDFS 클러스터가 HA 모드로 구성되면, 각 NameNode는 ZKFailoverController(ZKFC) 프로세스를 함께 실행합니다. 이 ZKFC는 ZooKeeper와 연결되며, 자신의 NameNode가 Active 또는 Standby 상태인지를 나타내는 정보를 ZooKeeper의 ZNode에 Ephemeral(임시) ZNode로 등록합니다.
/hadoop-ha/[nameservice]/ActiveStandbyElectorLock
이 ActiveStandbyElectorLock 노드는 단 하나의 NameNode만 소유할 수 있으며, 이를 통해 현재 Active NameNode를 결정합니다.
( 이로써 스플릿 브레인 현상을 어느정도는 방어해 줄 수 있습니다. Zookeeper 는 자체 엔진만으로 Lock 을 구현할 수 있을만큼 동시성 이슈를 민감하게 처리 할 수 있습니다. )
두번째, Ephemeral ZNode와 Watcher를 통한 상태 감지 및 장애 인식을 합니다.
각 NameNode에는 ZKFailoverController(ZKFC)가 함께 실행되며, ZooKeeper와 연결됩니다.
이 ZKFC는 자신이 관리하는 NameNode가 Active 상태가 되었을 때, ZooKeeper 내부의 /hadoop-ha/[nameservice]/ActiveStandbyElectorLock 경로에 Ephemeral ZNode를 생성합니다. Ephemeral ZNode는 해당 ZKFC와 ZooKeeper 간의 세션이 유지되는 동안만 존재하며, 세션이 끊기면 자동으로 삭제됩니다.
이 ZNode에는 다른 Standby 상태의 ZKFC 인스턴스들이 watcher를 등록합니다. ZooKeeper는 해당 ZNode에 변화(삭제, 수정 등)가 발생하면, watch를 등록한 모든 ZKFC에게 일회성 이벤트 알림을 전달합니다. 이 이벤트는 Active NameNode가 비정상 종료되어 세션이 끊어지고, ZNode가 삭제된 경우 발생합니다.
이 알림을 받은 Standby NameNode들의 ZKFC는 장애 상황을 감지하고 즉시 Leader Election(리더 선출) 절차에 참여하게 됩니다. 이렇게 ZooKeeper는 자체적으로 장애를 처리하지는 않지만, Ephemeral ZNode와 watch 메커니즘을 통해 상태 변화를 빠르게 감지하고 알림을 전달함으로써, 클라이언트 측에서 장애 복구가 가능하도록 보조하는 역할을 수행합니다.
세번째, ZKFC가 상태 변화 감지 후 리더 선출을 진행합니다.
Ephemeral ZNode 삭제는 ZooKeeper 내부적으로 watcher 메커니즘을 통해 모든 ZKFC에 통지됩니다. 각 Standby NameNode의 ZKFC는 각 NameNode 의 상태를 주기적으로 확인하다가, 이를 감지하고 즉시 Leader Election(리더 선출)에 참여합니다.
이 리더 선출은 ZooKeeper의 ActiveStandbyElector 클래스를 통해 구현되며, 선출된 리더만이 ActiveStandbyElectorLock ZNode에 새로 Ephemeral 노드를 생성할 권한을 획득합니다. 결국, 단 하나의 Standby NameNode만이 승격되어 Active 상태로 전환됩니다.
네번째, 새로운 Active NameNode 등록 및 상태를 반영합니다.
리더로 선출된 NameNode는 ZooKeeper의 ActiveStandbyElectorLock 경로에 Ephemeral ZNode를 생성하여 자신이 새로운 Active임을 등록합니다. 이후 클러스터의 다른 구성 요소(DataNode, Client 등)는 ZooKeeper에서 이 정보를 조회하여, 새롭게 승격된 NameNode로 연결합니다. 이 과정을 통해 서비스는 중단 없이 지속됩니다.
다섯번째, 기존 장애 NameNode 복구 후 Standby로 재등록합니다.
장애로 인해 종료되었던 NameNode가 재시작되면, 해당 노드의 ZKFC는 다시 ZooKeeper와 연결하여 Standby 상태로 클러스터에 참여합니다. 이때 ZooKeeper는 이미 Active가 존재함을 알고 있으므로, 중복 활성화를 방지하고 해당 노드를 Standby로만 유지합니다.
Apache Zookeeper 에 대한 더 자세한 이야기는 추후 Zookeeper 를 더 깊게 공부하고 다뤄보겠습니다.
현재는 zNode 의 개념과, watch 의 개념. 그리고 분산락의 개념 정도만 알아두어도, 하둡을 공부하는데에 문제가 없을것으로 생각됩니다.
🐘 2.2. Quorum Journal Nodes H/A
출처 : Quorum Journal Nodes
HDFS의 고가용성(HA) 아키텍처에서 Quorum Journal Nodes(QJN)이 필요하게 된 이유는, Zookeeper만으로는 EditLog 동기화 및 데이터 일관성 유지가 어렵기 때문입니다. Zookeeper는 주로 NameNode의 상태 관리와 Active/Standby 전환을 담당하지만, HDFS에서 중요한 역할을 하는 EditLog의 저장과 동기화는 처리하지 못합니다. 또한, 두 개의 NameNode가 동시에 Active가 되는 Split-brain 현상을 방지하기 위해서는, Quorum Journal Nodes와 같은 별도의 메커니즘이 필요합니다.
Active와 Standby NameNode는 Journal Nodes라는 별도의 노드 그룹(또는 데몬 프로세스)을 통해 동기화를 유지합니다.
Journal Nodes는 ring topology로 서로 연결되어 있습니다.
Journal Node에 들어온 request는 ring 구조를 따라 다른 노드로 복사됩니다. 이로 인해 특정 Journal Node에 failure가 발생해도 Fault Tolerance가 보장됩니다.
Active NameNode는 Journal Node에 있는 EditLogs를 업데이트합니다.
Standby NameNode는 Journal Node로부터 EditLogs의 변경 사항을 읽고, 그것을 자신의 namespace에 적용합니다.
Failover 시에 Standby NameNode는 Active NameNode가 되기 전에 우선 자신이 가진 metadata의 내용이 Journal Node에 있는 모든 업데이트를 반영한 상태인지 확인합니다. Journal Node의 모든 데이터가 싱크되었다면 그때 Active NameNode가 됩니다.
모든 DataNode는 Active NameNode와 Standby NameNode의 IP 주소를 모두 알고 있습니다. DataNode는 자신의 heartbeat와 block report 데이터를 두 NameNode에게 보냅니다. 이로 인해 Standby는 Active가 되기 전에 이미 DataNode 정보와 block location 정보를 모두 알고 있으므로 빠르게 failover를 수행할 수 있습니다.
특히, Split-brain 현상을 방지하기 위해, QJN은 하나의 NameNode만이 EditLog를 기록하도록 보장합니다. Standby NameNode가 QJN에 대한 write 권한을 획득하면, 다른 NameNode는 Active 상태로 전환되지 못하도록 막습니다. 이 과정은 Fencing이라고 하며, 이를 통해 두 NameNode가 동시에 Active 상태가 되는 문제를 예방할 수 있습니다. Fencing이 완료된 후, Standby는 Active 역할을 수행할 수 있게 됩니다.
🐘 2.3. Shared Storage H/A
링 형 구조로 별도 저널 노드들이 서로 연결되어 동기화하는 Quorum Journal Nodes 방법도 있지만, 하나의 스토리지를 공유하는 Shared Storage 방식도 있습니다.
먼저 Active NameNode는 EditLog를 공유 스토리지에 기록합니다. 이후 Standby NameNode는 공유 스토리지에 저장된 EditLog를 읽어 자신의 namespace에 반영합니다. 이 과정은 Standby NameNode가 Active NameNode로서의 역할을 맡을 준비가 되도록 도와줍니다.
Failover가 발생하면, Standby NameNode는 공유 스토리지에 있는 EditLog의 모든 변경 사항을 반영한 후, Active NameNode로 전환됩니다. 이때, Standby NameNode는 모든 변경 사항을 적용한 후에 Active 상태로 변환되기 때문에 데이터 일관성을 유지할 수 있습니다.
또한, Split-brain을 방지하기 위해, Shared Storage 환경에서도 fencing 메커니즘이 필요합니다. 여기서 Fencing은 두 개의 NameNode가 동시에 Active 상태가 되는 것을 방지하는 방법입니다.
하둡을 관리하는 관리자는 적어도 하나의 fencing 방법을 설정해야 합니다.
다양한 fencing 방법이 있을 수 있는데.. 예를 들어, 문제가 되는 NameNode의 프로세스를 종료시키거나, 해당 NameNode 의 공유 스토리지에 대한 액세스 권한을 취소하는 방식이 있을 수 있습니다.
그레이스풀한 종료가 어려운 환경에 있다면, STONITH(Shoot the Other Node in the Head) 라는 마지막 수단으로 사용할 수 있는 fencing 방법이 있습니다. STONITH는 특수 전원 공급 장치를 이용해 활성화된 NameNode의 머신을 강제로 종료시킵니다. 이를 통해, Split-brain 상황을 방지하고 시스템의 일관성을 유지할 수 있습니다.
🐘 2.4. Failover
결국 위와 같은 방법들을 공부해야 하는 이유는, 클러스터 환경에서 장애 조치를 어떻게 할 것인가를 위해서입니다. 여기서 장애 조치는, 시스템에 문제가 발생했을 때, 이를 감지하고 두 번째 시스템으로 자동으로 전환하는 과정을 의미합니다
장애 조치에는 두 가지 방식이 있는데, 관리자가 계획적이고 의도적으로 진행하는 Graceful Failover 가 있고, 관리자가 의도하지 않은 타이밍에 갑자기 진행되는 Atomic Failover 가 있습니다.
우리가 위에서 알아보았던, Zookeeper 를 통한 Failover 과정은 이러한 Atomic Failover 과정이라고 할 수 있습니다.
🐘 2.5. Observer NameNode ( ONN )
하둡에 대한 첫 게시글인 분산 시스템의 이해와 하둡의 등장 배경 에서 Version 에 대한 포스팅 부분에서 미처 이야기 하지 못한 부분이 바로 Observer NameNode 입니다. Observer NameNode 는 하둡 3.x 버전부터 사용 할 수 있습니다. ( 의도적으로 빼먹은 건 아니고, 깜빡했습니다 ㅎㅎ.. )
그럼 대체 Observer NameNode 가 무엇이고 왜 생긴 것일까요.
위에서 포스팅한 HA 아키텍처에서 Active NameNode는 클라이언트의 모든 요청을 받으며, Standby NameNode는 단순히 Active NameNode와 같도록 동기화만 수행합니다. HA는 달성되었지만, 여전히 단일 Active NameNode가 병목 현상을 일으키는 문제는 발생할 수 있습니다. 단일 Active NameNode에 부하가 심해지면, HA를 이용하여 Failover가 발생하더라도 부하로 인해 Active NameNode를 사용할 수 없는 상태가 연쇄적으로 발생할 수 있습니다.
그렇기에 등장한 것이 Observer NameNode 입니다.
Observer NameNode는 HDFS에서 Active NameNode와 Standby NameNode의 기능을 결합한 역할을 합니다. Standby NameNode는 Active NameNode와 동기화되어 있지만, 실제로 클라이언트의 요청을 처리하지 않고, 장애 발생 시 Failover를 위해 대기만 합니다. 반면, Observer NameNode는 Standby와 유사하게 상태를 유지하지만, Active NameNode처럼 읽기 요청도 처리할 수 있습니다. 즉, Observer NameNode는 데이터를 읽는 요청을 받아들일 수 있으며, 이를 통해 Active NameNode의 부하를 분산시킬 수 있습니다.
HDFS의 일반적인 사용 사례는 “write-once-read-many” 접근 방식을 따릅니다. 즉, 한 번 쓰고 여러 번 읽는 경우가 많습니다. 이러한 특성 덕분에 Observer NameNode가 읽기 요청을 분담함으로써 Active NameNode의 부하를 크게 줄일 수 있습니다. 결국 Active NameNode가 처리해야 할 읽기 작업의 양이 줄어들어 시스템의 전체 성능을 개선할 수 있습니다.
이에 따라, NameNode 는 하둡 3 버전부터, HA Hadoop Cluster 에서 3가지 상태를 가질 수 있게 됩니다. ( Active, Standby, Observer )
또한, NameNode 의 상태를 변경할 때는 다음과 같은 규칙이 있습니다.
active → observer ( : ❌ 불가능 )
observer → active ( : ❌ 불가능 )
active → standby → observer ( : ✅ 가능 )
observer → standby → active ( : ✅ 가능 )
즉, 가능한 전이 순서는 반드시 중간에 standby를 거쳐야 합니다.
이렇게 정한 이유는, 일반적으로 안정성과 일관성 유지를 위한 것입니다. 특히 observer는 passive(수동적) 역할이기 때문에, 곧바로 서비스를 운영하는 active로 전환되면 위험할 수 있어 이런 제한을 둡니다.
✅ Read-Write 의 일관성 유지 방법 ( 하나의 클라이언트 - read your own writes )
기존 하둡은 쓰기 작업이 완전히 종료되어 close() 메서드가 실행됨으로써 NameNode의 edit log에 반영되고, 해당 block이 최종적으로 commit되기 전까지는, 다른 클라이언트는 물론, 동일 클라이언트라도 그 데이터는 보장되지 않았습니다.
그렇기 때문에 write 도중 read 를 하여도 문제가 되지 않게끔 설계가 되어있었습니다. 하지만 하둡 V3 에 등장한 Observer NameNode 는, wrtie 가 close() 는 되었지만, NameNode 로부터 복사를 완료하지 못하여 일관성을 보장하지 못하는 경우가 발생하게 됩니다.
위에서 설명하였듯, Observer NameNode 는 Active NameNode 와 비슷한 역할을 하지만 read 전용입니다. 그런데 일관성이 깨지게 되면, 읽기 성능에 문제가 발생하게 됩니다. 그래서 하둡 V3 부터는 State ID 라는 것을 도입하게 됩니다.
Iceberg 나, Hudi 등을 공부한 적이 있다면, 이 테이블 포멧들이 ACID 를 보장해 줄 때, 스냅샷 아이디라는 것을 쓰는 걸 알 수 있습니다. 하둡도 비슷한 개념으로 State ID 를 사용합니다.
state ID는 Namenode의 transaction ID를 기반으로 생성되며, 클라이언트 측에서 내부적으로 저장 및 관리됩니다. 클라이언트는 이 state ID를 RPC 요청의 헤더에 포함시켜 NameNode에 전달합니다. 이 state ID는 Hadoop 내부의 DFSClient와 같은 구성 요소에서 메모리 상으로 유지되며, 클라이언트가 쓰기나 읽기 작업을 수행할 때마다 갱신되고 전송됩니다
예를 하나 들어보겠습니다.
클라이언트가 Active NameNode를 통해 데이터를 HDFS에 씁니다. Active NameNode는 클라이언트가 요청한 데이터를 DataNode에 저장하도록 지시하고, 해당 작업에 대한 transaction ID를 생성합니다. 클라이언트는 이 transaction ID를 기반으로 state ID를 갱신합니다.
클라이언트가 데이터를 읽기 위해 Observer NameNode 에 읽기를 요청합니다. 이 때, 클라이언트는 이전에 갱신한 state ID를 Observer NameNode에 전달합니다.
Observer NameNode는 자신이 가지고 있는 현재 transaction ID와 클라이언트가 보낸 state ID를 비교합니다. ( 하나의 클라이언트일 경우, write 한 쪽에서 계속 state ID 를 가지고 있게 때문에 가능합니다. )
만약, Observer NameNode의 transaction ID가 클라이언트가 보낸 state ID보다 같거나 더 최신이라면, Observer는 읽기 작업을 수행합니다.
결과적으로, 클라이언트는 자신이 쓴 최신 데이터를 바로 읽을 수 있습니다. 하지만 클라이언트가 갱신된 상태 ID를 보내지 않은 경우, Observer NameNode가 클라이언트의 요청을 처리하는 데 있어 최신 상태를 반영하지 못할 수 있습니다. 그럴 경우에는 이전 데이터를 읽게 됩니다.
💡 Stats ID 는 “파일 단위”나 “디렉토리 단위”가 아니라, HDFS 전체 메타데이터의 글로벌한 변경 순서를 나타내는 값입니다.
✅ Observer NameNode 의 Edit Log Tailing
Observer NameNode에게 Edit log tailing은 매우 중요한 기능입니다. 이는 Active NameNode에 기록되는 새로운 트랜잭션들을 Observer NameNode가 얼마나 빠르게 반영하느냐에 따라, 읽기 요청 처리 시의 일관성과 정확도가 결정되기 때문입니다. Observer NameNode는 읽기 전용 요청을 처리하여 Active NameNode의 부하를 줄이는 역할을 합니다. 그러나 메타데이터가 최신 상태로 반영되지 않으면 오래된 데이터를 응답하게 되어 전체 시스템 신뢰도에 악영향을 줄 수 있습니다. 따라서 Edit log tailing의 반영 지연 시간, 즉 latency를 줄이는 것이 성능 향상에 매우 중요합니다.
기존의 Edit log tailing 방식은 HTTP 기반 통신을 사용하였고, Edit log가 디스크에 flush된 이후에야 Observer가 로그를 tailing할 수 있었습니다. 이 방식은 통신 속도와 디스크 I/O 의존성 측면에서 latency가 클 수밖에 없었습니다.
이를 개선하기 위해 새롭게 도입된 Edit Tailing Fast-Path 방식은 latency를 줄이기 위한 두 가지 핵심 변경점을 포함하고 있습니다. 첫째, 기존의 HTTP 통신 대신 RPC 기반 통신을 사용하여 더욱 빠르고 효율적인 로그 전달이 가능해졌습니다. RPC는 무거운 기능 없이 간단하게 통신할 수 있고, 개발자가 어떻게 통신할지 직접 정할 수 있어서, 불필요한 지연이 줄어들고 더 빠르게 데이터를 주고받을 수 있습니다. 둘째, JournalNode의 in-memory cache를 활용함으로써 디스크에 로그가 기록되기를 기다릴 필요 없이 메모리에서 직접 edit log 항목을 가져올 수 있게 되었습니다. 이로 인해 Observer NameNode는 거의 실시간에 가까운 로그 반영이 가능해졌습니다.
결과적으로 Edit Tailing Fast-Path는 Observer NameNode의 latency를 획기적으로 줄이며, 메타데이터 반영 속도를 높여 전체 HDFS 시스템의 성능과 신뢰성을 향상시키는 데 크게 기여하게 되었습니다.
✅ Client-side Proxy Provider
ObserverReadProxyProvider 라는 클래스는 클라이언트가 데이터를 읽을 때 사용되는 새로운 클라이언트 측 프록시 제공자입니다. 이 클래스는 기존의 ConfiguredFailoverProxyProvider 를 상속받아 만들어졌습니다. 이를 통해 클라이언트는 데이터를 읽을 때 active namenode뿐만 아니라 observer namenode에서도 읽을 수 있게 됩니다.
클라이언트가 읽기 요청을 보내면, ObserverReadProxyProvider는 먼저 클러스터 내에서 사용 가능한 observer namenode를 통해 데이터를 읽으려 시도합니다. 만약 모든 observer namenode에서 읽기에 실패할 경우, 그때서야 active namenode로 요청을 보냅니다.
Read-Write 의 일관성 유지 방법 ( 여러 다른 클라이언트 - Maintaining Client Consistency)
앞에서 설명한 “read your own writes” 개념에 따라, 클라이언트 ‘A’가 active NameNode에 a.txt 파일을 생성했다고 가정합니다. 이 경우, ‘A’가 observer NameNode에 데이터를 읽으라고 요청하면, observer는 a.txt 파일이 완전히 생성되었는지 확인한 후에 응답합니다. 즉, ‘A’는 자신이 쓴 데이터를 바로 읽을 수 있습니다.
하지만 ‘A’가 파일을 생성한 뒤, 이 사실을 HDFS와 무관한 다른 클라이언트 ‘B’에게 알려주었다고 해봅시다. 이후 ‘B’가 HDFS에서 a.txt를 읽으려고 시도할 경우, 해당 파일을 읽을 수 있다는 보장은 없습니다. 이유는 ‘B’가 observer NameNode를 통해 데이터를 읽으려 할 때, 해당 observer가 아직 최신 메타데이터로 동기화되지 않았을 수 있기 때문입니다.
이러한 문제, 즉 클라이언트 간의 일관성 결여를 해결하기 위해 msync()라는 기능이 도입되었습니다. msync()를 호출하면 클라이언트는 active NameNode의 최신 상태 ID(state ID)를 동기화하게 됩니다. 이 과정을 거치면 이후 모든 읽기 요청은 해당 시점까지의 메타데이터와 일치하게 됩니다. 즉, ‘B’가 msync()를 먼저 호출한 후 a.txt를 조회하면, 파일을 정상적으로 읽을 수 있게 됩니다.
msync()를 사용하기 위해 애플리케이션 코드를 따로 수정할 필요는 없습니다. 클라이언트는 observer NameNode에서 읽기를 수행하기 전에 자동으로 msync()를 호출하여 초기화 전에 발생한 쓰기를 볼 수 있도록 합니다.
또한 ObserverReadProxyProvider는 자동 msync() 모드를 지원합니다. 이 모드를 설정하면 일정한 간격으로 자동으로 msync()를 수행하며, 클라이언트가 일정 시간 이상 오래된 데이터를 읽지 않도록 보장합니다. 단, 이 기능은 msync() 호출마다 active NameNode에 RPC를 수행하므로 성능 오버헤드가 발생할 수 있으며, 기본 설정으로는 비활성화되어 있습니다.
설정 방법은 아래와 같습니다.
-- hdfs-site.xml
<property>
<name>dfs.client.observer.auto-msync.enabled</name>
<value>true</value>
</property>
<property>
<name>dfs.client.observer.auto-msync.interval.ms</name>
<value>5000</value> <!-- 5초마다 `msync()` 수행 -->
</property>
이 내용을 좀 더 직관적이고, 순서대로 정리를 해보겠습니다.
A 클라이언트(foo)가 a.txt 파일 생성
A는 active NameNode에 a.txt 파일을 생성하는 write 요청을 보냅니다.
이 write는 active NameNode에서 처리되고, 이후 observer NameNode로 메타데이터가 비동기 전파됩니다.
B 클라이언트(bar)가 observer NameNode에 read 요청
B는 a.txt 파일을 읽기 위해 observer NameNode에 read 요청을 보냅니다.
하지만 이 시점에 observer NameNode가 아직 최신 메타데이터(a.txt 생성 정보)를 받지 못했을 가능성이 있습니다.
B가 a.txt를 읽지 못하는 경우 발생
observer가 아직 동기화되지 않았다면, B는 a.txt 파일을 찾을 수 없거나 존재하지 않는다는 응답을 받을 수 있습니다.
클라이언트 간 일관성 문제 발생
같은 시점에 A는 파일을 볼 수 있는데, B는 볼 수 없는 read-your-own-writes 불일치 상황이 발생합니다.
이를 해결하기 위한 msync() 호출
B가 msync()를 명시적으로 호출하거나 자동 msync() 기능이 활성화되어 있다면, B는 active NameNode의 최신 상태 ID를 동기화합니다.
B가 observer에 다시 read 요청
이제 observer는 active NameNode에서 전파된 최신 메타데이터 상태를 반영하게 되어, B는 정상적으로 a.txt를 읽을 수 있습니다.
msync() 가 동작하면, 클라이언트가 msync() 호출 → active NameNode에 최신 state ID 요청 → active NameNode가 최신 state ID 응답 → 클라이언트는 해당 state ID 기준으로 observer NameNode와 동기화 여부 확인 → observer가 최신이면 observer에서 read, 아니면 active로 read 우회의 과정을 거칩니다.
◈
🐘 3. Eraser Coding
🐘 3.1. Software Data Fault Tolerance
Hadoop은 기본적으로 데이터의 내결함성을 보장하기 위해 하나의 데이터 블록에 대해 3개의 복제본(replica)을 생성하여 저장합니다. 이 복제본들은 서로 물리적으로 다른 위치에 저장되며, 일반적으로는 서로 다른 머신, 다른 랙(Rack), 심지어는 서로 다른 데이터 센터에 분산시켜 저장합니다. 이와 같은 방식은 특정 하드웨어에 장애가 발생하더라도 데이터가 유실되지 않도록 보호하는 역할을 합니다.
복제본 기반의 데이터 보호 방식은 매우 강력한 내결함성을 제공하는 반면, 다음과 같은 단점이 존재합니다.
첫째, 동일한 데이터를 여러 벌 저장해야 하기 때문에 데이터를 저장할 때 시간과 리소스가 더 많이 소모됩니다. 특히 Write 작업 시 성능 저하가 발생할 수 있습니다.
둘째, 3개의 복제본을 유지하기 위해 실제 데이터 크기의 3배 이상의 저장 공간이 필요하게 되므로, 저장 비용이 상당히 증가합니다.
이러한 문제를 해결하기 위한 방안 중 하나로 Hadoop 3에서는 Erasure Coding(삭제 코드) 기법을 도입하여 스토리지 효율성과 내결함성을 동시에 확보하고자 하였습니다. (※ Erasure Coding에 대해서는 아래에서 다루겠습니다.)
🐘 3.2. Hardware Data Fault Tolerance & RAID
Hadoop 클러스터는 위에서 설명한 것처럼 소프트웨어적으로 복제본을 통해 데이터 유실을 방지할 수 있습니다. 하지만, 시스템 전체의 내결함성 수준을 더 높이기 위해 하드웨어 차원에서의 보완 방법을 함께 적용하는 경우도 있습니다.
대표적인 방법 중 하나는 RAID(Redundant Array of Independent Disks) 구성을 통해 하드웨어 자체에서 장애에 대응하는 것입니다. 예를 들어 RAID-5나 RAID-6 같은 방식은 디스크 일부에 패리티(Parity) 정보를 저장함으로써, 특정 디스크가 고장나더라도 데이터를 복구할 수 있도록 합니다.
하드웨어 기반의 Fault Tolerance는 소프트웨어를 수정하지 않고도 데이터 보호 수준을 향상시킬 수 있다는 장점이 있습니다. 그러나 이러한 RAID 구성을 사용하면 추가적인 저장 공간이 필요하며, 데이터 저장 또는 복구 시 더 많은 시스템 자원이 소모되므로 비용이 증가합니다.
그러면, 본격적인 Hadoop 의 Eraser Coding 을 알아보기 전에, RAID 의 개념을 간단하게나마 짚고 넘어가겠습니다.
✅ RAID 란?
RAID는 Redundant Array of Inexpensive Disks의 약자로, 여러 개의 독립적인 하드 디스크를 하나의 논리적인 장치로 묶어 데이터를 저장하는 기술입니다. 우리말로는 보통 중복 배열 디스크 또는 복수 저가형 디스크 배열이라고 번역됩니다.
즉, 1개의 Disk 를 가상화로 각 독립적인 배열 Drive 로 나누는 것을 RAID 라고 합니다. ( ex - 1개의 Disk 를 가지고 있지만, C 드라이브와 D 드라이브로 나누는 것. ) 회사 by 회사겠지만, 보통 현업에서는 1 Disk 당, 1 Drive 로 Mapping 하여 사용하는 것이 일반적입니다.
RAID는 디스크를 배열로 가상화하여 구성함으로써, 성능(Performance), 용량(Capacity), 신뢰성(Reliability) 측면에서 시스템을 향상시키는 효과가 있습니다. 예를 들어, 데이터를 병렬로 읽고 쓸 수 있어 I/O 성능이 향상되며, 일부 디스크에 장애가 발생하더라도 나머지 디스크의 정보로 데이터를 복구할 수 있는 내결함성을 제공합니다.
RAID 구현 방식은 크게 하드웨어 RAID와 소프트웨어 RAID로 나뉩니다.
하드웨어 RAID는 전용 RAID 컨트롤러를 사용하여 RAID 구성을 물리적으로 관리합니다. 이 방식은 운영체제와 독립적으로 동작하며, 시스템 자원 소모가 적고 안정성 및 성능이 뛰어나다는 장점이 있습니다. 다만, 전용 장비가 필요하므로 구축 비용이 높고, 컨트롤러 고장 시 전체 시스템이 영향을 받을 수 있는 단점도 존재합니다.
소프트웨어 RAID는 RAID 기능을 운영체제가 직접 구현하여 관리하는 방식입니다. 별도의 하드웨어가 필요 없기 때문에 비용이 저렴하고 설정이 유연하지만, CPU와 메모리 등 시스템 자원을 더 많이 사용하게 되며, 성능 면에서는 하드웨어 RAID에 비해 다소 낮을 수 있습니다.
RAID는 구성 방식에 따라 RAID 0, RAID 1, RAID 5, RAID 6, RAID 10 등 여러 레벨로 나뉘며, 각 방식은 데이터 보호 수준, 쓰기/읽기 성능, 디스크 사용 효율성에 따라 선택하게 됩니다.
이렇게 0, 1, 5, 6, 10 등으로 나누는 것은 하드웨어 RAID든 소프트웨어 RAID든 둘 다에서 구현할 수 있는 구성 방식입니다.
아래는 RAID 의 종류 몇 가지를 정리하였습니다. ( Hadoop 내용이기 때문에, RAID 내용은 간단하게 기본적인 내용만 정리하였습니다. )
✅ RAID 0
출처 : RAID 0 Image
RAID 0은 데이터를 블록 단위로 분할(striping)하여 여러 드라이브에 분산 저장하는 방식입니다. 이 구성은 데이터를 순차적으로 나누어 각 디스크에 배열처럼 저장하기 때문에, 드라이브 수만큼 동시에 읽기/쓰기 작업이 가능합니다. 그 결과, I/O 성능이 매우 우수하며 저장 공간의 손실도 없이 전체 디스크 용량을 모두 활용할 수 있습니다. 또한, 패리티 계산이나 중복 저장이 없기 때문에 오버헤드가 전혀 발생하지 않는다는 장점이 있습니다.
하지만 RAID 0은 데이터 중복(redundancy)이 전혀 없는 구조입니다. 즉, 하나의 디스크라도 고장이 나면 해당 디스크에 저장된 데이터는 복구가 불가능하며, 전체 파일이 손상될 가능성이 매우 높습니다. 이로 인해 RAID 0은 신뢰성보다는 성능이 중요한 경우에만 적합합니다.
따라서 RAID 0은 데이터 보존이 중요하지 않은 임시 작업 공간이나 고속 캐시, 비중요 테스트 환경 등에 활용되며, 데이터 보관이나 복구가 필요한 환경에서는 절대 권장되지 않습니다.
✅ RAID 1
출처 : RAID 1 Image
RAID 1은 동일한 데이터를 최소 두 개의 드라이브에 동일하게 저장하는 방식입니다. 이 구조는 하나의 드라이브에 장애가 발생하더라도, 다른 드라이브에 동일한 데이터가 저장되어 있기 때문에 데이터 유실 없이 안정적인 운영이 가능합니다. 즉, 장애 허용(fault tolerance) 능력이 RAID 0보다 훨씬 뛰어납니다.
또한, RAID 1은 읽기(Read) 성능이 우수한 편입니다. 데이터는 두 개의 디스크 중 더 빠르게 접근 가능한 쪽에서 읽을 수 있기 때문에, 병렬 읽기가 가능하여 응답 속도가 향상됩니다.
반면, 쓰기(Write) 성능은 RAID 0에 비해 느릴 수 있습니다. 동일한 데이터를 모든 드라이브에 중복 저장해야 하기 때문입니다. 또한 RAID 1은 물리적으로 두 개의 드라이브를 사용하더라도, 실제로 사용할 수 있는 용량은 절반 수준인 50%입니다. 예를 들어, 1TB 디스크 2개를 구성하면 실질적으로는 1TB의 공간만 사용할 수 있습니다.
따라서 RAID 1은 데이터 안정성이 중요한 시스템에 적합하며, 속도보다 가용성과 복구 가능성을 우선시하는 환경에서 많이 사용됩니다.
💡 RAID 5, 6를 배우기전에, Parity 에 대하여
RAID 5와 RAID 6은 단순 복제(RAID 1)나 분산 저장(RAID 0)과 달리, ‘패리티(Parity)’라는 개념을 기반으로 데이터 복구 기능을 제공합니다.
이 패리티가 RAID의 핵심적인 복구 메커니즘이기 때문에, 먼저 Parity가 무엇인지 이해하지 못하면 RAID 5/6의 구조와 동작 원리를 이해하기 어렵습니다.
Parity(패리티)는 컴퓨터 과학에서 데이터의 오류 검출을 위해 사용하는 비트입니다.
일반적으로는 7비트의 원래 데이터에 1비트의 Parity Bit를 추가하여 전송 도중 발생할 수 있는 데이터 오류를 감지할 수 있게 합니다.
Parity는 크게 두 가지 방식이 있습니다:
Even Parity (짝수 패리티): 전체 비트 중 1의 개수가 짝수가 되도록 Parity Bit를 설정
Odd Parity (홀수 패리티): 전체 비트 중 1의 개수가 홀수가 되도록 Parity Bit를 설정
💡 Parity 예시: 7비트 데이터 → 8비트 패리티 처리
7 bits of data
1-bit count
8 bits (Even Parity)
8 bits (Odd Parity)
0000000
0
00000000
00000001
1010001
3
10100011
10100010
1101001
4
11010010
11010011
1111111
7
11111111
11111110
패리티 비트는 단순한 오류 감지뿐만 아니라, RAID 시스템에서는 장애 디스크의 데이터를 복구하는 핵심 요소로 발전했습니다.
💡 Parity 블록의 특징
주로 XOR 연산을 사용하여 생성하며, 복수의 데이터 블록들을 XOR 연산한 결과를 패리티 블록에 저장합니다. 디스크 하나가 고장나면, 나머지 데이터 + 패리티로 유실된 블록을 복구할 수 있습니다.
💡 XOR(배타적 논리합, Exclusive OR) 연산 : 두 개의 비트가 서로 다를 때 1, 같을 때는 0을 반환하는 논리 연산입니다.
💡 0 ⊕ 0 = 0 그리고, 1 ⊕ 1 = 0 그리고, 0 ⊕ 1 = 1 그리고, 1 ⊕ 0 = 1 가 있습니다.
💡 Parity 예제: 5개의 디스크 중 1개가 장애 발생한 경우
⭕ 정상 상태 예시:
DISK 1
DISK 2
DISK 3
DISK 4
DISK 5 (Parity)
0
0
0
0
0 = 0⊕0⊕0⊕0
0
1
0
1
0 = 0⊕1⊕0⊕1
0
1
1
0
0 = 0⊕1⊕1⊕0
1
0
0
1
0 = 1⊕0⊕0⊕1
1
0
0
0
1 = 1⊕0⊕0⊕0
❌ DISK 3 장애 발생 시:
DISK 1
DISK 2
DISK 3 (❌)
DISK 4
DISK 5 (Parity)
0
0
❌
0
0
0
1
❌
1
0
0
1
❌
0
0
1
0
❌
1
0
1
0
❌
0
1
🔁 Parity 복구 방식
패리티는 XOR 연산이기 때문에, 유실된 블록을 제외한 모든 블록과 패리티를 XOR 하면 유실된 블록 값을 역산할 수 있습니다.
✅ RAID 5
그럼 다시 RAID 로 돌아와서, 이번에는 RAID 5입니다.
출처 : RAID 5 Image
RAID 5는 최소 3개 이상의 드라이브로 구성해야 하는 스토리지 방식입니다. 하나의 데이터 블록을 여러 드라이브에 나누어 저장하며, 개별 블록을 통째로 복제하지는 않습니다. 대신, 패리티(parity) 라고 불리는 특별한 블록을 추가로 생성하여 다른 드라이브에 분산 저장합니다.
만약 하나의 드라이브에 장애가 발생하면, 해당 드라이브에 있던 데이터 블록은 다른 드라이브에 있는 데이터와 패리티 블록을 이용해 복구할 수 있습니다. 이로 인해 단일 드라이브 장애 시에도 제로 다운타임(Zero downtime)이 보장되며, 읽기 성능도 빠른 편입니다.
RAID 5는 전체 디스크 용량 중 약 33% (3개 드라이브 기준)를 패리티 저장 공간으로 사용하므로, 데이터 보호를 유지하면서도 RAID 1과 비교해 더 높은 저장 효율을 제공합니다. 예를 들어, 3개의 1TB 드라이브로 RAID 5를 구성하면 총 3TB의 용량이 필요하지만, 실제 저장 가능한 데이터 용량은 2TB입니다. 이는 하나의 디스크 분량이 패리티 저장용으로 사용되기 때문입니다. 만약 1TB의 데이터를 저장하려면, 최소 1.5TB 이상의 총 디스크 용량이 필요하다는 뜻입니다.
일반적으로는 4개의 드라이브로 구성해 약 25%의 용량 손실로 운영하는 경우가 많습니다. 예를 들어, 4개의 1TB 드라이브를 RAID 5로 구성하면 총 4TB 용량 중 3TB만 사용할 수 있습니다.
하지만 데이터를 저장할 때마다 패리티를 계산해야 하므로 쓰기 성능은 다소 느려집니다. 또한 두 개 이상의 드라이브가 동시에 장애가 발생하면 데이터 복구가 불가능하다는 단점이 있습니다.
RAID 5는 최대 16개 드라이브까지 확장할 수 있으며, 디스크 수가 제한된 파일 서버나 애플리케이션 서버에 적합한 구성 방식입니다.
하나 예시를 들어보겠습니다.
4개의 디스크가 있다고 생각해봅시다. 각 디스크에는 데이터 조각과 함께 패리티라는 특별한 정보가 저장되어 있습니다. 패리티는 데이터를 복구하는 데 필요한 ‘체크포인트’ 같은 역할을 합니다.
예를 들어, 디스크 1에는 숫자 5, 디스크 2에는 숫자 7, 디스크 3에는 숫자 3이 저장되어 있다고 합시다. 디스크 4에는 이 숫자들을 XOR 연산한 결과인 패리티 값이 저장됩니다.
만약 디스크 2가 고장 나서 숫자 7을 잃어버렸다면, 나머지 숫자들과 패리티 값을 이용해 잃어버린 숫자 7을 다시 계산해 복구할 수 있습니다.
이처럼 RAID 5는 하나의 디스크가 고장 나도 데이터를 잃지 않고 복구할 수 있게 해주는 방식입니다.
✅ RAID 6
출처 : RAID 6 Image
RAID 6는 RAID 5와 매우 유사한 방식이지만, 패리티(parity) 블록이 두 개의 드라이브에 나누어 저장된다는 점이 가장 큰 차이점입니다. 또한, RAID 5에서 사용되는 XOR 연산 대신에 Reed-Solomon 부호라는 더 복잡한 방식으로 패리티를 생성합니다.
RAID 6는 최소 4개의 드라이브로 구성해야 하며, 동시에 두 개의 드라이브에 장애가 발생해도 데이터를 복구할 수 있다는 강력한 내결함성을 가지고 있습니다. 읽기 성능은 RAID 5와 비슷하지만, 패리티 블록이 하나 더 추가되기 때문에 쓰기 성능은 RAID 5보다 느린 편입니다.
이러한 특징 때문에 RAID 6는 읽기 위주의 트랜잭션이 많은 웹 서버에 적합하며, 쓰기 작업이 빈번한 무거운 데이터베이스에는 적합하지 않습니다.
💡 Reed-Solomon 부호란? 내부 구현은 아직 정확히 공부하지 않았지만, 데이터가 손상되거나 일부가 사라져도, 이 코드를 통해 원래 데이터를 복원할 수 있게 해주는 알고리즘입니다.
하나 예시를 들어보겠습니다.
4개의 디스크로 RAID 6를 구성했다고 가정해봅시다. 이때, 데이터 블록뿐 아니라 두 종류의 패리티 정보가 각각 다른 두 디스크에 나누어 저장됩니다.
예를 들어, 디스크 1과 디스크 2에는 데이터가 저장되어 있고, 디스크 3과 디스크 4에는 두 가지 다른 패리티 정보가 저장됩니다. 만약 디스크 2와 디스크 4가 동시에 고장 나더라도, 나머지 디스크에 저장된 데이터와 두 종류의 패리티 정보를 이용해 손실된 데이터를 모두 복구할 수 있습니다.
이처럼 RAID 6는 한 번에 두 개의 디스크 장애까지 견딜 수 있어, 더 높은 안정성이 필요한 환경에서 많이 사용됩니다.
✅ RAID 10
출처 : RAID 10 Image
RAID 10은 RAID 0과 RAID 1의 장점을 결합한 시스템입니다. ( 그래서 RAID 10 이 아니라, RAID 1 + 0 이라고 보는게 맞습니다 :D ) 모든 데이터 블록에 대해 복제본을 다른 드라이브에 유지하면서, 전체 데이터 블록을 서로 두 개 이상의 드라이브에 나누어 분배하는 방식입니다. 최소 4개의 드라이브가 필요합니다.
RAID 10은 RAID 0 수준의 빠른 속도와 RAID 1 수준의 높은 내결함성을 동시에 보장합니다. 하나의 드라이브에 장애가 발생해도 복제본이 유지된 다른 드라이브를 통해 데이터를 복구할 수 있습니다. 다만, 용량 측면에서는 RAID 1과 동일하게 50%만 활용할 수 있어, RAID 5나 RAID 6에 비해 저장 공간 효율성이 떨어지고 비용이 더 높다는 단점이 있습니다.
✅ RAID 특징들을 간단히 표로 정리
특징
RAID 0
RAID 1
RAID 5
RAID 6
RAID 10
최소 드라이브 수
2
2
3
4
4
내결함성
없음
단일 드라이브 장애 가능
단일 드라이브 장애 가능
두 개 드라이브 장애 가능
각 서브어레이별 1개 장애 가능
읽기 성능
높음
중간
낮음
낮음
높음
쓰기 성능
높음
중간
낮음
낮음
중간
용량 활용도
100%
50%
67% ~ 94%
50% ~ 88%
50%
주요 사용처
고성능 워크스테이션, 실시간 데이터 처리
운영체제, 트랜잭션 데이터베이스
데이터 웨어하우스, 웹서버, 아카이빙
데이터 백업, 고가용성 서버
빠른 데이터베이스, 파일 서버, 애플리케이션 서버
내결함성과 성능을 모두 고려한다면 RAID 5, RAID 6, RAID 10 중에서 선택할 수 있습니다. 다만, RAID 10은 용량 손실이 크고 비용이 많이 들기 때문에, 현실적으로는 RAID 5를 가장 많이 선택하는 편입니다.
🐘 3.3. Hadoop’s Eraser Coding
이제 하둡에서 Erasure Coding이 어떻게 이루어지는지 공부해보겠습니다.
기본적으로 하둡은 Replication을 위한 복제 데이터를 2개까지 복사해둡니다. 이는 장애 대응에 효과적이라는 장점이 있지만, Disk 활용 면에서는 최악이라고 할 수 있습니다. 왜냐하면, 클러스터 환경을 통틀어 1TB 정도 공간이 있다고 가정한다면, Replication을 위한 복제본까지 담기 위해, 실제 데이터는 333.33GB밖에 담지 못하게 되기 때문입니다.
이러한 문제를 해결하기 위해 Hadoop V3부터는 Erasure Coding 방법을 기용하였습니다.
RAID는 하드웨어 방식과 소프트웨어 방식으로 나뉘는데, 하드웨어 RAID는 별도의 RAID 컨트롤러 카드를 통해 구현됩니다. 이 컨트롤러가 패리티 계산이나 읽기·쓰기 작업을 전담하기 때문에 시스템의 CPU에 부담을 주지 않고 빠른 성능을 제공하는 장점이 있습니다. 하지만 컨트롤러 카드 자체가 고가라는 단점이 있습니다.
반면 소프트웨어 RAID는 기존 컴퓨터의 구조를 그대로 사용하면서 디스크만 추가하면 별도의 하드웨어 없이도 RAID를 구현할 수 있어 비용을 절감할 수 있습니다. 하지만 모든 작업이 CPU 리소스를 공유하기 때문에 다른 작업들과 리소스를 나누게 되어 전반적인 처리 속도가 느려질 수 있습니다.
출처 : Hadoop Erasure Coding Image 1
💡 중요 💡
N: 몇개의 chunk 로 나눌지
K: 몇개의 parity 로 구성할지
RS(N,K) 로 표현함.
Hadoop의 Erasure Coding은 RAID 방식을 소프트웨어로 구현한 기술입니다. RAID 5나 RAID 6처럼 패리티를 이용해 데이터를 복구하는 방식이지만, 패리티를 계산하는 방식에서 차이를 보입니다. 데이터를 여러 개의 조각으로 나누고, Reed-Solomon과 같은 알고리즘을 사용해 패리티 데이터를 생성합니다. 이 방식은 데이터 보호 수준을 유연하게 설정할 수 있다는 점에서 기존 RAID와 차별화됩니다. 예를 들어, n개의 블록으로 데이터를 분할하고, 여기에 대해 k개의 패리티 블록을 추가하면, 총 n+k개의 블록 중 최대 k개의 블록이 손실되더라도 n개의 데이터 블록이 남아있다면 원본 데이터를 복구할 수 있습니다. 이러한 구조는 일반적으로 RS(n, k) 형태로 표현됩니다.
출처 : Hadoop Erasure Coding Image 2
위 그림은 Hadoop에서 Erasure Coding 이 적용되기 전과 후의 블록 구조 차이를 보여줍니다.
즉, 하나의 논리적 블록(Logical Block)을 기반으로 패리티(parity)를 구성하는 방식의 변화를 설명한 것입니다.
참고로 논리적 블록 크기 자체는 Erasure Coding 가 적용되더라도 바뀌지 않습니다.
먼저, Contiguous Block 구조는 하나의 논리 블록이 하나의 물리 블록(Storage Block)에 그대로 저장되는 방식입니다.
이 구조에서는 하나의 파일을 순차적으로 읽을 수 있고, 구현이 간단하다는 장점이 있습니다.
만약 Erasure Coding 을 적용해야 한다면 데이터를 조각내어 여러 디스크에 분산시켜야 하므로, 이렇게 단일 블록에 저장하는 구조에서는 병렬 처리나 패리티 분산 저장이 어렵습니다. 단일 블록 단위로만 처리되기 때문에 또한 병렬 처리나 IO 분산이 불가능합니다.
반면, Striped Block 구조는 하나의 논리 블록을 더 작은 단위인 셀(cell)로 쪼갠 다음,
이 셀들을 stripe(여러 셀로 구성된 집합)로 구성해, 여러 개의 스토리지 블록 세트에 라운드로빈 방식으로 나눠 저장합니다.
쉽게 말하면, 데이터를 얇게 썰어서 여러 디스크에 고르게 분산시켜 저장하는 것입니다.
읽을 때도 마찬가지로, 하나의 논리 블록을 구성하는 여러 셀을 병렬로 동시에 읽을 수 있기 때문에,
읽기 속도(read performance)가 기존 구조보다 더 빨라질 수 있습니다.
이러한 방식 덕분에 단일 블록에 대해서도 병렬 처리가 가능해지므로,
성능 향상이라는 측면에서도 효과가 큽니다.
✅ Erasure Coding 의 장점
Erasure Coding의 가장 큰 장점은 데이터 복구 능력을 유지하면서도 저장 공간을 훨씬 더 효율적으로 사용할 수 있다는 점입니다.
기존의 Hadoop은 장애 대비를 위해 하나의 블록을 3개까지 복제(3-way replication) 하다 보니,
전체 저장 공간의 2/3가 복제본 저장에 소모되는 비효율이 있었습니다.
하지만 EC를 도입하면, 예를 들어 RS(6,3)처럼 6개의 데이터 블록과 3개의 패리티 블록을 사용해 총 9개의 블록을 구성하게 됩니다.
이 경우 3개의 블록이 손상되더라도 나머지 블록으로 원래 데이터를 복원할 수 있고,
전체 저장 공간의 활용률도 훨씬 높아지게 됩니다.
출처 : Hadoop Erasure Coding 비교 표
해당 이미지를 보면 알 수 있듯, RS(10, 4) 로 구현하면, 스토리지의 40%만을 복원을 위해 쓰게 되지만, 기존 방식대로 하게 된다면 200%를 복원을 위해 사용하게 됩니다.
이러한 차이를 극명하게 볼 수 있는 표라고 할 수 있습니다.
출처 : EC 적용 전 후에 대한 Read 성능 지표
또한 앞서 설명한 Striped Block 구조 덕분에 병렬 처리가 가능해지므로,
파일을 읽는 성능이 기존보다 더 개선되는 결과도 함께 얻을 수 있습니다.
해당 이미지는 다른 개발자가 직접 테스트한 성능 지표 참고자료입니다.
✅ Erasure Coding 의 단점
물론 단점도 존재합니다.
RAID에서와 마찬가지로, 단순 복제 방식에 비해 Erasure Coding 은 더 많은 계산이 필요합니다.
데이터를 저장할 때는 패리티를 계산하는 인코딩(encoding) 작업이 필요하고,
손상된 데이터를 복구할 때는 디코딩(decoding) 작업이 필요하기 때문에,
특히 쓰기 성능(write performance) 면에서는 기존보다 느려질 수 있습니다.
하지만 이 단점도 완전히 해결 불가능한 것은 아닙니다.
Intel에서 제공하는 ISA-L encoder 라이브러리 덕분에,
패리티 계산 속도가 개선되어 성능 저하를 어느 정도 줄일 수 있었습니다.
실제로 측정해보면, 쓰기 성능은 약 30% 정도만 감소하는 것으로 나타났습니다.
출처 : 100GB 데이터셋에 대한 쓰기 성능 지표
해당 성능 지표를 보면 알 수 있듯 ISA-L(인텔의 인코딩/디코딩 라이브러리) 사용 시 쓰기 성능이 크게 개선됩니다. 패리티가 많아질수록 인코딩 부담이 커져 기본적으로 쓰기 시간이 늘어나지만, ISA-L 덕분에 이를 상당 부분 줄일 수 있습니다. 따라서, 하둡에서 Erasure Coding을 쓸 때 ISA-L 같은 하드웨어 최적화 라이브러리가 성능 병목을 완화하는 데 매우 중요합니다.
🐘 3.4. FIFO Queue vs Fair Call Queue
Hadoop과 같은 분산 시스템에서는 여러 클라이언트가 동시에 서버(NameNode, DataNode 등)에 요청을 보냅니다.
이러한 요청들은 곧바로 처리될 수 없고, 잠시 저장해두었다가 순차적으로 처리해야 하는 상황이 자주 발생합니다.
이를 위해 사용하는 구조가 바로 Call Queue (요청 큐) 입니다.
💡 요청 큐는 클라이언트로부터 들어온 작업 요청을 임시로 보관하고, 서버의 가용한 리소스에 맞춰 하나씩 꺼내 처리하기 위한 대기열 구조입니다.
✅ FIFO Queue란?
출처 : FIFO Queue
FIFO (First-In-First-Out) Queue는 먼저 들어온 요청을 먼저 처리하는 방식의 큐(queue)입니다.
Hadoop에서 Fair Call Queue가 도입되기 전까지는 클라이언트의 모든 요청을 이 단일 FIFO 큐에 저장한 뒤, 요청 도착 순서대로 처리하는 구조가 사용되었습니다.
설정은 core-site.xml 에서 할 수 있으며 다음과 같습니다. ( * 설정이 없을 경우 기본으로는 FIFO Queue 를 활용합니다. )
<property>
<name>ipc.server.callqueue.class</name>
<value>org.apache.hadoop.ipc.FifoCallQueue</value>
<description>RPC 요청 처리 시 FIFO Queue를 사용하도록 설정</description>
</property>
클라이언트로부터 작업 요청이 도착하면, Reader Thread가 이 요청을 하나의 FIFO 큐에 저장합니다. Handler가 작업을 수행할 준비가 되면, 이 큐에서 맨 앞에 있는 요청을 하나씩 꺼내어 처리합니다.
작업의 예시로는 정보 조회나, 생성, append 작업, 파일리스트 조회(대규모의 경우 병목 발생), 변경 등이 있습니다. 이는 애플리케이션을 통한 작업이든, hdfs dfs 명령어를 통한 작업이든 동일합니다.
이러한 방식은 단순하고 구현이 쉬우며, 요청 순서를 그대로 유지할 수 있다는 장점이 있습니다.
하지만, 분산 시스템 환경에서는 심각한 단점을 가지고 있어, 결과적으로 공정성과 성능 문제가 발생했습니다.
✅ FIFO Queue 의 단점
FIFO 구조의 가장 큰 문제는, 특정 클라이언트가 다량의 요청.. 혹은 무거운 요청을 전송할 경우 큐를 사실상 점령하게 된다는 것입니다. 이러한 heavy user로 인해 다른 사용자들의 요청은 뒤로 밀려, 전체 시스템의 응답 시간이 심각하게 지연됩니다.
⛔️ 예: 하나의 유저가 수천 개의 요청을 보낼 경우, 다른 모든 유저의 요청은 해당 요청 뒤에 대기하게 되어 수백 ms 이상의 지연 발생
또한, Hadoop의 NameNode는 파일 시스템 메타데이터를 관리하는 핵심 구성요소로, 클러스터의 모든 read, write, create, delete 요청이 이 노드를 거칩니다. 그런데 이러한 FIFO Queue 를 사용하게 되면, 요청들을 병렬로 처리할 수 없고, 모든 요청이 하나의 줄에서 순차적으로 처리되기 때문에 병목이 매우 심각하게 발생합니다.
실제로 과거 하둡을 운영하는 업체들은 수많은 클러스터에서 수 ms~수백 ms 단위의 응답 지연이 빈번히 발생했으며, 이는 전체 시스템의 성능 저하로 이어졌습니다.
그럴경우 과거에는 큐를 독점한 사용자 요청을 강제로 종료(kill) 하는 방식으로 문제를 해결하려 했습니다. 하지만, 그렇게 kill 을 해버리면, 이미 처리 중이던 요청을 강제로 종료하면서 데이터 무결성 손상이 생기거나, 종료 도중 오류 발생 시 재시도 실패, 그리고 심한 경우 클러스터 전체가 수 시간 동안 멈추는 상황까지 발생하였습니다.
출처 : FIFO Queue 의 문제점 비교 통계
그리고 가장 큰 문제는 대량 요청이 사용자의 잘못된 MapReduce 작업에 의해 자주 발생했다는 점입니다. 의도하지 않았더라도, 이러한 작업은 위 그림처럼 과도한 요청을 발생시켜 시스템 전체에 부하를 유발합니다.
더 나아가, 의도적으로 이러한 작업을 수행할 경우 DDoS 공격처럼 시스템을 마비시키는 악의적인 행위도 가능해집니다. 따라서, 단순한 FIFO 처리 방식은 공정성 결여뿐만 아니라, 보안 측면에서도 취약하다는 문제가 있습니다.
💡 DDos 란?: 분산 서비스 거부 공격으로, 여러 대의 컴퓨터(분산된 여러 공격자)가 동시에 특정 서버나 네트워크에 대량의 요청을 보내서 정상적인 서비스가 정상적으로 작동하지 못하도록 방해하는 공격입니다.
✅ Fair Call Queue 란?
출처 : Fair Call Queue
Hadoop에서 기존의 FIFO Queue가 가진 한계를 극복하기 위해, 라우터에서 사용하는 QoS(Quality of Service) 기능에서 영감을 받아 FIFO Queue를 Fair Call Queue로 교체하였습니다.
QoS는 네트워크 장비에서 트래픽을 관리해 각 서비스가 적절한 품질을 유지하도록 보장하는 기술입니다. 이를 Hadoop의 요청 처리에 적용하여, 클라이언트별 요청이 공정하게 처리되고, 특정 클라이언트의 과도한 요청으로 인해 전체 서비스가 지연되는 문제를 해결하고자 하였습니다.
설정은 core-site.xml 에서 다음과 같이 할 수 있습니다.
<property>
<name>ipc.server.callqueue.class</name>
<value>org.apache.hadoop.ipc.FairCallQueue</value>
<description>RPC 요청 처리 시 Fair Call Queue를 사용하도록 설정</description>
</property>
Fair Call Queue는 크게 scheduler, multiplexer , 그리고 multi-level queue 이렇게 세 가지 구성 요소로 이루어져 있습니다.
RPC Scheduler
각 클라이언트별 큐에 담긴 요청들을 적절한 비율로 선택하여 처리하는 역할을 합니다.
클라이언트 간 공정성을 보장하고, 자원을 효율적으로 분배하여 응답 지연을 최소화합니다.
Multi-level Queue
각 클라이언트별로 분리된 요청들이 들어가는 여러 개의 큐로, 클라이언트마다 독립적인 대기열을 가집니다.
이렇게 분리함으로써, 한 클라이언트의 요청이 다른 클라이언트의 요청 처리에 영향을 주지 않도록 합니다.
총 4단계의 큐로 구성되었습니다. (priority 0 ~ 3)
RPC Multiplexer
여러 클라이언트로부터 들어오는 요청들을 받아 각각의 클라이언트별 큐로 분배하는 역할을 합니다.
즉, 요청을 한 곳에 몰아두지 않고 각 사용자별로 요청을 분리하여 관리합니다.
이러한 구조 덕분에 Fair Call Queue는 특정 사용자의 과도한 요청이 전체 시스템 성능 저하를 초래하는 문제를 효과적으로 완화하며, Hadoop 클러스터의 안정성과 응답성을 높였습니다.
✅ Fair Call Queue - RPC Scheduler
클라이언트의 RPC 요청이 Listen Queue에 도착하면, 여러 개의 Reader Thread가 요청을 RpcScheduler에 전달합니다. RpcScheduler는 요청을 여러 개의 Priority Queue로 분리하여 저장합니다. 각 요청은 우선순위(priority)를 계산한 뒤, 해당 Priority Queue에 할당됩니다. RPC Scheduler는 pluggable 구조로 되어 있어, 다양한 구현체를 선택할 수 있습니다.
RPC Scheduler 의 기본 구현체는 DecayRpcScheduler입니다. ( 깃허브 코드 )
DecayRpcScheduler는 각 사 사용자마다 얼마나 많은 요청을 보내는지를 추적하고, 이 정보를 기준으로 우선순위를 부여합니다. 다만, 오래 전에 보낸 요청은 중요도를 줄이기 위해 시간에 따라 수치를 감소시키는(decay) 방식으로 처리합니다.
동작 과정을 풀어서 설명해보겠습니다.
먼저 사용자가 RPC 요청을 보낼 때마다, 그 사용자에 대한 요청 횟수(count)를 기록합니다. 그 후 5초마다(sweep period) 모든 사용자에 대해 요청 수를 다시 계산합니다. 이 때 순히 요청 수를 더하는 것이 아니라, 기존 요청 수에 Decay Factor를 곱합니다. 예를 들면, 기존 요청 수가 10이고 decay factor가 0.5라면 10 × 0.5 를 해서 5 가 됩니다.
위에서 Decay 된 값에 이번 sweep 주기 동안 새롭게 들어온 요청 수를 더합니다. Decay 된 값이 5이고, 새 요청이 4건이면 5 + 4 해서 9 가 됩니다. 그리고 모든 사용자의 decay된 요청 수를 합산하여 전체 요청량을 구합니다. 그 중 각 사용자가 차지하는 비율에 따라 우선순위(priority)를 부여합니다.
계산된 우선순위는 캐시에 저장되어 다음 sweep 때까지 유지되며, 요청이 들어올 때는 이 priority를 기준으로 적절한 priority queue에 분류됩니다. 만약 새 사용자가 등장하여 요청을 보낸다면, 이전 캐시가 없기 때문에 on-the-fly로 우선순위를 계산합니다. 이때도 전체 요청 대비 비율을 따져서 임시로 priority가 부여됩니다.
다음은 예시를 보겠습니다. Sweep 주기는 5초, Decay Factor 는 0.5로 잡은 상태에서 3명의 User 클라이언트로부터 요청이 들어오는 상황입니다.
📌 요청 수 변화 및 우선순위 계산 예시
시간(초)
사용자
이전 요청 수
Decay 적용 후
새 요청 수
현재 요청 수 (Decay + 새 요청)
전체 합
전체 대비 비율
Priority
0
userA
0
-
10
10
24
41.7%
2
0
userB
0
-
8
8
33.3%
2
0
userC
0
-
6
6
25.0%
1
5
userA
10
5
4
9
18.75
48.0%
2
5
userB
8
4
2
6
32.0%
2
5
userC
6
3
0.75
3.75
20.0%
1
10
userA
9
4.5
0
4.5
10.125
44.4%
2
10
userB
6
3
0
3
29.6%
2
10
userC
3.75
1.875
0.75
2.625
25.9%
1
📌 우선순위 기준 (기본 설정)
전체 점유율 기준
Priority
설명
≥ 50%
3
최하 우선순위
25% ~ 50%
2
중간 사용자
12.5% ~ 25%
1
일반 사용자
< 12.5%
0
최상 우선순위
참고로 Sweep 주기와 Decay Factor 는 core-site.xml 에서 설정해줄 수 있습니다.
<property>
<name>ipc.server.callqueue.sweep.period.ms</name>
<value>5000</value>
<description>Sweep 주기를 밀리초 단위로 설정 (기본 5초)</description>
</property>
<property>
<name>ipc.server.callqueue.decay.factor</name>
<value>0.5</value>
<description>Decay Factor 값을 설정 (기본 0.5)</description>
</property>
✅ Fair Call Queue - Multi-level Queue
Multi-level Queue는 요청(Call)을 우선순위나 중요도에 따라 여러 개의 레벨(계층) 큐로 나누어 관리하는 큐 구조입니다. 높은 우선순위 큐의 요청이 먼저 처리되고, 만약 해당 큐에 요청이 없으면 낮은 우선순위 큐의 요청을 처리합니다.
✅ Fair Call Queue - RPC Multiplexer
RPC Multiplexer 는 여러 우선순위 큐에서 요청을 효율적으로 처리하기 위한 컴포넌트입니다.
여러 우선순위 큐(예: high-priority, low-priority)를 비교하여 요청을 선택합니다. 이 때 높은 우선순위 큐를 더 자주 처리하여 중요한 요청을 우선적으로 처리합니다. 동시에, 낮은 우선순위 큐도 일정 비율로 처리하여 starvation(처리 지연)을 방지합니다.
Multiplexer 는 현재 구현체로 WeightedRoundRobinMultiplexer가 하드코딩되어 있습니다. ( 깃허브 코드 )
WeightedRoundRobinMultiplexer 는 각 우선순위 큐별로 가중치(weight)를 부여하고, 그 비율에 따라 요청을 처리합니다.
기본 가중치는 다음과 같습니다
우선순위 레벨
가중치 (Weight)
처리 방식
Highest priority (0)
8
큐에서 8개의 요청을 연속으로 꺼내서 처리
2nd highest priority (1)
4
큐에서 4개의 요청을 꺼내서 처리
3rd highest priority (2)
2
큐에서 2개의 요청을 꺼내서 처리
Lowest priority (3)
1
큐에서 1개의 요청을 꺼내서 처리
이 방식으로 높은 우선순위 큐에서 더 많은 요청을 먼저 처리하지만, 낮은 우선순위 큐도 적절히 처리하여 처리 지연 현상(starvation)을 방지 합니다.
이러한 흐름을 간단히 정리하면,
Fair Call Queue 가 도입되고, RPC Scheduler 는 Decay Factor 를 통해 우선순위를 나누어 총 4개의 Multi-level Queue 로 작업을 넣어주고, RPC Multiplexer 는 해당 큐에서, 우선순위에 따라 가중치를 부여하여 적정 수의 작업을 꺼내 처리 할 수 있게 한다고 할 수 있습니다.
✅ Fair Call Queue - Backoff
Backoff는 priority-weighting 메커니즘에 추가로 동작하는 기능입니다.
BackOff 는 요청을 즉시 처리하지 않고 클라이언트에게 예외(Exception)를 던지는 방식입니다. 전형적으로 request queue가 가득 찼을 때 발생합니다.
또한, backkoff by response time 기능을 제공하기도 한느데 이는 높은 우선순위 큐의 요청 처리가 너무 느려질 경우, 낮은 우선순위 큐의 요청을 backoff 합니다.
예를 들어, priority 1의 response time threshold가 10초로 설정되어 있는데, 해당 큐의 평균 응답 시간이 12초라면, priority 2 이하의 요청은 backoff exception을 받습니다. 반면에 priority 0과 1의 요청은 정상 처리됩니다.
이 방식은 시스템에 부하가 높아져 높은 우선순위 사용자까지 영향받을 때, heavy user에게 backoff를 강제하는 효과가 있습니다. 따라서 Hadoop Client를 사용하는 어플리케이션 개발자는 backoff에 의한 예외가 자주 발생한다면, 자신의 요청이 시스템에 과도한 부하를 주고 있지 않은지 점검할 필요가 있습니다.
<!-- Backoff 기능 활성화 -->
<property>
<name>ipc.decay.scheduler.enable.backoff</name>
<value>true</value>
</property>
<!-- 응답시간 기반 Backoff 기준 활성화 -->
<property>
<name>ipc.decay.scheduler.enable.response.time.backoff</name>
<value>true</value>
</property>
<!-- Priority 1의 응답시간 Backoff 기준 (초 단위) -->
<property>
<name>ipc.decay.scheduler.response.time.threshold.priority.1</name>
<value>10</value>
</property>
✅ Fair Call Queue - Identity Provider
위에서 설명한 RPC Scheduler 는 사용자 단위로 요청 수, 우선순위, decay, backoff 등을 관리합니다. 때 “사용자”를 어떻게 정의할지 정하는 것이 바로 IdentityProvider입니다. 예를 들어, userA, userB의 요청을 분리하고 싶다면 user.name 기준으로 식별해야 합니다. 반면 동일 사용자가 다양한 서비스나 IP에서 오는 요청을 분리하고 싶을 수도 있습니다.
기본은 UserIdentityProvider로, 단순히 client의 username 으로 구분합니다.
✅ Cost based Fair Call Queue ?
Fair Call Queue는 사용자 간의 요청 수를 기준으로 priority 처리를 시도합니다. 하지만, 요청이 얼마나 비싼 작업인지에 대한 고려는 부족합니다. 결과적으로, 요청 수는 같지만 시스템 자원을 과도하게 사용하는 사용자가 공정성을 해칠 수 있습니다.
예를 들어, 다음 3가지 요청을 생각해봅시다. getFileInfo 요청 1000개와, listStatus 요청 1000개 (큰 디렉토리 대상), 그리고 mkdir 요청 1000개 가 있습니다.
getFileInfo, listStatus는 단순 조회라서 가볍습니다. 반면, mkdir은 파일시스템에 직접 변경을 가하고, exclusive lock이 필요해서 훨씬 무겁습니다.
하지만 Fair Call Queue는 단순히 “1000번 요청”만 보니, 이 차이를 구분 못합니다.
하둡은 이 문제를 해결하기 위해, 요청 하나하나가 시스템 자원을 얼마나 사용하는지를 계산해서 우선순위에 반영하는 Cost-based Fair Call Queue 기능을 도입했습니다. 즉, 요청 수가 아니라 요청을 처리하는 데 걸린 시간을 기준으로 판단합니다. 무거운 요청을 자주 보내는 사용자는 자동으로 낮은 우선순위를 받는다고 볼 수 있습니다.
Cost based Fair Call Queue 는 요청 처리에 걸린 실제 시간 + 사용된 lock의 종류에 따라 가중치를 부여합니다.
단순 작업: lock 없이 실행된 시간 → 그대로 반영 (×1)
공유 락(shared lock)이 걸린 작업: ×10 가중치
배타 락(exclusive lock)이 걸린 작업: ×100 가중치
단, 요청을 기다리는 시간(queue 대기)이나 락을 얻기까지 걸리는 시간은 계산에 포함되지 않습니다.
저도 이 부분을 공부할 때 이해가 좀 되지 않아서, 표로 정리해보았습니다.
먼저, 3명의 사용자가 각기 다른 요청을 한 경우 아래 표와 같이 됩니다.
사용자
요청 종류
실행 시간(ms)
Lock 종류
가중치
Cost 계산 (시간 × 가중치)
userA
getFileInfo
10
없음
×1
10
userB
listStatus
20
Shared Lock
×10
200
userC
mkdir
15
Exclusive Lock
×100
1500
그러면, 다음 표처럼 우선순위를 계산합니다. (비중 기준)
사용자
Cost
전체 비중 (%)
우선순위 (기본 기준)
userA
10
0.58%
0 (최상위)
userB
200
11.7%
0 (최상위)
userC
1500
87.7%
3 (최하위)
그러면, 요청 반복 시 누적 Cost 변화가 일어납니다. (이 때, Decay 는 고려하지 않습니다.)
사용자
요청 1회당 Cost
요청 횟수
누적 Cost
userA
10
2회
20
userB
200
2회
400
userC
1500
2회
3000
최종적으로는 아래 표와 같이 됩니다.
사용자
전체 비중 (%)
우선순위
userA
0.58%
0
userB
11.7%
0
userC
87.7%
3
설정은 core-site.xml 에서 가능합니다. 이 설정을 추가하면 Fair Call Queue가 요청의 실제 비용까지 고려해서 사용자 우선순위를 조정할 수 있습니다.
<property>
<name>ipc.costprovider.class</name>
<value>org.apache.hadoop.ipc.WeightedTimeCostProvider</value>
</property>
이러한 방법을 통해, FIFO Queue 의 단점을 보완하고, 무엇보다 DoS 공격에 대해서 일정 수준 이하의 낮은 latency를 보장할 수 있게 되었습니다.
◈
✏️ 결론
이번 포스팅에서는 하둡의 핵심 이론들을 알아보았습니다. HDFS 의 아키텍처부터 시작해서 HA 구성, ONN 그리고 EC 와 Fair Call Queue 까지.. 회사에서 단순히 하둡을 다루는 거 만으로는 알 수 없었던 방대한 지식들을 공부 할 수 있었습니다.
하둡은 단순한 분산 파일 시스템 이상의 의미를 지닙니다. 수많은 요청과 데이터가 오가는 대규모 환경에서 어떻게 안정성과 성능을 유지하는지, 어떤 원리와 기법들이 그 바탕에 있는지를 알게 되면, 비로소 하둡을 ‘잘 다룬다’는 말이 어떤 의미인지 깊이 공감할 수 있습니다.
이번 학습을 통해 알게 된 여러 메커니즘들은 단순히 기술적인 지식에 머무르지 않고, 실제 현장에서 마주하는 문제들을 해결하는 강력한 무기가 되어줄 것입니다.
앞으로도 하둡과 같은 대규모 시스템을 다루면서, 이론과 실무의 간극을 줄이고, 지속적으로 성장해 나가야겠다는 다짐을 하게 되었습니다.
이 포스팅을 읽어주신 여러분도 이 글을 통해 하둡에 대한 이해가 한층 더 깊어지고, 복잡한 시스템 속에서 나만의 통찰을 얻어가시길 바랍니다.
그럼 다음 포스팅에서는 YARN 을 공부하고 정리해보겠습니다.
◈
📚 공부 참고 자료
📑 1. 패스트 캠퍼스 - 한 번에 끝내는 데이터 엔지니어링 초격차 패키지 Online.
📑 2. HDFS NameNode RPC QoS 최적화
📑 3. Hadoop이란 무엇입니까?
📑 4. 하둡 3.0에선 무엇이 달라졌을까? ( Erasure Coding 을 이해하기 좋은 글 )
📑 5. OREILLY 하둡 완벽 가이드 4판
📑 6. HDFS 아키텍처
📑 7. NameNode High Availability with Quorum Journal Manager
📑 8. RAID 에 대하여
📑 9. 하둡과 주키퍼
-
📘 분산 시스템의 이해와 하둡의 등장 배경
오늘날 데이터는 폭발적으로 증가하고 있으며, 이를 효과적으로 저장하고 처리하는 기술의 중요성도 함께 커지고 있습니다. 이러한 흐름 속에서 주목받은 기술 중 하나가 바로 하둡(Hadoop)입니다. 하둡은 대규모 데이터를 여러 대의 서버에 분산 저장하고 병렬로 처리할 수 있도록 지원하는 오픈소스 프레임워크로, 한동안 빅데이터 처리의 핵심 도구로 널리 활용되어 왔습니다.
최근에는 클라우드 기반의 데이터 처리 환경이 확산되고, Spark나 Flink와 같은 보다 유연하고 고성능의 기술들이 주류로 떠오르면서 하둡의 사용 빈도는 줄어들고 있는 추세입니다. 하지만 그럼에도 불구하고 하둡을 학습하는 일은 여전히 의미가 있습니다. 하둡은 분산 시스템의 구조적 이해를 바탕으로 동작하며, 이러한 개념은 현대의 다양한 데이터 처리 기술에 적용되는 핵심 원리이기 때문입니다. 즉, 하둡을 통해 분산 시스템의 개념을 익히고 데이터 처리의 흐름을 이해하는 것은 이후 다른 기술을 배우는 데에도 큰 도움이 됩니다.
특히 하둡은 단일 서버가 아닌 다수의 서버가 협력하여 작업을 수행하는 분산 시스템 기반 위에서 설계되었습니다. 따라서 하둡을 본격적으로 학습하기 전에 분산 시스템의 기본 개념과 원리를 먼저 익혀두는 것이 매우 중요합니다. 서버 간 데이터 공유, 장애 복구, 일관성 유지와 같은 핵심 개념들을 이해하면 하둡의 내부 동작뿐만 아니라 다양한 빅데이터 기술 전반에 대한 통찰을 얻을 수 있습니다.
이 글에서는 하둡을 왜 여전히 공부할 필요가 있는지, 그리고 그에 앞서 분산 시스템을 먼저 학습해야 하는 이유에 대해 살펴보겠습니다.
◈
🐘 1. 분산 시스템 등장 이전의 시대
컴퓨터는 처음 등장한 이후, 오랜 시간 동안 단일 시스템 안에서 모든 연산과 처리를 수행하는 구조로 발전해 왔습니다. 성능 향상을 위해 더 빠른 CPU, 더 많은 메모리, 더 큰 저장 장치가 꾸준히 개발되었으며, 이를 통해 다양한 문제를 해결할 수 있게 되었습니다. 하지만 데이터의 양이 기하급수적으로 증가하고, 실시간 처리의 중요성이 높아지면서 단일 시스템의 한계가 점점 뚜렷하게 드러나기 시작했습니다.
이 장에서는 본격적으로 분산 시스템의 개념을 살펴보기 전에, 분산 시스템이 왜 필요한지를 이해하기 위한 배경으로서 과거의 컴퓨팅 환경은 어떻게 발전해왔는지를 먼저 짚어보고자 합니다. 단일 시스템의 특징과 한계, 그리고 그 한계를 극복하기 위한 기술적 흐름을 살펴보며, 분산 시스템의 필요성이 어떻게 등장하게 되었는지 자연스럽게 연결해보겠습니다.
🐘 1.1. 컴퓨터는 어떻게 발전 해왔을까
✅ 1.1.1. 1930년대 컴퓨터의 시작
컴퓨터의 시작은 사람이 직접 계산해야 했던 반복적인 작업을 대신해주는 기계를 만드는 것에서 출발했습니다. 지금처럼 키보드나 모니터가 있는 형태는 아니었고, 마우스는커녕 화면조차 존재하지 않았습니다. 초기 컴퓨터는 연산 작업을 자동화하기 위한 전기 기계식 장치에 가까웠습니다.
그 대표적인 예가 1941년에 등장한 Z3 컴퓨터입니다. 독일의 콘라드 추제가 개발한 Z3는 세계 최초의 프로그래머블 컴퓨터로 알려져 있으며, 당시에는 전자식이 아닌 릴레이 기반의 전기 기계식 구조를 가지고 있었습니다. 하나의 문제를 해결하기 위해 기계의 배선을 물리적으로 바꾸거나, 명령을 수동으로 입력해야 했기 때문에 지금과는 비교할 수 없을 만큼 비효율적이었습니다.
이러한 초기 컴퓨터들은 주로 복잡한 계산이 필요한 군사적 작업에 활용되었습니다. 예를 들어, 포탄의 궤적을 계산하는 데 사람의 손으로는 한 시간 이상 걸리는 작업을, 기계는 몇 분 만에 처리할 수 있었습니다. 이는 계산 정확도와 속도 면에서 큰 진보였고, 이후 컴퓨터 기술 발전의 중요한 기반이 되었습니다.
✅ 1.1.2. 컴퓨터 구조의 중요한 전환점
1940년대에 들어서면서 컴퓨터의 구조는 중요한 전환점을 맞이하게 됩니다. 이 시기에 등장한 폰 노이만 아키텍처는 컴퓨터 설계의 패러다임을 완전히 바꾸어 놓았으며, 오늘날 대부분의 컴퓨터 구조에 기본이 되는 개념입니다.
1945년에 미국에서 개발된 ENIAC은 세계 최초의 범용 전자식 컴퓨터로, 본격적인 전자 계산기의 시대를 열었습니다. 하지만 ENIAC은 프로그램을 저장할 수 없는 구조였기 때문에, 새로운 작업을 수행하려면 일일이 배선을 변경해야 했습니다. 이는 마치 스마트폰에서 앱을 바꿀 때마다 내부 회로를 새로 납땜해야 하는 것과 같은 매우 번거롭고 비효율적인 방식이었습니다.
이러한 문제를 해결하기 위해 등장한 개념이 바로 ‘저장 프로그램 방식(stored-program concept)’입니다. 이 개념은 프로그램을 컴퓨터의 기억장치에 저장하고, CPU가 이를 불러와 실행하도록 구성된 구조를 의미합니다. 이로 인해 프로그램의 변경이 훨씬 유연하고 빠르게 이루어질 수 있게 되었으며, 컴퓨터가 지금처럼 다양한 작업을 손쉽게 수행할 수 있는 기반이 마련되었습니다.
출처 : Stored Program Image 출처
폰 노이만 구조는 프로그램도 데이터처럼 메모리에 저장해 처리하는 방식을 제안하였습니다. 이 구조의 가장 큰 특징은 프로그램과 데이터를 동일한 메모리 공간에 저장함으로써, 컴퓨터가 보다 유연하게 동작할 수 있게 되었다는 점입니다. 이를 통해 복잡한 작업을 손쉽게 수행할 수 있게 되었고, 사용자가 필요할 때마다 프로그램을 바꾸어 실행하는 이른바 ‘재프로그래밍’이 가능해졌습니다.
오늘날 사용되는 거의 모든 컴퓨터는 이 구조를 따르고 있습니다. 기본적으로 중앙처리장치(CPU), 기억장치(메모리), 그리고 입출력 장치로 구성되며, 명령어는 메모리에 저장된 순서대로 하나씩 불러와 실행됩니다. 이러한 처리 방식은 현대 컴퓨터 시스템의 표준적인 동작 원리가 되었고, 다양한 기술의 발전을 이끄는 토대가 되었습니다.
✅ 1.1.3. 개인 PC 와 프로그래밍 언어의 대중화
컴퓨터는 점차 연구실과 기업의 전유물에서 벗어나, 일반 가정과 개인의 손에 들어오게 되었습니다. 이러한 변화의 중심에는 마이크로프로세서의 등장과 IBM의 x86 아키텍처 기반 개인용 컴퓨터가 있었습니다. 이 시기는 컴퓨터가 대중화되는 결정적인 전환점이 되었으며, 이후 프로그래밍 언어의 발전에도 큰 영향을 미치게 됩니다.
1981년, IBM이 발표한 IBM PC는 인텔의 8086 마이크로프로세서를 기반으로 만들어졌습니다. 이 구조는 이후 데스크톱 PC의 표준이 되었고, 오늘날에도 여전히 많은 컴퓨터가 x86 아키텍처를 따르고 있습니다. x86 아키텍처는 CPU가 이해하는 명령어 집합 구조를 의미하며, 소프트웨어와 하드웨어 간의 호환성을 높이는 데 기여하였습니다.
이러한 하드웨어 기반 위에서 다양한 프로그래밍 언어가 발전하였습니다. 어셈블리 언어는 기계어보다 사람이 이해하기 쉬운 명령어로 구성되어 있었으며, CPU를 직접 제어할 수 있는 특징이 있었습니다. 이어서 C 언어나 Pascal과 같은 고급 언어들이 등장하면서, 프로그래밍은 더욱 직관적이고 효율적으로 변화해갔습니다. 이러한 언어들은 다양한 운영체제와 시스템에서 활용될 수 있었고, 컴퓨터 활용의 폭을 크게 넓히는 역할을 하였습니다.
Apple II, Commodore 64와 같은 개인용 컴퓨터가 보급되면서, 어린 시절부터 컴퓨터를 접하고 프로그래밍을 배우는 세대도 등장하게 되었습니다. 많은 개발자들이 이 시기에 처음 컴퓨터를 경험하며 프로그래밍에 입문하였고, 이는 현재 IT 산업의 성장에 큰 밑거름이 되었습니다.
💡 x86 아키텍처란?
x86 아키텍처는 인텔이 만든 8086 마이크로프로세서에서 시작된 CPU의 명령어 집합 구조(Instruction Set Architecture, ISA)를 이야기함.
쉽게 이야기하면, CPU가 어떤 명령어를 이해하고 처리할 수 있는지를 정의한 일종의 ‘언어 체계’.
✅ 1.1.4. Remote Procedure Call ( RPC ) 의 등장
컴퓨터는 인간보다 훨씬 빠른 속도로 명령어를 처리할 수 있는 기계입니다. 그렇기 때문에, 우리가 원하는 작업을 컴퓨터가 대신 처리해주기를 바란다면, 명확한 명령어만 입력해주는 것으로 충분합니다.
그런데 여기서 한 가지 의문이 생깁니다.
“반드시 내 컴퓨터에서만 모든 명령어를 처리해야 할까?”
만약 물리적인 제약을 넘어서, 다른 컴퓨터에서 실행 중인 프로그램도 내 컴퓨터처럼 자유롭게 사용할 수 있다면, 훨씬 더 많은 사람과 시스템이 효율적으로 작업을 수행할 수 있을 것입니다.
이러한 문제의식에서 등장한 개념이 바로 RPC(Remote Procedure Call)입니다.
RPC는 ‘원격 프로시저 호출’이라는 뜻을 가지고 있으며, 말 그대로 다른 컴퓨터에 존재하는 함수(프로시저)를 호출할 수 있도록 하는 기술입니다. 이 기술을 사용하면, 네트워크를 통해 원격에 있는 함수를 마치 내 컴퓨터에 있는 함수처럼 간단하게 호출할 수 있습니다.
// 일반 함수 호출
int result = add(3, 5);
// 실제로는 네트워크를 통해 원격 서버에 있는 'add' 함수를 호출
// 하지만 프로그래머는 로컬 호출처럼 사용
RPC는 이처럼 복잡한 네트워크 통신 과정을 감추고, 프로그래머가 익숙한 함수 호출 형태로 사용할 수 있도록 인터페이스를 추상화합니다. 개발자는 함수가 로컬에 있는지 원격에 있는지 구분하지 않고 코드를 작성할 수 있습니다.
RPC가 본격적으로 실용화된 것은 1980년대입니다. 특히 1984년, Sun Microsystems가 유닉스 환경에서 ONC RPC(Open Network Computing RPC)를 구현하면서 다양한 시스템에 적용되기 시작했습니다. 이 시기에 등장한 대표적인 구조가 바로 클라이언트-서버 아키텍처입니다.
클라이언트(Client): 요청(Request)을 보내는 측.
서버(Server): 요청을 받아 응답(Response)을 처리하는 측.
RPC는 이 구조 안에서 클라이언트가 서버의 함수를 호출하고 결과를 받는 방식으로 작동합니다. 이때 통신은 일반적으로 요청-응답(request-response) 패턴을 따릅니다.
✅ 1.1.5. 데이터베이스(Database)의 등장
컴퓨터가 단순한 계산을 넘어서 다양한 작업을 처리하게 되면서, 사람들은 더 많은 데이터를 더 빠르게 처리할 수는 없을까?라는 질문을 던지기 시작했습니다. 특히 네트워크 기술이 발전하여 여러 컴퓨터가 연결되고, 원격으로 프로그램을 호출할 수 있게 되자, 하나의 서버에 데이터를 모아두고 여러 사람이 동시에 접근하여 처리하는 환경이 가능해졌습니다.
이러한 시대적 배경 속에서 등장한 것이 바로 데이터베이스 시스템(Database System)입니다.
1970년대, IBM의 연구원이었던 E.F. Codd는 데이터를 구조화된 형태로 저장하고 관리할 수 있는 관계형 데이터 모델(Relational Model)을 제안합니다. 이 모델은 데이터를 테이블(행과 열)의 형태로 구성하여, 논리적으로 쉽게 관리할 수 있도록 설계되었습니다.
이후 이러한 개념을 바탕으로 발전한 시스템이 바로 관계형 데이터베이스 관리 시스템(RDBMS)입니다.
데이터를 표 형식으로 저장합니다.
SQL을 통해 데이터를 검색하고, 삽입하고, 수정하며, 삭제할 수 있습니다.
IBM은 이 모델을 바탕으로 1983년, 세계 최초의 상용 관계형 데이터베이스인 IBM DB2를 출시하며 데이터베이스 시장의 문을 열었습니다.
또한, SQL(Structured Query Language)은 데이터를 조작하고 관리하기 위한 언어로, 1986년 ANSI(미국표준협회)에 의해 표준화됩니다.
SQL의 표준화는 다양한 데이터베이스 시스템 간의 호환성과 통일성을 높이는 계기가 되었으며, 현재까지도 대부분의 관계형 데이터베이스에서 표준 언어로 사용되고 있습니다.
-- 예: 고객 테이블에서 이름이 'Hadoop' 이라는 고객을 조회
SELECT * FROM customers WHERE name = 'Hadoop';
이후 Oracle, MySQL, PostgreSQL 등 다양한 RDBMS가 등장하면서, 대량의 정형 데이터를 안정적으로 저장하고 처리하는 환경이 구축됩니다.
이 당시 데이터베이스는 하드웨어 자체의 성능을 늘리는 방식(Scale-Up)으로 처리량을 확장했습니다.
CPU, 메모리, 디스크 등을 업그레이드하는 식이라고 볼 수 있습니다.
하지만 이 방식은 비용이 많이 들고, 일정 이상으로는 확장이 어렵다는 단점이 극명했습니다.
이후 하둡이라는 분산 시스템을 사용하게 된 계기가 되기도 하였으며, 현재는 샤딩(Sharding) 이라는 기법을 활용하여 Scale-Out 형태로 성능을 늘리고 있습니다.
✅ 1.1.6. 3-Tier Architecture 의 등장
초기의 웹 서비스 구조는 매우 단순했습니다. 대부분의 시스템은 모든 기능을 하나의 서버에서 처리하는 모놀리식(Monolithic) 구조를 따랐습니다. 사용자의 요청을 받고, 비즈니스 로직을 처리하고, 데이터를 저장하는 모든 작업을 하나의 서버가 전담했던 것입니다.
이 방식은 소규모 시스템에서는 효율적일 수 있지만, 시간이 지나고 사용자 수가 급증하면서 다음과 같은 한계에 직면하게 됩니다.
✏️ 사용자 수가 증가할수록 처리 성능이 급격히 저하.
✏️ 모든 기능이 한 서버에 집중되어 있어 유지보수가 어려움.
✏️ 전체 시스템의 확장이 어려움. 특정 기능만 확장하는 것이 불가능하고, 전체 서버를 업그레이드해야 함.
이러한 문제를 해결하고자 등장한 것이 바로 3-Tier Architecture(3계층 아키텍처)입니다.
1990년대 중반, 클라이언트-서버 구조가 발전함에 따라 3-Tier Architecture가 도입되었습니다. 이는 시스템을 기능별로 세 개의 계층으로 나누어 설계하는 방식입니다. 각 계층은 독립적으로 운영되며, 서로 명확한 책임을 갖습니다.
▶ 1. Presentation Tier (프레젠테이션 계층)
사용자가 직접 접하는 UI 부분이며, 웹 브라우저, 모바일 앱 등의 형태로 사용자와 상호작용을 합니다.
▶ 2. Application Tier (로직 계층)
사용자의 요청을 처리하고, 비즈니스 로직을 실행하는 핵심 계층입니다.
▶ 3. Data Tier (데이터 계층)
데이터를 실제로 저장하고 관리하는 계층입니다.
이러한 구조를 도입하면 다양한 이점을 얻을 수 있습니다.
우선, 각 계층이 담당하는 역할이 명확히 분리되므로 유지보수가 훨씬 수월해집니다. 예를 들어, UI와 비즈니스 로직, 데이터 저장 영역을 각각 독립적으로 관리할 수 있어 문제 발생 시 빠르게 원인을 파악하고 수정할 수 있습니다.
또한, 시스템 확장성도 크게 향상됩니다. 특정 기능이나 계층에 부하가 몰릴 경우, 해당 부분만 선택적으로 확장하거나 보완하면 되기 때문에 전체 시스템을 재구성하지 않아도 됩니다.
뿐만 아니라, 각 계층은 독립적으로 배포할 수 있기 때문에, 하나의 기능을 수정하거나 교체할 때 다른 계층에 영향을 주지 않고 운영을 계속할 수 있습니다.
마지막으로, RPC(Remote Procedure Call)나 전문적인 데이터베이스 시스템(DBMS)과의 연계가 용이해져 계층 간 통신이 효율적으로 이루어지고, 다양한 외부 시스템과의 통합도 훨씬 수월해집니다.
3-Tier Architecture는 오늘날 대부분의 웹 애플리케이션 아키텍처의 기본이 되었으며, 이후 등장하는 N-Tier Architecture, 마이크로서비스(MSA) 아키텍처의 기반이 되기도 합니다.
✅ 1.1.7. Web-WAS-DB 구조의 등장
3-Tier Architecture가 개념적으로 정립된 이후, 이를 실질적으로 웹 환경에서 구현한 구조가 바로 Web-WAS-DB 구조입니다.
이 구조는 각각의 역할을 담당하는 세 가지 계층으로 구성되어 있습니다.
▶ 1. Web Server(웹 서버)
사용자의 요청을 가장 먼저 받아들이는 계층입니다.
HTML, CSS, JS, 이미지와 같은 정적인 콘텐츠를 처리하며, 단순한 요청은 웹 서버가 직접 응답을 반환합니다. 대표적인 웹 서버로는 Apache, Nginx 등이 있습니다.
▶ 2. WAS(Web Application Server, 웹 애플리케이션 서버)
비즈니스 로직을 수행하는 계층으로, 로그인, 회원가입, 데이터 조회, 결제 등 복잡한 처리를 담당합니다. 필요에 따라 데이터베이스와 통신하여 데이터를 가져오고, 처리 결과를 사용자에게 전달합니다. 대표적인 예로는 Tomcat, Spring Boot, JBoss 등이 있습니다.
▶ 3. Database(데이터베이스 서버)
데이터 저장과 관리를 담당하는 계층으로, 사용자 정보, 상품 정보, 로그 등 영속적인 데이터를 저장합니다. MySQL, PostgreSQL, Oracle, MongoDB 등의 시스템이 여기에 해당합니다.
실제 웹 서비스에서 발생하는 요청의 90% 이상은 정적인 콘텐츠에 대한 요청입니다.
공지사항, 이미지, 게시글 목록과 같이 변하지 않는 정보를 반복해서 요청하는 경우가 대부분입니다. 이러한 단순 요청조차 WAS와 DB를 거쳐 처리하게 되면 자원이 불필요하게 낭비되고, 응답 속도도 느려질 수 있습니다.
Web-WAS-DB 구조는 이러한 문제를 해결하기 위해 각 계층의 역할을 명확히 분리하고 있습니다.
정적인 요청은 웹 서버에서 빠르게 처리하며, WAS는 로직 처리가 필요한 요청만 담당합니다. DB는 필요한 경우에만 데이터를 조회하거나 저장하는 방식입니다.
또한 자주 요청되는 정적 응답은 캐시(Cache)로 미리 저장해두고, 동일한 요청이 반복될 경우 빠르게 응답할 수 있도록 하여 시스템의 전체 성능을 향상시킬 수 있습니다.
하지만 이 구조도 단점이 존재합니다.
모든 요청이 결국 WAS나 DB 같은 특정 계층을 거치기 때문에, 트래픽이 몰릴 경우 병목 현상이 발생하여 응답 속도가 느려질 수 있습니다. 초기에는 이러한 문제를 Scale-Up 방식, 즉 서버의 하드웨어 성능을 높이는 방법으로 해결하려 했지만, 점차 증가하는 트래픽과 복잡한 서비스 구조에는 한계가 있었습니다.
이러한 한계를 극복하기 위해 시스템은 수평 확장(Scale-Out) 방식으로 발전하게 되었습니다.
WAS는 로드 밸런서를 통해 여러 서버에 요청을 분산시키며 MSA 구조로 점차 변화해 갔으며, DB는 데이터를 나누어 저장하는 샤딩(Sharding)이나 하둡과 같은 분산 시스템을 도입하여 성능과 확장성을 확보하게 되었습니다.
🐘 1.2. 기존 시스템의 한계점
위에서 설명한 내용처럼 최초의 컴퓨터는 매우 거대하고 복잡한 구조를 가지고 있었으며, 프로그램을 실행하는 과정 또한 번거롭고 어려웠습니다. 이후 컴퓨터 기술의 발전은 이러한 물리적 크기를 줄이고, 프로그램을 보다 쉽게 작성하고 실행할 수 있는 환경을 만드는 방향으로 집중되었습니다.
이러한 발전 과정 속에서 등장한 대표적인 개념이 바로 위에서 설명한 폰 노이만 아키텍처와 x86 기반의 CPU 아키텍처입니다.
이들 개념을 통해 컴퓨터의 구조와 동작 방식이 표준화되었으며, 많은 시스템이 동일한 구조를 바탕으로 동작하게 되었습니다.
컴퓨터 구조가 표준화된 이후에는 하나의 컴퓨터 내부에서 더 빠른 CPU로 교체한다거나, 더 큰 메모리로 교체하는 등, 성능을 향상시키는 방식, 즉 Scale-Up 방식으로 발전이 이루어졌습니다.
하지만 온라인 서비스가 본격적으로 확산되면서 문제가 발생하기 시작했습니다.
서비스 이용자 수가 기하급수적으로 증가하면서, 단일 컴퓨터만으로는 급증하는 트래픽과 데이터 양을 감당할 수 없게 되었습니다.
하드웨어는 시간이 지나면서 꾸준히 발전해왔지만, 그 발전 속도는 선형적이었습니다. 반면 사용자 수, 트래픽, 데이터량은 기하급수적으로 증가하였습니다.
이러한 배경에서 무어의 법칙이라는 개념이 등장하게 되었습니다.
💡 무어의 법칙
CPU 트랜지스터 수는 약 18~24개월마다 두 배씩 증가
→ 그러나 이조차도 오늘날의 요구사항을 감당하기에는 한계가 발생하였음.
이제 문제 해결의 중심은 하드웨어에서 소프트웨어로 넘어갈 수밖에 없게 되었습니다.
기존의 Scale-Up 방식은 더 좋은 하드웨어를 도입함으로써 성능을 높이는 접근이었지만, 이는 비용이 많이 들며 확장성에도 분명한 한계가 존재했습니다.
이에 따라 소프트웨어 개발자들은 보다 유연하고 확장 가능한 방식인 Scale-Out 전략을 고민하게 되었습니다.
Scale-Out은 여러 대의 시스템을 연결해 전체 성능을 높이는 전략입니다.
하지만 이 방식을 구현하려면 반드시 시스템 간 상태를 공유하지 않아야 한다는 중요한 조건이 있었습니다.
상태를 공유하게 되면 자원이 많이 소모되거나, 기술적으로 불필요한 제약이 발생할 수 있기 때문입니다.
단편적으로 살펴보면, 애플리케이션 서버(Application Server)는 로드 밸런서(Load Balancer)를 통해 전처리를 수행하고, 별도의 상태를 가지지 않도록 설계함으로써 Scale-Out 확장이 가능했습니다.
하지만 온라인 서비스의 핵심이라 할 수 있는 데이터베이스(Database)는 대량의 데이터를 공유해야 하므로, 쉽게 Scale-Out을 구현하기 어려웠습니다.
특히 Sharding 기능이 도입되기 이전까지는 데이터베이스 확장성에 큰 제약이 존재하였습니다.
따라서 데이터베이스를 중심으로 여러 대의 서버로 확장하면서도, 상태와 데이터를 효율적으로 공유할 수 있는 구조가 요구되었습니다.
이러한 요구에 따라 분산 시스템의 필요성이 점차 부각되기 시작했습니다.
분산 시스템은 각 서버가 독립적으로 동작하면서도, 필요한 데이터를 공유하고 일관성을 유지할 수 있도록 설계되어야 합니다.
이와 같은 필요를 충족하기 위해 다양한 분산 시스템 기술들이 발전하였으며, 이는 오늘날 대규모 온라인 서비스를 구성하는 데 있어 필수적인 아키텍처로 자리 잡게 되었습니다.
◈
🐘 2. 분산 시스템의 등장
🐘 2.1. 분산 시스템이란
분산 시스템이란 여러 대의 컴퓨터(노드)가 네트워크를 통해 연결되어, 마치 하나의 시스템처럼 동작하도록 구성된 시스템을 말합니다. 이러한 시스템은 각 노드가 독립적으로 작업을 수행하면서도, 전체적으로는 하나의 통합된 서비스를 제공하도록 설계되어 있습니다.
즉, 단일 시스템의 한계를 극복하고, 확장성과 신뢰성, 고가용성을 확보하기 위해 사용되는 아키텍처입니다. 서버, 저장소, 네트워크 등 다양한 자원이 여러 위치에 분산되어 있음에도 불구하고, 사용자에게는 하나의 일관된 시스템처럼 보이는 것이 분산 시스템의 핵심입니다.
분산 시스템은 현대의 대규모 온라인 서비스, 클라우드 컴퓨팅, 빅데이터 처리 등 다양한 분야에서 필수적인 기반 기술로 활용되고 있습니다.
🐘 2.2. 분산 시스템의 기본적인 특징
분산 시스템은 반드시 아래와 같은 특징을 가져야 합니다. 우리가 알고 있는 분산 시스템들이 아래와 같은 특징을 가지고 있는지 고민하면서 보면 좋습니다.
✅ 2.2.1. Concurrency
동시성을 의미합니다. 클라이언트의 작업 요청을 여러 대의 분산된 컴퓨터에서 동시에 수행 할 수 있어야 하며, 동시 실행 자원을 늘려서 처리량을 늘릴 수 있다는 강점을 의미합니다.
✅ 2.2.2. No Global Clock
시스템의 각 부분이 비동기식으로 동작함을 의미합니다. 즉, 어떤 부분의 상태 때문에 다른곳에서 Lock 이 걸리거나 병목현상이 걸리지 않습니다.
✅ 2.2.3.Independent Failure
여러 시스템 중 하나가 다운되더라도 나머지 시스템이 정상적으로 작동하여, 작업을 수행 할 수 있어야 함을 의미합니다.
쉽게 풀어서 이야기하면, 시스템 하나가 문제가 발생했다고 하여, 전체 시스템에 영향이 가서는 안된다는 이야기입니다.
🐘 2.3. 분산 시스템 이론
✅ 2.3.1 BASE 이론
현대의 대규모 분산 시스템에서는 데이터의 일관성보다는 가용성과 성능이 더 중요해지는 경우가 많습니다. 이러한 요구를 충족하기 위해 등장한 개념이 바로 BASE 원칙입니다. BASE는 전통적인 관계형 데이터베이스에서 사용하는 ACID 원칙과 대비되는 개념으로, 특히 NoSQL 데이터베이스에서 자주 사용되는 설계 철학입니다.
1️⃣ BASE의 요소
BASE는 다음의 세 가지 핵심 요소로 구성되어 있습니다.
▶ Basically Available (기본적인 가용성)
시스템 전체가 완전히 중단되는 일은 없도록 보장합니다. 일부 노드에 장애가 발생하더라도 전체 시스템은 계속 동작하며, 최소한의 기능은 유지됩니다. 이를 위해 데이터 복제를 수행하며, 동일한 데이터를 여러 노드에 분산하여 저장합니다.
▶ Soft State (유연한 상태)
데이터의 상태가 항상 일관적이지 않을 수 있음을 의미합니다. 특정 시점에서 조회한 데이터와 이후 시점의 데이터가 다를 수 있으며, 이에 대한 일관성 유지 책임은 시스템이 아닌 사용자 또는 클라이언트 애플리케이션에 있습니다.
예를 들어, Hadoop에서는 데이터를 Write한 직후 복제가 0.0001초만에 동시에 이루어지지 않기 때문에, 복제가 완료되기 전의 시점에서는 일부 노드에 최신 데이터가 존재하지 않을 수 있습니다.
▶ Eventually Consistent (최종적인 일관성)
모든 노드가 즉시 동일한 데이터를 가지지는 않지만, 시간이 지나면 결국 일관된 상태로 수렴하게 됩니다. 약간의 지연은 있을 수 있으나, 데이터는 결국 저장되고 조회가 가능해집니다.
위의 Hadoop 예시를 이어보면, Write와 동시에 복제가 이루어지지는 않지만, 내부 정책에 따라 복제가 완료되며, 결국 모든 노드가 동일한 데이터를 가지게 됩니다.
2️⃣ BASE의 특징
BASE는 관계형 데이터베이스에서 보장하는 ACID 원칙과는 정반대의 방향을 지향합니다.
ACID는 트랜잭션의 원자성, 일관성, 고립성, 지속성을 강조하며, 은행 시스템과 같이 정합성이 중요한 업무에 적합합니다.
반면 BASE는 즉각적인 일관성(immediate consistency)을 포기하고, 대신 높은 가용성과 성능, 확장성을 추구합니다.
결과적으로 BASE는 사용자 경험을 우선시하는 시스템, 예를 들어 SNS, 실시간 광고 시스템, 대규모 로그 분석 플랫폼 등에 적합한 방식입니다.
3️⃣ BASE의 사례
Facebook 광고 플랫폼의 리포트 조회 사례를 통해 BASE 원칙을 이해할 수 있습니다.
사용자가 광고 리포트를 조회하면 다음과 같은 데이터를 확인할 수 있습니다.
💡 서울 지역 노출된 광고 총 10,000 건
그 중, 1,000 건이 타겟팅되었고, 이 중 남성은 100 명
그러나 10분 후 다시 조회하면 결과가 조금 달라질 수 있습니다.
💡 서울 지역 노출된 광고 총 10,000 건 동일
그 중, 1,060 건이 타겟팅되었고, 이 중 남성은 102 명으로 늘어남
이는 데이터가 점진적으로 수렴하고 있다는 사실을 보여줍니다. 즉, 처음에는 완전한 일관성이 없더라도 시간이 지나면 전체 데이터가 정확히 반영된다는 BASE 원칙의 Eventually Consistent를 반영한 예시입니다.
마케터는 처음 조회한 데이터를 기반으로 노출 : 타겟팅 : 남자 = 100 : 10 : 1이라는 성과를 측정하고 전략을 수립합니다. 이후 데이터가 갱신되더라도, 사용자는 이를 바탕으로 전략을 유연하게 조정할 수 있습니다.
✅ 2.3.2 CAP 이론
분산 시스템을 설계하다 보면 반드시 고려해야 할 개념이 있습니다. 바로 CAP 정리(CAP Theorem)입니다. 이 이론은 분산 시스템에서 동시에 충족시키기 어려운 세 가지 속성을 정의하며, 실제 시스템 설계 시 무엇을 선택하고 어떤 속성을 포기할지를 결정하는 기준이 됩니다.
출처 : CAP Image 출처
1️⃣ CAP의 세 가지 요소
CAP은 아래의 세 가지 요소로 구성되어 있습니다.
▶ Consistency (일관성)
모든 노드가 항상 동일한 데이터를 반환해야 한다는 개념입니다. 여러 클라이언트가 동시에 동일한 요청을 하더라도 같은 응답을 받아야 하며, 이는 데이터의 정합성을 유지하는 데 필수적인 속성입니다.
여기서 말하는 일관성은 ACID 원칙에서의 C(Consistency)와는 다릅니다. ACID의 일관성은 트랜잭션의 무결성을 의미하는 반면, CAP의 일관성은 분산된 시스템 내 모든 노드 간의 데이터 동기화를 의미합니다.
▶ Availability (가용성)
시스템의 일부 구성 요소에 장애가 발생하더라도 전체 서비스는 중단되지 않고 응답이 가능해야 한다는 개념입니다. 즉, 사용자가 언제 어떤 요청을 하더라도 시스템은 반드시 응답을 반환해야 합니다.
▶ Partition Tolerance (분할 내성)
노드 간의 네트워크가 단절되더라도 시스템이 계속 작동할 수 있어야 한다는 개념입니다. 분산 시스템에서는 네트워크 장애가 불가피하게 발생하므로, 이를 견딜 수 있는 구조를 갖추는 것이 필수입니다.
❗ CAP 정리의 핵심:
세 가지 중 두 가지만 선택할 수 있음.
이론적으로 하나의 시스템이 세 가지 속성을 모두 완벽히 충족시키는 것은 불가능 함.
현실적으로는 대부분 Availability를 확보해야 하므로, CA, AP 중에서 선택.
2️⃣ CA 시스템
CA(일관성과 가용성) 시스템은 네트워크에 문제가 없다는 전제 하에, 일관성과 가용성을 모두 만족시키는 시스템입니다.
하지만 이 시스템은 Partition Tolerance, 즉 네트워크 분할 상황에서는 정상적으로 동작하지 않습니다.
이러한 특성 때문에 CA 시스템은 주로 단일 노드 또는 파티션이 발생하지 않는 환경에서 사용됩니다.
즉, CA는 분산시스템에서 선택할 수는 없습니다.
( 전통적인 RDBMS, 단일 노드에서의 MongoDB 등 )
3️⃣ AP 시스템
AP(가용성과 분할 내성) 시스템은 만약, 분산 환경에서 두 노드 간의 네트워크가 중단되었을 때, 데이터가 일관되도록 완벽히 보장할 수는 없을지라도 모든 요청은 결과를 반환해 가용성을 보장하고 시스템은 계속 동작할 수 있게 해야하는 시스템입니다.
모든 노드가 요청에 응답하기 때문에 시스템은 가용성을 보장하게 됩니다. 그리고 동시에 시스템은 계속 동작하므로 Partition Tolerance도 함께 보장할 수 있습니다.
하지만 데이터는 일관되지 않기 때문에 동일한 내용의 요청일지라도 다른 데이터를 가지고 있기에, 응답은 다를 수 있으므로 데이터의 일관성을 항상 보장할 수 없습니다.
( Cassandra, HBase, Druid 등 )
4️⃣ CP 시스템
CP 시스템은 네트워크에 문제가 생겨 노드 간의 연결이 단절되더라도, 데이터의 일관성을 보장하고 시스템이 계속 동작할 수 있도록 설계된 구조입니다.
이러한 시스템은 가용성을 일부 포기하는 대신, 데이터가 항상 정확하게 유지되도록 합니다.
예를 들어, A 노드에서 쓰기 요청을 막아버리는 방식으로 일관성을 유지할 수 있습니다. 이렇게 되면 가용성은 낮아지지만, 일관성과 분할 허용성은 보장됩니다.
네트워크가 복구된 후에는 각 노드 간의 데이터 동기화가 반드시 수행되어야 하며, 이를 통해 전체 시스템의 일관성을 회복하게 됩니다.
MongoDB는 기본적으로 Primary 노드에서 모든 쓰기를 처리하고, 이후 Secondary 노드로 복제합니다. 이를 통해 일관성을 유지할 수 있습니다.
단, 가용성을 높이기 위해 Secondary 노드를 읽기 전용으로 활용하면, 복제 지연으로 인해 일관성이 약해질 수 있습니다
5️⃣ CAP 이론의 한계
CAP 이론은 분산 시스템에서 Consistency(일관성), Availability(가용성), Partition Tolerance(분할 허용성) 중 두 가지만 보장할 수 있다는 이론입니다. 하지만 현실적으로는 완벽한 CP 또는 완벽한 AP 시스템이 존재하기 어렵습니다.
첫 째로, 완벽한 CP 시스템은 이상적일 뿐입니다.
완벽한 일관성을 보장하는 CP 시스템은 하나의 트랜잭션이 모든 노드에 복제된 후에야 완료됩니다. 이러한 방식은 높은 일관성을 보장할 수 있지만, 가용성과 성능을 희생해야 합니다.
만약 일부 노드에 장애가 발생한다면, 트랜잭션은 무조건 실패하게 됩니다. 또한 노드 수가 증가할수록 지연 시간도 길어지게 됩니다. 이처럼 강한 일관성을 추구하다 보면, 오히려 분산 시스템을 사용할 이유가 사라집니다.
둘 째로, 완벽한 AP 시스템 역시 한계가 있습니다.
완벽한 가용성을 보장하는 AP 시스템은 모든 노드가 어떤 상황에서도 응답을 보장해야 합니다. 예를 들어, 하나의 노드가 네트워크 분할로 인해 고립된 상황을 생각해봅시다. 고립된 노드는 다른 노드와 데이터를 동기화할 수 없으므로 일관성이 깨질 수 있습니다.
그럼에도 불구하고 이 노드가 계속해서 응답한다면, 일시적으로는 완벽한 가용성을 갖는 것처럼 보입니다. 하지만 이런 노드에 연결된 사용자는 일관성이 깨진 데이터를 계속 보게 될 수 있으며, 이는 상용 시스템에서는 치명적인 문제가 될 수 있습니다.
즉, 이 안에서 Trade Off 관계를 명확히 이해하고, 균형을 맞춘 설계가 필요합니다.
완벽한 CP 또는 AP는 현실적인 설계가 아닙니다. 네트워크 분할(Partition)은 언제든지 발생할 수 있다는 전제를 가지고, CP와 AP 사이에서 균형점을 찾는 것이 중요합니다.
많은 분산 데이터베이스는 실제로 AP 쪽에 비중을 두고 설계되고 있으며, 시스템 요구사항에 따라 적절한 트레이드오프가 필요합니다.
강한 일관성을 추구할수록 Strong, 약한 일관성을 추구할수록 Weak하다고 표현합니다.
단, 많은 분산형 데이터베이스는 AP 쪽에 더 많은 비중을 둡니다.
출처 : CP 와 AP
✅ 2.3.1 PACELC 이론
출처 : PACELC 이론
CAP 이론의 한계에 따라 실제 운영을 반영한 이론입니다.
분산된 환경에서 장애가 발생한(Partition) 경우, 가용성(Availability)과 일관성(Consistency)을 고려해야하고
정상 상황의 경우(Else), 지연시간(Latency)과 일관성(Consistency)를 고려해야한다는 이론입니다.
좀 풀어서 설명하면,
장애 상황에서는(Partition), 일부 노드에 접근이 불가능한 경우가 발생합니다.
이러한 상황에서는 데이터를 일관되게 반영할 수 없다면, 아예 반영 자체를 실패하게 만들어 일관성을 보장해야 합니다(Consistency). 또는, 접근 가능한 노드에만 데이터를 반영하여 가용성을 보장할 수도 있습니다(Availability).
정상 상황에서는(Else), 모든 노드에 일관성 있게 데이터를 반영하고 지연 시간이 늘어나는 것을 감수하여 일관성을 보장할 수 있습니다(Consistency). 반대로, 빠르게 응답하기 위해 지연 시간을 줄이고 일관성을 일부 포기하여 낮은 지연 시간을 보장할 수도 있습니다(Latency).
이 안에서 조합을 하는 것입니다.
장애 상황
정상 상황
설명
P + A (장애 상황 + 가용성)
E + L (정상 상황 + 지연 시간)
장애 상황에서는 가용 노드만 기능을 제공하고, 정상 상황에서는 지연 시간을 최적화하는 것을 우선적으로 고려하는 시스템.
P + A (장애 상황 + 가용성)
E + C (정상 상황 + 일관성)
장애 상황에서는 가용 노드만 기능을 제공하고, 정상 상황에서는 지연 시간이 증가하더라도 일관적인 데이터를 보장하는 시스템.
P + C (장애 상황 + 일관성)
E + L (정상 상황 + 지연 시간)
장애 상황에서는 데이터에 일관성을 보장하고, 정상 상황에서는 지연 시간을 최적화하는 것을 우선적으로 고려하는 시스템.
P + C (장애 상황 + 일관성)
E + C (정상 상황 + 일관성)
장애 상황에서도, 정상 상황에서도 데이터의 일관성을 보장하는 시스템.
해당 표를 조금 더 풀어서 설명해보곘습니다.
PA/EL
장애상황에서 조금 느려질 수 있지만, 좀 멀리있는 가용 노드만으로도 어떻게든 서비스를 계속 제공합니다. 이 과정에서 일관성은 깨질 수 있습니다.
정상상황에서는 가장 가까운 서버에서 빠른 응답을 받을 수 있도록 합니다. 단, 아직 모든 분산 시스템에 업데이트가 되지 않을 수 있기 때문에 경우에 따라 가장 최신 정보를 가져오지 않을 수 있습니다.
PA/EC
장애상황에서 조금 느려질 수 있지만, 좀 멀리있는 가용 노드만으로도 어떻게든 서비스를 계속 제공합니다. 이 과정에서 일관성은 깨질 수 있습니다.
정상상황에서는 데이터 동기화로 조금 느릴 수 있지만, 일관성있는 데이터를 조회 할 수 있습니다.
PC/EL
장애상황에서는 남은 가용 노드 안에서 일관성을 최대한 유지하고 서비스를 제공합니다. 단, 일관성을 유지할 수 없는 상황이라면 트랜잭션을 거절할 수도 있으며, 장애 서버가 복구 될 때까지 기다려야 할 수도 있습니다.
정상상황에서는 가장 가까운 서버에서 빠른 응답을 받을 수 있도록 합니다. 단, 아직 모든 분산 시스템에 업데이트가 되지 않을 수 있기 때문에 경우에 따라 가장 최신 정보를 가져오지 않을 수 있습니다.
PC/EA
장애상황에서는 남은 가용 노드 안에서 일관성을 최대한 유지하고 서비스를 제공합니다. 단, 일관성을 유지할 수 없는 상황이라면 트랜잭션을 거절할 수도 있으며, 장애 서버가 복구 될 때까지 기다려야 할 수도 있습니다.
정상상황에서는 데이터 동기화로 조금 느릴 수 있지만, 일관성있는 데이터를 조회 할 수 있습니다.
예시를 들면,
MongoDB는 PA/EC 시스템입니다.
분산 환경에서 장애가 발생하면 쓰기를 중단하고 읽기만 가능하게 만들기 때문이며, 정상 상황에서는 Secondary 멤버와 Primary 멤버 간의 데이터 일관성을 보장하기 때문입니다.
여기서, 장애가 발생하면 쓰기가 중단되는 것을 조금 더 설명하자면 mongoDB의 automatic failover 때문.
‘electionTimeoutMillis’ 이라는 설정값만큼 timeout을 적용해, 이 timeout 시간 내에 Primary 노드가 다른 구성원과 소통하지 않으면 클러스터는 가지고 있는 Secondary 노드 중 하나를 Primary 노드로 만든다.
Secondary 노드 중 하나를 Primary 노드로 만드는 election 과정 중에는 쓰기 작업이 중단된다.
또 다른 예시로는
Cassandra나 DynamoDB는 PA/EL 시스템이라는게 있습니다.
장애 상황에서는(P) 정상인 노드에만 데이터를 읽고 쓰고(A), 장애 노드가 복구된다면 그때 데이터를 반영합니다.
또한 정상 상황에서는(E) 빠른 응답을 위해서 모든 노드 전체에 다 데이터를 읽고 쓰지는 않습니다(L).
결국은 요구사항이나, 상황에 따라 Trade-Off 를 명확히 이해하여 기술을 활용하는 것이 좋습니다.
🐘 2.4. 분산 시스템 구축 시 고려해야 할 부분
✅ 2.4.1. Heterogeneity (이질성)
분산 시스템은 구축함에 있어서 최대한 이질성을 고려해야 합니다. 왜냐하면 서로 다른 시스템에 설치를 할 수 있어야하고, 서로 다른 시스템 사이에 정보와 자원을 공유해야 할 수도 있습니다.
이는 네트워크,OS,하드웨어,프로그래밍 언어 등이 있습니다.
좋은 방법으로는, 하드웨어나 OS 에 관계없이 일관된 개발을 위한 언어인 Java 나 Scala, Go 언어등을 사용하는 것이 좋으며,
필요시에는 원하는 추상화를 이룰 수있는 Middleware 를 사용하는 것도 좋습니다. (CORBA 나 RMI 등)
💡 예시
✔️ Apache Hadoop
Hadoop은 리눅스, Windows, macOS 등 다양한 OS에서 동작할 수 있으며, 다양한 하드웨어 환경에서도 실행 가능하다.
또한 Java 기반으로 작성되어 있어 플랫폼 독립성이 뛰어나고, 다양한 클러스터 환경에 쉽게 이식 가능하다.
✅ 2.4.2. Openess
시스템을 다양한 방식으로 확장성(extended, 덧붙임), 재구현(reimplemented)할 수 있는지 여부를 의미합니다.
💡 예시
✔️ 마이크로소프트사에서 plug and play 개념→ Interface만 정해시 공표를 하고나면 거기에 해당하는 소프트웨어 회사나 하드웨어 회사들이 이런 Interface에 근거를 해서 뭔가를 개발하면 Windows운영체제에서 그대로 돌아간다는 개념
✅ 2.4.3. Security
권한이 없다면 공개조차 불가, 허가되지 않은 방식으로 데이터 변경 불가, 권한이 있다면 시스템 접근 가능
분산 시스템은 위와 같은 보안 요구사항을 만족해야 합니다.
💡 예시
✔️ Kerberos + Hadoop
Hadoop 클러스터에 Kerberos 인증을 적용하면, 각 사용자와 서비스는 정해진 권한을 가진 토큰을 통해 접근이 가능하며,
인증되지 않은 사용자는 데이터에 접근할 수 없다.
✅ 2.4.4. Scalability
분산 시스템은 사용자 수나 시스템 자원의 증가에 따라 성능 저하 없이 확장 가능해야 합니다. 보통 수평 확장(horizontal scaling) 방식을 선호합니다.
서버들을 증가 시키더라도 더 이상 performance가 늘어나지 않는다면, 더 이상의 확장성은 없다고 볼 수 있습니다.
즉, 확장성 좋은 시스템을 설계하자는 의미입니다.
✅ 2.4.5. Failure Handling
분산시스템도 어쨌든 시스템이기 때문에 장애나 실패를 피할 수는 없습니다. 이러한 장애/실패에 대한 대응을 (자동화된 방식으로) 할 수 있어야 함을 의미합니다.
고장 감지 ▶ 고장 완화 ▶ 고장 허용 ▶ 고장 복구 ▶ 중복성
이렇게 다섯 단계로 진행됩니다.
예를 들면, 체크섬을 통해 데이터가 손상됐는지 수시로 확인하고, 고장이 발생했다면 해당 작업을 재전송 하거나, 복원하는 방법으로 고장을 완화할 수 있습니다.(RAID) 이후, 다른 노드를 통해 전체 시스템을 유지하면서 고장을 허용하고, Rollback 이나, 다른 방법을 통해 고장한 결험을 복구합니다. 마지막으로 다양한 경로와 복제를 통해 고장 발생 시 대응 할 수 있어야 합니다.
💡 예시
✔️ Spark의 Stage 재시도 메커니즘
Spark 작업 수행 중 일부 Executor가 실패하더라도, 해당 Task만 재시도하여 전체 Job을 중단시키지 않는다.
✅ 2.4.6. Concurrency
여러 사용자가 동시에 하나의 자원을 공유하는 상황에서 발생하는 문제를 해결해야 합니다.
여러 클라이언트가 동시에 접근해도 자원이 일관된 상태를 유지해야 하며, 병렬 처리를 통해 효율성을 높이고, shared resource는 자신의 상태를 명확히 표현할 수 있어야 합니다. 또한 리소스는 일관성(consistency) 있는 방식으로 동기화(synchronization) 되어야 합니다.
💡 예시
✔️ ZooKeeper
ZooKeeper는 여러 분산 시스템에서 동기화된 설정 관리, 리더 선출, 락(lock) 등을 제공하여 동시성 문제를 해결한다.
✅ 2.4.7. Transparency
사용자가 분산 시스템의 내부 구조나 동작 방식을 인식하지 못하도록 만들어야 합니다. 이를 투명성이라고 하는데, 분산 시스템 이론에는 다양한 종류의 투명성이 존재합니다.
✏️ 접근 투명성: 로컬/원격 자원을 동일한 방식으로 접근합니다.
✏️ 위치 투명성: 자원의 물리적 위치(IP, 위치 등)와 무관하게 접근합니다.
✏️ 동시성 투명성: 여러 프로세스가 공유 자원을 문제 없이 사용할 수 있도록 합니다.
✏️ 복제 투명성: 자원의 복제 유무와 상관없이 동일하게 사용 가능합니다.
✏️ 장애 투명성: 장애 발생 시 사용자/애플리케이션이 이를 인지하지 않도록 처리합니다.
✏️ 이동 투명성: 자원이나 클라이언트의 위치 이동이 시스템 동작에 영향이 없습니다.
✏️ 성능 투명성: 부하에 따라 시스템 재구성이 가능합니다.
✏️ 확장 투명성: 시스템 규모가 확장되어도 구조나 알고리즘 변경 없이 운영이 가능합니다.
💡 예시
✔️ Cloud Storage (ex: Amazon S3)
사용자는 객체가 어느 리전에 저장되어 있는지 몰라도 되고, 필요 시 언제든지 리소스를 추가할 수 있어 성능/확장/위치 투명성을 모두 제공한다.
🐘 2.5. 분산 시스템 USE CASE 몇 가지
✅ 2.5.1. 분산 저장소
출처 : 하둡 아키텍처
대용량의 분산시스템이 가장 필요한 곳이 저장소입니다. 대량의 데이터를 나누어서 저장하면서도 유실되면 안되고, 언제든지 조회가 가능해야 했습니다.
대표적으로 우리가 앞으로 알아볼 Hadoop 이 있습니다.
✅ 2.5.2. Load Balancer
출처 : 로드 밸런서
과거의 로드밸런서는 하나 또는 두 개의 고성능 하드웨어 장비나 스위치로 로드밸런싱을 처리했습니다. 하지만 현대의 로드밸런서는 인스턴스가 수백에서 수천 대까지 연결되므로 하나의 고스펙 하드웨어로 모든 로드밸런싱을 처리할 수 없습니다. 또한, 설정이나 규칙의 변경이 잦고 그 복잡도가 높아졌습니다.
따라서 AWS, Azure와 같은 클라우드 서비스에서 선택하는 로드밸런서는 모두 소프트웨어 로드밸런서입니다. 외부에 노출되는 IP 주소나 DNS 주소는 하나이지만, 내부적으로는 HA(High Availability)를 위해 여러 서버와 스위치로 구성되어 있습니다. 또한, 실시간 설정 반영을 위한 동기화 시스템도 구축되어 있습니다.
✅ 2.5.3. 분산 메시지 큐
출처 : 분산 메시지 큐 - 카프카
Queue라고 하면 FIFO(First In First Out)가 가능해야 하므로, 순서가 보장되어야 합니다. Queue는 하나만 존재할 수밖에 없습니다.
하지만 하나의 Queue로는 물리적으로 처리량에 한계가 있으므로, 하나의 주제에 대해 여러 개의 Queue를 두어 처리량을 늘릴 수 있는 구조가 만들어졌습니다. 이 구조를 대표하는 예가 Kafka입니다.
Kafka에서는 하나의 Topic(논리적 Queue)이 여러 개의 Partition(물리적 Queue)으로 구성됩니다. 각 Partition별로는 순서를 보장할 수 있지만, 전체 Topic에 대해서는 순서를 보장할 수 없습니다. 만약 전체 Topic의 순서를 보장하려 한다면, 처리량 손해를 감수해야 합니다.
◈
🐘 3. 하둡의 등장과 개요
기술의 이해는 History 를 파악하는게 중요하다는 생각이 들었고, 그렇기 때문에 기본 시스템의 역사부터 분산 시스템까지 알아보았습니다.
분산 시스템 의 개념이 이해되었다면, 이제 본격적으로 하둡에 대해서 알아보겠습니다.
출처 : Apache-Hadoop
🐘 3.1. 하둡의 등장 배경
온라인 서비스와 데이터 처리 기술이 발전함에 따라, 우리가 다루어야 할 데이터의 양과 종류는 폭발적으로 증가했습니다. 기존에는 주로 정형 데이터를 다뤘기 때문에 RDBMS에 저장하여 관리하는 것이 일반적이었습니다. 하지만 웹 로그, 이미지, 영상 파일과 같은 비정형 데이터가 많아지면서, 기존 RDBMS로 모든 데이터를 저장하고 처리하기에는 여러 한계가 드러났습니다.
우선 비정형 데이터는 크기가 방대하여, RDBMS에 저장하려면 고성능, 고비용의 장비가 필요했습니다. 또한 비정형 데이터의 경우, RDBMS가 제공하는 복잡한 기능들(트랜잭션 관리, 복잡한 쿼리 등)을 굳이 사용할 필요도 없었습니다. 결국, 자주 사용하지 않는 대용량 데이터를 저장하기 위해 RDBMS를 무작정 확장하는 것은 비용 대비 효율이 매우 낮은 선택이 되었습니다.
이러한 문제를 해결하기 위해 등장한 것이 바로 하둡(Hadoop)입니다. 하둡은 값비싼 전용 서버가 아닌, 범용 x86 리눅스 서버(Commodity Server)에서도 설치하고 운용할 수 있습니다. 데이터 양이 늘어나더라도 단순히 서버 노드를 추가하는 것만으로 쉽게 확장할 수 있으며, 이를 위해 별도의 재설치나 복잡한 재구성이 필요하지 않은 점이 큰 장점입니다.
하둡은 데이터의 복제본을 여러 서버에 저장하기 때문에, 서버나 디스크에 장애가 발생하더라도 데이터 복구가 가능합니다. 또한 데이터가 여러 서버에 분산 저장되어 있기 때문에, 데이터 처리를 병렬로 수행할 수 있습니다. 이로 인해 기존 방식보다 훨씬 뛰어난 성능 향상을 기대할 수 있습니다.
실제로, 2008년, 뉴욕타임즈(New York Times)는 약 130년 분량에 해당하는 신문 기사 1,100만 페이지를 디지털화하는 프로젝트를 진행했습니다. 이 작업에는 AWS의 EC2, S3, 그리고 하둡을 활용했습니다. 결과는 놀라웠습니다. 이 방대한 작업을 단 하루 만에 완료했으며, 소요된 비용은 약 200만 원에 불과했습니다. 당시 기존 서버와 일반 병렬 처리 기술로 작업을 수행했을 경우, 무려 14년이 걸렸을 것이라 예상되었습니다.
출처 : 뉴욕타임즈 하둡 사례 이미지
🐘 3.2. 하둡이란
하둡을 한 문장으로 정의하면, 하나의 성능 좋은 컴퓨터를 이용하여 데이터를 처리하는 대신 적당한 성능의 컴퓨터 여러 대를 클러스터화하고 큰 크기의 데이터를 클러스터에서 병렬로 동시에 처리하여 처리 속도를 높이는 것을 목적으로 하는 분산처리를 위한 오픈소스 프레임워크 라고 정의 할 수 있습니다.
데이터의 규모가 기가바이트에서 페타바이트에 이르는 대규모 데이터 세트를 효율적으로 저장 및 처리하는 데 사용되며, 하나의 대형 컴퓨터를 사용하여 데이터를 저장 및 처리하는 대신 Hadoop을 사용하면 여러 컴퓨터를 함께 클러스터링하여 대량의 데이터 세트를 병렬로 분석할 수 있습니다.
또한 여러가지 실행 엔진, 프로그래밍 및 데이터 처리 엔진 등, 하둡 생태계 전반을 포함하는 의미로 확장 & 발전 되어왔습니다.
출처 : 하둡 생태계 이미지
다음 포스팅에서 좀 더 자세히 다루겠지만, 하둡의 기본 메커니즘은 데이터가 들어오면, 그 데이터를 쪼개고 분리하여 저장하는 개념입니다.
쪼개서 저장한 데이터가 어느 노드에 위치하였는지를 알 수 있는 메타 데이터 또한 있으며, 해당 데이터를 복제하여 다른 노드에 저장함으로써, Replication 도 보장해줍니다.
이러한 내용을 필두로, 하둡의 주요 특징들을 살펴보면 다음과 같습니다.
✅ 확장성
→ 하둡은 페타바이트(PB) 단위의 데이터를 신뢰성 있게 저장하고 처리할 수 있도록 설계되었습니다. 필요에 따라 서버 노드를 추가함으로써 시스템을 수평적으로 확장할 수 있습니다.
✅ 경제성
→ 고가의 전용 서버 대신 범용 리눅스 서버(Commodity Server)를 사용하여 데이터와 연산을 분산 처리합니다. 이러한 범용 서버를 수천 대까지 클러스터링하여 운영할 수 있어 초기 투자 비용이 낮고 경제적입니다.
✅ 효율성
→ 데이터를 저장한 서버에서 직접 연산을 수행(Data Locality)함으로써, 불필요한 네트워크 전송을 줄이고 빠른 병렬 처리를 가능하게 합니다. 이를 통해 대용량 데이터도 빠르게 처리할 수 있습니다.
✅ 신뢰성
→ 하둡은 데이터를 자동으로 여러 복제본으로 저장하며, 서버나 디스크 장애가 발생하더라도 작업을 자동으로 재배치하여 복구할 수 있도록 설계되어 있습니다. 이는 시스템 전체의 높은 신뢰성과 가용성을 보장합니다.
또한 이러한 특징을 유지하기 위하여 하둡은 다음과 같은 네 가지 주요 모듈을 가지고 있습니다.
✅ HDFS
→ 표준 또는 저사양 하드웨어에서 실행되는 분산 파일 시스템입니다. HDFS 는 높은 내결함성과 대규모 데이터 세트에 대한 저장 및 처리를 담당합니다.
✅ YARN
→ 클러스터 노드 및 리소스 사용을 관리하고 모니터링합니다. 이를 통해 작업 및 Task 를 관리하고 Scheduling 을 합니다. ( Version 2부터 추가 )
✅ MapReduce
→ 프로그램에서 데이터에 대한 병렬 계산을 수행하는 데 도움이 되는 프레임워크입니다. Map Task 는 입력 데이터를 가져와 키 값 페어로 계산 할 수 있는 데이터 세트로 변환합니다. Map Task 의 출력은 Reduce Task 를 통하여 출력을 집계하고 원하는 결과를 제공합니다.
✅ Hadoop Common
→ 모든 모듈에서 사용 할 수 있는 공통 Java 라이브러리를 제공합니다.
해당 모듈들에 대하여서는, 다음 포스팅에서 보다 자세하게 알아 볼 예정입니다.
🐘 3.3. 하둡 아키텍처와 Version 별 특징
✅ 3.3.1. Hadoop v1.0
출처 : 하둡 V1 아키텍처
하둡(Hadoop) v1은 2011년에 정식으로 발표된 분산 데이터 처리 프레임워크입니다. 하둡 v1은 대규모 데이터를 효율적으로 저장하고 처리할 수 있도록 분산 저장과 병렬 처리라는 두 가지 핵심 기능을 제공합니다.
먼저, 분산 저장은 하둡의 NameNode와 DataNode가 담당합니다. NameNode는 하둡 파일 시스템(HDFS) 내에서 파일들의 메타데이터를 관리하는 역할을 합니다. 파일이 어떤 블록으로 나뉘어 저장되어 있는지, 각 블록이 어느 DataNode에 위치하는지 등의 정보를 기록하고 유지합니다. 또한, 전체 클러스터에 존재하는 DataNode들의 상태를 주기적으로 점검하고, 이상이 생긴 노드를 감지하여 관리하는 기능도 수행합니다. 반면, DataNode는 실제로 사용자의 데이터가 저장되는 공간으로, 데이터를 블록 단위로 나누어 저장합니다. 이때 데이터는 한 곳에만 저장되지 않고, 여러 노드에 복제되어 저장됩니다. 이러한 블록 복제 덕분에 특정 노드에 장애가 발생하더라도 데이터 유실을 방지할 수 있습니다.
다음으로, 병렬 처리는 JobTracker와 TaskTracker가 담당합니다. JobTracker는 클러스터 전체의 작업(job)들을 관리하는 중앙 컨트롤러 역할을 합니다. 사용자가 작업을 제출하면 JobTracker는 이를 적절히 분할하고, 클러스터 내 자원을 고려하여 작업을 각 TaskTracker에게 할당합니다. 또한, 각 작업의 진행 상황을 모니터링하고, 작업 실패가 발생할 경우 재시도를 조율하는 등 안정적인 처리를 지원합니다. 이러한 구조 덕분에 하둡 v1에서는 최대 약 4,000대에 이르는 노드를 하나의 클러스터에 등록하여 관리할 수 있었습니다.
TaskTracker는 JobTracker로부터 할당받은 작업을 실제로 수행하는 역할을 합니다. 각 TaskTracker는 자신에게 배정된 작업을 처리하고, 결과를 JobTracker에 보고합니다. 만약 작업 도중 오류가 발생하면 이를 즉시 JobTracker에 알리고, 필요 시 작업을 재수행합니다.
이와 같은 구조를 통해 하둡 v1은 대용량 데이터를 안정적으로 저장하고, 동시에 빠르게 처리할 수 있는 기반을 마련하였습니다. 이후 하둡은 이 기본 구조를 발전시켜 더욱 강력한 분산 처리 생태계를 구축하게 됩니다.
✅ 3.3.2. Hadoop v2.0
출처 : 하둡 V2 아키텍처
하둡 v1은 대용량 데이터를 분산 저장하고 병렬로 처리하는 강력한 프레임워크였지만, 구조적인 한계로 인해 몇 가지 뚜렷한 단점과 문제점을 가지고 있었습니다.
가장 큰 문제는 JobTracker의 병목현상이었습니다. 하둡 v1에서는 JobTracker가 클러스터의 모든 자원 관리(Resource Management)와 작업 스케줄링(Job Scheduling)을 동시에 처리하였습니다. 이에 따라 클러스터에 등록된 노드 수가 증가할수록 JobTracker에 과부하가 발생하였고, 결국 수 많은 노드들을 안정적으로 관리하는데에 한계가 찾아왔습니다.
또한, JobTracker가 단일 장애 지점(Single Point of Failure)이 되었기 때문에, JobTracker에 문제가 발생하면 클러스터 전체의 작업이 중단되는 심각한 위험이 존재하였습니다.
이 외에도 하둡 v1은 MapReduce 프로그래밍 모델에만 종속되어 있어 다양한 데이터 처리 방식이나 복잡한 애플리케이션 실행이 어려웠습니다. 새로운 워크로드나 다양한 데이터 처리 엔진을 유연하게 수용할 수 있는 구조가 부족하였습니다.
이러한 문제를 해결하기 위해, V2 부터 도입된 것이 YARN 입니다. ( YARN 에 대하여서도, 추후 더 자세하게 다룰 예정입니다. )
하둡 v2는 기존 하둡 v1의 JobTracker 병목현상을 제거하고 확장성을 크게 향상시키기 위해 YARN(Yet Another Resource Negotiator) 아키텍처를 도입하였습니다. ( 비슷한 이유로 MapReduce 도 YARN 에서 사용하느냐 아니냐로 1.0 과 2.0 기점이 바뀌게 됩니다. )
YARN 아키텍처는 JobTracker의 역할을 세분화하여, 자원 관리와 애플리케이션 관리 기능을 분리하였습니다.
▶ 🍓ResourceManager: 클러스터 전체의 자원을 통합적으로 관리. 작업을 직접 수행하지 않고, 어떤 작업에 얼마만큼의 자원을 할당할지 결정하는 역할 수행.
▶ 🍒NodeManager: 각 노드에서 자원의 사용 현황을 모니터링하고, 실제 컨테이너(Container)를 관리.
▶ 🍎ApplicationMaster: 각 애플리케이션(작업)마다 별도로 생성되어, 애플리케이션의 라이프사이클을 관리. ResourceManager와 통신하여 필요한 자원을 요청하고, 작업 실행을 조율.
▶ 🍏Container: 실제 작업이 수행되는 단위. ResourceManager로부터 자원을 할당받아 생성되며, 작업이 완료되면 컨테이너는 종료되어 자원이 반환 됨.
이러한 구조 덕분에 하둡 v2는 자원 관리와 작업 관리를 분리함으로써 시스템 부하를 효과적으로 분산시킬 수 있게 되었습니다. 결과적으로 클러스터에 등록할 수 있는 노드 수가 대폭 증가하여, 최대 약 10,000대 이상의 노드를 관리할 수 있는 높은 확장성을 확보할 수 있었습니다.
또한 YARN 아키텍처에서는 MapReduce에 한정되지 않고, 다양한 종류의 애플리케이션이 컨테이너를 통해 실행될 수 있도록 지원합니다. 이를 통해 Spark, HBase, Storm 등 다양한 데이터 처리 프레임워크와 컴포넌트를 YARN 클러스터 위에서 효율적으로 실행할 수 있게 되었습니다.
특히, 컨테이너는 작업이 요청될 때만 생성되고, 작업이 끝나면 즉시 종료되기 때문에 클러스터의 자원을 매우 효율적으로 활용할 수 있습니다. 이로써 하둡은 단순한 MapReduce 기반 시스템을 넘어, 다양한 데이터 처리 엔진을 통합하는 범용 데이터 플랫폼으로 발전할 수 있게 되었습니다.
✅ 3.3.3. Hadoop v3.0
출처 : 하둡이 세 마리니까, 하둡 V3 이미지…?ㅎㅎㅎ
하둡 V1 에서 V2 로의 진화는, 대량의 작업을 안정적으로 처리하기 위한 YARN 의 도입이 핵심이었다면, V3 로의 진화는 성능 개선과, 여러가지 편의성 그리고 현 생태계에 알맞는 고도화를 목적으로 업데이트 되었습니다.
여기서 등장하는 Erasure Encoding 이나, YARN 타임라인 서비스 등에 대하여서도 역시 다음 포스팅에서 함께 제대로 정리할 예정입니다.
지금은 그저 V3에 추가된 내용에 대한 개요만 보겠습니다.
▶ ① Java Version Upgrade
Hadoop 3에서 모든 Hadoop JAR은 Java 8의 런타임 버전을 대상으로 컴파일됩니다. 따라서 여전히 Java 7 이하를 사용하는 사용자는 Hadoop 3으로 작업을 시작할 때 Java 8로 업그레이드해야 합니다.
▶ ② HDFS Erasure Encoding
추후 다시 알아보겠지만, HDFS의 기본 복제 개수는 3개입니다. 하나는 원본 데이터 블록이고, 나머지 두 개는 각각 100%의 스토리지 오버헤드가 필요한 복제본입니다.
결과적으로 총 200%의 스토리지 오버헤드가 발생하며, 네트워크 대역폭과 같은 추가 리소스도 소모합니다. (즉, 1TB 데이터를 저장하는데에 3TB 용량이 필요한겁니다.)
그러나 I/O 활동이 적은 Cold Data 들 또한, 정상적인 작업 중 거의 액세스되지 않음에도 불구하고 여전히 원본 데이터와 동일한 수준의 리소스를 소비합니다.
이레이저 코딩은 이러한 상황을 개선하기 위해 고안되었습니다. 이레이저 코딩은 데이터를 저장할 때 복제본 대신 사용되며, 기존 복제 방식보다 훨씬 적은 스토리지 오버헤드로 동일한 수준의 내결함성을 제공합니다.
HDFS에 EC(Erasure Coding)를 통합함으로써 스토리지 효율성을 대폭 향상시키면서도 내결함성을 유지할 수 있습니다.
예를 들어, 6개의 블록을 가진 3x 복제 파일은 18개의 블록 공간을 사용하지만, EC(6 데이터, 3 패리티) 방식을 적용하면 9개의 블록(6 데이터 블록 + 3 패리티 블록)만 사용합니다. 이를 통해 약 50%의 스토리지 오버헤드만 발생하게 됩니다.
다만 이레이저 코딩은 데이터 재구성을 위해 원격 판독 작업이 추가로 필요하므로, 일반적으로 자주 액세스하지 않는 데이터 저장에 주로 사용합니다. 삭제 코딩을 적용하기 전에 사용자는 스토리지, 네트워크, CPU 오버헤드 등 모든 요소를 충분히 고려해야 합니다.
이레이저 코딩을 효과적으로 지원하기 위해 Hadoop은 HDFS 아키텍처를 일부 변경하였습니다.
먼저, 블록 그룹 단위로 동작할 수 있게끔, NameNode 가 확장 되었습니다.
HDFS에 이레이저 코딩(EC)을 적용하면 하나의 파일이 여러 조각(=여러 블록)으로 나뉩니다. 그리고 이 조각들은 “그룹”으로 묶입니다. 이 묶음을 “블록 그룹(Block Group)”이라고 부릅니다.
원래 HDFS에서는 파일을 관리할 때 블록 하나하나를 따로따로 관리했습니다. 그런데 EC를 도입하면서는 하나의 블록 그룹 단위로 관리하려는 것입니다. (즉, 블록 하나하나가 아니라 블록을 묶은 ‘그룹’을 관리)
그런데 이렇게 하려면 문제가 하나 생깁니다. 블록이 많아지니까, NameNode가 이 블록 하나하나를 기억(메모리 사용)해야 해서 메모리 부담이 커질 수 있습니다.
그래서 “계층적 블록 명명 방식” 이라는 새로운 규칙을 도입합니다.
이 규칙은 블록 그룹의 ID를 알고 있으면, 그 안에 포함된 내부 블록 ID들을 유추할 수 있게 만들어 놓은 것입니다.
덕분에 모든 내부 블록을 하나하나 기억할 필요 없이, 블록 그룹 하나만 기억하면 됩니다. 이렇게 해서 NameNode의 메모리 부담을 줄입니다.
다음은, 클라이언트가 확장되었습니다.
HDFS 클라이언트(파일을 읽고 쓰는 주체)도 EC(이레이저 코딩)를 지원하도록 개선되었습니다. 이제 파일을 읽고 쓸 때, 클라이언트는 “블록 그룹” 단위로 여러 블록을 동시에(병렬로) 다루게 됩니다. (예전에는 블록 하나하나 순서대로 처리했음)
이제 파일을 쓸 때, HDFS는 DFSStripedOutputStream이라는 것을 사용합니다. 이 스트림은 블록 그룹 안의 각 내부 블록을 저장할 DataNode에 각각 스트리머를 만들어서 데이터를 보냅니다.
예를 들어, 6개 데이터 블록, 3개 패리티 블록이라면 총 9개의 스트리머가 관리됩니다. 그리고 이 작업을 조율하는 코디네이터가 있어서 블록 그룹이 다 찼을 때 그룹을 종료하고, 새로운 블록 그룹을 할당하는 등의 일을 관리합니다.
파일을 읽을 때는 DFSStripedInputStream이라는 것을 사용합니다.
클라이언트는 필요한 데이터를 요청하면, 어떤 내부 블록에 그 데이터가 저장돼 있는지 계산하고, 여러 DataNode에 동시에 요청을 날립니다(병렬 읽기). 만약 일부 블록에 장애가 생겼다면 패리티 블록을 활용해서 디코딩(복구) 과정을 추가로 실행합니다.
정리하자면, 파일 읽고 쓰는 경로가 블록 그룹 기반으로 병렬화되어 더 빨라졌으며, 쓰기는 각 내부 블록마다 스트리머를 따로 만들어 DataNode에 보내고, 읽기는 필요한 블록을 계산해서 병렬로 가져오고, 장애가 있으면 복구도 지원한다고 할 수 있겠습니다.
다음은, DataNode 가 확장되었습니다.
DataNode는 HDFS에서 데이터를 실제로 저장하는 노드입니다. 이레이저 코딩을 사용하면, 데이터 블록과 패리티 블록이 함께 저장됩니다.
그런데, EC 블록 중 하나가 실패하면, 이 블록을 복구하기 위해 백그라운드에서 추가 작업(ECWorker)을 수행합니다. NameNode는 실패한 EC 블록을 감지하고, 이를 복구할 DataNode를 선택합니다.
이후, 복구 작업이 진행됩니다. 이 작업은 세 가지 단계로 이루어집니다.
첫 번째는 필요한 데이터와 패리티 블록만을 읽어와서 복구 작업을 위한 기초 데이터를 준비하며,
두 번째는 읽어온 데이터를 디코딩합니다. 이 디코딩 과정에서 누락된 데이터와 패리티 블록이 함께 복원됩니다.
마지막 세 번째로 디코딩이 끝난 후, 복구된 데이터 블록이 대상 DataNode로 전송되어 다시 저장됩니다.
마지막으로 ErasureCoding 정책을 확립하였습니다.
ErasureCodingPolicy 클래스에 캡슐화를 함으로써, HDFS 클러스터에서 복제(Replication)와 이레이저 코딩(EC)을 다르게 설정할 수 있습니다.
예를 들어, 파일 A는 복제 방식으로 내결함성을 지원하고, 파일 B는 이레이저 코딩 방식으로 내결함성을 지원할 수 있습니다.
Hadoop github ErasureCodingPolicy
▶ ③ Shell Script 개선
기존에는 Hadoop의 각 스크립트 파일에서 별도로 환경 변수를 설정했었습니다. 예를 들어, hdfs-daemon.sh, yarn-daemon.sh와 같은 스크립트에서 각각 환경 변수를 설정할 수 있었습니다.
만약 환경 변수가 여러 곳에 흩어져 있으면 관리가 매우 번거로웠습니다. 그러나 이제 모든 Hadoop 셸 스크립트가 hadoop-env.sh를 실행하도록 변경되었습니다. 이를 통해 하나의 파일에서 모든 환경 변수를 관리할 수 있습니다.
또한, Hadoop에서 데몬을 시작하고 중지하는 방식이 개선되었습니다. 예를 들어, 기존에는 hdfs-daemon.sh와 같은 개별 스크립트를 사용하여 데몬을 시작하거나 중지해야 했습니다. ( ./sbin/hadoop-daemon.sh start namenode )
이 명령어들에서 -daemon 옵션을 사용하여 더 일관성 있는 방식으로 데몬을 시작하거나 중지할 수 있도록 변경되었습니다. ( hdfs –daemon start namenode )
그 외에도 ssh, ${HADOOP_CONF_DIR}, 에러메시지 등이 개선되었습니다.
▶ ④ Shaded Client Jars
Hadoop 2 버전에서는 hadoop-client라는 라이브러리를 사용하여 Hadoop 애플리케이션이 Hadoop의 종속성을 애플리케이션의 클래스 경로에 추가하도록 했습니다.
이는, 어떤 라이브러리가 다른 라이브러리를 참조할 때, 그 참조된 라이브러리도 애플리케이션에 포함되는 경우를 말합니다. 예를 들어, Hadoop이 사용하는 라이브러리가 다른 버전으로 애플리케이션에 포함되면서, 버전 충돌이 발생할 수 있습니다.
Hadoop 3에서는 이러한 문제를 해결하기 위해 hadoop-client-api와 hadoop-client-runtime이라는 새로운 아티팩트를 도입했습니다.
💡 hadoop-client-api
이 아티팩트는 컴파일 범위로 설정되어 있습니다. 즉, 애플리케이션을 컴파일할 때만 필요한 라이브러리를 제공합니다.
예시: Hadoop 애플리케이션을 작성하는 동안 필요한 클래스와 메서드를 포함하지만, 실제 실행 시에는 사용되지 않습니다.
💡 hadoop-client-runtime
이 아티팩트는 런타임 범위로 설정되어 있습니다. 즉, 애플리케이션이 실행될 때 실제로 필요한 Hadoop의 종속성을 포함합니다.
이 아티팩트는 Hadoop의 모든 종속성을 단일 JAR 파일로 묶어서 제공합니다. 이로 인해 애플리케이션의 클래스 경로로 누출되는 Hadoop의 종속성을 완전히 방지할 수 있습니다.
예시를 하나 들어보면 HBase가 있습니다.
HBase는 Hadoop과 함께 사용되는 시스템인데, Hadoop의 구현 종속성 없이 Hadoop 클러스터와 통신을 할 수 있습니다. 이전에는 HBase가 Hadoop 클러스터와 통신하려면 Hadoop의 종속성을 애플리케이션에 추가해야 했고, 버전 충돌이 발생할 수 있었습니다.
Hadoop 3에서는 HBase가 Hadoop의 종속성을 포함하지 않고, 음영 처리된 Hadoop 클라이언트 API를 사용하여 안정적으로 Hadoop 클러스터와 통신할 수 있습니다.
▶ ⑤ Opportunistic Containers 의 지원
Opportunistic Containers 는 YARN에서 리소스가 부족하더라도 실행을 시도하는 컨테이너가 도입되었습니다.
기본적으로 리소스가 부족한 경우 대기 상태로 남아 있다가, 리소스가 할당되면 실행됩니다.
이 컨테이너는 Guaranteed Containers와 비교하여 우선순위가 낮습니다.
즉, 리소스가 부족하면 Guaranteed Containers가 먼저 실행되고, 그 후에 Opportunistic Containers가 실행됩니다.
이러한 컨테이너의 도입은 리소스를 절약할 수 있기 때문에 클러스터 사용률을 높일 수 있습니다.
( 또한, 분산 스케줄링 작업에서도 지원합니다. )
▶ ⑥ MapReduce 최적화
Hadoop 3에서는 MapReduce 작업의 성능 향상을 위해 맵 출력 수집기(map output collector)에 대한 네이티브(Java 외부, 주로 C/C++ 기반) 구현을 추가하였습니다.
이 최적화는 셔플(shuffle) 작업이 많은 경우, 작업 속도를 30% 이상 향상시킬 수 있습니다.
구체적으로, Hadoop 3는 NativeMapOutputCollector라는 네이티브 컴포넌트를 MapTask에 추가하였습니다.
이는 매퍼가 방출하는 (key, value) 쌍을 처리할 때 사용되며, 정렬(sort), 스필(spill), IFile 직렬화 과정을 모두 네이티브 코드로 수행합니다.
이러한 네이티브 최적화는 JNI(Java Native Interface)를 기반으로 구현되어, 자바만으로 처리할 때보다 훨씬 빠른 속도로 중간 데이터를 처리할 수 있습니다.
Hadoop github NativeMapOutputCollector
위에서 이해가 안갈 수 있는 용어를 조금 더 정리하였습니다. 이 부분은 앞으로 Map Reduce 개념을 공부하는데 있어서 중요합니다.
Spill(스필)은 맵 출력 결과가 메모리 버퍼를 초과할 때, 버퍼에 담긴 데이터를 디스크에 임시 파일로 기록하는 과정을 의미합니다.
MapReduce 작업은 메모리 용량에 한계가 있기 때문에, 일정 수준 이상으로 데이터가 쌓이면 자동으로 spill 작업을 수행합니다.
스필이 자주 발생하면 디스크 I/O 비용이 증가하여 성능 저하로 이어질 수 있으므로, 이를 최적화하는 것은 매우 중요합니다.
Shuffle(셔플)은 맵 단계에서 생성된 (key, value) 쌍들을 리듀스 작업으로 전달하기 위해 네트워크를 통해 이동시키고, 이를 키 기준으로 정렬하는 과정을 의미합니다.
셔플 과정은 MapReduce 전체 작업 중 가장 비용이 많이 드는 단계 중 하나입니다.
네트워크 전송과 디스크 쓰기·읽기, 정렬 과정이 복잡하게 얽혀 있기 때문에, 이 부분의 최적화가 전체 성능에 큰 영향을 미칩니다.
▶ ⑦ 2개 이상의 NameNode 지원
Hadoop V2에서는 HDFS NameNode HA 아키텍처가 단일 활성 NameNode와, 단일 대기 NameNode로 구성되어 있습니다.
이 아키텍처는 세 개의 JournalNode로 이루어진 쿼럼(quorum)에 편집 로그(edit log)를 복제하여, 하나의 NameNode에 장애가 발생하더라도 서비스를 지속할 수 있도록 설계되었습니다.
그러나 이후 비즈니스 크리티컬 환경에서는 하나 이상의 장애를 견딜 수 있는 더 높은 수준의 내결함성(fault tolerance)이 요구되었습니다.
이에 따라 Hadoop V3에서는 사용자가 여러 개의 대기(standby) NameNode를 실행할 수 있도록 기능을 확장하였습니다.
예를 들어, 3개의 NameNode(1개의 활성(active), 2개의 대기(standby))와 5개의 JournalNode로 클러스터를 구성할 수 있습니다.
이러한 구성에서는 최대 2개의 NameNode 장애를 허용할 수 있어, 시스템의 안정성과 가용성이 크게 향상됩니다.
2개 이상의 NameNode 를 지원할 수 있게 해주는 JournalNode 에 대해서 간단히 개요를 정리하면,
HDFS 고가용성 아키텍처에서 NameNode들의 편집 로그(edit log)를 저장하고 동기화하는 역할을 합니다.
활성 NameNode가 파일 시스템 메타데이터를 변경하면, 해당 변경사항을 JournalNode에 복제하여 기록합니다.
대기 NameNode는 이 JournalNode로부터 편집 로그를 읽어 들여 활성 NameNode와 항상 동기화된 상태를 유지합니다.
JournalNode는 별도의 저장소 노드로 구성되며, 안정적인 장애 복구를 위해 홀수 개로 설정하는 것이 일반적입니다.
쿼럼(Quorum)은 분산 시스템에서 의사 결정을 내리기 위해 필요한 최소한의 동의 수를 의미합니다.
HDFS에서는 JournalNode 집합에서 과반수 이상의 응답을 받아야 변경사항이 정상적으로 기록되었다고 인정합니다.
예를 들어 5개의 JournalNode를 구성할 경우, 3개 이상의 노드가 응답해야 쿼럼이 성립합니다.
( 마치 주키퍼와 같습니다. )
이러한 방식은 일부 노드가 장애를 일으켜도 시스템 전체가 안정적으로 동작할 수 있도록 보장합니다.
▶ ⑧ 기본 포트번호 변경
Hadoop V2에서는 여러 Hadoop 서비스의 기본 포트가 Linux의 임시 포트 범위(32768–61000) 내에 존재했습니다.
클라이언트 프로그램이 특정 포트 번호를 명시하지 않는 경우, 이 임시 포트 범위 내 포트가 사용됩니다.
이로 인해 서비스가 시작할 때 다른 응용 프로그램과 포트 충돌이 발생하여 포트에 바인딩할 수 없는 문제가 종종 발생하였습니다.
이 문제를 해결하기 위해, Hadoop 3에서는 여러 핵심 서비스(NameNode, Secondary NameNode, DataNode 등)의 기본 포트가 임시 포트 범위 바깥으로 이동되었습니다.
이로써 서비스 간 포트 충돌 가능성이 줄어들고 시스템 안정성이 향상되었습니다.
대표적으로 변경된 주요 포트는 다음과 같습니다.
Daemon
App
Hadoop 2.x Port
Hadoop 3 Port
NameNode
Hadoop HDFS NameNode
8020
9820
Hadoop HDFS NameNode HTTP UI
50070
9870
Hadoop HDFS NameNode HTTPS UI
50470
9871
Secondary NN
Secondary NameNode HTTP
50091
9869
Secondary NameNode HTTP UI
50090
9868
DataNode
Hadoop HDFS DataNode IPC
50020
9867
Hadoop HDFS DataNode
50010
9866
Hadoop HDFS DataNode HTTP UI
50075
9864
Hadoop HDFS DataNode HTTPS UI
50475
9865
▶ ⑨ 파일 시스템 커넥터 지원
Hadoop은 Microsoft Azure Data Lake 및 Aliyun Object Storage System과의 통합을 지원합니다.
이제 이 두 시스템을 Hadoop 호환 파일 시스템(Hadoop Compatible File System, HCFS)의 대체 저장소로 사용할 수 있습니다.
먼저 Microsoft Azure Data Lake 통합이 추가되었고, 이후에 Aliyun Object Storage System 통합이 추가되었습니다.
▶ ⑩ DataNode 내부 밸런서
기존 HDFS 구조에서 DataNode 는 하나의 DataNode 가 여러 개의 디스크를 관리하였습니다. ( 하나의 서버에 HDD, SDD 등 여러 디스크를 달 수 있음 )
그러나, 파일을 저장할 때 Hadoop 은 모든 디스크에 균등하게 데이터를 분산시켜 저장합니다. 그래서 디스크마다 데이터가 비슷한 비율로 채워집니다.
이러면, 새 디스크를 추가하거나 기존 디스크를 교체하면 DataNode 내의 디스크 간 데이터 분포 불균형 문제가 발생합니다.
기존의 HDFS Balancer 는 노드 간 데이터 균형만 조정 할 수 있었지, 하나의 DataNode 안에서의 디스크들 사이 균형은 조정할 수 없었습니다.
하지만 하둡 V3에서는 DataNode 내부 디스크 간 데이터 분포 불균형도 조정할 수 있습니다.
이를 위해 새로운 기능인 HDFS 디스크 밸런서(Disk Balancer)가 추가되었으며, 이 디스크 밸런서는 CLI(Command Line Interface) 명령어로 실행할 수 있습니다.
먼저 DataNode 안의 디스크들 사이에 데이터가 고르게 분포되도록 이동시킵니다.
예를 들어, 기존 디스크가 90% 차 있고 새 디스크가 5% 차 있다면, 데이터를 옮겨서 둘 다 비슷하게 50%, 50%로 맞추는 식입니다.
이렇게 하면 I/O 성능이 향상되고, 디스크 과부하 문제를 방지할 수 있습니다.
hdfs diskbalancer -plan <DataNode명> -out <플랜파일>
hdfs diskbalancer -execute <플랜파일>
▶ ⑪ 데몬 및 작업 Heap 관리 재작업
Hadoop 3에서는 데몬의 힙 메모리 관리와 MapReduce 작업에 관련된 설정 방식에 여러 가지 중요한 변경이 적용되었습니다.
먼저 데몬 Heap 크기 설정 방식이 변경되었습니다.
기존에는 HADOOP_HEAPSIZE 환경 변수를 사용하여 힙 크기를 지정했으나, 이제는 이 방식이 더 이상 사용되지 않습니다.
HADOOP_HEAPSIZE_MAX는 JVM의 최대 힙 크기(Xmx)를 지정합니다.
HADOOP_HEAPSIZE_MIN는 JVM의 초기 힙 크기(Xms)를 지정합니다.
이 변수들은 단위(MB, GB 등)를 지원하며, 숫자만 입력할 경우 메가바이트(MB)로 간주합니다.
또한, Hadoop은 이제 호스트 머신의 메모리 크기를 자동으로 감지하여 적절한 힙 크기를 자동으로 설정할 수 있습니다.
그리고, MapReduce 작업 Heap 크기 관리도 기존에 비해 단순화 되었습니다.
이제 작업 구성(mapreduce.map.memory.mb, mapreduce.reduce.memory.mb)과 Java 옵션(-Xmx)을 동시에 설정할 필요가 없습니다.
하나의 설정만으로도 Heap 크기가 자동으로 적용됩니다.
항목
Hadoop 2.x 방식
Hadoop 3.x 이후 변경점
데몬 힙 크기 설정 방식
HADOOP_HEAPSIZE 사용
HADOOP_HEAPSIZE_MAX, HADOOP_HEAPSIZE_MIN 사용
메모리 단위 지원
숫자만 입력 (MB로 간주)
MB, GB 등의 단위 명시 가능
자동 메모리 튜닝
없음 (수동 설정 필요)
호스트 메모리 크기에 따라 자동 조정 가능
작업 힙 설정 방식
구성값과 Java 옵션 모두 명시 필요
하나만 지정해도 자동 적용됨
기존 설정 호환성
기존 방식 유지
기존 구성은 영향을 받지 않고 그대로 작동함
▶ ⑫ YARN TimeLine Service V2
YARN Timeline Service는 YARN 클러스터에서 실행되는 애플리케이션들의 상태, 이벤트, 그리고 다양한 메타데이터를 수집하고 저장하는 핵심 컴포넌트입니다. 단순한 실행 상태뿐 아니라, 각 태스크(Task)의 상세 실행 내역, 오류 발생 기록, 자원 사용 통계 등 정밀한 데이터를 중앙 저장소에 기록함으로써, 운영자나 개발자가 문제를 쉽게 분석하고 성능을 최적화할 수 있도록 돕습니다.
YARN 환경에서 수십~수천 개의 애플리케이션이 동시에 실행되고, 각 애플리케이션은 수백 개의 컨테이너와 태스크를 포함하는 경우가 많습니다. 이런 상황에서 단순히 ResourceManager의 UI만으로는 실행 내역을 파악하기 어렵습니다.
예를 들어, 어떤 MapReduce 작업이 실패했는지, Spark 애플리케이션에서 어느 컨테이너가 자원 부족으로 종료되었는지, 또는 태스크 수행 시간이 평균보다 비정상적으로 길어진 경우가 어디인지 등을 파악해야 할 때, Timeline Service는 필수적입니다.
YARN Timeline Service는 이러한 요구를 충족시키기 위해 설계된 컴포넌트로, 처음에는 v1 버전으로 단순한 단일 서버 구조에서 시작되었습니다. Timeline Service v1은 모든 이벤트 데이터를 JSON 형태로 수집한 뒤, HDFS에 저장하는 방식을 사용했습니다. 구조가 단순하고 설정이 비교적 쉬웠기 때문에 소규모 또는 테스트 환경에서는 적합했지만, 대규모 클러스터에서는 여러 한계가 드러났습니다.
가장 큰 문제는 확장성 부족과 병목현상이었습니다.
읽기와 쓰기 요청이 모두 하나의 서버 인스턴스로 집중되다 보니, 작업이 몰릴 경우 이벤트 유실, 응답 지연, 시스템 과부하 등의 문제가 빈번하게 발생했습니다. 또한 UI나 REST API를 통해 이벤트를 조회할 때도 성능 저하가 잦았습니다.
이러한 한계를 극복하기 위해 Hadoop 3.0부터 Timeline Service v2 (TSv2)가 도입되었습니다.
TimeLine Service V2 (이하 TSv2) 는 확장성과 신뢰성 향상, 흐름과 집계를 통한 사용성 개선 이라는 두 가지 목표를 가지고 업그레이드 되었습니다.
v.2는 분산 작성기(distributed writer) 아키텍처를 채택하여 데이터 수집과 제공 기능을 분리하였습니다.
데이터 수집은 각 YARN 애플리케이션마다 하나의 수집기(installer collector)를 두어 분산 방식으로 처리하며, 데이터 제공은 REST API를 통해 쿼리를 처리하는 별도의 판독기 인스턴스가 전담합니다.
이러한 구조는 수평적 확장을 가능하게 하며, 읽기/쓰기 부하를 분산시켜 성능 저하 없이 안정적인 운영을 지원합니다.
백업 저장소로는 Apache HBase를 기본 값으로 사용합니다. Apache HBase는 대량의 데이터를 저장하고 처리하는 데 뛰어난 성능을 제공하며, 읽기 및 쓰기 작업에 대해 우수한 응답 시간을 유지하면서도 대규모로 잘 확장되기 때문에 타임라인 서비스 v.2의 저장소로 적합합니다.
다음, v2 사용성 향상에 대해 이야기하자면, 대부분의 사용자들은 개별 YARN 애플리케이션보다는, 여러 YARN 애플리케이션으로 구성된 하나의 논리적 단위, 즉 “흐름(flow)” 수준의 정보에 더 많은 관심을 가집니다. 실제로 하나의 작업을 완료하기 위해 여러 개의 YARN 애플리케이션이 집합적으로 실행되는 경우가 많습니다.
이에 따라, 타임라인 서비스 v.2는 이러한 흐름 개념을 명시적으로 지원합니다. 사용자는 흐름 단위로 애플리케이션들을 묶어 관리할 수 있으며, 이를 통해 전체 작업의 진행 상황이나 성능을 더 효과적으로 파악할 수 있습니다.
또한, 타임라인 서비스 v.2는 흐름 수준에서 메트릭(metric) 집계를 지원합니다. 이를 통해 개별 애플리케이션 단위가 아닌, 전체 흐름 단위로 리소스 사용량, 처리 시간 등의 메트릭을 통합적으로 확인할 수 있어, 사용자 입장에서 보다 직관적이고 유용한 모니터링이 가능합니다.
출처 : Yarn TimeLine V2
YARN 타임라인 서비스 v.2는 분산 아키텍처 기반으로 설계되어 데이터 수집과 저장, 조회의 효율성과 확장성을 극대화합니다.
이 서비스는 여러 개의 수집기(작성기)를 활용하여 백엔드 스토리지에 데이터를 저장합니다. 이러한 수집기들은 분산되어 있으며, 각 수집기는 전용 애플리케이션 마스터와 함께 동일한 노드에 배치됩니다. 해당 애플리케이션에 대한 모든 데이터는 리소스 관리자(ResourceManager)의 타임라인 수집기를 제외하고, 각 애플리케이션 수준에 배치된 타임라인 수집기로 전송됩니다.
지정된 애플리케이션의 경우, 애플리케이션 마스터는 관련 데이터를 로컬에 배치된 타임라인 수집기로 직접 기록합니다. 또한, 해당 애플리케이션의 컨테이너를 실행 중인 다른 노드의 노드 관리자(NodeManager)들도 애플리케이션 마스터가 위치한 노드의 타임라인 수집기에 데이터를 전달하여 기록합니다.
리소스 관리자는 자체적인 타임라인 수집기를 따로 유지하며, 시스템 전체에 대한 과도한 데이터 기록을 방지하기 위해 YARN의 일반적인 수명 주기 이벤트만 기록합니다.
한편, 타임라인 리더는 수집기와는 분리된 독립된 데몬 프로세스로 동작합니다.
이 리더는 REST API를 통해 클라이언트의 쿼리에 응답하는 역할을 하며, 읽기 요청 처리에 전념합니다. 이러한 구조는 읽기와 쓰기를 완전히 분리함으로써 성능을 최적화하고 시스템의 확장성을 더욱 높입니다.
해당 내용에 대한 자세한 이야기는 추후에 YARN 파트에서 자세히 포스팅 하겠습니다.
◈
✏️ 결론
이번 포스팅에서는 Hadoop 이 왜 등장하게 되었는지, 그 이전 시스템은 어떻게 발전해 왔고 어떤 문제가 있었는지를 알아보았습니다.
단순히 Hadoop 에 대해 바로 공부하는 것보다, 분산 시스템 그 자체에 대하여 먼저 공부하고 Hadoop 을 공부한다면, Hadoop 등장의 히스토리를 알 수 있고, 그로 인해 더 잘 이해할 수 있게 될 거라고 생각했습니다.
공부한 내용을 토대로 글을 포스팅하였기 때문에, 계속 깊게 더 공부하고 글을 남길 것이며, 해당 글도 계속 다시 읽고 복습 할 예정입니다.
다음 포스팅에서부터는 HDFS, Yarn, MapReduce 등 본격적으로 Hadoop 에 대해 정리할 예정입니다.
긴 글 읽어주셔서 감사합니다. 다음 포스팅에서 뵙겠습니다. :D
◈
📚 공부 참고 자료
📑 1. 패스트 캠퍼스 - 한 번에 끝내는 데이터 엔지니어링 초격차 패키지 Online.
📑 2. 분산처리에서 해결해야 할 challenges
📑 3. NoSQL 이란?
📑 4. CAP 이론에 대한 카산드라와 몽고DB
📑 5. 몽고DB 복제
📑 6. 하둡이란?
📑 7. Hadoop이란 무엇입니까?
📑 8. 하둡 3.0에선 무엇이 달라졌을까? ( Erasure Coding 을 이해하기 좋은 글 )
📑 9. OREILLY 하둡 완벽 가이드 4판
-
📘 Kafka RetryTopic 관련 기본 Template Bean 이름 변경
✏️ 1. 서론
개발자로서 오픈소스에 기여하는 일은 언젠가 꼭 해보고 싶은 목표 중 하나였습니다. 하지만 막상 시도하려고 하면, 어디서부터 시작해야 할지 막막한 것이 현실이었습니다.
그러던 중, 오픈소스 기여 방법을 안내해주는 커뮤니티를 알게 되었고, 그곳에서 멘토링 프로그램에 참여하게 되었습니다. 이슈를 찾고, 기여할 부분을 고민하며, 본격적으로 오픈소스의 세계에 발을 들이게 되었죠.
Kafka를 공부하던 어느 날, 문득 Spring Kafka의 내부 구현이 궁금해졌고, 자연스럽게 GitHub 저장소를 들여다보게 되었습니다. 거기서 발견한 하나의 이슈 — 작아 보이지만 사용자 혼란을 야기할 수 있는 문제 — 를 계기로 생애 첫 Pull Request를 만들게 되었고, 운 좋게도 머지되며 Spring Kafka의 공식 Contributor가 될 수 있었습니다.
이 글에서는 그 여정을 공유하며, 어떤 이슈를 발견하고 어떻게 기여했는지를 정리해보려 합니다. 오픈소스 기여를 꿈꾸지만 막연한 분들께 작게나마 도움이 되었으면 합니다.
◈
✏️ 2. 본론
🤔 2.1. 이슈 선택 과정
이슈를 선정할 때 가장 중요하게 생각한 기준은 기여가 비교적 쉬운가?, 그리고 해당 프로젝트의 소스 코드를 깊이 이해하지 않더라도 수정이 가능한가? 였습니다.
이러한 기준에 따라 Apache 프로젝트, Spring 프로젝트 등 여러 오픈소스 저장소를 둘러보며 적절한 이슈를 찾기 시작했습니다.
처음에는 Apache Jackrabbit Oak 프로젝트에서 기여할 만한 이슈를 찾을 수 있었습니다. 자세한 내용은 뒤에서 설명하겠지만, 결과적으로 해당 PR은 머지되지 못했고, 다시 새로운 이슈를 찾아 나섰습니다.
그러던 중, Spring-Kafka 에서 KafkaTemplate Bean 이름 불일치 이슈 를 발견하게 되었고, 그 내용을 바탕으로 기여하게 되었습니다.
📚 2.2. 이슈: KafkaTemplate Bean 이름 불일치
Spring Kafka의 공식 문서에서는 @RetryableTopic 이 기본적으로 사용할 KafkaTemplate 빈의 이름을 defaultRetryTopicKafkaTemplate라고 명시하고 있습니다. 그러나 실제 @RetryableTopic 의 JavaDoc에서는 다음과 같이 설명하고 있었습니다.
* If not specified, a bean with name {@code retryTopicDefaultKafkaTemplate} or {@code kafkaTemplate} will be looked up.
즉, JavaDoc에는 retryTopicDefaultKafkaTemplate, 공식 문서에는 defaultRetryTopicKafkaTemplate라는 이름이 나와 있었고, 테스트 코드 일부도 JavaDoc 쪽 이름을 따라가고 있었습니다. 이로 인해 어떤 빈 이름을 사용해야 하는지 명확하지 않았습니다.
👉 해당 이슈는 Spring Kafka GitHub Issue #3514 에서 논의되었습니다.
💻 2.3. PR: JavaDoc 정정
먼저, @RetryableTopic 어노테이션 클래스의 JavaDoc을 공식 문서와 일치하도록 수정했습니다.
Before:
* The bean name of the {@link org.springframework.kafka.core.KafkaTemplate} bean that
* will be used to forward the message to the retry and Dlt topics. If not specified,
* a bean with name {@code retryTopicDefaultKafkaTemplate} or {@code kafkaTemplate}
* will be looked up.
*
* @return the kafkaTemplate bean name.
After:
* The bean name of the {@link org.springframework.kafka.core.KafkaTemplate} bean that
* will be used to forward the message to the retry and Dlt topics. If not specified,
* a bean with name {@code defaultRetryTopicKafkaTemplate} or {@code kafkaTemplate}
* will be looked up.
*
* @return the kafkaTemplate bean name.
bean 으로 retryTopicDefaultKafkaTemplate 대신 defaultRetryTopicKafkaTemplate 사용한다고 수정을 하였습니다.
이 수정으로 공식 문서와 JavaDoc이 일치하게 되었고, 실제 사용자들이 혼란 없이 올바른 빈 이름을 사용할 수 있게 되었습니다.
👉 Commit Message → Fix: Replace retryTopicDefaultKafkaTemplate with defaultRetryTopicKafkaTemplate in docs
💻 2.4. PR: 테스트 코드 정정
또한 테스트 코드 내에서도 잘못된 빈 이름 문자열을 직접 명시하고 있었습니다. RetryTopicConfigurationProviderTests 라는 테스트 클래스에서 사용 중이었는데, 이 부분은 상수 RetryTopicBeanNames.DEFAULT_KAFKA_TEMPLATE_BEAN_NAME 을 사용하도록 리팩터링했습니다.
Before:
@Test
void shouldProvideFromAnnotation() {
// setup
willReturn(kafkaOperations).given(beanFactory).getBean("retryTopicDefaultKafkaTemplate", KafkaOperations.class);
// given
RetryTopicConfigurationProvider provider = new RetryTopicConfigurationProvider(beanFactory);
RetryTopicConfiguration configuration = provider.findRetryConfigurationFor(topics, annotatedMethod, bean);
RetryTopicConfiguration configurationFromClass = provider
.findRetryConfigurationFor(topics, null, AnnotatedClass.class, bean);
// then
then(this.beanFactory).should(times(0)).getBeansOfType(RetryTopicConfiguration.class);
assertThat(configuration).isNotNull();
assertThat(configurationFromClass).isNotNull();
}
@Test
void shouldProvideFromMetaAnnotation() {
// setup
willReturn(kafkaOperations).given(beanFactory).getBean("retryTopicDefaultKafkaTemplate", KafkaOperations.class);
// given
RetryTopicConfigurationProvider provider = new RetryTopicConfigurationProvider(beanFactory);
RetryTopicConfiguration configuration = provider.findRetryConfigurationFor(topics, metaAnnotatedMethod, bean);
RetryTopicConfiguration configurationFromClass = provider
.findRetryConfigurationFor(topics, null, MetaAnnotatedClass.class, bean);
// then
then(this.beanFactory).should(times(0)).getBeansOfType(RetryTopicConfiguration.class);
assertThat(configuration).isNotNull();
assertThat(configuration.getConcurrency()).isEqualTo(3);
assertThat(configurationFromClass).isNotNull();
assertThat(configurationFromClass.getConcurrency()).isEqualTo(3);
}
After:
@Test
void shouldProvideFromAnnotation() {
// setup
willReturn(kafkaOperations).given(beanFactory).getBean(RetryTopicBeanNames.DEFAULT_KAFKA_TEMPLATE_BEAN_NAME, KafkaOperations.class);
// given
RetryTopicConfigurationProvider provider = new RetryTopicConfigurationProvider(beanFactory);
RetryTopicConfiguration configuration = provider.findRetryConfigurationFor(topics, annotatedMethod, bean);
RetryTopicConfiguration configurationFromClass = provider
.findRetryConfigurationFor(topics, null, AnnotatedClass.class, bean);
// then
then(this.beanFactory).should(times(0)).getBeansOfType(RetryTopicConfiguration.class);
assertThat(configuration).isNotNull();
assertThat(configurationFromClass).isNotNull();
}
@Test
void shouldProvideFromMetaAnnotation() {
// setup
willReturn(kafkaOperations).given(beanFactory).getBean(RetryTopicBeanNames.DEFAULT_KAFKA_TEMPLATE_BEAN_NAME, KafkaOperations.class);
// given
RetryTopicConfigurationProvider provider = new RetryTopicConfigurationProvider(beanFactory);
RetryTopicConfiguration configuration = provider.findRetryConfigurationFor(topics, metaAnnotatedMethod, bean);
RetryTopicConfiguration configurationFromClass = provider
.findRetryConfigurationFor(topics, null, MetaAnnotatedClass.class, bean);
// then
then(this.beanFactory).should(times(0)).getBeansOfType(RetryTopicConfiguration.class);
assertThat(configuration).isNotNull();
assertThat(configuration.getConcurrency()).isEqualTo(3);
assertThat(configurationFromClass).isNotNull();
assertThat(configurationFromClass.getConcurrency()).isEqualTo(3);
}
"retryTopicDefaultKafkaTemplate" 으로 되어있던 Bean 이름 부분을 RetryTopicBeanNames.DEFAULT_KAFKA_TEMPLATE_BEAN_NAME 로 변경하였습니다.
이를 통해 빈 이름도 변경하였고, 하드코딩된 문자열을 제거하면서, 상수를 사용하는 방식으로 코드의 일관성과 안정성을 확보했습니다. 추후 KafkaTemplate 빈 이름이 변경되더라도 상수만 수정하면 되므로 유지보수가 용이해졌습니다.
👉 Commit Message → Fix: Replace retryTopicDefaultKafkaTemplate with RetryTopicBeanNames.DEFAULT_KAFKA_TEMPLATE_BEAN_NAME in Test Code
그 결과, Spring Kafka 의 Contributor 가 되었고, 제 생의 첫 오픈 소스 기여에 성공하게 되었습니다.
해당 PR 내용을 확인하고 싶다면, Spring Kafka PR 기여 성공 해당 링크를 클릭하시기 바랍니다.
🤣 2.5. 배움: Apache Jackrabbit Oak 기여 실패 경험
위에서 언급하였지만, Spring Kafka 를 기여하기 전에, Apache Jackrabbit Oak 의 이슈를 하나 먼저 찾을 수 있었습니다.
💡 Apache Jackrabbit Oak ?
파일이나 문서 같은 데이터를 트리 구조로 저장하고 관리해주는 시스템입니다.
Java로 만들어졌고, Adobe Experience Manager(AEM) 같은 유명한 콘텐츠 관리 시스템에서 내부 저장소로 사용되고 있습니다.
예를 들면, 웹사이트에서 페이지를 만들거나 이미지를 업로드할 때, 그 데이터를 저장하는 데 Oak가 쓰일 수 있습니다.
파일이나 폴더를 디렉토리처럼 관리하고, 누가 언제 바꿨는지 기록도 남길 수 있어서, 문서 관리 시스템이나 CMS에 딱 맞는 구조입니다.
당시 이슈 내용은 다음과 같았는데
MAX_SEGMENT_SIZE 상수가 Segment와 SegmentDataUtil s에 중복 정의되어 있으니 하나로 공유 할 수 있게 해달라는 내용이었습니다.
Segment 라는 클래스에서 static final int MAX_SEGMENT_SIZE = 1 << 18; 로 되어있는 부분에 public 을 달아주고, 그걸 SegmentDataUtil 에서 사용 할 수 있게 해주는게 전부였습니다.
그래서 아래와 같이, 코드를 수정하려 PR 을 보냈습니다.
하지만 결과는 Merge 실패였습니다.
이유는 간단했습니다.
이미 저보다 먼저 해당 이슈를 캐치하고 PR 을 올린 사람이 있었기 때문이었습니다.
오픈 소스 생태계를 보면, 처음에는 잘 몰라서 실수 할 수 있는 2가지가 있습니다. 하나는 완료 된 이슈인데도 이슈가 Close 되어있지 않은게 있을 수 있다는 것입니다.
또 다른 하나는 누군가 해당 이슈를 해결하기 위해 Assign 을 받았는데, 그것을 가로채는 행동을 해서는 안된다는 것입니다.
보통은 이슈에 댓글로 본인이 해결하겠다고 리플을 달아두고 진행을 합니다. 그렇기 때문에, 어떠한 이슈를 해결하고자 한다면, 꼭 댓글에 본인이 해결하겠다는 의사를 밝히고, 진행하는 것이 좋습니다.
그리고 다른 사람이 진행을 하겠다고 댓글을 이미 남긴 상태라면, 그걸 가로채서는 안됩니다. 다만, 댓글이 달리고 한참이 지났는데도 해결이 되지 않은 이슈라면, 해당 이슈가 현재 해결 중인지, 그게 아니라면 내가 해결을 해도 되는지 댓글로 물어보고 진행을 하는 방향도 있습니다.
◈
✏️ 3. 결론
이번 기여를 통해 Spring Kafka 문서와 코드의 불일치 문제를 해결하며, 보다 명확하고 유지보수하기 쉬운 기반을 마련하는 데 작은 보탬이 될 수 있었습니다.
비록 작고 사소한 변경처럼 보일 수 있지만, 실제 사용자 경험에 직접적인 영향을 줄 수 있다는 점에서 그 의미는 결코 작지 않았습니다. 무엇보다 오픈소스 생태계의 문화, 프로세스, 협업 방식을 실제로 체험하고 배울 수 있었던 값진 경험이었습니다.
오픈소스 기여를 어렵게만 느끼는 분들도 많겠지만, 완벽하게 아는 상태에서 시작하는 사람은 없습니다. 저 역시 문서 하나 고치고, 테스트 코드 한 줄 바꾸는 데서 시작했습니다.
작은 기여도 충분히 의미 있고, 오픈소스 커뮤니티는 그런 기여를 소중히 여깁니다.
👨 “중요한 건 완벽함이 아니라 시작입니다. 지금 여러분도 첫 발을 내디뎌보세요.”
-
MEMO - Flink Table Maintenance
Overview
When using Apache Iceberg tables in a Flink streaming environment, it’s important to automate table maintenance operations such as snapshot expiration, small file compaction, and orphan file cleanup.
Previously, these maintenance tasks were only available through Iceberg Spark Actions, requiring a separate Spark cluster. However, maintaining Spark infrastructure just for table optimization adds complexity and operational overhead.
The TableMaintenance API in Apache Iceberg enables Flink jobs to execute these maintenance tasks natively within the same streaming job or as an independent Flink job, avoiding the need for external systems. This approach simplifies architecture, reduces costs, and improves automation.
Quick Start
Here’s a simple example that sets up automated maintenance for an Iceberg table.
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
TableLoader tableLoader = TableLoader.fromHadoopTable("path/to/table");
TriggerLockFactory lockFactory = TriggerLockFactory.defaultLockFactory();
TableMaintenance.forTable(env, tableLoader, lockFactory)
.uidSuffix("my-maintenance-job")
.rateLimit(Duration.ofMinutes(10))
.lockCheckDelay(Duration.ofSeconds(10))
.add(ExpireSnapshots.builder()
.scheduleOnCommitCount(10)
.maxSnapshotAge(Duration.ofMinutes(10))
.retainLast(5)
.deleteBatchSize(5)
.parallelism(8))
.add(RewriteDataFiles.builder()
.scheduleOnDataFileCount(10)
.targetFileSizeBytes(128 * 1024 * 1024)
.partialProgressEnabled(true)
.partialProgressMaxCommits(10))
.append();
env.execute("Table Maintenance Job");
Configuration Options
TableMaintenance Builder
Method
Description
Default
uidSuffix(String)
Unique identifier suffix for the job
Random UUID
rateLimit(Duration)
Minimum interval between task executions
60 seconds
lockCheckDelay(Duration)
Delay for checking lock availability
30 seconds
parallelism(int)
Default parallelism for maintenance tasks
System default
maxReadBack(int)
Max snapshots to check during initialization
100
ExpireSnapshots Configuration
Method
Description
Default Value
Type
maxSnapshotAge(Duration)
Maximum age of snapshots to retain
No limit
Duration
retainLast(int)
Minimum number of snapshots to retain
1
int
deleteBatchSize(int)
Number of files to delete in each batch
1000
int
scheduleOnCommitCount(int)
Trigger after N commits
No automatic scheduling
int
scheduleOnDataFileCount(int)
Trigger after N data files
No automatic scheduling
int
scheduleOnDataFileSize(long)
Trigger after total data file size (bytes)
No automatic scheduling
long
scheduleOnIntervalSecond(long)
Trigger after time interval (seconds)
No automatic scheduling
long
parallelism(int)
Parallelism for this specific task
Inherits from TableMaintenance
int
RewriteDataFiles Configuration
Method
Description
Default Value
Type
targetFileSizeBytes(long)
Target size for rewritten files
Table property or 512MB
long
partialProgressEnabled(boolean)
Enable partial progress commits
false
boolean
partialProgressMaxCommits(int)
Maximum commits for partial progress
10
int
scheduleOnCommitCount(int)
Trigger after N commits
10
int
scheduleOnDataFileCount(int)
Trigger after N data files
1000
int
scheduleOnDataFileSize(long)
Trigger after total data file size (bytes)
100GB
long
scheduleOnIntervalSecond(long)
Trigger after time interval (seconds)
600 (10 minutes)
long
maxRewriteBytes(long)
Maximum bytes to rewrite per execution
Long.MAX_VALUE
long
parallelism(int)
Parallelism for this specific task
Inherits from TableMaintenance
int
Flink Configuration Options
You can also configure maintenance behavior through Flink configuration:
Configuration Key
Description
Default Value
Type
iceberg.maintenance.rate-limit-seconds
Rate limit in seconds
60
long
iceberg.maintenance.lock-check-delay-seconds
Lock check delay in seconds
30
long
iceberg.maintenance.rewrite.max-bytes
Maximum rewrite bytes
Long.MAX_VALUE
long
iceberg.maintenance.rewrite.schedule.commit-count
Schedule on commit count
10
int
iceberg.maintenance.rewrite.schedule.data-file-count
Schedule on data file count
1000
int
iceberg.maintenance.rewrite.schedule.data-file-size
Schedule on data file size
100GB
long
iceberg.maintenance.rewrite.schedule.interval-second
Schedule interval in seconds
600
long
Complete Example
public class TableMaintenanceJob {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60000); // Enable checkpointing
// Configure table loader
TableLoader tableLoader = TableLoader.fromCatalog(
CatalogLoader.hive("my_catalog", configuration),
TableIdentifier.of("database", "table")
);
// Set up maintenance with comprehensive configuration
TableMaintenance.forTable(env, tableLoader, TriggerLockFactory.defaultLockFactory())
.uidSuffix("production-maintenance")
.rateLimit(Duration.ofMinutes(15))
.lockCheckDelay(Duration.ofSeconds(30))
.parallelism(4)
// Daily snapshot cleanup
.add(ExpireSnapshots.builder()
.maxSnapshotAge(Duration.ofDays(7))
.retainLast(10)
.deleteBatchSize(1000)
.scheduleOnCommitCount(50)
.parallelism(2))
// Continuous file optimization
.add(RewriteDataFiles.builder()
.targetFileSizeBytes(256 * 1024 * 1024)
.minFileSizeBytes(32 * 1024 * 1024)
.scheduleOnDataFileCount(20)
.partialProgressEnabled(true)
.partialProgressMaxCommits(5)
.maxRewriteBytes(2L * 1024 * 1024 * 1024)
.parallelism(6))
.append();
env.execute("Iceberg Table Maintenance");
}
}
Scheduling Options
Maintenance tasks can be triggered based on various conditions:
Time-based Scheduling
ExpireSnapshots.builder()
.scheduleOnIntervalSecond(3600)
Commit-based Scheduling
RewriteDataFiles.builder()
.scheduleOnCommitCount(50)
Data Volume-based Scheduling
RewriteDataFiles.builder()
.scheduleOnDataFileCount(500)
.scheduleOnDataFileSize(50L * 1024 * 1024 * 1024)
IcebergSink with Post-Commit Integration
Apache Iceberg Sink V2 for Flink allows automatic execution of maintenance tasks after data is committed to the table, using the addPostCommitTopology(…) method.
IcebergSink.forRowData(dataStream)
.table(table)
.tableLoader(tableLoader)
.setAll(properties)
.addPostCommitTopology(
TableMaintenance.forTable(env, tableLoader, TriggerLockFactory.defaultLockFactory())
.rateLimit(Duration.ofMinutes(10))
.add(ExpireSnapshots.builder().scheduleOnCommitCount(10))
.add(RewriteDataFiles.builder().scheduleOnDataFileCount(50))
)
.append();
This approach executes maintenance tasks in the same job as the sink, enabling real-time optimization without running a separate job.
Lock Configuration Example
Iceberg uses a locking mechanism to prevent multiple Flink jobs from performing maintenance on the same table simultaneously. Locks are provided via the TriggerLockFactory and support either JDBC or ZooKeeper backends.
JDBC Lock Example
flink-maintenance.lock.type=jdbc
flink-maintenance.lock.lock-id=catalog.db.table
flink-maintenance.lock.jdbc.uri=jdbc:postgresql://localhost:5432/iceberg
flink-maintenance.lock.jdbc.user=flink
flink-maintenance.lock.jdbc.password=flinkpw
flink-maintenance.lock.jdbc.init-lock-tables=true
JDBC-based locking is recommended for most production environments.
ZooKeeper Lock Example
flink-maintenance.lock.type=zookeeper
flink-maintenance.lock.zookeeper.uri=zk://zk1:2181,zk2:2181
flink-maintenance.lock.zookeeper.session-timeout-ms=60000
flink-maintenance.lock.zookeeper.connection-timeout-ms=15000
flink-maintenance.lock.zookeeper.max-retries=3
flink-maintenance.lock.zookeeper.base-sleep-ms=3000
Use ZooKeeper-based locks only in high-availability or multi-process coordination environments.
Best Practices
Resource Management
Use dedicated slot sharing groups for maintenance tasks
Set appropriate parallelism based on cluster resources
Enable checkpointing for fault tolerance
Scheduling Strategy
Avoid too frequent executions with rateLimit
Use scheduleOnCommitCount for write-heavy tables
Use scheduleOnDataFileCount for fine-grained control
Performance Tuning
Adjust deleteBatchSize based on storage performance
Enable partialProgressEnabled for large rewrite operations
Set reasonable maxRewriteBytes limits
Troubleshooting
Common Issues
OutOfMemoryError during file deletion:
.deleteBatchSize(500)
Lock conflicts:
.lockCheckDelay(Duration.ofMinutes(1))
.rateLimit(Duration.ofMinutes(10))
Slow rewrite operations:
.partialProgressEnabled(true)
.partialProgressMaxCommits(3)
.maxRewriteBytes(1024 * 1024 * 1024)
Touch background to close