나의 사이트 수정

This commit is contained in:
2026-09-04 01:44:59 +09:00
parent 78c2ce676a
commit eeb14b40cb
15 changed files with 1054 additions and 405 deletions

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
# 빌드 컨텍스트에서 제외할 것들.
# 특히 wwwroot/uploads 는 올려 둔 사진이 수십 MB 라 이미지에 들어가면 안 된다.
**/bin
**/obj
**/.vs
**/.vscode
**/.git
**/.gitignore
**/node_modules
**/*.user
**/*.suo
# 업로드된 사진은 볼륨으로 관리한다. 이미지에 굽지 않는다.
GameServer/wwwroot/uploads/
# 비밀값
.env
**/appsettings.Development.json
# 문서·산출물
README.md
*.md

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# 이 파일을 .env 로 복사한 뒤 값을 채운다. (.env 는 git 에 올라가지 않는다)
#
# cp .env.example .env
#
# 여기 넣은 값이 appsettings.json 보다 우선한다.
# MariaDB 연결 문자열.
# 컨테이너 안에서는 localhost 가 컨테이너 자신을 가리킨다.
# DB 가 같은 호스트에서 돌더라도 localhost 가 아니라 실제 주소를 적어야 한다.
DB_CONNECTION=Server=152.69.235.249;Port=3306;Database=mygame;Uid=root;Pwd=여기에-DB-비밀번호;
# 포트폴리오 관리자 비밀번호 (글쓰기 로그인)
ADMIN_PASSWORD=여기에-관리자-비밀번호
# 업로드 한 장의 최대 크기 (바이트). 기본 100MB
MAX_UPLOAD_BYTES=104857600

7
.gitignore vendored
View File

@@ -276,3 +276,10 @@ __pycache__/
# Portfolio uploaded media
GameServer/wwwroot/uploads/*
!GameServer/wwwroot/uploads/.gitkeep
# 배포 산출물
/artifacts/
# 컨테이너 배포용 비밀값 · 호스트 업로드 폴더
.env
/uploads/

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# Genesis 게임 서버 + 포트폴리오 사이트 — 리눅스 컨테이너 이미지
#
# 빌드 컨텍스트는 저장소 루트다. (Dockerfile 이 루트에 있으므로 `docker build .`)
#
# docker build -t genesis-gameserver .
# docker run -d --name genesis -p 5281:8080 genesis-gameserver
#
# 주의: 업로드된 이미지는 /app/wwwroot/uploads 에 쌓인다.
# 볼륨으로 빼 두지 않으면 컨테이너를 새로 만들 때 전부 사라진다.
# ─────────────────────────────────────────────────────────────
# 1단계 — 빌드
# 프로젝트가 net9.0 을 대상으로 하므로 SDK 9 이미지를 쓴다.
# ─────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# csproj 만 먼저 복사해 restore 한다.
# 소스만 바뀐 경우 이 레이어가 캐시되어 재빌드가 빨라진다.
COPY GameServer/GameServer.csproj GameServer/
RUN dotnet restore GameServer/GameServer.csproj
COPY . .
RUN dotnet publish GameServer/GameServer.csproj \
-c Release \
-o /app/publish \
-p:UseAppHost=false
# ─────────────────────────────────────────────────────────────
# 2단계 — 실행
# ─────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
# 업로드 폴더를 만들고 비루트 사용자(app, UID 1654)에게 쓰기 권한을 준다.
# 호스트 폴더를 마운트할 때도 같은 UID 로 맞춰야 한다. (README 참고)
RUN mkdir -p /app/wwwroot/uploads && chown -R app:app /app/wwwroot/uploads
# 마운트를 잊어도 최소한 컨테이너 수명 동안은 동작하도록 볼륨을 선언해 둔다.
VOLUME ["/app/wwwroot/uploads"]
# .NET 8 부터 컨테이너 기본 포트는 8080 이다. (루트 권한이 필요 없는 포트)
ENV ASPNETCORE_HTTP_PORTS=8080 \
ASPNETCORE_ENVIRONMENT=Production \
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
EXPOSE 8080
USER app
ENTRYPOINT ["dotnet", "GameServer.dll"]

View File

@@ -5,6 +5,13 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- 업로드된 사진은 배포 산출물에 넣지 않는다.
서버에서는 볼륨(컨테이너) 또는 서버의 uploads 폴더로 따로 관리한다.
빼지 않으면 dotnet publish 가 사진 수십 MB 를 함께 배포한다. -->
<Content Remove="wwwroot/uploads/**" />
<None Remove="wwwroot/uploads/**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.13">

View File

@@ -448,209 +448,37 @@ em {
color: var(--text-mute);
}
/* --------------------------------------------------------------------------
6. 인라인 카테고리 필터 — 칩이 아니라 글자
-------------------------------------------------------------------------- */
.filters {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-4);
}
.filter {
position: relative;
display: inline-flex;
align-items: baseline;
gap: var(--space-2);
padding: var(--space-1) 0;
color: var(--text-mute);
font-size: var(--step--1);
font-weight: 600;
white-space: nowrap;
transition: color var(--dur) var(--ease);
}
.filter:hover {
color: var(--text);
}
.filter.is-active {
color: var(--text);
}
/* 활성 항목은 카테고리 색 밑줄 */
.filter.is-active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
border-radius: 2px;
background: var(--card-accent);
}
.filter__count {
font-family: var(--font-mono);
font-size: var(--step--2);
color: var(--text-mute);
opacity: 0.7;
}
.filter.is-active .filter__count {
color: var(--card-accent);
opacity: 1;
}
/* --------------------------------------------------------------------------
7. 인덱스 목록 + 커서 썸네일
-------------------------------------------------------------------------- */
/* 대표 작업 — 목록 맨 앞 글을 크게 보여 준다.
글이 하나뿐이어도 화면이 비어 보이지 않게 하는 역할. */
.lead {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-6);
align-items: center;
margin-bottom: var(--space-7);
color: var(--text);
}
.lead__media {
position: relative;
margin: 0;
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: var(--radius);
border: 1px solid var(--rule);
background: var(--surface-2);
}
.lead__media img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 620ms var(--ease);
}
.lead:hover .lead__media img {
transform: scale(1.03);
}
/* 이미지가 없는 글의 자리 — 카테고리 색으로 옅게 물든 판 + 첫 글자 */
.lead__media--none,
/* 이미지가 없는 글의 자리 — 카테고리 색으로 옅게 물든 판 + 제목 첫 글자 */
.idx__thumb--none {
display: grid;
place-items: center;
background-color: var(--surface-2);
background-image:
linear-gradient(140deg,
color-mix(in srgb, var(--card-accent) 16%, transparent) 0%,
color-mix(in srgb, var(--card-accent) 18%, transparent) 0%,
transparent 62%);
color: color-mix(in srgb, var(--card-accent) 45%, var(--text-mute));
font-family: var(--font-mono);
font-weight: 700;
font-size: 26px;
line-height: 1;
}
.lead__media--none {
font-size: clamp(56px, 8vw, 104px);
}
.idx__thumb--none {
font-size: 20px;
}
/* color-mix 미지원 브라우저 대비 */
@supports not (background: color-mix(in srgb, red 10%, transparent)) {
.lead__media--none,
.idx__thumb--none {
background-image: none;
color: var(--text-mute);
}
}
.lead__body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
min-width: 0;
}
/* 한글은 자간을 크게 벌리면 글자가 흩어져 보인다. 살짝만 준다. */
.lead__label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--step--2);
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-mute);
}
.lead__label::before {
content: "";
width: 18px;
height: 2px;
border-radius: 2px;
background: var(--card-accent);
}
.lead__cat {
font-family: var(--font-mono);
font-size: var(--step--1);
font-weight: 700;
letter-spacing: 0.06em;
color: var(--card-accent);
}
.lead__title {
font-size: var(--step-3);
font-weight: 800;
letter-spacing: -0.035em;
line-height: 1.2;
word-break: keep-all;
color: var(--text);
}
.lead__sum {
font-size: var(--step-0);
color: var(--text-dim);
line-height: 1.75;
word-break: keep-all;
}
.lead__meta {
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--text-mute);
}
.lead__more {
margin-top: var(--space-2);
font-size: var(--step--1);
font-weight: 700;
color: var(--card-accent);
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--card-accent) 32%, transparent);
transition: box-shadow var(--dur) var(--ease);
}
.lead__more::after {
content: " →";
}
.lead:hover .lead__more {
box-shadow: inset 0 -2px 0 var(--card-accent);
}
/* 인덱스 행의 작은 썸네일 */
/* 인덱스 행의 썸네일 */
.idx__thumb {
margin: 0;
width: 96px;
width: 132px;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: var(--radius-sm);
@@ -671,6 +499,41 @@ em {
border-color: color-mix(in srgb, var(--card-accent) 45%, var(--rule));
}
/* 카테고리 묶음 — 분야 제목 + 그 아래 목록 */
.group {
margin-bottom: var(--space-7);
}
.group:last-child {
margin-bottom: 0;
}
.group__title {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-5);
font-size: var(--step-1);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.group__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--card-accent);
flex: none;
}
.group__count {
font-family: var(--font-mono);
font-size: var(--step--2);
font-weight: 700;
color: var(--text-mute);
}
.index {
list-style: none;
border-top: 1px solid var(--rule-strong);
@@ -683,11 +546,12 @@ em {
/* 행 하나. 번호 / 본문 / 메타 세 칸 그리드. */
.idx {
display: grid;
/* 번호 · 썸네일 · 본문 · 메타 */
grid-template-columns: 2.6rem auto 1fr auto;
/* 번호 · 썸네일 · 본문 · 날짜 */
grid-template-columns: 2.4rem auto 1fr auto;
align-items: center;
gap: var(--space-4);
padding: var(--space-4) 0;
gap: var(--space-5);
/* 썸네일이 잘 보이도록 위아래를 넉넉히 둔다 */
padding: var(--space-5) 0;
color: var(--text);
transition: padding-left var(--dur) var(--ease), color var(--dur) var(--ease);
}
@@ -1794,13 +1658,17 @@ a.sr-only:focus-visible {
/* 좁은 화면에서는 메타를 제목 아래로 내린다 */
.idx {
grid-template-columns: 2rem 64px 1fr;
gap: var(--space-3);
grid-template-columns: 2rem 88px 1fr;
gap: var(--space-4);
padding: var(--space-4) 0;
}
.idx__thumb {
width: 64px;
width: 88px;
}
.group {
margin-bottom: var(--space-7);
}
.idx__meta {

View File

@@ -1,21 +1,19 @@
// views/home.js — 홈(작업 인덱스) 화면
// views/home.js — 홈(작업 목록) 화면
// 라우트: #/ · #/?cat=<slug>
// 구성: 히어로 → 인라인 카테고리 필터 → 대표 작업 한 건 → 번호 매긴 인덱스
// 구성: 히어로 → 카테고리별 묶음, 각 묶음 안에서 번호 매긴 목록
//
// 카드 그리드 대신 '작품집 목차' 형태를 쓰되, 이미지가 늘 자리를 갖도록
// 맨 앞 글은 크게(lead) 보여 주고 나머지 행에는 작은 썸네일을 붙인다.
// 이미지가 없는 글은 제목 첫 글자를 넣은 파스텔 판으로 대신한다.
// 날짜순으로 전부 섞어 보여 주지 않는다. 작업의 선후보다 분야가 중요해서
// 카테고리마다 제목을 달고 그 아래에 작업을 나열한다.
// 모든 항목은 같은 크기다. 대신 행 간격을 넉넉히 두어 썸네일이 잘 보이게 한다.
import { api } from '../api.js';
import { el, escapeHtml, formatDate, setBusy, skeletonRows, toast } from '../ui.js';
import { el, escapeHtml, formatDate, setBusy, skeletonRows } from '../ui.js';
/** 한 번에 불러오는 게시물 수 */
const PAGE_SIZE = 20;
/** 한 번에 긁어오는 게시물 수 (여러 번 나눠 받아 전부 모은다) */
const FETCH_SIZE = 50;
/** 카테고리 캐시 수명 (ms) */
const CATEGORY_TTL_MS = 60000;
// 카테고리 목록은 모듈 스코프에 캐시한다.
// 필터만 바뀌는 경우(#/ ↔ #/?cat=game) 뷰가 다시 마운트돼도 다시 요청하지 않는다.
let catCache = null;
let catCachedAt = 0;
let catInflight = null;
@@ -78,7 +76,7 @@ function padNo(n) {
return String(n).padStart(2, '0');
}
/** 'YYYY. MM. DD.' → 'YY.MM' (인덱스에서는 짧게 쓴다) */
/** 'YYYY. MM. DD.' → 'YY.MM' */
function shortDate(iso) {
const full = formatDate(iso);
const m = /^(\d{4})\.\s*(\d{2})\./.exec(full);
@@ -92,98 +90,44 @@ function firstChar(title) {
return Array.from(text)[0];
}
/**
* 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판.
* @param {string} cls 바깥 figure 의 클래스
* @param {string} url 안전하게 걸러진 이미지 주소 ('' 가능)
* @param {string} title alt / 대체 글자용
*/
function mediaHtml(cls, url, title) {
/** 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판. */
function thumbHtml(url, title) {
if (url) {
return (
'<figure class="' + cls + '">' +
'<figure class="idx__thumb">' +
'<img src="' + escapeHtml(url) + '" alt="' + escapeHtml(title) + '"' +
' loading="lazy" decoding="async">' +
'</figure>'
);
}
return (
'<figure class="' + cls + ' ' + cls + '--none" aria-hidden="true">' +
'<figure class="idx__thumb idx__thumb--none" aria-hidden="true">' +
'<span>' + escapeHtml(firstChar(title)) + '</span>' +
'</figure>'
);
}
/**
* 카테고리 필터. 칩이 아니라 글자 링크다.
*/
function filterHtml(slug, name, count, accent, active) {
const href = slug === 'all' ? '#/' : '#/?cat=' + encodeURIComponent(slug);
const style = accent ? ' style="--card-accent:' + accent + '"' : '';
return (
'<a class="filter' + (active ? ' is-active' : '') + '" href="' + escapeHtml(href) + '"' +
style + (active ? ' aria-current="true"' : '') + '>' +
escapeHtml(name) +
'<span class="filter__count">' + toInt(count) + '</span>' +
'</a>'
);
}
/**
* 대표 작업(가장 최근 글) 한 건을 크게 보여 준다.
* 목록의. 묶음 안에서의 순번을 쓴다.
* @param {Object} post PostSummaryDto
*/
function leadHtml(post) {
const postNo = toInt(post && post.postNo);
const title = String((post && post.title) || '(제목 없음)');
const catName = String((post && post.categoryName) || '미분류');
const summary = post && post.summary ? String(post.summary) : '';
const accent = safeAccent(post && post.categoryAccent);
const thumb = safeUrl(post && post.thumbnailUrl);
return (
'<a class="lead" href="#/post/' + postNo + '"' +
(accent ? ' style="--card-accent:' + accent + '"' : '') + '>' +
mediaHtml('lead__media', thumb, title) +
'<div class="lead__body">' +
'<span class="lead__label">최신 작업</span>' +
'<span class="lead__cat">' + escapeHtml(catName) + '</span>' +
'<h3 class="lead__title">' + escapeHtml(title) + '</h3>' +
(summary ? '<p class="lead__sum">' + escapeHtml(summary) + '</p>' : '') +
'<span class="lead__meta">' +
escapeHtml(formatDate(post && post.createdAt)) +
'</span>' +
'<span class="lead__more">자세히 보기</span>' +
'</div>' +
'</a>'
);
}
/**
* 인덱스 행 하나.
* @param {Object} post PostSummaryDto
* @param {number} order 화면상의 순번 (1 부터)
* @param {number} order 묶음 안 순번 (1 부터)
*/
function rowHtml(post, order) {
const postNo = toInt(post && post.postNo);
const title = String((post && post.title) || '(제목 없음)');
const catName = String((post && post.categoryName) || '미분류');
const summary = post && post.summary ? String(post.summary) : '';
const accent = safeAccent(post && post.categoryAccent);
const thumb = safeUrl(post && post.thumbnailUrl);
return (
'<li class="index__item">' +
'<a class="idx" href="#/post/' + postNo + '"' +
(accent ? ' style="--card-accent:' + accent + '"' : '') + '>' +
'<a class="idx" href="#/post/' + postNo + '">' +
'<span class="idx__no">' + padNo(order) + '</span>' +
mediaHtml('idx__thumb', thumb, title) +
thumbHtml(thumb, title) +
'<span class="idx__body">' +
'<span class="idx__title">' + escapeHtml(title) + '</span>' +
(summary ? '<span class="idx__sum">' + escapeHtml(summary) + '</span>' : '') +
'</span>' +
'<span class="idx__meta">' +
'<span class="idx__cat">' + escapeHtml(catName) + '</span>' +
'<span class="idx__date">' + escapeHtml(shortDate(post && post.createdAt)) + '</span>' +
'</span>' +
'</a>' +
@@ -191,6 +135,57 @@ function rowHtml(post, order) {
);
}
/** 카테고리 묶음 하나 */
function groupHtml(group) {
const rows = group.posts.map((p, i) => rowHtml(p, i + 1)).join('');
return (
'<section class="group"' + (group.accent ? ' style="--card-accent:' + group.accent + '"' : '') + '>' +
'<h3 class="group__title">' +
'<span class="group__dot" aria-hidden="true"></span>' +
escapeHtml(group.name) +
'<span class="group__count">' + group.posts.length + '</span>' +
'</h3>' +
'<ol class="index">' + rows + '</ol>' +
'</section>'
);
}
/**
* 게시물을 카테고리별로 묶는다. 순서는 카테고리 목록의 순서(sortOrder)를 따른다.
* @param {Array} cats CategoryDto[]
* @param {Array} posts PostSummaryDto[]
*/
function groupByCategory(cats, posts) {
const rank = new Map();
(Array.isArray(cats) ? cats : []).forEach((c, i) => rank.set(toInt(c.categoryNo), i));
const buckets = new Map();
for (const p of posts) {
const key = toInt(p && p.categoryNo);
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(p);
}
const out = [];
for (const [categoryNo, list] of buckets) {
const head = list[0] || {};
out.push({
categoryNo,
name: String(head.categoryName || '미분류'),
accent: safeAccent(head.categoryAccent),
posts: list
});
}
// 카테고리 목록에 없는 분류(삭제된 것 등)는 뒤로 보낸다.
out.sort((a, b) => {
const ra = rank.has(a.categoryNo) ? rank.get(a.categoryNo) : 9999;
const rb = rank.has(b.categoryNo) ? rank.get(b.categoryNo) : 9999;
return ra - rb;
});
return out;
}
/** 화면 뼈대 (API 데이터가 섞이지 않는 정적 마크업) */
const SHELL_HTML =
'<section class="hero">' +
@@ -200,12 +195,10 @@ const SHELL_HTML =
'<section class="section">' +
'<div class="section__head">' +
'<h2 class="section__title">작업</h2>' +
'<nav class="filters" id="home-cats" aria-label="카테고리 필터" aria-busy="true"></nav>' +
'<div id="home-scope"></div>' +
'</div>' +
'<div id="home-lead"></div>' +
'<ol class="index" id="home-grid"></ol>' +
'<div id="home-groups"></div>' +
'<div id="home-state"></div>' +
'<div id="home-more"></div>' +
'</section>';
/**
@@ -219,86 +212,44 @@ export async function view(params, outlet) {
const activeSlug = normalizeSlug(query.cat);
let disposed = false;
let page = 1;
let loaded = 0;
let total = 0;
outlet.insertAdjacentHTML('beforeend', SHELL_HTML);
const statsEl = outlet.querySelector('#home-stats');
const catsEl = outlet.querySelector('#home-cats');
const leadEl = outlet.querySelector('#home-lead');
const listEl = outlet.querySelector('#home-grid');
const scopeEl = outlet.querySelector('#home-scope');
const groupsEl = outlet.querySelector('#home-groups');
const stateEl = outlet.querySelector('#home-state');
const moreEl = outlet.querySelector('#home-more');
// ── 카테고리 필터 ─────────────────────────────────────────────
function renderFilters(cats) {
const list = Array.isArray(cats) ? cats : [];
const totalPosts = list.reduce((sum, c) => sum + toInt(c && c.postCount), 0);
let html = filterHtml('all', '전체', totalPosts, '', activeSlug === 'all');
for (const cat of list) {
const slug = String((cat && cat.slug) || '');
if (!slug) continue;
html += filterHtml(
slug,
String((cat && cat.name) || slug),
toInt(cat && cat.postCount),
safeAccent(cat && cat.accent),
slug.toLowerCase() === activeSlug
);
}
catsEl.innerHTML = html;
catsEl.setAttribute('aria-busy', 'false');
}
function renderStats(cats) {
const list = Array.isArray(cats) ? cats : [];
const totalPosts = list.reduce((sum, c) => sum + toInt(c && c.postCount), 0);
statsEl.innerHTML =
'<span class="hero__stat">작업 <b>' + totalPosts + '</b></span>' +
'<span class="hero__stat">카테고리 <b>' + list.length + '</b></span>' +
'<span class="hero__stat">분야 <b>' + list.length + '</b></span>' +
'<span class="hero__stat">Unity · .NET · MariaDB</span>';
}
// 필터를 누르면 해시에 기록한다.
// 보조 버튼·수정키 클릭은 브라우저 기본 동작(새 탭 등)에 맡긴다.
function onCatsClick(ev) {
if (ev.defaultPrevented) return;
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
const target = ev.target instanceof Element ? ev.target.closest('a.filter') : null;
if (!target || !catsEl.contains(target)) return;
ev.preventDefault();
const next = target.getAttribute('href');
if (next && next !== location.hash) location.hash = next;
}
// ── 상태 표시 ─────────────────────────────────────────────────
function clearState() {
stateEl.innerHTML = '';
/** 한 분야만 보고 있을 때 전체로 돌아가는 링크 */
function renderScope(show) {
scopeEl.innerHTML = show
? '<a class="btn btn--ghost btn--sm" href="#/">← 전체 작업</a>'
: '';
}
function showEmpty() {
leadEl.innerHTML = '';
listEl.innerHTML = '';
listEl.classList.add('is-hidden');
moreEl.innerHTML = '';
groupsEl.innerHTML = '';
const filtered = activeSlug !== 'all';
stateEl.innerHTML =
'<div class="empty">' +
'<p>' + (filtered ? '이 카테고리에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '</p>' +
'<p class="tagline">' + (filtered ? '다른 카테고리도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '</p>' +
'<p>' + (filtered ? '이 분야에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '</p>' +
'<p class="tagline">' + (filtered ? '다른 분야도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '</p>' +
(filtered ? '<a class="btn btn--ghost" href="#/">전체 보기</a>'
: '<a class="btn btn--ghost" href="#/admin">관리자</a>') +
'</div>';
}
function showError(err) {
leadEl.innerHTML = '';
listEl.innerHTML = '';
listEl.classList.add('is-hidden');
moreEl.innerHTML = '';
groupsEl.innerHTML = '';
stateEl.innerHTML =
'<div class="empty">' +
'<p>작업 목록을 불러오지 못했습니다.</p>' +
@@ -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 = '<ol class="index">' + skeletonRows(4) + '</ol>';
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 = '<span class="hero__stat">Unity · .NET · MariaDB</span>';
}
);
// await 하지 않는다: 라우터가 곧바로 cleanup 을 받아 갈 수 있게 한다.
loadFirstPage();
load();
return () => {
disposed = true;
catsEl.removeEventListener('click', onCatsClick);
setBusy(false);
};
}

291
README.md
View File

@@ -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 <PID>` |
| 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회**로 제한됩니다.

View File

@@ -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

42
deploy/genesis.service Normal file
View File

@@ -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

115
deploy/healthcheck.sh Normal file
View File

@@ -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

5
deploy/publish-linux.cmd Normal file
View File

@@ -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" %*

144
deploy/publish-linux.ps1 Normal file
View File

@@ -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 등록을 참고하세요)"

124
deploy/publish-linux.sh Normal file
View File

@@ -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\""

52
docker-compose.yml Normal file
View File

@@ -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"