데이터 타입 & 직렬화

데이터 타입 & 직렬화

Apache Flink는 고유한 타입 기술자(type descriptors), 일반 타입 추출(generic type extraction), 타입 직렬화 프레임워크를 포함하여 데이터 타입과 직렬화를 독특한 방식으로 처리합니다. 이 문서는 이러한 개념과 그 배경 이론을 설명합니다.

출처: 문서

본문

지원되는 데이터 타입

Flink는 DataStream에 있을 수 있는 요소 타입에 몇 가지 제한을 둡니다. 그 이유는 시스템이 효율적인 실행 전략을 결정하기 위해 타입을 분석하기 때문입니다.

여덟 가지 서로 다른 데이터 타입 범주가 있습니다.

  1. Java Tuples
  2. Java POJOs
  3. 기본 타입(Primitive Types)
  4. 공통 컬렉션 타입(Common Collection Types)
  5. 일반 클래스(Regular Classes)
  6. Value(값)
  7. Hadoop Writables
  8. 특수 타입(Special Types)

Tuples

Tuples은 다양한 타입의 고정된 수의 필드를 포함하는 복합 타입입니다. Java API는 Tuple1부터 Tuple25까지의 클래스를 제공합니다. Tuple의 각 필드는 더 많은 tuple을 포함한 임의의 Flink 타입일 수 있으며, 이로 인해 중첩된 tuple이 생성됩니다. Tuple의 필드는 필드 이름으로 tuple.f4처럼 직접 접근하거나 일반 getter 메서드 tuple.getField(int position)을 사용해 접근할 수 있습니다. 필드 인덱스는 0에서 시작합니다. 이는 Scala tuple과 대조적이지만 Java의 일반 인덱싱과 더 일관적입니다.

DataStream<Tuple2<String, Integer>> wordCounts = env.fromElements(
    new Tuple2<String, Integer>("hello", 1),
    new Tuple2<String, Integer>("world", 2));

wordCounts.map(new MapFunction<Tuple2<String, Integer>, Integer>() {
    @Override
    public Integer map(Tuple2<String, Integer> value) throws Exception {
        return value.f1;
    }
});

wordCounts.keyBy(value -> value.f0);

POJOs

Java 클래스는 다음 요구사항을 충족하면 Flink에 의해 특수 POJO 데이터 타입으로 취급됩니다.

  • 클래스는 public이어야 합니다.
  • 인수가 없는 public 생성자(기본 생성자)가 있어야 합니다.
  • 모든 필드는 public이거나 getter와 setter 함수를 통해 접근 가능해야 합니다. foo라는 필드의 경우 getter와 setter 메서드는 getFoo()setFoo()로 이름 붙여져야 합니다.
  • 필드의 타입은 등록된 직렬화기가 지원해야 합니다.

Java records는 Flink 1.19부터 POJO 타입으로도 인식됩니다(FLINK-32380). public record 클래스는 PojoSerializer에 의해 정식 생성자(canonical constructor)를 통해 직렬화됩니다. 위의 무인수 생성자와 getter/setter 요구사항은 적용되지 않습니다.

POJO는 일반적으로 PojoTypeInfo로 표현되며 PojoSerializer로 직렬화됩니다(Kryo를 구성 가능한 폴백으로 사용). 예외는 POJO가 실제로 Avro 타입(Avro Specific Records)이거나 "Avro Reflect Types"로 생성된 경우입니다. 그 경우 POJO는 AvroTypeInfo로 표현되고 AvroSerializer로 직렬화됩니다. 필요한 경우 사용자 지정 직렬화기를 등록할 수도 있습니다. 자세한 내용은 Serialization을 참조하세요.

Flink는 POJO 타입의 구조를 분석합니다. 즉 POJO의 필드를 학습합니다. 결과적으로 POJO 타입은 일반 타입보다 사용하기 쉽습니다. 또한 Flink는 POJO를 일반 타입보다 더 효율적으로 처리할 수 있습니다.

flink-test-utilsorg.apache.flink.types.PojoTestUtils#assertSerializedAsPojo()로 클래스가 POJO 요구사항을 준수하는지 테스트할 수 있습니다. POJO의 어떤 필드도 Kryo로 직렬화되지 않도록 보장하려면 대신 assertSerializedAsPojoWithoutKryo()를 사용하세요.

다음 예시는 두 개의 public 필드를 가진 간단한 POJO를 보여줍니다.

public class WordWithCount {

    public String word;
    public int count;

    public WordWithCount() {}

    public WordWithCount(String word, int count) {
        this.word = word;
        this.count = count;
    }
}

DataStream<WordWithCount> wordCounts = env.fromElements(
    new WordWithCount("hello", 1),
    new WordWithCount("world", 2));

wordCounts.keyBy(value -> value.word);

기본 타입

Flink는 Integer, String, Double 같은 모든 Java 기본 타입을 지원합니다.

공통 컬렉션 타입

Flink는 일반적인 Java 컬렉션 타입에 대한 전용 직렬화 지원을 제공하며, 이는 타입 메타데이터의 분석과 직렬화를 피함으로써 범용 직렬화 프레임워크를 거치는 것보다 더 효율적입니다. 현재 Map, List, Set과 그 상위 인터페이스 Collection만 지원됩니다. 이를 활용하려면 컬렉션 타입을 다음으로 선언해야 합니다.

  1. 구체적 타입 인자: 예: List<String>이지만 List, List<T> 또는 List<?>는 안 됩니다. Flink가 요소 타입의 직렬화를 디스패치하는 데 필요하기 때문입니다.
  2. 인터페이스 타입: 예: List<String>이지만 LinkedList<String>은 안 됩니다. Flink가 직렬화를 통해 기본 구현 타입을 보존하지 않기 때문입니다.

다른 자격 없는 컬렉션 타입은 일반 클래스 타입으로 Flink가 처리합니다. 구현 타입도 보존되어야 한다면 사용자 지정 직렬화기로 등록해야 합니다.

일반 클래스 타입

Flink는 대부분의 Java 클래스(API 및 사용자 지정)를 지원합니다. 파일 포인터, I/O 스트림 또는 다른 네이티브 리소스 같은 직렬화할 수 없는 필드를 포함하는 클래스에는 제한이 적용됩니다. Java Beans 규칙을 따르는 클래스는 일반적으로 잘 작동합니다.

POJO 타입으로 식별되지 않는(위 POJO 요구사항 참조) 모든 클래스는 Flink가 일반 클래스 타입으로 처리합니다. Flink는 이러한 데이터 타입을 블랙 박스로 취급하며 그 내용(예: 효율적인 정렬)에 접근할 수 없습니다. 일반 타입은 Kryo 직렬화 프레임워크를 사용해 직렬화/역직렬화됩니다.

Value

Value 타입은 직렬화와 역직렬화를 수동으로 기술합니다. 범용 직렬화 프레임워크를 거치는 대신 readwrite 메서드로 org.apache.flink.types.Value 인터페이스를 구현하여 이러한 연산을 위한 사용자 지정 코드를 제공합니다. 범용 직렬화가 매우 비효율적일 때 Value 타입을 사용하는 것이 합리적입니다. 예를 들어 요소의 희소 벡터를 배열로 구현하는 데이터 타입이 있습니다. 배열이 대부분 0임을 알면 0이 아닌 요소에 대해 특수 인코딩을 사용할 수 있지만, 범용 직렬화는 단순히 모든 배열 요소를 쓰게 됩니다.

org.apache.flink.types.CopyableValue 인터페이스는 유사한 방식으로 수동 내부 클로닝 로직을 지원합니다.

Flink는 기본 데이터 타입에 해당하는 사전 정의된 Value 타입을 제공합니다. (ByteValue, ShortValue, IntValue, LongValue, FloatValue, DoubleValue, StringValue, CharValue, BooleanValue). 이러한 Value 타입은 기본 데이터 타입의 변경 가능한(mutable) 변형으로 작동합니다: 그 값은 변경될 수 있어 프로그래머가 객체를 재사용하고 가비지 컬렉터의 부담을 덜 수 있습니다.

Hadoop Writables

org.apache.hadoop.Writable 인터페이스를 구현하는 타입을 사용할 수 있습니다. write()readFields() 메서드에 정의된 직렬화 로직이 직렬화에 사용됩니다.

특수 타입

Java API는 Either의 자체 사용자 지정 구현을 가지고 있습니다. Scala의 Either와 유사하게 두 가지 가능한 타입(Left 또는 Right)의 값을 나타냅니다. Either는 오류 처리 또는 두 가지 다른 유형의 레코드를 출력해야 하는 연산자에 유용할 수 있습니다.

타입 소거(Type Erasure) 및 타입 추론

참고: 이 섹션은 Java에만 관련됩니다.

Java 컴파일러는 컴파일 후 일반 타입 정보의 대부분을 버립니다. 이를 Java에서 *타입 소거(type erasure)*라고 합니다. 이는 런타임에 객체의 인스턴스가 더 이상 자신의 일반 타입을 알지 못함을 의미합니다. 예를 들어 DataStream<String>DataStream<Long>의 인스턴스는 JVM에 동일하게 보입니다.

Flink는 프로그램을 실행할 준비를 할 때(프로그램의 main 메서드가 호출될 때) 타입 정보가 필요합니다. Flink Java API는 버려진 타입 정보를 다양한 방식으로 재구성하고 데이터 집합과 연산자에 명시적으로 저장하려고 시도합니다. DataStream.getType()으로 타입을 검색할 수 있습니다. 이 메서드는 Flink의 내부 타입 표현 방식인 TypeInformation의 인스턴스를 반환합니다.

타입 추론에는 한계가 있으며 어떤 경우에는 프로그래머의 "협력"이 필요합니다. 그 예로는 StreamExecutionEnvironment.fromCollection()처럼 컬렉션에서 데이터 집합을 만드는 메서드가 있으며, 여기서 타입을 설명하는 인수를 전달할 수 있습니다. 그러나 MapFunction<I, O> 같은 일반 함수도 추가 타입 정보가 필요할 수 있습니다.

ResultTypeQueryable 인터페이스는 입력 포맷과 함수가 자신의 반환 타입을 API에 명시적으로 알려주도록 구현할 수 있습니다. 함수가 호출되는 입력 타입은 보통 이전 연산의 결과 타입에서 추론될 수 있습니다.

Flink의 타입 처리

Flink는 분산 계산 중 교환되고 저장되는 데이터 타입에 대한 많은 정보를 추론하려고 시도합니다. 데이터베이스가 테이블의 스키마를 추론하는 것처럼 생각하세요. 대부분의 경우 Flink는 필요한 모든 정보를 스스로 원활하게 추론합니다. 타입 정보를 가지면 Flink가 멋진 일을 할 수 있습니다.

  • Flink가 데이터 타입에 대해 더 많이 알수록 직렬화와 데이터 레이아웃 체계가 더 좋아집니다. 이는 Flink의 메모리 사용 패러다임(가능한 곳에서는 힙 안팎에서 직렬화된 데이터로 작업하고 직렬화를 매우 저렴하게 만드는 것)에 매우 중요합니다.
  • 마지막으로 대부분의 경우 직렬화 프레임워크에 대해 걱정하고 타입을 등록해야 하는 수고에서 사용자를 면제해줍니다.

일반적으로 데이터 타입에 대한 정보는 pre-flight 단계 - 즉 프로그램이 DataStream을 호출하고 execute(), print(), count() 또는 collect()를 호출하기 전 - 에 필요합니다.

가장 빈번한 문제

사용자가 Flink의 데이터 타입 처리와 상호 작용해야 하는 가장 빈번한 문제는 다음과 같습니다.

  • 하위 타입 등록: 함수 시그니처가 상위 타입만 기술하지만 실행 중 실제로는 그 하위 타입을 사용한다면, Flink가 이러한 하위 타입을 인식하게 하는 것이 성능을 크게 향상시킬 수 있습니다. 이를 위해 pipeline.serialization-config를 통해 각 하위 타입에 대한 직렬화기를 등록하세요.
pipeline.serialization-config:
# register serializer for POJO types
  - org.example.MyCustomType1: {type: pojo, class: org.example.MyCustomSerializer1}
# register serializer for generic types with Kryo
  - org.example.MyCustomType2: {type: kryo, kryo-type: registered, class: org.example.MyCustomSerializer2}

프로그래밍 방식으로도 설정할 수 있습니다(코드 예시에서는 더 간결한 flow-style YAML을 사용하고, 위 예시에서는 가독성을 위해 block-style YAML 목록을 사용한다는 점에 유의하세요).

Configuration config = new Configuration();
config.set(PipelineOptions.SERIALIZATION_CONFIG,
    List.of("org.example.MyCustomType1: {type: pojo, class: org.example.MyCustomSerializer1}",
        "org.example.MyCustomType2: {type: kryo, kryo-type: registered, class: org.example.MyCustomSerializer2}"));
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(config);
  • 사용자 지정 직렬화기 등록: Flink는 스스로 투명하게 처리하지 못하는 타입에 대해 Kryo로 폴백합니다. 모든 타입이 Kryo(그리고 따라서 Flink)에 원활하게 처리되는 것은 아닙니다. 예를 들어 많은 Google Guava 컬렉션 타입은 기본적으로 잘 작동하지 않습니다. 해결책은 pipeline.serialization-config를 통해 문제를 일으키는 타입에 대한 추가 직렬화기를 등록하는 것입니다.
pipeline.serialization-config:
  - org.example.MyCustomType: {type: kryo, kryo-type: default, class: org.example.MyCustomSerializer}

추가 Kryo 직렬화기는 많은 라이브러리에서 사용할 수 있습니다. 외부 직렬화기 작업에 대한 자세한 내용은 3rd party serializer를 참조하세요.

  • 타입 힌트 추가: 때로 Flink가 모든 요령에도 불구하고 일반 타입을 추론할 수 없을 때 사용자는 *타입 힌트(type hint)*를 전달해야 합니다. 이는 일반적으로 Java API에서만 필요합니다. Type Hints Section에서 자세히 설명합니다.
  • TypeInformation 수동 생성: Java의 일반 타입 소거로 인해 Flink가 데이터 타입을 추론할 수 없는 일부 API 호출에 필요할 수 있습니다. 자세한 내용은 Creating a TypeInformation or TypeSerializer를 참조하세요.

Flink의 TypeInformation 클래스

TypeInformation 클래스는 모든 타입 기술자의 기본 클래스입니다. 타입의 몇 가지 기본 속성을 드러내고 타입에 대한 직렬화기를, 특수화에서는 비교기(comparator)를 생성할 수 있습니다. (Flink의 비교기는 순서를 정의하는 것보다 훨씬 더 많은 일을 합니다 - 기본적으로 키를 처리하는 유틸리티입니다)

내부적으로 Flink는 타입을 다음과 같이 구분합니다.

  • 기본 타입: 모든 Java 기본 타입과 박싱된 형태, 그리고 void, String, Date, BigDecimal, BigInteger.
  • 기본 배열 및 Object 배열
  • 복합 타입
    • Flink Java Tuples(Flink Java API의 일부): 최대 25개 필드, null 필드는 지원하지 않음
    • Row: 임의의 수의 필드와 null 필드 지원을 가진 튜플
    • POJOs: 특정 bean류 패턴을 따르는 클래스
  • 보조 타입(Option, Either, Lists, Maps 등)
  • 일반 타입: Flink 자체가 아닌 Kryo로 직렬화됩니다.

POJO는 복잡한 타입 생성을 지원하므로 특히 흥미롭습니다. 또한 런타임에 투명하며 Flink가 매우 효율적으로 처리할 수 있습니다.

POJO 타입 규칙

Flink는 다음 조건이 충족되면 데이터 타입을 POJO 타입으로 인식합니다("이름으로" 필드 참조 허용).

  • 클래스가 public이고 독립적(비정적 내부 클래스 아님)
  • 클래스에 public 무인수 생성자가 있음
  • 클래스(및 모든 상위 클래스)의 모든 비정적, 비일시적 필드가 public(비-final)이거나 Java beans의 getter 및 setter 명명 규칙을 따르는 public getter- 및 setter- 메서드가 있음.

Java records도 POJO 타입으로 인식됩니다. 클래스는 여전히 public이어야 하지만 무인수 생성자와 getter/setter 규칙은 면제됩니다. Java records는 정식 생성자를 통해 인스턴스화됩니다.

사용자 정의 데이터 타입이 POJO 타입으로 인식될 수 없으면 GenericType으로 처리되고 Kryo로 직렬화되어야 합니다.

TypeInformation 또는 TypeSerializer 생성

Java가 일반적으로 일반 타입 정보를 소거하기 때문에 TypeInformation 구성에 타입을 전달해야 합니다.

비일반 타입의 경우 Class를 전달할 수 있습니다.

TypeInformation<String> info = TypeInformation.of(String.class);

일반 타입의 경우 TypeHint를 통해 일반 타입 정보를 "캡처"해야 합니다.

TypeInformation<Tuple2<String, Double>> info = TypeInformation.of(new TypeHint<Tuple2<String, Double>>(){});

내부적으로 이것은 런타임까지 보존하기 위해 일반 정보를 캡처하는 TypeHint의 익명 하위 클래스를 만듭니다.

TypeSerializer를 만드는 두 가지 방법이 있습니다.

첫 번째는 TypeInformation 객체에서 typeInfo.createSerializer(config)를 호출하는 것입니다. config 매개변수는 SerializerConfig 타입이며 프로그램의 등록된 사용자 지정 직렬화기에 대한 정보를 보유합니다. 가능한 곳에서는 프로그램의 적절한 SerializerConfig를 전달하세요.

두 번째는 함수 내에서 getRuntimeContext().createSerializer(typeInfo)를 사용하는 것입니다. 함수(예: MapFunction) 내부에서 함수를 Rich Function으로 만들고 getRuntimeContext().createSerializer(typeInfo)를 호출하면 얻을 수 있습니다.

Java API의 타입 정보

일반적인 경우 Java는 일반 타입 정보를 소거합니다. Flink는 Java가 보존하는 몇 안 되는 비트(주로 함수 시그니처와 하위 클래스 정보)를 사용해 리플렉션으로 가능한 많은 타입 정보를 재구성하려고 시도합니다. 이 로직은 함수의 반환 타입이 입력 타입에 의존하는 경우에 대한 간단한 타입 추론도 포함합니다.

public class AppendOne<T> implements MapFunction<T, Tuple2<T, Long>> {

    public Tuple2<T, Long> map(T value) {
        return new Tuple2<T, Long>(value, 1L);
    }
}

Flink가 모든 일반 타입 정보를 재구성할 수 없는 경우가 있습니다. 그 경우 사용자는 타입 힌트를 통해 도와야 합니다.

Java API의 타입 힌트

Flink가 소거된 일반 타입 정보를 재구성할 수 없는 경우 Java API는 소위 타입 힌트를 제공합니다. 타입 힌트는 함수가 생성하는 데이터 스트림 또는 데이터 집합의 타입을 시스템에 알려줍니다.

DataStream<SomeType> result = stream
    .map(new MyGenericNonInferrableFunction<Long, SomeType>())
        .returns(SomeType.class);

returns 문은 생성된 타입을 지정하며, 이 경우 클래스를 통해 지정합니다. 힌트는 다음을 통한 타입 정의를 지원합니다.

  • 클래스 (비매개변수화 타입(제네릭 없음)용)
  • returns(new TypeHint<Tuple2<Integer, SomeType>>(){}) 형태의 TypeHints. TypeHint 클래스는 일반 타입 정보를 캡처하고 런타임을 위해 보존할 수 있습니다(익명 하위 클래스 통해).

Java 8 람다의 타입 추출

Java 8 람다의 타입 추출은 람다가 함수 인터페이스를 확장하는 구현 클래스와 연관되지 않으므로 비람다와 다르게 작동합니다.

현재 Flink는 람다를 구현하는 메서드를 알아내고 Java의 일반 시그니처를 사용해 매개변수 타입과 반환 타입을 결정하려고 시도합니다. 그러나 이러한 시그니처는 모든 컴파일러가 람다에 대해 생성하지는 않습니다. 예기치 않은 동작이 관찰되면 returns 메서드로 반환 타입을 수동으로 지정하세요.

POJO 타입의 직렬화

PojoTypeInfo는 POJO 내부의 모든 필드에 대한 직렬화기를 생성합니다. int, long, String 등 표준 타입은 Flink와 함께 제공되는 직렬화기가 처리합니다. 다른 모든 타입에 대해서는 Kryo로 폴백합니다.

Kryo가 타입을 처리할 수 없다면 PojoTypeInfoAvro로 POJO를 직렬화하도록 요청할 수 있습니다. 그러려면 flink-avro 모듈을 포함하고 다음을 설정하세요.

pipeline.force-avro: true

Flink가 Avro에 의해 생성된 POJO를 Avro 직렬화기로 자동 직렬화한다는 점에 유의하세요.

전체 POJO 타입을 Kryo 직렬화기가 처리하도록 하려면 다음을 설정하세요.

pipeline.force-kryo: true

Kryo가 POJO를 직렬화할 수 없다면 pipeline.serialization-config를 사용해 Kryo에 사용자 지정 직렬화기를 추가할 수 있습니다.

pipeline.serialization-config:
  - org.example.MyCustomType: {type: kryo, kryo-type: registered, class: org.example.MyCustomSerializer}

Kryo 폴백 비활성화

프로그램이 일반 타입에 대한 폴백으로 Kryo 사용을 명시적으로 피하려는 경우가 있습니다. 가장 흔한 경우는 모든 타입이 Flink 자체 직렬화기 또는 사용자 정의 직렬화기를 통해 효율적으로 직렬화되도록 보장하려는 것입니다. 그렇게 하려면 다음을 설정하세요.

pipeline.generic-types: false

Kryo를 거치는 데이터 타입을 만날 때마다 예외가 발생합니다.

팩토리를 사용한 타입 정보 정의

타입 정보 팩토리는 사용자 정의 타입 정보를 Flink 타입 시스템에 플러그인할 수 있게 해줍니다. org.apache.flink.api.common.typeinfo.TypeInfoFactory를 구현하여 사용자 지정 타입 정보를 반환해야 합니다. 팩토리는 타입 추출 단계에서 해당 타입에 대한 사용자 지정 타입 정보를 제공하기 위해 호출됩니다.

타입 계층에서 위로 이동하면서 가장 가까운 팩토리가 선택됩니다. 그러나 내장 팩토리가 최고 우선순위를 가집니다. 팩토리는 또한 Flink의 내장 타입보다 더 높은 우선순위를 가지므로 자신이 무엇을 하고 있는지 알아야 합니다.

pipeline.serialization-config 구성 옵션을 통해 사용자 지정 타입 정보 팩토리를 해당 타입과 연결할 수 있습니다.

pipeline.serialization-config:
  - org.example.MyCustomType: {type: typeinfo, class: org.example.MyCustomTypeInfoFactory}

또는 해당 타입 또는 이 타입을 사용하는 POJO의 필드에 @org.apache.flink.api.common.typeinfo.TypeInfo 주석을 달아 팩토리를 연결할 수 있습니다. 구성 옵션을 통해 연결된 타입 정보 팩토리가 더 높은 우선순위를 가진다는 점에 유의하세요.

다음 예시는 사용자 지정 타입 MyTuple에 주석을 달고 Java에서 팩토리를 사용해 사용자 지정 타입 정보를 제공하는 방법을 보여줍니다.

주석이 달린 사용자 지정 타입:

@TypeInfo(MyTupleTypeInfoFactory.class)
public class MyTuple<T0, T1> {
  public T0 myfield0;
  public T1 myfield1;
}

사용자 지정 타입 정보를 제공하는 팩토리:

public class MyTupleTypeInfoFactory extends TypeInfoFactory<MyTuple> {

  @Override
  public TypeInformation<MyTuple> createTypeInfo(Type t, Map<String, TypeInformation<?>> genericParameters) {
    return new MyTupleTypeInfo(genericParameters.get("T0"), genericParameters.get("T1"));
  }
}

타입 자체에 주석을 다는 대신(타사 코드에서는 불가능할 수 있음) 유효한 Flink POJO 내부에서 이 타입의 사용에 주석을 달 수도 있습니다.

public class MyPojo {
  public int id;

  @TypeInfo(MyTupleTypeInfoFactory.class)
  public MyTuple<Integer, String> tuple;
}

createTypeInfo(Type, Map<String, TypeInformation<?>>) 메서드는 팩토리가 대상으로 하는 타입에 대한 타입 정보를 생성합니다. 매개변수는 타입 자체와 가능하면 타입의 일반 타입 매개변수에 대한 추가 정보를 제공합니다.

타입이 Flink 함수의 입력 타입에서 파생되어야 할 수 있는 일반 매개변수를 포함한다면, 일반 매개변수를 타입 정보에 양방향으로 매핑하기 위해 org.apache.flink.api.common.typeinfo.TypeInformation#getGenericParameters도 구현해야 합니다.

더 알아보기 (Learn more)