SSD Nodes Learn Hosting plans →
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-08-27

Shlink로 나만의 단축 URL 서비스 구축하기

Docker Compose와 Postgres를 사용하여 VPS에 Shlink를 설치하는 방법을 안내합니다. 짧은 도메인 설정부터 API 키 생성, QR 코드 활용 및 클릭 통계 분석까지 운영 환경 구축에 필요한 모든 과정을 상세히 설명합니다.

구축할 내용

자체 호스팅 URL 단축 서비스는 긴 링크를 사용자가 소유한 짧은 링크로 변환하고 클릭 수를 집계하는 소형 서버입니다. Shlink는 오픈 소스이며 Docker 이미지로 제공되고, 컨테이너 하나와 데이터베이스만으로 모든 기능을 수행하므로 가장 적합한 선택입니다. 이 가이드에서는 VPS에 실제 단축 도메인을 연결하고, HTTPS, API 키, QR 코드 및 클릭 통계 기능을 갖춘 Shlink를 설치합니다.

상용 단축 서비스와 유사한 환경을 구성하려면 두 가지 요소가 필요합니다. API 서버는 리다이렉트를 처리하고 데이터를 저장합니다. 웹 클라이언트는 브라우저에서 API와 통신하는 별도의 정적 애플리케이션입니다. 두 가지를 모두 실행하거나, API만 실행하여 명령줄 인터페이스로 제어할 수도 있습니다.

이 가이드에서 사용하는 버전은 2026년 7월 기준 최신 버전인 Shlink 5.1과 shlink-web-client 4.8입니다.

먼저 서버에 짧은 도메인을 연결하십시오

도메인은 곧 제품 그 자체입니다. s.example.com/abc123은 사용자가 보게 될 링크이므로, 무엇을 설치하기 전에 짧고 적절한 도메인을 선택하십시오. Shlink는 모든 단축 URL에 도메인을 저장하므로, 나중에 도메인을 변경하면 이미 배포한 모든 링크가 작동하지 않게 됩니다.

단축 도메인에 대한 DNS A 레코드를 하나 생성하여 VPS의 공인 IPv4 주소를 가리키도록 설정하십시오. 서버가 IPv6를 지원한다면 AAAA 레코드도 추가하십시오. 그 후 계속 진행하기 전에 도메인이 올바르게 해석되는지 확인하십시오.

dig +short s.example.com A

출력 결과는 서버의 주소여야 합니다. 결과가 비어 있다면 레코드가 아직 전파되지 않은 상태입니다. 이 경우 도메인 이름이 해석되지 않아 TLS(transport layer security) 인증서를 발급받을 수 없으므로, 이후의 모든 단계가 혼란스러운 방식으로 실패하게 됩니다.

Compose 파일

Shlink는 데이터베이스가 필요합니다. 테스트 용도로는 SQLite로 충분하지만, 운영 환경에서는 Postgres를 사용하는 것이 좋습니다. 방문 기록이 쌓이면 인덱스와 동시 쓰기 작업 처리에 Postgres가 훨씬 유리하기 때문입니다. 이 내용을 /opt/shlink/compose.yaml에 작성하십시오.

services:
  shlink:
    image: shlinkio/shlink:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      DEFAULT_DOMAIN: s.example.com
      IS_HTTPS_ENABLED: "true"
      DB_DRIVER: postgres
      DB_HOST: database
      DB_NAME: shlink
      DB_USER: shlink
      DB_PASSWORD: ${DB_PASSWORD}
    depends_on:
      - database

  database:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: shlink
      POSTGRES_USER: shlink
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - shlink_db:/var/lib/postgresql/data

  web-client:
    image: shlinkio/shlink-web-client:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8081:8080"

volumes:
  shlink_db:

두 게시 포트 모두 127.0.0.1에 바인딩되므로, 다음 섹션에서 리버스 프록시를 설정하기 전까지는 인터넷에서 접근할 수 없습니다. Docker는 호스트 방화벽보다 우선하여 자체 포워딩 규칙을 작성합니다. 따라서 단순히 8080:8080으로 설정하면 방화벽이 닫혀 있는 서버에서도 애플리케이션이 외부로 노출될 수 있습니다. 루프백 주소에 바인딩하면 이를 방지할 수 있습니다. 이 패턴은 동일한 방식으로 실행하는 모든 애플리케이션에 적용되며, 자세한 내용은 VPS에서 Docker Compose 사용하기 가이드에서 다룹니다.

데이터베이스 비밀번호는 compose 파일 옆의 .env 파일에서 가져오므로, YAML 파일에 직접 기록되지 않습니다.

sudo mkdir -p /opt/shlink
printf 'DB_PASSWORD=%s\n' "$(openssl rand -base64 24)" | sudo tee /opt/shlink/.env
sudo chmod 600 /opt/shlink/.env

서비스를 시작하고 API가 올라오는지 확인하십시오.

cd /opt/shlink
sudo docker compose up -d
sudo docker compose logs -f shlink

첫 실행 시 데이터베이스 마이그레이션이 수행되므로 이후 실행보다 시간이 더 걸립니다. 서비스가 안정화되면 로컬에서 응답하는지 확인하십시오.

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/rest/health

200이 출력되면 API가 정상 작동하며 데이터베이스 연결도 성공한 것입니다. 여기서 500가 발생한다면 거의 항상 데이터베이스 문제입니다. .envDB_PASSWORD이 Postgres 생성 시 설정한 값과 일치하지 않는 경우입니다. Postgres 이미지는 빈 데이터 디렉터리를 초기화할 때만 POSTGRES_PASSWORD를 읽기 때문입니다. 나중에 비밀번호를 수정해도 볼륨을 삭제하고 다시 시작하기 전까지는 아무런 효과가 없습니다.

앞단에서 HTTPS 종료하기

Shlink는 8080 포트에서 일반 HTTP를 서비스합니다. TLS는 리버스 프록시에서 처리해야 하며, 가장 중요한 설정은 원본 호스트 이름을 그대로 전달하는 것입니다. Shlink는 Host 헤더를 읽어 단축 URL이 어떤 도메인에 속하는지 판단합니다. 따라서 프록시가 이 헤더를 재작성하면 존재하는 링크임에도 404 응답이 발생하거나, 방문 통계가 잘못된 도메인에 기록됩니다.

server {
    server_name s.example.com;
    listen 80;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

그다음 인증서를 발급받습니다. 갱신 타이머를 포함한 전체 과정은 Ubuntu 24.04의 nginx용 Certbot 가이드에서 확인할 수 있습니다.

sudo certbot --nginx -d s.example.com

compose 파일의 IS_HTTPS_ENABLED: "true" 설정은 Shlink가 반환하는 단축 URL에 https://를 포함하도록 만듭니다. 이 설정 자체가 TLS를 활성화하는 것은 아닙니다. 이를 HTTPS 프록시 뒤에서 false으로 두면, API가 반환하는 모든 링크가 http:// 링크가 되어 리다이렉트가 발생합니다. 이는 왕복 시간을 낭비하게 만들며 웹 클라이언트에서도 올바르게 보이지 않습니다.

API 키 생성

키 없이는 그 어떤 서비스도 API와 통신할 수 없습니다. 컨테이너 내부에서 CLI를 통해 키를 생성하십시오.

sudo docker compose exec shlink shlink api-key:generate --name "web client"

이 명령은 키를 단 한 번만 출력합니다. 키는 해시된 상태로 저장되어 다시 확인할 수 없으므로 지금 즉시 복사해 두십시오. shlink api-key:list은 각 키의 이름과 활성화 여부만 보여주며, 키 자체는 표시하지 않습니다. 키를 폐기하려면 shlink api-key:disable와 함께 키 이름을 사용하십시오.

모든 REST 호출은 X-Api-Key 헤더에 키를 포함해야 합니다.

curl -H "X-Api-Key: YOUR_KEY" https://s.example.com/rest/v3/short-urls

shortUrls 키를 포함한 JSON 객체가 반환되면 키가 정상적으로 작동하는 것입니다. 401와 함께 INVALID_API_KEY이 반환된다면 키가 잘못되었거나, 비활성화되었거나, 만료되었음을 의미합니다.

명령줄에서 단축 링크 생성하기

CLI는 링크를 만드는 가장 빠른 방법이며, 스크립트 작성에도 적합합니다.

sudo docker compose exec shlink shlink short-url:create https://example.com/a/very/long/path
sudo docker compose exec shlink shlink short-url:create https://example.com/docs --custom-slug docs --tag reference

--custom-slug를 사용하면 생성된 코드 대신 읽기 쉬운 링크를 만들 수 있습니다. 슬러그(slug)는 도메인별로 고유하므로, 이미 사용 중인 슬러그로 다시 생성하려고 하면 덮어쓰기 대신 실패 처리됩니다. --tag는 반복해서 사용할 수 있으며, 태그는 나중에 통계를 통합해서 보고 싶은 링크들을 그룹화하는 용도로 사용합니다.

기존 링크 목록을 확인한 다음, 특정 링크의 트래픽을 조회합니다.

sudo docker compose exec shlink shlink short-url:list
sudo docker compose exec shlink shlink short-url:visits docs

short-url:visits은 클릭당 한 줄씩 날짜, 리퍼러(referrer), 사용자 에이전트(user agent) 정보를 출력합니다. 국가 및 도시 열은 GEOLITE_LICENSE_KEY 환경 변수를 설정하지 않으면 비어 있는 상태로 유지됩니다. 이 환경 변수는 Shlink가 GeoLite2 데이터베이스를 다운로드하는 데 사용하는 무료 MaxMind 키입니다. 설정하지 않아도 방문 기록은 남지만, 위치 정보는 표시되지 않습니다.

웹 클라이언트와 QR 코드

웹 클라이언트는 현재 127.0.0.1:8081에 위치하며 별도의 프록시 항목이 필요합니다. 공개를 원치 않는다면 SSH 터널을 사용할 수도 있습니다. 처음 로드할 때 서버 URL과 API 키를 요구합니다. https://s.example.com와 생성한 키를 입력하십시오. 클라이언트는 두 정보를 브라우저 저장소에 보관하고 API를 직접 호출하므로, 데이터가 제3자를 거치지 않습니다. 인터페이스를 API로부터 분리하는 방식은 주목할 만한 패턴입니다. 이 방식 덕분에 Halcyon이 미디어 서버를 수정하지 않고도 Jellyfin 라이브러리를 1990년대 대여점처럼 꾸미는 것이 가능합니다.

QR 코드는 별도의 설정이 필요 없습니다. 임의의 단축 URL 뒤에 /qr-code을 추가하면 API가 이미지를 반환합니다.

https://s.example.com/docs/qr-code?size=500&format=svg&margin=20

size은 픽셀 단위의 너비이며 50에서 1000까지 설정할 수 있고, 기본값은 300입니다. formatpng 또는 svg입니다. margin는 코드 주변의 여백(픽셀 단위)이며, 최종 이미지 크기는 코드 크기에 여백의 두 배를 더한 값입니다. 작게 인쇄하거나 일부가 가려져도 스캔이 가능하도록 하려면 errorCorrection=Q을 추가하십시오.

서비스 유지하기

단축 URL 서비스가 조용히 실패하는 경우가 있습니다. 링크 리다이렉트가 중단되어도 사용자는 링크가 죽었다고 생각할 뿐 아무도 알려주지 않습니다. 홈페이지가 아닌 실제 단축 URL을 대상으로 업타임 체크를 수행하고, 리다이렉트가 아닌 응답이 오면 알림을 받도록 설정하십시오. 직접 호스팅하는 Uptime Kuma 인스턴스는 이 작업을 잘 수행하며, 특정 상태 코드를 감시할 수 있습니다.

컨테이너가 아닌 데이터베이스를 백업하십시오. 하나의 명령어로 덤프를 생성할 수 있습니다.

sudo docker compose exec -T database pg_dump -U shlink shlink | gzip > shlink-$(date +%F).sql.gz

해당 파일과 compose 파일을 함께 사용하면 새로운 서버에서 전체 서비스를 재구축할 수 있습니다. 서버의 모든 애플리케이션은 각자의 백업 쌍이 필요합니다. 사진 라이브러리는 까다로운 사례인데, PhotoPrism 및 Immich는 데이터베이스의 행뿐만 아니라 디스크에 원본 파일을 저장하기 때문입니다. 따라서 덤프만으로는 복구가 불가능합니다. 업그레이드는 sudo docker compose pull을 실행한 뒤 sudo docker compose up -d을 수행하며, Shlink는 시작 시 새로운 마이그레이션을 자동으로 실행합니다. 마이그레이션은 롤백할 수 없으므로, 이미지를 가져오기(pull) 전에 반드시 덤프를 생성하십시오.

FAQ

리버스 프록시를 추가한 뒤 단축 URL이 404를 반환하는 이유는 무엇입니까?

Shlink는 Host 헤더에 포함된 도메인을 기준으로 단축 코드를 대조합니다. 프록시가 자체 이름이나 내부 주소를 전달하면, Shlink는 해당 도메인에서 코드를 찾으려 시도하지만 일치하는 링크가 없으므로 404를 반환합니다. nginx location 블록에 proxy_set_header Host $host;을 설정하고 프록시를 다시 로드하십시오. 컨테이너를 재시작할 필요 없이 즉시 링크가 정상적으로 작동합니다.

Postgres가 꼭 필요합니까, 아니면 SQLite로 충분합니까?

Shlink를 테스트하는 용도라면 SQLite로 충분하며 별도의 컨테이너도 필요하지 않습니다. 하지만 중요한 링크를 게시할 계획이라면 Postgres로 전환하십시오. 클릭이 발생할 때마다 방문 기록 행이 증가하며 SQLite는 쓰기 작업을 직렬화하기 때문입니다. 나중에 전환하려면 링크를 내보내고 다시 가져오는 과정이 필요하므로, 처음부터 Postgres를 선택하면 이러한 마이그레이션 과정을 생략할 수 있습니다.

복사해두지 않은 API 키를 복구할 수 있습니까?

아니요. Shlink는 키의 해시값만 저장하므로 api-key:list 명령은 키의 이름과 상태만 보여줄 뿐 실제 값은 표시하지 않습니다. shlink api-key:generate를 사용하여 새 키를 생성하고 웹 클라이언트에 붙여넣은 뒤, 기존 키는 shlink api-key:disable으로 비활성화하여 사용을 중단하십시오.

방문 통계에서 국가 열이 비어 있는 이유는 무엇입니까?

지리적 위치 정보를 확인하려면 GeoLite2 데이터베이스가 필요하며, Shlink는 GEOLITE_LICENSE_KEY를 제공해야만 이를 다운로드합니다. 해당 키는 MaxMind에서 무료로 발급받을 수 있습니다. 환경 변수 섹션에 키를 추가하고 컨테이너를 다시 생성하면 이후 발생하는 방문부터 위치 정보가 기록됩니다. 설정 이전에 기록된 방문 정보는 shlink visit:locate를 실행하기 전까지는 비어 있는 상태로 유지됩니다.

도메인을 유지한 채 데이터만 이전하십시오. pg_dump으로 데이터베이스를 덤프하고, 덤프 파일과 compose 파일을 새 서버로 복사한 뒤 스택을 시작하십시오. 실제 트래픽이 유입되기 전에 빈 데이터베이스에 덤프를 복원하십시오. DNS 레코드는 마지막에 변경하십시오. 모든 데이터가 데이터베이스에 저장되므로 단축 코드와 방문 기록은 그대로 유지됩니다.

#shlink#url-shortener#self-hosting#docker#postgres