티스토리 뷰

개요

  • Amazon Athena는 아마존이 제공하는 서버리스 대화식 데이터 쿼리 서비스 상품으로, 최근 대두되는 주제인 데이터 레이크에 있어 아마존의 주력 서비스라고 할 수 있다. 이번 글에서는 Spring Boot 프로젝트에서 MyBatis를 이용하여 Athena에 자유롭게 동적 쿼리를 작성하는 방법을 소개하고자 한다.

Athena JDBC 연동 과정에서 발견된 문제점

  • Athena JDBC 드라이버는 Maven 중앙 저장소에서 제공되지 않아 별도 다운로드하여 관리해야 한다. 버전 관리에서 있어서 상당한 불편을 초래한다.
  • 더군다나 Athena JDBC 드라이버는 한 프로젝트 내에서 Amazon SDK 라이브러리와 병행 사용시 충돌이 발생하여 리패키징 작업을 해야 한다. (리패키징 방법은 아래 설명하였다.)
  • Athena JDBC 드라이버는 Prepared Statement 기능을 제공하지 않는다. (최신 버전의 Presto는 본 기능을 제공하는데, Athena는 구형의 Presto 엔진 기반으로 제공하지 않는다.) 이 것으로 인해 Spring Data JPAQueryDSL을 이용한 높은 생산성을 얻을 수 없게 되었다. (QueryDSL의 강력함은 본 블로그의 이 글을 참고한다.)
  • 대안으로는 완전한 ORM은 아니지만 비슷한 수준을 제공하는 MyBatis를 이용하는 것이 가능하다. 개인적으로는 복잡한 쿼리를 다룸에 있어 최선이라는 생각이 든다. 이번 글에서 설명할 주제이기도 하다.

Athena JDBC 드라이버 다운로드

  • AthenaMaven에서 JDBC 드라이버를 제공하지 않아 별도로 다운로드하여 라이브러리에 추가해야 한다. 여기에서 AthenaJDBC42.jar 파일을 다운로드한다.
  • 만약, 프로젝트에서 이미 Amazon SDK를 사용 중이라면 다운로드 받은 드라이버와 버전 충돌이 발생한다. 해결책은 아래 작업 후 AthenaJDBC42.jar 파일을 재압축하면 된다.
1. AthenaJDBC42.jar 파일을 압축 해제한다.
2. /com/amazonaws 폴더를 삭제한다
3. 기존 AthenaJDBC42.jar을 삭제하고, 동일한 파일명으로 재압축한다.
  • 재압축한 파일은 프로젝트의 /libs 폴더를 생성하여 복사한다.

build.gradle.kts

  • 프로젝트의 /build.gradle.kts 파일에 아래 내용을 추가한다.
dependencies {
    implementation("org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation(files("libs/AthenaJDBC42.jar"))
    implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4")
    implementation("org.mybatis:mybatis-typehandlers-jsr310:1.0.2")
    implementation("com.github.pagehelper:pagehelper-spring-boot-starter:1.3.0")
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:2020.0.1")
    }
}
  • pagehelper-spring-boot-starter 아티팩트는 MyBatis에서 편리한 페이징을 가능하게 해준다. 자세한 사용 예는 아래 설명한다.

application.yml

  • 프로젝트의 /application.yml 파일에 아래 내용을 추가한다.
cloud:
  aws:
    credentials:
      instance-profile: false
      access-key: {access-key}
      secret-key: {secret-key}
    region:
      auto: false
      static: {region}
    stack:
      auto: false

spring:
  datasource:
    url: jdbc:awsathena://User={access-key};Password={secret-key};S3OutputLocation=s3://{};AwsRegion={region};
    driver-class-name: com.simba.athena.jdbc.Driver
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    open-in-view: false
    properties:
      hibernate:
        format_sql: true
    generate-ddl: false

mybatis:
  mapper-locations: mapper/*.xml

pagehelper:
  helper-dialect: com.jsonobject.example.AthenaDialect
  reasonable: true
  supportMethodsArguments: true
  params: count=countSql

페이지네이션 구현체 작성

  • MyBatis는 별도의 페이지네이션 기능을 제공하지 않아 사용자가 직접 구현해야 하는데, JPA에 익숙한 사람이라면 여간 귀찮은 일이 아닐 수 없다. 다행히도 PageHelper 라이브러리가 그 역할을 대신해준다.
  • 페이지네이션을 위한 쿼리문을 작성하는 방법은 데이터베이스마다 천차만별이다. PageHelper는 미리 다양한 데이터베이스에 맞는 Dialect 구현체를 제공하는데, 안타깝게도 Athena에 대해서는 제공하지 않아 직접 구현해야 했다. Athena의 페이지네이션 쿼리문의 기본 골격은 아래와 같다. 이 것을 기반으로 구현체를 작성할 것이다.
select * from (
  select row_number() over() as PAGEHELPER_ROW_ID, *
  from (
    {SQL}
  )
)
where PAGEHELPER_ROW_ID between {START_ROW} and {END_ROW}
  • 프로젝트의 /src/main/java 폴더에 AthenaDialect.java 파일을 생성 후, 아래와 같이 구현체를 작성한다.
package com.jsonobject.example;

import com.github.pagehelper.Page;
import com.github.pagehelper.dialect.AbstractHelperDialect;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;

import java.util.Map;

public class AthenaDialect extends AbstractHelperDialect {

    @Override
    public Object processPageParameter(MappedStatement ms, Map<String, Object> paramMap, Page page, BoundSql boundSql, CacheKey pageKey) {

        return paramMap;
    }

    @Override
    public String getPageSql(String sql, Page page, CacheKey pageKey) {

        StringBuilder sqlBuilder = new StringBuilder(sql.length() + 120);
        sqlBuilder.append("select * from ( ");
        sqlBuilder.append(" select row_number() over() as PAGEHELPER_ROW_ID, * FROM ( \n");
        sqlBuilder.append(sql);
        sqlBuilder.append("\n )) where PAGEHELPER_ROW_ID between ").append(page.getStartRow()).append(" and ").append(page.getEndRow());

        return sqlBuilder.toString();
    }
}
  • AthenaJDBC 드라이버는 Prepared Statement 기능을 제공하지 않아 위와 같이 쿼리문에 직접 파라메터를 포함하는 형태로 작성하였다.

쿼리 Mapper 작성

  • MyBatis는 실제 실행될 쿼리문을 Mapper에 선언하는데, 다이나믹 쿼리를 자유롭게 구사하기에는 아래와 같이 XML 형식의 Mapper 파일을 작성하는 것이 가장 효율적이다. 프로젝트 내 /src/resources/mapper/fooMapper.xml 파일 생성 후 아래와 같이 작성한다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.jsonobject.example.FooRepository">

    <resultMap type="com.jsonobject.example.Foo" id="fooMap">
        <result property="id" column="id"/>
        <result property="userId" column="user_id"/>
        <result property="createdAt" column="created_at"/>
        <result property="updatedAt" column="updated_at"/>
    </resultMap>

    <select id="selectFooList" parameterType="com.jsonobject.example.Foo" resultMap="fooMap">
        select
            id, user_id, created_at, updated_at
        from
            "athena-database".foo
        <where>
            <if test="userId != null">
                and  user_id = '${userId}'
            </if>
        </where>
        order by created_at desc
    </select>

</mapper>
  • 파라메터 맵핑시 $ 기호를 사용하여 전달된 파라메터를 쿼리문의 일부로 포함시켜 버린다. 이 기능 덕분에 Prepared Statement 기능이 없는 Athena JDBC 드라이버에 질의가 가능하다.
  • 앞서 PageHelper의 도움으로 Mapper에 페이지네이션 관련 내용을 작성할 필요가 없어 상당히 편리해졌다.
  • Mapper의 보다 자세한 사용 예는 본 블로그의 이 글을 참고한다.

@Repository 작성

  • 이제 리파지터리 역할을 수행할 클래스를 작성할 차례이다.
package com.jsonobject.example.repository

import com.jsonobject.example.Foo
import com.github.pagehelper.PageHelper
import org.apache.ibatis.session.SqlSession
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Repository

@Repository
class FooRepository(
    @Autowired
    @Qualifier("sqlSessionTemplate")
    val sqlSession: SqlSession
) {
    fun fetchByUserIdWithPage(userId: String, pageable: Pageable): Page<Foo> {

        val page: com.github.pagehelper.Page<Foo> =
            PageHelper.startPage<Foo>(pageable.pageNumber, pageable.pageSize).doSelectPage {
                val list: List<Foo> = sqlSession.selectList(
                    "com.jsonobject.example.FooRepository.selectFooList",
                    Foo(userId = userId)
                )
            }

        return PageImpl(page.result, pageable, page.total)
    }
}
  • PageHelper를 이용하여 앞서 작성한 쿼리문의 실행 결과를 com.github.pagehelper.Page<T> 오브젝트 획득 후 다시 org.springframework.data.domain.Page<T> 오브젝트로 변환한 예이다. 이를 통해 Spring Boot와의 호환성을 높일 수 있다.

페이지네이션 조회 결과

  • 아래는 org.springframework.data.domain.Page 오브젝트로 변환한 후의 결과를 JSON으로 출력한 예이다. 최소의 작업으로 페이지네이션에 필요한 정보가 담긴 것을 확인할 수 있다.
{
  "content": [
    {
      "id": "902badf9d148ae2b623d41cf",
      "userId": "902bad16348eb6167aa020a2",
      "createdAt": "2021-02-16T11:35:21.956",
      "updatedAt": "2021-02-16T11:35:29.914"
    },
    ...
  ],
  "pageable": {
    "sort": {
      "sorted": false,
      "unsorted": true,
      "empty": true
    },
    "offset": 0,
    "pageNumber": 0,
    "pageSize": 10,
    "unpaged": false,
    "paged": true
  },
  "last": false,
  "totalElements": 1837,
  "totalPages": 184,
  "size": 10,
  "number": 0,
  "sort": {
    "sorted": false,
    "unsorted": true,
    "empty": true
  },
  "first": true,
  "numberOfElements": 10,
  "empty": false
}

참고 글

댓글
댓글쓰기 폼