' +
- '
' + (filtered ? '이 카테고리에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '
' +
- '
' + (filtered ? '다른 카테고리도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '
' +
+ '
' + (filtered ? '이 분야에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '
' +
+ '
' + (filtered ? '다른 분야도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '
' +
(filtered ? '
전체 보기'
: '
관리자') +
'
';
}
function showError(err) {
- leadEl.innerHTML = '';
- listEl.innerHTML = '';
- listEl.classList.add('is-hidden');
- moreEl.innerHTML = '';
+ groupsEl.innerHTML = '';
stateEl.innerHTML =
'' +
'
작업 목록을 불러오지 못했습니다.
' +
@@ -311,61 +262,47 @@ export async function view(params, outlet) {
class: 'btn btn--ghost',
type: 'button',
text: '다시 시도',
- onclick: () => { loadFirstPage(); }
+ onclick: () => { load(); }
})
);
}
}
- // ── 목록 ──────────────────────────────────────────────────────
- /** 첫 페이지: 맨 앞 글은 lead 로, 나머지는 인덱스 행으로. */
- function drawFirst(items) {
- leadEl.innerHTML = leadHtml(items[0]);
- const rest = items.slice(1);
- listEl.innerHTML = rest.map((p, i) => rowHtml(p, i + 2)).join('');
- listEl.classList.toggle('is-hidden', rest.length === 0);
+ /** 나눠 받은 페이지를 전부 모은다. 포트폴리오라 개수가 많지 않다. */
+ async function fetchAllPosts() {
+ const all = [];
+ let page = 1;
+ for (;;) {
+ const res = await api.posts.list({ category: activeSlug, page, size: FETCH_SIZE });
+ const items = res && Array.isArray(res.items) ? res.items : [];
+ all.push(...items);
+ const total = toInt(res && res.total);
+ if (!items.length || all.length >= total || page > 40) break;
+ page += 1;
+ }
+ return all;
}
- function drawMore(items) {
- const startAt = loaded - items.length;
- listEl.classList.remove('is-hidden');
- listEl.insertAdjacentHTML('beforeend', items.map((p, i) => rowHtml(p, startAt + i + 1)).join(''));
- }
-
- function renderMoreButton() {
- moreEl.innerHTML = '';
- if (loaded >= total) return;
- const btn = el('button', {
- class: 'btn btn--ghost',
- type: 'button',
- text: '더 보기',
- onclick: () => { loadNextPage(btn); }
- });
- moreEl.appendChild(btn);
- }
-
- async function loadFirstPage() {
- page = 1;
- loaded = 0;
- total = 0;
- clearState();
- moreEl.innerHTML = '';
- leadEl.innerHTML = '';
- listEl.classList.remove('is-hidden');
- listEl.innerHTML = skeletonRows(4);
+ async function load() {
+ stateEl.innerHTML = '';
+ groupsEl.innerHTML = '
' + skeletonRows(4) + '
';
setBusy(true);
try {
- const res = await api.posts.list({ category: activeSlug, page: 1, size: PAGE_SIZE });
+ const [cats, posts] = await Promise.all([
+ getCategories().catch(() => []),
+ fetchAllPosts()
+ ]);
if (disposed) return;
- const items = res && Array.isArray(res.items) ? res.items : [];
- loaded = items.length;
- total = Math.max(toInt(res && res.total), loaded);
- if (!items.length) {
+
+ renderStats(cats);
+ renderScope(activeSlug !== 'all');
+
+ if (!posts.length) {
showEmpty();
return;
}
- drawFirst(items);
- renderMoreButton();
+
+ groupsEl.innerHTML = groupByCategory(cats, posts).map(groupHtml).join('');
} catch (err) {
if (disposed) return;
showError(err);
@@ -374,56 +311,10 @@ export async function view(params, outlet) {
}
}
- async function loadNextPage(btn) {
- if (btn.disabled) return;
- btn.disabled = true;
- btn.textContent = '불러오는 중…';
- setBusy(true);
- try {
- const res = await api.posts.list({ category: activeSlug, page: page + 1, size: PAGE_SIZE });
- if (disposed) return;
- const items = res && Array.isArray(res.items) ? res.items : [];
- page += 1;
- loaded += items.length;
- total = Math.max(toInt(res && res.total), loaded);
- // 빈 페이지가 오면 더 이상 없는 것으로 본다(무한 클릭 방지).
- if (!items.length) total = loaded;
- else drawMore(items);
- renderMoreButton();
- } catch (err) {
- if (disposed) return;
- // 이미 그려진 행은 그대로 두고 버튼만 되돌린다.
- btn.disabled = false;
- btn.textContent = '더 보기';
- toast((err && err.message) || '더 불러오지 못했습니다.', 'error');
- } finally {
- setBusy(false);
- }
- }
-
- catsEl.addEventListener('click', onCatsClick);
-
- // 카테고리와 첫 페이지는 서로를 기다리지 않는다.
- getCategories().then(
- (cats) => {
- if (disposed) return;
- renderFilters(cats);
- renderStats(cats);
- },
- () => {
- if (disposed) return;
- // 카테고리를 못 받아도 목록 자체는 보여준다.
- renderFilters([]);
- statsEl.innerHTML = '
Unity · .NET · MariaDB';
- }
- );
-
- // await 하지 않는다: 라우터가 곧바로 cleanup 을 받아 갈 수 있게 한다.
- loadFirstPage();
+ load();
return () => {
disposed = true;
- catsEl.removeEventListener('click', onCatsClick);
setBusy(false);
};
}
diff --git a/README.md b/README.md
index 7e6f1db..37229c1 100644
--- a/README.md
+++ b/README.md
@@ -164,7 +164,296 @@ GameServer/
---
-## 5. 보안 메모
+## 5. 리눅스에 배포하기 — 도커 없이
+
+런타임까지 통째로 묶은 **자체 포함(self-contained) 실행 파일**을 만들어 올리는 방식입니다.
+**서버에 .NET 을 설치할 필요가 없습니다.** Visual Studio 게시 대화상자의
+"배포 모드: 자체 포함 / 대상 런타임: linux-x64" 와 같은 결과물입니다.
+
+### 만들기
+
+윈도우에서 만들어도 됩니다. 리눅스용 실행 파일이 나옵니다.
+
+**PowerShell** (VS Code 기본 터미널)
+```powershell
+.\deploy\publish-linux.cmd
+```
+
+**Git Bash · WSL · 맥 · 리눅스**
+```bash
+./deploy/publish-linux.sh
+```
+
+셋 다 같은 일을 합니다. 상황에 맞는 것을 쓰세요.
+
+| 파일 | 쓰는 곳 |
+|---|---|
+| `publish-linux.cmd` | PowerShell·cmd. 실행 정책을 건드리지 않아도 됩니다 |
+| `publish-linux.ps1` | 실제 내용. 아래 정책 문제를 해결했다면 직접 실행해도 됩니다 |
+| `publish-linux.sh` | Git Bash · WSL · 맥 · 리눅스 |
+
+#### `.ps1` 이 "이 시스템에서 스크립트를 실행할 수 없으므로" 오류가 날 때
+
+윈도우 기본 실행 정책이 `Restricted` 라 `.ps1` 이 막힙니다. 세 가지 방법이 있습니다.
+
+1. **`.cmd` 를 쓴다** — 가장 간단합니다. 정책을 바꾸지 않습니다.
+ ```powershell
+ .\deploy\publish-linux.cmd
+ ```
+ > `cmd` 파일은 앞에 `.\` 를 꼭 붙이세요. 없으면 현재 폴더를 찾지 못합니다.
+
+2. **한 번만 우회한다** — 시스템 설정을 바꾸지 않습니다.
+ ```powershell
+ powershell -ExecutionPolicy Bypass -File .\deploy\publish-linux.ps1
+ ```
+
+3. **내 계정에만 정책을 푼다** — 앞으로 계속 `.ps1` 을 쓰고 싶다면.
+ ```powershell
+ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
+ ```
+ `RemoteSigned` 는 직접 만든 스크립트는 실행하고, 인터넷에서 받은 것은
+ 서명을 요구합니다. 관리자 권한이 필요 없고 이 계정에만 적용됩니다.
+
+`artifacts/linux-x64/` 에 이런 결과가 나옵니다. **53MB, 파일 네 개뿐입니다.**
+
+```
+GameServer 리눅스 실행 파일 (런타임 포함, 단일 파일)
+GameServer.staticwebassets.endpoints.json
+appsettings.json
+wwwroot/ 사이트 (html · css · js)
+```
+
+스크립트가 쓰는 명령은 이것 하나입니다.
+
+```bash
+dotnet publish GameServer/GameServer.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -o artifacts/linux-x64
+```
+
+> `PublishTrimmed` 는 켜지 마세요. 용량은 더 줄지만 EF Core 가 리플렉션으로 찾는 타입이
+> 잘려 나가 실행 중에 터집니다.
+
+### 서버에서 바로 실행해 보기
+
+복사한 폴더로 들어가 `run.sh` 를 실행하면 끝입니다. 서버에 .NET 을 깔 필요가 없습니다.
+
+```bash
+cd /srv/genesis
+chmod +x run.sh
+./run.sh # PORT=8080 ./run.sh 처럼 포트를 바꿀 수 있습니다
+```
+
+`run.sh` 가 두 가지를 대신 해 줍니다.
+
+- **실행 권한** — 윈도우에서 복사하면 실행 비트가 사라져 `Permission denied` 가 납니다.
+- **바인딩 주소** — `ASPNETCORE_URLS` 를 주지 않으면 `localhost:5000` 에만 묶여
+ 서버 안에서만 보이고 브라우저로는 안 열립니다. `0.0.0.0:5281` 로 열어 줍니다.
+
+클라우드 서버라면 방화벽도 열어야 합니다. `sudo ufw allow 5281/tcp`
+
+SSH 를 끊으면 함께 종료되므로, 계속 띄워 두려면 아래 systemd 등록을 하세요.
+
+### 이미 돌고 있는 서버 멈추기
+
+새로 올린 것이 같은 포트를 쓰려면 기존 프로세스를 먼저 내려야 합니다.
+`Address already in use` 가 뜨면 이 경우입니다.
+
+**1) 무엇이 포트를 잡고 있는지 본다**
+
+```bash
+sudo ss -tlnp | grep 5281 # 없으면: sudo lsof -i :5281
+ps aux | grep -iE 'GameServer|dotnet' | grep -v grep
+systemctl list-units --type=service | grep -iE 'genesis|game|portfolio'
+docker ps # 도커로 띄웠다면
+```
+
+**2) 나온 결과에 맞춰 끈다**
+
+| 어떻게 떠 있나 | 끄는 법 |
+|---|---|
+| systemd 서비스 | `sudo systemctl stop 이름` |
+| 도커 컨테이너 | `docker stop 이름` |
+| 그냥 실행한 프로세스 | `kill -INT
` |
+| screen · tmux 안 | `screen -r` / `tmux attach` 후 `Ctrl+C` |
+
+**systemd 로 등록돼 있다면 `kill` 은 소용없습니다.** `Restart=always` 때문에
+바로 되살아납니다. 반드시 `systemctl stop` 을 쓰고, 다시 안 뜨게 하려면
+`sudo systemctl disable 이름` 까지 하세요.
+
+**3) 정말 내려갔는지 확인**
+
+```bash
+sudo ss -tlnp | grep 5281 # 아무것도 안 나오면 성공
+```
+
+> .NET 은 `SIGINT`(`kill -INT`) 를 받아야 정상 종료합니다.
+> 그냥 `kill` (SIGTERM) 이나 `kill -9` 는 쓰던 파일을 정리하지 못하고 끊길 수 있습니다.
+
+### 서버 준비 (처음 한 번)
+
+```bash
+sudo useradd -r -s /usr/sbin/nologin genesis
+sudo mkdir -p /srv/genesis/wwwroot/uploads
+sudo chown -R genesis:genesis /srv/genesis
+
+# 비밀값
+sudo cp deploy/genesis.env.example /srv/genesis/genesis.env
+sudo nano /srv/genesis/genesis.env
+sudo chown genesis:genesis /srv/genesis/genesis.env
+sudo chmod 600 /srv/genesis/genesis.env
+
+# 서비스 등록
+sudo cp deploy/genesis.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now genesis
+```
+
+### 올리기 · 갱신
+
+```powershell
+.\deploy\publish-linux.ps1 -Target user@서버주소 # PowerShell
+```
+```bash
+./deploy/publish-linux.sh user@서버주소 # bash
+```
+
+빌드 → 전송 → 실행 권한 부여까지 한 번에 합니다.
+(bash 판은 rsync 를, PowerShell 판은 scp 를 씁니다. 둘 다 서버의 사진을 지우지 않습니다.)
+**업로드된 사진(`wwwroot/uploads`)과 `genesis.env` 는 건드리지 않습니다.**
+
+수동으로 할 때도 이 두 가지는 반드시 지켜야 합니다.
+
+```bash
+rsync -avz --delete --exclude 'wwwroot/uploads/' --exclude 'genesis.env' artifacts/linux-x64/ user@서버:/srv/genesis/
+
+ssh user@서버 "chmod +x /srv/genesis/GameServer && sudo systemctl restart genesis"
+```
+
+> `chmod +x` 를 빼먹기 쉽습니다. 윈도우에서 복사하면 실행 권한이 사라져
+> `Permission denied` 로 서비스가 안 뜹니다.
+
+### 배포 후 확인
+
+산출물에 들어 있는 `healthcheck.sh` 를 서버에서 실행하면 한 번에 점검합니다.
+
+```bash
+cd /home/ubuntu/gameserver # 앱이 있는 폴더
+./healthcheck.sh 5000 GameServer # 포트, 서비스 이름
+```
+
+이런 것들을 확인합니다.
+
+1. 서비스가 `active (running)` 인지, 반복 재시작 중은 아닌지
+2. 포트를 실제로 듣고 있는지
+3. **기존 게임 API (`/myGame/*`) 가 그대로 되는지**
+4. 포트폴리오 API 와 카테고리 개수 (0 개면 DB 스키마 생성 실패)
+5. 사이트 정적 파일 (`/`, `css`, `js`)
+6. 업로드 폴더 쓰기 권한
+7. 최근 로그의 오류
+
+실패한 항목이 있으면 종료 코드 1 을 돌려주므로 자동화에도 쓸 수 있습니다.
+
+직접 보고 싶다면:
+
+```bash
+sudo systemctl status GameServer
+sudo journalctl -u GameServer -n 100 --no-pager
+sudo journalctl -u GameServer -f # 실시간
+sudo ss -tlnp | grep 5000
+curl -s localhost:5000/api/portfolio/categories
+```
+
+정상이라면 로그에 이 세 줄이 보입니다.
+
+```
+포트폴리오 스키마 준비 완료 (카테고리 N건).
+Now listening on: http://0.0.0.0:5000
+Application started. Press Ctrl+C to shut down.
+```
+
+> `Failed to determine the https port for redirect.` 경고는 무시해도 됩니다.
+> HTTP 로만 띄웠을 때 늘 나오는 안내입니다.
+
+### 어느 방식을 고를까
+
+| | 자체 포함 배포 | 도커 |
+|---|---|---|
+| 서버에 필요한 것 | 없음 | Docker |
+| 전송량 | 53MB | 이미지 레이어 |
+| 격리 | systemd 수준 | 컨테이너 |
+| 롤백 | 이전 폴더로 교체 | 이전 이미지 태그 |
+
+혼자 쓰는 서버라면 **자체 포함 배포가 더 단순합니다.** 도커 쪽은 아래 6절에 있습니다.
+
+---
+
+## 6. 리눅스 컨테이너로 배포하기
+
+### 준비물
+
+로컬(윈도우)에서 이미지를 만들 필요는 없습니다. **서버에서 바로 빌드하는 쪽이 간단합니다.**
+
+- 서버: Docker Engine + Compose 플러그인
+ ```bash
+ curl -fsSL https://get.docker.com | sudo sh
+ sudo usermod -aG docker $USER # 다시 로그인해야 적용됩니다
+ ```
+- VS Code(선택): 확장 두 개
+ - **Docker** (`ms-azuretools.vscode-docker`) — Dockerfile 문법 지원, 이미지·컨테이너 목록, 우클릭 빌드/실행
+ - **Remote - SSH** (`ms-vscode-remote.remote-ssh`) — 서버에 붙어서 그대로 편집·실행
+
+ Visual Studio 의 "Docker 지원 추가 → 게시" 같은 원클릭 배포는 VS Code 에 없습니다.
+ 대신 Remote-SSH 로 서버에 붙어 아래 명령을 터미널에서 실행하는 흐름이 표준입니다.
+
+### 첫 배포
+
+```bash
+git clone <저장소 주소> genesis && cd genesis
+
+cp .env.example .env
+nano .env # DB 연결 문자열 · 관리자 비밀번호 입력
+
+mkdir -p uploads
+sudo chown -R 1654:1654 uploads # 컨테이너의 app 사용자가 쓸 수 있게
+
+docker compose up -d --build
+```
+
+`http://서버주소:5281` 로 접속됩니다.
+
+### 다시 배포
+
+```bash
+git pull
+docker compose up -d --build
+```
+
+업로드된 사진은 호스트의 `./uploads` 에 있으므로 재배포해도 그대로 남습니다.
+
+### 확인 · 문제 해결
+
+```bash
+docker compose ps # 상태 (healthy 여야 정상)
+docker compose logs -f # 로그
+docker compose down # 중지
+```
+
+- **업로드가 403/500 으로 실패** → `uploads` 폴더 소유자 문제입니다.
+ `sudo chown -R 1654:1654 uploads` 를 다시 확인하세요.
+- **DB 에 못 붙음** → 컨테이너 안에서 `localhost` 는 컨테이너 자신입니다.
+ DB 가 같은 서버에 있어도 `.env` 에는 실제 IP 를 적어야 합니다.
+- **앞에 nginx 를 두는 경우** → `docker-compose.yml` 의 포트를
+ `"127.0.0.1:5281:8080"` 으로 바꿔 외부 직접 접근을 막으세요.
+
+### 컨테이너 없이 배포할 때
+
+`dotnet publish` 산출물만 올려도 됩니다. 이때 **업로드 폴더는 덮어쓰지 마세요.**
+`GameServer.csproj` 에서 `wwwroot/uploads` 를 배포 산출물에서 빼 두었으므로
+publish 결과에는 사진이 들어가지 않습니다(12MB). 서버의 기존 `wwwroot/uploads` 는
+그대로 두고 나머지 파일만 교체하면 됩니다.
+
+---
+
+## 7. 보안 메모
- 조회는 누구나, **작성 · 수정 · 삭제는 로그인한 관리자만** 가능합니다 (쿠키 세션).
- 로그인은 IP별 **5분에 8회**로 제한됩니다.
diff --git a/deploy/genesis.env.example b/deploy/genesis.env.example
new file mode 100644
index 0000000..963ade3
--- /dev/null
+++ b/deploy/genesis.env.example
@@ -0,0 +1,15 @@
+# 서버의 /srv/genesis/genesis.env 로 복사해 값을 채운다.
+#
+# sudo cp deploy/genesis.env.example /srv/genesis/genesis.env
+# sudo nano /srv/genesis/genesis.env
+# sudo chown genesis:genesis /srv/genesis/genesis.env
+# sudo chmod 600 /srv/genesis/genesis.env
+#
+# systemd 가 읽는 형식이라 KEY=VALUE 한 줄씩, 따옴표는 붙이지 않는다.
+# 값 안에 $ 가 있으면 systemd 가 변수로 해석하므로 $$ 로 두 번 쓴다.
+#
+# 여기 넣은 값이 appsettings.json 보다 우선한다.
+
+ConnectionStrings__DefaultConnection=Server=152.69.235.249;Port=3306;Database=mygame;Uid=root;Pwd=여기에-DB-비밀번호;
+Portfolio__AdminPassword=여기에-관리자-비밀번호
+Portfolio__MaxUploadBytes=104857600
diff --git a/deploy/genesis.service b/deploy/genesis.service
new file mode 100644
index 0000000..d3b660d
--- /dev/null
+++ b/deploy/genesis.service
@@ -0,0 +1,42 @@
+# systemd 서비스 등록 파일.
+# 서버에서:
+# sudo cp deploy/genesis.service /etc/systemd/system/
+# sudo systemctl daemon-reload
+# sudo systemctl enable --now genesis
+#
+# 경로나 사용자 이름을 바꿨다면 아래 값도 함께 고치세요.
+
+[Unit]
+Description=Genesis 게임 서버 + 포트폴리오 사이트
+After=network.target
+
+[Service]
+# 실행 파일과 wwwroot, appsettings.json 이 함께 있는 폴더
+WorkingDirectory=/srv/genesis
+ExecStart=/srv/genesis/GameServer
+
+# 비밀값은 이 파일에서 읽는다. (git 에 올리지 않는다)
+EnvironmentFile=/srv/genesis/genesis.env
+
+Environment=ASPNETCORE_ENVIRONMENT=Production
+# 0.0.0.0 이어야 외부에서 접속된다. nginx 를 앞에 둔다면 127.0.0.1 로 좁히세요.
+Environment=ASPNETCORE_URLS=http://0.0.0.0:5281
+Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
+
+User=genesis
+Group=genesis
+
+Restart=always
+RestartSec=5
+# .NET 은 SIGINT 를 받아야 정상 종료한다. (기본 SIGTERM 이면 강제 종료됨)
+KillSignal=SIGINT
+SyslogIdentifier=genesis
+
+# 최소한의 격리. 업로드 폴더에는 써야 하므로 전체 읽기전용은 걸지 않는다.
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=full
+ProtectHome=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/healthcheck.sh b/deploy/healthcheck.sh
new file mode 100644
index 0000000..ccbd9d9
--- /dev/null
+++ b/deploy/healthcheck.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+# 배포 후 서버가 제대로 떴는지 확인한다. 서버에서 실행한다.
+#
+# ./healthcheck.sh # 기본 http://localhost:5000
+# ./healthcheck.sh 5281 # 포트만 바꿔서
+# ./healthcheck.sh 5000 GameServer # 서비스 이름까지 지정
+# ./healthcheck.sh 5000 GameServer /home/ubuntu/gameserver # 앱 폴더까지
+#
+# 실패한 항목이 하나라도 있으면 종료 코드 1 을 돌려준다.
+
+BASE="http://localhost:${1:-5000}"
+SERVICE="${2:-GameServer}"
+# 앱 폴더(GameServer 실행 파일과 wwwroot 가 있는 곳). 기본은 이 스크립트가 있는 폴더.
+APPDIR="${3:-$(cd "$(dirname "$0")" && pwd)}"
+
+PASS=0
+FAIL=0
+ok() { PASS=$((PASS+1)); printf ' \033[32mOK \033[0m %s\n' "$1"; }
+bad() { FAIL=$((FAIL+1)); printf ' \033[31m실패\033[0m %s\n %s\n' "$1" "$2"; }
+
+# code URL -> 응답 코드만
+code() { curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" 2>/dev/null; }
+
+echo
+echo "=== 1. 서비스 상태 ==="
+if systemctl is-active --quiet "$SERVICE" 2>/dev/null; then
+ ok "$SERVICE 실행 중"
+ since=$(systemctl show -p ActiveEnterTimestamp --value "$SERVICE" 2>/dev/null)
+ [ -n "$since" ] && echo " 시작: $since"
+ # 계속 죽었다 살아나는 중인지
+ n=$(systemctl show -p NRestarts --value "$SERVICE" 2>/dev/null || echo 0)
+ if [ "${n:-0}" -gt 0 ]; then
+ echo " 재시작 횟수: $n (반복되면 로그를 확인하세요)"
+ fi
+else
+ bad "$SERVICE 가 실행 중이 아님" "sudo systemctl status $SERVICE"
+fi
+
+echo
+echo "=== 2. 포트 열림 ==="
+PORT="${1:-5000}"
+if ss -tln 2>/dev/null | grep -q ":$PORT "; then
+ ok "포트 $PORT 수신 중"
+ ss -tlnp 2>/dev/null | grep ":$PORT " | sed 's/^/ /'
+else
+ bad "포트 $PORT 가 열려 있지 않음" "sudo ss -tlnp | grep $PORT"
+fi
+
+echo
+echo "=== 3. 기존 게임 API (회귀 확인) ==="
+for p in /myGame/playableCharacters /myGame/userCharacters; do
+ c=$(code "$BASE$p")
+ if [ "$c" = "200" ]; then ok "GET $p"
+ else bad "GET $p -> $c" "DB 연결이나 라우팅을 확인하세요"; fi
+done
+
+echo
+echo "=== 4. 포트폴리오 ==="
+c=$(code "$BASE/api/portfolio/categories")
+if [ "$c" = "200" ]; then
+ ok "카테고리 API"
+ n=$(curl -s --max-time 10 "$BASE/api/portfolio/categories" | grep -o '"categoryNo"' | wc -l)
+ echo " 카테고리 $n 개"
+ [ "$n" -eq 0 ] && echo " (0 개면 DB 스키마 생성이 실패했을 수 있습니다. 로그를 보세요)"
+else
+ bad "카테고리 API -> $c" "DB 연결 실패일 가능성이 큽니다"
+fi
+
+c=$(code "$BASE/api/portfolio/posts")
+[ "$c" = "200" ] && ok "게시물 API" || bad "게시물 API -> $c" ""
+
+echo
+echo "=== 5. 사이트 (정적 파일) ==="
+for p in / /css/style.css /js/app.js; do
+ c=$(code "$BASE$p")
+ if [ "$c" = "200" ]; then ok "GET $p"
+ else bad "GET $p -> $c" "wwwroot 가 함께 배포됐는지 확인하세요"; fi
+done
+
+echo
+echo "=== 6. 업로드 폴더 쓰기 권한 ==="
+UPDIR="$APPDIR/wwwroot/uploads"
+if [ -d "$UPDIR" ]; then
+ if [ -w "$UPDIR" ]; then ok "쓰기 가능 ($UPDIR)"
+ else bad "쓰기 불가 ($UPDIR)" "서비스 실행 사용자에게 권한을 주세요"; fi
+ echo " 보관된 파일 $(find "$UPDIR" -type f ! -name '.gitkeep' 2>/dev/null | wc -l) 개"
+else
+ bad "업로드 폴더 없음 ($UPDIR)" "mkdir -p '$UPDIR'"
+fi
+
+echo
+echo "=== 7. 최근 오류 로그 ==="
+# grep -c 는 일치가 없으면 0 을 출력하고 종료코드 1 을 낸다.
+# '|| echo 0' 을 붙이면 0 이 두 번 나와 숫자 비교가 깨지므로 쓰지 않는다.
+errs=$(journalctl -u "$SERVICE" -n 200 --no-pager 2>/dev/null | grep -icE 'fail|error|exception')
+errs=$(printf '%s' "${errs:-0}" | head -1)
+case "$errs" in ''|*[!0-9]*) errs=0 ;; esac
+
+if [ "$errs" -eq 0 ]; then
+ ok "최근 200줄에 오류 없음"
+else
+ echo " 오류로 보이는 줄 $errs 개 — 확인:"
+ journalctl -u "$SERVICE" -n 200 --no-pager 2>/dev/null \
+ | grep -iE 'fail|error|exception' | tail -5 | sed 's/^/ /'
+fi
+
+echo
+echo "================================"
+printf ' 통과 %d 실패 %d\n' "$PASS" "$FAIL"
+echo "================================"
+if [ "$FAIL" -gt 0 ]; then
+ echo
+ echo "로그 전체 보기: sudo journalctl -u $SERVICE -n 100 --no-pager"
+ exit 1
+fi
diff --git a/deploy/publish-linux.cmd b/deploy/publish-linux.cmd
new file mode 100644
index 0000000..36628fe
--- /dev/null
+++ b/deploy/publish-linux.cmd
@@ -0,0 +1,5 @@
+@echo off
+REM PowerShell 실행 정책을 바꾸지 않고 게시 스크립트를 실행한다.
+REM publish-linux.cmd
+REM publish-linux.cmd -Target user@서버주소
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0publish-linux.ps1" %*
diff --git a/deploy/publish-linux.ps1 b/deploy/publish-linux.ps1
new file mode 100644
index 0000000..3bfde6e
--- /dev/null
+++ b/deploy/publish-linux.ps1
@@ -0,0 +1,144 @@
+# 리눅스용 자체 포함 실행 파일을 만든다. (윈도우 PowerShell 용)
+# bash 가 필요 없다. VS Code 기본 터미널에서 그대로 돌아간다.
+#
+# .\deploy\publish-linux.ps1
+# .\deploy\publish-linux.ps1 -Target ubuntu@서버주소 # 만들고 복사까지
+#
+# 경로·서비스 이름이 다르면:
+# .\deploy\publish-linux.ps1 -Target ubuntu@서버 `
+# -RemoteDir /home/ubuntu/gameserver -Service GameServer
+#
+# 서버에는 .NET 을 설치할 필요가 없다.
+
+param(
+ [string]$Target = "",
+ [string]$RemoteDir = "/srv/genesis",
+ # 비우면 서버에서 자동으로 찾는다.
+ [string]$Service = ""
+)
+
+$ErrorActionPreference = "Stop"
+
+# 저장소 루트로 이동 (이 스크립트는 deploy/ 안에 있다)
+$root = Split-Path -Parent $PSScriptRoot
+Set-Location $root
+
+$project = "GameServer/GameServer.csproj"
+$out = "artifacts/linux-x64"
+
+Write-Host "== 1) 빌드 ==" -ForegroundColor Cyan
+if (Test-Path $out) { Remove-Item $out -Recurse -Force }
+
+dotnet publish $project `
+ -c Release `
+ -r linux-x64 `
+ --self-contained true `
+ -p:PublishSingleFile=true `
+ -p:EnableCompressionInSingleFile=true `
+ -p:DebugType=none `
+ -p:DebugSymbols=false `
+ -o $out `
+ --nologo
+if ($LASTEXITCODE -ne 0) { throw "publish 실패" }
+
+# 개발용 설정은 서버로 보내지 않는다.
+Remove-Item "$out/appsettings.Development.json" -Force -ErrorAction SilentlyContinue
+
+# 서버에서 바로 띄울 실행 스크립트.
+# - ASPNETCORE_URLS 가 없으면 localhost:5000 에만 묶여 외부에서 안 보인다.
+# - 윈도우에서 복사하면 실행 권한이 사라지므로 스스로 chmod 한다.
+# 반드시 LF 로 저장해야 한다. CRLF 면 리눅스에서 "bad interpreter" 로 죽는다.
+$runSh = @(
+ '#!/usr/bin/env bash',
+ '# 서버에서 이 폴더로 들어와 ./run.sh 하면 바로 뜬다.',
+ '# PORT=8080 ./run.sh 처럼 포트를 바꿀 수 있다.',
+ 'set -euo pipefail',
+ 'cd "$(dirname "$0")"',
+ '',
+ 'PORT="${PORT:-5281}"',
+ '',
+ 'chmod +x ./GameServer 2>/dev/null || true',
+ 'mkdir -p wwwroot/uploads',
+ '',
+ '# 0.0.0.0 이어야 바깥에서 접속된다. 127.0.0.1 로 두면 서버 안에서만 보인다.',
+ 'export ASPNETCORE_URLS="http://0.0.0.0:${PORT}"',
+ 'export ASPNETCORE_ENVIRONMENT=Production',
+ '',
+ '# genesis.env 가 있으면 거기 값이 appsettings.json 보다 우선한다.',
+ 'if [ -f ./genesis.env ]; then',
+ ' set -a; . ./genesis.env; set +a',
+ 'fi',
+ '',
+ 'echo "http://0.0.0.0:${PORT} 에서 실행합니다. (Ctrl+C 로 종료)"',
+ 'exec ./GameServer',
+ ''
+) -join "`n"
+
+# UTF-8(BOM 없음) + LF 로 기록
+$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+[System.IO.File]::WriteAllText((Join-Path $root "$out/run.sh"), $runSh, $utf8NoBom)
+
+# 배포 후 확인 스크립트도 함께 넣는다.
+Copy-Item (Join-Path $PSScriptRoot "healthcheck.sh") "$out/" -Force -ErrorAction SilentlyContinue
+
+$size = "{0:N0} MB" -f ((Get-ChildItem $out -Recurse -File | Measure-Object Length -Sum).Sum / 1MB)
+Write-Host ""
+Write-Host " 산출물: $out ($size)" -ForegroundColor Green
+
+if ([string]::IsNullOrWhiteSpace($Target)) {
+ Write-Host ""
+ Write-Host " 서버로 올리려면:" -ForegroundColor Yellow
+ Write-Host " .\deploy\publish-linux.ps1 -Target user@서버주소"
+ Write-Host ""
+ Write-Host " 직접 복사한다면:"
+ Write-Host " scp -r $out/* user@서버주소:$RemoteDir/"
+ Write-Host " ssh user@서버주소 `"cd $RemoteDir && chmod +x run.sh && ./run.sh`""
+ exit 0
+}
+
+Write-Host ""
+Write-Host "== 2) 업로드 ==" -ForegroundColor Cyan
+Write-Host " 대상: ${Target}:$RemoteDir"
+
+# scp 는 지우지 않고 덮어쓰기만 한다.
+# 산출물에 wwwroot/uploads 가 없으므로 서버의 사진은 그대로 남는다.
+ssh $Target "mkdir -p '$RemoteDir'"
+if ($LASTEXITCODE -ne 0) { throw "서버 접속 실패" }
+
+scp -r "$out/*" "${Target}:$RemoteDir/"
+if ($LASTEXITCODE -ne 0) { throw "복사 실패" }
+
+Write-Host ""
+Write-Host "== 3) 마무리 ==" -ForegroundColor Cyan
+
+# 서버에서 실행할 명령. 작은따옴표 here-string 이라 PowerShell 이 손대지 않는다.
+$remoteScript = @'
+set -e
+cd "__DIR__"
+chmod +x GameServer run.sh
+mkdir -p wwwroot/uploads
+echo " 실행 권한 설정 완료"
+
+SVC="__SVC__"
+if [ -z "$SVC" ]; then
+ for c in GameServer genesis gameserver portfolio; do
+ if systemctl list-unit-files 2>/dev/null | grep -q "^$c.service"; then SVC="$c"; break; fi
+ done
+fi
+
+if [ -n "$SVC" ]; then
+ sudo systemctl restart "$SVC" && echo " $SVC 서비스 재시작"
+else
+ echo " systemd 서비스를 찾지 못했습니다. ./run.sh 로 직접 실행하세요."
+fi
+'@
+
+$remoteScript = $remoteScript.Replace('__DIR__', $RemoteDir).Replace('__SVC__', $Service)
+$remoteScript | ssh $Target "bash -s"
+
+Write-Host ""
+Write-Host "완료. 서버에서 실행하려면:" -ForegroundColor Green
+Write-Host " ssh $Target"
+Write-Host " cd $RemoteDir && ./run.sh"
+Write-Host ""
+Write-Host " (계속 띄워 두려면 README 5절의 systemd 등록을 참고하세요)"
diff --git a/deploy/publish-linux.sh b/deploy/publish-linux.sh
new file mode 100644
index 0000000..b8f0318
--- /dev/null
+++ b/deploy/publish-linux.sh
@@ -0,0 +1,124 @@
+#!/usr/bin/env bash
+# 리눅스용 자체 포함 실행 파일을 만들고 서버로 올린다.
+# 윈도우(Git Bash)·맥·리눅스 어디서 돌려도 된다. 서버에는 .NET 을 설치할 필요가 없다.
+#
+# ./deploy/publish-linux.sh # 빌드만
+# ./deploy/publish-linux.sh ubuntu@서버 # 빌드 후 업로드까지
+#
+# 경로·서비스 이름이 다르면 환경변수로 준다:
+# REMOTE_DIR=/home/ubuntu/gameserver SERVICE=GameServer # ./deploy/publish-linux.sh ubuntu@서버
+#
+# 업로드된 사진(wwwroot/uploads)은 건드리지 않는다.
+
+set -euo pipefail
+
+PROJECT="GameServer/GameServer.csproj"
+OUT="artifacts/linux-x64"
+TARGET="${1-}"
+REMOTE_DIR="${REMOTE_DIR:-/srv/genesis}"
+# 비우면 서버에서 자동으로 찾는다.
+SERVICE="${SERVICE:-}"
+
+echo "== 1) 빌드 =="
+rm -rf "$OUT"
+dotnet publish "$PROJECT" \
+ -c Release \
+ -r linux-x64 \
+ --self-contained true \
+ -p:PublishSingleFile=true \
+ -p:EnableCompressionInSingleFile=true \
+ -p:DebugType=none \
+ -p:DebugSymbols=false \
+ -o "$OUT" \
+ --nologo
+
+# 개발용 설정은 서버로 보내지 않는다.
+rm -f "$OUT/appsettings.Development.json"
+
+# 서버에서 바로 띄울 수 있는 실행 스크립트를 함께 넣는다.
+# - ASPNETCORE_URLS 를 지정하지 않으면 localhost:5000 에만 묶여 외부에서 안 보인다.
+# - 윈도우에서 복사하면 실행 권한이 사라지므로 여기서 다시 붙인다.
+cat > "$OUT/run.sh" <<'RUNSH'
+#!/usr/bin/env bash
+# 서버에서 이 폴더로 들어와 ./run.sh 하면 바로 뜬다.
+# PORT=8080 ./run.sh 처럼 포트를 바꿀 수 있다.
+set -euo pipefail
+cd "$(dirname "$0")"
+
+PORT="${PORT:-5281}"
+
+chmod +x ./GameServer 2>/dev/null || true
+mkdir -p wwwroot/uploads
+
+# 0.0.0.0 이어야 바깥에서 접속된다. 127.0.0.1 로 두면 서버 안에서만 보인다.
+export ASPNETCORE_URLS="http://0.0.0.0:${PORT}"
+export ASPNETCORE_ENVIRONMENT=Production
+
+# genesis.env 가 있으면 거기 값이 appsettings.json 보다 우선한다.
+if [ -f ./genesis.env ]; then
+ set -a; . ./genesis.env; set +a
+fi
+
+echo "http://0.0.0.0:${PORT} 에서 실행합니다. (Ctrl+C 로 종료)"
+exec ./GameServer
+RUNSH
+chmod +x "$OUT/run.sh"
+
+# 배포 후 확인 스크립트도 함께 넣는다.
+cp "$(dirname "$0")/healthcheck.sh" "$OUT/" 2>/dev/null || true
+chmod +x "$OUT/healthcheck.sh" 2>/dev/null || true
+
+echo
+echo " 산출물: $OUT ($(du -sh "$OUT" | cut -f1))"
+
+if [ -z "$TARGET" ]; then
+ echo
+ echo " 서버로 올리려면: $0 user@서버주소"
+ exit 0
+fi
+
+echo
+echo "== 2) 업로드 =="
+echo " 대상: $TARGET:$REMOTE_DIR"
+
+ssh "$TARGET" "mkdir -p '$REMOTE_DIR'"
+
+if command -v rsync >/dev/null 2>&1; then
+ # rsync 가 있으면 지운 파일까지 맞춰 준다.
+ # 다만 uploads 와 비밀값 파일은 제외해 서버 것을 남긴다.
+ rsync -avz --delete --exclude 'wwwroot/uploads/' --exclude 'genesis.env' --exclude 'appsettings.Production.json' "$OUT/" "$TARGET:$REMOTE_DIR/"
+else
+ # 윈도우 Git Bash 에는 보통 rsync 가 없다. scp 로 보낸다.
+ # scp 는 덮어쓰기만 하고 지우지 않으므로 서버의 사진은 그대로 남는다.
+ echo " rsync 가 없어 scp 로 보냅니다."
+ # 산출물에 appsettings.Production.json 이 없으므로 서버 것이 덮어써지지 않는다.
+ scp -r "$OUT"/* "$TARGET:$REMOTE_DIR/"
+fi
+
+echo
+echo "== 3) 마무리 =="
+# 윈도우에서 복사하면 실행 권한이 사라진다. 서버에서 다시 붙인다.
+ssh "$TARGET" "
+ cd '$REMOTE_DIR' &&
+ chmod +x GameServer run.sh &&
+ mkdir -p wwwroot/uploads &&
+ echo ' 실행 권한 설정 완료'
+
+ SVC='$SERVICE'
+ # 이름을 안 줬으면 이 폴더를 실행하는 서비스를 찾아본다.
+ if [ -z \"\$SVC\" ]; then
+ for c in GameServer genesis gameserver portfolio; do
+ if systemctl list-unit-files 2>/dev/null | grep -q \"^\$c.service\"; then SVC=\$c; break; fi
+ done
+ fi
+
+ if [ -n \"\$SVC\" ]; then
+ sudo systemctl restart \"\$SVC\" && echo \" \$SVC 서비스 재시작\"
+ else
+ echo ' systemd 서비스를 찾지 못했습니다. ./run.sh 로 직접 실행하세요.'
+ fi
+"
+
+echo
+echo "완료."
+echo " 바로 띄워 보려면: ssh $TARGET \"cd $REMOTE_DIR && ./run.sh\""
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e62e3ed
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,52 @@
+# 리눅스 서버에서 한 번에 띄우기.
+#
+# cp .env.example .env # 값 채우고
+# docker compose up -d --build
+#
+# 다시 배포할 때:
+# git pull && docker compose up -d --build
+#
+# 업로드 폴더는 호스트의 ./uploads 로 빼 두었다.
+# 컨테이너를 지우고 다시 만들어도 올려 둔 사진은 그대로 남는다.
+
+services:
+ gameserver:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: genesis-gameserver:latest
+ container_name: genesis
+ restart: unless-stopped
+
+ ports:
+ # 호스트 5281 → 컨테이너 8080
+ # 앞에 nginx 같은 리버스 프록시를 둔다면 "127.0.0.1:5281:8080" 으로 묶어
+ # 외부에서 직접 접근하지 못하게 하는 편이 낫다.
+ - "5281:8080"
+
+ environment:
+ ASPNETCORE_ENVIRONMENT: Production
+ # 환경변수가 appsettings.json 보다 우선한다. 비밀값은 여기(.env)로만 넣는다.
+ ConnectionStrings__DefaultConnection: ${DB_CONNECTION}
+ Portfolio__AdminPassword: ${ADMIN_PASSWORD}
+ Portfolio__MaxUploadBytes: ${MAX_UPLOAD_BYTES:-104857600}
+
+ volumes:
+ # 업로드된 사진. 이게 없으면 재배포 때 전부 사라진다.
+ # 호스트 폴더 소유자를 컨테이너의 app 사용자(UID 1654)로 맞춰야 쓰기가 된다:
+ # mkdir -p uploads && sudo chown -R 1654:1654 uploads
+ - ./uploads:/app/wwwroot/uploads
+
+ healthcheck:
+ # 포트폴리오 공개 API 가 응답하면 정상으로 본다.
+ test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/portfolio/categories >/dev/null 2>&1 || exit 1"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 40s
+
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"