[Scala] Doobie 로 postgres 사용하기

Sangwoo Park·2023년 8월 4일
0

개요

이 포스팅은 스칼라를 처음 접하는 분들도 쉽게 따라할 수 있도록 하는것이 목적입니다.
scala에 대한 기본적인것을 몰라도 따라하기 쉽도록 작성해보았습니다.

사용하는 기술, 도구들

  • scala
  • Doobie
  • postgres
  • Docker
  • IntelliJ

혹시 scala 가 처음이라면?

소스코드

프로젝트 소스코드는 깃허브에 올려두었습니다. 참고하세요.

0. Doobie 란?

scala 프로젝트에서 사용할 수 있는 JDBC 라이브러리입니다.
이제부터 Doobie 를 이용해서 scala 프로젝트에서 postgres 와 커넥션을 맺고, 데이터를 조회하거나 저장하는 것을 해보겠습니다.

1. scala 프로젝트 생성

개발 환경은 intelliJ 를 이용하겠습니다.

File -> New -> Project 를 누르고

위와 같이 설정 후 Creaet 를 클릭하면 끝

2. Doobie 의존성 설정

scala 는 sbt 라는 빌드 툴을 이용합니다.
Doobie 를 사용하기 위해 build.sbt 파일에 들어가서 의존성을 설정해줍니다.

val DoobieVersion = "1.0.0-RC1"
val NewTypeVersion = "0.4.4"

lazy val root = (project in file("."))
  .settings(
    name := "HelloDoobie",
    libraryDependencies ++= Seq(
      "org.tpolecat" %% "doobie-core" % DoobieVersion,
      "org.tpolecat" %% "doobie-postgres" % DoobieVersion,
      "org.tpolecat" %% "doobie-hikari" % DoobieVersion,
      "io.estatico" %% "newtype" % NewTypeVersion
    )
  )

설정 후 reload 를 통해 의존성을 새로고침 해줍시다.

3. DB 준비

Doobie 를 사용해서 프로젝트와 연결할 DB가 있어야겠죠?

간단한 환경설정을 위해 docker-compose 를 사용하겠습니다.

최상위 디렉토리에 docker-compose.yml 파일을 생성하고, 아래와 같이 내용을 작성합니다.

version: '3.1'

services:
  db:
    image: postgres
    restart: always
    volumes:
      - "./initdb.sql:/docker-entrypoint-initdb.d/initdb.sql"
    environment:
      - "POSTGRES_USER=user"
      - "POSTGRES_PASSWORD=1111"
      - "POSTGRES_DB=hellodoobie"
    ports:
      - "5432:5432"
  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

여기서 volumes 부분을 보면 "./initdb.sql:/docker-entrypoint-initdb.d/initdb.sql" 라고 되어있습니다.
./initdb.sql 를 docker container 안에 docker-entrypoint-initdb.d/initdb.sql 로 복사하겠다는 뜻입니다.
docker container 에서 docker-entrypoint-initdb.d 경로에 있는 .sql 스크립트는 컨테이너가 생성될때 같이 실행됩니다.

그리고 DB 실습을 위해 필요한 initdb.sql 파일 또한 docker-compose.yml 파일과 동일한 디렉토리에 만들고, 아래와 같이 내용을 채워줍니다.

-- Directors
CREATE TABLE directors
(
    id        serial            NOT NULL,
    PRIMARY KEY (id),
    name      character varying NOT NULL,
    last_name character varying NOT NULL
);

-- Movies
CREATE TABLE movies
(
    id uuid NOT NULL,
    title              character varying NOT NULL,
    year_of_production smallint          NOT NULL,
    director_id        integer           NOT NULL
);

ALTER TABLE movies
    ADD CONSTRAINT movies_id PRIMARY KEY (id);
ALTER TABLE movies
    ADD FOREIGN KEY (director_id) REFERENCES directors (id);

-- Actors
CREATE TABLE actors
(
    id   serial            NOT NULL,
    PRIMARY KEY (id),
    name character varying NOT NULL
);

-- Link between movies and actors
CREATE TABLE movies_actors
(
    movie_id uuid NOT NULL,
    actor_id integer NOT NULL
);

ALTER TABLE movies_actors
    ADD CONSTRAINT movies_actors_id_movies_id_actors PRIMARY KEY (movie_id, actor_id);
ALTER TABLE movies_actors
    ADD FOREIGN KEY (movie_id) REFERENCES movies (id);
ALTER TABLE movies_actors
    ADD FOREIGN KEY (actor_id) REFERENCES actors (id);


-- Actors
INSERT INTO actors (name)
VALUES ('Henry Cavill');
INSERT INTO actors (name)
VALUES ('Gal Godot');
INSERT INTO actors (name)
VALUES ('Ezra Miller');
INSERT INTO actors (name)
VALUES ('Ben Affleck');
INSERT INTO actors (name)
VALUES ('Ray Fisher');
INSERT INTO actors (name)
VALUES ('Jason Momoa');

-- Directors
INSERT INTO directors (name, last_name)
VALUES ('Zack', 'Snyder');

-- Movies
INSERT INTO movies (id, title, year_of_production, director_id)
VALUES ('5e5a39bb-a497-4432-93e8-7322f16ac0b2', 'Zack Snyder''s Justice League', '2021', 1);

-- Actor-Movie link
INSERT INTO movies_actors (movie_id, actor_id)
VALUES ('5e5a39bb-a497-4432-93e8-7322f16ac0b2', 1);
INSERT INTO movies_actors (movie_id, actor_id)
VALUES ('5e5a39bb-a497-4432-93e8-7322f16ac0b2', 2);
INSERT INTO movies_actors (movie_id, actor_id)
VALUES ('5e5a39bb-a497-4432-93e8-7322f16ac0b2', 3);
INSERT INTO movies_actors (movie_id, actor_id)
VALUES ('5e5a39bb-a497-4432-93e8-7322f16ac0b2', 4);
INSERT INTO movies_actors (movie_id, actor_id)
VALUES ('5e5a39bb-a497-4432-93e8-7322f16ac0b2', 5);
INSERT INTO movies_actors (movie_id, actor_id)
VALUES ('5e5a39bb-a497-4432-93e8-7322f16ac0b2', 6);

docker-compose 파일의 내용을 정리하자면,

postgres DB 컨테이너를 만들거고, 이름은 user, password는 1111, 포트는 5432로 접근할거야. 그리고 시작할 때 initdb.sql 스크립트를 실행시켜

라는 뜻인거죠.

이제 터미널에 docker-compose up -d 를 입력해주면 컨테이너가 만들어지고, 5432번 포트로 접근이 되며, 데이터베이스 스키마와 데이터가 준비되어 있는 것을 확인할 수 있습니다.

4. 조회하기

4.1. 기본

DB 조회를 실행할 파일을 main 경로에 만들어주겠습니다.
저는 HelloDoobie.scala 라는 이름으로 만들어 아래와 같이 작성하겠습니다.

import cats.effect.{ExitCode, IO, IOApp}
import doobie.implicits._
import doobie.util.transactor.Transactor

object HelloDoobie extends IOApp { //1

  val xa: Transactor[IO] = Transactor.fromDriverManager[IO]( //2
    "org.postgresql.Driver",
    "jdbc:postgresql:hellodoobie",
    "user", // username
    "1111" // password
  )

  def findAllActorsNames: IO[List[String]] = { //3
  	val query = sql"select name from actors".query[String] //4
    query.to[List].transact(xa)
  }

  override def run(args: List[String]): IO[ExitCode] =
    findAllActorsNamesProgram.map(println).as(ExitCode.Success) //5
}

코드를 알아보겠습니다.
DB 커넥션이 잘 되는지 확인하기 위해 간단한 조회 쿼리를 만들었습니다.

  1. cats 의 IOApp 을 extends 함으로서 실행 가능한 프로젝트를 만듭니다.
  2. Transactor 는 DB와 커넥션을 맺고 트랜잭션을 관리하도록 합니다.
  3. findAllActorsNames 에서 sql query를 만들고 transact(xa) 로 실행시킵니다.
  4. sql"select ..." 과 같이 sql 인터폴레이션을 사용하기 위해서는 import doobie.implicits._를 import 해주어야 함을 유의하세요.
  5. run 에서 조회 함수를 실행시키고 결과에 map(println) 을 해주어 결과를 출력할 수 있도록 합니다.

그러면 아래와 같이 조회 결과가 출력됩니다.
List(Henry Cavill, Gal Godot, Ezra Miller, Ben Affleck, Ray Fisher, Jason Momoa)

간단한 조회를 성공했습니다.

위의 findAllActorsNames 메서드에서 query 를 만들고 transact(xa) 를 통해서 트랜잭션을 실행시켰습니다. 같은 방식으로 작동하는 다른 조회방법들을 살펴보겠습니다.

4.2. find by Id

id(혹은 특정 컬럼) 으로 데이터를 조회하는 방법입니다. 결과가 있을수도, 없을수도 있기 때문에 Option 형태로 가져옵니다.

  def findActorById(id: Int): IO[Option[String]] = {
    val query = sql"select name from actors where id=$id".query[String]
    query.option.transact(xa)
  }

전체조회와 동일하게 query 를 interpolation 을 이용하여 작성하고, option.transact(xa) 를 실행시켜 줍니다.

4.3. find all stream

  def findActorsStream: IO[List[String]] = {
    sql"select name from actors".query[String]
      .stream.compile.toList.transact(xa)
  }

query에 stream.compile.toList 를 이용해 stream 방식으로 조회할 수 있습니다.

4.4. find by Name (with HC Module)

  def findActorByName(name: String): IO[Option[Actor]] = {
    val queryString = "select id, name from actors where name = ?"
    HC.stream[Actor](
      queryString,
      HPS.set(name),
      100 //chunk size
    ).compile.toList.map(_.headOption).transact(xa)
  }

HC(Highlevel Connection) 와 HPS(Highlevel PreparedStatement) 를 이용하여 조금 더 로우 레벨에서 직접 쿼리를 작성하는 방법입니다. HC 에 query 와 HPS, chunk size 를 설정해서 쿼리를 실행합니다.
이 때, 쿼리는 ? 를 wildcard 로 사용하는 plain String 입니다.

4.5. Fragments

  def findActorsByInitial(letter: String) = {
    val selectPart = fr"select id, name"
    val fromPart = fr"from actors"
    val wherePart = fr"where Left(name, 1) = $letter"

    val statement = selectPart ++ fromPart ++ wherePart
    statement.query[Actor].stream.compile.toList.transact(xa)
  }

fragment 는 SQL문의 조각들을 가지고 조합하여 전체 SQL문을 빌드하는 방법입니다.
++ 연산자로 각 statement 를 조합하고, stream.compile 로 실행시킬 수 있습니다.

5. 저장하기

5.1. 기본

  def saveActor(id: Int, name: String) = {
    val query = sql"insert into actors (id, name) VALUES ($id, $name)"
    query.update.run.transact(xa)
  }

저장하기도 조회 쿼리와 비슷합니다. 다른점은, update.run 을 통해 실행시켜준다는 것입니다.

5.2. id 자동생성

  def saveActorAutoGenerated(name: String) = {
    val query = sql"insert into actors (name) VALUES ($name)"
    query.update.withUniqueGeneratedKeyswithUniqueGeneratedKeys[Int]("id").transact(xa)
  }

withUniqueGeneratedKeys 메서드를 이용하여 지정된 컬럼의 유니크 값을 자동생성할 수 있습니다. 이때 제네릭에는 파라미터로 넘겨주는 컬럼의 자료형을 입력합니다.
withUniqueGeneratedKeys 를 이용하지 않더라도, DB에 pk, auto increment 가 지정되어있는 컬럼은 생략하면 자동으로 생성됩니다.

5.3. desugared version

  def saveActors_v2(id: Int, name: String) = {
    val queryString = "insert into actors (id, name) values (?, ?)"
    Update[Actor](queryString).run(Actor(id, name)).transact(xa)
  }

5.2.와 같은 쿼리 방법은 사실 syntactic sugar 입니다. 해당 쿼리를 desugarize 한다면 위의 쿼리와 같이 됩니다. 이 방법으로도 동일하게 데이터를 삽입할 수 있습니다.
여러 건의 데이터를 한번에 저장하는 경우에는 desugared 방법으로 작성해야 하기 때문에 알아둘 필요가 있겠습니다.

5.4. bulk insert

  def saveActorsBulk(names: List[String]) = {
    val queryString = "insert into actors (name) values (?)"
    Update[String](queryString)
      .updateManyWithGeneratedKeys[Actor]("id", "name")(names).compile.toList.transact(xa)
  }

5.3. 에서 살펴본 방법을 참고하여 만들어진 bulk insert 쿼리입니다.

profile
going up

2개의 댓글

comment-user-thumbnail
2023년 8월 4일

잘 읽었습니다. 좋은 정보 감사드립니다.

1개의 답글