프로젝트 개요

KT AIVLE School에서 Django 기반 웹 개발 경험을 쌓은 뒤, 직접 개인 포트폴리오 겸 블로그 사이트를 처음부터 설계·구현·배포한 개인 프로젝트입니다. 단순히 기술을 연습하는 것을 넘어, 실제로 운영 가능한 나만의 웹 공간을 만드는 것을 목표로 했습니다.

이후 Claude AI와 협업하여 현재의 풀스택 포트폴리오 사이트(이 사이트)로 발전시켰습니다.

항목 내용
기간 2025.06 ~ (진행 중)
규모 개인 프로젝트
기술 스택 Python 3.x, Django 4.2, SQLite, CKEditor 6, OpenAI API
배포 환경 AWS EC2 (ap-northeast-2), Nginx, uWSGI, Route53, ACM
GitHub Django-blog-public
운영 URL devprofessional.xyz

1. 기획 — 기능 우선순위 정의

아이디어 구체화 단계에서 수기 스케치로 구조를 잡은 뒤, Figma로 화면 배치와 UX 흐름을 설계했습니다. 포트폴리오 사이트로서의 목적과 블로그로서의 목적을 모두 충족해야 했기 때문에, 핵심 기능을 먼저 구현하고 단계적으로 확장하는 방식을 선택했습니다.

[기능 우선순위]

필수(MVP) ──────────────────────────────
  1. Figma 기반 UI/UX 설계
  2. DB(models.py) 설계 및 마이그레이션
  3. 게시글 CRUD — 포스트 생성·조회·수정·삭제
  4. CKEditor — 리치 텍스트 편집 + 이미지 업로드

추가 기능 ──────────────────────────────
  5. 자료실 — PDF 업로드 및 다운로드
  6. 프로필 / 이력 / 소개 페이지
  7. 방명록 (GuestBook)
  8. 검색 + 카테고리 필터

고도화 ─────────────────────────────────
  9. RAG 기반 AI 챗봇 (OpenAI API)
  10. AWS EC2 배포 + 도메인 연결
  11. 모바일 반응형

2. 프로젝트 구조

myblog/                         ← Django 프로젝트 루트
├── manage.py
├── requirements.txt
├── .env                        ← API 키 등 환경변수 (git 제외)
│
├── myblog/                     ← 프로젝트 설정 패키지
│   ├── settings.py
│   ├── urls.py                 ← 루트 URL 라우터
│   ├── wsgi.py                 ← 배포용 WSGI 엔트리포인트
│   └── views.py                ← 404 핸들러
│
├── blog/                       ← 메인 앱
│   ├── models.py               ← Posts, Statements, Document, RagContent, GuestbookEntry
│   ├── views.py                ← 모든 뷰 함수
│   ├── urls.py                 ← 앱 URL 패턴
│   ├── forms.py                ← ModelForm (PostForm, GuestbookEntryForm)
│   └── admin.py
│
├── templates/                  ← 전체 HTML 템플릿
│   ├── blog/
│   │   ├── home.html           ← 메인 페이지 (최근 글 슬라이더)
│   │   ├── profile.html        ← 자기소개 페이지
│   │   ├── post_list.html      ← 글 목록 + 검색
│   │   ├── post_detail.html    ← 글 상세 (이전글/다음글)
│   │   ├── create.html         ← 글 작성 (CKEditor)
│   │   ├── documents.html      ← 자료실
│   │   ├── guestbook.html      ← 방명록
│   │   └── rag.html            ← AI 챗봇 페이지
│   └── 404.html
│
├── static/                     ← CSS, JS, 이미지
├── staticfiles/                ← collectstatic 결과물 (배포용)
└── media/                      ← 사용자 업로드 파일
    ├── uploads/                ← CKEditor 업로드 이미지
    └── documents/              ← 자료실 PDF
        ├── resume/
        ├── project/
        └── certification/

3. 데이터 모델 설계

각 모델은 독립 엔티티 구조로 설계하여 앱 단일 책임 원칙을 지켰습니다.

┌─────────────────────────┐   ┌────────────────────────┐
│         Posts           │   │       Statements        │
├─────────────────────────┤   ├────────────────────────┤
│ id          (PK)        │   │ id          (PK)        │
│ title       CharField   │   │ title       CharField   │
│ content     RichTextField│   │ sub_title   CharField   │
│ type        blog/portf. │   │ content     RichTextField│
│ image       ImageField  │   │ type        public/corp │
│ github_url  URLField    │   │ corp_name   CharField   │
│ reference_url URLField  │   │ created_time datetime   │
│ created_time datetime   │   └────────────────────────┘
│ updated_time datetime   │
└─────────────────────────┘   ┌────────────────────────┐
                              │       Document          │
┌─────────────────────────┐   ├────────────────────────┤
│      GuestbookEntry     │   │ id          (PK)        │
├─────────────────────────┤   │ title       CharField   │
│ id          (PK)        │   │ doc_type    resume/proj │
│ name        CharField   │   │ file        FileField   │
│ message     TextField   │   │ original_filename       │
│ created_at  datetime    │   │ uploaded_time datetime  │
└─────────────────────────┘   └────────────────────────┘

┌─────────────────────────┐
│       RagContent        │   ← AI 챗봇용 컨텍스트 DB
├─────────────────────────┤
│ id          (PK)        │
│ title       CharField   │
│ content     TextField   │
│ category    CharField   │
│ created_at  datetime    │
└─────────────────────────┘

models.py 핵심 구현

from ckeditor_uploader.fields import RichTextUploadingField
import unicodedata, uuid, os

# 파일명 한글 깨짐 방지: 유니코드 → ASCII 변환 후 UUID 접미사 추가
def upload_to_path(instance, filename):
    ext = filename.split(".")[-1]
    base = (
        unicodedata.normalize("NFKD", instance.title)
        .encode("ascii", "ignore")
        .decode("ascii")
    )
    base = base.replace(" ", "_") or "file"
    new_filename = f"{base}_{uuid.uuid4().hex[:8]}.{ext}"
    return f"documents/{instance.doc_type}/{new_filename}"  # doc_type별 폴더 분리

class Posts(models.Model):
    POST_TYPES = (("blog", "BLOG"), ("portfolio", "PORTFOLIO"))
    title   = models.CharField(max_length=200)
    content = RichTextUploadingField(blank=True, null=True)  # CKEditor 리치텍스트
    type    = models.CharField(max_length=20, choices=POST_TYPES, default="blog")
    image   = models.ImageField(upload_to="", blank=True, null=True)
    github_url    = models.URLField(blank=True, null=True)
    reference_url = models.URLField(blank=True, null=True)
    created_time  = models.DateTimeField(auto_now_add=True)
    updated_time  = models.DateTimeField(auto_now=True)

class RagContent(models.Model):
    # RAG 챗봇이 참조하는 자기소개·프로젝트 요약 DB
    title    = models.CharField(max_length=255)
    content  = models.TextField()
    category = models.CharField(max_length=50)  # "profile", "project", "skill" 등

4. URL 라우팅 구조

[루트 urls.py]                  [blog/urls.py (app_name="blog")]

/                 → landing     blog/home/            → home
/home/            → home        blog/list/            → post_list
/admin/           → Django admin blog/list_portfolio/ → list_portfolio
/blog/ ──────────────────────▶  blog/create/          → create_post
/ckeditor/        → CKEditor    blog/detail/<int:pk>  → post_detail
                                blog/documents/       → documents
                                blog/documents/download/<int:pk> → download
                                blog/profile/         → profile
                                blog/guestbook/       → guestbook
                                blog/rag_chat/        → rag_chat_view
                                blog/statement_detail/<int:pk> → statement_detail

랜딩 로직: 첫 방문 시 프로필 페이지 → 이후 재방문은 홈 바로 이동 (세션 활용)

def landing_view(request):
    if not request.session.get("visited_profile"):
        request.session["visited_profile"] = True
        return redirect("blog:profile_first")  # 첫 방문: 프로필 먼저
    return redirect("home")                    # 재방문: 홈 바로 이동

5. 핵심 기능 구현

5-1. 게시글 CRUD

조회 (Read) — 목록 + 검색 + 타입 필터

def post_list(request):
    # GET 파라미터로 타입 필터 (blog / portfolio)
    post_type = request.GET.get("type")
    all_posts = (
        models.Posts.objects.filter(type=post_type).order_by("-created_time")
        if post_type
        else models.Posts.objects.all().order_by("-created_time")
    )

    # POST 요청으로 키워드 검색 — 제목 OR 본문 모두 검색
    if request.method == "POST":
        search_kwd = request.POST.get("search", "")
        if search_kwd:
            all_posts = models.Posts.objects.filter(
                Q(title__icontains=search_kwd) | Q(content__icontains=search_kwd)
            )
    return render(request, "blog/post_list.html", {"all_posts": all_posts})

def post_detail(request, pk):
    post = get_object_or_404(models.Posts, pk=pk)
    # 이전글/다음글: 생성 시간 기준으로 인접 포스트 쿼리
    prev_post = models.Posts.objects.filter(
        created_time__lt=post.created_time).order_by("-created_time").first()
    next_post = models.Posts.objects.filter(
        created_time__gt=post.created_time).order_by("created_time").first()
    return render(request, "blog/post_detail.html",
                  {"post": post, "prev_post": prev_post, "next_post": next_post})

작성 (Create) — ModelForm 활용

# forms.py
class PostForm(forms.ModelForm):
    class Meta:
        model  = Posts
        fields = "__all__"  # 모든 필드 노출 (admin 전용 작성)

# views.py
def create_post(request):
    if request.method == "POST":
        form = PostForm(request.POST, request.FILES)  # FILES: 이미지 업로드 포함
        if form.is_valid():
            form.save()
            return redirect("blog:list")
    else:
        form = PostForm()
    return render(request, "blog/create.html", {"form": form})

5-2. CKEditor 리치텍스트 에디터

게시글 작성 시 폰트·색상·이미지·코드 블록 등을 지원하는 WYSIWYG 에디터입니다.

settings.py 커스텀 툴바 설정

CKEDITOR_UPLOAD_PATH = "uploads/"  # 미디어 업로드 경로

CKEDITOR_CONFIGS = {
    "default": {
        "toolbar": "Custom",
        "toolbar_Custom": [
            ["Bold", "Italic", "Underline"],
            ["Font", "FontSize"],
            ["TextColor", "BGColor"],           # 글자색 / 배경색
            ["JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"],
            ["NumberedList", "BulletedList"],
            ["Link", "Unlink"],
            ["Image", "Embed"],                  # 이미지·영상 삽입
            ["CodeSnippet", "Maximize"],         # 코드 블록
            ["RemoveFormat", "Source"],
        ],
        "extraPlugins": ",".join([
            "codesnippet", "justify", "colorbutton",
            "colordialog", "horizontalrule", "font",
        ]),
        "height": 400,
        "width": "100%",
    }
}

작동 흐름:

사용자 작성 → [CKEditor 에디터]
                    │
              이미지 삽입 시
                    │
              POST /ckeditor/upload/
                    │
              media/uploads/ 에 저장
                    │
              <img src="/media/uploads/..."> 태그 삽입
                    │
              form.save() → DB에 HTML 저장

5-3. 자료실 — 파일 다운로드

한글 파일명을 URL-safe하게 처리하는 것이 핵심 과제였습니다.

def download_document(request, document_id):
    doc      = get_object_or_404(Document, pk=document_id)
    file_path = doc.file.path
    response  = FileResponse(open(file_path, "rb"))

    # 한글 파일명 처리: RFC 5987 인코딩 (filename*=UTF-8'')
    filename = quote(doc.original_filename)
    response["Content-Disposition"] = f"attachment; filename*=UTF-8''{filename}"
    return response

파일 저장 경로도 한글·특수문자 처리를 고려해 설계했습니다.

def upload_to_path(instance, filename):
    # 1. 유니코드 정규화 (NFKD) → ASCII 변환으로 한글 제거
    base = unicodedata.normalize("NFKD", instance.title).encode("ascii","ignore").decode("ascii")
    base = base.replace(" ", "_") or "file"
    # 2. UUID 8자리 접미사로 파일명 충돌 방지
    new_filename = f"{base}_{uuid.uuid4().hex[:8]}.{filename.split('.')[-1]}"
    # 3. doc_type별 폴더 자동 분류: documents/resume/, documents/project/ 등
    return f"documents/{instance.doc_type}/{new_filename}"

5-4. RAG 기반 AI 챗봇

DB에 저장된 자기소개·프로젝트 요약 텍스트를 컨텍스트로 주입하여, 방문자가 "이 사람이 어떤 프로젝트를 했나요?" 같은 질문을 할 수 있는 챗봇입니다.

구조:

[방문자 질문 입력]
        │
  POST /blog/rag_chat/
        │
  RagContent.objects.all()  ← DB에서 자기소개·프로젝트 텍스트 전체 조회
        │
  context_text 생성 (title + content 연결)
        │
  GPT-4 API 호출
  ┌─────────────────────────────────┐
  │ System: 아래는 나의 자기소개 및 │
  │ 프로젝트 요약입니다.            │
  │ {context_text}                  │
  │ 질문: {user_question}           │
  └─────────────────────────────────┘
        │
  answer 렌더링

구현 코드:

@csrf_exempt
def rag_chat_view(request):
    if request.method == "POST":
        user_question = request.POST.get("question")

        # 세션 기반 사용 횟수 제한 (10건/세션)
        session_key = "rag_question_count"
        request.session[session_key] = request.session.get(session_key, 0) + 1
        if request.session[session_key] > 10:
            return render(request, "blog/rag.html", {"answer": "질문 가능 횟수 초과(10건)"})

        # DB에서 RAG 컨텍스트 조회
        rag_texts    = RagContent.objects.all()
        context_text = "

".join(f"{obj.title}:
{obj.content}" for obj in rag_texts)

        prompt = (
            "아래는 나의 자기소개서 및 프로젝트, 프로필 요약입니다.\n"
            "이 정보를 바탕으로 사용자의 질문에 응답해주세요.\n"
            "-----\n"
            "{context_text}\n"
            "-----\n"
            "질문: {user_question}"
        )
        response = client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.7,
        )
        answer = response.choices[0].message.content
        return render(request, "blog/rag.html", {"question": user_question, "answer": answer})

5-5. 방명록

페이지네이션을 적용하여 방문자 메시지가 늘어도 성능 저하 없이 표시됩니다.

def guestbook(request):
    entries = GuestbookEntry.objects.order_by("-created_at")
    form    = GuestbookEntryForm(request.POST or None)

    if request.method == "POST" and form.is_valid():
        form.save()
        return redirect("blog:guestbook")  # PRG 패턴으로 새로고침 중복 제출 방지

    paginator   = Paginator(entries, 10)   # 페이지당 10건
    page_number = request.GET.get("page")
    page_obj    = paginator.get_page(page_number)
    return render(request, "blog/guestbook.html", {"form": form, "entries": page_obj})

6. AWS 배포 아키텍처

인터넷
  │
  ▼
[Route53]
  도메인: devprofessional.xyz
  A 레코드 → ALB (또는 EC2 Public IP)
  │
  ▼
[ACM 인증서]
  *.devprofessional.xyz 와일드카드 인증서
  (현재 HTTP 운영 중 / ALB 443 리스너 구성 예정)
  │
  ▼
[EC2 Instance]  ap-northeast-2 (서울 리전)
  OS: Ubuntu
  ┌─────────────────────────────────┐
  │  Nginx (리버스 프록시, 포트 80) │
  │    │                           │
  │    ├── /static/ → staticfiles/ │  (정적 파일 직접 서빙)
  │    ├── /media/  → media/       │  (미디어 직접 서빙)
  │    └── 그 외    → uWSGI 소켓  │
  │              │                 │
  │         [uWSGI]                │
  │    Django WSGI 앱 실행          │
  │    (myblog/wsgi.py)            │
  └─────────────────────────────────┘
  │
  ▼
[SQLite DB]
  db.sqlite3 (EC2 로컬, 정기 백업)

Nginx 설정 핵심:

server {
    listen 80;
    server_name devprofessional.xyz www.devprofessional.xyz;

    # 정적 파일 직접 서빙 (Django 우회 → 빠른 응답)
    location /static/ {
        alias /home/ubuntu/myblog/staticfiles/;
    }
    location /media/ {
        alias /home/ubuntu/myblog/media/;
    }

    # 그 외 요청은 uWSGI로 프록시
    location / {
        include     uwsgi_params;
        uwsgi_pass  unix:/run/uwsgi/myblog.sock;  # 유닉스 소켓
    }
}

배포 프로세스:

[로컬 개발]
     │ git push
     ▼
[GitHub repository]
     │ git pull (수동)
     ▼
[EC2 인스턴스]
     │
     ├── python manage.py migrate
     ├── python manage.py collectstatic
     └── sudo systemctl restart uwsgi
              │
              ▼
        [서비스 반영]

7. 주요 설정 정리

settings.py 배포/개발 환경 분리:

# 개발 환경
DEBUG        = True
ALLOWED_HOSTS = ["*"]

# 배포 환경 (주석 처리 전환)
# DEBUG = False
# ALLOWED_HOSTS = [
#     '.ec2-13-125-208-9.ap-northeast-2.compute.amazonaws.com',
#     '.devprofessional.xyz',
#     '172.31.33.84',
# ]

정적·미디어 파일 설정:

STATIC_URL      = "/static/"
STATICFILES_DIRS = [os.path.join(BASE_DIR, "static")]    # 개발 시 참조
STATIC_ROOT     = os.path.join(BASE_DIR, "staticfiles")  # collectstatic 결과물

MEDIA_URL  = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media")

환경변수 관리 (.env):

import environ

env = environ.Env()
environ.Env.read_env(os.path.join(BASE_DIR, ".env"))
OPENAI_API_KEY = env("OPENAI_API_KEY")  # .env 파일에서 로드 (git에 미포함)

8. 기술적으로 배운 것들

미디어 vs 정적 파일 분리

처음에는 개발 환경과 배포 환경에서 이미지가 안 보이는 문제가 반복됐습니다. 원인을 추적하며 Django의 파일 서빙 원칙을 명확히 이해했습니다.

┌──────────────┬──────────────────────────────────────────────┐
│              │  개발 (DEBUG=True)          배포 (DEBUG=False)│
├──────────────┼─────────────────────────────────────────────│
│ 정적 파일    │  runserver 자동 처리        Nginx 직접 서빙  │
│ (static/)    │                             (collectstatic)  │
├──────────────┼─────────────────────────────────────────────│
│ 미디어 파일  │  urls.py에 수동 추가        Nginx 직접 서빙  │
│ (media/)     │  static(MEDIA_URL, ...)                      │
└──────────────┴─────────────────────────────────────────────┘

PRG 패턴 (Post-Redirect-Get)

방명록·글쓰기 등 POST 처리 후 redirect()로 응답하는 패턴을 일관되게 적용했습니다. 브라우저 새로고침 시 폼이 중복 제출되는 문제를 방지합니다.

세션 기반 상태 관리

랜딩 페이지의 "첫 방문 vs 재방문" 분기, 챗봇의 "사용 횟수 제한"을 별도 DB 없이 Django 세션으로 간단하게 처리했습니다.


9. 향후 개선 사항

완료 ✅
  - 블로그 CRUD
  - CKEditor 리치텍스트
  - 자료실 + 파일 다운로드
  - RAG 챗봇 (GPT-4)
  - AWS EC2 배포 + 도메인

진행 중 / 예정 🔧
  - HTTPS 전환 (ACM + ALB 443 리스너)
  - SQLite → PostgreSQL 이전 (데이터 무결성·동시성 개선)
  - 벡터 DB 기반 RAG 고도화 (현재는 전체 텍스트 주입)
  - CI/CD 파이프라인 (GitHub Actions → EC2 자동 배포)

자료