Gleam 애플리케이션에서 Postgres 데이터베이스를 설정하고, 연결하고, 쿼리하는 방법을 단계별 예제로 설명합니다. pog, squirrel, envoy 등을 활용해 실제 환경과 유사한 구성을 만들 수 있습니다.
2026년에 세상을 위한 무언가를 만들고 있고, 관계형 DB를 쓰고 있다면 알아야 할 것은 이거다. (sqlite 도 쓸모가 있긴 하다, 물론이다 – 마이크로소프트나 오라클 제품은 절대 쓰지 마라)
pog 가 당신이 원하는 Gleam 클라이언트다. 좋고, 널리 쓰이고, Gleam의 창시자인 lpil이 지원한다. 사실상 표준이다.
곧바로 squirrel을 쓰고 싶어질 거다. 이것도 좋고, 널리 쓰이고, Gleam 코어 개발자인 giacomocavalieri가 지원한다.
그렇다, SQL을 써야 한다. 아니다, ORM은 필요 없다. SQL은 좋은데, ORM은 … 의견이 갈린다: 쉬운 일을 더 쉽게 만들지만 어려운 일은 더 어렵게 만든다. SQL을 배우지 않고 피해갈 수 있다고 생각할 수도 있지만, 그건 불가능하고 사실 그게 괜찮다. Giacomo가 올린 관련 영상이 있다 (이 글을 쓰는 동안에 딱 올라왔다). LLM이 쿼리 작성 방법을 제안해 주는 데 꽤 유용하다는 걸 느끼고 있다.
psql 은 Postgres용 관리 툴이다. GUI 툴인 pgAdmin4는 추천하지 않는다 – 터무니없이 거대하고 부풀어 오른 엉망진창이다. 좋은 GUI 툴이 있긴 하겠지만, 무료이면서 뛰어난 것을 아직 못 찾았다. psql은 항상 사용할 수 있고, 뭐든 할 수 있지만 명령을 좀 배워야 한다.
짧은 것도 있다
내가 항상 찾아보게 되는 것들
postgres-server → databases → schemas → tables
기술적으로는 테이블이 스키마 아래에 있고 스키마는 데이터베이스 아래에 있다 – 너무 신경 쓰지 말고 그냥 public 스키마를 쓰면 된다. 하나의 서버에 여러 데이터베이스가 있을 수 있다.
* [user vs role](https://www.postgresql.org/docs/current/user-manag.html) – Postgres 에서 말하는 ‘role’ 은 ‘이 <해당 role>의 역할로 행동 중인 사용자’를 뜻한다 – 너무 신경 쓰지 말자, 그냥 user 라고 생각하면 된다.
* 사용자와 owner. 처음에는 너와 너의 코드가 접속할 단일 사용자 하나를 만들어라. 이 사용자가 데이터베이스의 owner 이고 모든 grant 를 갖도록 하라. 나중에 이걸 좁히고 더 제한적인 접근 권한을 가진 계정을 만들 수 있다.
* `postgres` 유저는 전체 Postgres 서버의 슈퍼유저 같은 존재다. 이 유저로 접속할 수 있어야 한다. 비밀번호를 비밀번호 관리 툴에 적어 두어라. 다른 유저를 만드는 것은 이 유저다.
* psql 명령어 참고
* `\l` 데이터베이스 목록 보기
* `\c <database name>` : 해당 DB 로 접속
* `\d` 현재 접속한 DB의 테이블 설명
* `\du` 사용자와 그들의 role 설명
오늘은 데이터베이스 자체를 가르치진 않는다. 여기서는 시작을 위한 최소 실용 예제만 본다.
bash$ psql CREATE DATABASE example_db; CREATE USER example_user WITH ENCRYPTED PASSWORD 'abc123'; GRANT ALL PRIVILEGES ON DATABASE example_db TO example_user; ALTER DATABASE example_db OWNER TO example_user; \l ... \q
example_user로 example_db에 다시 접속해서 테이블을 만들고 레코드를 하나 추가하자.
bash$ psql -U example_user example_db CREATE TABLE mytable ( id serial PRIMARY KEY, -- auto_incrementing PK name text NOT NULL UNIQUE, -- 고유한 이름 ts timestamp NOT NULL DEFAULT now(), score integer ); \d List of relations Schema | Name | Type | Owner --------+----------------+----------+-------------- public | mytable | table | example_user public | mytable_id_seq | sequence | example_user (2 rows) INSERT INTO mytable (name, score) VALUES ('Alice', 42); SELECT * FROM mytable; id | name | ts | score ----+-------+----------------------------+------- 1 | Alice | 2025-12-09 23:15:31.920194 | 42
예전에는 HOST, PORT 같은 요소들을 각각의 환경 변수로부터 따로따로 가져오는 경우가 많았는데, 요즘은 대부분 모든 정보를 한 번에 담은 데이터베이스 커넥션 URI를 쓴다:
postgresql://[user[:password]@]host[:port][/dbname]
Gleam은 문자열 끝에 어떤 파라미터도 허용하지 않는 것처럼 보인다. 커넥션 스트링은 원래 postgresql://[user[:password]@]host[:port][/dbname][?param1=value1&...] 형태로 정의되지만, 내가 url_config 를 호출했을 때 ?param… 부분이 있으면 Error 를 반환했다. 문서 상으로는 작동해야 할 것 같지만, 실제로는 프로그램이 크래시 나고 있었다.
비밀번호(그리고, 사실상 나머지 모든 것)도 URL 인코딩이 필요하다. 여기 실제 예제가 있다 – 비밀번호에 따옴표와 대괄호가 있어서 인코딩해야 했다 – 으.
DATABASE_URL=postgres://myuser:Ace%22%5DRj54w2@localhost:5432/mydb
코드 안에서 이 값을 문자열로 직접 할당해서 테스트용으로 쓸 수는 있겠지만, 실제로는 반드시 환경 변수로 빼야 한다.
환경 변수를 읽으려면 envoy를 쓰자.
또는 dotenv_gleam으로 ".env" 파일에서 읽을 수 있다.
환경 변수를 원하는 방식으로 설정하고, 다음으로 환경에 잘 들어갔는지 확인하라:
bash$ echo $DATABASE_URL postgres://... blah blah blah
envoy 설치:
bashgleam add envoy
파일 작성: src/db_example.gleam
gleamimport envoy pub fn main() { let assert Ok(db_url) = envoy.get("DATABASE_URL") echo db_url }
실행해서 환경에서 읽어온 커넥션 스트링을 확인하자:
bash$ gleam run -m db_example Compiling envoy Compiling proto Compiled in 0.35s Running db_example.main src/db_example.gleam:5 "postgres://myuser:Ace%22%5DRj54w2@localhost:5432/mydb"
이제 DB에 접속해서 읽을 수 있게 되려면 정말 많은 것들이 한꺼번에 맞물려야 한다. 여기서 배울 수 있는 최소 실용 예제를 보자:
gleamimport envoy import gleam/erlang/process import pog import gleam/otp/static_supervisor as supervisor import gleam/dynamic/decode pub fn main() { // 커넥션 스트링이 담긴 환경 변수 읽기 let assert Ok(db_url) = envoy.get("DATABASE_URL") echo "DATABASE_URL: " <> db_url // DB 커넥션 풀에 접근하기 위한 Name 이 필요하다 let db_pool_name = process.new_name("db_pool") // db_url 로부터 커넥션 설정(config) 불러오기 let assert Ok(config) = pog.url_config(db_pool_name, db_url) echo config // pool 설정으로부터 child spec 만들기 let pool_child_spec = config |> pog.pool_size(5) |> pog.supervised // child spec 으로 supervisor 시작 let _ = supervisor.new(supervisor.RestForOne) |> supervisor.add(pool_child_spec) |> supervisor.start // 접속 핸들 얻기 let db = pog.named_connection(db_pool_name) echo db // 실행할 SQL 쿼리 let query_sql = "SELECT name, score FROM mytable WHERE id = $1" // 결과를 Gleam 값들로 디코딩하는 방법 정의 let row_decoder = { use name <- decode.field(0, decode.string) use score <- decode.field(1, decode.int) decode.success(#(name, score)) } // SQL, 파라미터, 디코더로부터 쿼리를 만들고 실행 let id_param = pog.int(1) let assert Ok(data) = pog.query(query_sql) |> pog.parameter(id_param) |> pog.returning(row_decoder) |> pog.execute(db) // 결과 데이터에서 값 꺼내 보기 assert data.count == 1 assert data.rows == [#("Alice", 42)] echo data }
이건 진짜 줄 단위로 읽어가며 이해해야 한다. 이해 안 되는 건 찾아보자. 위에서부터 중요한 개념들을 정리해 보면:
static_supervisor 는 이름이 좀 길어서 import 할 때 별칭을 쓴다.
let assert Ok(db_url) – Ok 가 아니라면 그냥 크래시 나게 둔다.
process.new_name – 프로세스와 supervisor 를 다룰 때 Name 은 필수다.
child spec 은 supervisor 아래에서 돌아갈 worker 프로세스들을 정의한 레시피다.
supervisor 시작하기 – 사실 하나도 어렵지 않다.
디코더(decoder)! 외부 데이터를 받아 올바르게 타입이 지정된 Gleam 값으로 바꿔 준다.
$1 같은 번호 매겨진 파라미터를 사용하는 SQL 문.
좋다! 이제 동작한다. 커넥션 관련 코드는 한 번만 실행하면 되고, 헬퍼 함수로 떼어낼 수 있다. 그런데 SQL 한 줄이 디코더랑 결과 처리 코드까지 합쳐서 거의 10줄이 되었고, 이런 쿼리들을 수십 개씩 만들게 될 것이다. 이제 squirrel 을 쓸 차례다 🐿️
이건 마라톤이다. 그래도 거의 다 왔다. 🐿️ 는 SQL 쿼리가 들어 있는 파일들을 읽어서, 그 쿼리를 실행하기 위한 깔끔한 Gleam 코드를 특수 모듈에 자동으로 만들어 주는 영리한 유틸리티다. 덕분에 보일러플레이트 코드가 크게 줄어든다. 문서가 꽤 잘 되어 있으니, 다람쥐(squirrel)가 뭐라 하는지 읽어 보자.
프로젝트 디렉터리(그 안에 /src/가 있는 곳)에서:
bashgleam add squirrel --dev mkdir src/sql
위에서 사용한 작은 SQL 쿼리를 파일로 저장한다.
src/sql/get_mytable.sql
sqlSELECT name, score FROM mytable WHERE id = $1
squirrel 실행:
bashgleam run -m squirrel
이렇게 하면 함수와 타입들이 자동 생성된 새 파일이 만들어진다:
/src/sql.gleam
이제 코드에서 이렇게 쓸 수 있다:
gleamimport sql ... // 위 main() 함수의 마지막 부분에 이것을 추가한다 // 이전과 같은 db 커넥션 "db" 를 사용하고 // SQL 에서 정의한 하나의 파라미터를 받는다 let assert Ok(data) = sql.get_mytable(db, 1) echo data.rows
Squirrel 은 각 .sql 파일 이름을 사용해 함수와 Row 타입을 만들어 준다. 예를 들어 src/sql/get_mytable.sql 로부터 sql.get_mytable() 과 sql.GetMytableRow 를 얻게 된다. 직접 디코딩 코드를 쓰는 것에 비해 훨씬 낫다.
트랜잭션에 대한 작은 곁가지 이야기
여기서 트랜잭션까지 들어가려던 건 아니지만, 문서만 보고는 딱 와 닿지 않는 부분이 있었고, 이 힌트가 누군가에게 도움이 될 거라서 적는다. 트랜잭션을 사용하려면 반드시 pog.transaction() 을 통해 함수를 호출해야 한다 – 그러면 그 안에서 너가 원래 호출하려던 함수가 실행된다. 이 함수는 롤백됐는지, 그리고 Postgres 가 정확히 무엇을 문제 삼았는지를 알려 주는 유용한
TransactionError를 돌려줄 수 있다.
가서 뭔가를 만들어 보자.
그리고 퍼블릭 호스팅 환경을 가능한 한 일찍 한 번 써 보라. 실제로 동작하는지 확인해야 한다. 그쪽에서 요구하는 것들(데이터베이스 버전 같은)이 있다면, 개발 환경이 최대한 비슷하도록 맞춰 두는 게 좋다.
호스팅 서비스에서는 DB를 켜는 방법이 있을 것이고, 코드에서 사용할 커넥션 스트링을 제공해 줄 것이다. 아마 "DATABASE" 같은 환경 변수에 넣어 줄 수도 있고, 직접 설정해야 할 수도 있다. 코드로 커넥션 정보를 전달하는 방법에는 여러 가지가 있다: 환경 변수, .env 파일(dotenv), 기타 공유 시크릿 파일 등. 단, GitHub 에 비밀 코드를 커밋하지만 말자.
호스팅 서비스에서 외부 접속을 열어 준다면, 개발 환경에서 바로 DB에 접속할 수도 있다. 그래도 비상 상황이 아니라면 그 채널은 닫아 두겠다. 대신 호스팅 서비스에서 ssh 나 터미널 접속을 제공할 것이므로, 그쪽 커맨드라인에서 psql을 쓰면 된다.
보안 모범 사례. 비밀값을 커밋하지 말 것. 좋은 비밀번호를 쓸 것. 최소 권한만 허용할 것. 백업 준비할 것.
관리형 마이그레이션. DB 스키마를 어느 정도 자신감을 가지고 바꿀 수 있도록 해 준다. cigogne이 선호되는 도구로 보이지만, 나는 아직 써 보지는 않았다.
리플리케이션, 백업, 스케일링, 샤딩, 고가용성.
데이터 안전성, 프라이버시, 그리고 운영(production) 데이터로 개발·디버깅을 어떻게 할지.
성능과 튜닝. 로그, 인덱스, 최적화, 캐싱, 하드웨어.
이건 다 "성공했을 때 생기는 문제들"이다 – 일단 돌아가는 무언가를 만들기 전까지는 걱정하지 말자.