개인용 디지털 보드판 웹 서비스로, 사진과 포스트잇 형태의 메모를 자유롭게 배치하고 관리할 수 있는 애플리케이션입니다.
"Start for free" 버튼 클릭
계정 정보 입력
계정 이름 설정
신용카드 등록
자동 결제 없음 (명시적 업그레이드 필요)
이메일 인증 및 계정 활성화
생성한 계정으로 로그인
인스턴스 생성
메뉴 → Compute → Instances → Create Instance
인스턴스 설정
Networking:
SSH 키 설정
Public key 자동 등록
인스턴스 생성 완료
Security List 설정
VCN Details → Security Lists → Default Security List
Ingress Rules 추가
HTTP 트래픽 허용
HTTPS 트래픽 허용
Flask 개발 서버 (선택사항)
SSH 키 권한 설정 (로컬 Mac/Linux)
bash
chmod 400 ~/Downloads/board-service.key
SSH 접속
bash
ssh -i ~/Downloads/board-service.key ubuntu@<PUBLIC_IP>
# 시스템 업데이트
sudo apt update && sudo apt upgrade -y
# 필수 패키지 설치
sudo apt install -y python3 python3-pip python3-venv git nginx
# 방화벽 설정 (Ubuntu 내부)
sudo ufw allow 22 # SSH
sudo ufw allow 80 # HTTP
sudo ufw allow 443 # HTTPS
sudo ufw allow 5000 # Flask (개발용)
sudo ufw enable
# 프로젝트 디렉토리 생성
mkdir -p ~/board-service
cd ~/board-service
# Python 가상환경 생성
python3 -m venv venv
source venv/bin/activate
# Flask 및 필요 라이브러리 설치
pip install flask flask-cors pillow gunicorn
board-service/
├── app.py # Flask 메인 애플리케이션
├── config.py # 설정 파일
├── requirements.txt # Python 패키지 목록
├── static/
│ ├── css/
│ │ └── style.css # 스타일시트
│ ├── js/
│ │ └── board.js # 프론트엔드 JavaScript
│ └── uploads/ # 업로드된 이미지 저장
└── templates/
└── index.html # 메인 HTML 템플릿
└── database/
└── board.db # SQLite 데이터베이스
from flask import Flask, render_template, request, jsonify, send_from_directory
from flask_cors import CORS
import sqlite3
import os
from datetime import datetime
from werkzeug.utils import secure_filename
import uuid
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['UPLOAD_FOLDER'] = 'static/uploads'
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB 제한
CORS(app)
# 업로드 폴더 생성
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs('database', exist_ok=True)
# 허용된 파일 확장자
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_db_connection():
conn = sqlite3.connect('database/board.db')
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""데이터베이스 초기화"""
conn = get_db_connection()
cursor = conn.cursor()
# 포스트잇 테이블
cursor.execute('''
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
color TEXT DEFAULT 'yellow',
position_x INTEGER DEFAULT 100,
position_y INTEGER DEFAULT 100,
width INTEGER DEFAULT 200,
height INTEGER DEFAULT 200,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 이미지 테이블
cursor.execute('''
CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
position_x INTEGER DEFAULT 100,
position_y INTEGER DEFAULT 100,
width INTEGER DEFAULT 300,
height INTEGER DEFAULT 300,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
@app.route('/')
def index():
"""메인 페이지"""
return render_template('index.html')
@app.route('/api/notes', methods=['GET'])
def get_notes():
"""모든 포스트잇 조회"""
conn = get_db_connection()
notes = conn.execute('SELECT * FROM notes ORDER BY created_at DESC').fetchall()
conn.close()
return jsonify([dict(note) for note in notes])
@app.route('/api/notes', methods=['POST'])
def create_note():
"""포스트잇 생성"""
data = request.json
note_id = str(uuid.uuid4())
conn = get_db_connection()
conn.execute('''
INSERT INTO notes (id, content, color, position_x, position_y, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
note_id,
data.get('content', ''),
data.get('color', 'yellow'),
data.get('position_x', 100),
data.get('position_y', 100),
data.get('width', 200),
data.get('height', 200)
))
conn.commit()
conn.close()
return jsonify({'id': note_id, 'message': 'Note created successfully'}), 201
@app.route('/api/notes/<note_id>', methods=['PUT'])
def update_note(note_id):
"""포스트잇 수정"""
data = request.json
conn = get_db_connection()
conn.execute('''
UPDATE notes
SET content = ?, color = ?, position_x = ?, position_y = ?,
width = ?, height = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (
data.get('content'),
data.get('color'),
data.get('position_x'),
data.get('position_y'),
data.get('width'),
data.get('height'),
note_id
))
conn.commit()
conn.close()
return jsonify({'message': 'Note updated successfully'})
@app.route('/api/notes/<note_id>', methods=['DELETE'])
def delete_note(note_id):
"""포스트잇 삭제"""
conn = get_db_connection()
conn.execute('DELETE FROM notes WHERE id = ?', (note_id,))
conn.commit()
conn.close()
return jsonify({'message': 'Note deleted successfully'})
@app.route('/api/images', methods=['GET'])
def get_images():
"""모든 이미지 조회"""
conn = get_db_connection()
images = conn.execute('SELECT * FROM images ORDER BY created_at DESC').fetchall()
conn.close()
return jsonify([dict(image) for image in images])
@app.route('/api/images', methods=['POST'])
def upload_image():
"""이미지 업로드"""
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if file and allowed_file(file.filename):
original_filename = secure_filename(file.filename)
file_ext = original_filename.rsplit('.', 1)[1].lower()
filename = f"{uuid.uuid4()}.{file_ext}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
image_id = str(uuid.uuid4())
position_x = request.form.get('position_x', 100)
position_y = request.form.get('position_y', 100)
conn = get_db_connection()
conn.execute('''
INSERT INTO images (id, filename, original_filename, position_x, position_y)
VALUES (?, ?, ?, ?, ?)
''', (image_id, filename, original_filename, position_x, position_y))
conn.commit()
conn.close()
return jsonify({
'id': image_id,
'filename': filename,
'original_filename': original_filename,
'message': 'Image uploaded successfully'
}), 201
return jsonify({'error': 'Invalid file type'}), 400
@app.route('/api/images/<image_id>', methods=['PUT'])
def update_image(image_id):
"""이미지 위치 및 크기 수정"""
data = request.json
conn = get_db_connection()
conn.execute('''
UPDATE images
SET position_x = ?, position_y = ?, width = ?, height = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (
data.get('position_x'),
data.get('position_y'),
data.get('width'),
data.get('height'),
image_id
))
conn.commit()
conn.close()
return jsonify({'message': 'Image updated successfully'})
@app.route('/api/images/<image_id>', methods=['DELETE'])
def delete_image(image_id):
"""이미지 삭제"""
conn = get_db_connection()
image = conn.execute('SELECT filename FROM images WHERE id = ?', (image_id,)).fetchone()
if image:
# 파일 삭제
filepath = os.path.join(app.config['UPLOAD_FOLDER'], image['filename'])
if os.path.exists(filepath):
os.remove(filepath)
# DB에서 삭제
conn.execute('DELETE FROM images WHERE id = ?', (image_id,))
conn.commit()
conn.close()
return jsonify({'message': 'Image deleted successfully'})
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000, debug=True)
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>개인 보드판</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div id="board">
<!-- 동적으로 생성될 포스트잇과 이미지 -->
</div>
<div id="controls">
<button id="addNote" class="control-btn">📝 메모 추가</button>
<input type="file" id="imageUpload" accept="image/*" style="display: none;">
<button id="addImageBtn" class="control-btn">📷 사진 추가</button>
<button id="clearBoard" class="control-btn danger">🗑️ 전체 삭제</button>
</div>
<div id="colorPalette" style="display: none;">
<div class="color-option" data-color="yellow" style="background: #fef68a;"></div>
<div class="color-option" data-color="green" style="background: #a8e6a3;"></div>
<div class="color-option" data-color="blue" style="background: #a3d5e6;"></div>
<div class="color-option" data-color="pink" style="background: #ffc0cb;"></div>
</div>
<script src="{{ url_for('static', filename='js/board.js') }}"></script>
</body>
</html>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', sans-serif;
overflow: hidden;
background: url('https://images.unsplash.com/photo-1557682250-33bd709cbe85?w=1920') center/cover;
background-attachment: fixed;
}
#board {
width: 100vw;
height: 100vh;
position: relative;
background: linear-gradient(
rgba(210, 180, 140, 0.3) 1px,
transparent 1px
),
linear-gradient(
90deg,
rgba(210, 180, 140, 0.3) 1px,
transparent 1px
);
background-size: 20px 20px;
}
.note {
position: absolute;
min-width: 200px;
min-height: 200px;
padding: 15px;
border-radius: 3px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: move;
resize: both;
overflow: auto;
font-family: 'Nanum Pen Script', cursive;
font-size: 18px;
transition: transform 0.1s;
}
.note:hover {
transform: rotate(0deg) !important;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.note.yellow {
background: linear-gradient(135deg, #fef68a 0%, #f9d423 100%);
}
.note.green {
background: linear-gradient(135deg, #a8e6a3 0%, #56ab2f 100%);
}
.note.blue {
background: linear-gradient(135deg, #a3d5e6 0%, #4a90e2 100%);
}
.note.pink {
background: linear-gradient(135deg, #ffc0cb 0%, #ff758c 100%);
}
.note-content {
width: 100%;
height: 100%;
border: none;
background: transparent;
resize: none;
outline: none;
font-family: inherit;
font-size: inherit;
color: #333;
}
.note-delete {
position: absolute;
top: 5px;
right: 5px;
background: rgba(255, 0, 0, 0.7);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
display: none;
font-size: 16px;
line-height: 1;
}
.note:hover .note-delete {
display: block;
}
.note-color {
position: absolute;
bottom: 5px;
right: 5px;
background: white;
border: 1px solid #ddd;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
display: none;
}
.note:hover .note-color {
display: block;
}
.image-container {
position: absolute;
cursor: move;
border: 3px solid white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
border-radius: 5px;
overflow: hidden;
}
.image-container:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
}
.image-container img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.image-delete {
position: absolute;
top: 5px;
right: 5px;
background: rgba(255, 0, 0, 0.8);
color: white;
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
cursor: pointer;
display: none;
font-size: 18px;
line-height: 1;
}
.image-container:hover .image-delete {
display: block;
}
#controls {
position: fixed;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 2000;
}
.control-btn {
padding: 12px 20px;
background: white;
border: 2px solid #333;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: all 0.3s;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.control-btn:hover {
background: #f0f0f0;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.control-btn.danger {
background: #ff4444;
color: white;
border-color: #cc0000;
}
.control-btn.danger:hover {
background: #cc0000;
}
#colorPalette {
position: fixed;
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
gap: 10px;
z-index: 3000;
}
.color-option {
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
border: 3px solid #ddd;
transition: transform 0.2s;
}
.color-option:hover {
transform: scale(1.1);
border-color: #333;
}
/* 스크롤바 스타일 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
}
// 전역 변수
let draggedElement = null;
let offsetX = 0;
let offsetY = 0;
let currentNoteForColor = null;
// 페이지 로드시 초기화
document.addEventListener('DOMContentLoaded', function() {
loadNotes();
loadImages();
setupEventListeners();
});
// 이벤트 리스너 설정
function setupEventListeners() {
// 메모 추가 버튼
document.getElementById('addNote').addEventListener('click', createNote);
// 이미지 추가 버튼
document.getElementById('addImageBtn').addEventListener('click', function() {
document.getElementById('imageUpload').click();
});
// 이미지 업로드
document.getElementById('imageUpload').addEventListener('change', handleImageUpload);
// 전체 삭제 버튼
document.getElementById('clearBoard').addEventListener('click', clearBoard);
// 보드 클릭시 컬러 팔레트 숨기기
document.getElementById('board').addEventListener('click', function(e) {
if (e.target.id === 'board') {
document.getElementById('colorPalette').style.display = 'none';
}
});
// 컬러 팔레트 옵션
document.querySelectorAll('.color-option').forEach(option => {
option.addEventListener('click', function() {
const color = this.dataset.color;
if (currentNoteForColor) {
changeNoteColor(currentNoteForColor, color);
}
});
});
}
// 포스트잇 불러오기
async function loadNotes() {
try {
const response = await fetch('/api/notes');
const notes = await response.json();
notes.forEach(note => {
createNoteElement(note);
});
} catch (error) {
console.error('Error loading notes:', error);
}
}
// 이미지 불러오기
async function loadImages() {
try {
const response = await fetch('/api/images');
const images = await response.json();
images.forEach(image => {
createImageElement(image);
});
} catch (error) {
console.error('Error loading images:', error);
}
}
// 새 포스트잇 생성
async function createNote() {
const noteData = {
content: '새 메모',
color: 'yellow',
position_x: Math.random() * (window.innerWidth - 250),
position_y: Math.random() * (window.innerHeight - 250),
width: 200,
height: 200
};
try {
const response = await fetch('/api/notes', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(noteData)
});
const result = await response.json();
noteData.id = result.id;
createNoteElement(noteData);
} catch (error) {
console.error('Error creating note:', error);
}
}
// 포스트잇 DOM 요소 생성
function createNoteElement(noteData) {
const note = document.createElement('div');
note.className = `note ${noteData.color}`;
note.dataset.id = noteData.id;
note.style.left = noteData.position_x + 'px';
note.style.top = noteData.position_y + 'px';
note.style.width = noteData.width + 'px';
note.style.height = noteData.height + 'px';
note.style.transform = `rotate(${Math.random() * 6 - 3}deg)`;
const textarea = document.createElement('textarea');
textarea.className = 'note-content';
textarea.value = noteData.content;
textarea.addEventListener('blur', function() {
updateNote(noteData.id, {
content: this.value,
color: noteData.color,
position_x: parseInt(note.style.left),
position_y: parseInt(note.style.top),
width: note.offsetWidth,
height: note.offsetHeight
});
});
const deleteBtn = document.createElement('button');
deleteBtn.className = 'note-delete';
deleteBtn.innerHTML = '×';
deleteBtn.addEventListener('click', function() {
deleteNote(noteData.id, note);
});
const colorBtn = document.createElement('button');
colorBtn.className = 'note-color';
colorBtn.innerHTML = '🎨';
colorBtn.addEventListener('click', function(e) {
e.stopPropagation();
showColorPalette(e.clientX, e.clientY, noteData.id);
});
note.appendChild(textarea);
note.appendChild(deleteBtn);
note.appendChild(colorBtn);
// 드래그 기능
note.addEventListener('mousedown', startDrag);
document.getElementById('board').appendChild(note);
}
// 포스트잇 업데이트
async function updateNote(noteId, data) {
try {
await fetch(`/api/notes/${noteId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
} catch (error) {
console.error('Error updating note:', error);
}
}
// 포스트잇 삭제
async function deleteNote(noteId, element) {
if (confirm('이 메모를 삭제하시겠습니까?')) {
try {
await fetch(`/api/notes/${noteId}`, {
method: 'DELETE'
});
element.remove();
} catch (error) {
console.error('Error deleting note:', error);
}
}
}
// 컬러 팔레트 표시
function showColorPalette(x, y, noteId) {
const palette = document.getElementById('colorPalette');
palette.style.display = 'flex';
palette.style.left = x + 'px';
palette.style.top = y + 'px';
currentNoteForColor = noteId;
}
// 포스트잇 색상 변경
function changeNoteColor(noteId, color) {
const note = document.querySelector(`.note[data-id="${noteId}"]`);
if (note) {
note.className = `note ${color}`;
updateNote(noteId, {
content: note.querySelector('.note-content').value,
color: color,
position_x: parseInt(note.style.left),
position_y: parseInt(note.style.top),
width: note.offsetWidth,
height: note.offsetHeight
});
}
document.getElementById('colorPalette').style.display = 'none';
}
// 이미지 업로드 처리
async function handleImageUpload(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('position_x', Math.random() * (window.innerWidth - 350));
formData.append('position_y', Math.random() * (window.innerHeight - 350));
try {
const response = await fetch('/api/images', {
method: 'POST',
body: formData
});
const result = await response.json();
createImageElement({
id: result.id,
filename: result.filename,
position_x: parseInt(formData.get('position_x')),
position_y: parseInt(formData.get('position_y')),
width: 300,
height: 300
});
e.target.value = '';
} catch (error) {
console.error('Error uploading image:', error);
alert('이미지 업로드에 실패했습니다.');
}
}
// 이미지 DOM 요소 생성
function createImageElement(imageData) {
const container = document.createElement('div');
container.className = 'image-container';
container.dataset.id = imageData.id;
container.style.left = imageData.position_x + 'px';
container.style.top = imageData.position_y + 'px';
container.style.width = (imageData.width || 300) + 'px';
container.style.height = (imageData.height || 300) + 'px';
const img = document.createElement('img');
img.src = `/static/uploads/${imageData.filename}`;
img.alt = imageData.original_filename || 'Image';
const deleteBtn = document.createElement('button');
deleteBtn.className = 'image-delete';
deleteBtn.innerHTML = '×';
deleteBtn.addEventListener('click', function() {
deleteImage(imageData.id, container);
});
container.appendChild(img);
container.appendChild(deleteBtn);
// 드래그 기능
container.addEventListener('mousedown', startDrag);
document.getElementById('board').appendChild(container);
}
// 이미지 삭제
async function deleteImage(imageId, element) {
if (confirm('이 이미지를 삭제하시겠습니까?')) {
try {
await fetch(`/api/images/${imageId}`, {
method: 'DELETE'
});
element.remove();
} catch (error) {
console.error('Error deleting image:', error);
}
}
}
// 드래그 시작
function startDrag(e) {
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON') {
return;
}
draggedElement = e.currentTarget;
offsetX = e.clientX - draggedElement.offsetLeft;
offsetY = e.clientY - draggedElement.offsetTop;
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
e.preventDefault();
}
// 드래그 중
function drag(e) {
if (draggedElement) {
const x = e.clientX - offsetX;
const y = e.clientY - offsetY;
draggedElement.style.left = Math.max(0, Math.min(x, window.innerWidth - draggedElement.offsetWidth)) + 'px';
draggedElement.style.top = Math.max(0, Math.min(y, window.innerHeight - draggedElement.offsetHeight)) + 'px';
}
}
// 드래그 종료
function stopDrag() {
if (draggedElement) {
const id = draggedElement.dataset.id;
const isNote = draggedElement.classList.contains('note');
const data = {
position_x: parseInt(draggedElement.style.left),
position_y: parseInt(draggedElement.style.top),
width: draggedElement.offsetWidth,
height: draggedElement.offsetHeight
};
if (isNote) {
data.content = draggedElement.querySelector('.note-content').value;
data.color = Array.from(draggedElement.classList).find(c => c !== 'note') || 'yellow';
updateNote(id, data);
} else {
updateImage(id, data);
}
draggedElement = null;
}
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', stopDrag);
}
// 이미지 업데이트
async function updateImage(imageId, data) {
try {
await fetch(`/api/images/${imageId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
} catch (error) {
console.error('Error updating image:', error);
}
}
// 전체 삭제
async function clearBoard() {
if (!confirm('모든 메모와 이미지를 삭제하시겠습니까?')) {
return;
}
try {
// 모든 노트 삭제
const notes = document.querySelectorAll('.note');
for (const note of notes) {
await fetch(`/api/notes/${note.dataset.id}`, { method: 'DELETE' });
}
// 모든 이미지 삭제
const images = document.querySelectorAll('.image-container');
for (const image of images) {
await fetch(`/api/images/${image.dataset.id}`, { method: 'DELETE' });
}
// DOM에서 제거
document.getElementById('board').innerHTML = '';
alert('모든 항목이 삭제되었습니다.');
} catch (error) {
console.error('Error clearing board:', error);
alert('삭제 중 오류가 발생했습니다.');
}
}
Flask==3.0.0
flask-cors==4.0.0
Pillow==10.1.0
gunicorn==21.2.0
CREATE TABLE notes (
id TEXT PRIMARY KEY, -- UUID
content TEXT NOT NULL, -- 메모 내용
color TEXT DEFAULT 'yellow', -- 색상 (yellow/green/blue/pink)
position_x INTEGER DEFAULT 100, -- X 좌표
position_y INTEGER DEFAULT 100, -- Y 좌표
width INTEGER DEFAULT 200, -- 너비
height INTEGER DEFAULT 200, -- 높이
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE images (
id TEXT PRIMARY KEY, -- UUID
filename TEXT NOT NULL, -- 저장된 파일명
original_filename TEXT NOT NULL, -- 원본 파일명
position_x INTEGER DEFAULT 100, -- X 좌표
position_y INTEGER DEFAULT 100, -- Y 좌표
width INTEGER DEFAULT 300, -- 너비
height INTEGER DEFAULT 300, -- 높이
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
# Python에서 실행
python3 app.py
# 또는
python3 -c "from app import init_db; init_db()"
import multiprocessing
bind = "0.0.0.0:5000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
worker_connections = 1000
timeout = 30
keepalive = 2
# 로깅
accesslog = "/home/ubuntu/board-service/logs/access.log"
errorlog = "/home/ubuntu/board-service/logs/error.log"
loglevel = "info"
# 프로세스 이름
proc_name = "board-service"
# 재시작
max_requests = 1000
max_requests_jitter = 100
mkdir -p ~/board-service/logs
sudo nano /etc/systemd/system/board-service.service
[Unit]
Description=Board Service Flask Application
After=network.target
[Service]
Type=notify
User=ubuntu
Group=ubuntu
WorkingDirectory=/home/ubuntu/board-service
Environment="PATH=/home/ubuntu/board-service/venv/bin"
ExecStart=/home/ubuntu/board-service/venv/bin/gunicorn \
--config gunicorn_config.py \
app:app
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable board-service
sudo systemctl start board-service
sudo systemctl status board-service
sudo nano /etc/nginx/sites-available/board-service
server {
listen 80;
server_name <YOUR_PUBLIC_IP>;
client_max_body_size 20M;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 지원 (필요시)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /static {
alias /home/ubuntu/board-service/static;
expires 30d;
add_header Cache-Control "public, immutable";
}
}
sudo ln -s /etc/nginx/sites-available/board-service /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
# Certbot 설치
sudo apt install certbot python3-certbot-nginx -y
# SSL 인증서 발급
sudo certbot --nginx -d yourdomain.com
# 자동 갱신 설정
sudo systemctl enable certbot.timer
# 서비스 상태 확인
sudo systemctl status board-service
# 서비스 재시작
sudo systemctl restart board-service
# 로그 확인
sudo journalctl -u board-service -f
# Nginx 로그 확인
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log
# 애플리케이션 로그 확인
tail -f ~/board-service/logs/access.log
tail -f ~/board-service/logs/error.log
#!/bin/bash
BACKUP_DIR="/home/ubuntu/backups"
DB_PATH="/home/ubuntu/board-service/database/board.db"
UPLOAD_DIR="/home/ubuntu/board-service/static/uploads"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
# 데이터베이스 백업
cp $DB_PATH "$BACKUP_DIR/board_db_$DATE.db"
# 업로드 파일 백업
tar -czf "$BACKUP_DIR/uploads_$DATE.tar.gz" -C $(dirname $UPLOAD_DIR) $(basename $UPLOAD_DIR)
# 7일 이상 된 백업 삭제
find $BACKUP_DIR -name "board_db_*.db" -mtime +7 -delete
find $BACKUP_DIR -name "uploads_*.tar.gz" -mtime +7 -delete
echo "Backup completed: $DATE"
chmod +x backup.sh
# Cron 편집
crontab -e
# 매일 새벽 2시에 백업 실행
0 2 * * * /home/ubuntu/board-service/backup.sh >> /home/ubuntu/board-service/logs/backup.log 2>&1
# CPU, 메모리 사용량 확인
htop
# 디스크 사용량 확인
df -h
# 네트워크 연결 확인
netstat -tulpn | grep :80
#!/bin/bash
URL="http://localhost:5000"
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" $URL)
if [ $RESPONSE -eq 200 ]; then
echo "Service is running normally"
else
echo "Service is down! HTTP Status: $RESPONSE"
sudo systemctl restart board-service
echo "Service restarted at $(date)"
fi
# 로그 확인
sudo journalctl -u board-service -n 50
# Python 가상환경 활성화 확인
source ~/board-service/venv/bin/activate
which python3
# 수동 실행으로 오류 확인
cd ~/board-service
python3 app.py
# 업로드 디렉토리 권한 확인
ls -la ~/board-service/static/uploads
# 권한 설정
chmod 755 ~/board-service/static/uploads
# Gunicorn 서비스 상태 확인
sudo systemctl status board-service
# 포트 사용 확인
sudo netstat -tulpn | grep 5000
# 방화벽 확인
sudo ufw status
이 가이드를 따라 오라클 프리티어 클라우드에서 개인용 보드판 웹 서비스를 성공적으로 구축할 수 있습니다.
문서 작성일: 2025년 11월 12일
버전: 1.0