Spring Boot + JDBC Template 이용하기

대부분의 경우에는 ORM (서버라면 JPA, 안드로이드는 ObjectBox) 를 사용했었지만, 이번 개인 프로젝트를 진행하면서 ORM을 사용하지 않게 되었다.

그 이유는, 안드로이드의 경우에는 한번 쯤은 Room을 제대로 써보고 싶다는 생각이 들었기도 했고, 서버에서는 비교적 쿼리가 들어가기 때문이었다.

물론, 쿼리의 복잡성 때문에 ORM을 선택하지 않았다는 이유는 아니고, 다른 오픈소스 프로젝트를 Spring Boot로 포팅하면서 기능을 덧붙이는 방식으로 진행했었기 때문에, DB 구조 등을 그대로 가져왔기 때문이다.

DB 구조는 표현하면 다음과 같다.

작품 테이블 - 작품 ID, 작품의 정보, 작가 등
태그 테이블 - 태그 ID, 태그 이름
작품ID-태그ID 테이블 - 작품 ID, 태그 ID (1:N)

그리고 클라이언트가 원하는 자료에는 태그 정보가 당연하게도 필요했었고, 검색하는 필터에도 태그 정보가 당연하게 들어가게 된다.

이를 위해 가능하기 위해 태그 정보까지 검색하고, 태그 정보까지 불러올 수 있는 기능을 최소한의 LEFT JOIN과 INNER JOIN을 사용해서 하려고 했었고, 결국에는 Spring Boot에서 query string를 구성해서 실행하는 방식으로 하기로 결정했다.

그렇게 되면, Spring Boot에서 ORM을 사용하지 않고 바로 데이터 소스에 연결해서 사용해야 하는데, 그 때 JDBC Template를 사용하면 비교적 쉽게 할 수 있다.

따라서 본 글에서는 간단하게나마 Spring Boot 2.1 + JDBC Template 기반으로 쿼리를 실행하고 데이터를 받을 수 있도록 다뤄보려 한다. 참고로 본 예제에서 사용한 DB는 MariaDB이다.

임포트

implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
runtimeOnly("mysql:mysql-connector-java")

MariaDB를 connector로 사용할 것이므로 mysql-connector-java도 같이 둔다.

모델 정의

여기서 모델이란 DB 결과를 파싱해서 보여줄 수 있는 클래스 객체이며, ORM의 Entity와는 다르게 어떠한 부가정보도 필요로 하지 않는다.

단, 이 클래스의 구현은 RowMapper와 관련이 있는데, 만일 쿼리의 SELECT 로 가져오는 Column들에 대해서 같은 이름으로 클래스의 프로퍼티를 만들어주는 것이 가능하다면, BeanPropertyRowMapper 라고 하는 Mapper로 쓸 수 있기 때문이다.

즉, 쿼리에서 나올 항목이 id, name 이라면 해당 클래스는 똑같이 id, name를 담고 있으면 된다.

applications.properties

## 데이터 소스 접속 정보 설정
spring.datasource.url=jdbc:mysql://{ADDRESS}/{DB_NAME}?characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username={USERNAME}
spring.datasource.password={PASSWORD}

## 로깅 설정
logging.path=logs
logging.level.com.tutorial.springboot=DEBUG

## HikariCP에서 사용되는 타임아웃 시간 정의
spring.datasource.hikari.idleTimeout=40000
spring.datasource.hikari.connection-timeout=5000
spring.datasource.hikari.validation-timeout=10000
spring.datasource.hikari.maxLifetime=580000

Spring Boot 2.0 부터 HikariCP가 기본 Connection Pool 관리 도구로 선정되었으므로, HikariCP의 설정을 포함하면서도 기본 데이터 소스에 대한 접속 정보를 설정한다.

상기된 것 처럼 여기에서는 MariaDB를 사용할 것이므로 jdbc:mysql:// 를 사용했다.

쿼리 실행하기

쿼리는 Repository에서 접근하는 구조로 간다고 가정해본다.

Repository에서는 JdbcTemplate 라는 클래스를 Autowired로 주입받게 되면, SpringBootApplication이 실행될 때 applications.properties 에 기재된 접속 정보를 가지고 DataSource 객체를 생성해서 JdbcTemplate 객체로 관리할 수 있게 된다.

@Autowired
private lateinit var jdbcTemplate: JdbcTemplate

또는, 전통적인 ? 를 대신해 파라미터 이름을 사용할 수 있게 하는 NamedParameterJdbcTemplate 도 사용이 가능하다.

@Autowired
private lateinit var jdbcTemplate: NamedParameterJdbcTemplate

쿼리는 jdbcTemplate.query(String, RowMapper) 로 실행이 가능하고, NamedParameterJdbcTemplate 한정으로 Param을 넣을 수 있는 jdbcTemplate.query(String, Map, RowMapper) 사용이 가능하다.

가령, 유저의 id와 이름을 가져오는 query를 실행한다면, 아래와 구현할 수 있다.

@Repository
class JdbcUserRepository : UserRepository {

  @Autowired
  private lateinit var jdbcTemplate: NamedParameterJdbcTemplate

  override fun findAll(): List<User> {
      val query = "SELECT id, name FROM users"
      return jdbcTemplate.query(query, BeanPropertyRowMapper(User::class.java))
  }
}

전 챕터에서 잠깐 언급되었던 BeanPropertyRowMapper 는 주어진 클래스 객체를 가지고 결과를 파싱할 수 있게 도와주는 클래스로, 편의성을 위해 사용된다. (성능을 고려한다면, 직접 RowMapper 클래스를 구현하여 사용하도록 권장되고 있다.)

만일 RowMapper를 직접 구현한다면 아래와 같다.

@Repository
class JdbcUserRepository : UserRepository {

  @Autowired
  private lateinit var jdbcTemplate: NamedParameterJdbcTemplate

  override fun findAll(): List<User> {
      val query = "SELECT id, name FROM users"
      return jdbcTemplate.query(query) { rs, rowNum ->
          User().apply {
              name = rs.getString("name")
              id = rs.getString("id")
          }
      }
  }
}

그 외에도 가변 인자를 사용할 수 있는 SimpleJdbcTemplate 등도 있으나 더 언급하지는 않을 예정이다.

JdbcTemplate 의 이점

먼저, JdbcTemplate는 JDBC 사용을 단순화하고 일반적인 오류를 피하는 데에 큰 도움을 줄 수 있다는 것이다. 위의 코드를 보면 단순히 개발자가 한 것은 접속 정보를 설정하고 Repository에서 @Autowired 어노테이션으로 통해 인스턴스를 주입받은 것 밖에는 존재하지 않는다.

이는 나머지 처리에 대해 JdbcTemplate가 적절하게 처리해준다는 것을 보여주기도 하며, org.springframework.dao 패키지에 정의된 것 보다 유익한 계층 구조로 반환해줄 수 있다는 의미이다.

또한, JdbcTemplate의 인스턴스는 한번 메모리에 로드되면 Thrad-Safe 하게 된다.

즉, 한번 생성된 인스턴스는 SpringBootApplications 내부에 있는 Repository들에게 공유된 참조를 보내줄 수 있는데, 이는 JdbcTemplate가 DataSource에 대한 상태는 가지고 있지만 이 상태는 대화 상태(Conversational state) 가 아니다는 특성을 가지고 있기 때문이다.

물론 이러한 특성을 가진 탓에 실행하는 쿼리에 maxResult를 설정하는 것이 중요한데, 이는 하나의 Repository에서 하는 작업이 다른 Repository에 영향을 줄 수 있기 때문이다.

Kanon-Bot 개발 기록: node + Typescript로 Youtube-dl 사용하기

도입

최근 여유 시간에 KanonDL-Bot 라고 하는 텔레그램 메신저 봇을 만들고 있는데, 해당 봇의 메인 기능은 ‘youtube-dl‘로 영상이나 음악을 다운받아 바로 전송해주는 기능이다.

흔히 부르는 Y****** to video/mp3 계열이긴 한데, 만든 사람들이 많아도 하나같이 신뢰할 수 없기에 ts도 다시 공부할겸 직접 만들고 있다.

따라서 금주의 기록에는 Youtube-DL를 node + typescript에서 사용하는 것을 정리하려고 한다.

의존성 설치

기본적으로 Youtube-DL는 각 OS마다 바이너리를 제공하기는 하지만, 여기서는 기 제작된 드라이버인 node-youtube-dl를 사용한다.

npm install youtube-dl

해당 드라이버에서는 postinstall 로 youtubedl의 바이너리를 가져오고 설정하게 된다.

다만 youtube-dl 의 기능 중에서 ffmpeg를 의존하는 기능을 사용하려 할 경우, ffprobe or avconv is not installed 오류를 만나게 된다.

이 때는, 각 OS에 맞춰서 바이너리를 설치하면 된다.

choco install ffmpeg // chocolately (Windows 전용)
apt install ffmpeg // ubuntu (Dockerize 용)

기능 개발

정보 가져오기

해당 드라이버가 getInfo 메서드를 제공하기는 하지만, youtube-dl가 제공하는 모든 기능에 대해 제공하지는 않는다.

따라서 다른 기능도 마찬가지지만 드라이버에서 제공하는 기능을 사용하는 것이 아닌 직접 바이너리를 실행하는 youtube-dl exec 를 사용할 것이다.

먼저 필요한 기능인 ‘정보 가져오기’인 경우, youtube-dl는 자동으로 파일을 다운로드 받으므로 파일을 저장하지 않는 조건으로 가져올 필요가 있다. 해당 명령어는 아래와 같다.

youtube-dl -s -j {url}

여기서 -s는 비 다운로드 옵션, -j는 가져온 정보에 대해 json으로 덤프하는 기능이다.

이를 node-youtube-dl가 제공하는 드라이버로 실행하면 다음과 같다.

import youtubedl = require('youtube-dl');

export function extractInfo(url: string) {
    return new Promise<Media.Info>((resolve, reject) => {
        youtubedl.exec(url, ['-s', '-j'], {}, (err: any, output: string[]) => {
            if (err) {
                reject(err)
                return;
            }

            let message = output.join('\n')
            let info = JSON.parse(message)
            resolve(info)
        })
    });
}

여기에서 Media.Info 는 결과를 정리한 Model이고, 이 쪽에서 참조할 수 있다.

사용하는 쪽에서는 다음과 같이 사용할 수 있다.

import * as YoutubeDLWrapper from '../core/youtubedl'
YoutubeDLWrapper.extractInfo(url)
            .then((info: Media.Info) => {
                 // info - 추출한 정보들
            });

영상 다운로드 하기

기본적인 영상 다운로드 명렁어는 다음과 같다.

youtube-dl {url}

하지만 텔레그램이 지원하는 포맷은 50MB 이하의 mp4 파일 이므로 변환해줄 필요가 있다.

따라서, 아래의 과정을 youtube-dl가 하게 하면 될 것이다.

  1. 확장자가 mp4인 video를 지정한 경로에 다운로드
  2. 확장자가 m4a인 audio를 지정한 경로에 다운로드
  3. video와 audio를 mp4로 인코딩하되, 파일 사이즈는 50MB 미만으로 해서 지정한 경로에 저장

위 조건을 명령어로 하면 다음과 같다.

youtube-dl -f (bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4)[filesize<48M] -o {output_path} {url}

이에 맞춘 파일이 output_path에 최종적으로 저장되며, 아래와 같은 응답으로 오게 된다.

 OPMZTg1k8r0: Downloading webpage
 OPMZTg1k8r0: Downloading video info webpage
[download] Destination: C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.f135.mp4
[download] 100% of 34.10MiB in 00:01
[download] Destination: C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.f140.m4a
[download] 100% of 4.08MiB in 00:00
[ffmpeg] Merging formats into "C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【 
公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.mp4"
Deleting original file C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.f135.mp4 (pass -k to keep)
Deleting original file C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.f140.m4a (pass -k to keep)

응답은 총 1회 호출되므로, 파일이 저장된 곳을 정규식으로 찾아 경로를 반환해주면 될 것이다.

따라서 이를 driver로 표현하면 다음과 같을 것이다.

export function downloadVideo(url: string) {
    return new Promise<string>((resolve, reject) => {
        youtubedl.exec(url, ['-f', '(bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4)[filesize<50M]', '-o', output_path], {}, (err: Error, output: string[]) => {
            if (err) {
                reject("")
                return;
            }

            let message = output.join('\n')
            let match = message.match(/\[ffmpeg] Merging formats into (.+)/)

            if (match != undefined) {
                resolve(match[1].replace(/"/gi, ''))
            }
        });
    });
};

음악 다운로드 하기

음악도 영상과는 크게 다르지 않고, youtube-dl가 아래와 같은 작업을 하게 하면 된다.

  1. 확장자가 webm인 audio를 지정한 경로에 다운로드
  2. webm인 audio를 mp3로 변환

위 조건을 명령어로 하면 다음과 같다.

youtube-dl -f bestaudio -o {output_path} -x --audio-format mp3 {url}

응답은 다음과 같이 오게 된다.

 OPMZTg1k8r0: Downloading webpage
 OPMZTg1k8r0: Downloading video info webpage
[download] Destination: C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.webm
[download] 100% of 3.94MiB in 00:00
[ffmpeg] Destination: C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.mp3
Deleting original file C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.webm (pass -k to keep)

똑같이 저장된 경로를 정규식으로 찾는다면, Driver로는 아래와 같이 표현할 수 있다.

export function downloadAudio(tuple: url) {
    return new Promise<string>((resolve, reject) => {
        youtubedl.exec(url, ['-f', 'bestaudio', '-o', output_path, '-x', '--audio-format', 'mp3'], {}, (err: Error, output: string[]) => {
            if (err) {
                reject('')
                return;
            }

            let message = output.join('\n')
            let match = message.match(/\[ffmpeg\] Destination\: (.+)/)
            if (match != undefined) {
                resolve(match[1])
            }
        });
    });
};

마무리

이로서 기본적인 youtube-dl 기능은 가져왔고, 이에 아이디어를 덧붙이면 괜찮은 봇이 만들어 질 것 같다.

현재까지 개발된 것 중에서 제일 문제라고 하면 멀티 프로세스 문제와 promise의 난해함이라고 할 수 있는데, 그나마 쉽게 적용 가능할 것 같은 RxJs로의 마이그레이션을 다음주쯤에 해볼 것 같다. 아니면 Dockerize를 해볼지도.

JNI에서 RegisterNatives 사용하기

아득히 먼 예전(약 5년 이상)에는 JNI를 사용하기 위해 javah 를 사용하여 헤더 파일을 만들고, 헤더 파일에 선언된 메서드를 사용하는 일이 있었다.

JNIEXPORT void JNICALL Java_com_github_windsekirun_**_bridge_JniBridge_nativeOnStart(JNIEnv *env, jclass type) {
      LAppDelegate::GetInstance()->OnStart();
}

가령 JniBridge 라는 클래스에 nativeOnStart 라는 메서드가 있다면, 위에 선언된 메서드를 실행하는 방식이다.

하나 두개 쯤은 문제가 없지만, 메서드가 많이 있을때는 아무래도 깔끔하지 못하는 단점이 있었다.

이번에 Live2D를 사용한 토이 프로젝트를 진행하면서 다시 JNI를 사용하게 되었는데, 몇주 전 동적으로 메서드를 등록할 수 있다는 방법을 알게 된 터라 한번 사용해보았다.

RegisterNatives

JNI_OnLoad() (네이티브 라이브러리가 로드될 때 초기화 작업하는 메서드) 에서 사용하며, 아래의 파라미터를 받는다.

jclass clazz

위의 JNIBridge처럼 native 메서드가 선언된 클래스이다.

com/github/windsekirun/**/bridge/JniBridge 처럼 선언된다.

JNINativeMethod*

jni.h에 선언된 구조체로, 아래와 같은 형식이다.

typedef struct {
  const char* name;
  const char* signature;
  void*       fnPtr;
} JNINativeMethod;

name는 native 메서드가 선언된 클래스에서의 메서드 이름, signature는 해당 메서드의 JNI 시그니쳐, fnPtr는 jni에서의 메서드로 보면 된다.

가령 아래와 같은 메서드가 있다고 가정한다.

// java
public static native void nativeOnStart();

// c++
static void nativeOnStart(JNIEnv *env, jclass type) {
  LAppDelegate::GetInstance()->OnStart();
}

이 때, JNINativeMethod는 {"nativeOnStart", "()V", (void *) nativeOnStart} 가 된다.

따라서, 두 번째 파라미터에는 이러한 JNINativeMethod의 배열을 삽입하면 된다.

numMethods

두번째의 배열에 대하여 전체 갯수를 적는다.

실제 사용하기

JNIBridge 라는 클래스에 아래 메서드들이 있다.

public static native void nativeOnStart();
public static native void nativeOnPause();
public static native void nativeOnStop();
public static native void nativeOnDestroy();
public static native void nativeOnSurfaceCreated();
public static native void nativeOnSurfaceChanged(int width, int height);
public static native void nativeOnDrawFrame();
public static native void nativeOnTouchesBegan(float pointX, float pointY);
public static native void nativeOnTouchesEnded(float pointX, float pointY);
public static native void nativeOnTouchesMoved(float pointX, float pointY);
public static native void nativeLoadModel(String modelName);

그리고, 이를 등록할 JNINativeMethod* 를 선언한다.

static const char *classPathName = "com/github/windsekirun/**/bridge/JniBridge";

static JNINativeMethod methods[] = {
      {"nativeOnStart",         "()V",                 (void *) nativeOnStart},
      {"nativeOnPause",         "()V",                 (void *) nativeOnPause},
      {"nativeOnStop",           "()V",                 (void *) nativeOnStop},
      {"nativeOnDestroy",       "()V",                 (void *) nativeOnDestroy},
      {"nativeOnSurfaceCreated", "()V",                 (void *) nativeOnSurfaceCreated},
      {"nativeOnSurfaceChanged", "(II)V",               (void *) nativeOnSurfaceChanged},
      {"nativeOnDrawFrame",     "()V",                 (void *) nativeOnDrawFrame},
      {"nativeOnTouchesBegan",   "(FF)V",               (void *) nativeOnTouchesBegan},
      {"nativeOnTouchesEnded",   "(FF)V",               (void *) nativeOnTouchesEnded},
      {"nativeOnTouchesMoved",   "(FF)V",               (void *) nativeOnTouchesMoved},
      {"nativeLoadModel",       "(Ljava/lang/String)V", (void *) nativeLoadModel},
};

마지막으로 RegisterNative 메서드를 실행하는 메서드를 작성한다.

static int registerNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *gMethods,
                                int numMethods) {
  jclass clazz;
  clazz = env->FindClass(className);
  if (clazz == nullptr) {
      return JNI_FALSE;
  }

  if (env->RegisterNatives(clazz, gMethods, numMethods) < 0) {
      return JNI_FALSE;
  }

  return JNI_TRUE;
}

마지막으로 JNI_OnLoad에서 메서드를 실행한다.

registerNativeMethods(env, classPathName, methods, sizeof(methods) / sizeof(methods[0]));