안녕하세요!
Synology NAS를 사용하다 보면 Synology Photos, Immich, PhotoPrism 등 좋은 사진 관리 앱들이 있습니다.
앨범별, 인물별, 태그별로 사진을 정리하는 데는 더할 나위 없이 강력하죠.
하지만 문득 이런 생각이 들 때가 있습니다.
내가 다녀온 모든 여행지를 한눈에 볼 순 없을까?
사진들 속에는 ‘어디서’ 그리고 ‘언제’라는 귀중한 데이터가 GPS와 촬영 날짜 정보로 고스란히 담겨 있습니다.
기존 사진 앱들이 ‘앨범’이라는 서랍에 사진을 잘 정리해준다면,
이 데이터들을 활용해 ‘지도’라는 커다란 캔버스 위에 여행 기록을 펼쳐보는 것도 하나의 재미지 않을까요?
그래서 직접 만들어보기로 했습니다.
이 가이드에서는 복잡한 설정 없이, Synology NAS와 Docker, Portainer를 이용해
시놀로지의 사진을 지도 위에 펼쳐보는 ‘나만의 인터랙티브 여행 지도’를 구축하는 모든 과정을 안내합니다.
결과물 미리보기 및 기능





- 인터랙티브 지도
- 웹 브라우저를 통해 어디서든 접속할 수 있으며, 확대/축소가 가능한 지도 위에 내 모든 여행 사진의 위치가 표시됩니다.
- 지능형 클러스터링
- 한 지역에 사진이 몰려있으면 자동으로 그룹화(클러스터링)하여 지도를 깔끔하게 보여주고,
확대하면 개별 사진 마커가 나타납니다.
- 한 지역에 사진이 몰려있으면 자동으로 그룹화(클러스터링)하여 지도를 깔끔하게 보여주고,
- 날짜 필터링
- 달력 UI를 통해 원하는 시작 날짜와 종료 날짜를 선택하여, 특정 기간에 찍은 사진들만 지도에 표시할 수 있습니다.
이 프로젝트의 핵심 기능은 다음과 같습니다.
- GPS 및 촬영 날짜 자동 추출
- JPG는 물론 HEIC 사진 형식까지 지원하며,
사진 속 EXIF 메타데이터에서 위도, 경도, 촬영 날짜 정보를 자동으로 읽어옵니다.
- JPG는 물론 HEIC 사진 형식까지 지원하며,
- 실시간 자동 업데이트
- NAS 사진 폴더에 새 여행 사진을 추가하면, 웹사이트를 새로고침할 필요 없이 지도에 실시간으로 자동 반영됩니다.
- HEIC 실시간 변환 지원
- 웹 브라우저에서 바로 볼 수 없는 HEIC 파일을 실시간으로 JPG로 변환하여 보여줍니다.
- 손쉬운 배포 및 관리
- Docker와 Portainer를 통해 모든 복잡한 설치 과정을 자동화하고, 웹 UI로 손쉽게 관리할 수 있습니다.
- 완벽한 프라이버시
- 외부 서비스 없이, 100% 여러분의 Synology NAS 안에서만 구동됩니다.
프로젝트 전체 코드

먼저 시놀로지의 Docker 경로에 적당한 폴더를 생성합니다.
필자의 경우 /volume1/docker/CLI/travel-map에 위치해 있습니다.
db와 templates 폴더를 생성합니다.
이후 아래에서 생성할 파일 이름과 코드를 참고하여, 위 이미지와 같은 상태로 만듭니다.
Dockerfile
Docker 이미지를 만드는 설계도입니다.
# Python 3.9 slim 버전을 기반 이미지로 사용합니다.
FROM python:3.9-slim
# 시스템 패키지 설치 (HEIC 파일 지원)
RUN apt-get update && apt-get install -y --no-install-recommends libheif-dev \
&& rm -rf /var/lib/apt/lists/*
# 컨테이너 내의 작업 디렉토리를 /app 으로 설정합니다.
WORKDIR /app
# requirements.txt 파일을 먼저 복사하여 의존성을 설치합니다.
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
# 현재 디렉토리의 모든 파일을 컨테이너의 작업 디렉토리로 복사합니다.
COPY . .
# 컨테이너가 시작될 때 실행할 명령을 직접 지정합니다.
# 1. /data/photos.db 파일이 없으면 build_database.py를 실행하여 초기 DB를 생성합니다.
# 2. app.py를 실행하여 웹 서버를 시작합니다.
CMD ["/bin/sh", "-c", "if [ ! -f /data/photos.db ]; then echo '데이터베이스 파일이 없어 초기 스캔을 시작합니다.'; python3 build_database.py; fi; echo '웹 애플리케이션을 시작합니다.'; exec python3 app.py"]
requirements.txt
프로젝트에 필요한 파이썬 라이브러리 목록입니다.
Pillow
Flask
watchdog
pillow-heif
piexif
Flask-SocketIO
.dockerignore
Docker 이미지를 만들 때 불필요한 파일들을 제외시키는 설정 파일입니다.
# 데이터베이스 파일은 이미지에 포함하지 않음
photos.db
# Python 캐시 파일 무시
__pycache__/
*.pyc
*.pyo
*.pyd
# 기타 불필요한 파일들
.git
.gitignore
.dockerignore
README.md
build_database.py
최초 실행 시 사진 폴더를 스캔하여 데이터베이스를 생성하는 스크립트입니다.
import os
import sqlite3
import math
from PIL import Image
from PIL.ExifTags import GPSTAGS
import pillow_heif
import piexif
from datetime import datetime
# HEIC 지원 활성화
pillow_heif.register_heif_opener()
# --- 설정 ---
PHOTO_DIR = "/photos"
DB_FILE = "/data/photos.db"
ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.heic', '.HEIC']
# --- 설정 끝 ---
def get_exif_data(filename):
"""이미지 파일에서 EXIF 데이터를 추출합니다."""
try:
img = Image.open(filename)
if 'exif' in img.info:
return piexif.load(img.info['exif'])
elif hasattr(img, '_getexif'): # piexif가 못 읽는 경우를 위한 대비
exif = img._getexif()
if exif:
return piexif.load(img.info.get('exif', b''))
return None
except Exception:
return None
def get_decimal_from_dms(dms, ref):
"""DMS(도, 분, 초) 형식의 GPS 좌표를 십진수 형식으로 변환합니다."""
try:
degrees = dms[0][0] / dms[0][1]
minutes = dms[1][0] / dms[1][1] / 60.0
seconds = dms[2][0] / dms[2][1] / 3600.0
if ref in ['S', 'W']:
return -(degrees + minutes + seconds)
return round(degrees + minutes + seconds, 7)
except (ZeroDivisionError, TypeError, IndexError):
return None
def get_lat_lon(exif_data):
"""EXIF 데이터에서 위도와 경도를 추출합니다."""
try:
if "GPS" not in exif_data:
return None, None
gps_info = exif_data["GPS"]
lat_dms = gps_info.get(piexif.GPSIFD.GPSLatitude)
lon_dms = gps_info.get(piexif.GPSIFD.GPSLongitude)
lat_ref = gps_info.get(piexif.GPSIFD.GPSLatitudeRef, b'N').decode()
lon_ref = gps_info.get(piexif.GPSIFD.GPSLongitudeRef, b'E').decode()
if lat_dms and lon_dms:
lat = get_decimal_from_dms(lat_dms, lat_ref)
lon = get_decimal_from_dms(lon_dms, lon_ref)
if lat is not None and lon is not None and not (math.isnan(lat) or math.isnan(lon)):
return lat, lon
except Exception:
pass
return None, None
def get_taken_at(exif_data):
"""EXIF 데이터에서 촬영 날짜(DateTimeOriginal)를 추출합니다."""
if not exif_data or "Exif" not in exif_data:
return None
taken_at_tag = piexif.ExifIFD.DateTimeOriginal
if taken_at_tag in exif_data["Exif"]:
try:
date_str = exif_data["Exif"][taken_at_tag].decode('utf-8')
return datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S').strftime('%Y-%m-%d')
except (ValueError, TypeError):
return None
return None
def setup_database():
"""데이터베이스 연결 및 테이블 생성을 처리합니다."""
os.makedirs(os.path.dirname(DB_FILE), exist_ok=True)
conn = sqlite3.connect(DB_FILE)
conn.execute('DROP TABLE IF EXISTS photos')
conn.execute('''
CREATE TABLE photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT UNIQUE NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
taken_at TEXT
)
''')
conn.commit()
return conn
def main():
"""메인 실행 함수"""
conn = setup_database()
cursor = conn.cursor()
print("사진 폴더를 스캔하고 데이터베이스를 구축합니다...")
total_files = 0
gps_files = 0
for root, dirs, files in os.walk(PHOTO_DIR):
if '@eaDir' in dirs:
dirs.remove('@eaDir')
for filename in files:
try:
total_files += 1
if not any(filename.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS):
continue
file_path = os.path.join(root, filename)
exif_data = get_exif_data(file_path)
if not exif_data:
continue
lat, lon = get_lat_lon(exif_data)
taken_at = get_taken_at(exif_data)
if lat is not None and lon is not None:
gps_files += 1
try:
cursor.execute(
"INSERT INTO photos (file_path, latitude, longitude, taken_at) VALUES (?, ?, ?, ?)",
(file_path, lat, lon, taken_at)
)
except sqlite3.IntegrityError:
pass
except Exception as e:
print(f"[오류] 파일 처리 중 문제 발생: {filename} | 원인: {e}")
continue
conn.commit()
conn.close()
print(f"\n데이터베이스 구축 완료! (총 {total_files}개 파일 중 {gps_files}개의 GPS 정보 발견)")
if __name__ == '__main__':
main()
app.py
실시간 웹 서버 및 폴더 감시를 담당하는 메인 애플리케이션입니다.
import os
import sqlite3
import threading
import math
import io
from flask import Flask, jsonify, render_template, send_from_directory, Response, abort
from flask_socketio import SocketIO
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from PIL import Image
import pillow_heif
import piexif
from datetime import datetime
# --- 설정 ---
PHOTO_DIR = "/photos"
DB_FILE = "/data/photos.db"
ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.heic', '.HEIC']
# --- 설정 끝 ---
pillow_heif.register_heif_opener()
app = Flask(__name__)
socketio = SocketIO(app, async_mode='threading')
# --- 데이터베이스 및 EXIF 처리 유틸리티 ---
def get_db_connection():
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def get_exif_data(filename):
"""이미지 파일에서 EXIF 데이터를 추출합니다."""
try:
img = Image.open(filename)
if 'exif' in img.info:
return piexif.load(img.info['exif'])
elif hasattr(img, '_getexif'): # piexif가 못 읽는 경우를 위한 대비
exif = img._getexif()
if exif:
return piexif.load(img.info.get('exif', b''))
return None
except Exception:
return None
def get_decimal_from_dms(dms, ref):
"""DMS(도, 분, 초) 형식의 GPS 좌표를 십진수 형식으로 변환합니다."""
try:
degrees = dms[0][0] / dms[0][1]
minutes = dms[1][0] / dms[1][1] / 60.0
seconds = dms[2][0] / dms[2][1] / 3600.0
if ref in ['S', 'W']:
return -(degrees + minutes + seconds)
return round(degrees + minutes + seconds, 7)
except (ZeroDivisionError, TypeError, IndexError):
return None
def get_lat_lon(exif_data):
"""EXIF 데이터에서 위도와 경도를 추출합니다."""
try:
if "GPS" not in exif_data:
return None, None
gps_info = exif_data["GPS"]
lat_dms = gps_info.get(piexif.GPSIFD.GPSLatitude)
lon_dms = gps_info.get(piexif.GPSIFD.GPSLongitude)
lat_ref = gps_info.get(piexif.GPSIFD.GPSLatitudeRef, b'N').decode()
lon_ref = gps_info.get(piexif.GPSIFD.GPSLongitudeRef, b'E').decode()
if lat_dms and lon_dms:
lat = get_decimal_from_dms(lat_dms, lat_ref)
lon = get_decimal_from_dms(lon_dms, lon_ref)
if lat is not None and lon is not None and not (math.isnan(lat) or math.isnan(lon)):
return lat, lon
except Exception:
pass
return None, None
def get_taken_at(exif_data):
if not exif_data or "Exif" not in exif_data: return None
taken_at_tag = piexif.ExifIFD.DateTimeOriginal
if taken_at_tag in exif_data["Exif"]:
try:
date_str = exif_data["Exif"][taken_at_tag].decode('utf-8')
return datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S').strftime('%Y-%m-%d')
except (ValueError, TypeError): return None
return None
# --- 실시간 파일 감시 ---
class PhotoEventHandler(FileSystemEventHandler):
def process(self, event_type, src_path, dest_path=None):
try:
if '@eaDir' in src_path or (dest_path and '@eaDir' in dest_path):
return
filename = os.path.basename(src_path)
if not any(filename.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS):
return
conn = get_db_connection()
cursor = conn.cursor()
if event_type == 'CREATED':
exif_data = get_exif_data(src_path)
if exif_data:
lat, lon = get_lat_lon(exif_data)
taken_at = get_taken_at(exif_data)
if lat is not None and lon is not None:
cursor.execute("INSERT OR REPLACE INTO photos (file_path, latitude, longitude, taken_at) VALUES (?, ?, ?, ?)",
(src_path, lat, lon, taken_at))
conn.commit()
new_photo = dict(cursor.execute("SELECT * FROM photos WHERE file_path = ?", (src_path,)).fetchone())
socketio.emit('photo_added', new_photo)
elif event_type == 'DELETED':
cursor.execute("SELECT id FROM photos WHERE file_path = ?", (src_path,))
photo = cursor.fetchone()
if photo:
photo_id = photo['id']
cursor.execute("DELETE FROM photos WHERE id = ?", (photo_id,))
conn.commit()
socketio.emit('photo_deleted', {'id': photo_id})
elif event_type == 'MOVED':
cursor.execute("UPDATE photos SET file_path = ? WHERE file_path = ?", (dest_path, src_path))
conn.commit()
photo = cursor.execute("SELECT * FROM photos WHERE file_path = ?", (dest_path,)).fetchone()
if photo:
socketio.emit('photo_moved', {'id': photo['id'], 'new_file_path': dest_path})
conn.close()
except Exception as e:
print(f"[오류] 실시간 파일 처리 중 문제 발생: {src_path} | 원인: {e}")
def on_created(self, event):
if not event.is_directory: self.process('CREATED', event.src_path)
def on_deleted(self, event):
if not event.is_directory: self.process('DELETED', event.src_path)
def on_moved(self, event):
if not event.is_directory: self.process('MOVED', event.src_path, dest_path=event.dest_path)
def start_watcher():
observer = Observer()
observer.schedule(PhotoEventHandler(), PHOTO_DIR, recursive=True)
observer.start()
# --- Flask 라우트 ---
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/photos')
def get_photos_api():
conn = get_db_connection()
photos = conn.execute('SELECT id, file_path, latitude, longitude, taken_at FROM photos').fetchall()
conn.close()
return jsonify([dict(p) for p in photos])
@app.route('/image/<path:filepath>')
def serve_image(filepath):
"""HEIC 파일을 JPG로 실시간 변환하여 제공하는 라우트"""
try:
full_path = os.path.join(PHOTO_DIR, filepath)
if not os.path.exists(full_path):
abort(404)
if filepath.lower().endswith('.heic'):
img = Image.open(full_path)
img_byte_arr = io.BytesIO()
img.save(img_byte_arr, format='JPEG')
img_byte_arr.seek(0)
return Response(img_byte_arr, mimetype='image/jpeg')
else:
return send_from_directory(PHOTO_DIR, filepath)
except Exception as e:
print(f"[오류] 이미지 제공 중 문제 발생: {filepath} | 원인: {e}")
abort(500)
if __name__ == '__main__':
threading.Thread(target=start_watcher, daemon=True).start()
socketio.run(app, host='0.0.0.0', port=5000, allow_unsafe_werkzeug=True)
index.html
사용자에게 보여지는 지도 웹페이지입니다.
templates 폴더 안에 저장해야 합니다.
<!DOCTYPE html>
<html>
<head>
<title>Photo Map</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CSS Libraries -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<style>
body, html { margin: 0; padding: 0; height: 100%; }
#map { height: 100%; }
.leaflet-popup-content-wrapper { width: 320px; height: 320px; }
.leaflet-popup-content { margin: 0; width: 100% !important; height: 100% !important; }
.leaflet-popup-content img { width: 100%; height: 100%; object-fit: cover; }
.carousel-item img { max-height: 80vh; object-fit: contain; }
.carousel-caption { background-color: rgba(0, 0, 0, 0.5); border-radius: 5px; }
#date-filter-container {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 10px;
border-radius: 5px;
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
display: flex;
align-items: center;
gap: 10px;
}
#date-filter-container input {
width: 120px;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="date-filter-container">
<input type="text" id="startDate" placeholder="시작 날짜">
<span>~</span>
<input type="text" id="endDate" placeholder="종료 날짜">
<button id="filterBtn" class="btn btn-primary btn-sm">적용</button>
<button id="resetBtn" class="btn btn-secondary btn-sm">초기화</button>
</div>
<!-- Modal -->
<div class="modal fade" id="photoCarouselModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-body p-0">
<div id="photoCarousel" class="carousel slide">
<div class="carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#photoCarousel" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span><span class="visually-hidden">Previous</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#photoCarousel" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span><span class="visually-hidden">Next</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- JS Libraries -->
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://npmcdn.com/flatpickr/dist/l10n/ko.js"></script>
<script>
// --- 초기 설정 ---
var map = L.map('map').setView([36.5, 127.5], 7);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
var markerClusterGroup = L.markerClusterGroup();
map.addLayer(markerClusterGroup);
var allMarkers = [];
var markersById = {};
var galleryModal = new bootstrap.Modal(document.getElementById('photoCarouselModal'));
var socket = io();
// --- 날짜 필터링 UI 설정 ---
var fpStart = flatpickr("#startDate", { locale: "ko" });
var fpEnd = flatpickr("#endDate", { locale: "ko" });
document.getElementById('filterBtn').addEventListener('click', filterMarkersByDate);
document.getElementById('resetBtn').addEventListener('click', () => {
fpStart.clear();
fpEnd.clear();
filterMarkersByDate();
});
// --- 함수 정의 ---
function createMarker(photo) {
var relativePath = photo.file_path.replace('/photos/', '');
var imageUrl = `/image/${relativePath}`;
var popupContent = `<a href="${imageUrl}" target="_blank"><img src="${imageUrl}" alt="Photo" /></a>`;
var marker = L.marker([photo.latitude, photo.longitude], {
id: photo.id,
imagePath: relativePath,
imageUrl: imageUrl,
takenAt: photo.taken_at
}).bindPopup(popupContent, { maxWidth: "auto" });
return marker;
}
function filterMarkersByDate() {
markerClusterGroup.clearLayers();
var startDate = fpStart.selectedDates[0];
var endDate = fpEnd.selectedDates[0];
if (endDate) {
endDate.setHours(23, 59, 59, 999); // 종료일의 마지막 시간까지 포함
}
var filteredMarkers = allMarkers.filter(marker => {
if (!marker.options.takenAt) return true; // 날짜 정보 없는 사진은 항상 표시
var takenDate = new Date(marker.options.takenAt);
var startMatch = startDate ? takenDate >= startDate : true;
var endMatch = endDate ? takenDate <= endDate : true;
return startMatch && endMatch;
});
markerClusterGroup.addLayers(filteredMarkers);
}
// --- 데이터 로딩 및 소켓 이벤트 처리 ---
fetch('/api/photos')
.then(response => response.json())
.then(photos => {
photos.forEach(photo => {
var marker = createMarker(photo);
allMarkers.push(marker);
markersById[photo.id] = marker;
});
filterMarkersByDate(); // 초기 로드 시 전체 마커 표시
});
socket.on('photo_added', function(photo) {
var marker = createMarker(photo);
allMarkers.push(marker);
markersById[photo.id] = marker;
filterMarkersByDate(); // 필터 상태에 맞게 갱신
});
socket.on('photo_deleted', function(data) {
var marker = markersById[data.id];
if (marker) {
allMarkers = allMarkers.filter(m => m.options.id !== data.id);
delete markersById[data.id];
filterMarkersByDate(); // 필터 상태에 맞게 갱신
}
});
socket.on('photo_moved', function(data) {
var marker = markersById[data.id];
if (marker) {
var newRelativePath = data.new_file_path.replace('/photos/', '');
var newImageUrl = `/image/${newRelativePath}`;
marker.options.imagePath = newRelativePath;
marker.options.imageUrl = newImageUrl;
var newPopupContent = `<a href="${newImageUrl}" target="_blank"><img src="${newImageUrl}" alt="Photo" /></a>`;
marker.bindPopup(newPopupContent, { maxWidth: "auto" });
}
});
// --- 클러스터 클릭 이벤트 (갤러리) ---
markerClusterGroup.on('clusterclick', function (a) {
if (map.getZoom() < map.getMaxZoom()) {
a.layer.zoomToBounds();
return;
}
var childMarkers = a.layer.getAllChildMarkers();
var carouselInner = document.querySelector('#photoCarousel .carousel-inner');
carouselInner.innerHTML = '';
childMarkers.forEach(function(marker, index) {
var imageUrl = marker.options.imageUrl;
var activeClass = index === 0 ? 'active' : '';
var carouselItem = `
<div class="carousel-item ${activeClass}">
<img src="${imageUrl}" class="d-block w-100" alt="...">
</div>`;
carouselInner.innerHTML += carouselItem;
});
galleryModal.show();
});
</script>
</body>
</html>
설치 단계
파일을 경로에 잘 생성했다면, 모든 준비가 끝났습니다.
이제 아래 단계를 잘 따라오시기 바랍니다.
Docker 이미지 빌드
소스 코드를 실행 패키지(이미지)로 만들기 위해, 시놀로지 SSH에 접속하고,
sudo -i를 입력하여 root 권한을 얻습니다.
이후 cd 명령어로, 프로젝트의 전체 코드가 담긴 경로로 이동합니다.
cd [경로]
필자의 경우 아래와 같습니다.
cd /volume1/docker/CLI/travel-map

아래 명령어를 입력하여 Docker 이미지를 빌드합니다.
몇 분 정도 소요될 수 있습니다.
docker build --no-cache -t travel-map:local .

명령어 실행이 끝나고 성공 메시지가 나타나면, 위와 같이 Docker 이미지에 travel-map이 생성됩니다.
SSH 접속은 종료해도 좋습니다.
Portainer 스택으로 배포하기
Portainer가 없으신 분은, 이 가이드를 참고하여 설치하시기 바랍니다.

Portainer의 좌측에서 Stacks에 진입합니다.

Add stack을 클릭합니다.

이름을 지정하고, 아래의 코드를 복사하여 Web editor에 붙여 넣습니다.
필요하다면 포트를 수정합니다.
version: '3.8'
services:
travel-map:
# Docker Hub에서 이미지를 받는 대신, 로컬에 직접 빌드한 이미지를 사용하도록 지정
image: travel-map:local
container_name: travel-map
ports:
- "7979:5000" # 필요시 앞 7979번 포트를 다른 번호로 변경
volumes:
- ${PHOTO_PATH}:/photos
- ${DB_PATH}:/data
restart: unless-stopped
environment:
- TZ=Asia/Seoul

페이지를 아래로 내려 Add an environment variable를 두 번 클릭합니다.

아래를 참고하여, 위 이미지처럼 필드를 채웁니다.
- PHOTO_PATH
- 사진들이 있는 폴더의 실제 경로를 입력
- 필자의 경우 /volume1/Archive-Main/사진/Reference
- 사진들이 있는 폴더의 실제 경로를 입력
- DB_PATH
- 생성한 db 폴더 경로
- 필자의 경우 /volume1/docker/CLI/travel-map/db
- 생성한 db 폴더 경로

하단의 Deploy the stack 버튼을 클릭합니다.

우측 상단에 Success가 나타나면 성공입니다!



이제 Portainer의 Containers에서 travel-map의 로그를 확인합니다.

사진의 양과 CPU 성능에 따라 초기 스캔에 몇 분이 소요될 수 있습니다.
최종적으로 위와 같은 로그가 확인되면, NAS IP:PORT로 접속해서 지도에 핀을 확인합니다.


HEIC 파일도 jpg로 변환하여 브라우저에서 완벽하게 대응됩니다.
여기까지 따라오시느라 정말 수고 많으셨습니다!
실험적이지만, 이번 포스팅은 필자의 개입 없이 전부 Gemini CLI를 통해 만들어진 프로젝트입니다.
AI가 발전한 덕분에, 코딩을 할 줄 모르더라도 개인 범주에서는 충분히 만족할 만한 결과물을 만들 수 있는 시대가 왔습니다.
Gemini CLI에 흥미가 있으신 분은, Synology Gemini CLI 설치 가이드를 참고하여 주시면 감사하겠습니다.
중간에 코드가 많아 조금 복잡하게 느껴졌을 수도 있지만,
막상 가이드대로 진행해보면 대부분 복사해서 붙여 넣고 버튼 몇 번 누르는 과정이라 생각보다 어렵지 않으셨을 겁니다.
이 여행 지도가 여러분의 추억을 더욱 특별하게 만들어주는 작은 선물이 되었으면 좋겠습니다.
감사합니다!