Initial profile site commit

This commit is contained in:
2026-05-31 21:05:59 +09:00
commit db81f0e4a4
49 changed files with 18829 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
# 포트폴리오 사이트 - 설치 가이드
JSON + PHP 기반의 가벼운 포트폴리오 사이트입니다.
Synology NAS Web Station에서 동작하도록 설계되었습니다.
## 📁 파일 구조
```
portfolio/
├── index.html # 메인 페이지 (프로젝트 목록)
├── profile.html # 프로필 페이지
├── generate_password.php # 비밀번호 해시 생성기 (사용 후 삭제!)
├── api/
│ ├── config.php # 설정 (비밀번호 해시 위치)
│ ├── auth.php # 로그인/로그아웃 API
│ ├── projects.php # 프로젝트 CRUD API
│ ├── profile.php # 프로필 수정 API
│ └── upload.php # 이미지 업로드 API
├── data/
│ ├── projects.json # 프로젝트 데이터
│ ├── profile.json # 프로필 데이터
│ └── .htaccess # 직접 접근 차단
└── uploads/
└── .htaccess # PHP 실행 차단
```
## 🚀 설치 (Synology NAS 기준)
### 1. Web Station 설치 및 PHP 활성화
- Package Center에서 **Web Station** 설치
- **Web Station → Web Service Portal**에서 PHP 8.x 활성화
- **Web Service**에서 사이트의 PHP 버전을 8.x로 지정
### 2. 파일 업로드
- File Station에서 `/web/portfolio/` 폴더 생성
- 위 모든 파일을 해당 폴더에 업로드
### 3. 권한 설정
다음 폴더는 PHP가 쓸 수 있어야 합니다:
- `data/` (JSON 쓰기)
- `uploads/` (이미지 업로드)
File Station에서 두 폴더의 권한을 `http` 그룹에 쓰기 가능하게 설정하세요.
### 4. 비밀번호 설정 (⚠️ 매우 중요)
**(A) 해시 생성**
브라우저에서 접속:
```
http://your-nas/portfolio/generate_password.php
```
원하는 비밀번호를 입력하면 해시가 출력됩니다.
**(B) config.php 수정**
`api/config.php` 파일을 열어 아래 줄을 찾고:
```php
define('ADMIN_PASSWORD_HASH', '$2y$10$YourHashWillGoHere...');
```
방금 생성된 해시로 교체합니다.
**(C) generate_password.php 삭제**
보안을 위해 반드시 삭제하세요!
### 5. 접속
- 메인: `http://your-nas/portfolio/`
- 관리자 로그인: 페이지 하단 우측 방패 아이콘(🛡️) 클릭
## 🎯 기능
### 일반 방문자
- 프로젝트 목록 보기
- 프로필 페이지에서 자기소개/스킬/타임라인/연락처 확인
### 관리자 (로그인 후)
- ✅ 새 프로젝트 등록
- ✅ 기존 프로젝트 수정/삭제
- ✅ 프로필 정보 편집 (이름, 사진, 자기소개)
- ✅ 기술 스택 추가/수정/삭제 (스킬 바)
- ✅ 타임라인 추가/수정/삭제
- ✅ SNS 링크 관리
## 🔒 보안 체크리스트
- [ ] `generate_password.php` 삭제했는가?
- [ ] `config.php`의 ADMIN_PASSWORD_HASH를 실제 해시로 교체했는가?
- [ ] HTTPS 설정 (NAS의 Reverse Proxy 또는 Let's Encrypt)
- [ ] 비밀번호는 8자 이상, 영문/숫자/기호 조합
- [ ] `data/`, `uploads/` 폴더의 .htaccess 파일이 동작하는지 확인
## 💾 백업
데이터는 모두 텍스트 파일이므로 Git으로 관리할 수 있습니다:
```bash
cd /web/portfolio
git init
git remote add origin http://your-nas:3000/your-name/portfolio.git
git add .
git commit -m "initial"
git push -u origin main
```
`data/*.json` 파일이 변경 이력으로 남아 언제든 복구 가능합니다.
## 🐛 문제 해결
**"데이터를 읽을 수 없습니다" 오류**
`data/` 폴더의 쓰기 권한을 확인하세요.
**로그인이 안 됨**
`config.php`의 ADMIN_PASSWORD_HASH가 올바르게 교체되었는지 확인하세요.
**.htaccess가 동작하지 않음**
→ Web Station에서 Apache 사용 + `mod_rewrite`, `AllowOverride All` 설정 필요.
nginx를 쓴다면 nginx 설정으로 동일한 차단 규칙을 추가해야 합니다.
## 🔄 향후 확장 아이디어
- 프로젝트별 상세 페이지 (markdown 지원)
- 태그/카테고리 필터링
- 방문자 통계
- 다국어 지원 (i18n)
- Gitea API 연동으로 저장소 정보 자동 가져오기
+60
View File
@@ -0,0 +1,60 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
set_json_headers();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// =====================================================
// 로그인
// =====================================================
if ($method === 'POST' && $action === 'login') {
$input = get_json_input();
$password = $input['password'] ?? '';
if (empty($password)) {
json_response(['error' => '비밀번호를 입력하세요'], 400);
}
// 무차별 대입 방지 - 간단한 딜레이
usleep(500000); // 0.5초
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
// 세션 고정 공격 방지
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['login_time'] = time();
json_response(['success' => true, 'message' => '로그인 성공']);
} else {
json_response(['error' => '비밀번호가 일치하지 않습니다'], 401);
}
}
// =====================================================
// 로그아웃
// =====================================================
if ($method === 'POST' && $action === 'logout') {
session_destroy();
json_response(['success' => true]);
}
// =====================================================
// 인증 상태 확인
// =====================================================
if ($method === 'GET' && $action === 'check') {
$authenticated = isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true;
// 타임아웃 체크
if ($authenticated && isset($_SESSION['login_time'])) {
if ((time() - $_SESSION['login_time']) > SESSION_LIFETIME) {
session_destroy();
$authenticated = false;
}
}
json_response(['authenticated' => $authenticated]);
}
json_response(['error' => 'Invalid action'], 400);
+210
View File
@@ -0,0 +1,210 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
set_json_headers();
define('CATEGORIES_FILE', DATA_DIR . '/categories.json');
$method = $_SERVER['REQUEST_METHOD'];
// 카테고리 정규화 (parent_id 필드 보장)
function normalize_category($cat) {
if (!isset($cat['parent_id'])) $cat['parent_id'] = null;
if (!isset($cat['order'])) $cat['order'] = 0;
if (!isset($cat['color'])) $cat['color'] = '#00f2ff';
return $cat;
}
// =====================================================
// GET: 카테고리 목록
// =====================================================
if ($method === 'GET') {
$categories = read_json_safe(CATEGORIES_FILE) ?? [];
$categories = array_map('normalize_category', $categories);
usort($categories, function($a, $b) {
// parent끼리 먼저 정렬되도록
$aParent = $a['parent_id'] ?? 0;
$bParent = $b['parent_id'] ?? 0;
if ($aParent !== $bParent) return $aParent - $bParent;
return ($a['order'] ?? 0) - ($b['order'] ?? 0);
});
json_response($categories);
}
require_auth();
// =====================================================
// POST: 카테고리 추가
// =====================================================
if ($method === 'POST') {
$input = get_json_input();
$name = trim($input['name'] ?? '');
$color = trim($input['color'] ?? '#00f2ff');
$parentId = isset($input['parent_id']) && $input['parent_id'] !== '' && $input['parent_id'] !== null
? intval($input['parent_id'])
: null;
if (empty($name)) {
json_response(['error' => '카테고리 이름은 필수입니다'], 400);
}
$categories = read_json_safe(CATEGORIES_FILE) ?? [];
// parent_id 유효성 검증
if ($parentId !== null) {
$parentFound = false;
foreach ($categories as $c) {
if (($c['id'] ?? 0) === $parentId) {
// 부모가 또 다른 부모를 가지면 안 됨 (2단계로 제한)
if (!empty($c['parent_id'])) {
json_response(['error' => '서브 카테고리 아래에는 카테고리를 만들 수 없습니다'], 400);
}
$parentFound = true;
break;
}
}
if (!$parentFound) {
json_response(['error' => '부모 카테고리를 찾을 수 없습니다'], 400);
}
}
$maxId = 0;
$maxOrder = 0;
foreach ($categories as $c) {
if (($c['id'] ?? 0) > $maxId) $maxId = $c['id'];
// 같은 부모 내에서 max order
if (($c['parent_id'] ?? null) === $parentId && ($c['order'] ?? 0) > $maxOrder) {
$maxOrder = $c['order'];
}
}
$newCategory = [
'id' => $maxId + 1,
'name' => $name,
'color' => $color,
'parent_id' => $parentId,
'order' => $maxOrder + 1
];
$categories[] = $newCategory;
if (write_json_safe(CATEGORIES_FILE, $categories)) {
json_response(['success' => true, 'category' => $newCategory]);
} else {
json_response(['error' => '저장에 실패했습니다'], 500);
}
}
// =====================================================
// PUT: 카테고리 수정
// =====================================================
if ($method === 'PUT') {
$input = get_json_input();
$id = intval($input['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => '유효하지 않은 ID'], 400);
}
$categories = read_json_safe(CATEGORIES_FILE) ?? [];
$found = false;
foreach ($categories as $key => $cat) {
if ($cat['id'] === $id) {
if (isset($input['name'])) $categories[$key]['name'] = trim($input['name']);
if (isset($input['color'])) $categories[$key]['color'] = trim($input['color']);
if (isset($input['order'])) $categories[$key]['order'] = intval($input['order']);
// parent_id 변경은 허용하되, 자식이 있는 경우 자식으로 만들지 못하게
if (array_key_exists('parent_id', $input)) {
$newParent = $input['parent_id'];
$newParent = ($newParent === '' || $newParent === null) ? null : intval($newParent);
// 자기 자신을 부모로 설정 불가
if ($newParent === $id) {
json_response(['error' => '자기 자신을 부모로 설정할 수 없습니다'], 400);
}
// 자식이 있는 카테고리는 다른 카테고리의 자식이 될 수 없음
$hasChildren = false;
foreach ($categories as $c) {
if (($c['parent_id'] ?? null) === $id) {
$hasChildren = true;
break;
}
}
if ($newParent !== null && $hasChildren) {
json_response(['error' => '하위 카테고리가 있는 카테고리는 다른 카테고리의 하위로 이동할 수 없습니다'], 400);
}
$categories[$key]['parent_id'] = $newParent;
}
$found = true;
break;
}
}
if (!$found) {
json_response(['error' => '카테고리를 찾을 수 없습니다'], 404);
}
if (write_json_safe(CATEGORIES_FILE, $categories)) {
json_response(['success' => true]);
} else {
json_response(['error' => '저장에 실패했습니다'], 500);
}
}
// =====================================================
// DELETE: 카테고리 삭제
// =====================================================
if ($method === 'DELETE') {
$id = intval($_GET['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => '유효하지 않은 ID'], 400);
}
$categories = read_json_safe(CATEGORIES_FILE) ?? [];
// 자식 카테고리 ID 수집
$allIdsToDelete = [$id];
foreach ($categories as $c) {
if (($c['parent_id'] ?? null) === $id) {
$allIdsToDelete[] = $c['id'];
}
}
// 해당 카테고리(들)을 사용하는 글이 있는지 확인
$learnings = read_json_safe(DATA_DIR . '/learning.json') ?? [];
$usedCount = 0;
foreach ($learnings as $l) {
if (in_array(($l['category_id'] ?? 0), $allIdsToDelete)) $usedCount++;
}
if ($usedCount > 0) {
$isParent = count($allIdsToDelete) > 1;
$msg = $isParent
? "이 카테고리(또는 하위 카테고리)를 사용하는 글이 {$usedCount}개 있습니다. 먼저 해당 글들을 다른 카테고리로 옮기거나 삭제해주세요."
: "이 카테고리를 사용하는 글이 {$usedCount}개 있습니다. 먼저 해당 글들을 다른 카테고리로 옮기거나 삭제해주세요.";
json_response(['error' => $msg], 400);
}
$filtered = array_values(array_filter($categories, function($c) use ($allIdsToDelete) {
return !in_array(($c['id'] ?? 0), $allIdsToDelete);
}));
if (count($filtered) === count($categories)) {
json_response(['error' => '카테고리를 찾을 수 없습니다'], 404);
}
if (write_json_safe(CATEGORIES_FILE, $filtered)) {
json_response(['success' => true, 'deleted_count' => count($allIdsToDelete)]);
} else {
json_response(['error' => '삭제에 실패했습니다'], 500);
}
}
json_response(['error' => 'Method not allowed'], 405);
+101
View File
@@ -0,0 +1,101 @@
<?php
// =====================================================
// 설정 파일
// =====================================================
// ⚠️ 중요: 처음 사용 시 아래 ADMIN_PASSWORD_HASH를 변경하세요
// 비밀번호 해시 생성 방법:
// 브라우저에서 generate_password.php 접속 후 원하는 비번 입력
// 또는 터미널에서: php -r "echo password_hash('your_password', PASSWORD_DEFAULT);"
// =====================================================
// 기본 비밀번호: "admin1234" (반드시 변경하세요!)
define('ADMIN_PASSWORD_HASH', '$2y$10$Wj/5fxQX90AlvyVPBfE0te2aUbysSBlE/Umm7EluG880rqcRUlHGm');
// 데이터 파일 경로
define('DATA_DIR', __DIR__ . '/../data');
define('UPLOADS_DIR', __DIR__ . '/../uploads');
define('PROJECTS_FILE', DATA_DIR . '/projects.json');
define('PROFILE_FILE', DATA_DIR . '/profile.json');
// 세션 설정
define('SESSION_LIFETIME', 3600 * 4); // 4시간
// CORS 및 JSON 헤더
function set_json_headers() {
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
}
// JSON 파일을 락 걸고 읽기
function read_json_safe($filepath) {
if (!file_exists($filepath)) {
return null;
}
$fp = fopen($filepath, 'r');
if (!$fp) return null;
if (flock($fp, LOCK_SH)) {
$content = stream_get_contents($fp);
flock($fp, LOCK_UN);
fclose($fp);
return json_decode($content, true);
}
fclose($fp);
return null;
}
// JSON 파일을 락 걸고 쓰기
function write_json_safe($filepath, $data) {
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) return false;
$fp = fopen($filepath, 'c+');
if (!$fp) return false;
if (flock($fp, LOCK_EX)) {
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, $json);
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return true;
}
fclose($fp);
return false;
}
// 인증 체크
function require_auth() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// 세션 타임아웃 체크
if (isset($_SESSION['login_time']) &&
(time() - $_SESSION['login_time']) > SESSION_LIFETIME) {
session_destroy();
http_response_code(401);
echo json_encode(['error' => 'Session expired']);
exit;
}
}
// JSON 입력 받기
function get_json_input() {
$input = file_get_contents('php://input');
return json_decode($input, true);
}
// 응답 헬퍼
function json_response($data, $status = 200) {
http_response_code($status);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
+100
View File
@@ -0,0 +1,100 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
set_json_headers();
require_auth();
$method = $_SERVER['REQUEST_METHOD'];
// =====================================================
// POST: 특정 폴더 안의 파일들 삭제
// 또는 특정 파일 URL들 삭제
// =====================================================
if ($method !== 'POST') {
json_response(['error' => 'POST만 허용됩니다'], 405);
}
$input = get_json_input();
// 안전 가드: uploads 디렉토리 밖으로 못 나가게
function is_safe_uploads_path($path) {
$real_uploads = realpath(UPLOADS_DIR);
$real_target = realpath($path);
if ($real_uploads === false) return false;
if ($real_target === false) return false;
return strpos($real_target, $real_uploads) === 0;
}
$deleted = [];
$failed = [];
// 1) URL 리스트로 개별 파일 삭제
if (isset($input['urls']) && is_array($input['urls'])) {
foreach ($input['urls'] as $url) {
$url = trim($url);
if ($url === '') continue;
// uploads/ 로 시작하는 상대 경로만 허용
if (!preg_match('#^uploads/#', $url)) {
$failed[] = ['url' => $url, 'reason' => 'invalid path'];
continue;
}
// 외부 URL은 건너뛰기 (http://, https:// 등)
if (preg_match('#^https?://#i', $url)) {
continue;
}
// 절대 경로 만들기
$relative = preg_replace('#^uploads/#', '', $url);
$full_path = UPLOADS_DIR . '/' . $relative;
if (!file_exists($full_path)) {
$failed[] = ['url' => $url, 'reason' => 'not found'];
continue;
}
if (!is_safe_uploads_path($full_path)) {
$failed[] = ['url' => $url, 'reason' => 'unsafe path'];
continue;
}
if (@unlink($full_path)) {
$deleted[] = $url;
} else {
$failed[] = ['url' => $url, 'reason' => 'unlink failed'];
}
}
}
// 2) 폴더 삭제 (프로젝트 폴더 통째로)
if (isset($input['folder'])) {
$folder = trim($input['folder']);
if ($folder !== '' && !preg_match('#[/\\\\]#', $folder)) {
$folder_path = UPLOADS_DIR . '/' . $folder;
if (is_dir($folder_path) && is_safe_uploads_path($folder_path)) {
// 폴더 안의 파일들 삭제
$files = glob($folder_path . '/*');
foreach ($files as $f) {
if (is_file($f)) {
if (@unlink($f)) {
$deleted[] = $folder . '/' . basename($f);
} else {
$failed[] = ['url' => $folder . '/' . basename($f), 'reason' => 'unlink failed'];
}
}
}
// 빈 폴더는 삭제
@rmdir($folder_path);
}
}
}
json_response([
'success' => true,
'deleted_count' => count($deleted),
'failed_count' => count($failed),
'deleted' => $deleted,
'failed' => $failed
]);
+58
View File
@@ -0,0 +1,58 @@
<?php
// ============================================================
// error_config.php — API 파일 최상단에 require 추가
// 위치: /web/my_profile/api/error_config.php
//
// 사용법: 각 api/*.php 파일 최상단에 아래 한 줄 추가
// require_once __DIR__ . '/error_config.php';
// ============================================================
// 로그 파일 경로 (uploads 바깥, 웹 접근 불가 경로 권장)
define('LOG_FILE', dirname(__DIR__) . '/logs/app.log');
// logs/ 디렉토리 자동 생성
$log_dir = dirname(LOG_FILE);
if (!is_dir($log_dir)) {
mkdir($log_dir, 0750, true);
}
// PHP 에러를 파일에만 기록 (화면 노출 X)
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', LOG_FILE);
error_reporting(E_ALL);
// ── 커스텀 로거 ───────────────────────────────────────────
/**
* 앱 로그 기록
*
* @param string $level 'INFO' | 'WARN' | 'ERROR'
* @param string $msg 메시지
* @param array $ctx 추가 컨텍스트 (선택)
*/
function app_log(string $level, string $msg, array $ctx = []): void {
$ts = date('Y-m-d H:i:s');
$ip = $_SERVER['REMOTE_ADDR'] ?? '-';
$method = $_SERVER['REQUEST_METHOD'] ?? '-';
$uri = $_SERVER['REQUEST_URI'] ?? '-';
$ctx_str = $ctx ? ' | ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) : '';
$line = "[$ts][$level] $ip $method $uri | $msg$ctx_str" . PHP_EOL;
file_put_contents(LOG_FILE, $line, FILE_APPEND | LOCK_EX);
}
// ── 로그 로테이션 (10MB 초과 시 백업) ─────────────────────
if (file_exists(LOG_FILE) && filesize(LOG_FILE) > 10 * 1024 * 1024) {
rename(LOG_FILE, LOG_FILE . '.' . date('Ymd'));
}
// ── 미처리 예외 핸들러 ────────────────────────────────────
set_exception_handler(function (Throwable $e) {
app_log('ERROR', $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => '서버 오류가 발생했습니다.']);
exit;
});
+249
View File
@@ -0,0 +1,249 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
set_json_headers();
define('LEARNING_FILE', DATA_DIR . '/learning.json');
$method = $_SERVER['REQUEST_METHOD'];
// =====================================================
// GET: 학습 일지 목록 또는 단일 글 (인증 불필요)
// =====================================================
if ($method === 'GET') {
$learnings = read_json_safe(LEARNING_FILE) ?? [];
// 단일 글 조회: ?id=N
if (isset($_GET['id'])) {
$id = intval($_GET['id']);
foreach ($learnings as $l) {
if (($l['id'] ?? 0) === $id) {
// is_learning 필드는 더이상 의미 없음 (제거)
unset($l['is_learning']);
json_response($l);
}
}
json_response(['error' => '글을 찾을 수 없습니다'], 404);
}
// is_learning 필드 마이그레이션 (제거)
foreach ($learnings as &$l) {
unset($l['is_learning']);
}
unset($l);
// 카테고리 필터: ?category_id=N (단일 또는 카테고리 + 모든 자식)
if (isset($_GET['category_id'])) {
$catId = intval($_GET['category_id']);
$categories = read_json_safe(DATA_DIR . '/categories.json') ?? [];
// 자식 카테고리 ID도 포함
$catIds = [$catId];
foreach ($categories as $c) {
if (($c['parent_id'] ?? null) === $catId) {
$catIds[] = $c['id'];
}
}
$learnings = array_values(array_filter($learnings, function($l) use ($catIds) {
return in_array(($l['category_id'] ?? 0), $catIds);
}));
}
// 최신순 정렬
usort($learnings, function($a, $b) {
$aDate = $a['created_at'] ?? '';
$bDate = $b['created_at'] ?? '';
if ($aDate !== $bDate) return strcmp($bDate, $aDate);
return ($b['id'] ?? 0) - ($a['id'] ?? 0);
});
json_response($learnings);
}
require_auth();
// =====================================================
// POST: 새 학습 일지 작성
// =====================================================
if ($method === 'POST') {
$input = get_json_input();
$title = trim($input['title'] ?? '');
$content = trim($input['content'] ?? '');
$categoryId = intval($input['category_id'] ?? 0);
if (empty($title) || empty($content)) {
json_response(['error' => '제목과 내용은 필수입니다'], 400);
}
if ($categoryId <= 0) {
json_response(['error' => '카테고리를 선택해주세요'], 400);
}
// 카테고리가 서브 카테고리(parent_id 있음)인지 확인 - 글은 서브 카테고리에만 속해야 함
$categories = read_json_safe(DATA_DIR . '/categories.json') ?? [];
$foundCat = null;
foreach ($categories as $c) {
if (($c['id'] ?? 0) === $categoryId) {
$foundCat = $c;
break;
}
}
if (!$foundCat) {
json_response(['error' => '카테고리를 찾을 수 없습니다'], 400);
}
if (empty($foundCat['parent_id'])) {
json_response(['error' => '글은 서브 카테고리에만 작성할 수 있습니다 (예: Unity > 학습)'], 400);
}
// 태그 처리
$tags = [];
if (isset($input['tags'])) {
if (is_array($input['tags'])) {
$tags = array_values(array_filter(
array_map('trim', $input['tags']),
fn($v) => $v !== ''
));
} elseif (is_string($input['tags'])) {
$tags = array_values(array_filter(
array_map('trim', explode(',', $input['tags'])),
fn($v) => $v !== ''
));
}
}
$learnings = read_json_safe(LEARNING_FILE) ?? [];
$maxId = 0;
foreach ($learnings as $l) {
if (($l['id'] ?? 0) > $maxId) $maxId = $l['id'];
}
$createdAt = trim($input['created_at'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdAt)) {
$createdAt = date('Y-m-d');
}
$newLearning = [
'id' => $maxId + 1,
'title' => $title,
'category_id' => $categoryId,
'tags' => $tags,
'content' => $content,
'created_at' => $createdAt,
'updated_at' => date('Y-m-d')
];
$learnings[] = $newLearning;
if (write_json_safe(LEARNING_FILE, $learnings)) {
json_response(['success' => true, 'learning' => $newLearning]);
} else {
json_response(['error' => '저장에 실패했습니다'], 500);
}
}
// =====================================================
// PUT: 학습 일지 수정
// =====================================================
if ($method === 'PUT') {
$input = get_json_input();
$id = intval($input['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => '유효하지 않은 ID'], 400);
}
$learnings = read_json_safe(LEARNING_FILE) ?? [];
$found = false;
foreach ($learnings as $key => $l) {
if ($l['id'] === $id) {
// is_learning 필드는 제거
unset($learnings[$key]['is_learning']);
if (isset($input['title'])) {
$title = trim($input['title']);
if ($title === '') json_response(['error' => '제목은 비울 수 없습니다'], 400);
$learnings[$key]['title'] = $title;
}
if (isset($input['content'])) {
$content = trim($input['content']);
if ($content === '') json_response(['error' => '내용은 비울 수 없습니다'], 400);
$learnings[$key]['content'] = $content;
}
if (isset($input['category_id'])) {
$catId = intval($input['category_id']);
if ($catId > 0) {
// 서브 카테고리 검증
$categories = read_json_safe(DATA_DIR . '/categories.json') ?? [];
$foundCat = null;
foreach ($categories as $c) {
if (($c['id'] ?? 0) === $catId) { $foundCat = $c; break; }
}
if ($foundCat && empty($foundCat['parent_id'])) {
json_response(['error' => '글은 서브 카테고리에만 속할 수 있습니다'], 400);
}
$learnings[$key]['category_id'] = $catId;
}
}
if (isset($input['tags'])) {
$tags = is_array($input['tags'])
? array_values(array_filter(array_map('trim', $input['tags']), fn($v) => $v !== ''))
: (is_string($input['tags'])
? array_values(array_filter(array_map('trim', explode(',', $input['tags'])), fn($v) => $v !== ''))
: []);
$learnings[$key]['tags'] = $tags;
}
if (isset($input['created_at'])) {
$createdAt = trim($input['created_at']);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdAt)) {
$learnings[$key]['created_at'] = $createdAt;
}
}
$learnings[$key]['updated_at'] = date('Y-m-d');
$found = true;
break;
}
}
if (!$found) {
json_response(['error' => '글을 찾을 수 없습니다'], 404);
}
if (write_json_safe(LEARNING_FILE, $learnings)) {
json_response(['success' => true]);
} else {
json_response(['error' => '저장에 실패했습니다'], 500);
}
}
// =====================================================
// DELETE: 학습 일지 삭제
// =====================================================
if ($method === 'DELETE') {
$id = intval($_GET['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => '유효하지 않은 ID'], 400);
}
$learnings = read_json_safe(LEARNING_FILE) ?? [];
$filtered = array_values(array_filter($learnings, function($l) use ($id) {
return ($l['id'] ?? 0) !== $id;
}));
if (count($filtered) === count($learnings)) {
json_response(['error' => '글을 찾을 수 없습니다'], 404);
}
if (write_json_safe(LEARNING_FILE, $filtered)) {
json_response(['success' => true]);
} else {
json_response(['error' => '삭제에 실패했습니다'], 500);
}
}
json_response(['error' => 'Method not allowed'], 405);
View File
+114
View File
@@ -0,0 +1,114 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
set_json_headers();
$method = $_SERVER['REQUEST_METHOD'];
// =====================================================
// 헬퍼: 기존 퍼센트 기반 스킬을 카테고리 기반으로 마이그레이션
// =====================================================
function migrate_skills($profile) {
if (!isset($profile['skills']) || !is_array($profile['skills'])) {
$profile['skills'] = [];
return $profile;
}
// 이미 카테고리 형식인지 확인 (첫 항목에 'items' 있으면 새 형식)
$first = $profile['skills'][0] ?? null;
if ($first && isset($first['category']) && isset($first['items'])) {
return $profile; // 이미 새 형식
}
// 기존 형식 (name + level) → 카테고리 형식으로 변환
if ($first && isset($first['name']) && isset($first['level'])) {
$migrated = [
[
'category' => '기술',
'items' => array_map(fn($s) => $s['name'] ?? '', $profile['skills'])
]
];
$profile['skills'] = $migrated;
}
return $profile;
}
// =====================================================
// GET: 프로필 조회 (인증 불필요)
// =====================================================
if ($method === 'GET') {
$profile = read_json_safe(PROFILE_FILE);
if ($profile === null) {
json_response(['error' => '프로필을 읽을 수 없습니다'], 500);
}
$profile = migrate_skills($profile);
json_response($profile);
}
// 이하 수정은 인증 필요
require_auth();
// =====================================================
// PUT: 프로필 전체 업데이트
// =====================================================
if ($method === 'PUT') {
$input = get_json_input();
if (!is_array($input)) {
json_response(['error' => '잘못된 데이터 형식'], 400);
}
$current = read_json_safe(PROFILE_FILE) ?? [];
$updated = array_merge($current, [
'name' => trim($input['name'] ?? $current['name'] ?? ''),
'title' => trim($input['title'] ?? $current['title'] ?? ''),
'tagline' => trim($input['tagline'] ?? $current['tagline'] ?? ''),
'avatar' => trim($input['avatar'] ?? $current['avatar'] ?? ''),
'bio' => trim($input['bio'] ?? $current['bio'] ?? ''),
'location' => trim($input['location'] ?? $current['location'] ?? ''),
'email' => trim($input['email'] ?? $current['email'] ?? ''),
]);
// 카테고리화된 스킬 처리
if (isset($input['skills']) && is_array($input['skills'])) {
$cleanSkills = [];
foreach ($input['skills'] as $cat) {
if (!isset($cat['category']) || !isset($cat['items'])) continue;
$catName = trim($cat['category']);
if ($catName === '') continue;
$items = is_array($cat['items'])
? array_values(array_filter(array_map('trim', $cat['items']), fn($v) => $v !== ''))
: [];
if (count($items) > 0) {
$cleanSkills[] = [
'category' => $catName,
'items' => $items
];
}
}
$updated['skills'] = $cleanSkills;
}
if (isset($input['timeline']) && is_array($input['timeline'])) {
$updated['timeline'] = array_values(array_filter($input['timeline'], function($t) {
return !empty($t['title']);
}));
}
if (isset($input['social']) && is_array($input['social'])) {
$updated['social'] = array_merge($current['social'] ?? [], $input['social']);
}
if (write_json_safe(PROFILE_FILE, $updated)) {
json_response(['success' => true, 'profile' => $updated]);
} else {
json_response(['error' => '저장에 실패했습니다'], 500);
}
}
json_response(['error' => 'Method not allowed'], 405);
+221
View File
@@ -0,0 +1,221 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
set_json_headers();
$method = $_SERVER['REQUEST_METHOD'];
// =====================================================
// 헬퍼: images 배열 정규화
// =====================================================
function normalize_images($input) {
if (isset($input['images']) && is_array($input['images'])) {
$images = array_values(array_filter(
array_map('trim', $input['images']),
fn($v) => $v !== ''
));
return $images;
}
if (isset($input['image']) && trim($input['image']) !== '') {
return [trim($input['image'])];
}
return [];
}
function normalize_stack($input) {
if (isset($input['stack']) && is_array($input['stack'])) {
return array_values(array_filter(
array_map('trim', $input['stack']),
fn($v) => $v !== ''
));
}
if (isset($input['stack']) && is_string($input['stack'])) {
return array_values(array_filter(
array_map('trim', explode(',', $input['stack'])),
fn($v) => $v !== ''
));
}
return [];
}
// 기존 프로젝트 데이터를 새 형식으로 마이그레이션
function migrate_project_data($project) {
if (!isset($project['images']) || !is_array($project['images'])) {
$project['images'] = [];
if (!empty($project['image'])) {
$project['images'][] = $project['image'];
}
}
$project['image'] = $project['images'][0] ?? '';
// 새 필드들 기본값
if (!isset($project['stack']) || !is_array($project['stack'])) $project['stack'] = [];
if (!isset($project['period_start'])) $project['period_start'] = '';
if (!isset($project['period_end'])) $project['period_end'] = '';
if (!isset($project['video_url'])) $project['video_url'] = '';
// 기존 demo_url 필드는 제거 (마이그레이션)
unset($project['demo_url']);
return $project;
}
// =====================================================
// GET: 프로젝트 목록 조회
// =====================================================
if ($method === 'GET') {
$projects = read_json_safe(PROJECTS_FILE);
if ($projects === null) {
json_response(['error' => '데이터를 읽을 수 없습니다'], 500);
}
$projects = array_map('migrate_project_data', $projects);
usort($projects, function($a, $b) {
return ($b['id'] ?? 0) - ($a['id'] ?? 0);
});
json_response($projects);
}
require_auth();
// =====================================================
// POST: 새 프로젝트 추가
// =====================================================
if ($method === 'POST') {
$input = get_json_input();
$title = trim($input['title'] ?? '');
$label = trim($input['label'] ?? '');
$description = trim($input['description'] ?? '');
if (empty($title) || empty($label) || empty($description)) {
json_response(['error' => '제목, 라벨, 설명은 필수입니다'], 400);
}
$projects = read_json_safe(PROJECTS_FILE) ?? [];
$maxId = 0;
foreach ($projects as $p) {
if (($p['id'] ?? 0) > $maxId) $maxId = $p['id'];
}
$images = normalize_images($input);
$stack = normalize_stack($input);
$newProject = [
'id' => $maxId + 1,
'title' => $title,
'label' => $label,
'description' => $description,
'icon' => trim($input['icon'] ?? 'fa-solid fa-code'),
'images' => $images,
'image' => $images[0] ?? '',
'link' => trim($input['link'] ?? ''),
'stack' => $stack,
'period_start' => trim($input['period_start'] ?? ''),
'period_end' => trim($input['period_end'] ?? ''),
'video_url' => trim($input['video_url'] ?? ''),
'created_at' => date('Y-m-d')
];
$projects[] = $newProject;
if (write_json_safe(PROJECTS_FILE, $projects)) {
json_response(['success' => true, 'project' => $newProject]);
} else {
json_response(['error' => '저장에 실패했습니다'], 500);
}
}
// =====================================================
// PUT: 프로젝트 수정
// =====================================================
if ($method === 'PUT') {
$input = get_json_input();
$id = intval($input['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => '유효하지 않은 ID'], 400);
}
$projects = read_json_safe(PROJECTS_FILE) ?? [];
$found = false;
foreach ($projects as $key => $project) {
if ($project['id'] === $id) {
$projects[$key]['title'] = trim($input['title'] ?? $project['title']);
$projects[$key]['label'] = trim($input['label'] ?? $project['label']);
$projects[$key]['description'] = trim($input['description'] ?? $project['description']);
$projects[$key]['icon'] = trim($input['icon'] ?? $project['icon']);
$projects[$key]['link'] = trim($input['link'] ?? $project['link']);
if (isset($input['images']) || isset($input['image'])) {
$images = normalize_images($input);
$projects[$key]['images'] = $images;
$projects[$key]['image'] = $images[0] ?? '';
}
if (isset($input['stack'])) {
$projects[$key]['stack'] = normalize_stack($input);
}
if (isset($input['period_start'])) {
$projects[$key]['period_start'] = trim($input['period_start']);
}
if (isset($input['period_end'])) {
$projects[$key]['period_end'] = trim($input['period_end']);
}
if (isset($input['video_url'])) {
$projects[$key]['video_url'] = trim($input['video_url']);
}
// 기존 demo_url 필드 제거
unset($projects[$key]['demo_url']);
$projects[$key]['updated_at'] = date('Y-m-d');
$found = true;
break;
}
}
if (!$found) {
json_response(['error' => '프로젝트를 찾을 수 없습니다'], 404);
}
if (write_json_safe(PROJECTS_FILE, $projects)) {
json_response(['success' => true]);
} else {
json_response(['error' => '저장에 실패했습니다'], 500);
}
}
// =====================================================
// DELETE: 프로젝트 삭제
// =====================================================
if ($method === 'DELETE') {
$id = intval($_GET['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => '유효하지 않은 ID'], 400);
}
$projects = read_json_safe(PROJECTS_FILE) ?? [];
$filtered = array_values(array_filter($projects, function($p) use ($id) {
return ($p['id'] ?? 0) !== $id;
}));
if (count($filtered) === count($projects)) {
json_response(['error' => '프로젝트를 찾을 수 없습니다'], 404);
}
if (write_json_safe(PROJECTS_FILE, $filtered)) {
json_response(['success' => true]);
} else {
json_response(['error' => '삭제에 실패했습니다'], 500);
}
}
json_response(['error' => 'Method not allowed'], 405);
+229
View File
@@ -0,0 +1,229 @@
<?php
require_once 'config.php';
require_once __DIR__ . '/error_config.php';
session_start();
set_json_headers();
require_auth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(['error' => 'POST만 허용됩니다'], 405);
}
if (!isset($_FILES['file']) && !isset($_FILES['image'])) {
json_response(['error' => '파일 업로드 실패'], 400);
}
// 'file' 또는 'image' 둘 다 받기 (호환성)
$file = $_FILES['file'] ?? $_FILES['image'];
if ($file['error'] !== UPLOAD_ERR_OK) {
json_response(['error' => '파일 업로드 실패 (코드: ' . $file['error'] . ')'], 400);
}
// =====================================================
// 파일 사이즈 조정
// 이미지 5MB / 동영상 100MB / 일반 파일 50MB
// 단위: byte (1MB = 1024 * 1024)
// 제한 없음으로 하려면 PHP_INT_MAX로 설정
// 단, Synology PHP의 upload_max_filesize / post_max_size 설정도 함께 조정 필요
// =====================================================
$MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 이미지: 5 MB
$MAX_VIDEO_SIZE = 100 * 1024 * 1024; // 동영상: 100 MB
$MAX_FILE_SIZE = 50 * 1024 * 1024; // 일반 파일: 50 MB
// =====================================================
// 허용 MIME 타입과 확장자
$image_types = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp'
];
$video_types = [
'video/mp4' => 'mp4',
'video/webm' => 'webm',
'video/ogg' => 'ogv',
'video/quicktime' => 'mov'
];
// 일반 파일 허용 목록 (MIME → 확장자)
$file_types = [
'application/pdf' => 'pdf',
'application/zip' => 'zip',
'application/x-zip-compressed' => 'zip',
'application/x-7z-compressed' => '7z',
'application/x-rar-compressed' => 'rar',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'text/plain' => 'txt',
'text/markdown' => 'md',
'text/csv' => 'csv',
'application/json' => 'json',
];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$is_image = isset($image_types[$mime]);
$is_video = isset($video_types[$mime]);
$is_file = !$is_image && !$is_video && isset($file_types[$mime]);
if (!$is_image && !$is_video && !$is_file) {
json_response(['error' => '허용되지 않는 파일 형식입니다. (이미지/동영상/PDF/ZIP/문서 등)'], 400);
}
// 파일 크기 검증
if ($is_image && $file['size'] > $MAX_IMAGE_SIZE) {
$mb = round($MAX_IMAGE_SIZE / 1024 / 1024, 1);
json_response(['error' => "이미지 파일은 {$mb}MB 이하여야 합니다"], 400);
}
if ($is_video && $file['size'] > $MAX_VIDEO_SIZE) {
$mb = round($MAX_VIDEO_SIZE / 1024 / 1024, 1);
json_response(['error' => "동영상 파일은 {$mb}MB 이하여야 합니다"], 400);
}
if ($is_file && $file['size'] > $MAX_FILE_SIZE) {
$mb = round($MAX_FILE_SIZE / 1024 / 1024, 1);
json_response(['error' => "파일은 {$mb}MB 이하여야 합니다"], 400);
}
if ($is_image) {
$ext = $image_types[$mime];
$kind = 'image';
$prefix = 'img_';
} elseif ($is_video) {
$ext = $video_types[$mime];
$kind = 'video';
$prefix = 'vid_';
} else {
$ext = $file_types[$mime];
$kind = 'file';
$prefix = 'file_';
// 일반 파일은 원본 파일명 일부를 살림 (정리 후)
$origName = pathinfo($file['name'], PATHINFO_FILENAME);
$origName = preg_replace('/[^\p{L}\p{N}\-_]/u', '_', $origName);
$origName = mb_substr($origName, 0, 30, 'UTF-8');
$prefix = $origName . '_';
}
// =====================================================
// 폴더명 생성
// =====================================================
function sanitize_folder_name($title) {
$title = trim($title);
if ($title === '') return '';
// 위험한 문자 제거
$title = preg_replace('/[\/\\\\\:\*\?"<>\|]/u', '', $title);
$title = preg_replace('/\.\.+/u', '', $title);
// 공백을 하이픈으로
$title = preg_replace('/\s+/u', '-', $title);
// 영문/숫자/한글/하이픈/언더스코어 외 모두 제거
$title = preg_replace('/[^\p{L}\p{N}\-_]/u', '', $title);
$title = trim($title, '-_');
if (mb_strlen($title, 'UTF-8') > 50) {
$title = mb_substr($title, 0, 50, 'UTF-8');
}
return $title;
}
$projectTitle = isset($_POST['project_title']) ? trim($_POST['project_title']) : '';
$folderName = sanitize_folder_name($projectTitle);
if ($folderName === '') {
$folderName = '_untitled';
}
$uploadDir = UPLOADS_DIR . '/' . $folderName;
if (!is_dir(UPLOADS_DIR)) {
@mkdir(UPLOADS_DIR, 0775, true);
}
if (!is_dir($uploadDir)) {
if (!@mkdir($uploadDir, 0775, true)) {
json_response(['error' => '폴더 생성 실패. uploads 폴더에 쓰기 권한을 확인해주세요.'], 500);
}
}
// 안전한 파일명 생성
$filename = uniqid($prefix, true) . '.' . $ext;
$target_path = $uploadDir . '/' . $filename;
// =====================================================
// 이미지: WebP 변환 시도 (GD 지원 시)
// 동영상/일반 파일은 원본 그대로 저장
// =====================================================
function try_save_as_webp(string $tmp, string $dest_dir, string $base_name, string $mime, int $quality = 82): string|false {
if (!function_exists('imagewebp')) return false;
$src = match ($mime) {
'image/jpeg' => @imagecreatefromjpeg($tmp),
'image/png' => @imagecreatefrompng($tmp),
'image/gif' => @imagecreatefromgif($tmp),
'image/webp' => @imagecreatefromwebp($tmp),
default => false,
};
if (!$src) return false;
// PNG 투명도 보존
if ($mime === 'image/png') {
imagepalettetotruecolor($src);
imagealphablending($src, true);
imagesavealpha($src, true);
}
// 가로 1200px 초과 시 리사이즈
$ow = imagesx($src);
$oh = imagesy($src);
if ($ow > 1200) {
$nw = 1200;
$nh = (int)round($oh * (1200 / $ow));
$dst = imagecreatetruecolor($nw, $nh);
imagealphablending($dst, false);
imagesavealpha($dst, true);
$t = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefilledrectangle($dst, 0, 0, $nw, $nh, $t);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $ow, $oh);
imagedestroy($src);
$src = $dst;
}
$dest_path = $dest_dir . '/' . $base_name . '.webp';
$ok = imagewebp($src, $dest_path, $quality);
imagedestroy($src);
return $ok ? $dest_path : false;
}
if ($is_image) {
$base_name = pathinfo($filename, PATHINFO_FILENAME);
$webp_path = try_save_as_webp($file['tmp_name'], $uploadDir, $base_name, $mime);
if ($webp_path !== false) {
$filename = $base_name . '.webp';
$target_path = $webp_path;
$saved = true;
} else {
$saved = move_uploaded_file($file['tmp_name'], $target_path);
}
} else {
$saved = move_uploaded_file($file['tmp_name'], $target_path);
}
if ($saved) {
json_response([
'success' => true,
'url' => 'uploads/' . $folderName . '/' . $filename,
'filename' => $filename,
'original_name' => $file['name'],
'folder' => $folderName,
'kind' => $kind
]);
} else {
$err = error_get_last();
json_response(['error' => '파일 저장 실패', 'detail' => $err, 'target' => $target_path, 'tmp' => $file['tmp_name'], 'tmp_exists' => file_exists($file['tmp_name'])], 500);
}
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# ============================================================
# 포트폴리오 NAS 백업 스크립트
# 위치: /web/my_profile/backup.sh
# 실행: bash backup.sh 또는 crontab으로 자동화
# Synology Task Scheduler에 등록 권장 (매일 새벽 3시)
# ============================================================
SITE_DIR="/web/my_profile"
BACKUP_BASE="/volume1/backup/portfolio" # 백업 저장 경로 (필요 시 수정)
DATE=$(date +"%Y%m%d_%H%M%S")
BACKUP_DIR="$BACKUP_BASE/$DATE"
KEEP_DAYS=30 # 보관 기간 (일)
# ── 백업 디렉토리 생성 ──────────────────────────────────────
mkdir -p "$BACKUP_DIR"
# ── data/*.json 백업 ───────────────────────────────────────
if [ -d "$SITE_DIR/data" ]; then
cp -r "$SITE_DIR/data" "$BACKUP_DIR/data"
echo "[$(date)] data/ 백업 완료"
else
echo "[$(date)] WARNING: data/ 디렉토리 없음"
fi
# ── uploads/ 백업 (심볼릭 링크 포함) ──────────────────────
if [ -d "$SITE_DIR/uploads" ]; then
cp -rL "$SITE_DIR/uploads" "$BACKUP_DIR/uploads"
echo "[$(date)] uploads/ 백업 완료"
else
echo "[$(date)] WARNING: uploads/ 디렉토리 없음"
fi
# ── HTML/PHP 소스 백업 ────────────────────────────────────
cp "$SITE_DIR"/*.html "$BACKUP_DIR/" 2>/dev/null
cp "$SITE_DIR"/*.css "$BACKUP_DIR/" 2>/dev/null
cp -r "$SITE_DIR/api" "$BACKUP_DIR/api" 2>/dev/null
echo "[$(date)] 소스 파일 백업 완료"
# ── 압축 ──────────────────────────────────────────────────
tar -czf "$BACKUP_BASE/portfolio_$DATE.tar.gz" -C "$BACKUP_BASE" "$DATE"
rm -rf "$BACKUP_DIR"
echo "[$(date)] 압축 완료: portfolio_$DATE.tar.gz"
# ── 오래된 백업 삭제 ──────────────────────────────────────
find "$BACKUP_BASE" -name "portfolio_*.tar.gz" -mtime +$KEEP_DAYS -delete
echo "[$(date)] ${KEEP_DAYS}일 이상 된 백업 삭제 완료"
# ── 용량 확인 ─────────────────────────────────────────────
TOTAL=$(du -sh "$BACKUP_BASE" 2>/dev/null | cut -f1)
COUNT=$(ls "$BACKUP_BASE"/portfolio_*.tar.gz 2>/dev/null | wc -l)
echo "[$(date)] 백업 완료 | 총 ${COUNT}개 | 용량: ${TOTAL}"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
# data/ 폴더 직접 접근 차단
# 브라우저에서 /data/projects.json 같은 직접 접근을 막습니다.
# 데이터는 반드시 PHP API를 통해서만 조회되어야 합니다.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+163
View File
@@ -0,0 +1,163 @@
[
{
"id": 1,
"name": "Unity",
"color": "#00f2ff",
"parent_id": null,
"order": 1
},
{
"id": 2,
"name": "학습",
"color": "#ffd166",
"parent_id": 1,
"order": 1
},
{
"id": 3,
"name": "개발일지",
"color": "#06ffa5",
"parent_id": 1,
"order": 2
},
{
"id": 4,
"name": "Shader",
"color": "#7000ff",
"parent_id": null,
"order": 2
},
{
"id": 5,
"name": "학습",
"color": "#ffd166",
"parent_id": 4,
"order": 1
},
{
"id": 6,
"name": "개발일지",
"color": "#06ffa5",
"parent_id": 4,
"order": 2
},
{
"id": 7,
"name": "C++",
"color": "#ff6b9d",
"parent_id": null,
"order": 3
},
{
"id": 8,
"name": "학습",
"color": "#ffd166",
"parent_id": 7,
"order": 1
},
{
"id": 9,
"name": "개발일지",
"color": "#06ffa5",
"parent_id": 7,
"order": 2
},
{
"id": 10,
"name": "XR / VR",
"color": "#ffd166",
"parent_id": null,
"order": 4
},
{
"id": 11,
"name": "학습",
"color": "#ffd166",
"parent_id": 10,
"order": 1
},
{
"id": 12,
"name": "개발일지",
"color": "#06ffa5",
"parent_id": 10,
"order": 2
},
{
"id": 13,
"name": "Server / DevOps",
"color": "#06ffa5",
"parent_id": null,
"order": 5
},
{
"id": 14,
"name": "학습",
"color": "#ffd166",
"parent_id": 13,
"order": 1
},
{
"id": 15,
"name": "개발일지",
"color": "#06ffa5",
"parent_id": 13,
"order": 2
},
{
"id": 16,
"name": "3dsMAx",
"color": "#a6ff00",
"parent_id": null,
"order": 6
},
{
"id": 17,
"name": "학습",
"color": "#ffd166",
"parent_id": 16,
"order": 1
},
{
"id": 18,
"name": "메모",
"color": "#ff007b",
"parent_id": null,
"order": 7
},
{
"id": 19,
"name": "메모",
"color": "#ffd166",
"parent_id": 18,
"order": 1
},
{
"id": 20,
"name": "미래내일일경험",
"color": "#fff700",
"parent_id": null,
"order": 8
},
{
"id": 21,
"name": "일지",
"color": "#f50000",
"parent_id": 20,
"order": 1
},
{
"id": 22,
"name": "AI",
"color": "#ffbb00",
"parent_id": null,
"order": 9
},
{
"id": 23,
"name": "바이브코딩",
"color": "#ffd166",
"parent_id": 22,
"order": 1
}
]
+170
View File
File diff suppressed because one or more lines are too long
+74
View File
@@ -0,0 +1,74 @@
{
"name": "이종재",
"title": "Game & XR Developer",
"tagline": "가상과 현실의 경계를 허무는 인터랙티브 경험을 만듭니다",
"avatar": "uploads/profile.jpg",
"bio": "Unity와 Unreal Engine을 기반으로 게임과 XR 콘텐츠를 개발하고 있습니다. 단순히 작동하는 코드를 넘어, 사용자가 몰입할 수 있는 경험을 설계하는 것에 집중합니다. 자체 NAS 환경에서 Gitea와 CI/CD 파이프라인을 운영하며 개발 인프라부터 클라이언트까지 전 과정을 다룹니다.",
"location": "Gwangmyeong-si, Gyeonggi-do, South Korea",
"email": "jongjae0305@gmail.com",
"skills": [
{
"category": "언어",
"items": [
"C#",
"C++",
"Python",
"JavaScript",
"Java"
]
},
{
"category": "엔진 / 프레임워크",
"items": [
"Unity",
"Unreal Engine"
]
},
{
"category": "인프라 / DevOps",
"items": [
"Docker",
"Gitea",
"Synology NAS"
]
},
{
"category": "도구",
"items": [
"Git",
"Visual Studio",
"3dsMAx"
]
},
{
"category": "AI",
"items": [
"Claude",
"Gemini",
"ChatGpt"
]
}
],
"timeline": [
{
"year": "2026",
"title": "Unity, Unreal, 3dMax 공부",
"description": "Unity, Unreal, 3dMax 공부 및 게임 개발"
},
{
"year": "2025",
"title": "AI 공부",
"description": "Python 공부 및 전반적인 AI의 시스템 공부"
},
{
"year": "2024",
"title": "풀스택 교육을 통한 개발공부시작",
"description": "국비지원을 통한 학원에서 Java, Javascript, react, sql 등 풀스택"
}
],
"social": {
"github": "https://whdwo798.synology.me",
"linkedin": "",
"blog": ""
}
}
+66
View File
@@ -0,0 +1,66 @@
[
{
"id": 1,
"title": "WildRoot",
"label": "UNITY",
"description": "Unity로 만든 안드로이드 게임.\n조이스틱과 실드, 총 버튼을 이용하여 생성되는 무기를 파괴하여 타임을 기록하는게임",
"icon": "fa-solid fa-code",
"images": [
"uploads/WildRoot/img_69f356c0354dc0.69476663.jpg",
"uploads/WildRoot/img_69f356c02ec8e4.60887672.jpg",
"uploads/WildRoot/img_69f356c037cfb4.05035323.jpg"
],
"image": "uploads/WildRoot/img_69f356c0354dc0.69476663.jpg",
"link": "https://whdwo798.synology.me/Game/WildRoot.git",
"stack": [
"Unity"
],
"period_start": "2026-04-20",
"period_end": "2026-04-24",
"video_url": "uploads/WildRoot/WIldRoot.mp4",
"created_at": "2026-04-30"
},
{
"id": 2,
"title": "동물 사전",
"label": "UNITY, AR",
"description": "QR코드를 통하여 이미지를 동물을 소환하여 현실감 있게 느끼는 AR사전",
"icon": "fa-solid fa-code",
"images": [
"uploads/동물-사전/img_69fc43297ee5b3.62410794.jpg",
"uploads/동물-사전/img_69fc432b0552a7.38602241.jpg"
],
"image": "uploads/동물-사전/img_69fc43297ee5b3.62410794.jpg",
"link": "https://whdwo798.synology.me/whdwo798/AnimalDictionary.git",
"stack": [
"Unity"
],
"period_start": "2026-05-01",
"period_end": "2026-05-07",
"video_url": "/uploads/동물-사전/AnimalsDictionary.mp4",
"created_at": "2026-05-07",
"updated_at": "2026-05-07"
},
{
"id": 3,
"title": "BeatSaber",
"label": "UNITY, VR",
"description": "비트세이버 게임 모작",
"icon": "fa-solid fa-code",
"images": [
"uploads/BeatSaber/img_6a193f37360c14.56970202.webp",
"uploads/BeatSaber/img_6a193f37c56dc7.05489597.webp",
"uploads/BeatSaber/img_6a193f386123d1.60613240.webp"
],
"image": "uploads/BeatSaber/img_6a193f37360c14.56970202.webp",
"link": "https://whdwo798.synology.me/whdwo798/BeatSaber.git",
"stack": [
"Unity"
],
"period_start": "2026-05-01",
"period_end": "2026-05-29",
"video_url": "uploads/BeatSaber/beatsaber.mp4",
"created_at": "2026-05-29",
"updated_at": "2026-05-29"
}
]
+1911
View File
File diff suppressed because it is too large Load Diff
+639
View File
@@ -0,0 +1,639 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이종재 | Game & XR Developer</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Noto+Sans+KR:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root {
--primary: #00f2ff;
--secondary: #7000ff;
--bg-dark: #0a0a0f;
--card-bg: #161625;
--text-white: #e2e8f0;
--text-dim: #94a3b8;
--danger: #ff4757;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background-color: var(--bg-dark);
color: var(--text-white);
line-height: 1.7;
overflow-x: hidden;
}
h1, h2, h3, .logo, .card-label { font-family: 'Orbitron', sans-serif; }
nav {
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(10px);
padding: 1.2rem 8%;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 1000;
border-bottom: 1px solid rgba(0, 242, 255, 0.1);
}
nav .logo { font-weight: 700; font-size: 1.4rem; color: var(--primary); letter-spacing: 2px; }
nav .links { display: flex; align-items: center; gap: 2rem; }
nav .links a {
text-decoration: none;
color: var(--text-white);
font-size: 0.9rem;
transition: 0.3s;
display: flex;
align-items: center;
gap: 6px;
}
nav .links a:hover { color: var(--primary); }
nav .links a.profile-link {
border: 1px solid var(--primary);
padding: 0.5rem 1rem;
border-radius: 6px;
}
nav .links a.profile-link:hover {
background: var(--primary);
color: #000;
}
header {
min-height: 60vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 80px 20px;
background:
radial-gradient(circle at 30% 50%, rgba(112, 0, 255, 0.15) 0%, transparent 50%),
radial-gradient(circle at 70% 50%, rgba(0, 242, 255, 0.1) 0%, transparent 50%);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
header h1 {
font-size: 3.5rem;
margin-bottom: 1rem;
text-shadow: 0 0 30px rgba(0, 242, 255, 0.5);
letter-spacing: 2px;
}
header p { font-size: 1.2rem; color: var(--text-dim); max-width: 800px; padding: 0 20px; }
.highlight { color: var(--primary); }
main { padding: 80px 8%; }
.section-header { display: flex; align-items: center; gap: 20px; margin-bottom: 4rem; }
.section-header h2 { font-size: 2rem; letter-spacing: 1px; }
.line { flex-grow: 1; height: 1px; background: rgba(0, 242, 255, 0.2); }
.admin-controls {
display: flex;
gap: 10px;
}
.admin-controls.hidden { display: none; }
.btn {
padding: 0.6rem 1.2rem;
border-radius: 6px;
font-family: 'Orbitron', sans-serif;
font-size: 0.75rem;
letter-spacing: 1.5px;
cursor: pointer;
border: none;
transition: 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary { background: var(--primary); color: #000; font-weight: 700; }
.btn-primary:hover { background: #fff; transform: translateY(-2px); }
.btn-outline { background: transparent; border: 1px solid var(--primary); color: var(--primary); }
.btn-outline:hover { background: var(--primary); color: #000; }
.btn-danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2.5rem;
}
.card {
background: var(--card-bg);
border-radius: 1.2rem;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.05);
transition: 0.3s ease;
position: relative;
}
.card:hover { transform: translateY(-10px); border-color: var(--primary); box-shadow: 0 10px 40px rgba(0, 242, 255, 0.1); }
.card-img {
width: 100%;
height: 200px;
background: linear-gradient(135deg, #1e1e30 0%, #2a1a3e 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 3.5rem;
color: rgba(255,255,255,0.15);
background-size: cover;
background-position: center;
}
.card-content { padding: 1.8rem; }
.card-label {
color: var(--primary);
font-size: 0.7rem;
letter-spacing: 2px;
margin-bottom: 0.8rem;
display: block;
font-weight: 700;
}
.card h3 { font-size: 1.4rem; margin-bottom: 0.8rem; }
.card p { color: var(--text-dim); margin-bottom: 1.5rem; font-size: 0.95rem; }
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.card-actions.admin-mode {
border-top: 1px dashed rgba(255,255,255,0.1);
padding-top: 1rem;
margin-top: 1rem;
}
.btn-git {
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: #000;
background: var(--primary);
padding: 0.7rem 1.2rem;
border-radius: 0.6rem;
font-weight: 700;
font-size: 0.85rem;
transition: 0.3s;
}
.btn-git:hover { background: white; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty-state i { font-size: 3rem; opacity: 0.3; margin-bottom: 1rem; }
/* 모달 */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
z-index: 2000;
justify-content: center;
align-items: center;
padding: 20px;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--card-bg);
border: 1px solid rgba(0, 242, 255, 0.3);
border-radius: 1rem;
padding: 2rem;
max-width: 550px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.modal h2 {
color: var(--primary);
margin-bottom: 1.5rem;
font-size: 1.4rem;
letter-spacing: 1px;
}
.form-group { margin-bottom: 1.2rem; }
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 0.85rem;
color: var(--text-dim);
font-family: 'Orbitron', sans-serif;
letter-spacing: 1px;
}
.form-group input, .form-group textarea, .form-group select {
width: 100%;
padding: 12px;
background: #0a0a0f;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: var(--text-white);
font-family: 'Noto Sans KR', sans-serif;
font-size: 0.95rem;
transition: 0.3s;
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 242, 255, 0.1);
}
.form-group textarea { min-height: 100px; resize: vertical; }
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 1.5rem;
}
.alert {
padding: 12px;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.alert-error { background: rgba(255, 71, 87, 0.1); border: 1px solid var(--danger); color: var(--danger); }
.alert-success { background: rgba(0, 242, 255, 0.1); border: 1px solid var(--primary); color: var(--primary); }
.icon-hint {
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 4px;
}
.icon-hint a { color: var(--primary); }
footer {
padding: 60px 8% 30px;
text-align: center;
border-top: 1px solid rgba(255,255,255,0.05);
color: var(--text-dim);
font-size: 0.9rem;
}
footer .admin-toggle {
display: inline-block;
margin-top: 10px;
opacity: 0.3;
cursor: pointer;
transition: 0.3s;
}
footer .admin-toggle:hover { opacity: 1; color: var(--primary); }
@media (max-width: 768px) {
header h1 { font-size: 2.2rem; }
.grid { grid-template-columns: 1fr; }
nav { padding: 1rem 5%; }
nav .links { gap: 1rem; }
main { padding: 60px 5%; }
}
</style>
</head>
<body>
<nav>
<div class="logo">JONGJAE.XR</div>
<div class="links">
<a href="#work">PROJECTS</a>
<a href="profile.html" class="profile-link">
<i class="fa-solid fa-user"></i> PROFILE
</a>
</div>
</nav>
<header>
<h1>이종재 <span class="highlight">Portfolio</span></h1>
<p>Game & XR 개발자로서의 기술적 도전과 기록을 담은 공간입니다.<br>Gitea 서버를 통해 실제 <span class="highlight">소스 코드</span>를 확인하실 수 있습니다.</p>
</header>
<main id="work">
<div class="section-header">
<h2>DEVELOPMENT LOG</h2>
<div class="line"></div>
<div class="admin-controls hidden" id="adminControls">
<button class="btn btn-primary" onclick="openProjectModal()">
<i class="fa-solid fa-plus"></i> 새 프로젝트
</button>
<button class="btn btn-outline" onclick="logout()">
<i class="fa-solid fa-right-from-bracket"></i> 로그아웃
</button>
</div>
</div>
<div class="grid" id="projectsGrid">
<!-- 프로젝트가 동적으로 로드됩니다 -->
</div>
</main>
<footer>
<p>&copy; 2026 Lee Jong-jae. Hosted on Private Synology NAS.</p>
<span class="admin-toggle" onclick="openLoginModal()" title="Admin">
<i class="fa-solid fa-shield-halved"></i>
</span>
</footer>
<!-- 로그인 모달 -->
<div class="modal-overlay" id="loginModal">
<div class="modal">
<h2><i class="fa-solid fa-lock"></i> ADMIN LOGIN</h2>
<div id="loginAlert"></div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" id="loginPassword" placeholder="비밀번호를 입력하세요"
onkeypress="if(event.key==='Enter') doLogin()">
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeLoginModal()">취소</button>
<button class="btn btn-primary" onclick="doLogin()">로그인</button>
</div>
</div>
</div>
<!-- 프로젝트 등록/수정 모달 -->
<div class="modal-overlay" id="projectModal">
<div class="modal">
<h2 id="projectModalTitle">새 프로젝트</h2>
<div id="projectAlert"></div>
<input type="hidden" id="projectId">
<div class="form-group">
<label>제목 *</label>
<input type="text" id="projectTitle" placeholder="예: XR 인터랙티브 시뮬레이션">
</div>
<div class="form-group">
<label>라벨 *</label>
<input type="text" id="projectLabel" placeholder="예: UNITY / XR">
</div>
<div class="form-group">
<label>설명 *</label>
<textarea id="projectDescription" placeholder="프로젝트 소개"></textarea>
</div>
<div class="form-group">
<label>아이콘 (Font Awesome 클래스)</label>
<input type="text" id="projectIcon" placeholder="예: fa-solid fa-cube">
<p class="icon-hint">예시: <code>fa-solid fa-cube</code> · <code>fa-solid fa-gamepad</code> · <code>fa-solid fa-microchip</code> · <a href="https://fontawesome.com/search?o=r&m=free" target="_blank">아이콘 검색</a></p>
</div>
<div class="form-group">
<label>썸네일 이미지 URL (선택)</label>
<input type="text" id="projectImage" placeholder="비우면 아이콘 표시">
</div>
<div class="form-group">
<label>링크 URL (Gitea 저장소 등)</label>
<input type="text" id="projectLink" placeholder="https://...">
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeProjectModal()">취소</button>
<button class="btn btn-primary" onclick="saveProject()">
<i class="fa-solid fa-floppy-disk"></i> 저장
</button>
</div>
</div>
</div>
<script>
// ===== 상태 =====
let isAdmin = false;
let projects = [];
// ===== 초기화 =====
async function init() {
await checkAuth();
await loadProjects();
}
// ===== 인증 =====
async function checkAuth() {
try {
const res = await fetch('api/auth.php?action=check');
const data = await res.json();
isAdmin = data.authenticated === true;
document.getElementById('adminControls').classList.toggle('hidden', !isAdmin);
} catch (e) {
isAdmin = false;
}
}
function openLoginModal() {
if (isAdmin) {
logout();
return;
}
document.getElementById('loginModal').classList.add('active');
document.getElementById('loginPassword').focus();
}
function closeLoginModal() {
document.getElementById('loginModal').classList.remove('active');
document.getElementById('loginPassword').value = '';
document.getElementById('loginAlert').innerHTML = '';
}
async function doLogin() {
const password = document.getElementById('loginPassword').value;
if (!password) return;
try {
const res = await fetch('api/auth.php?action=login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (data.success) {
isAdmin = true;
document.getElementById('adminControls').classList.remove('hidden');
closeLoginModal();
await loadProjects();
} else {
showAlert('loginAlert', data.error || '로그인 실패', 'error');
}
} catch (e) {
showAlert('loginAlert', '서버 오류', 'error');
}
}
async function logout() {
await fetch('api/auth.php?action=logout', { method: 'POST' });
isAdmin = false;
document.getElementById('adminControls').classList.add('hidden');
await loadProjects();
}
// ===== 프로젝트 로드 =====
async function loadProjects() {
try {
const res = await fetch('api/projects.php');
projects = await res.json();
renderProjects();
} catch (e) {
console.error('Failed to load projects', e);
}
}
function renderProjects() {
const grid = document.getElementById('projectsGrid');
if (!projects || projects.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column: 1/-1;">
<i class="fa-solid fa-folder-open"></i>
<p>아직 등록된 프로젝트가 없습니다.</p>
</div>
`;
return;
}
grid.innerHTML = projects.map(p => `
<div class="card">
<div class="card-img" ${p.image ? `style="background-image:url('${escapeHtml(p.image)}'); font-size:0;"` : ''}>
${!p.image ? `<i class="${escapeHtml(p.icon || 'fa-solid fa-code')}"></i>` : ''}
</div>
<div class="card-content">
<span class="card-label">${escapeHtml(p.label)}</span>
<h3>${escapeHtml(p.title)}</h3>
<p>${escapeHtml(p.description)}</p>
<div class="card-actions">
${p.link ? `<a href="${escapeHtml(p.link)}" class="btn-git" target="_blank">
<i class="fa-brands fa-git-alt"></i> 소스 코드 보기
</a>` : ''}
</div>
${isAdmin ? `
<div class="card-actions admin-mode">
<button class="btn btn-outline" onclick="editProject(${p.id})">
<i class="fa-solid fa-pen"></i> 수정
</button>
<button class="btn btn-danger" onclick="deleteProject(${p.id})">
<i class="fa-solid fa-trash"></i> 삭제
</button>
</div>` : ''}
</div>
</div>
`).join('');
}
// ===== 프로젝트 모달 =====
function openProjectModal(project = null) {
document.getElementById('projectAlert').innerHTML = '';
if (project) {
document.getElementById('projectModalTitle').textContent = '프로젝트 수정';
document.getElementById('projectId').value = project.id;
document.getElementById('projectTitle').value = project.title;
document.getElementById('projectLabel').value = project.label;
document.getElementById('projectDescription').value = project.description;
document.getElementById('projectIcon').value = project.icon || '';
document.getElementById('projectImage').value = project.image || '';
document.getElementById('projectLink').value = project.link || '';
} else {
document.getElementById('projectModalTitle').textContent = '새 프로젝트';
document.getElementById('projectId').value = '';
['projectTitle', 'projectLabel', 'projectDescription',
'projectIcon', 'projectImage', 'projectLink'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('projectIcon').value = 'fa-solid fa-code';
}
document.getElementById('projectModal').classList.add('active');
}
function closeProjectModal() {
document.getElementById('projectModal').classList.remove('active');
}
function editProject(id) {
const project = projects.find(p => p.id === id);
if (project) openProjectModal(project);
}
async function saveProject() {
const id = document.getElementById('projectId').value;
const data = {
title: document.getElementById('projectTitle').value.trim(),
label: document.getElementById('projectLabel').value.trim(),
description: document.getElementById('projectDescription').value.trim(),
icon: document.getElementById('projectIcon').value.trim(),
image: document.getElementById('projectImage').value.trim(),
link: document.getElementById('projectLink').value.trim()
};
if (!data.title || !data.label || !data.description) {
showAlert('projectAlert', '제목, 라벨, 설명은 필수입니다', 'error');
return;
}
try {
let res;
if (id) {
data.id = parseInt(id);
res = await fetch('api/projects.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
res = await fetch('api/projects.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await res.json();
if (result.success) {
closeProjectModal();
await loadProjects();
} else {
showAlert('projectAlert', result.error || '저장 실패', 'error');
}
} catch (e) {
showAlert('projectAlert', '서버 오류', 'error');
}
}
async function deleteProject(id) {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const res = await fetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
const result = await res.json();
if (result.success) {
await loadProjects();
} else {
alert(result.error || '삭제 실패');
}
} catch (e) {
alert('서버 오류');
}
}
// ===== 유틸 =====
function showAlert(elemId, message, type) {
const elem = document.getElementById(elemId);
elem.innerHTML = `<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
if (type === 'success') {
setTimeout(() => elem.innerHTML = '', 3000);
}
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// 모달 외부 클릭 시 닫기
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('active');
});
});
init();
</script>
</body>
</html>
+4065
View File
File diff suppressed because it is too large Load Diff
+1086
View File
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
/* ===== CSS 변수 (라이트/다크 모드) ===== */
:root {
--bg: #F0EEE9;
--bg-card: #E8E4DE;
--bg-deep: #DDD8D2;
--bg-nav: rgba(240,238,233,0.95);
--primary: #5C8A6A;
--primary-dim: rgba(92,138,106,0.15);
--secondary: #3d6b50;
--text: #1a1a18;
--text-dim: #6a7a6e;
--text-sub: #8a9a8e;
--border: #d0ccc6;
--border-card: rgba(92,138,106,0.2);
--danger: #c0392b;
--shadow: rgba(0,0,0,0.08);
--nav-border: rgba(92,138,106,0.2);
/* 하위 호환 별칭 */
--bg-dark: var(--bg);
--card-bg: var(--bg-card);
--text-white: var(--text);
--learning-yellow: #b07a20;
--grass-l1: #a8c8b4;
--grass-l2: #6fa882;
--grass-l3: #3d7a56;
}
/* ===== 다크 모드 ===== */
[data-theme="dark"] {
--bg: #2a2420;
--bg-card: #322c28;
--bg-deep: #1e1a17;
--bg-nav: rgba(42,36,32,0.95);
--primary: #B2E2D2;
--primary-dim: rgba(178,226,210,0.12);
--secondary: #7ab8a8;
--text: #F5F5DC;
--text-dim: #a0b4ac;
--text-sub: #7a8a86;
--border: rgba(178,226,210,0.15);
--border-card: rgba(178,226,210,0.15);
--danger: #e07060;
--shadow: rgba(0,0,0,0.3);
--nav-border: rgba(178,226,210,0.15);
--bg-dark: var(--bg);
--card-bg: var(--bg-card);
--text-white: var(--text);
--learning-yellow: #c9a050;
--grass-l1: #4a8a72;
--grass-l2: #6fb89a;
--grass-l3: #B2E2D2;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background-color: var(--bg);
color: var(--text);
line-height: 1.7;
overflow-x: hidden;
transition: background-color 0.3s, color 0.3s;
}
h1, h2, h3, .logo, .card-label { font-family: 'Orbitron', sans-serif; }
nav {
background: var(--bg-nav);
backdrop-filter: blur(10px);
padding: 1.2rem 8%;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 1000;
border-bottom: 1px solid var(--nav-border);
}
nav .logo {
font-weight: 700; font-size: 1.4rem; color: var(--primary); letter-spacing: 2px;
text-decoration: none;
transition: 0.2s;
}
nav .logo:hover {
text-shadow: 0 0 20px var(--primary-dim);
}
nav .links { display: flex; align-items: center; gap: 2rem; }
nav .links a {
text-decoration: none; color: var(--text); font-size: 0.9rem;
transition: 0.3s; display: flex; align-items: center; gap: 6px;
position: relative;
padding: 4px 0;
}
nav .links a:hover { color: var(--primary); }
nav .links a.nav-active {
color: var(--primary);
}
nav .links a.nav-active::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 1px;
background: var(--primary);
box-shadow: 0 0 8px var(--primary);
}
/* ===== 버튼 (공통) ===== */
.btn {
padding: 0.6rem 1.2rem; border-radius: 6px;
font-family: 'Orbitron', sans-serif; font-size: 0.75rem; letter-spacing: 1.5px;
cursor: pointer; border: none; transition: 0.3s;
display: inline-flex; align-items: center; gap: 8px;
}
.btn-primary { background: var(--primary); color: var(--bg); font-weight: 700; }
.btn-primary:hover { background: var(--text); color: var(--bg); transform: translateY(-2px); }
.btn-outline { background: transparent; border: 1px solid var(--primary); color: var(--primary); }
.btn-outline:hover { background: var(--primary); color: var(--bg); }
.btn-danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
/* ===== 모달 (공통) ===== */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
z-index: 2000;
justify-content: center; align-items: center;
padding: 20px;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-card); color: var(--text);
background: var(--bg-card);
border: 1px solid rgba(var(--primary-rgb, 92,138,106), 0.3);
border-radius: 1rem;
padding: 2rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.modal.modal-wide {
max-width: min(66vw, 900px);
width: 100%;
}
.modal h2 {
color: var(--primary); margin-bottom: 1.5rem;
font-size: 1.4rem; letter-spacing: 1px; padding-right: 40px;
}
.modal-close-x {
position: absolute;
top: 1.2rem; right: 1.2rem;
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-dim); cursor: pointer;
font-size: 0.9rem; transition: 0.2s; z-index: 5;
}
.modal-close-x:hover {
color: var(--danger); border-color: var(--danger);
transform: rotate(90deg);
}
.form-group { margin-bottom: 1.2rem; }
.form-group label {
display: block; margin-bottom: 6px; font-size: 0.85rem;
color: var(--text-dim); font-family: 'Orbitron', sans-serif; letter-spacing: 1px;
}
.form-group input, .form-group textarea, .form-group select {
width: 100%; padding: 12px;
background: var(--bg-deep);
border: 1px solid var(--border); border-radius: 6px;
color: var(--text); font-family: 'Noto Sans KR', sans-serif;
font-size: 0.95rem; transition: 0.3s;
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
outline: none; border-color: var(--primary);
box-shadow: 0 0 0 3px var(--border);
}
.form-group textarea { min-height: 100px; resize: vertical; }
.modal-actions {
display: flex; gap: 10px; justify-content: flex-end; margin-top: 1.5rem;
}
.alert {
padding: 12px; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem;
}
.alert-error { background: rgba(255, 71, 87, 0.1); border: 1px solid var(--danger); color: var(--danger); }
.alert-success { background: var(--border); border: 1px solid var(--primary); color: var(--primary); }
/* ===== 테마 토글 버튼 ===== */
.theme-toggle {
background: var(--primary-dim);
border: 1px solid var(--border-card);
color: var(--primary);
width: 34px; height: 34px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
transition: 0.2s;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--primary);
color: var(--bg);
}
/* ===== 푸터 관리자 토글 ===== */
footer {
padding: 60px 8% 30px;
text-align: center;
border-top: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.9rem;
}
footer .admin-toggle {
display: inline-block; margin-top: 10px;
opacity: 0.3; cursor: pointer; transition: 0.3s;
}
footer .admin-toggle:hover { opacity: 1; color: var(--primary); }
/* ===== 반응형 공통 (nav + modal) ===== */
@media (max-width: 768px) {
nav { padding: 0.9rem 4%; flex-wrap: wrap; gap: 8px; }
nav .logo { font-size: 1.1rem; letter-spacing: 1px; }
nav .links { gap: 1rem; }
nav .links a { font-size: 0.8rem; }
.modal-overlay { padding: 12px; }
.modal.modal-wide { max-width: 92vw; }
.modal-actions .btn { width: 100%; justify-content: center; padding: 0.8rem; font-size: 0.8rem; }
}
@media (max-width: 380px) {
nav .logo { font-size: 0.95rem; }
nav .links { gap: 0.6rem; }
nav .links a { font-size: 0.75rem; }
}
+27
View File
@@ -0,0 +1,27 @@
# uploads/ 폴더에서 PHP 실행 방지
# 업로드된 파일이 코드로 실행되는 것을 차단합니다 (보안 핵심)
<FilesMatch "\.(php|phtml|php3|php4|php5|php7|phps|cgi|pl|py|jsp|asp|sh|js)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# PHP 핸들러 비활성화
<IfModule mod_php7.c>
php_flag engine off
</IfModule>
<IfModule mod_php8.c>
php_flag engine off
</IfModule>
# .htaccess 자체 접근 차단
<Files ".htaccess">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
</Files>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 927 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
@@ -0,0 +1,536 @@
# 단타 자동매매 시스템 종합 기획서
> 버전: v1.0
> 기준: 한국 주식 (코스피/코스닥) / KIS Open API / Synology NAS Docker
> 원칙: 규칙 기반 완전 자동화, 인간 판단 개입 0
---
## 0. 설계 원칙 (절대 불변)
1. **감정 0** — 모든 진입/청산은 코드가 결정. 예외 없음
2. **손절 우선** — 수익 극대화보다 손실 제한이 먼저
3. **단순함 우선** — 복잡한 전략보다 단순하고 견고한 전략
4. **검증 후 실거래** — 백테스트 → 모의투자 3개월 → 소액 실거래 순서 필수
5. **14:50 전량 청산** — 오버나이트는 절대 없음. 하드코딩
---
## 1. 시스템 전체 구조
```
┌─────────────────────────────────────────────────────────┐
│ Synology NAS (Docker) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ stockbot │ │ kill-switch │ ← 별도 컨테이너 │
│ │ (메인) │ │ (긴급중단) │ │
│ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ┌──────▼───────────────────────────────────┐ │
│ │ asyncio Event Loop │ │
│ │ ┌─────────┐ ┌─────────┐ ┌───────────┐ │ │
│ │ │Universe │ │Strategy │ │ Risk │ │ │
│ │ │Scanner │ │Engine │ │ Manager │ │ │
│ │ └─────────┘ └─────────┘ └───────────┘ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌───────────┐ │ │
│ │ │ Data │ │ Order │ │ Notifier │ │ │
│ │ │Collector│ │Executor │ │(Telegram) │ │ │
│ │ └─────────┘ └─────────┘ └───────────┘ │ │
│ └───────────────────────┬──────────────────┘ │
│ │ │
│ ┌───────────────────────▼──────────────────┐ │
│ │ SQLite (체결/포지션/로그) │ │
│ │ Redis (실시간 시세 캐시) │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Streamlit Dashboard (포트 8501) │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ │
KIS WebSocket KIS REST API
(실시간 시세) (주문/잔고)
```
---
## 2. 기술 스택 (확정)
| 항목 | 선택 | 이유 |
|------|------|------|
| 언어 | Python 3.11 | 생태계, KIS 예제 코드 모두 Python |
| 비동기 | asyncio + aiohttp | 초당 20건 rate limit 정밀 제어 |
| DB | SQLite | NAS 메모리 절약, 단일 파일 백업 용이 |
| 캐시 | Redis 7 (Docker) | 실시간 시세 캐시, TTL 관리 |
| 스케줄러 | APScheduler 3.x (AsyncIOScheduler) | 장 시작/마감 이벤트 트리거 |
| 알림 | python-telegram-bot | 단일 채널, 신뢰성 높음 |
| 대시보드 | Streamlit | 설치 단순, NAS 내부망 접근 |
| 컨테이너 | Docker Compose | Synology Container Manager 호환 |
| 백테스트 | vectorbt | pandas 기반, 분봉 데이터 처리 빠름 |
| 데이터 수집 | pykrx + KIS REST | 과거 분봉 백필 |
---
## 3. 디렉토리 구조
```
/volume1/docker/stockbot/
├── docker-compose.yml
├── .env ← API 키 전용 (Git 제외)
├── app/
│ ├── main.py ← 진입점, asyncio 루프
│ ├── config.py ← 전략 파라미터 전용
│ │
│ ├── data/
│ │ ├── collector.py ← KIS WebSocket 시세 수신
│ │ ├── universe.py ← 종목 풀 갱신 (08:30)
│ │ └── backfill.py ← 과거 분봉 백필 스크립트
│ │
│ ├── strategy/
│ │ ├── base.py ← 전략 추상 클래스
│ │ └── volatility_breakout.py ← 전략 A (유일한 실전 전략)
│ │
│ ├── risk/
│ │ └── manager.py ← 손절/일일한도/강제청산
│ │
│ ├── execution/
│ │ ├── kis_client.py ← KIS REST/WebSocket 래퍼
│ │ └── order_executor.py ← 주문 전송, 재시도, 슬리피지
│ │
│ ├── monitor/
│ │ ├── notifier.py ← 텔레그램 알림
│ │ └── dashboard.py ← Streamlit 대시보드
│ │
│ └── db/
│ ├── models.py ← SQLite 스키마
│ └── repository.py ← DB 접근 레이어
├── kill_switch/
│ └── kill.py ← 긴급 전량 청산 단독 실행
├── backtest/
│ ├── run_backtest.py ← vectorbt 백테스트 실행
│ └── results/ ← 백테스트 결과 저장
├── data/
│ ├── stockbot.db ← SQLite DB
│ └── universe_cache.json ← 당일 종목 풀 캐시
└── logs/
└── trades.log ← 영구 보관 (세금 신고용)
```
---
## 4. 전략 (확정: 전략 A 단독)
### 변동성 돌파 전략 (Volatility Breakout)
**선정 이유:**
- 래리 윌리엄스 실전 검증, 30년 이상 데이터 기반
- 룰이 단순 → 과적합 위험 최소
- 분봉 적용 시 한국 장 오전 변동성과 궁합 최적
- 진입 조건이 명확 → 자동화 구현 난이도 낮음
**핵심 파라미터 (config.py에서 관리)**
```python
STRATEGY_K = 0.5 # 변동성 계수 (0.4~0.6 안정 구간)
ENTRY_START = "09:00" # 진입 허용 시작
ENTRY_END = "14:30" # 진입 허용 마감
FORCE_EXIT = "14:50" # 강제 청산 시각 (하드코딩)
TP1_PCT = 0.02 # 1차 익절 +2% (50% 매도)
TP2_PCT = 0.03 # 2차 익절 +3% (전량)
SL_PCT = 0.015 # 손절 -1.5% (즉시 시장가)
MAX_HOLD_MIN = 120 # 최대 보유시간 120분 (무수익 시 청산)
```
**진입 로직**
```
목표가 = 당일 시가 + (전일 고가 - 전일 저가) × K
진입 조건 (전부 충족 시):
1. 현재가 >= 목표가
2. 현재 시각 ENTRY_START ~ ENTRY_END
3. 당일 KOSPI 등락률 > -1.0% ← 시장 약세 차단
4. 전일 거래대금 >= 100억
5. 시가총액 1,000억 ~ 3조
6. VI 미발동 종목
7. 당일 보유 종목 수 < 2 ← 동시 최대 2종목
8. 일일 누적 손실 < -3% ← 손실 한도 미도달
```
**청산 로직 (우선순위 순)**
```
1순위: 강제 청산 → 14:50 시장가 전량
2순위: 손절 → 현재가 <= 매수가 × (1 - SL_PCT) → 시장가
3순위: 1차 익절 → 현재가 >= 매수가 × (1 + TP1_PCT) → 50% 지정가
4순위: 2차 익절 → 현재가 >= 매수가 × (1 + TP2_PCT) → 전량 지정가
5순위: 시간 청산 → 보유 후 MAX_HOLD_MIN 경과, 수익 없을 시 시장가
```
---
## 5. 종목 유니버스 선정 (Universe)
매일 08:30 자동 갱신.
**1차 필터 (정적)**
| 조건 | 기준 |
|------|------|
| 시장 | 코스피 + 코스닥 |
| 시가총액 | 1,000억 ~ 3조 |
| 상장 기간 | 6개월 이상 |
| 제외 | 관리종목, 거래정지, 우선주, 스팩, ETF, ETN |
**2차 필터 (동적, 전일 기준)**
| 조건 | 기준 |
|------|------|
| 전일 거래대금 | 100억 이상 |
| 5일 평균 거래대금 | 50억 이상 |
| 전일 등락률 | -3% ~ +15% |
| 일봉 60일 이평선 | 현재가 위 |
**결과: 최대 30종목** (KIS WebSocket 안정성 기준으로 50 → 30 축소)
---
## 6. 리스크 관리
### 포지션 규칙
| 항목 | 값 |
|------|-----|
| 1종목 최대 비중 | 총자산 × 20% |
| 동시 보유 최대 | 2종목 |
| 분할매수 | 1차 10%, 신호 유지 시 2차 10% |
### 손실 한도 (계층별 자동 중단)
| 레벨 | 조건 | 동작 |
|------|------|------|
| L1 | 1회 매매 -1.5% | 즉시 손절 |
| L2 | 일일 누적 -3% | 당일 신규 진입 중단 |
| L3 | 3연속 손절 | 당일 매매 중단 |
| L4 | 주간 누적 -7% | 주말까지 중단 + 텔레그램 경고 |
| L5 | 월간 누적 -15% | 자동 전략 폐기 + 백테스트 재요청 |
### 안전장치
- VI(변동성완화장치) 발동 → 즉시 해당 종목 청산
- 호가 스프레드 > 0.5% → 진입 금지
- 매수 후 5분 미체결 → 주문 취소
- WebSocket 끊김 → 보유 포지션 즉시 시장가 청산
- API 오류 10건/분 → kill-switch 컨테이너 자동 실행
---
## 7. KIS API 운용
### 초당 20건 제한 대응 전략
```
WebSocket (실시간 시세) : 30종목 구독 → REST 호출 없음
REST (주문/잔고) : 주문 시에만 호출
REST (분봉 조회) : 신호 확인용, 1초 간격 제한
asyncio Semaphore(20) 로 초당 20건 보장
배치 처리: 30종목 ÷ 20건 = 1.5초/1회전
```
### WebSocket 구독 항목
- 실시간 체결가 (H0STCNT0)
- 실시간 호가 (H0STASP0)
- VI 발동/해제 (H0STVI0)
### 주문 타입 결정
| 상황 | 주문 타입 |
|------|----------|
| 진입 | 시장가 (빠른 체결 우선) |
| 1차 익절 | 지정가 (목표가 미리 걸기) |
| 2차 익절 | 지정가 |
| 손절 | 시장가 (슬리피지 감수) |
| 강제청산 | 시장가 |
---
## 8. 스케줄 타임라인
```
08:00 │ 시스템 기동, DB 연결 확인
08:30 │ Universe 갱신 (pykrx 전일 데이터)
08:50 │ WebSocket 연결, 30종목 구독 시작
│ 당일 시가 기반 목표가 계산
09:00 │ ─────────── 진입 허용 시작 ───────────
│ 1분봉 루프 시작 (asyncio, 1초 단위)
11:30 │ 신규 진입 일시 중단 (점심)
13:00 │ 신규 진입 재개
14:30 │ 신규 진입 마감
14:50 │ ─────────── 강제 청산 ───────────────
│ 보유 포지션 전량 시장가 매도
15:00 │ WebSocket 연결 종료
15:10 │ 당일 결산 로그 생성
15:20 │ 텔레그램 일일 결산 알림 발송
15:30 │ 시스템 대기 (다음 날 08:00까지)
```
---
## 9. DB 스키마 (SQLite)
```sql
-- 체결 내역
CREATE TABLE trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL, -- YYYY-MM-DD
ticker TEXT NOT NULL,
entry_time TEXT NOT NULL, -- HH:MM:SS
exit_time TEXT,
entry_price REAL NOT NULL,
exit_price REAL,
quantity INTEGER NOT NULL,
side TEXT NOT NULL, -- BUY / SELL
exit_reason TEXT, -- TP1/TP2/SL/FORCE/TIME
pnl REAL,
fee REAL,
slippage REAL,
strategy TEXT DEFAULT 'VB'
);
-- 일일 요약
CREATE TABLE daily_summary (
date TEXT PRIMARY KEY,
total_trades INTEGER,
win_trades INTEGER,
lose_trades INTEGER,
gross_pnl REAL,
total_fee REAL,
net_pnl REAL,
max_drawdown REAL,
trading_stopped INTEGER DEFAULT 0 -- L2 발동 여부
);
-- 포지션 (장중 현황)
CREATE TABLE positions (
ticker TEXT PRIMARY KEY,
entry_time TEXT,
entry_price REAL,
quantity INTEGER,
tp1_done INTEGER DEFAULT 0,
target_price REAL,
stop_price REAL
);
```
---
## 10. 알림 설계 (텔레그램)
| 이벤트 | 메시지 형식 |
|--------|-----------|
| 진입 | `[매수] 삼성전자 74,000원 / 목표 75,480 / 손절 72,890` |
| 1차 익절 | `[익절1] 삼성전자 +2.1% / 잔여 50%` |
| 손절 | `[손절] 삼성전자 -1.5% / 즉시 청산` |
| 강제 청산 | `[14:50 강제청산] 전 포지션 청산 완료` |
| L2 발동 | `[경고] 일일 손실 -3% 도달. 오늘 매매 중단.` |
| 일일 결산 | `[결산] 매매 5회 / 승 3 패 2 / 순손익 +1.2%` |
| 장애 | `[긴급] WebSocket 끊김. kill-switch 실행.` |
---
## 11. 백테스트 계획
### 데이터 준비
```
수집 대상: 코스피200 + 코스닥150 편입 종목
기간: 2021-01-01 ~ 2024-12-31 (4년치)
봉: 1분봉
도구: pykrx (무료, 과거 분봉 제공)
생존편향: 상장폐지 종목 별도 수집 포함
```
### 수수료 시뮬레이션
```
매수 수수료: 0.015%
매도 수수료: 0.015%
거래세: 0.18% (매도 시)
슬리피지: 시장가 0.10%, 지정가 0.03%
총 1회전 비용 추정: 약 0.21%
```
### 합격 기준 (out-of-sample 1년 기준)
| 지표 | 통과 기준 | 목표 |
|------|----------|------|
| 샤프지수 | > 1.0 | > 1.5 |
| MDD | < 15% | < 10% |
| 승률 | > 45% | > 55% |
| 손익비 | > 1.3 | > 1.8 |
| 일평균 매매 | 1~5회 | 2~3회 |
### K값 최적화 방법
```
1. K = 0.3 ~ 0.8 구간 0.05 단위 그리드 서치
2. 인샘플(2021~2023) 에서 상위 3개 K값 선별
3. 아웃샘플(2024) 에서 재검증
4. 피크 K값 배제 → 샤프지수 안정 구간 채택
5. 최종 채택: K = 0.5 (사전 기본값, 백테스트로 재확인)
```
---
## 12. 인프라 (Synology Docker)
### docker-compose.yml
```yaml
version: "3.9"
services:
redis:
image: redis:7-alpine
container_name: stockbot-redis
restart: unless-stopped
volumes:
- ./data/redis:/data
stockbot:
build: ./app
container_name: stockbot-main
restart: unless-stopped
depends_on:
- redis
env_file: .env
volumes:
- ./data:/app/data
- ./logs:/app/logs
environment:
- TZ=Asia/Seoul
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
kill-switch:
build: ./kill_switch
container_name: stockbot-killswitch
restart: "no" # 수동 또는 자동 트리거 시에만 실행
env_file: .env
profiles: ["emergency"] # docker compose --profile emergency up
dashboard:
build: ./monitor
container_name: stockbot-dashboard
restart: unless-stopped
ports:
- "8501:8501"
volumes:
- ./data:/app/data
environment:
- TZ=Asia/Seoul
```
### .env 구조
```
# KIS API
KIS_APP_KEY=...
KIS_APP_SECRET=...
KIS_ACCOUNT_NO=...
KIS_MOCK=true # 모의투자: true / 실거래: false
# 텔레그램
TELEGRAM_TOKEN=...
TELEGRAM_CHAT_ID=...
# Redis
REDIS_HOST=stockbot-redis
REDIS_PORT=6379
# 운영 모드
DRY_RUN=true # 주문 실제 전송 여부
LOG_LEVEL=INFO
```
---
## 13. 개발 로드맵
### Phase 1 — 데이터 기반 구축 (2주)
- [ ] KIS Open API 신청 + 모의투자 계좌 개설
- [ ] pykrx로 2021~2024 분봉 데이터 백필
- [ ] SQLite 스키마 생성
- [ ] Universe 스캐너 구현 (08:30 갱신)
- [ ] Docker Compose 기동 확인 (NAS)
### Phase 2 — 백테스트 (3주)
- [ ] vectorbt 환경 구성
- [ ] 변동성 돌파 전략 백테스트 코드 작성
- [ ] 수수료/슬리피지 반영
- [ ] K값 그리드 서치
- [ ] out-of-sample 검증 → 합격 기준 통과 확인
### Phase 3 — KIS 연동 + dry-run (2주)
- [ ] KIS REST 클라이언트 구현 (인증/토큰 갱신)
- [ ] KIS WebSocket 클라이언트 구현 (시세 수신)
- [ ] OrderExecutor 구현 (시장가/지정가/IOC)
- [ ] DRY_RUN=true 상태에서 신호 발생 확인
### Phase 4 — 리스크 + 알림 + 대시보드 (2주)
- [ ] RiskManager 전 계층 구현 (L1~L5)
- [ ] kill-switch 컨테이너 구현
- [ ] 텔레그램 Notifier 구현
- [ ] Streamlit 대시보드 구현 (실시간 PnL)
### Phase 5 — 모의투자 실운영 (최소 3개월)
- [ ] KIS_MOCK=true, DRY_RUN=false
- [ ] 매일 결산 로그 검토
- [ ] 주간 샤프지수/MDD 추적
- [ ] 이상 거동 발생 시 전략 파라미터 재검토 (코드 수정, K값 조정)
- [ ] 3개월 연속 샤프 > 1.0, MDD < 15% 달성 시 Phase 6 진입
### Phase 6 — 소액 실거래 (무기한)
- [ ] KIS_MOCK=false
- [ ] 총자산의 5% 한도로 시작
- [ ] 1개월 단위 성과 검토, 한도 점진 확대
---
## 14. 보안 체크리스트
- [ ] .env → .gitignore 등록 필수
- [ ] KIS API 키 → GitHub 절대 커밋 금지
- [ ] Synology 방화벽: 8501 포트 내부망만 허용
- [ ] 텔레그램 봇: 특정 chat_id만 허용 (화이트리스트)
- [ ] 모든 주문 로그 SQLite + logs/ 이중 보관
- [ ] 월 1회 .env 파일 암호화 백업
---
## 15. 절대 금지 항목 (하드코딩)
```python
# 이 리스트는 코드에서 상수로 관리, 절대 런타임 수정 불가
BLACKLIST_REASONS = [
"신규상장 6개월 미만",
"관리종목",
"투자경고",
"거래정지",
"우선주",
"스팩",
"ETF/ETN",
]
TRADING_BLACKOUT = [
("11:30", "13:00"), # 점심 휴식
("14:50", "15:30"), # 장 마감 전
("08:00", "09:00"), # 동시호가
]
HARD_EXIT_TIME = "14:50" # 절대 변경 불가
```
---
## 16. 면책 조항
> 본 기획서는 시스템 설계 문서이며, 투자 수익을 보장하지 않는다.
> 단타는 통계적으로 개인투자자의 90% 이상이 손실을 보는 영역이다.
> 백테스트 결과는 미래 수익을 보장하지 않으며, 반드시 모의투자 3개월 이상 검증 후 실거래로 전환할 것.
> 시스템 장애, API 오류, 네트워크 단절로 인한 손실에 대해 어떠한 책임도 지지 않는다.
@@ -0,0 +1,743 @@
# 단타 자동매매 시스템 종합 기획서 v2.0
> 버전: v2.0 (AI 판단 레이어 통합)
> 기준: 한국 주식 (코스피/코스닥) / KIS Open API / Synology NAS Docker
> 핵심 변경: 규칙 기반 실행 + Claude AI 일일 시장 판단 레이어 추가
---
## 0. 설계 원칙 (절대 불변)
1. **감정 0** — 실행은 코드가 결정. 단, 판단은 AI가 보조
2. **속도/판단 역할 분리** — AI는 느리지만 깊게(매일 1회), 실행은 빠르게(수식)
3. **손절 우선** — AI가 긍정 판단해도 손절 룰은 무조건 우선
4. **검증 후 실거래** — 백테스트 → 모의투자 3개월 → 소액 실거래
5. **14:50 전량 청산** — 하드코딩, 어떤 상황에서도 예외 없음
---
## 1. 시스템 전체 구조 (v2.0)
```
┌──────────────────────────────────────────────────────────────┐
│ Synology NAS (Docker) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ [레이어 1] AI 판단 엔진 (08:00) │ │
│ │ │ │
│ │ 데이터 수집 │ │
│ │ ├─ 전일 뉴스 (네이버 금융 크롤링) │ │
│ │ ├─ KOSPI/KOSDAQ 지수 흐름 (KIS REST) │ │
│ │ ├─ 외국인/기관 순매수 상위 (KIS 순위분석 API) │ │
│ │ ├─ 거래량 급증 종목 (KIS 순위분석 API) │ │
│ │ └─ 섹터별 등락률 (KIS 업종/기타 API) │ │
│ │ ↓ │ │
│ │ Claude API 호출 (하루 1회, 약 3,000 토큰) │ │
│ │ ↓ │ │
│ │ daily_context.json 생성 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ 읽기만 함 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ [레이어 2] 규칙 기반 실행 엔진 (09:00~) │ │
│ │ │ │
│ │ asyncio Event Loop │ │
│ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │
│ │ │Universe │ │Strategy │ │ Risk │ │ │
│ │ │Scanner │ │Engine │ │ Manager │ │ │
│ │ └──────────┘ └──────────┘ └────────────┘ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │
│ │ │ Data │ │ Order │ │ Notifier │ │ │
│ │ │Collector │ │Executor │ │(Telegram) │ │ │
│ │ └──────────┘ └──────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SQLite (체결/포지션/로그) │ Redis (실시간 시세 캐시) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Streamlit Dashboard (포트 8501) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ kill-switch (별도 컨테이너 / 긴급 청산) │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
│ │
KIS WebSocket KIS REST API
(실시간 시세/VI) (주문/잔고/순위/수급)
```
---
## 2. 핵심 변경: AI 판단 레이어 상세
### 2-1. 수집 데이터 소스
| 데이터 | 소스 | 방법 | 수집 시각 |
|--------|------|------|---------|
| 전일 종목 뉴스 헤드라인 | 네이버 금융 | HTTP 크롤링 | 07:30 |
| KOSPI/KOSDAQ 전일 종가·등락률 | KIS REST | API | 07:40 |
| 외국인 순매수 상위 30종목 | KIS 순위분석 API | API | 07:40 |
| 기관 순매수 상위 30종목 | KIS 순위분석 API | API | 07:40 |
| 거래량 급증 상위 30종목 | KIS 순위분석 API | API | 07:40 |
| 업종별 등락률 | KIS 업종/기타 API | API | 07:40 |
| 당일 경제 일정 (FOMC 등) | 네이버 금융 크롤링 | HTTP 크롤링 | 07:30 |
> KIS API만으로 수급·수치 데이터 대부분 커버 가능. 뉴스는 네이버 금융 크롤링으로 보완.
### 2-2. Claude API 프롬프트 구조
```python
SYSTEM_PROMPT = """
당신은 한국 주식 단타 전문 AI 분석가입니다.
매일 장 시작 전, 제공된 데이터를 분석해 오늘 단타 매매 전략을 판단합니다.
반드시 JSON 형식으로만 응답하며, 다른 텍스트는 포함하지 않습니다.
"""
USER_PROMPT = f"""
[전일 시장 요약]
- KOSPI: {kospi_change}% ({kospi_close}pt)
- KOSDAQ: {kosdaq_change}% ({kosdaq_close}pt)
[외국인 순매수 상위]
{foreign_buy_top10}
[기관 순매수 상위]
{institution_buy_top10}
[거래량 급증 상위]
{volume_surge_top10}
[업종별 등락률]
{sector_changes}
[주요 뉴스 헤드라인 (상위 20건)]
{news_headlines}
[오늘 경제 일정]
{economic_calendar}
위 데이터를 분석해 다음 JSON을 반환하세요:
{{
"trade_allowed": true/false,
"market_sentiment": "강세/중립/약세",
"sentiment_score": 0~100,
"risk_level": "낮음/보통/높음",
"hot_sectors": ["섹터1", "섹터2"],
"avoid_sectors": ["섹터3"],
"boosted_tickers": ["005930", "000660"],
"blacklist_tickers": ["종목코드"],
"position_size_multiplier": 0.5~1.5,
"reason": "한 줄 판단 이유"
}}
"""
```
### 2-3. daily_context.json 구조
```json
{
"date": "2026-05-13",
"generated_at": "08:05:22",
"trade_allowed": true,
"market_sentiment": "중립",
"sentiment_score": 62,
"risk_level": "보통",
"hot_sectors": ["반도체", "2차전지"],
"avoid_sectors": ["금융", "건설"],
"boosted_tickers": ["005930", "000660", "373220"],
"blacklist_tickers": [],
"position_size_multiplier": 1.0,
"reason": "외국인 반도체 순매수 지속, KOSPI 박스권 상단 접근 중립 판단"
}
```
### 2-4. 실행 엔진에서의 활용
```python
# 진입 조건에 AI 판단 필터 추가 (기존 조건 1~8 + 신규 9~11)
진입 조건 (전부 충족 시):
1. 현재가 >= 목표가 (변동성 돌파)
2. 현재 시각 09:00 ~ 14:30
3. KOSPI 등락률 > -1.0%
4. 전일 거래대금 >= 100억
5. 시가총액 1,000억 ~ 3조
6. VI 미발동
7. 보유 종목 수 < 2
8. 일일 누적 손실 < -3%
── AI 판단 필터 (신규) ──
9. daily_context["trade_allowed"] == true
10. 해당 종목 섹터가 avoid_sectors에 없음
11. 해당 종목이 blacklist_tickers에 없음
# 포지션 사이즈 조정
실제_투자비중 = 기본비중(20%) × position_size_multiplier
# sentiment_score 높을수록 multiplier 증가 (0.5~1.5)
# boosted_tickers 우선 진입
boosted 종목은 동일 신호 시 다른 종목보다 먼저 진입 처리
```
---
## 3. 기술 스택 (v2.0 확정)
| 항목 | 선택 | 이유 |
|------|------|------|
| 언어 | Python 3.11 | KIS 예제 코드 모두 Python |
| 비동기 | asyncio + aiohttp | rate limit 정밀 제어 |
| DB | SQLite | NAS 메모리 절약, 백업 단순 |
| 캐시 | Redis 7 (Docker) | 실시간 시세 캐시 |
| 스케줄러 | APScheduler (AsyncIOScheduler) | 장 이벤트 트리거 |
| AI 판단 | Claude claude-sonnet-4-20250514 | 뉴스/수급 분석 |
| 뉴스 수집 | aiohttp + BeautifulSoup4 | 네이버 금융 크롤링 |
| 알림 | python-telegram-bot | 텔레그램 단일 채널 |
| 대시보드 | Streamlit | NAS 내부망 접근 |
| 컨테이너 | Docker Compose | Synology Container Manager |
| 백테스트 | vectorbt | 분봉 데이터 고속 처리 |
| 데이터 수집 | pykrx + KIS REST | 과거 분봉 백필 |
---
## 4. 디렉토리 구조 (v2.0)
```
/volume1/docker/stockbot/
├── docker-compose.yml
├── .env
├── app/
│ ├── main.py ← 진입점, asyncio 루프
│ ├── config.py ← 전략 파라미터
│ │
│ ├── ai/ ← [신규] AI 판단 레이어
│ │ ├── context_builder.py ← 데이터 수집 + Claude API 호출
│ │ ├── news_crawler.py ← 네이버 금융 뉴스 크롤링
│ │ └── prompts.py ← 프롬프트 템플릿 관리
│ │
│ ├── data/
│ │ ├── collector.py ← KIS WebSocket 시세 수신
│ │ ├── universe.py ← 종목 풀 갱신 (08:30)
│ │ └── backfill.py ← 과거 분봉 백필
│ │
│ ├── strategy/
│ │ ├── base.py ← 전략 추상 클래스
│ │ └── volatility_breakout.py ← 변동성 돌파 전략 (AI 필터 포함)
│ │
│ ├── risk/
│ │ └── manager.py ← 손절/일일한도/강제청산
│ │
│ ├── execution/
│ │ ├── kis_client.py ← KIS REST/WebSocket 래퍼
│ │ └── order_executor.py ← 주문 전송, 재시도
│ │
│ ├── monitor/
│ │ ├── notifier.py ← 텔레그램 알림
│ │ └── dashboard.py ← Streamlit 대시보드
│ │
│ └── db/
│ ├── models.py ← SQLite 스키마
│ └── repository.py ← DB 접근 레이어
├── kill_switch/
│ └── kill.py
├── backtest/
│ ├── run_backtest.py
│ └── results/
├── data/
│ ├── stockbot.db
│ ├── universe_cache.json
│ └── daily_context.json ← [신규] AI 판단 결과 파일
└── logs/
├── trades.log
└── ai_context.log ← [신규] AI 판단 이력 보관
```
---
## 5. 전략 (확정: 변동성 돌파 + AI 필터)
### 핵심 파라미터 (config.py)
```python
# 변동성 돌파 파라미터
STRATEGY_K = 0.5
ENTRY_START = "09:00"
ENTRY_END = "14:30"
FORCE_EXIT = "14:50"
TP1_PCT = 0.02
TP2_PCT = 0.03
SL_PCT = 0.015
MAX_HOLD_MIN = 120
# AI 판단 파라미터 (신규)
AI_CONTEXT_PATH = "/app/data/daily_context.json"
AI_MIN_SCORE = 40 # sentiment_score 40 미만 시 trade_allowed=false 강제
AI_BOOST_MULTI = 1.5 # boosted_tickers 진입 비중 배율
AI_RISK_SL_MAP = { # risk_level별 손절 강화
"낮음": 0.015,
"보통": 0.015,
"높음": 0.010 # 고위험 장세엔 손절 타이트하게
}
```
### 진입 로직 (AI 필터 통합)
```
목표가 = 당일 시가 + (전일 고가 - 전일 저가) × K
진입 조건 (전부 충족):
[기술적 조건]
1. 현재가 >= 목표가
2. 09:00 ~ 14:30
3. KOSPI 등락률 > -1.0%
4. 전일 거래대금 >= 100억
5. 시가총액 1,000억 ~ 3조
6. VI 미발동
7. 보유 종목 수 < 2
8. 일일 누적 손실 < -3%
[AI 판단 조건]
9. trade_allowed == true
10. 종목 섹터 not in avoid_sectors
11. 종목 not in blacklist_tickers
```
### 청산 로직 (우선순위)
```
1순위: 14:50 강제 청산 (시장가)
2순위: 손절 → 현재가 <= 매수가 × (1 - SL_PCT) [risk_level에 따라 조정]
3순위: 1차 익절 → +TP1_PCT 도달 → 50% 지정가
4순위: 2차 익절 → +TP2_PCT 도달 → 전량 지정가
5순위: 시간 청산 → MAX_HOLD_MIN 경과 + 무수익
```
---
## 6. 종목 유니버스 (Universe)
매일 08:30 자동 갱신.
**1차 필터 (정적)**
| 조건 | 기준 |
|------|------|
| 시장 | 코스피 + 코스닥 |
| 시가총액 | 1,000억 ~ 3조 |
| 상장 기간 | 6개월 이상 |
| 제외 | 관리종목, 거래정지, 우선주, 스팩, ETF, ETN |
**2차 필터 (동적)**
| 조건 | 기준 |
|------|------|
| 전일 거래대금 | 100억 이상 |
| 5일 평균 거래대금 | 50억 이상 |
| 전일 등락률 | -3% ~ +15% |
| 60일 이평선 | 현재가 위 |
**AI 보정**
- boosted_tickers → 우선 감시 목록 상단 배치
- blacklist_tickers → 당일 유니버스에서 즉시 제거
- avoid_sectors → 해당 섹터 전체 진입 금지
**최대 감시 종목: 30개** (WebSocket 안정성 기준)
---
## 7. 리스크 관리
### 포지션 규칙
| 항목 | 기본값 | AI 조정 |
|------|--------|--------|
| 1종목 최대 비중 | 총자산 × 20% | × position_size_multiplier |
| 동시 보유 최대 | 2종목 | risk_level=높음 시 1종목 |
| 손절 기준 | -1.5% | risk_level=높음 시 -1.0% |
### 손실 한도 (계층별)
| 레벨 | 조건 | 동작 |
|------|------|------|
| L1 | 1회 매매 -1.5% | 즉시 손절 |
| L2 | 일일 누적 -3% | 당일 신규 진입 중단 |
| L3 | 3연속 손절 | 당일 매매 중단 |
| L4 | 주간 누적 -7% | 주말까지 중단 + 텔레그램 경고 |
| L5 | 월간 누적 -15% | 전략 폐기 + 백테스트 재실행 |
### 안전장치
- VI 발동 → 해당 종목 즉시 청산
- 호가 스프레드 > 0.5% → 진입 금지
- 매수 후 5분 미체결 → 주문 취소
- WebSocket 끊김 → 보유 포지션 즉시 시장가 청산
- API 오류 10건/분 → kill-switch 자동 실행
- Claude API 오류 → daily_context 없을 시 보수적 기본값으로 fallback
```python
# Claude API 장애 시 fallback
DEFAULT_CONTEXT = {
"trade_allowed": True,
"market_sentiment": "중립",
"sentiment_score": 50,
"risk_level": "보통",
"hot_sectors": [],
"avoid_sectors": [],
"boosted_tickers": [],
"blacklist_tickers": [],
"position_size_multiplier": 0.8, # 보수적으로 축소
"reason": "AI 판단 실패 - 기본값 적용"
}
```
---
## 8. KIS API 활용 전체 목록 (v2.0)
### 기존 (실행 레이어)
| API | 용도 | 방식 |
|-----|------|------|
| H0STCNT0 | 실시간 체결가 | WebSocket |
| H0STASP0 | 실시간 호가 | WebSocket |
| H0STVI0 | VI 발동/해제 | WebSocket |
| TTTC0802U | 주식 매수 주문 | REST POST |
| TTTC0801U | 주식 매도 주문 | REST POST |
| TTTC8001R | 잔고 조회 | REST GET |
### 신규 (AI 판단 레이어)
| API | 용도 | 수집 시각 |
|-----|------|---------|
| FHKST01010100 | 종목 현재가 (KOSPI/KOSDAQ 지수) | 07:40 |
| FHKST03010100 | 업종별 등락률 | 07:40 |
| FHPST01710000 | 거래량 순위 상위 30 | 07:40 |
| FHPST01700000 | 등락률 순위 상위 30 | 07:40 |
| FHKST04430000 | 외국인/기관 순매수 가집계 | 07:40 |
> 위 API 호출은 모두 장 시작 전 단 1회 → rate limit 부담 없음
---
## 9. 스케줄 타임라인 (v2.0)
```
07:30 │ [AI] 네이버 금융 뉴스 크롤링 (전일 헤드라인 수집)
07:40 │ [AI] KIS REST API → 수급/순위/업종 데이터 수집
08:00 │ [AI] Claude API 호출 → daily_context.json 생성
│ 텔레그램 알림: "[AI분석] 오늘 시장: 중립 / 반도체 주목"
08:30 │ Universe 갱신 + AI 블랙리스트 적용
08:50 │ WebSocket 연결, 30종목 구독
│ 목표가 계산 (변동성 돌파)
09:00 │ ─────────── 진입 허용 시작 ───────────
│ 1초 단위 asyncio 루프 시작
11:30 │ 신규 진입 중단 (점심)
13:00 │ 신규 진입 재개
14:30 │ 신규 진입 마감
14:50 │ ─────────── 강제 청산 ───────────────
15:00 │ WebSocket 종료
15:10 │ 당일 결산 로그
15:20 │ 텔레그램 일일 결산 알림
15:30 │ 대기
```
---
## 10. DB 스키마 (SQLite, v2.0)
```sql
-- 기존 체결 내역
CREATE TABLE trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
ticker TEXT NOT NULL,
entry_time TEXT NOT NULL,
exit_time TEXT,
entry_price REAL NOT NULL,
exit_price REAL,
quantity INTEGER NOT NULL,
side TEXT NOT NULL,
exit_reason TEXT,
pnl REAL,
fee REAL,
slippage REAL,
strategy TEXT DEFAULT 'VB',
ai_boosted INTEGER DEFAULT 0 -- [신규] AI boosted 여부
);
-- 기존 일일 요약
CREATE TABLE daily_summary (
date TEXT PRIMARY KEY,
total_trades INTEGER,
win_trades INTEGER,
lose_trades INTEGER,
gross_pnl REAL,
total_fee REAL,
net_pnl REAL,
max_drawdown REAL,
trading_stopped INTEGER DEFAULT 0
);
-- 포지션 (장중 현황)
CREATE TABLE positions (
ticker TEXT PRIMARY KEY,
entry_time TEXT,
entry_price REAL,
quantity INTEGER,
tp1_done INTEGER DEFAULT 0,
target_price REAL,
stop_price REAL
);
-- [신규] AI 판단 이력
CREATE TABLE ai_context_log (
date TEXT PRIMARY KEY,
generated_at TEXT,
trade_allowed INTEGER,
market_sentiment TEXT,
sentiment_score INTEGER,
risk_level TEXT,
hot_sectors TEXT, -- JSON 배열
avoid_sectors TEXT, -- JSON 배열
boosted_tickers TEXT, -- JSON 배열
blacklist_tickers TEXT, -- JSON 배열
position_size_mult REAL,
reason TEXT,
claude_tokens_used INTEGER,
api_call_success INTEGER DEFAULT 1
);
```
---
## 11. 알림 설계 (텔레그램, v2.0)
| 이벤트 | 메시지 형식 |
|--------|-----------|
| AI 분석 완료 | `[AI분석] 시장: 중립(62점) / 주목: 반도체,2차전지 / 회피: 금융` |
| AI 진입 차단 | `[AI차단] 삼성전자 진입 차단 - 금융 섹터 회피` |
| 매수 (일반) | `[매수] 삼성전자 74,000원 / 목표 75,480 / 손절 72,890` |
| 매수 (AI부스트) | `[매수★] 하이닉스 185,000원 / AI 추천 종목` |
| 1차 익절 | `[익절1] 삼성전자 +2.1% / 잔여 50%` |
| 손절 | `[손절] 삼성전자 -1.5% / 즉시 청산` |
| 강제 청산 | `[14:50 강제청산] 전 포지션 청산 완료` |
| L2 발동 | `[경고] 일일 손실 -3% 도달. 오늘 매매 중단.` |
| 일일 결산 | `[결산] 매매 5회 / 승 3 패 2 / 순손익 +1.2% / AI 정확도: 3/5` |
| 장애 | `[긴급] WebSocket 끊김. kill-switch 실행.` |
| AI 실패 | `[경고] AI 판단 실패. 기본값 적용 (비중 80%).` |
---
## 12. 비용 분석 (월간)
### Claude API 토큰 사용량 추정
| 작업 | 빈도 | 토큰/회 | 월간 토큰 |
|------|------|---------|---------|
| 일일 시장 판단 | 1회/일 × 22거래일 | ~3,000 | ~66,000 |
| fallback 재시도 | 0~2회/월 | ~3,000 | ~6,000 |
| **합계** | | | **~72,000 토큰/월** |
**월 비용 (Claude Sonnet 4): 약 $2~3 (한화 약 3,000~4,000원)**
### 전체 운영 비용
| 항목 | 월 비용 |
|------|--------|
| KIS API | 무료 |
| Claude API | ~$3 |
| 네이버 크롤링 | 무료 |
| Synology NAS 전기세 | 기존 운영 중이면 추가 없음 |
| **합계** | **~$3/월** |
---
## 13. 백테스트 계획 (v2.0)
### AI 판단 레이어 검증 방법
AI 판단은 백테스트에 직접 포함 불가 (과거 뉴스 재현 어려움).
따라서 2단계로 분리 검증:
```
1단계: 규칙 기반 백테스트 (기존)
- 변동성 돌파 전략 단독 성과 측정
- K값 최적화, 수수료/슬리피지 반영
2단계: AI 필터 사후 분석 (모의투자 후)
- 3개월 모의투자 후 daily_context.json 이력 vs 실제 결과 대조
- AI가 "avoid" 판단한 날 손실률 vs "trade_allowed" 날 수익률 비교
- AI 판단 정확도 계산 → 임계값 재조정
```
### 합격 기준 (out-of-sample)
| 지표 | 통과 기준 | 목표 |
|------|----------|------|
| 샤프지수 | > 1.0 | > 1.5 |
| MDD | < 15% | < 10% |
| 승률 | > 45% | > 55% |
| 손익비 | > 1.3 | > 1.8 |
| AI 차단 정확도 | > 55% | > 65% |
---
## 14. 인프라 (docker-compose.yml)
```yaml
version: "3.9"
services:
redis:
image: redis:7-alpine
container_name: stockbot-redis
restart: unless-stopped
volumes:
- ./data/redis:/data
stockbot:
build: ./app
container_name: stockbot-main
restart: unless-stopped
depends_on:
- redis
env_file: .env
volumes:
- ./data:/app/data
- ./logs:/app/logs
environment:
- TZ=Asia/Seoul
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
kill-switch:
build: ./kill_switch
container_name: stockbot-killswitch
restart: "no"
env_file: .env
profiles: ["emergency"]
dashboard:
build: ./monitor
container_name: stockbot-dashboard
restart: unless-stopped
ports:
- "8501:8501"
volumes:
- ./data:/app/data
environment:
- TZ=Asia/Seoul
```
### .env 구조 (v2.0)
```
# KIS API
KIS_APP_KEY=...
KIS_APP_SECRET=...
KIS_ACCOUNT_NO=...
KIS_MOCK=true
# Claude API (신규)
ANTHROPIC_API_KEY=...
CLAUDE_MODEL=claude-sonnet-4-20250514
# 텔레그램
TELEGRAM_TOKEN=...
TELEGRAM_CHAT_ID=...
# Redis
REDIS_HOST=stockbot-redis
REDIS_PORT=6379
# 운영 모드
DRY_RUN=true
LOG_LEVEL=INFO
AI_ENABLED=true # false 시 AI 레이어 비활성화, 기본값 사용
```
---
## 15. 개발 로드맵 (v2.0)
### Phase 1 — 데이터 기반 구축 (2주)
- [ ] KIS Open API 신청 + 모의투자 계좌
- [ ] pykrx 분봉 데이터 백필 (2021~2024)
- [ ] SQLite 스키마 (v2.0 포함)
- [ ] Universe 스캐너 구현
- [ ] Docker Compose NAS 기동 확인
### Phase 2 — 백테스트 (3주)
- [ ] vectorbt 변동성 돌파 백테스트
- [ ] 수수료/슬리피지 반영
- [ ] K값 그리드 서치 + out-of-sample 검증
### Phase 3 — KIS 연동 + dry-run (2주)
- [ ] KIS REST/WebSocket 클라이언트
- [ ] OrderExecutor (시장가/지정가/IOC)
- [ ] DRY_RUN=true 신호 발생 확인
### Phase 4 — AI 레이어 구현 (2주) ← 신규
- [ ] 네이버 금융 뉴스 크롤러 구현
- [ ] KIS 순위/수급 API 수집 모듈
- [ ] Claude API 연동 + 프롬프트 튜닝
- [ ] daily_context.json 생성 파이프라인
- [ ] fallback 로직 구현
- [ ] ai_context_log DB 저장
### Phase 5 — 리스크 + 알림 + 대시보드 (2주)
- [ ] RiskManager L1~L5 구현
- [ ] kill-switch 컨테이너
- [ ] 텔레그램 Notifier (AI 메시지 포함)
- [ ] Streamlit 대시보드 (AI 판단 현황 패널 추가)
### Phase 6 — 모의투자 실운영 (최소 3개월)
- [ ] KIS_MOCK=true, DRY_RUN=false
- [ ] 매일 결산 로그 + AI 정확도 추적
- [ ] 3개월 후 AI 필터 임계값 재조정
- [ ] 샤프 > 1.0, MDD < 15% 달성 시 Phase 7
### Phase 7 — 소액 실거래 (무기한)
- [ ] KIS_MOCK=false
- [ ] 총자산 5% 한도로 시작
- [ ] 1개월 단위 성과 검토
---
## 16. 보안 체크리스트
- [ ] .env → .gitignore 등록
- [ ] KIS/Anthropic API 키 → GitHub 절대 금지
- [ ] ANTHROPIC_API_KEY 월 1회 암호화 백업
- [ ] Synology 방화벽: 8501 내부망만 허용
- [ ] 텔레그램 봇: chat_id 화이트리스트
- [ ] 주문 로그 SQLite + logs/ 이중 보관
- [ ] AI 판단 이력 ai_context_log 영구 보관
---
## 17. 절대 금지 (하드코딩)
```python
BLACKLIST_REASONS = [
"신규상장 6개월 미만", "관리종목", "투자경고",
"거래정지", "우선주", "스팩", "ETF/ETN",
]
TRADING_BLACKOUT = [
("11:30", "13:00"), # 점심
("14:50", "15:30"), # 마감
("08:00", "09:00"), # 동시호가
]
HARD_EXIT_TIME = "14:50" # 절대 변경 불가
# AI가 trade_allowed=false 반환해도 이미 보유 중인 포지션 청산은 진행
# AI는 신규 진입만 차단, 청산 로직에는 관여하지 않음
AI_SCOPE = "ENTRY_ONLY" # 절대 변경 불가
```
---
## 18. 면책 조항
> 본 기획서는 시스템 설계 문서이며, 투자 수익을 보장하지 않는다.
> AI 판단 레이어는 보조 필터일 뿐, 수익을 보장하지 않는다.
> 단타는 개인투자자의 90% 이상이 손실을 보는 영역이다.
> 반드시 모의투자 3개월 이상 검증 후 실거래 전환할 것.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB