티스토리 뷰

들어가며


 현 회사에서 비즈니스 Feature 개발을 하면서 유저에게 필요한 다양한 데이터를 가공해서 전달하기 위해서 Presto DB 를 사용하고 있습니다. 이 과정에서 Presto DB 의 TimeZone 설정으로 인해서 꽤나 많은 고생을 겪게 되었는데 그 해결 과정을 공유하고자 합니다. 

 

Presto DB 란?

 Presto 는 대량의 (~ 수십 TB) 데이터를 분산처리 할 수 있는 클러스터 이자 쿼리엔진입니다. 쿼리엔진 측면에서 Presto 는 다수의 Woker Node 에서 대량의 데이터를 저장소로 부터 읽어 가공을 수행합니다. 이때 저장소는 다양한 종류(S3, MySQL, Elasticsearch 등)를 지원합니다. (자세한 내용은 공식 문서를 참고)

 

 

문제 발생


 어느날 Feature 개발을 위해서 Presto 의 Ad-hoc 쿼리가 필요하여 개발을 하고 테스트를 하고 있었습니다. 그런데 도저히 이해할 수 없는 현상을 마주했습니다. 

 

 아래 이미지는 Presto UI 의 Query Detail 화면에서 동일한 Query 두 쿼리를 비교한 이미지인데요. 놀랍게도 두 쿼리 모두 완벽히 일치하는 sql 로 실행된 쿼리인데 실행 결과가 달랐습니다. 각 쿼리는 단지 실행 방법이 달랐는데

  • 좌측: 편의를 위해서 '@SpringBootTest' 어노테이션을 통합 테스트였는데 Presto 가 읽은 input 데이터 자체가 없었고
  • 우측: 실제 Spring Application 을 load 하여 WAS에 API 를 날려 테스트를 진행했는데 이 경우는 정상적으로 조회가 되었습니다. 

 

 Presto UI 에서 유심히 여러 property 를 살펴보고 raw data 를 비교해봤지만 별다른 소득은 없었습니다. 

 

 문제 분석에 앞서 의심해볼만한 부분은 크게 두가지가 있었습니다. 

  • @SpringBootTest 로 인해서 Presto JDBC Driver 의 로직에 영향을 주었거나
  • TimeZone 으로 인한 시간 계산의 문제가 있거나

 

Presto JDBC Driver 를 까보자


참고) 본 글은 presto 0.278 버전을 기준으로 작성되었습니다. 

 

 Presto 에서는 jdbc 스펙을 맞춰 구현한 driver 를 제공하고 있는데요, 덕분에 Presto Datasource 설정을 통하여 JdbcTemplate 을 이용하여 사용할 수 있습니다. 

 

 아래 코드에서는 DataSource 를 생성할 때 특이하게 커넥션 풀을 사용하지 않는 'DriverManagerDataSource' 를 사용하였는데요. 그 이유에 대해서는 차차 알수 있을 겁니다. 

@Configuration
class PrestoConfiguration {
	@Bean(name = "PrestoDataSource")
    fun prestoDataSource(): DataSource {
        val properties = prestoDataSourceProperties()
        val dataSource = DriverManagerDataSource()
        dataSource.setDriverClassName(properties.driverClassName)
        dataSource.url = properties.url
        dataSource.username = properties.username
        dataSource.password = properties.password
        return dataSource
    }

    @Bean(name = "PrestoJdbcTemplate")
    fun prestoJdbcTemplate(): JdbcTemplate {
        val jdbcTemplate = JdbcTemplate(prestoDataSource())
        return jdbcTemplate
    }
}

@Component
class PrestoTemplate(
    private val jdbcTemplate: JdbcTemplate
) {
    fun execute(sql: String) { jdbcTemplate.execute(sql) }
    fun <T> query(sql: String, rse: ResultSetExtractor<T>): T? { return jdbcTemplate.query(sql, rse) }
    fun <T> query(sql: String, rowMapper: RowMapper<T>): List<T> { return jdbcTemplate.query(sql, rowMapper) }
}

 

 이제 실제 Presto Driver 내부를 들여다 보겠습니다. 아래 이미지는 대략적인 presto jdbc driver 의 구조입니다. 여기서 알 수 있는 사실은 JDBC 스펙에 맞게 구현한 component 들이 있구나 정도이고 이를 제외하면 눈여겨 볼 곳은 한 군데 입니다. 아래 이미지에서 강조된 StatementClient 부분입니다. 왜 Http Client 를 가지고 있는지 궁금하지 않으신가요 ? 

 


Presto Connection 은 단순 HTTP call 에 불과하다

 눈치채셨을수도 있겠지만 Presto Connection 은 실제 DB 로 물리적인 연결이 아닌 단순히 jdbc spec 에 맞춰 구현된 객체일 뿐입니다. 그래서 내부에서는 stateless 한 HTTP call 을 통해서 쿼리를 수행하고 결과를 받아오게 됩니다. 

 

 그 말인즉슨, Connection 생성에 대한 오버헤드가 없기 때문에 커넥션 풀을 사용하는 의미가 없고 Connection 을 매 요청마다 새로 생성해서 써도 상관 없다는 말입니다. 그래서 위에서 datasource 를 생성할 때 'DriverManagerDataSource' 를 사용한 이유 입니다.

 

원작자의 부연설명 (참고. trino 는 presto 로 만들어진 다른 버전의 db 임)

 

 좀 더 자세하게 실제로 Presto 의 쿼리 요청은 단일 HTTP request 로 그치지는 않습니다. 대략적으로는 최초에 쿼리 요청을 보내고 쿼리가 수행이 완료되기 까지의 일련의 과정 (presto 엔진이 쿼리 수행을 준비하고 작업 queue 에 올리고 쿼리를 수행하는 과정이 포함) 에 걸쳐 쿼리 result 를 반환할 때 까지 polling 하는 방식으로 동작합니다.

 

 자세한 Protocol 에 대한 내용은 아래 링크를 참고해주세요.  

 

 

GitHub - prestodb/presto: The official home of the Presto distributed SQL query engine for big data

The official home of the Presto distributed SQL query engine for big data - GitHub - prestodb/presto: The official home of the Presto distributed SQL query engine for big data

github.com


StatementClient 

 실제로 쿼리 요청을 처리하는 'StatementClient' 를 자세하게 살펴보겠습니다. 아래 코드에서 보다시피 생성자에서 최초에 HTTP 로 쿼리 요청을 보냅니다. 그리고 받아온 결과를 processResponse() 메소드를 통해서 저장합니다. 

 

StatementClientV1.java

 

최초 request 를 만드는 부분인데요 Header 를 통해서 additional 한 설정을 넘겨줄 수 있다는 사실을 알 수 있습니다.

 

 

Presto 로의 HTTP Call 에 대한 응답은 아래 이미지와 같이 Json 포맷으로 내려오는데요, 중요한 부분은 nextUri 항목입니다. StatementClient 에서는 최초 요청을 시작으로 쿼리 수행이 끝날때 까지 응답에 포함된 nextUri 을 계속해서 호출하는 방식으로 동작합니다. (nextUri 가 null 인 경우 완료라고 판단함) 

 

 

 

최초 요청 이후 StatementClient advance() 메소드를 result 가 채워질 때까지 반복적으로 호출 하는 형태로 동작합니다. 이때 해당 메소드를 외부에서 호출하는 주체는 PrestoResultSet 입니다.

 

StatementClientV1.java

PrestoResultSet.java


Presto Driver 는 아무런 잘못이 없엇다

 결론적으로 Presto JDBC Driver 는 아무런 문제가 없음을 알게 되었습니다. 그래도 Presto Driver 의 구조를 알았음에 위안을 삼고 다음 원인을 분석해보기로 했습니다. 

 

TimeZone 설정을 확인해보자 


 이제 남은 건 TimeZone 에 대한 의심이었습니다. 그도 그럴 것이 두 가지 테스트 상황에서 유일하게 달랐던 부분이 바로 Application 의 System Time 설정이었습니다.

 

먼저 WAS 를 통해서 테스트한 환경의 경우 TimeZone 이 UTC 로 설정됩니다. 회사 서버와 쌓이는 데이터들은 모두 UTC 기준으로 돌아가기 때문에 WAS 역시 load 시에 UTC TimeZone 으로 설정을 해주고 있습니다. 

 

@RestController
@SpringBootApplication
class Application {
    @GetMapping("/hello")
    fun hello() = Mono.just("Hello")
}

fun main(args: Array<String>) {
    TimeZone.setDefault(TimeZone.getTimeZone(ZoneOffset.UTC))
    println("TimeZone: " + ZoneId.systemDefault())
    //TimeZone: UTC
    runApplication<Application>(*args)
}

 

반면의 통합 테스트 환경에 경우에는 별도로 TimeZone 설정을 해주지 않고 있는데 그로 인해서 실행되는 JVM TimeZone 을 그대로 따라가게 되어 'Asia/Seoul' 로 설정되는 것을 알 수 있었습니다. 

 

@SpringBootTest(properties = ["spring.profiles.active=local"])
class SpringContextTest {
	@Test
    fun `test query`() {
	    println("TimeZone: " + ZoneId.systemDefault())
	    //TimeZone: Asia/Seoul
    }
}

TimeZone 이 Presto 에 주는 영향

 왠지 이제 Presto 에 쿼리 요청을 할 때 TimeZone 관련된 설정을 해줄 것 같은 의심이 드는데요. 요청방식은 이미 알고 있어서 어렵지 않게 찾을 수 있었습니다. 아래 코드를 보면 요청시에 ClientSession 으로 넘겨준 timeZone 을 HTTP 요청 헤더에 ('X-Presto-Time-Zone') 설정을 해주고 있었습니다. 그렇다면 ClientSession 은 어떻게 설정되는지 봐야겠습니다. 

 

 

Client Session 은 Presto Connection 에서 생성 해주고 있는데요, 이때 timeZone 은 아래 코드에서 설정됩니다. 의심했던 대로 역시 System Default Timezone 으로 설정을 해주고 있었습니다. 

 

검색을 해보니 저와 같은 이슈로 골머리를 앓으셨던 분을 찾을 수도 있었습니다 .. 

 

Presto JDBC 사용시 TimeZone 주의 | Popit

Presto JDBC 사용시 서버와 다른 TImeZone 사용시 다른 데이터가 조회될 수 있는 문제에 대해 설명

www.popit.kr


TimeZone Header 설정이 Presto 에 어떻게 적용될까 

그럼 한가지 의문이 또 생깁니다. 그렇다면 이 TimeZone 설정이 실제 Presto DB 에서 쿼리될 때 어떻게 적용되는 거지? 찾아봐야겠습니다. 

 

Presto 공식 문서를 보면 X-Presto-Time-Zone 헤더의 경우 '쿼리 수행시에 Presto 엔진의 기본 timezone 으로 적용된다' 라는. 내용으로 보아 저장된 값에 상관 없이 전달된 Time-Zone 으로 컨버팅 되어 계산되는 것을 예상해볼 수 있습니다. 

 

 

 그럼 실제로 테스트를 해보겠습니다. Presto 의 Data Type 에는 타임존 유무에 따른 TIMESTAMP, TIMESTAMP WITH TIME ZONE 이 있는데요, 먼저 타임존을 포함하지 않는 TIMESTAMP 타입으로 테스트를 해보겠습니다. (참고로 Presto 서버의 기본 timezone 은 UTC 로 설정)

ts                        
-----------------------
2022-01-07 22:00:00.00

 

 아래는 TimeZone 을 각각 다르게 설정하여 쿼리를 실행하는 테스트 인데요, 실행을 해보면 'UTC' TimeZone 으로 설정한 경우에 결과를 얻을 수 있는 것을 볼 수 있었습니다. 이로 미루어 보아 'Asia/Seoul' TimeZone 으로 설정한 경우 저장한 값들이 해당 TimeZone 으로 컨버팅 되어 조건에 부합하지 않게 되는 것을 알 수 있었습니다. 

 

@Test
fun `TIMESTAMP without timezone test`() {
    val sql = 
    	"SELECT * FROM table WHERE ts BETWEEN TIMESTAMP '2022-01-07 21:55:00' AND TIMESTAMP '2022-01-07 22:05:00'"            

    TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Seoul")))
    val kstResult = prestoTemplate.queryForList(sql)
    println(kstResult.joinToString(separator = "\n") { it.toString() })
    //결과: 

    TimeZone.setDefault(TimeZone.getTimeZone(ZoneOffset.UTC))
    val utcResult = prestoTemplate.queryForList(sql)
    println(utcResult.joinToString(separator = "\n") { it.toString() })
    //결과: { ts='2022-01-07 22:00:00.00' }
}

 

 이어서 타임존을 포함하는 TIMESTAMP 로 테스트 해보겠습니다. 

ts                        
-----------------------
2022-01-07 22:00:00.00 UTC

 

 아래 테스트를 실행해보면 위 테스트와 동일하게 데이터들이 전달된 TimeZone 으로 컨버팅되어 계산되는 것을 확인할 수 있었습니다. 

@Test
fun `TIMESTAMP without timezone test`() {
    val sql = 
    	"SELECT * FROM table WHERE ts BETWEEN TIMESTAMP '2022-01-07 21:55:00' AND TIMESTAMP '2022-01-07 22:05:00'"            

    TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Seoul")))
    val kstResult = prestoTemplate.queryForList(sql)
    println(kstResult.joinToString(separator = "\n") { it.toString() })
    //결과: 

    TimeZone.setDefault(TimeZone.getTimeZone(ZoneOffset.UTC))
    val utcResult = prestoTemplate.queryForList(sql)
    println(utcResult.joinToString(separator = "\n") { it.toString() })
    //결과: { ts='2022-01-07 22:00:00.00' }
}

결론

위 과정을 통해서 PrestoDB 는 저장되어 있는 값 상관 없이 Client 의 Session Timezone 으로 컨버팅 되는 사실을 알게 되었습니다. 그럼 이제 Client Session TimeZone 을 설정할 수 있다면 문제를 해결할 수 있을 것 같습니다. 

 

Trino 는 어떨까 ? 


 해결에 앞서서 현재 회사에서는 Presto DB 와 더불어 Trino DB 도 함께 사용하고 있는데요, 과연 Trino DB 에서도 동일한 이슈가 있을지 궁금해졌습니다. 'Trino DB' 에대해서 간략히 설명하자면 PrestoDB 를 만들던 엔지니어들이 Facebook 에서 퇴사해 2019 년부터 개발하며 LinkedIn, Lyft, Netflix, Slack 등에서 사용되는 쿼리 엔진입니다. 

 

 

Trino 는 다르다

 테스트를 위해서 Trino Driver 로 교체한 뒤 문제 상황을 동일하게 재현해보았는데요, 예상과는 다른 결과를 얻었습니다. 쿼리 UI 에서는 Time Zone 이 각각 다르게 전달되었지만 두 결과 모두 원했던 동일하게 정상적인 결과를 얻는 것을 확인하였습니다. 

 

 

Trino 의 경우 커뮤니티가 비교적 활성화 되어 있어서 Slack 커뮤니티를 통해서 원인을 확인할 수 있었습니다. Trino 의 경우 기본적으로 서버의 TimeZone 으로 보존되며 Client 로 부터 전달된 TimeZone 은 일부 상황에서만 사용된다고 합니다. 

  • TIMESTAMP -> TIMESTAMP WITH TIME ZONE 으로 캐스팅시 
  • current_timestamp 를 사용할 때 

 


결론

TrinoDB 와 PrestoDB 는 Client 의 Session Timezone 을 사용하는 방식이 다르고 Trino 의 경우는 기본적으로 서버의 TimeZone 을 사용한다는 사실을 알 수 있었습니다. 

 

 

Lesson Learned


여기까지 매우 긴 여정이었는데요. 이 과정을 통해서 실제로 Action Item 으로 적용하여 얻을 수 있었던 소득이 꽤 있었습니다. 

 

Presto Connection TimeZone 고정

 아래 코드는 Presto Connection 에 직접 TimeZone 을 설정하는 코드입니다. Connection 지정된 TimeZone 은 최우선으로 적용됩니다. 이를 통해서 구동되는 JVM 환경과는 전혀 상관 없이 원하는 TimeZone 으로 쿼리가 수행되도록 설정을 할 수 있게 되었습니다. 

  • TimeZone 으로 인한 아픔은 제 선에서 끝낼 수 있게 되었습니다. 
val connection = DriverManager.getConnection(url);
val prestoConnection = connection.unwrap(PrestoConnection::class.java)
prestoConnection.timeZoneId = ZoneOffset.UTC.id

Presto Client Tag 를 활용한 Resource Group 관리

 Presto Connection 을 조작할 수 있게 되면서 할 수 있게된 부가가치 높은 일들이 있었습니다. 바로 Resource Group 적용인데요, Presto 클러스터의 리소스를 여러 가지 사용자 정의 속성으로 Grouping 하여 리소스 제한 정책을 적용할 수 있는 기능입니다. 이를 통해서 서비스 특성에 맞게 쿼리 단위로 세밀하게 리소스 조정이 가능하게 됩니다. (ex. 특정 워크스페이스 별, 구독 플랜 별) 

 

Resource Groups — Presto 0.278.1 Documentation

Resource Groups Resource groups place limits on resource usage, and can enforce queueing policies on queries that run within them or divide their resources among sub-groups. A query belongs to a single resource group, and consumes resources from that group

prestodb.io

 

 사실 오래전 부터 회사 DE 분께서 원하셨던 기능이었는데 의도치 못하게 이제야 한을 풀어드리게 되었습니다. 😂

val connection = DriverManager.getConnection(url);
val prestoConnection = connection.unwrap(PrestoConnection::class.java)
prestoConnection.setClientInfo("ClientTags", accountId)
prestoConnection.setClientInfo("ApplicationName", workspaceId)

JDBC Template 을 활용한 Presto Template

 회사 프로젝트에서는 Presto Driver 를 Jdbc Template 로 wrapping 하여 사용하고 있는데요, 그로 인해서 기본 Statement 를 사용하는 방식으로는 Connection 을 조작하기가 어려웠습니다. 포기하던 찰나에 시니어 엔지니어 분께 도움을 받아 PreparedStatement 를 활용하여 큰 수정 없이 적용할 수 있는 Presto Template 을 만들 수 있게 되었습니다. 

 

 소스가 꽤 길어 별도 링크로 남겨두겠습니다 !

 

PrestoDriverExample.kt

GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

마치며


지금까지 Presto DB TimeZone 으로 인해서 고생을 겪고 해결하는 과정까지의 이야기였습니다. 의심으로 시작해서 하나둘씩 원인을 알아가고 나름 의미 있는(?) 결과까지 얻을 수 있었던 뜻깊은 시간이었던 것 같습니다. 이 글을 통해서 부디 저와 같은 시행착오를 겪는 분들이 없으셨으면 좋겠습니다 .. ㅎㅎ 

 

긴 글 읽어주셔서 감사합니다 😀

 

반응형
댓글