코드 수정
등급관리 로직
현재
fetchMyGardenStats
- 사용자의 글 개수와 잔디밭(히트맵) 데이터를 구하기 위해 while 문을 돌며
- 모든 페이지의 게시글을 프론트엔드로 전부 가져오고(N+1 문제 발생)있음
- 이는 글이 100개, 1000개로 늘어나면 브라우저가 느려지고 서버 트래픽이 폭주하는
개선방안
- 백엔드에서 '사용자의 총 글 개수' / '히트맵용 날짜 데이터' / '계산된 등급 정보'를
- 한 번에 던져주는 통계 전용 API를 만들기 필요
service
from apps.post.models import Post
GRADE_SETTINGS = [
...
]
def get_user_garden_stats(user):
"""
특정 유저의 정원(잔디밭 및 등급) 통계 데이터를 계산하여 반환하는 서비스 함수
"""
user_posts = Post.objects.filter(author=user, is_temp=False)
total_count = user_posts.count()
post_dates = list(user_posts.values_list('created_at__date', flat=True))
formatted_dates = [
date.strftime('%Y-%-m-%-d') if hasattr(date, 'strftime') else str(date)
for date in post_dates
]
current_grade = next(
(g for g in GRADE_SETTINGS if total_count >= g["min"]),
GRADE_SETTINGS[-1]
)
reversed_settings = list(reversed(GRADE_SETTINGS))
next_grade = next(
(g for g in reversed_settings if g["min"] > total_count),
None
)
progress_percent = 100
remain_posts = 0
if next_grade:
remain_posts = next_grade["min"] - total_count
required_for_next = next_grade["min"] - current_grade["min"]
earned_in_current = total_count - current_grade["min"]
if required_for_next > 0:
progress_percent = (earned_in_current / required_for_next) * 100
return {
"total_count": total_count,
"current_grade": current_grade,
"next_grade": next_grade,
"progress_percent": progress_percent,
"remain_posts": remain_posts,
"heatmap_dates": formatted_dates
}
View
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from apps.user.services.users_stat_service import get_user_garden_stats
class UserGardenStatsAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
stats_data = get_user_garden_stats(request.user)
return Response(stats_data)
수정에 따른 장점
수정 전 (프론트엔드 로직)
- 내 등급을 계산하기 위해 프론트엔드에서 while 문을 돌며
- 내가 쓴 모든 게시글 데이터를 끝까지 다 불러와야 했음
- 만약 글이 1,000개라면 1,000개의 제목, 내용, 썸네일 데이터가 모두
- 네트워크를 타고 브라우저로 전송 (데이터 요금 폭탄, 로딩 지연 발생)
수정 후 (백엔드 로직)
- 백엔드에서 데이터베이스(DB)에게 "이 유저 글이 총 몇 개야?"라고 물어보면
- DB 내부에서 최적화된 연산(COUNT())으로 단 0.001초 만에 숫자만 반환
- 브라우저로는 무거운 게시글 데이터 대신 "글 100개, 레벨 5, 80% 진행됨"이라는
- 아주 가벼운 텍스트(JSON)만 전송되므로 속도가 수십 배 이상 빨라짐
강력한 보안과 데이터 무결성 (Security)
수정 전
- 프론트엔드(브라우저)에 작성된 자바스크립트 코드는 누구나 F12(개발자 도구)를 눌러서
- 악의적인 사용자가 자신의 브라우저에서
total_count = 9999로 변조하여
- 최고 레벨 아이콘을 띄우는 등 비즈니스 로직을 속일 수 있었음
수정 후
- 등급의 기준(GRADE_SETTINGS)과 계산 로직이 백엔드 서버 깊숙한 곳에 숨겨짐
- 프론트엔드는 서버가 주는 결과를 그저 화면에 보여주기만 하므로
- 사용자가 임의로 자신의 등급이나 데이터를 조작하는 것이 원천적으로 불가능해짐
단일 진실 공급원과 유지보수성 (Single Source of Truth)
수정 전
- 만약 나중에 "레벨 2 승급 기준을 글 5개에서 10개로 올리자!"라고 기획이 변경되면
- 나중에 모바일 앱(iOS, Android)을 출시하게 되면
- 각각의 앱 코드도 다 수정해야 하는 대참사 발생
수정 후
- 백엔드가 '단일 진실 공급원(하나의 기준점)'이 됨
- 등급 기준이 바뀌거나 이미지가 변경되더라도
- 백엔드의
services.py 코드 한 줄만 수정하면 웹사이트
- 모바일 앱 등 모든 프론트엔드 화면에 일제히 새로운 기준이 적용됨
완벽한 역할 분리 (Separation of Concerns)
프론트엔드
- 오직 "사용자에게 어떻게 예쁘게 보여줄 것인가(UI/UX)"에만 집중
- 프로그레스 바 애니메이션 채우기, 잔디밭 색칠하기 등
백엔드
- 오직 "어떻게 정확하고 빠르게 데이터를 계산할 것인가(비즈니스 로직)"에만 집중 가능
오류

user_posts = Post.objects.filter(author=user, is_temp=False)
total_count = user_posts.count()
——————————————————————————————————————[비교]—————————————————————————————————————————
user_posts = Post.objects.filter(user=user, is_temp=False)
total_count = user_posts.count()
- Post테이블의 user컬럼명으로 존재하기때문에 author로 하면 안됨
class Post(TimeStampedModel):
...
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts"
)
...
최적화
날짜 데이터 중복 제거 (QuerySet 최적화)
- 현재 로직은 하루에 글을 10개 쓰면 post_dates 배열에 같은 날짜가 10번 중복해서 들어감
- 프론트엔드에서는 잔디밭에 '글을 쓴 날(존재 여부)'만 확인하면 되므로
- 데이터베이스 단계에서 중복을 제거하고 가져오는 것이 훨씬 효율적
post_dates = list(user_posts.values_list("created_at__date", flat=True))
——————————————————————————————————————[비교]—————————————————————————————————————————
post_dates = list(user_posts.values_list("created_at__date", flat=True).distinct())
진행률(%) 소수점 처리
- 나누기 연산을 하면 33.3333333333% 처럼 소수점이 길게 나올 수 있음
- JSON 응답 데이터를 깔끔하게 만들고 프론트엔드 렌더링을 돕기 위해
progress_percent = (earned_in_current / required_for_next) * 100
——————————————————————————————————————[비교]—————————————————————————————————————————
progress_percent = int((earned_in_current / required_for_next) * 100)
기능구현
마이페이지
- 프로필 이미지 / 작성 글 목록 / 등급 / 작성 글 개수 를 한번에 내려주는 api를 작성하면
- 작성 글 목록은 페이지네이션이 필수인데 유저 프로필 정보(이미지, 등급 등)와 글 목록이
- 하나의 API로 묶여있으면 페이지를 넘길때마다 유저정보까지 중복으로 같이 불러와야함
필요 API
내 프로필/통계 정보 API
- 역할
- 프로필 이미지, 닉네임, 한 줄 소개(bio), 등급(현재 등급, 다음 등급 등)
- 총 작성 글 개수를 반환
내 작성 글 목록 API (기존 API 재사용)
시리얼라이저
from rest_framework import serializers
class UserInfoSerializer(serializers.Serializer):
email = serializers.EmailField(help_text="유저 이메일")
nickname = serializers.CharField(help_text="유저 닉네임")
profile_img = serializers.CharField(allow_null=True, required=False, help_text="프로필 이미지 URL")
bio = serializers.CharField(allow_null=True, required=False, help_text="자기소개")
class UserStatsSerializer(serializers.Serializer):
total_post_count = serializers.IntegerField(help_text="총 작성 글 개수")
current_grade = serializers.DictField(help_text="현재 등급 정보")
next_grade = serializers.DictField(allow_null=True, help_text="다음 등급 정보 (최고 레벨이면 null)")
progress_percent = serializers.IntegerField(help_text="다음 등급까지의 진행률(%)")
class UserProfileResponseSerializer(serializers.Serializer):
user_info = UserInfoSerializer(help_text="유저 기본 정보")
stats = UserStatsSerializer(help_text="유저 활동 통계")
view
class UserProfileAPIView(APIView):
"""마이페이지 상단에 표시될 유저 프로필 및 활동 통계 정보를 제공합니다."""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["회원관리"],
summary="마이페이지 프로필 조회",
responses={200: UserProfileResponseSerializer}
)
def get(self, request):
user = cast(User, request.user)
stats_data = get_user_garden_stats(user)
raw_data = {
"user_info": {
"email": user.email,
"nickname": user.nickname,
"profile_img": user.profile_img,
"bio": user.bio,
},
"stats": {
"total_post_count": stats_data["total_count"],
"current_grade": stats_data["current_grade"],
"next_grade": stats_data["next_grade"],
"progress_percent": stats_data["progress_percent"],
}
}
serializer = UserProfileResponseSerializer(instance=raw_data)
return Response(serializer.data)
프로필이미지 업로드
고민
현재
- S3 저장 경로가
post/thumbnails/{today}/{unique_filename}으로 하드코딩
- 이 API를 그대로 프로필 이미지에 사용하면
- 프로필 사진이 '게시글 썸네일' 폴더에 섞여서 저장됨
- 나중에 S3 용량을 관리하거나 이미지를 정리할 때 귀찮아질 것 같음
해결 방안
- 기존
PresignedUrlAPIView가 업로드 목적(게시글용인지, 프로필용인지)을
- S3에 업로드한 후, 프론트엔드가 발급받은 이미지 URL을 DB에 저장할 수 있도록
UserProfileAPIView에 PATCH (수정) 메서드를 추가
domain 파라미터 추가
ext = filename.split(".")[-1]
unique_filename = f"{uuid.uuid4().hex}.{ext}"
today = datetime.now().strftime("%Y/%m/%d")
object_name = f"post/thumbnails/{today}/{unique_filename}"
——————————————————————————————————————[비교]—————————————————————————————————————————
domain = request.query_params.get("domain", "post_thumbnail")
ext = filename.split(".")[-1]
unique_filename = f"{uuid.uuid4().hex}.{ext}"
today = datetime.now().strftime("%Y/%m/%d")
if domain == "profile":
object_name = f"user/profiles/{today}/{unique_filename}"
else:
object_name = f"post/thumbnails/{today}/{unique_filename}"
프로필 수정용 시리얼라이저 추가
- 사용자가 프로필 이미지뿐만 아니라 닉네임, 한 줄 소개를 수정할 수도 있으므로
- 정보 수정을 위한 요청(Request)용 시리얼라이저를 추가
class UserProfileUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["nickname", "profile_img", "bio"]
extra_kwargs = {
"nickname": {"required": False},
"profile_img": {"required": False},
"bio": {"required": False},
}
마이페이지 정보 수정(PATCH) 추가
- PATCH 메서드를 추가하여 전달받은 S3 URL을 실제 유저 DB에 덮어씌움
def patch(self, request):
user = cast(User, request.user)
serializer = UserProfileUpdateSerializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
stats_data = get_user_garden_stats(user)
raw_data = {
"user_info": {
"email": user.email,
"nickname": user.nickname,
"profile_img": user.profile_img,
"bio": user.bio,
},
"stats": {
"total_post_count": stats_data["total_count"],
"current_grade": stats_data["current_grade"],
"next_grade": stats_data["next_grade"],
"progress_percent": stats_data["progress_percent"],
}
}
response_serializer = UserProfileResponseSerializer(instance=raw_data)
return Response(response_serializer.data)
문제

- 401 (Unauthorized) 및 "불러오지 못했습니다" 에러
- 의미: 백엔드(서버)가 "누구인지 알 수 없어 접근 권한이 없다"고 요청을 거절한 상태
- 원인: 프론트엔드 코드 간의 저장소 키(Key) 이름 불일치 때문
- login.html 파일에서는 로그인이 성공했을 때 토큰을 access_token이라는
- 그런데
mypage.html 파일에서는 이 토큰을 꺼내올 때 access라는 이름으로 찾고 있음
- 이름이 다르니 당연히 토큰을 찾지 못해 null 값을 서버로 보내게 되고
- 서버는 인증되지 않은 사용자로 판단하여 401 에러를 발생시킨 것
- 이로 인해 프로필 정보와 게시글을 가져오는 로직이 실패
- 404 (Not Found) 에러
- 의미: 요청한 리소스(주소나 파일)를 서버에서 찾을 수 없다는 뜻
- 원인: 데이터 통신 에러와는 별개로
mypage.html에 명시된 기본 프로필 이미지 파일(/static/img/default_profile.png)이
- 실제 서버 경로에 존재하지 않아서 발생했을 가능성이 매우 높음
해결
mypage.html에서 토큰을 가져오는 함수의 키 이름을 login.html과 일치
// 로컬 스토리지에서 JWT Access Token을 가져오는 헬퍼 함수
function getAccessToken() {
return localStorage.getItem('access'); // 로그인 시 저장한 키 이름에 맞게 수정하세요.
}
——————————————————————————————————————[비교]—————————————————————————————————————————
// 로컬 스토리지에서 JWT Access Token을 가져오는 헬퍼 함수
function getAccessToken() {
return localStorage.getItem('access_token'); // 로그인 시 저장한 키 이름에 맞게 수정하세요.
}
-
(프로젝트 폴더)/static/img/default_profile.png 경로에 이미지 추가

-
결과

문제2

첫 번째 에러
- Cannot set properties of null (setting 'innerText')
- 원인
- 자바스크립트의 fetchUserProfile 함수가
- 통계 정보(총 게시글 수, 등급, 프로그레스 바 등)를 업데이트하려고
- totalPostCount, gradeName 등의 ID를 가진 HTML 태그를 찾았으나
- 현재 mypage.html 파일에는 해당 ID를 가진 태그들이 존재하지 않기 때문
- 태그가 없으니 null을 반환하고, null에 innerText를 넣으려다 에러가 발생한 것
- 최선의 해결책
- HTML 요소를 업데이트하기 전에, 해당 요소가 화면(DOM)에
- 실제로 존재하는지 먼저 확인하는 방어적(Defensive) 코드를 작성하는 것이 최선
- 향후 HTML 구조가 변경되더라도 자바스크립트 에러로 인해 전체 페이지가 멈추는 것을 방지
두 번째 에러
- data.forEach is not a function
- 원인
- forEach는 데이터가 배열(Array,
[ ])일 때만 사용할 수 있는 반복문 함수
- 백엔드에서 게시글 목록을 불러왔을 때
- 순수한 배열이 아니라 페이지네이션(Pagination) 정보가 포함된
- 객체(Object,
{ }) 형태로 데이터를 보내주었기 때문에 발생한 에러
- (예:
{"count": 10, "next": null, "results": [...]})
- 최선의 해결책
- 백엔드 응답이 순수 배열인지, 아니면 results나 data라는 키(Key) 안에
- 배열이 숨어있는 객체인지 동적으로 파악하여 어떤 상황에서든 유연하게 배열을
- 추출해 내는 로직을 추가하는 것이 가장 견고한(Robust) 최선의 방식
프로필페이지
- 기존의 로직을 이용하기에는 현재 UserProfileAPIView 여기서는 유저식별자를 받지 않기 때문에
- 다른 사용자의 닉네임을 눌렀을때 그 사용자의 정보를 가져올 방법이 없음
- 따라서 파라미터로 다른 이용자의 정보를 받아오는 로직을 받으려 하는데
고민
- 원래 닉네임을 받아서 검색을 진행하려 했지만 현재 세팅 자체가 닉네임이 unique가 아니기 때문에
- 닉네임으로 진행하기 애매하고 그렇다고 중복금지 처리를 해도
- 지금 깃허브와 디스코드 소셜로그인을 진행하면 각각 기존의 디스코드 / 깃허브의 닉네임을 가지기 떼문에
- 소셜로그인의 닉네임 중복검사를 진행할 방법이 없다
닉네임 중복 처리
import uuid
def generate_unique_nickname(base_nickname: str) -> str:
"""
소셜 로그인 시 중복되지 않는 닉네임을 생성하는 함수입니다.
"""
nickname = base_nickname
while User.objects.filter(nickname=nickname).exists():
random_str = uuid.uuid4().hex[:4]
nickname = f"{base_nickname}_{random_str}"
return nickname
github_id = str(user_json.get("id"))
nickname = user_json.get("login")
...
with transaction.atomic():
...
if not user:
user = User.objects.create_user(
email=email,
nickname=nickname,
password=None,
)
...
——————————————————————————————————————[비교]—————————————————————————————————————————
github_id = str(user_json.get("id"))
nickname = user_json.get("login")
...
with transaction.atomic():
...
if not user:
unique_nickname = generate_unique_nickname(base_nickname)
user = User.objects.create_user(
email=email,
nickname=unique_nickname,
password=None,
)
...
discord_id = str(user_json.get("id"))
nickname = user_json.get("username")
email = user_json.get("email")
...
with transaction.atomic():
social_account = SocialAccount.objects.filter(
provider="discord", social_id=discord_id
).first()
if social_account:
user = social_account.user
else:
user = User.objects.filter(email=email).first()
if not user:
user = User.objects.create_user(
email=email,
nickname=nickname,
password=None,
)
SocialAccount.objects.create(
user=user, provider="discord", social_id=discord_id
)
——————————————————————————————————————[비교]—————————————————————————————————————————
discord_id = str(user_json.get("id"))
base_nickname = user_json.get("username")
email = user_json.get("email")
...
with transaction.atomic():
...
if not user:
unique_nickname = generate_unique_nickname(base_nickname)
user = User.objects.create_user(
email=email,
nickname=unique_nickname,
password=None,
)
...
수정에 의한 장점
- 사용자 경험 향상
- 가입 시 에러가 나거나 추가로 닉네임을 입력하는 창으로 넘기지 않고 "일단 가입을 완료"시켜줌
- 가입이 끝난 후, 원한다면 유저가 나중에 마이페이지에서 본인이 원하는 닉네임으로
- 자유롭게 바꿀 수 있도록 유도하는 것이 최신 트렌드
- 무한 루프 방지
uuid.uuid4()는 사실상 겹칠 확률이 0에 가까운 고유 값을 만들어주기 때문에
- 재사용성 향상
generate_unique_nickname 함수를 분리해 두었으므로
- 향후 구글, 카카오, 네이버 등 다른 소셜 로그인을 추가할 때도 똑같이 함수 한 줄만 호출하면
- 중복 처리가 끝
model 수정
class User(AbstractBaseUser, PermissionsMixin, TimeStampedModel):
...
nickname = models.CharField(
max_length=50,
...
)
——————————————————————————————————————[비교]—————————————————————————————————————————
class User(AbstractBaseUser, PermissionsMixin, TimeStampedModel):
...
nickname = models.CharField(
max_length=50,
unique=True,
...
)
serializer
- 타인의 프로필을 보여줄 때는 이메일을 제외한 공개용 시리얼라이저가 필요
class PublicUserInfoSerializer(serializers.Serializer):
nickname = serializers.CharField(help_text="유저 닉네임")
profile_img = serializers.CharField(
allow_null=True, required=False, help_text="프로필 이미지 URL"
)
bio = serializers.CharField(allow_null=True, required=False, help_text="자기소개")
class PublicUserProfileResponseSerializer(serializers.Serializer):
user_info = PublicUserInfoSerializer(help_text="유저 공개 정보")
stats = UserStatsSerializer(help_text="유저 활동 통계")
service
def get_public_profile(nickname: str) -> dict:
"""
닉네임을 기반으로 유저를 찾아 공개용 프로필 데이터를 조립해 반환하는 서비스 함수입니다.
"""
try:
target_user = User.objects.get(nickname=nickname)
except User.DoesNotExist:
raise BaseCustomException(ErrorMessage.USER_NOT_FOUND)
stats_data = get_user_garden_stats(target_user)
return {
"user_info": {
"nickname": target_user.nickname,
"profile_img": target_user.profile_img,
"bio": target_user.bio,
},
"stats": {
"total_post_count": stats_data["total_count"],
"current_grade": stats_data["current_grade"],
"next_grade": stats_data["next_grade"],
"progress_percent": stats_data["progress_percent"],
},
}
view
class PublicUserProfileAPIView(APIView):
"""특정 닉네임을 가진 사용자의 공개 프로필 및 활동 통계 정보를 제공합니다."""
permission_classes = [AllowAny]
@extend_schema(
tags=["회원관리"],
summary="타인 공개 프로필 조회 (닉네임 기반)",
responses={200: PublicUserProfileResponseSerializer},
)
def get(self, request, nickname):
raw_data = get_public_profile(nickname)
serializer = PublicUserProfileResponseSerializer(instance=raw_data)
return Response(serializer.data)
결과

닉네임 클릭
- 아 잘못짤라서 이미지 변경은 안보이네
- 변경 잘됨 gif잘못 잘라서 안보이는 것

확인요망
