프로젝트 기능구현 기간: 2026/02/15 ~ 2026/03/18
일자 별 기능구현
1주차
02/15
02/16
02/17
02/18
- 기존 코드 최적화
- 게시글 작성의 비즈니스로직 추가 및 기능 최적화
02/19
- 작성 글 공개/비공개 기능 추가
- 포스트 작성 플로우 분석 / 목록 조회 플로우 분석
- 포스트 수정, 삭제 기능 추가
02/20
- 페이지네이션 도입
- 한 개의 게시글만 가져오는 기능 추가
02/21
- 등급 기능 추가(SVG)
- 깃허브같은 일일 잔디심기 기능 추가
- 기존 28칸으로 했는데 안이뻐서 그냥 365일로 전환해버렸음
02/22
프론트
- 홈페이지 프론트 수정
- 최근 작성글 부분에 기존의 전체 글이 조회되던걸 사용자가 작성한 최근 글이 조회되도록 변경
- 프론트 페이지네이션 도입
- 본문 작성 페이지 'Toast UI Editor'를 사용하여 기능 추가
백엔드
- 태그에 따른 필터기능 도입
- "좋아요" 기능 도입
- 인덱스 처리하여 DB단에서 동시성제어
이미지 처리방식 고민
2주차
02/23
- 기존 기능(포스트 조회)에 태그 추가
- 태그 개수를 세어주는 로직 추가
- 댓글기능 추가
- 댓글 조회 기능 추가
02/24 ~ 02/28
03/01
- 문제 해결
- 수정 페이지에 태그 입력창 추가 - F
- 좋아요 기능 오류
- "좋아요"를 누른 게시글에 다시 "좋아요"를 눌렀을때 취소되지 않음
- 기능 개발
- 댓글 수정 / 댓글 삭제 기능 추가 및 댓글 디자인 도입
3주차
03/02
03/03
- 시리즈 수정 / 삭제 기능 구현
- 게시글 작성시에 시리즈 선택 기능 넣기
- 게시글 상세조회 창에서 시리즈 보이는 기능 추가
03/04
- 코딩테스트 내용 정리
- 기능 추가 고민
- 구현 고민
03/05
- 휴지통 기능 구현
- 휴지통에 있는 포스트 내용 미리보기 기능 추가 - F
- 태그 필터링 기능 추가
- 태그개수 카운트 기능 추가
03/06
03/07
03/08
- 이미지 처리방식 도입 (S3 / Presigned URL 둘다 해봄 )
4주차
03/09
- env오류 해결
- 프로젝트 설계 초반에 env가 깃허브에 올라가 있던걸 이미지 처리로 인한 env수정할 때 인지함
- 유출된 DB비밀번호 수정
- IAM / Bucket 재발급 및 오류 해결
- 프론트 이미지 처리 디자인 수정
- 깃허브 소셜로그인 구현
03/10
03/11
- ai 작성 문체 변환기 토큰값 확장
- 스트리밍응답 적용
- 하지만 딱히 성능적으로 좋아진거같지는 않음, 현재가 최선인것같음
- 2초정도 더 빨리 나오기는 함
- 본문 이미지 첨부 방식 Presigned URL로 전환
- 사용자 등급 이미지 전환
- 잔디 처리방식 전환(365번 -> 1번으로 최적화)
03/12
03/13
- 글자수 카운트 기능 추가
- 자동 임시저장 기능 추가
03/14
- 원래 프론트에서 관리하던 등급관리 로직을 백엔드로 전환
- 날짜데이터 중복 제거
- 진행률 소수점 처리
- 마이페이지 기능 구현
- 소셜과 일반 아이디 닉네임 중복 방지
03/15
5주차
03/16
03/17
- 회원가입 이메일 인증
- 비밀번호 찾기 기능 추가
- 마이페이지에서 닉네임 중복 여부 판단
03/18
- 서버 상태 모니터링 방법 시도
- 시리즈 관련 오류 및 애매한 부분 수정
- 초반 테스트할때 넣었던 안어울리는 이모지 삭제
- 시리즈에 속해있던 포스트 수정시 시리즈에서 빠지는 문제 해결
03/19
- 블로그 프로젝트를 진행하면서 작성한 TIL전체 다듬기
- README작성
03/20
- 각 기능별 사용 요소 정리 및 포트폴리오 포트폴리오/자소서 수정
03/22~23
코드 분석
User ❗️
User ✅
목적
- Django REST Framework(DRF)를 기반으로 작성
- 자체 이메일 회원가입 및 JWT 기반 인증
- 소셜 로그인(GitHub, Discord)
- 이메일 인증을 통한 비밀번호 찾기
- 그리고 유저의 활동 기반 통계(잔디밭, 등급) 등을 제공하는 종합적인 사용자 관리 시스템
주요 모듈 및 함수별 역할
model
- 장고의 기본 유저 모델을 확장한 User 모델을 정의
- 아이디 대신 이메일을 로그인 식별자로 사용하며, 닉네임, 프로필 이미지 등을 관리
view
- DRF의 APIView를 상속받아 클라이언트(프론트엔드)의 HTTP 요청을 처리
- 로그인(login.py), 회원가입(signup.py), 소셜 로그인 콜백(social_login.py) 등
service
- View에서 복잡한 로직을 덜어내어 수행하는 '서비스 레이어'
- 이메일 인증번호 발송 및 Redis 저장(email_service.py)
- 외부 OAuth 토큰 요청 및 고유 닉네임 생성(social_login_service.py)
- 유저의 게시글 수 기반 등급 계산(users_stat_service.py) 등의 핵심 동작을 수행
serializer
- 클라이언트로부터 들어온 JSON 데이터를 검증(이메일 중복 체크 등)하거나
- 파이썬 객체를 다시 JSON으로 변환하여 프론트엔드에 응답하기 위한 틀 역할을 수행
핵심 기법
현재 적용
서비스 레이어 패턴 (Service Layer Pattern)
- 비즈니스 로직을 views.py에 작성하지 않고 services 디렉토리 하위의
- 클래스/메서드(예: UserService.authenticate_user())로 분리
Redis를 활용한 TTL(Time-To-Live) 캐싱 기법
- 이메일 인증번호나 비밀번호 초기화 코드를 RDBMS에 저장하지 않고
- Django 캐시(Redis)에 300초(5분) 설정과 함께 cache.set(key, code, timeout=300)으로 보관
데이터베이스 트랜잭션(transaction.atomic)
- 소셜 로그인 연동 시, 새로운 유저를 생성하는 쿼리와 SocialAccount를 생성하는 쿼리를
- 하나의 트랜잭션 블록으로 묶어
- 중간에 에러가 발생하면 전체를 롤백(Rollback)하도록 데이터 무결성을 보장
현재 방법 장점
메모리 사용량 및 DB 부하 최적화
- 인증번호 저장을 위해 RDBMS 대안을 사용했다면
- 생성되고 버려지는 일회성 데이터(인증코드) 때문에 DB에 잦은 쓰기/삭제 I/O가 발생하고
- 스케줄러를 통한 만료 데이터 청소 로직이 추가로 필요했을 것
- 현재의 Redis 채택으로 인하여
- I/O 병목을 없애고 애플리케이션 코드를 획기적으로 줄였음
유지보수와 단일 책임 원칙(SRP) 준수
- 서비스 레이어 분리를 통해 View는 오직 '요청 데이터 검증과 응답 반환'에만 집중하게 되었음
- 향후 새로운 인증 방식이나 로직이 추가되더라도 View 코드를 손대지 않고 Service 모듈만
ai ❗️
ai ✅
목적
- Google Gemini API(gemini-2.5-flash 모델)를 활용하여
- 사용자가 입력한 원본 텍스트를 원하는 특정 문체(IT 전문가, 친근한 리뷰어, 감성 에세이 등)로
- 실시간 변환하여 제공하는 AI 텍스트 스트리밍 변환 시스템
주요 모듈 및 함수별 역할
prompt
- AI에게 지시할 각 문체별 페르소나 및 규칙(System Prompt)을
- 상수 딕셔너리(TONE_MAPPING) 형태로 매핑하여 관리
service
- convert_text_tone 함수가 핵심 역할
- 클라이언트의 요청(문체)에 맞는 프롬프트를 찾아 Gemini Client에 주입하고 AI 모델이 생성해내는
- 텍스트 조각(chunk)들을 yield 키워드를 사용해 제너레이터(Generator) 형태로 반환
view
- 클라이언트의 변환 요청(JSON)을 받아 유효성을 검사한 뒤 서비스 계층을 호출
- 이후 반환받은 제너레이터를 Django의 StreamingHttpResponse에 담아
- 서버-클라이언트 간 실시간 데이터 스트림 방식으로 응답을 전송
핵심 기법
현재 적용
스트리밍 생성 및 Server-Sent Events (SSE) 응답 기법
- AI 텍스트 생성은 완료까지 수 초 이상 소요될 수 있음
- 이를 해결하기 위해 모델이 전체 문장을 완성할 때까지 기다리지 않고
- generate_content_stream과 파이썬의 yield를 사용해 텍스트 조각이
- 생성되는 즉시 클라이언트로 전송(StreamingHttpResponse)
프롬프트 엔지니어링의 모듈화
- 비즈니스 로직(Service) 내부에 길고 복잡한 프롬프트 문자열을 하드코딩하지 않고,
- tone_prompts.py라는 별도 파일로 완전히 분리
웹 서버 버퍼링 우회 (X-Accel-Buffering: no)
- Nginx 등 리버스 프록시를 사용하는 배포 환경에서
- 서버가 데이터를 모았다가 보내지 않고 조각이 발생할 때마다
- 즉시 클라이언트에게 쏘아주도록 HTTP 헤더를 세밀하게 제어
현재 방법 장점
사용자 경험(UX) 극대화 및 타임아웃 방지
- 동기식 대안을 사용했다면 사용자는 화면이 멈춘 채로 5~10초를 기다려야 하며
- 현재의 스트리밍 기법은 타이핑이 쳐지는 듯한 UI를 프론트엔드에서 구현할 수 있게 하여
- 체감 대기 시간을 획기적으로 줄여주는 가장 최선의 접근 방식
단일 책임 원칙 준수 및 유지보수성 향상
- 프롬프트를 분리한 덕분에,
- 추후 파이썬 코드를 모르는 기획자나 프롬프트 엔지니어도 tone_prompts.py 파일만 수정하여
- AI의 응답 품질을 튜닝하거나 새로운 문체를 쉽게 추가할 수 있는 유연한 구조가 완성
Post ❗️
Post ✅
목적
- 게시글 CRUD, 임시저장, 공개 범위 설정, 태그 연동, 소프트 딜리트(휴지통) 기능뿐만 아니라
- 사용자 간의 상호작용을 위한 '좋아요' 기능과 다중 조건(시리즈, 태그, 검색어) 기반의
- 최적화된 피드 조회 기능을 제공하는 종합 블로그 게시글 관리 시스템
주요 모듈 및 함수별 역할
model
- 게시글(Post)의 스키마를 정의
- 작성자(User), 시리즈(Series)와 외래 키로 연결되며
- 삭제 시간을 기록하는 deleted_at 필드로 휴지통 기능을 구현
- 또한 중간 테이블(PostTag)을 명시하여 태그(Tag)와 다대다 관계를 맺음
- 데이터 모델 - 좋아요
- 특정 게시글(Post)과 이를 좋아하는 유저(User)를 연결하는 모델
- UniqueConstraint를 설정하여 한 유저가 동일한 게시글에 여러 번 좋아요를 누를 수 없도록
비즈니스 로직 - 생성
- 게시글을 실제로 DB에 생성하는 로직을 담당
- 본문을 잘라 자동으로 요약본(summary)을 생성하고
- 새 태그 생성 및 게시글-태그 간의 연결을 대량(Bulk) 처리하여 쿼리를 최적화
비즈니스 로직 - 휴지통
deleted_at 필드가 존재하는 데이터만 필터링하여
- 휴지통 목록을 조회하거나 복구(restore_trashed_post)
- 영구 삭제(hard_delete_post)하는 기능을 수행
비즈니스 로직 - 검색 및 목록
- 전달받은 파라미터를 바탕으로 실질적인 DB 검색을 수행
- 장고의 Q 객체를 사용하여 제목, 내용, 태그 이름 중 하나라도
- 검색어가 포함된(icontains) 게시글을 찾는 다중 검색 로직을 담당
비즈니스 로직 - 좋아요
- 게시글이 삭제(Soft Delete)되지 않고 유효한지 먼저 검증한 뒤
- Like 객체를 생성(get_or_create)하거나 삭제(delete)하는 역할을 수행
API 엔드포인트(post,like)
- 클라이언트 요청을 받아 시리즈, 태그, 검색어 기준의 필터링을 수행하고
- 페이지네이션을 적용하여 전체 포스트 피드 및 내 블로그 글을 응답
- 게시글 목록 조회 시 URL 쿼리 파라미터(?search=, ?tag=)를 추출하기만 하고
- 실제 비즈니스 로직은 서비스 계층으로 위임(Delegate)하여 단일 책임 원칙을 철저히 지킴
- 좋아요 등록/취소 역시 add_post_like, remove_post_like 서비스를 호출하는 역할만 함
핵심 기법
현재 적용
소프트 딜리트 (Soft Delete) 패턴
- deleted_at 필드를 두어 삭제 요청 시 실제 DB 레코드를 날리지 않고 삭제된 시간만 기록
- 이를 통해 사용자에게 '휴지통' 기능을 제공하며 데이터 복구를 가능하게 함
Bulk Create를 통한 쿼리 최적화
- 새로운 태그를 생성하고(Tag.objects.bulk_create)
- 게시글과 태그를 연결할 때(PostTag.objects.bulk_create)
- 하나씩 save()를 호출하지 않고 한 번의 쿼리로 삽입하여 N+1 문제 및 쓰기 성능 저하를 방지
ORM 조인 최적화 (N+1 문제 방지)
- 휴지통 목록 조회 시 select_related("user", "series")를 사용하여
- 정방향 외래 키 데이터를 SQL의 JOIN으로 한 번에 가져오고
- prefetch_related("tags")로 다대다 관계의 태그 데이터를 IN 쿼리로 묶어 가져옴
서비스 레이어에서의 ORM 고급 쿼리 (Q 객체와 distinct)
- 다중 검색 로직을 구현할 때
Q(title__icontains=...) | Q(...) 형태를 사용하여
- 또한 태그 이름으로 검색할 때 발생하는 M:N 조인 중복 결과를 제거하기 위해
서브쿼리(Subquery) 및 Exists를 활용한 최적화
- 게시글을 조회할 때마다 해당 글에 달린 좋아요 수를
Count("likes", distinct=True)로 집계
- 특히 현재 접속한 유저가 좋아요를 눌렀는지 여부(
is_liked_by_user)를
- 파이썬 반복문이 아닌 DB 단의
Exists(is_liked_subquery)를 통해
- 가상 필드로 만들어 N+1 문제를 원천 차단
데이터 무결성을 위한 DB 제약조건
- 앱 레벨(Python)에서만 중복 좋아요를 체크하지 않고,
models.UniqueConstraint(fields=["post", "user"])를
- DB 스키마에 직접 적용하여 동시성 이슈(Race Condition)가 발생하더라도
- 데이터 무결성을 보장하도록 설계
현재 방법 장점
성능과 데이터 정합성 보장
create_post 서비스 내에서
transaction.atomic 블록과 bulk_create를 함께 사용했음
- 만약 태그 10개를 등록할 때 하나씩 INSERT 한다면 DB 트랜잭션 오버헤드가 크지만
- 현재 구조는 단 2~3번의 쿼리로 모든 작업을 완료하여
- 서버 메모리 사용량 최적화 및 DB 응답 시간 단축이라는 결과를 냄
유지보수성과 역할 분리의 극대화
- 뷰에 검색 로직과 좋아요 비즈니스 로직을 넣지 않고 서비스 레이어로 완전히 분리
- 향후 "관리자용 게시글 검색 API" 등을 추가로 만들 때
- 뷰 코드 재작성 없이 get_global_posts 서비스 함수만 재사용하면 되므로
- 코드의 재사용성과 테스트 용이성(Testability) 향상
목적
- 특정 게시글(Post)에 종속되어 사용자들이 의견을 나눌 수 있도록 하는
- 댓글(Comment)의 생성, 조회, 수정, 삭제(CRUD) 라이프사이클 관리 시스템
주요 모듈 및 함수별 역할
데이터 모델
- 댓글 내용을 담는 content 필드를 가지며
- 어떤 게시글에 달렸는지(post), 누가 작성했는지(user)를 외래 키(ForeignKey)로 연결하여 관계 정의
조회 기능 (List)
- CommentAPIView의 GET 메서드에서 호출
- 부모 게시글이 삭제(Soft Delete)되지 않고 존재하는지 먼저 검증한 뒤
- 해당 게시글에 달린 댓글들을 작성일(created_at) 순으로 정렬하고 페이지네이션을 적용하여 반환
생성 기능 (Create)
- CommentAPIView의 POST 메서드에서 호출
- 게시글의 유효성을 확인한 후
- 클라이언트가 보낸 내용(content)과 로그인한 유저 정보를 엮어 새로운 댓글 레코드를 DB에 INSERT
수정 및 삭제 기능 (Update/Delete)
- CommentManageAPIView에서 담당
- 두 기능 모두 타인이 임의로 조작하지 못하도록
- "댓글이 존재하는지"와 "요청한 유저가 실제 댓글 작성자와 일치하는지"를
- DB 조회 후 애플리케이션 레벨에서 엄격히 검증(
if comment.user != user:)
핵심 기법
현재 적용
시리얼라이저 필드 매핑 및 N+1 방지
- CommentListSerializer를 보면 source="user.nickname"을 통해
- 작성자의 닉네임과 프로필 이미지를 엮어서 반환
- 이 과정에서 발생할 수 있는 N+1 쿼리 문제를 막기 위해
- 조회 서비스에서
select_related("user")를 사용하여
- SQL의 JOIN 연산으로 유저 데이터를 한 번에 가져오는 최적화를 적용
부분 업데이트 최적화 (update_fields)
- 댓글 수정 서비스에서
comment.save(update_fields=["content"])를 명시
- 이는
UPDATE comments SET content=... 처럼 쿼리를 제한하여
- 변경되지 않은 다른 필드들까지 불필요하게 덮어쓰기 되는 것을 방지하는 좋은 기법
현재 방법 장점
- 현재의 '서비스 레이어 위임 패턴'과 '게시글 생존 여부 검증(deleted_at__isnull=True)' 방식은
- 이미 삭제된 휴지통 속 게시글에 유령 댓글이 달리거나 조회되는 데이터 정합성 오류를 원천 차단
Tag ❗️
Tag ✅
목적
- 이 코드는 게시글을 주제별로 묶어 분류하고 쉽게 검색할 수 있도록 돕는 태그(Tag) 시스템
- 중간 테이블(Through Model)을 활용한 관계 매핑과
- 태그별 사용 빈도(Count) 집계 기능을 독립적으로 관리하는 애플리케이션
주요 모듈 및 함수별 역할
tag.py 및 post_tag.py (데이터 모델)
- 고유한 태그 이름(name)을 저장하는 Tag 모델
- 게시글(Post)과 태그(Tag) 간의 다대다(M:N) 관계를 물리적으로 연결하는
비즈니스 로직 - 태그 집계
- 시스템에 등록된 태그들을 조회하면서
- 각 태그가 현재 몇 개의 게시글에 사용되고 있는지(사용 빈도)를 데이터베이스 레벨에서 집계하여 반환
데이터 직렬화
- 태그의 기본 정보(이름, ID)와 서비스 레이어에서 계산된 '게시글 카운트' 데이터를
- 프론트엔드가 요구하는 JSON 규격으로 변환
API 엔드포인트
- 클라이언트의 태그 목록 및 통계 데이터 요청(GET)을 받아 서비스 레이어를 호출하고 결과를 응답
핵심 기법
현재 적용
명시적 중간 테이블 패턴 (Custom Through Model)
- 장고(Django)의 ManyToManyField가 자동으로 만들어주는 숨겨진 테이블을 사용하지 않고
- PostTag라는 모델을 명시적으로 분리해 선언함
- 이는 나중에 "이 태그가 이 글에 언제 달렸는가(created_at)" 같은
- 추가 메타데이터를 쉽게 확장할 수 있게 하기위한 설계
데이터베이스 레벨 집계 (ORM Annotation)
- 태그 개수를 파이썬(Python)의 반복문으로 하나씩 세지 않고
- tag_count_service에서 장고 ORM의
annotate(count=Count(...)) 기능을 사용해
- DB 엔진 내부에서 그룹화 및 집계 연산을 완료하도록 최적화
현재 방법 장점
- 단일 책임 원칙과 쓰기 성능 극대화
- tags 앱을 모델부터 뷰까지 별도로 독립시켰기 때문에 시스템 간 결합도가 낮아졌음
- 특히 PostTag 중간 테이블을 명시한 덕분에
post_create_service.py 로직에서 bulk_create를 활용해
- N개의 태그를 단 한 번의 쿼리로 매핑하는 성능상 이점을 얻음
Series ❗️
Series ✅
목적
- 블로그 플랫폼 등에서 사용자가 자신의 게시글들을
- 특정 주제나 흐름으로 묶을 수 있도록 돕는 '시리즈(Series)'
- 또는 카테고리의 생성, 조회, 이름 수정, 삭제(CRUD)를 관리하는 시스템
주요 모듈 및 함수별 역할
데이터 모델
- 시리즈의 소유자(user)와 이름(name)을 저장
- 핵심적으로
UniqueConstraint를 사용하여
- 한 명의 유저가 동일한 이름의 시리즈를 중복해서 만들 수 없도록 데이터베이스 레벨에서 제약걸기
조회 기능 (series_list_service.py & SeriesAPIView)
- 로그인한 유저(user)를 기준으로
- 해당 유저가 생성한 시리즈 목록만 필터링하여 최신 생성일(-created_at) 순으로 반환
생성 기능 (series_manage_service.py & SeriesAPIView)
- 사용자가 요청한 이름으로 새 시리즈를 만듬
- 이때 이름 중복 제약조건을 위반하여 발생하는 데이터베이스 에러(IntegrityError)를 잡아냄
수정 및 삭제 기능 (series_manage_service.py & SeriesDetailAPIView)
- 타인의 시리즈를 조작할 수 없도록
- 수정/삭제 전 반드시 id와 user가 모두 일치하는
- 객체를 먼저 조회하여 강력한 소유권(Ownership) 검증을 수행
핵심 기법
현재 적용
DB 무결성 제약과 EAFP 패턴
- Easier to Ask for Forgiveness than Permission
- 시리즈 이름의 중복을 막기 위해 애플리케이션 단에서 먼저 exists()로 검사하지 않고
- DB의
UniqueConstraint를 믿고 무작정 create()를 시도
- 실패할 경우
IntegrityError 예외를 처리하는 전형적인 파이썬의 EAFP 철학을 따르고
- 이는 다중 접속 환경에서의 동시성 문제(Race Condition)를 막아냅니다.
안전한 객체 참조 (IDOR 방어)
- 수정과 삭제 서비스 로직에서
Series.objects.filter(id=series_id, user=user).first() 방식을 사용
- 악의적인 유저가 다른 유저의 시리즈 ID를 파라미터로 몰래 보내더라도
user=user 필터 때문에 객체를 찾지 못하게 되어 안전하게 권한 에러를 뱉어내는 설계
부분 업데이트 최적화
- 시리즈 이름 변경 시
series.save(update_fields=["name"])를 호출하여
- 불필요한 필드(예: created_at 등)가 덮어씌워지는 것을 방지하고 쿼리 효율을 높임
현재 방법 장점
- 높은 보안성과 데이터 정합성 보장
- 사용자 권한 체크 로직(user=user)과 제약 조건 예외 처리(IntegrityError)를 통해
- 비즈니스 로직(서비스 레이어)이 외부 공격(조작된 ID 전송)이나 비정상적인 데이터 입력을
- 자체적으로 방어하는 매우 견고한(Robust) 아키텍처를 완성
쿼리 최적화
Post
전체 글 조회
# 수정 전
200 GET
/api/v1/post/
23ms overall
5ms on queries
6 queries
# 수정 후
200 GET
/api/v1/post/
23ms overall
5ms on queries
3 queries
기존
- 기존 로직에서는 메인 데이터 조회 외에도 시리얼라이저(Serializer) 단에서
- 추가적인 쿼리가 지연 평가(Lazy Evaluation)되며 N+1 문제와 불필요한 단일 쿼리가 발생
- 작성자 등급 계산용 카운트 쿼리 발생
PostListSerializer에서 작성자의 등급(Grade)을 계산하기 위해
- 유저별 총 게시글 수를 구하는
.count() 쿼리가 실행되었음
__init__에서 딕셔너리로 캐싱하여 동일 작성자에 대한 중복 쿼리는 막았지만
- 페이지 내에 등장하는 '새로운 작성자'마다 최소 1번씩의 추가 쿼리가 데이터베이스로 날아가고 있었음
- Series 정보 조회를 위한 N+1 문제
- 시리얼라이저에서
source="series.name"을 통해 시리즈를 참조하고 있었으나
- 메인 쿼리의
select_related 목록에 "series"가 누락
- 이로 인해 게시글 목록을 순회할 때
- 각 게시글의 서로 다른 Series 정보를 가져오기 위한 추가 쿼리가 반복적으로 발생
해결
- Django ORM의 고급 기능(Subquery, OuterRef)과 즉시 로딩(select_related)을 활용하여
- 애플리케이션(Python) 메모리 단에서 처리하던 로직을 데이터베이스(DB) 단의 메인 쿼리 하나로 병합
- Subquery와 OuterRef를 활용한 작성자 게시글 수 통합
- 기존 시리얼라이저의 딕셔너리 캐싱 로직을 완전히 제거
- 대신
OuterRef를 이용해 메인 쿼리의 user_id와 매칭되는 서브쿼리를 작성
- 이를
.annotate(author_total_posts=Subquery(...))를 통해 연결
- 결과적으로 메인 쿼리가 실행될 때
- 각 유저의 게시글 수를 DB에서 한 번에 계산하여 가져오도록 변경하여 시리얼라이저 단의 N+1을 제거
- select_related에 Series 추가
- get_global_posts()의 메인 쿼리셋에
.select_related("user", "series") 처럼 "series"를 명시적으로 추가
결과
- 쿼리 수 50% 감소 (6회 -> 3회)
- 불필요한 시리얼라이저 N+1 쿼리들이 메인 쿼리로 병합
- 남은 3회의 쿼리는 필수적인 페이지네이션 카운트, 메인 데이터+서브쿼리 조회, 태그 Prefetch 쿼리
- 성능 및 확장성 확보
- 현재 응답 시간(23ms)과 DB 시간(5ms)은 데이터 모수가 적어 극적인 차이가 보이지 않을 수 있음
- 하지만 트래픽이 많아지고 한 페이지에 노출되는 다양한 작성자와 시리즈가 늘어날수록
- 기하급수적으로 늘어날 뻔한 DB 부하를 사전에 차단
내 글 조회
# 수정 전
/api/v1/post/my/
28ms overall
6ms on queries
14 queries
# 수정 후(2026/03/21)
/api/v1/post/my/
17ms overall
4ms on queries
5 queries
# 수정 후(2026/03/22)
200 GET
/api/v1/post/my/
31ms overall
4ms on queries
4 queries
1단계: N+1 문제의 발견 (14 쿼리)
- 시리얼라이저가 10개의 내 게시글을 응답으로 만들면서
- 각 게시글마다 작성자의 총 게시글 수를 구하는
.count() 쿼리를 10번이나 반복해서 호출(N+1)
2단계: 애플리케이션 단 메모리 캐싱 (5 쿼리)
- '내 글 조회'의 작성자는 결국 나 자신 1명이라는 점에 착안
- 시리얼라이저
__init__에 딕셔너리를 만들어 최초 1회만 DB에서 카운트를 가져오고
- 나머지 9번은 메모리에서 꺼내 쓰도록 캐싱하여 쿼리를 획기적으로 줄였음
- 이 과정에서 휴지통에 간 글을 제외하는 예외 처리도 추가했음
3단계: 데이터베이스 단 서브쿼리 병합 (4 쿼리)
- 이후 '전체 글 조회' API를 최적화하면서 시리얼라이저 캐싱 방식을 제거하게 되었고
- 이 여파로 내 글 조회에서 등급이 '0'으로 누락되는 버그가 발생
- 이를 해결하기 위해 파이썬 메모리(캐싱)에 의존하는 대신
OuterRef와 Subquery를 사용해 메인 쿼리 실행 시
- DB 내부에서 카운트까지 한 번에 연산해 오도록 아키텍처를 완전히 통일
임시글 목록
# 수정 전
/api/v1/post/my/temp/
30ms overall
3ms on queries
7 queries
# 수정 후
/api/v1/post/my/temp/
28ms overall
2ms on queries
4 queries
원인 (불필요한 연산 및 N+1 문제)
- 도메인 특성에 맞지 않는 무거운 연산
- 기존 코드에는 임시글(작성 중인 비공개 글)을 조회함에도 불구하고
- 좋아요 수를 집계하는
.annotate(likes_count=Count("likes", distinct=True)) 로직이 포함
- 공개되지 않은 임시글에는 '좋아요'가 달릴 수 없으므로
- 데이터베이스 입장에서는 불필요한 조인(JOIN)과 그룹화(GROUP BY) 연산을 낭비
- Series 참조로 인한 N+1 문제
- 다른 조회 API들과 마찬가지로, 시리얼라이저에서 임시글이 속한 시리즈(series) 정보를 참조할 때
- 메인 쿼리에
select_related("series")가 누락되어 있어
- 각 게시글마다 시리즈 정보를 가져오기 위한 추가 쿼리가 발생
해결 (로직 다이어트 및 즉시 로딩)
- 불필요한 좋아요 집계 로직 제거
- 도메인 맥락에 맞게 임시글 목록에서는 좋아요를 카운트하는
.annotate(...) 코드를 삭제
- select_related 명시적 선언
- 쿼리셋에
.select_related("user", "series")를 추가
- 메인 게시글을 가져올 때 SQL JOIN을 통해 작성자와 시리즈 정보를 한 번에 묶어서 가져오도록 변경
- 이를 통해 시리얼라이저 단에서 발생하던 N+1 문제를 원천 차단
결과 (쿼리 다이어트 및 도메인 로직 정교화)
- 불필요한 쿼리 제거 (7회 -> 4회)
- N+1 문제를 유발하던 추가 쿼리들이 제거되어 총 쿼리 수가 절반 가까이 줄임
- 데이터베이스 부하 감소
- 의미 없는 Count 연산을 제거함으로써
- DB의 컴퓨팅 리소스 낭비를 막고 쿼리 실행 속도를 미세하게나마 단축(3ms -> 2ms)시킴
- 코드의 목적 명확화
- 기술적인 성능 향상뿐만 아니라
- "임시글에는 좋아요가 필요 없다"는 비즈니스 로직(도메인 규칙)이 코드에 정확히 반영
휴지통 목록
# 수정 전
/api/v1/post/my/trash/
31ms overall
7ms on queries
21 queries
# 수정 후
/api/v1/post/my/trash/
17ms overall
3ms on queries
4 queries
원인 (지연 평가로 인한 N+1 문제 폭발)
- 시리얼라이저의 지연 평가(Lazy Evaluation)
- 기존 코드에서는 메인 게시글 10개를 먼저 가져온 뒤
- 시리얼라이저가 JSON 응답을 만드는 과정에서 추가 데이터를 그때그때 DB에 요청하는 구조
- N+1 쿼리 연쇄 발생
- 휴지통에 10개의 글이 있다고 가정할 때
- 1번 글의 series를 찾기 위해 1번, tags를 찾기 위해 1번 쿼리를 날리는 작업이 10번의 게시글 내내 반복
- 이로 인해 메인 쿼리 1개에 시리즈 조회 최대 10개, 태그 조회 최대 10개가 더해져 총 21번의 쿼리가 폭주
- 불필요한 연산
- 임시글 목록과 마찬가지로, 휴지통에 있는 글에는 불필요한
- 좋아요 수 집계(
.annotate(likes_count=Count("likes")))가 포함되어 있어 DB 리소스를 낭비
해결 (즉시 로딩을 통한 사전 지시 및 다이어트)
- 불필요한 좋아요 집계 제거
- 휴지통 목록의 도메인 특성에 맞게 Count("likes") 연산을 제거하여 쿼리를 가볍게 만듬
- select_related (JOIN 병합)
- 쿼리셋에
.select_related("user", "series")를 추가하여 DB에게
- "이따가 시리얼라이저가 유저랑 시리즈 정보도 물어볼 테니
- 처음부터 JOIN해서 하나의 표로 가져와!"라고 미리 지시
- 이로 인해 10번 발생하던 시리즈 조회 쿼리가 메인 쿼리 1개로 완벽히 흡수
- prefetch_related (IN 쿼리 병합)
- 다대다(M:N) 관계라 JOIN이 까다로운 태그 정보는
.prefetch_related("tags")를 통해 해결
- DB가 조회된 게시글 ID들을 모아 IN (...) 조건으로 단 1번의 추가 쿼리만 날려 태그 정보를 싹 쓸어옴
결과
- 쿼리 수 80% 대폭 감소 (21회 -> 4회)
- 수십 개로 쪼개져 날아가던 쿼리들이 정확히 4개의 필수 쿼리
- 유저/세션 확인 1개 + 페이지네이션 COUNT 1개 + 메인 및 JOIN 1개 + 태그 IN 조회 1개 로 정리
- 응답 속도 향상
- DB에서 소요되는 시간(7ms -> 3ms)은 물론
- 전체 API 응답 시간(31ms -> 17ms)까지 눈에 띄게 단축되며 쾌적한 성능을 확보
상세조회
# 수정 전
/api/v1/post/104/
20ms overall
3ms on queries
6 queries
# 수정 후
/api/v1/post/104/
21ms overall
5ms on queries
3 queries
원인 (시리얼라이저에 숨어있던 3가지 쿼리 낭비)
- 목록 조회가 아닌 단일 상세 조회임에도 불구하고
- 시리얼라이저가 응답 데이터를 조립하는 과정에서 3개의 불필요한 추가 쿼리가 발생
[낭비 1] Series N+1
- 시리얼라이저에서
series.name을 요구하지만
- 서비스 레이어의 메인 쿼리에
select_related("series")가 누락되어 있어
- DB에 시리즈 정보를 다시 묻는 쿼리가 발생
[낭비 2] 작성자 총 게시글 카운트
- 작성자의 등급(Grade) 이미지를 계산하기 위해
get_author_grade_image 메서드 내부에서
Post.objects.filter(...).count()가 매번 실행되고 있었음
[낭비 3] 좋아요 여부(is_liked) 확인
- 현재 접속한 유저가 이 글에 좋아요를 눌렀는지 확인하기 위해
get_is_liked 메서드 내부에서
obj.likes.filter(...).exists()가 실행되며 또다시 DB와 통신하고 있었음
해결 (Subquery와 Exists를 활용한 연산 위임)
- 뷰(View)에서 현재 요청을 보낸 유저(request.user) 정보를 서비스 레이어로 넘겨주고
- 시리얼라이저의 연산 로직을 모두 DB 단의 쿼리로 끌어내림
- select_related("series") 추가
- 메인 쿼리에 시리즈를 JOIN하여
[낭비 1]을 해결
- Subquery 도입
- 작성자의 총 게시글 수를 구하는 서브쿼리를 작성하고
.annotate(author_total_posts=Subquery(...))로 결합하여 [낭비 2]를 해결
- Exists 서브쿼리 도입
- 뷰에서 넘겨받은 user 객체를 활용해 현재 게시글에 해당 유저의 좋아요가 존재하는지 확인하는 로직을
.annotate(is_liked_by_user=Exists(...))로 결합하여 [낭비 3]을 해결
- 시리얼라이저 다이어트
- 결과적으로 시리얼라이저는 DB를 다시 찌르지 않고
getattr을 사용해 메인 쿼리가 미리 계산해서 붙여준 값을 읽기만 하도록 역할을 축소
결과 (완벽한 구조 통일 및 쿼리 최소화)
- 쿼리 수 50% 감소 (6회 -> 3회)
- 유저 검증, 메인+서브쿼리 1회, 태그 Prefetch 1회라는 필수적인 3개의 쿼리만 남기고
- 시리얼라이저 잉여 쿼리를 모두 제거
- DB와 Application의 역할 분리
- 복잡한 연산은 데이터베이스가(DB 쿼리), 응답 포맷팅은 애플리케이션이(시리얼라이저) 담당하도록
- 아키텍처의 책임과 역할을 명확히 분리
- 성능 지표에 대한 이해
- 최적화 후 로컬 응답 시간(20ms -> 21ms)과 DB 시간(3ms -> 5ms)이 미세하게 늘거나 비슷해 보일 수 있음
- 이는 서브쿼리가 추가되면서 단일 쿼리의 무게가 살짝 무거워졌기 때문
- 하지만, 네트워크 통신 횟수(쿼리 개수)를 절반으로 줄였기 때문에
- 실제 운영 환경(Production)에서 DB와 웹 서버 간의 네트워크 지연(Latency)이 발생할 경우
- 훨씬 더 안정적이고 압도적인 성능 이점을 가짐
의도
Post
Like
- DB의
UniqueConstraint를 활용하는 상황에서는 명시적인 락(Lock)을 걸지 않아도 완벽하게 안전
- 현재 Like 모델에
['post', 'user'] 조합으로 유니크 제약조건을 걸었고
add_post_like에서 get_or_create와 IntegrityError 예외 처리를 통해 방어
- 사용자가 아무리 빠르게 '좋아요' 버튼을 여러 번(따닥!) 누르더라도
- 데이터베이스 자체가 두 번째 인서트부터는 무조건 에러를 뱉어내고 튕겨내기 때문에 데이터 무결성이 보장
DB 유니크 제약조건(Constraint) 활용
- 이 방식은 "일단 넣어보고, DB가 안 된다고 하면(IntegrityError) 무시하자!"라는 낙관적인 접근
- 장점 (Pros)
- 최고의 성능: 락을 획득하고 해제하는 오버헤드가 없어서 응답 속도가 매우 빠름
- 데드락(교착 상태) 위험 제로
- 트랜잭션들이 서로 자원을 기다리며 멈추는 데드락이 원천적으로 발생하지 않음
- 높은 동시성 처리량: 여러 사용자가 동시에 요청을 보내도 병목 없이 처리됨
- 단점 (Cons)
- 충돌이 발생했을 때 예외(IntegrityError)를 발생시키고
- 이를 캐치하는 과정이 일반적인 로직 흐름보다는 서버 자원을 미세하게 더 사용함
- 하지만 '좋아요 따닥' 같은 케이스는 빈도가 낮아 전혀 문제 되지 않음
DB 락 (Locking) 방식: 비관적 락 (select_for_update)
- 이 방식은 "누군가 이 게시글의 좋아요 데이터를 건드리고 있으면
- 끝날 때까지 다른 사람들은 줄 서서 기다려!"라는 엄격한 접근
- 장점 (Pros)
- 데이터의 상태를 완벽하게 통제 가능
- 데이터를 읽고, 복잡한 검증을 거친 후 업데이트해야 하는 복잡한 로직에서 필수적
- 단점 (Cons)
- 성능 저하 및 병목
- 트래픽이 몰릴 때 락을 얻기 위해 대기하는 시간이 길어져 전체 서버 응답이 느려질 수 있음
- 데드락 위험
- 락을 잡는 순서가 꼬이면 시스템이 멈추는 데드락이 발생할 위험이 있음
락(Lock)이 필요한 순간
- 나중에 조회 성능을 극대화하기 위해
- Post 모델에 likes_count = models.IntegerField(default=0) 필드를 추가(역정규화)
- 이때는 A 사용자와 B 사용자가 동시에 좋아요를 누를 때
- 기존 likes_count를 읽어와서 +1을 하는 과정에서 동시성 문제(Lost Update)가 발생할 수 있음
- 이런 경우에만 락(
select_for_update)이나 DB 원자적 연산(F())이 필요해짐
개선사항
개선 사항
User
보안성 개선: 무차별 대입 공격(Brute-Force) 방어 로직 부재 ✅
- 이슈
- 현재 코드는 이메일 인증번호 발송 API나 로그인 API에 대해
- 재시도 횟수 제한(Rate Limiting)이 존재하지 않음
- 악의적인 봇이 짧은 시간에 수만 건의 로그인 시도나 이메일 발송을 요청할 경우
- 서버 다운이나 SMTP 쿼터 초과가 발생할 수 있음
- 해결 가이드
- DRF에서 기본 제공하는 Throttling 기능을 도입 추천
가독성 및 예외 처리 구체화 ✅
- 이슈
- social_login_service.py에서 requests.post()를 통해
- 외부 서버와 통신할 때 네트워크 오류나 HTTP 상태 코드 에러에 대한 방어가 적음
- 해결 가이드
- requests 라이브러리의 raise_for_status() 메서드나 Timeout 설정을 명시적으로 추가하여
- 외부 API 장애가 우리 서버의 무한 대기(Hang) 현상으로 이어지지 않도록 방어 로직을 리팩토링 하기
성능 최적화: 이메일 발송 로직의 비동기(Asynchronous) 처리
- 이슈
- password_service.py와 email_service.py의 send_mail(...) 함수가 동기적으로 동작함
- 이메일을 발송하기 위해 구글 SMTP 서버와 통신하는 시간 동안
- 사용자는 회원가입이나 메일 발송 API 응답을 하염없이 기다려야 함 (네트워크 지연 발생)
- 해결 가이드
- Celery + Redis (또는 장고 최신 버전의 async/await 기능)를 도입하여
- 이메일 발송 작업을 백그라운드 워커(Worker)로 넘기기
AI
보안 및 비용 최적화: 입력값 길이 제한과 Rate Limiting 부재 ✅
- 이슈
- 외부 LLM API(Gemini)는 요청한 텍스트의 길이에 비례하여 비용이 발생
- 현재 뷰에서는 사용자가 보낸 text의 길이를 제한하지 않으므로
- 악의적으로 수만 자의 텍스트를 요청하여 API 비용을 폭증시키거나
- 쿼터를 고갈시킬 수 있음
- 또한 무차별 요청(Rate Limiting)에 대한 방어도 없음
- 개선 가이드
- 단순 딕셔너리(request.data.get) 추출 대신 DRF의 Serializer를 도입하여
- serializers.CharField(max_length=2000) 등으로
- 요청 텍스트의 최대 길이를 반드시 제한 필요
- 해당 View에
throttle_classes = [UserRateThrottle, AnonRateThrottle] 를
- 추가하여 하루/분당 API 호출 횟수를 제한 필요
성능 최적화: SDK Client 인스턴스 재사용 ✅
- 이슈
- openai_service.py를 보면 convert_text_tone 함수가 호출될 때마다
- 매번 client = genai.Client(...) 객체를 새롭게 생성하고 있음
- 개선 가이드
- 클라이언트 초기화 비용을 줄이기 위해
- 파일 최상단(글로벌 영역)에서 client를 한 번만 초기화해 두고
- 여러 요청이 이를 재사용하도록 수정하는 것이 성능상 유리
- (만약 해당 SDK가 멀티스레드 환경에서 Thread-Safe 하다는 전제 하에 적용)
안정성 개선: 스트리밍 중 예외 처리의 한계 ✅
- 이슈
- ToneConverterAPIView의 try-except 블록은
- 초기 연결 실패 시에는 400, 500 에러를 잘 반환함
- 하지만 StreamingHttpResponse가 이미 시작되어
- 클라이언트로 200 OK 상태 코드가 날아간 이후에 텍스트를 생성하다가
- 오류(예: Google 서버 일시 끊김)가 발생하면
- 서버는 상태 코드를 500으로 바꿀 수 없고 그냥 연결을 뚝 끊어버리게 됨
- 개선 가이드
- 프론트엔드가 중간 에러를 명확히 인지할 수 있도록
- 순수 텍스트(문자열 바이트)만 yield 하는 대신 구조화된 SSE(Server-Sent Events) 포맷을 사용
Post
구조 및 가독성 개선: 뷰(View)의 중복 코드 제거 ✅
- 이슈
- post_api.py의 PostAPIView와 MyPostAPIView를 보면
- 쿼리 파라미터(series, tag, search)를 파싱하고
- 페이징 객체(paginator)를 다루는 코드가 완전히 중복
- 개선 가이드
- DRF의 GenericAPIView 또는 ViewSet을 상속받도록 리팩토링하고
- django-filter 라이브러리를 도입하면
- 파라미터 파싱 및 페이징 코드를 직접 작성할 필요 없이
- 한두 줄의 클래스 속성 선언으로 가독성을 대폭 향상시킬 수 있음
성능 및 로직 개선: 자동 요약(Summary) 기능의 한계 ✅
- 이슈
- create_post에서 요약이 없을 때
content[:150]으로 단순 문자열 자르기를 시도함
- 만약 content가 HTML 태그를 포함한 에디터 데이터라면
- 중간에
<div> 같은 태그가 잘리면서 프론트엔드에서 렌더링 레이아웃이 깨질 위험이 있음
- 개선 가이드
- 장고의 내장 유틸리티인
django.utils.html.strip_tags를 사용하여
- 본문에서 순수 텍스트만 추출한 뒤 150자로 자르도록 수정하는 것을 권장
안정성 개선: get_or_create의 예외 처리 부족 ✅
- 이슈
add_post_like 서비스에서
Like.objects.get_or_create(post=post, user=user)를 호출
- 만약 아주 짧은 순간에 동일한 유저가 여러 번 클릭을 보내면(동시성 이슈)
- DB의
UniqueConstraint에 의해 장고에서 IntegrityError가 발생하며
- 서버가 500 에러를 뱉을 수 있음
- 해결 가이드
- 예외를 안전하게 잡아 클라이언트에게 적절한 400번대 응답을 주도록 처리해야 함
from django.db import IntegrityError
try:
Like.objects.get_or_create(post=post, user=user)
except IntegrityError:
pass
쿼리 최적화: is_liked_by_user 서브쿼리 위치 조정
- 이슈
- get_post_detail에서는 Exists를 통해 좋아요 여부를 확인하고 있음
- 하지만 피드 목록(get_global_posts 등)을 조회할 때는
- 유저가 각 게시글에 좋아요를 눌렀는지 알 수 있는 값이 내려가지 않음
- 이로 인해 프론트엔드에서 목록 화면의 하트 아이콘을 칠해야 할지 말아야 할지 판단하기 어려움
- 해결 가이드
- 목록 조회 서비스 함수에도 인증된 유저가 요청을 보낸 경우
Exists(is_liked_subquery) 조건을 annotate에 추가하여
- 목록에서도 자신의 좋아요 여부를 파악할 수 있도록 프론트엔드-백엔드 간의 데이터 규격을 맞추가
성능 오버헤드 개선: 불필요한 트랜잭션(@transaction.atomic) 제거
- 이슈
- update_comment_service.py와 delete_comment_service.py 상단에
- @transaction.atomic 데코레이터가 선언되어 있음
- 이 로직들은 단순히 단일 레코드 하나를 save() 하거나 delete() 하는 작업만 수행
- 장고는 단일 쿼리에 대해 기본적으로 Auto-commit 속성을 통한 원자성을 보장하므로
- 별도의 트랜잭션 블록을 생성하는 것은 DB 연결 자원을 미세하게 낭비하는 오버헤드가 될 수 있음
- 해결 가이드
- 다중 쿼리(여러 테이블을 동시에 수정)가 아니므로
- 해당 파일들에서 @transaction.atomic 데코레이터를 제거하여 성능을 최적화하는 것을 권장
논리적 무결성 개선: 뷰(View)의 퍼미션(Permission) 설정 수정
- 이슈
- 수정과 삭제를 담당하는 CommentManageAPIView의
permission_classes가 [IsAuthenticatedOrReadOnly]로 설정되어 있음
- 이 엔드포인트는 PUT과 DELETE 메서드만 존재하며 읽기(GET) 메서드가 없음
- 즉, 비로그인 유저가 읽기 용도로 접근할 일이 없는 API
- 해결 가이드
- 비로그인 유저의 잘못된 요청이 서비스 레이어까지 도달하여 불필요한 연산을 발생시키지 않도록
CommentManageAPIView의 권한을
permission_classes = [IsAuthenticated]로 변경하여
- 진입 단계에서부터 401 Unauthorized 에러로 튕겨내야 함
데이터 보존 정책: 댓글 물리 삭제(Hard Delete)의 한계
- 이슈
- 게시글(Post) 시스템은
deleted_at을 활용한 소프트 딜리트(휴지통)를 구현하여
- 실수로 삭제한 데이터를 복구할 수 있는 반면
- 댓글 삭제 서비스는
comment.delete()를 호출하여
- DB에서 데이터를 영구히 날려버리고(Hard Delete) 있음
- 해결 가이드
- 사용자 경험의 일관성을 맞추거나 악의적인 댓글 작성 후 삭제(증거 인멸)를 방어하기 위해
- Comment 모델에도 deleted_at 필드를 추가하여
- 소프트 딜리트 방식으로 리팩토링하는 것을 권장
Tag
데이터 무결성 개선: 휴지통(Soft Delete) 게시글 제외 처리
- 이슈
tag_count_service.py에서 태그 수를 집계할 때, 단순히 연결된 글의 개수만 세게 되면
- 사용자가 휴지통에 버린 글(deleted_at__isnull=False)이나 비공개 글까지
- 집계에 포함될 우려가 높음
- 이러면 화면에는 "Django 태그 (5개)"라고 뜨는데
- 클릭해 보면 공개된 글이 3개밖에 없는 데이터 불일치가 발생함
- 개선 가이드
- 집계 쿼리 내부에 filter 조건을 명시하여 '살아있는 공개 글'만 카운트하고
- 카운트가 0인 태그는 목록에서 제외하는 로직을 추가해야 함
from django.db.models import Count, Q
tags_with_count = Tag.objects.annotate(
valid_post_count=Count(
'posts',
filter=Q(posts__deleted_at__isnull=True, posts__visibility='PUBLIC')
)
).filter(valid_post_count__gt=0)
성능 최적화: 통계 API 캐싱(Caching) 적용
- 이슈
- 태그 클라우드(목록 및 통계)는 블로그 방문자들이 메인 페이지에 접속할 때마다
- 매우 빈번하게 호출되지만, 매초마다 극적으로 숫자가 변하는 데이터는 아님
- 매 요청마다 DB에서 무거운 GROUP BY 연산을 수행하는 것은 서버 리소스 낭비
- 개선 가이드
- Redis를 활용하여 tag_api.py 혹은 서비스 로직에 장고 캐시를 적용하는 것을 권장
from django.core.cache import cache
def get_tag_counts():
tags = cache.get("global_tag_counts")
if not tags:
tags = list(Tag.objects.annotate(...))
cache.set("global_tag_counts", tags, timeout=3600)
return tags
품질 향상: 문자열 정규화 (소문자/여백 처리)
- 이슈
- 사용자들은 태그를 입력할 때 "Python", "python", " python " 등 제각각으로 입력
- 별도의 정규화 과정이 없다면 DB에 이 세 가지가 전혀 다른 태그로 생성되어 통계와 검색 결과가 파편화됨
- 개선 가이드
- 태그를 생성하거나 등록하는 서비스 로직의 앞단(또는 시리얼라이저)에서
- 입력된 태그 문자열의 양옆 공백을 자르고(strip())
- 모두 소문자로 강제 변환(lower())하는 전처리 파이프라인을 추가
Series
사용자 경험(UX) 개선: 시리즈 삭제 시의 사이드 이펙트(부작용) 핸들링
- 이슈
- 현재 코드는 시리즈를 데이터베이스에서 영구 삭제(Hard Delete)
- Post 모델에서 시리즈를
FK로 참조하며 on_delete=models.SET_NULL이 설정되어 있다면,
- 시리즈 삭제 시 해당 시리즈에 속했던 게시글들은 소속을 잃고 흩어지게 됨
- 해결 가이드
- 유저가 실수로 시리즈를 삭제하는 것을 방지하기 위해 삭제 전
- "이 시리즈에 포함된 N개의 게시글이 소속을 잃게 됩니다. 그래도 삭제하시겠습니까?"
- 같은 프롬프트를 띄울 수 있도록, 삭제 API 응답이나 별도 API를 통해
- '시리즈에 종속된 게시글의 개수'를 미리 체크해 주는 로직을 추가하는 것을 권장
- 이슈
- SeriesAPIView의 get 메서드에는 현재 페이징 처리가 되어있지 않음
- 만약 유저가 블로그를 오래 운영하여 1,000개의 시리즈를 만들었다면
- API 호출 한 번에 1,000개의 데이터가 한꺼번에 직렬화되어 내려가므로
- 서버 부하와 네트워크 지연이 발생
- 해결 가이드
- 다른 View(예: PostAPIView)에서 사용한 것처럼
pagination_class = SeriesPageNumberPagination 등을 도입하여
- 한 번에 20~30개씩 잘라서 응답하도록 개선하는 것이 대규모 트래픽 대비에 좋음
논리적 무결성: 반환 타입(Return Type) 일관성 유지
- 이슈
- SeriesListSerializer를 보면
- 응답에 id, name, created_at, updated_at을 포함하고 있음
- 하지만 시리즈 생성(POST)과 수정(PUT) API에서는 시리얼라이저를 재사용하지 않고
{"id": series.id, "message": ...} 형태의 임의의 딕셔너리를 반환하고 있음
- 해결 가이드
- 프론트엔드 상태 관리를 용이하게 하기 위해 생성/수정 성공 시에도
- 저장된 객체의 전체 정보(특히 업데이트된 시간 등)를 내려주는 것이 REST API 표준임
return Response(
SeriesListSerializer(updated_series).data,
status=status.HTTP_200_OK
)
다른 대안
User
다른 대안
인증번호 상태 관리의 대안 (RDBMS 테이블 활용)
- 현재 방식
- 대안
- DB에 EmailVerification 같은 별도 테이블을 만들고 created_at 컬럼을 둠
- 사용자가 인증번호를 입력할 때
- 현재 시간과 created_at의 시간 차이가 5분 이내인지 DB 쿼리문으로 직접 계산하여 검증하는 방식
소셜 로그인 연동 방식의 대안 (외부 패키지 도입)
- 현재 방식
- requests 라이브러리를 통해 Github, Discord의 API 엔드포인트로 직접 HTTP 통신을 보내어
- 액세스 토큰과 유저 정보를 파싱
- 대안
- 장고 생태계의 표준인 django-allauth와 dj-rest-auth 라이브러리를 도입하여
- 소셜 로그인 파이프라인 흐름, 콜백 처리, 소셜 모델 연결을 패키지에 위임 가능
AI
다른 대안
스트리밍 대안 (동기식/블로킹 방식):
- 현재 방식
- generate_content_stream과 yield를 활용
- 대안
- API의 스트리밍 기능을 끄고(generate_content 사용) 서버가 AI 텍스트 생성을 끝까지 기다림
- 완성된 하나의 큰 문자열을 일반적인 DRF의 Response({"converted_text": ...}) 형태로
비동기 큐와 웹소켓 (Celery + WebSockets) 활용
- 현재 방식
- 대안
- AI 요청 자체를 백그라운드 워커(Celery)로 넘기고
- HTTP 응답은 즉시 "작업 접수됨"으로 끝냄
- 이후 워커가 처리를 완료하거나 조각을 생성할 때마다
- WebSocket이나 Redis Pub/Sub을 통해 클라이언트에게
- 푸시(Push) 알림을 보내는 방식
- 대규모 트래픽에서 HTTP 스레드 고갈을 막을 수 있음
Post
다른 대안
소프트 딜리트 관리 대안 (커스텀 매니저 활용)
- 현재 방식
- 삭제된 글을 제외해야 하는 뷰나 서비스에서
- 매번
deleted_at__isnull=True 조건을 수동으로 붙여줘야 할 가능성이 있음
- 대안
- 장고의
models.Manager를 상속받아 ActivePostManager를 만들고
- 기본 매니저(objects)가 항상
deleted_at__isnull=True인 쿼리셋만 반환하도록 재정의
- 삭제된 글은
all_objects = models.Manager() 같은
- 별도 매니저로만 접근하게 하면 휴먼 에러를 막을 수 있음
태그 관계 설계의 대안 (비정형 데이터 활용)
- 현재 방식
- Tag 테이블과 PostTag 중간 테이블을 생성하여 정통적인 RDBMS의 M:N 관계를 구현
- 대안
- PostgreSQL을 사용 중이라면
ArrayField(models.CharField())를 사용하거나
- 범용적인
JSONField를 사용하여 태그 문자열 배열을 하나의 컬럼에 직접 저장 가능
- 태그별 통계가 매우 중요하지 않다면
- 중간 테이블을 없애 조인 비용을 줄이고 쓰기 속도를 극대화 가능
좋아요 카운트 관리 방식의 대안 (역정규화)
- 현재 방식
- get_global_posts 등에서
Count("likes")를 사용하여
- 매번 DB 조인을 통해 좋아요 개수를 동적으로 계산
- 대안
- 트래픽이 커질 경우 쿼리 부하가 심해질 수 있음
- Post 모델 자체에 like_count라는 정수형 컬럼을 추가하고
- 좋아요가 생성/삭제될 때마다 장고의 F 객체(F('like_count') + 1)를 사용해
- 값을 업데이트(역정규화)하는 방식으로 읽기 성능을 극대화 가능
검색(Search) 구현의 대안 (전문 검색 엔진 도입)
- 현재 방식
- RDBMS의 LIKE %keyword% 연산(장고의
icontains)을 사용
- 대안
- 데이터가 많아지면
icontains는 인덱스를 타지 못해 성능이 급격히 저하
- PostgreSQL의
SearchVector를 활용한 Full-Text Search를 도입하거나,
ElasticSearch 같은 외부 전문 검색 엔진을 도입하여
- 형태소 분석 및 초고속 검색을 지원하도록 개선 가능
다른 대안
작성자 권한 검증 방식의 대안 (DRF Permission 클래스 활용)
- 현재 방식
- 서비스 레이어 내부에서 if comment.user != user: 조건문을 통해 직접 에러를 발생시킴
- 대안
- 비즈니스 로직에 도달하기 전, 뷰(View) 단계에서 DRF의
- 커스텀 퍼미션(BasePermission 상속)인
IsOwnerOrReadOnly 같은
- 클래스를 만들어
permission_classes에 선언하는 방법 존재
- 이렇게 하면 권한 체크 로직을 View 계층의 앞단으로 분리 가능
댓글 구조화 대안 (계층형/대댓글 구조)
- 현재 방식
- 모든 댓글이 게시글 하나에만 종속되는 1차원적인 '평면(Flat) 구조'
- 대안
- 모델에 자기 자신을 참조하는
parent = models.ForeignKey('self', null=True) 필드를 추가
django-mptt 라이브러리를 도입하여
- '대댓글(Nested Comments)' 기능을 지원하는 트리(Tree) 구조로 확장 가능
Tag
다른 대안
다대다(M:N) 관계 설계의 대안 (PostgreSQL ArrayField 도입)
- 현재 방식
- Tag, PostTag, Post 3개의 테이블을 쪼개어 정규화하고, 조인(JOIN)을 통해 관계를 형성
- 대안
- 만약 PostgreSQL을 사용한다면 RDBMS의 조인 비용을 아예 없애기 위해
- Post 모델에
tags = ArrayField(models.CharField(...))를 선언하여
- 태그들을 문자열 배열 형태로 한 컬럼에 직접 밀어 넣는 반정규화 방식을 취할 수 있음
- 읽기 속도는 극한으로 빨라지지만, 특정 태그의 이름을 일괄 변경할 때 다소 번거로울 수 있음
태그 카운트(집계) 로직의 대안 (역정규화 방식)
- 현재 방식
- API가 호출될 때마다 서비스 함수에서 동적으로 GROUP BY와 COUNT 쿼리를 실행하여 개수를 계산
- 대안
- 태그와 게시글이 수십만 개로 늘어나면 매번 카운트를 세는 것이 무거워짐
- Tag 모델에
post_count라는 정수형 컬럼을 추가하고
- 글에 태그가 달리거나 삭제될 때마다 post_count의 숫자를 +1, -1 업데이트해 두는 방식
- 이렇게 하면 조회 API에서 단순히 값을 읽어오기만 하면 되므로 응답 속도가 획기적으로 개선
Series
다른 대안
중복 검증 로직의 대안 (App-Level Validation 활용)
- 현재 방식
- DB 에러(IntegrityError)를 try-except로 직접 잡아냄
- 대안
- DRF의 Serializer 내부에
validators = [UniqueTogetherValidator(queryset=Series.objects.all(), fields=['user', 'name'])]를 선언하여
- 뷰(View)의
is_valid() 단계에서 미리 중복을 걸러내는 방식
- 이렇게 하면 서비스 레이어에 에러 처리 코드를 두지 않아도 되어 코드가 더 깔끔해질 수 있음
삭제 처리 성능 최적화 대안 (Queryset 직접 삭제)
- 현재 방식
- 삭제할 때 filter(...).first()로 객체를 DB에서 메모리로 한 번 불러온(SELECT) 뒤, series.delete()(DELETE)를 수행합니다.
- 대안
- 객체를 굳이 메모리로 가져올 필요 없이
deleted_count, _ = Series.objects.filter(id=series_id, user=user).delete()
- 한 줄로 처리할 수 있음
deleted_count가 0이면 존재하지 않거나 남의 것이므로 에러를 발생시키면 됨
- 불필요한 SELECT 쿼리 1회를 아낄 수 있는 고성능 튜닝 기법
배포
AWS 기반 도커 컨테이너 배포 및 CI/CD 자동화 파이프라인 구축
인프라 환경 구축 및 최적화
- AWS EC2(t3.small) 인스턴스를 프로비저닝하고
- 고정 IP(Elastic IP) 및 가비아 도메인 DNS 설정을 완료
- t3 인스턴스의 제한된 RAM 용량으로 인한 OOM(Out of Memory) 현상을 방지하기 위해
- 2GB의 스왑 메모리(Swap Memory)를 할당하여 서버 안정성을 확보
웹 서버 및 HTTPS(SSL) 보안 적용
- S3(대용량 미디어)와 Nginx(정적 파일 캐싱)를 분리하여 파일 서빙 비용과 성능을 최적화
- Let's Encrypt(Certbot)를 이용해 SSL 인증서를 발급받고
- Nginx의 리버스 프록시 설정을 통해 80번(HTTP) 포트 요청을
- 443번(HTTPS)으로 자동 리다이렉트하도록 구축
GitHub Actions를 활용한 CI/CD 구축
- main 브랜치에 코드가 푸시되면 GitHub Actions가 동작하여 AWS EC2에 SSH로 접속하고
- 최신 코드를 pull 받아 Docker Compose를 재빌드하도록 배포 자동화 스크립트를 작성
배포 환경 트러블슈팅 (Troubleshooting)
- 도커 네트워크 분리 문제 해결
- 로컬 환경의 127.0.0.1로 설정되어 있던 DB 호스트를
- 도커 컴포즈의 서비스명(db)으로 변경하여 컨테이너 간 통신 에러를 해결
OAuth 동적 Redirect URI 설정
- 소셜 로그인 시 로컬 호스트로 하드코딩되어 있던 콜백 주소를
- 장고의 request.build_absolute_uri()를 활용해 동적으로 생성하도록 리팩토링하여
- 로컬과 배포 환경 모두에서 정상 동작하도록 개선
전문적
인프라 및 배포 (Infrastructure & Deployment)
- AWS EC2, Nginx, Gunicorn 기반의 프로덕션 환경을 구축하고
- Docker Compose를 활용하여 백엔드 웹 서버와 데이터베이스를 컨테이너화하여 배포 환경의 일관성을 확보
- Let's Encrypt를 활용한 SSL 인증서 발급 및 Nginx 리버스 프록시 설정으로 HTTPS 보안 통신을 적용
- GitHub Actions와 SSH를 연동하여 코드 푸시 시 자동으로 서버에 접속해 컨테이너를 재빌드하는
- CI/CD 자동화 파이프라인을 구축하여 배포 소요 시간을 단축
- EC2 t3 인스턴스의 메모리 부족(1GB)으로 인한 서버 다운(OOM) 문제를 해결하기 위해
- 2GB의 스왑 메모리(가상 램)를 설정하여 무중단 운영 안정성을 확보
- 정적 파일(Static)은 Nginx가 캐싱하여 직접 서빙하고
- 유저 업로드 파일(Media)은 AWS S3로 분리하여 서버의 부하를 줄이고 응답 속도를 최적화
트러블슈팅 (Troubleshooting)
- 도커 네트워크 환경에서 발생하는 DB Connection Refused 에러를 컨테이너 간 서비스명(DNS) 매핑으로 해결
- 배포 환경에서 소셜 로그인(GitHub, Discord) 콜백이 실패하는 문제를 해결하기 위해
- build_absolute_uri()를 활용해 현재 접속 호스트를 기반으로 동적인 Redirect URI를 생성하도록 결합도를 낮춤