그림을 그리는 사람들을 위한 커뮤니티, 그리미티
개발 기간 : 2025.01 ~ 2025.02
팀원 : FE 1인, BE 1인, DE 1인

로그인은 카카오와 구글 소셜 로그인을 지원하며, 다중 기기 로그인 시 편의성을 높이기 위해 JWT 기반의 accessToken, refreshToken 인증 방식을 적용했다.

메인 화면이다. 랭킹 부분은 모바일에선 Swiper를 사용해서 편의성을 높였다.
최신 그림은 무한스크롤로 탐색된다. 특정 그림을 클릭해서 상세 그림 조회 후 뒤로가기로 돌아오면 탐색하던 스크롤 위치로 되돌아온다.


알림은 다양한 상황에 따라 출력되며... 설정에서 알림 on/off를 통해 원하는 알림만 받을 수 있게끔 구현했다.

그림 등록 페이지이다. 사진은 열장까지 등록 가능하며 썸네일로 보일 대표 이미지를 선택할 수 있다. 그림 순서는 DnD를 사용하여 변경이 가능하다.

그림이 두장 이상일 땐 두장까지만 보여주고 [전체 보기] 버튼을 클릭하면 나머지 그림이 출력되도록 구현하였다.
태그 클릭 시 해당 태그 검색 결과 화면으로 이동한다.
좋아요와 북마크 기능을 통해 아카이빙 가능하다.
공유는 URL 복사, 트위터 공유, 카카오톡 공유가 가능하다.

사용자 편의를 고려하여 그림 클릭 시 확대 기능을 추가했다.

댓글은 댓글, 답글, 멘션, 댓글 좋아요로 구성되어 있으며, 답글 조회는 답글 n개 보기 버튼을 클릭 시에 api 요청이 된다.

인기 태그와 인기 유저도 pc에서는 가로스크롤로 마우스 휠로 좌우 이동이 가능하게 하였고, 모바일에서는 Swiper를 사용하여 탐색되게 했다. 편하겠지...?ㅎㅎ

검색은 그림, 작가, 게시글 세 부분을 분리해서 결과를 조회하도록 하였다.

야심작 자유게시판 ㅋㅋ 사람들이 많이 써줬으면 좋겠는데... 왜 안쓰지...
질문, 피드백으로 말머리 구분되게 하였고.. 작가, 제목+내용으로 검색도 가능하다! 그리고 게시글 관련 부분은 전부 페이지네이션이다! 글이 없어서 예시를 보여줄 수 없다....ㅠ

글 작성하는 부분.... 내가 원하는 기능이 있는 에디터 찾기가.. 참 힘들었다... 이 내용은 아래 트러블슈팅에서 얘기할게요........ㅠ

게시글은 그림 댓글과 로직이 다르다! 답글 더보기를 누르지 않아도 한번에 댓글과 답글을 전부 보여준다!
게시글도 URL 복사, 트위터, 카카오톡으로 공유할 수 있도록 했다.

팔로잉 탭은 피드처럼 내가 팔로잉하는 유저의 그림을 무한스크롤로 쭉 볼 수 있다! 로딩 속도 및 서버 부담을 줄이기 위해 팔로잉 피드에서는 댓글보기 버튼을 눌러야 댓글 데이터를 가져오게 구현했다.


내가 좋아하는 프로필 페이지 ㅎ
커버 이미지가 꽉차게 보이는 시안인데.. 그냥 이미지를 등록하면 정가운데로 설정되어서.. 사용자들은 커버 이미지로 설정하고 싶은 영역을 크롭하고 싶을 거 같았다... 쉽진 않았지만 내가 해냄!

아카이빙 한 그림이나 글들은 마이페이지에서 볼 수 있다!

모~~든 이미지 등록 과정은 S3 Pre-Signed URL을 활용하여 진행되며, 업로드 전에 확장자를 webp로 변환하는 구조이다!
불필요한 트래픽을 줄이고, 효율적으로 이미지 업로드를 처리할 수 있었다. 하지만 이 구조를 처음 이해하는 데는 어려움이 있었다.... 굳이 이렇게 번거롭게 해야 하나? 싶었지만, 전체적으로 이 방식을 적용하니 통일성이 좋아졌고, 서버 부담도 확실히 줄어든 것을 체감했다!...
그림 여러 장을 업로드할 때 Swiper와 Drag & Drop 이벤트 충돌 문제가 발생했다. 한 행에 여러 장의 그림을 정렬하기 위해 Swiper를 사용했지만, DnD를 적용하여 순서를 변경하려고 할 때 드래그 이벤트와 Swiper의 터치 이벤트가 충돌하며 정상적으로 동작하지 않는 문제가 있었다.
이벤트 우선순위를 조정하는 방식으로 해결을 시도했지만, Swiper의 내부 동작과 완전히 분리하기 어려웠다. 결국 Swiper를 제거하고 가로 스크롤을 적용하는 방식으로 변경하여 원하는 기능을 안정적으로 구현할 수 있었다. 이를 통해 드래그 시 튕김 현상을 방지하고, 보다 자연스러운 인터랙션을 제공할 수 있었다....
자유게시판의 글쓰기 에디터를 구현하면서 여러 라이브러리를 검토했다.
ReactQuill: 보편적이고 사용이 간편하지만, ref 타입 에러가 많아 유지보수성이 떨어지고, 이미지 업로드 및 내부 이미지 Drag & Drop 기능이 제한적이라 적합하지 않았다.
TipTap: 강력한 확장성을 제공하지만, 모든 요소를 직접 커스텀 디자인해야 해서 개발 비용이 너무 컸다.
TinyMCE: 가장 무난한 선택지였지만, 클라우드 서비스를 이용할 경우 무료 업로드 횟수 제한이 있었다.
무료로 활용하기 위해 TinyMCE 관련 JS/CSS 파일을 프로젝트 내부에 직접 배포하는 방식을 고려했지만, Next.js의 서버 부담을 줄이기 위해 S3 + CloudFront에 업로드하여 정적 리소스를 제공하는 방식으로 최적화했다. 이를 통해 무료 사용이 가능하면서도 성능과 유지보수성을 고려한 최적의 솔루션을 도출할 수 있었다.
이미지 업로드, 링크 등록, 이미지 dnd, 이미지 크기 조정, 본문 꾸미기 등 서비스에 필요한 기능을 전부 사용할 수 있게 되어서 만족스러웠다...


프로젝트의 SSR과 SEO를 고려하여 Next.js로 개발하였고, 안정적인 배포를 위해 Vercel을 선택했다. 하지만 Vercel의 무료 플랜에서는 이미지 최적화 기능이 매우매우매우 빠르게 제한에 도달한다는 문제점이 있었다.
팀원들이 취업 준비생이라 유료 플랜을 이용하는 것이 부담스러웠기 때문에, 무료 플랜 내에서 최적의 해결책을 모색했다.
기존에는 Next.js의 <Image/> 컴포넌트를 사용하여 자동으로 이미지 최적화를 진행했지만, 이 방식이 무료 플랜 제한에 영향을 미쳤다. 이를 해결하기 위해 기본 <img/> 태그와 loading="lazy" 속성을 활용하여 브라우저에서 제공하는 네이티브 이미지 최적화 기능을 적극 활용했다.
이 방식으로 전환한 결과, Vercel의 무료 플랜 내에서 최적화 제한을 피하면서도 SSR과 SEO 성능을 유지할 수 있었다.
SEO 개선을 위해 SSR을 적용하는 과정에서 기존 React Query 기반의 API 요청 방식으로는 불가하다는 것을 발견했다... React Query는 클라이언트 측에서 데이터를 가져오는 방식이기 때문에, 크롤러가 페이지를 스캔할 때 필요한 메타데이터가 포함되지 않다. 이를 해결하기 위해 SSR에서 API를 호출하는 별도의 함수를 만들어 사용했다.
getServerSideProps를 활용하여 서버에서 API 데이터를 가져오도록 변경함으로써, 검색 엔진과 SNS 공유 시 정확한 메타데이터(제목, 설명, 태그, 썸네일 이미지 등)를 포함할 수 있도록 했다.
이를 통해 검색 결과의 노출도를 높이고, 링크 공유 시 보다 풍부한 정보를 제공하여 사용자 접근성을 개선했다.
export async function getSSRDetails(id: string): Promise<MetaDetailsResponse> {
try {
const token = typeof window !== "undefined" ? localStorage.getItem("access_token") : null;
const response = await axios.get(`https://api.grimity.com/feeds/${id}/meta`, {
params: { id },
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const data = response.data;
return {
...data,
thumbnail: `https://image.grimity.com/${data.thumbnail}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
throw new Error("DELETED_FEED");
}
console.error("Error fetching details:", error);
throw error;
}
}
import { getSSRDetails, MetaDetailsResponse } from "@/api/feeds/getFeedsId";
import Detail from "@/components/Detail/Detail";
import { serviceUrl } from "@/constants/serviceurl";
import { useScrollRestoration } from "@/hooks/useScrollRestoration";
import { GetServerSideProps } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect } from "react";
type Props = {
details: MetaDetailsResponse;
};
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.params as { id: string };
try {
const details = await getSSRDetails(id);
return {
props: { details },
};
} catch (error) {
return { notFound: true };
}
};
export default function FeedDetail({ details }: Props) {
const router = useRouter();
const { restoreScrollPosition } = useScrollRestoration("details-scroll");
useEffect(() => {
if (sessionStorage.getItem("details-scroll") !== null) {
restoreScrollPosition();
sessionStorage.removeItem("details-scroll");
}
}, []);
const { id } = router.query;
if (!id) {
return null;
}
return (
<>
<Head>
<title>{`${details.title} - 그리미티`}</title>
<meta name="description" content={details.content ?? ""} />
<meta name="keywords" content={`그리미티, grimity, ${details.tags}`} />
<meta property="og:title" content={`${details.title} - 그리미티`} />
<meta
property="og:description"
content={`${details.content} | grimity | ${details.tags}`}
/>
<meta property="og:image" content={details.thumbnail ?? ""} />
<meta property="og:url" content={`${serviceUrl}feeds/${details.id}`} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={`${details.title} - 그리미티`} />
<meta name="twitter:description" content={details.content ?? ""} />
<meta name="twitter:image" content={details.thumbnail ?? ""} />
</Head>
<Detail id={id as string} />
</>
);
}



새로고침하거나 화면에 커서를 둘 때마다 API 요청이 발생하여, 서버 부하가 증가하고 성능 저하 문제가 있었다. 특히, 불필요한 중복 호출로 인해 데이터 패칭이 비효율적으로 이루어졌다.
이를 해결하기 위해 React-Query의 QueryKey 최적화 및 캐싱 전략을 적용했다.
QueryKey를 체계적으로 설정하여 불필요한 refetch를 방지
staleTime과 cacheTime을 조정하여 데이터를 적절히 유지
동일한 요청이 중복 호출되지 않도록 구조 개선
로그인한 사용자의 정보는 항상 최신 상태를 유지해야 하므로 예외 처리하여,
사용자 정보 API만 기존 방식으로 유지하고 나머지 API는 캐싱을 적극 활용하여 최적화했다.
서버 부담을 줄이면서도 빠른 데이터 제공이 가능해졌다.
export function useFeedsLatest({ size }: FeedsLatestRequest) {
return useInfiniteQuery<FeedsLatestResponse>(
"feedsLatest",
({ pageParam = undefined }) => getFeedsLatest({ size, cursor: pageParam }),
{
getNextPageParam: (lastPage) => {
return lastPage.nextCursor ? lastPage.nextCursor : undefined;
},
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
},
);
}

로그인은 JWT 기반 인증을 사용하며, accessToken이 만료되면 자동으로 갱신하는 구조를 적용했다.
accessToken만 사용해봤어서 refreshToken이랑 낯을 좀 가렸다... axios interceptors도 예~전에 작성해둔 코드 계속 재활용해왔어서 refT 적용하기 위해서 코드 수정하는데 이해가 ... 어려웠다... 하지만 덕분에 남의 코드를 많이 보게 되었다.. 같은 기능 다른 코드는 정말 많다는 사실을 새삼 느꼈다.
모달을 구현할 때, PC에서는 모달 형태로 화면을 띄우지만 모바일에서는 화면을 꽉 채우는 페이지 형태로 모달을 표시해야 했다. 이로 인해 두 가지 방식(PC와 모바일) 간의 관리가 어려운 상황이 발생했다. 모바일 시안에 백 버튼이 없어서 브라우저의 기본 뒤로가기 기능을 통해 모달이 닫기도록 구현해야 했다.
모바일에서 뒤로가기 버튼을 누르면 모달이 닫히도록 훅을 만들었고, 이를 모든 모달이 쓰이는 모든 컴포넌트에 적용했다. 그러나 이 방식은 경로 스택에 문제가 생겼다... 뒤로가기 버튼을 여러 번 눌러야 모달이 닫히거나, 모달 내 요소를 클릭하고 뒤로가기 버튼을 눌렀을 때 이상한 화면이 나타나는 등 예기치 않은 동작이 발생했다...
이 문제를 해결하기 위해, 모달 컴포넌트 자체에서 경로 변경을 감지하고, 경로가 변경될 때마다 전역 상태에서 모달을 닫는 방식으로 개선했다.
useEffect(() => {
if (modal.isOpen && modal.isFill) {
window.history.pushState({ isModalOpen: true }, "", window.location.href);
}
const handlePopState = () => {
setModal({ isOpen: false, type: null, data: null });
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, [modal.isOpen, setModal]);
useEffect(() => {
const handleRouteChange = () => {
setModal({ isOpen: false, type: null, data: null });
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
}, [router, setModal]);
댓글 기능은 댓글 작성, 답글 작성, 댓글 및 답글 표시, 멘션, 댓글 좋아요 등 다양한 기능을 포함하고 있다. 그러나 그림 상세 페이지와 글 상세 페이지에서 API 구조가 달라, 두 페이지에서 동일한 댓글 컴포넌트를 적용은 어려웠다.
그림 상세 페이지에서는 답글 더보기 버튼을 클릭해야 답글 데이터를 요청하고,
글 상세 페이지에서는 댓글 API가 댓글과 답글 데이터를 한 번에 반환된다.
이로 인해 댓글 컴포넌트를 두 가지 방식으로 분리해야 했고, 양쪽 모두 관리하기가 매우 매우 매우 복잡했다.
댓글 기능이 있는 그 많은 서비스의 개발자들은 다 이 과정을 거친걸까?...ㅠ
모든 요구 사항을 충족시키기 위해, 각 페이지의 API 구조에 맞춰 댓글 컴포넌트를 별도로 관리해야 했고, 이로 인해 매우 까다로운 개발이 두 번 반복되었다... 하하 ;;;;; 코드도 엄~~청 길어서 다시 만들어보라고 하면... 눈물 나올 듯....
제대로 된 프론트 단독 개발은 처음이었는데... 컨벤션 정할 것도 없고 PR, 리뷰, merge 대기 등에 시간 낭비를 안해도 되니까 너무 좋았다. 그래서 빠른 개발이 가능했던 거 같다!
이 많은 기능과 삽질을 한 달 안에 해결해냈다니... 오 나 좀 대견한데 ㅋㅋ
다만 어려움이 생길 때마다.. 혼자 이겨내야 했다... 나 아니면 해결할 사람이 없기에 회피도 못 하고... '이걸 내가 왜 해야 돼...'라는 생각만 하면서 끙끙대다가.. 해결하면 "내가 해냄 who is this 천재 개발자"... 성취감max 이게 개발의 맛;; ㅋㅋ
프로젝트 내내 이 상황의 반복이었던 거 같다...
사실 제일 힘들었던 건... 3주 정도 개발을 진행해서 얼추 완성을 한 상태에서 디자이너 변경 이슈로 인해... 기존 프로젝트를 버리고... 새 프로젝트로 시작하는 과정에서 멘탈이 힘들었다... ㅠ 열심히 키운 다마고치가 자고 일어나니까 죽어있었을 때 이런 기분이었는데 ㅋㅋ 어떡하겠니.. 할 수 없지... 라는 걸 아는데.. 열심히 만든 그 시간과 결과를 버려야 한다는게 씁쓸했다 OTL... 협업을 하다보면 참 별 일이 다 일어나는구나..를 배웠다..;;
그래서 다시 만들어낸 프로젝트가 너무 소중하다... 정말 열심히 만들었는데 지금보다 더 많은 사용자가 생겼으면 좋겠다... 그 사용자들의 피드백을 반영해서 "편하고 깔끔해서 계속 쓰고 싶은 서비스"를 제공하고 싶다!
프로젝트를 하면서 처음 써보는 기술과 써왔지만 제대로 활용을 못했던 기술들을 많이 배웠다. 역시.. 프로젝트는 다다익선