이 포스팅은 스칼라를 처음 접하는 분들도 쉽게 따라할 수 있도록 하는것이 목적입니다.
scala에 대한 기본적인것을 몰라도 따라하기 쉽도록 작성해보았습니다.
프로젝트 소스코드는 깃허브에 올려두었습니다. 참고하세요.
scala 프로젝트에서 사용할 수 있는 JDBC 라이브러리입니다.
이제부터 Doobie 를 이용해서 scala 프로젝트에서 postgres 와 커넥션을 맺고, 데이터를 조회하거나 저장하는 것을 해보겠습니다.
개발 환경은 intelliJ 를 이용하겠습니다.
File -> New -> Project
를 누르고
위와 같이 설정 후 Creaet
를 클릭하면 끝
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 를 통해 의존성을 새로고침 해줍시다.
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번 포트로 접근이 되며, 데이터베이스 스키마와 데이터가 준비되어 있는 것을 확인할 수 있습니다.
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 커넥션이 잘 되는지 확인하기 위해 간단한 조회 쿼리를 만들었습니다.
- cats 의 IOApp 을 extends 함으로서 실행 가능한 프로젝트를 만듭니다.
Transactor
는 DB와 커넥션을 맺고 트랜잭션을 관리하도록 합니다.findAllActorsNames
에서 sql query를 만들고transact(xa)
로 실행시킵니다.sql"select ..."
과 같이 sql 인터폴레이션을 사용하기 위해서는import doobie.implicits._
를 import 해주어야 함을 유의하세요.run
에서 조회 함수를 실행시키고 결과에 map(println) 을 해주어 결과를 출력할 수 있도록 합니다.
그러면 아래와 같이 조회 결과가 출력됩니다.
List(Henry Cavill, Gal Godot, Ezra Miller, Ben Affleck, Ray Fisher, Jason Momoa)
간단한 조회를 성공했습니다.
위의 findAllActorsNames
메서드에서 query 를 만들고 transact(xa)
를 통해서 트랜잭션을 실행시켰습니다. 같은 방식으로 작동하는 다른 조회방법들을 살펴보겠습니다.
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)
를 실행시켜 줍니다.
def findActorsStream: IO[List[String]] = {
sql"select name from actors".query[String]
.stream.compile.toList.transact(xa)
}
query에 stream.compile.toList
를 이용해 stream 방식으로 조회할 수 있습니다.
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 입니다.
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
로 실행시킬 수 있습니다.
def saveActor(id: Int, name: String) = {
val query = sql"insert into actors (id, name) VALUES ($id, $name)"
query.update.run.transact(xa)
}
저장하기도 조회 쿼리와 비슷합니다. 다른점은, update.run
을 통해 실행시켜준다는 것입니다.
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 가 지정되어있는 컬럼은 생략하면 자동으로 생성됩니다.
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 방법으로 작성해야 하기 때문에 알아둘 필요가 있겠습니다.
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 쿼리입니다.
잘 읽었습니다. 좋은 정보 감사드립니다.