oracle_board_service_guide

📝 노트 2025-11-12
수정

오라클 프리티어 클라우드 보드판 웹 서비스 구축 가이드

목차

  1. 프로젝트 개요
  2. 오라클 클라우드 프리티어 설정
  3. 서버 환경 구축
  4. 애플리케이션 개발
  5. 데이터베이스 설계
  6. 배포 및 운영

1. 프로젝트 개요

1.1 서비스 설명

개인용 디지털 보드판 웹 서비스로, 사진과 포스트잇 형태의 메모를 자유롭게 배치하고 관리할 수 있는 애플리케이션입니다.

1.2 주요 기능

  • 사진 업로드 및 배치
  • 포스트잇 메모 생성 및 편집
  • 드래그 앤 드롭으로 위치 변경
  • 색상별 포스트잇 구분
  • 데이터베이스 기반 영구 저장

1.3 기술 스택

  • Backend: Python Flask
  • Database: SQLite (또는 PostgreSQL)
  • Frontend: HTML5, CSS3, JavaScript
  • Server: Oracle Cloud Infrastructure (OCI) Free Tier
  • OS: Ubuntu 22.04 LTS

2. 오라클 클라우드 프리티어 설정

2.1 오라클 클라우드 계정 생성

  1. Oracle Cloud 웹사이트 접속
  2. https://www.oracle.com/cloud/free/ 방문
  3. "Start for free" 버튼 클릭

  4. 계정 정보 입력

  5. 이메일 주소
  6. 국가 선택 (대한민국)
  7. 계정 이름 설정

  8. 신용카드 등록

  9. 본인 확인용 (프리티어는 무료)
  10. 자동 결제 없음 (명시적 업그레이드 필요)

  11. 이메일 인증 및 계정 활성화

2.2 Compute Instance 생성

  1. OCI 콘솔 로그인
  2. cloud.oracle.com 접속
  3. 생성한 계정으로 로그인

  4. 인스턴스 생성 메뉴 → Compute → Instances → Create Instance

  5. 인스턴스 설정

  6. Name: board-service
  7. Placement: 기본값 유지
  8. Image: Ubuntu 22.04
  9. Shape:
    • VM.Standard.A1.Flex (ARM 기반, 무료)
    • OCPU: 2
    • Memory: 12 GB
  10. Networking:

    • VCN: 기본 VCN 사용
    • Subnet: Public Subnet
    • Public IP: 자동 할당
  11. SSH 키 설정

  12. "Generate SSH key pair" 선택
  13. Private key 다운로드 (board-service.key)
  14. Public key 자동 등록

  15. 인스턴스 생성 완료

  16. "Create" 버튼 클릭
  17. 약 1-2분 후 인스턴스 실행 상태 확인
  18. Public IP 주소 메모

2.3 방화벽 설정

  1. Security List 설정 VCN Details → Security Lists → Default Security List

  2. Ingress Rules 추가

  3. HTTP 트래픽 허용

    • Source CIDR: 0.0.0.0/0
    • Destination Port: 80
  4. HTTPS 트래픽 허용

    • Source CIDR: 0.0.0.0/0
    • Destination Port: 443
  5. Flask 개발 서버 (선택사항)

    • Source CIDR: 0.0.0.0/0
    • Destination Port: 5000

3. 서버 환경 구축

3.1 SSH 접속

  1. SSH 키 권한 설정 (로컬 Mac/Linux) bash chmod 400 ~/Downloads/board-service.key

  2. SSH 접속 bash ssh -i ~/Downloads/board-service.key ubuntu@<PUBLIC_IP>

3.2 시스템 업데이트 및 기본 패키지 설치

# 시스템 업데이트
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

3.3 애플리케이션 디렉토리 생성

# 프로젝트 디렉토리 생성
mkdir -p ~/board-service
cd ~/board-service

# Python 가상환경 생성
python3 -m venv venv
source venv/bin/activate

# Flask 및 필요 라이브러리 설치
pip install flask flask-cors pillow gunicorn

4. 애플리케이션 개발

4.1 프로젝트 구조

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 데이터베이스

4.2 Flask 애플리케이션 (app.py)

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)

4.3 HTML 템플릿 (templates/index.html)

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

4.4 CSS 스타일 (static/css/style.css)

* {
    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);
}

4.5 JavaScript (static/js/board.js)

// 전역 변수
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('삭제 중 오류가 발생했습니다.');
    }
}

4.6 requirements.txt

Flask==3.0.0
flask-cors==4.0.0
Pillow==10.1.0
gunicorn==21.2.0

5. 데이터베이스 설계

5.1 데이터베이스 스키마

notes 테이블 (포스트잇)

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
);

images 테이블 (이미지)

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
);

5.2 데이터베이스 초기화

# Python에서 실행
python3 app.py
# 또는
python3 -c "from app import init_db; init_db()"

6. 배포 및 운영

6.1 Gunicorn 설정

  1. Gunicorn 설정 파일 생성 (gunicorn_config.py)
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
  1. 로그 디렉토리 생성
mkdir -p ~/board-service/logs

6.2 Systemd 서비스 설정

  1. 서비스 파일 생성
sudo nano /etc/systemd/system/board-service.service
  1. 서비스 설정 내용
[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
  1. 서비스 활성화 및 시작
sudo systemctl daemon-reload
sudo systemctl enable board-service
sudo systemctl start board-service
sudo systemctl status board-service

6.3 Nginx 리버스 프록시 설정

  1. Nginx 설정 파일 생성
sudo nano /etc/nginx/sites-available/board-service
  1. 설정 내용
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";
    }
}
  1. 설정 활성화
sudo ln -s /etc/nginx/sites-available/board-service /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx

6.4 SSL 인증서 설정 (선택사항 - 도메인이 있는 경우)

# Certbot 설치
sudo apt install certbot python3-certbot-nginx -y

# SSL 인증서 발급
sudo certbot --nginx -d yourdomain.com

# 자동 갱신 설정
sudo systemctl enable certbot.timer

6.5 서비스 관리 명령어

# 서비스 상태 확인
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

6.6 백업 및 복구

  1. 데이터베이스 백업 스크립트 (backup.sh)
#!/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"
  1. 백업 스크립트 실행 권한 부여
chmod +x backup.sh
  1. 자동 백업 설정 (Cron)
# Cron 편집
crontab -e

# 매일 새벽 2시에 백업 실행
0 2 * * * /home/ubuntu/board-service/backup.sh >> /home/ubuntu/board-service/logs/backup.log 2>&1

6.7 모니터링

  1. 서버 리소스 모니터링
# CPU, 메모리 사용량 확인
htop

# 디스크 사용량 확인
df -h

# 네트워크 연결 확인
netstat -tulpn | grep :80
  1. 애플리케이션 상태 확인 스크립트 (health_check.sh)
#!/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

7. 추가 기능 개발 아이디어

7.1 향후 개선 사항

  • 사용자 인증 (로그인 기능)
  • 다중 보드 지원
  • 태그 기능
  • 검색 기능
  • 모바일 반응형 개선
  • 실시간 협업 (WebSocket)
  • 파일 첨부 기능
  • PDF 내보내기

7.2 성능 최적화

  • 이미지 썸네일 생성
  • CDN 연동
  • 캐싱 전략
  • 데이터베이스 인덱싱

8. 문제 해결 가이드

8.1 자주 발생하는 문제

문제 1: 서비스가 시작되지 않음

# 로그 확인
sudo journalctl -u board-service -n 50

# Python 가상환경 활성화 확인
source ~/board-service/venv/bin/activate
which python3

# 수동 실행으로 오류 확인
cd ~/board-service
python3 app.py

문제 2: 이미지 업로드 실패

# 업로드 디렉토리 권한 확인
ls -la ~/board-service/static/uploads

# 권한 설정
chmod 755 ~/board-service/static/uploads

문제 3: Nginx 502 Bad Gateway

# Gunicorn 서비스 상태 확인
sudo systemctl status board-service

# 포트 사용 확인
sudo netstat -tulpn | grep 5000

# 방화벽 확인
sudo ufw status

8.2 디버깅 팁

  • 항상 로그 파일 먼저 확인
  • 브라우저 개발자 도구에서 네트워크 탭 확인
  • Python 가상환경이 올바르게 활성화되었는지 확인
  • 파일 권한 및 소유자 확인

9. 결론

이 가이드를 따라 오라클 프리티어 클라우드에서 개인용 보드판 웹 서비스를 성공적으로 구축할 수 있습니다.

주요 체크리스트

  • ✅ 오라클 클라우드 계정 생성
  • ✅ Compute Instance 설정
  • ✅ 방화벽 규칙 설정
  • ✅ Flask 애플리케이션 개발
  • ✅ 데이터베이스 구성
  • ✅ Gunicorn + Nginx 배포
  • ✅ 자동 백업 설정
  • ✅ 모니터링 설정

참고 자료

  • Oracle Cloud 공식 문서: https://docs.oracle.com/en-us/iaas/
  • Flask 공식 문서: https://flask.palletsprojects.com/
  • Gunicorn 문서: https://docs.gunicorn.org/
  • Nginx 문서: https://nginx.org/en/docs/

문서 작성일: 2025년 11월 12일
버전: 1.0

상세 정보
생성일: 2025-11-12
수정일: 2025-11-14
이 아이템이 링크하는 문서

링크된 문서가 없습니다.

이 아이템을 참조하는 문서

참조하는 문서가 없습니다.

태그
오라클
액션
수정
공유 & 관리
복제
목록으로 메인으로
마지막 수정: 2025-11-14 14:36
이미지 URL 수정됨