[Spark] 데이터프레임

2020. 8. 3. 15:11 Big Data/빅데이터

데이터프레임은 관계형 데이터베이스의 테이블에서 칼럼 이름(named columns)으로 구성된 변경 불가능한 분산 데이터 컬렉션입니다. 아파치 스파크 1.0에서의 경험적 피처로서 스키마 RDD(SchemaRDD)가 소개된 것처럼, 스키마RDD가 아파치 스파크 1.3 버전에서 데이터프레임이라는 이름으로 바뀌었습니다. 파이썬 Pandas의 데이터프레임 또는 R의 데이터프레임에 익숙한하다면 스파크 데이터프레임은 구조적인 데이터로 작업을 쉽게 해준다는 측면에서 Pandas의 데이터프레임 또는 R의 데이터프레임과 비슷한 개념일 것입니다.

 

분산된 데이터 컬렉션에 구조체를 씌움으로써 스파크 사용자는 스파크 SQL로 구조적 데이터를 쿼리하거나 람다 대신에 표현 함수를 사용할 수 있습니다. 이번 포스팅에서는 두 함수를 사용하는 샘플 코드를 모두 포함합니다. 데이터를 구조적으로 바꿈으로써, 아파치 스파크 엔진(정확히 말하면, 카탈리스트 옵티마이저)의 스파크 쿼리 성능을 크게 향상시켰습니다. 이전 버전의 스파크 API(즉, RDD)에서 쿼리를 파이썬에서 실행하는 것은 자바 JVM과 Py4J 사이의 커뮤니케이션 오버헤드 때문에 크게 느릴 수 있습니다.

 

이전 스파크 버전에서의 데이터프레임에 익숙하다면 스파크 2.0에서 SQLContext 대신에 SaprkSession을 사용한다는 것을 알 수 있습니다. HiveContext, SQLContext, StreamingContext, SparkContext와 같은 다양한 스파크 컨텍스트는 SparkSession으로 모두 통합되었습니다. 이런 식으로 데이터 읽기의 시작점으로서 또는 메타데이터, 설정, 클러스터 자원 관리 등을 위해 세션을 사용합니다.

 

 

파이썬에서의 RDD 커뮤니케이션


파이스파크 프로그램이 RDD를 사용할 때마다 잡을 수행하면 발생하는 오버헤드가 잠재적으로 존재합니다. 다음 다이어그램에 명시돼 있듯이, 파이스파크 드라이버에서 스파크 컨텍스트는 JavaSparkContext를 사용해 JVM을 실행하기 위해 Py4j를 사용합니다. 모든 RDD 트랜스포메이션은 최초에 파이썬 RDD 자바 객체로 매핑됩니다.

 

이 작업들이 스파크 워커에 푸시됐을 때, 파이썬 RDD 객체는 파이썬이 처리할 코드와 데이터를 보내기 위해 파이프로 파이썬 subprocess를 실행합니다.

 


 

 

파이스파크는 이러한 방법을 통해 여러 개의 워커 노드에 있는 여러 파이썬 서브프로세스로 데이터 처리를 분배하는 반면, 파이썬과 JVM간의 많은 컨텍스트 스위칭과 커뮤니케이션 오버헤드가 존재합니다.

 

 

카탈리스트 옵티마이저 리뷰


스파크 SQL 엔진이 빠른 가장 중요한 이유는 카탈리스트 옵티마이저 때문입니다. 데이터베이스에 기초가 있는 독자들에게 이 다이어그램은 구조적/물리적 플래너, 그리고 관계형 데이터베이스 관리 시스템에서의 비용 모델/비용 기반 최적화와 비슷합니다.

 


 

 

즉시 쿼리를 실행하는 것에 반해, 이것의 가장 중요한 점은 스파크 엔진의 카탈리스트 옵티마이저가 논리적 플랜을 컴파일하고 최적화한다는 것과 가장 효과적인 물리적 플랜을 결정하는 비용 옵티마이저를 가지고 있다는 것입니다.

 

스파크 SQL 엔진이 서술어 푸시다운(predicate pushdown)과 열 자르기(column pruning) 등을 포함하는 룰 기반의 비용 기반의 최적화를 모두 가지고 있는 반면 아파치 스파크 2.2버전의 비용 옵티마이저는 비용 기반의 최적화 프레임을 브로드캐스트, 조인 셀렉션 위에 구현하기 위한 상위 단계 프로젝트입니다.(참고 사이트)

 

 텅스텐 프로젝트의 일부로, 각 행 데이터를 해석하지 않고 바이트 코드를 생성해서 성능을 개선할 수 있습니다. 옵티마이저는 함수적 프로그래밍 구조체에 기반하며 두 가지 목적을 위해 디자인되었습니다. 하나는 새로운 최적화 테크닉과 기능을 스파크 SQL에 추가하기 쉽게 하는 것이며, 나머지 하나는 외부 개발자들이 옵티마이저를 확장하기 편하게 하기 위함입니다. 예를 들어 데이터 소스 기반 룰을 추가하거나 새로운 데이터 타입을 지원하는 등의 기능을 추가적으로 개발하기 편하게 하기 위함입니다.

 

 

데이터프레임을 이용한 파이스파크 스피드업


최적화되지 않은 RDD의 성능과 비교하면 데이터프레임과 카탈리스트 옵티마이저는 파이스파크 성능을 매우 향상시킵니다. 파이썬 쿼리 스피드는 RDD를 이용하는 스칼라 쿼리보다 약 두 배정도 느립니다. 일반적으로 이러한 쿼리 성능 저하는 파이썬과 JVM에서의 오버헤드로 인해 발생합니다.

 


 

데이터프레임을 파이썬과 같이 사용하면 파이썬 성능이 크게 향상될 뿐만 아니라 파이썬과 스칼라, SQL, R의 성능이 고르게 일정해집니다. 데이터프레임과 파이스파크를 같이 사용하면 대부분 훨씬 빠릅니다. 물론 몇몇 예외적인 경우는 있습니다. 가장 흔한 경우는 파이썬 UDF를 사용할 때입니다. 이때 파이썬과 JVM 사이에서의 커뮤니케이션이 발생하게 됩니다. 이러한 경우가 연산을 RDD에서 실행했을 때와 비슷한 최악의 시나리오입니다.

 

 파이썬은 카탈리스트 옵티마이저의 기반 코드가 스칼라로 개발돼 있어도 스파크의 성능 최적화 이점을 취할 수 있습니다. 이는 파이스파크 데이터프레임 쿼리를 훨씬 빠르게 동작할 수 있도록 하는 2,000줄 정도 되는 파이썬 래퍼(wrapper) 코드입니다.

 

대체로 파이썬 데이터프레임과 SQL, 스칼라 데이터프레임, R 데이터 프레임은 카랄리스트 옵티마이저를 사용할 수 있습니다.

 

 

데이터프레임 생성하기


전형적으로 SparkSession을 사용해 데이터를 임포트하는 방식으로 데이터프레임을 생성할 것입니다.(또는 스파크를 파이스파크 셸에서 호출)

 

스파크 1.x 버전에서는 전형적으로 sqlContext를 사용해야 합니다.

 

다음 포스티에서는 데이터를 어떻게 로컬 파일시스템, 분산 하둡 파일시스템(HDFS) 또는 클라우드 저장소 시스템에 임포트하는지 이야기 할 것입니다. 이번 포스팅에서는 스파크에 자신의 데이터프레임을 생성하는 것 또는 데이터브릭스 커뮤니티 에디션에서 이미 사용 가능한 데이터 소스를 활용하는데 초점을 맞출 것입니다.

 

우선 파일시스템에 접근하는 대신에 데이터프레임을 만들기 위해 데이터를 생성해보려합니다. 그러기 위해 stringJSONRDD RDD를 먼저 생성하고 그것을 데이터프레임으로 변환해야 합니다. 이 코드는 수영 선수 RDD를(ID, 이름, 나이, 눈 색깔) JSON 포맷으로 생성합니다.

 

 

* JSON 데이터 생성하기

 

다음 코드에서 처음에 우리는 stringJSONRDD RDD를 생성합니다.

stringJSONRDD = sc.parallelize((
	"""{"id":"123", "name":"Katie", "age":19, "eyeColor":"brown"}""",
	"""{"id":"234", "name":"Michael", "age":22, "eyeColor":"green"}""",
	"""{"id":"345", "name":"Simone", "age":23, "eyeColor":"blue"}""",
))

이제 RDD를 생성했으니 이것을 SparkSession read.json 함수를 사용해서 테이터프레임으로 바꿀 것입니다(즉, spark.read.json() 함수를 사용해). 또한 .createOrReplaceTempView 함수를 사용해 임시 테이블을 생성할 것입니다. 스파크 1.x 버전에서 이 함수는 .registerTempTable이었으나 스파크 2.x에서는 이것이 없어졌습니다.

 

 

* 데이터 프레임 생성하기

 

데이터프레임을 생성하기 위한 코드는 다음과 같습니다. 

swimmersJSON = spark.read.json(stringJSONRDD)

 

* 임시 테이블 생성하기

 

임시 테이블을 생성하기 위한 코드는 다음과 같습니다.

swimmersJSON.createOrReplaceTempView("swimmerJSON")

많은 RDD 함수는 액션 함수가 실행되기 이전까지는 실행되지 않는 트랜스포메이션입니다. 예를 들어, 다음 코드에서 sc.parallelize는 spark.read.json을 사용해 RDD를 데이터프레임으로 변환할 때 실행되는 트랜스포메이션입니다. 스파크 잡은 spark.read.json을 포함하는 두번째 셀 이전까지는 실행되지 않습니다.

 

map과 mapPartitions 작업은 데이터프레임을 생성하기 위해 필요한 작업이고, 스파크 잡의 parallelize 작업은 stringJSONRDD RDD를 생성하는 첫 번째 셀에서 시작합니다.

 

paralellize, map, mapPartitions가 모두 RDD 트랜스포메이션이라는 것은 중요한 사실입니다. 데이터프레임 작업내에 감싸여서 spark.read.json은 RDD 트랜스포메이션일 뿐만 아니라 RDD를 데이터프레임으로 바꾸는 액션이기도 합니다. 이 점은 매우 중요합니다. 데이터프레임 작업을 실행하고 있다고 하더라도, 디버깅 목적으로 스파크 UI 내에서 RDD 작업이 가능함을 알고 있어야 하기 때문입니다.

 

임시 테이블을 생성하는 것은 데이터프레임 트랜스포메이션이고 데이터프레임 액션이 실행되기 전까지 실행되지 않습니다.

 

데이터프레임 트랜스포메이션/액션은 RDD 트랜스포메이션/액션과 게으른 작업들이 있다는 점에서(트랜스포메이션) 비슷합니다. 그러나 RDD와 비교하면, 데이터프레임은 카탈리스트 옵티마이저 때문에 그렇게 LAZY하지는 않습니다.

 

 

간단한 데이터프레임 쿼리


이제 swimmerJSON 데이터프레임을 생성했기 때문에 데이터프레임 API를 SQL 쿼리처럼 실행할 수 있습니다. 데이터프레임 내의 모든 행을 보여주는 간단한 쿼리부터 시작해보겠습니다.

 

 

* 데이터프레임 API 쿼리 

 

데이터프레임 API를 사용하기 위해 처음 n개의 행을 콘솔에 출력하는 show(<n>) 함수를 사용할 수 있습니다. show() 함수를 실행하면 디폴트로 10줄을 출력합니다.

# DataFrame API
swimmersJSON.show()

 

* SQL 쿼리

 

SQL 쿼리를 작성하고 싶으면, 다음 쿼리를 실행하면 됩니다.

spark.sql("select * from swimmersJSON").collect()

모든 데이터를 행 객체로 변환하는 .collect() 함수를 사용하고 있습니다. collect() 함수나 show() 함수는 데이터프레임과 SQL 쿼리에 대해 사용할 수 있습니다. 단, collect() 함수는 데이터프레임의 모든 행을 리턴하고 실행 노드에서 드라이버 노드로 이동하기 때문에 작은 데이터프레임에 대해 사용하는 것이 좋습니다. 따라서 <n>을 사용해 리턴되는 행의 개수를 제한하는 take(<n>)이나 show(<n>)을 사용하는 것이 더 좋습니다. 데이터브릭스를 사용하면 %sql 명령어를 사용할 수 있고, 또한 SQL 쿼리를 노트북 셀 내에서 직접 실행할 수도 있습니다.

 

 

RDD로 연동하기


기존에 있는 RDD를 데이터프레임으로 변경하는 두 가지 방법이 있습니다. 하나는 리플렉션을 사용해 스키마를 추측하는 것이고, 다른 하나는 스키마를 직접적으로 코드상에서 명시하는 것입니다. 리플렉션을 이용한 방법은 더욱 자세한 코드를 작성하게 할 수 있는 반면(단, 스파크 애플리케이션이 이미 스키마 구조를 알고 있을 때), 스키마를 명시하는 방법은 열과 데이터 타입이 런타임에 드러날 때 데이터프레임의 구조를 만들도록 합니다.

 

 

* 리플렉션을 이용한 스키마 추측하기

 

데이터프레임을 빌드하고 쿼리를 수행하는 과정에서 이 데이터프레임에 대한 스키마는 자동으로 정의된다는 내용은 언급하지 않았습니다. 최초에 행 객체는 키/값 쌍 리스트가 행 클래스의 **kwargs로 전달되면서 구성됩니다. 그 후, 스파크 SQL은 이 행 객체의 RDD를 데이터프레임으로 변경합니다. 키는 칼럼이고 데이터 타입은 데이터 샘플링을 통해 추측됩니다.

 

**kwargs 구조체를 이용해 런타임에 여러 개의 파라미터를 함수에 전달할 수 있습니다.

 

다시 코드로 돌아와서, 최초에 swimmerJSON 데이터프레임을 생성한 후 printSchema() 함수를 사용해 스키마를 직접적으로 명시하지 않고 스키마 정의를 확인할 수 있습니다.

# 스키마 출력하기
swimmerJSON.printSchema()

그런데 만약 이 예제에서 id가 원래는 long타입이 아니라 스트링 타입이기 때문에 스키마를 명시하고 싶다면 어떻게 해야하는지 살펴보겠습니다.

 

 

* 프로그래밍하는 것처럼 스키마 명시하기

 

이런 경우에는 스파크 SQL 데이터 타입을 가져와서 스키마를 프로그래밍처럼 명시해보겠습니다.

# types를 임포트한다
from pyspark.sql.types import *

# 콤마로 분리된 데이터를 생성한다
stringCSVRDD = sc.parallelize([
	(123, 'Katie', 19, 'brown'),
	(234, 'Michael', 22, 'green'),
	(345, 'Simone', 23, 'blue')
])

우선 다음과 같이 각 변수마다 스키마를 스트링으로 인코딩할 것입니다. 그리고 스키마를 StructType과 StructField를 이용해 정의할 것입니다.

# 스키마를 명시한다
schema = StructType([
	StructField("id", LongType(), True),
	StructField("name", StringType(), True),
	StructField("age", LongType(), True),
	StructField("eyeColor", StringType(), True)
])

StructField 클래스는 "name: 필드의 이름, dataType: 필드의 데이터 타입, nullabe: 필드가 널이 될 수 있는지 명시" 세가지 구분으로 쪼개집니다. 마지막으로 생성된 schema를 stringCSVRDD RDD에 적용하고, SQL을 이용해 쿼리할 수 있도록 임시 뷰를 생성합니다.

# RDD에 스키마를 적용하고 데이터프레임을 생성
swimmers = spark.createDataFrame(stringCSVRDD, schema)

# 데이터프레임을 이용해 임시 뷰를 생성
swimmers.createOrReplaceTempView("swimmers")

이전 절에서는 id를 스트링 타입으로 했으나, 이 예제에서는 스키마에 대한 더욱 자세한 컨트롤을 수행했으며 id 가 long타입이라고 명시했습니다.

swimmers.printSchema()

대부분의 경우에 스키마는 추측될 수 있어서 이전의 예제처럼 스키마를 명시할 필요가 없긴합니다.

 

 

데이터프레임 API로 쿼리하기 


collect(), show(), take() 함수를 이용해 데이터프레임 내의 데이터를 볼 수 있습니다. show()와 take() 함수는 리턴되는 행의 개수를 제한하는 옵션을 포함합니다.

 

 

* 행의 개수

 

데이터프레임 내에 있는 행의 개수를 얻기 위해 count() 함수를 사용할 수 있습니다.

swimmers.count()

 

* 필터문 실행하기

 

필터문을 실행하기 위해 filter 절을 사용할 수 있습니다. 다음 코드에서는 리턴될 칼럼을 명시하기 위해 select 절을 사용했습니다.

# age가 22인 데이터의 id와 age를 출력
swimmers.select("id", "age").filter("age = 22").show()

# 위의 코드를 작성하는 다른 방법
swimmers.select(swimmers.id, swimmers.age).filter(swimmers.age == 22).show()

눈 색깔이 b로 시작하는 수영 선수의 이름을 얻고 싶다면 SQL 쿼리 문법과 비슷하게 다음과 같이 사용할 수 있습니다.

# eyeColor가 b로 시작하는 데이터의 name, eyeColor 칼럼을 얻는다
swimmers.select("name", "eyeColor").filter("eyeColor like 'b%'").show()

 

SQL로 쿼리하기


같은 쿼리를 실행해보겠습니다. 같은 데이터프레임을 이용해 SQL 쿼리를 실행하려고 합니다. 참고로 swimmers에 대해 .createOrReplaceTempView 함수를 실행했기 때문에 데이터프레임은 접근 가능합니다.

 

 

* 행의 갯수

 

다음 코드는 SQL을 이용해 데이터프레임 내 행의 개수를 얻기 위한 코드입니다.

spark.sql("select count(1) from swimmers").show()

 

* 필터문을 where 절을 사용해 실행하기

 

필터문을 SQL을 이용해 실행하기 위해 다음 코드에 적혀 있듯이 where 절을 사용할 수 있습니다.

# age가 22인 데이터의 id와 age를 출력
spark.sql("select id, age from swimmers where age = 22").show()

이 쿼리는 age가 22인 데이터의 id와 age 칼럼을 출력합니다. 데이터프레임 API를 쿼리하는 것처럼, b로 시작하는 눈 색깔을 가지고 있는 swimmers의 이름을 얻고 싶다면 like를 사용할 수 있습니다.

spark.sql("select name, eyeColor from swimmers where eyeColor like 'b%'").show()

스파크 SQL과 데이터프레임으로 작업할 때 잊으면 안되는 중요한 점은 CSV, JSON을 비롯한 다양한 데이터 포맷이 작업하기는 쉬우나 스파크 SQL 분석 쿼리에 대한 가장 일반적인 포맷은 파케이(parquet) 파일 포맷이라는 것입니다. 파케이 파일 포맷은 열 포맷으로 돼 있습니다. 이는 다른 데이터 처리 시스템에 의해 지원되며, 자동으로 원본 데이터의 스키마를 보존하는 스파크 SQL은 파케이 파일에 대한 읽기와 쓰기를 모두 지원합니다.

 

 

데이터프레임 시나리오: 비행 기록 성능


데이터프레임으로 쿼리할 수 있는 타입을 보여주기 위해 비행 기록 성능에 대한 유스케이스를 살펴보겠습니다. 항공사의 지연율과 비행 지연의 원인에 대해 분석해보겠습니다. 또한 비행 지연의 여러 변수들을 살펴보기 위해 공항 데이터셋(오픈 공항/항공사/경로 데이터)과 조인해보겠습니다. 이를 통해 비행 지연과 관련된 변수들을 더 잘 이해할 수 있을 것입니다.

 

 

* 출발지 데이터셋 준비하기

파일 경로 위치를 명시하고 SparkSession을 이용해 임포트한 후, 출발지 공항과 비행 성능 데이터셋을 처리할 것입니다.

# 파일 경로 설정
flightPerfFilePath = "/databricks-datasets/flights/departuredelays.csv"
airportsFilePath = "/databricks-datasets/flights/airport-codes-na.txt"

# 공항 데이터셋 획득
airports = spark.read.csv(airportsFilePath, header='true', inferSchema='true', sep='\t')
airports.createOrReplaceTempView("airports")

# 출발지 지연 데이터셋 획득
flightPerf = spark.read.csv(flightPerfFilePath, header='true')
flightPerf.createOrReplaceTempView("FlightPerformance")

# 출발지 지연 데이터셋 캐시
flightPerf.cache()

CSV 리더를 이용해 데이터를 임포트한다는 것을 알아두겠습니다. CSV 리더는 콤마('.')가 아닌 다른 구분자여도 상관없습니다. 마지막으로 바로 다음 쿼리가 더 빠르게 수행되도록 비행 데이터셋을 캐시합니다.

 

 

* 비행 성능 데이터셋과 공항 데이터셋 조인하기

 

데이터프레임/SQL로 하는 더 일반적인 작업으로는 서로 다른 두 개의 데이터셋을 조인하는 것이 있습니다. 이는 종종 성능 측면에서 다소 부담이 되는 작업이기도 합니다. 데이터프레임 내에는 이러한 조인들에 대한 많은 성능 최적화가 기본으로 포함돼 있습니다.

 

이번 시나리오에서는 워싱턴 주의 전체 지연을 도시와 출발지 코드에 따라 쿼리했습니다. 이는 비행 성능 데이터와 공항 데이터를 IATA 코드를 기준으로 조인해야 가능합니다. 

# 도시와 출발지 코드에 따라 비행 지연 합을 쿼리하기(워싱턴 주에 대해)
spark.sql("""
	select a.City,
	  	f.orgin,
		sum(f.delay) as Delays
		from FlightPerformance f
		join airports a
		on a.IATA = f.origin
		where a.State = 'WA'
		group by a.City, f.origin
		order by sum(f.delay) desc"""
).show()

데이터브릭스, iPython, 주피터, 아파치 제플린과 같은 노트북을 사용하면 쿼리를 더욱 쉽게 실행하고 시각화할 수 있습니다. 다음 예제는 데이터브릭스 노트북을 사용하고 있습니다. 파이썬 노트북에서는 %sql 함수를 이용해 SQL문을 노트북 셀 내에서 실행할 수 있습니다. 아래 쿠 ㅓ리는 이전의 쿼리와 같으나 포맷 때문에 더욱 읽기가 쉽습니다.

%sql
-- 도시와 출발지 코드에 따라 비행 지연 합을 쿼리하기(워싱턴 주에 대해)
select a.City, f.origin, sum(f.delay) as Delays
from FlightPerformance f
   join airports a
     on a.IATA = f.origin
where a.State = 'WA'
group by a.City, f.origin
order by sum(f.delay) desc

데이터프레임의 주요 장점은 정보가 테이블과 비슷하게 구조화된다는 것입니다. 그러므로 노트북을 사용하든, 다른 BI 툴을 사용하든 데이터를 빠르게 시각화할 수 있습니다.

 

 

* 스파크 데이터셋 API

 

아파치 스파크 1.6에서 소개했던 것처럼, 스파크 데이터셋의 목적은 스파크 SQL 실행 엔진의 성능과 구조적 견고함을 제공하고, 사용자가 트랜스포메이션을 도메인 객체들에 대해 쉽게 표현할 수 있는 API를 제공하는데 있습니다. 다음 다이어그램에 나와있듯이 스파크 2.0에서는 데이터프레임 API가 데이터셋 API에 합쳐졌습니다. 또한 데이터 처리 기능을 모든 라이브러리에 통합했습니다. 이것으로 인해 개발자들이 추가적으로 학습해야 할 것들이 줄어들었고, 데이터셋으로 불리는 하이레벨이면서 타입이 유연한 API로 작업을 할 수 있습니다.

 


 

개념적으로, 스파크 데이터프레임은 Dataset[Row] 제네릭 객체 집합에 대한 별칭입니다. 반대로 데이터셋은 스칼라나 자바에서 프로그래머가 정의한 사용자 클래스에 의해 타입을 확실히 명시해야 하는 JVM 객체입니다. 이 점은 특히 중요합니다. 이는 타입에 견고함이 적은 파이스파크에서는 데이터셋 API를 지원하지 않는다는 것을 의미하기 때문입니다. 파이스파크에서 사용 가능하지 않은 데이터셋 API의 부분은 RDD로 변환하거나 UDF를 사용해 접근 가능합니다.

 

 

요약


스파크 데이터프레임으로, 파이썬 개발자들은 잠재적으로 매우 빠른 간단한 추상 계층을 사용할 수 있습니다. 스파크 내에서 파이썬이 느린 가장 주된 이유는 JVM과 서브프로세스 간의 커뮤니케이션 계층 때문입니다. 파이썬 데이터프레임 사용자를 위해 스칼라 데이터프레임 주위에 파이선 래퍼를 둬서 파이썬 서브프로세스와 JVM 사이의 커뮤니케이션 오버헤드를 없앨 수 있도록 했습니다. 스파크 데이터프레임은 카탈리스트 옵티마이저와 프로젝트 텅스텐을 통해 상당한 성능 개선을 이뤘습니다.



출처: https://12bme.tistory.com/307?category=737765 [길은 가면, 뒤에 있다.]