사용자 후기 기반의 리뷰 커뮤니티로 베스트 후기·실시간 후기·검색 등 다양한 방식으로 후기를 탐색할 수 있는 플랫폼입니다. 게시글 작성, 이미지 업로드, 북마크, 댓글, 실시간 알림 등 커뮤니티 서비스에 필요한 기능을 제공합니다.
여러 플랫폼에 흩어진 후기 정보를 한 곳에서 쉽게 탐색할 수 있는 공간을 만드는 것이 프로젝트의 가장 큰 목표였습니다.
사용자가 작성한 리뷰가 더 많은 사람에게 도달하려면 검색을 통한 유입이 중요하다고 판단해,
주요 페이지와 게시글 본문이 검색 엔진에 제대로 노출될 수 있는 구조를 갖추는 데 집중했습니다.
또한 조회 => 반응(댓글/북마크) => 알림 => 재참여로 이어지는 흐름이 끊기지 않도록 사용자가 자연스럽게 계속 참여할 수 있는 경험을 만드는 것도 중요한 목표였습니다.
- Next.js 15
- Typescript
- Tanstack Query (서버 상태 관리)
- Zustand (클라이언트 상태 관리)
- Tailwind CSS
- Radix UI, shadcn/ui
- lucide-react
- Tiptap
- Sentry
- Jest
- React Testing Library
- AWS EC2 (Ubuntu)
- Nginx
- PM2
- Github Actions + CodeDeploy (CI/CD 파이프라인)
커뮤니티 서비스는 검색 유입이 중요해 게시글 본문이 검색 엔진이 바로 읽을 수 있는 형태로 제공돼야 했습니다.
CSR은 초기 HTML이 비어 있어 크롤러가 내용을 제대로 가져가지 못하므로 서버에서 컨텐츠를 먼저 그려주는 Next.js의 서버 컴포넌트 기반 렌더링과 동적 메타데이터 기능이 더 적합하다고 판단했습니다.
또 초기 로딩 성능이 중요해 리액트 단독 환경처럼 모든 자바스크립트 코드를 먼저 받는 구조보다 서버에서 필요한 컨텐츠만 먼저 내려주는 방식이 유리했습니다. 여기에 ISR, SSG 같은 페이지 단위 캐싱 전략과 확장된 fetch의 캐싱 옵션까지 활용할 수 있다는 점을 고려해 Next.js를 선택했습니다.
커뮤니티 특성상 사용자마다 결과가 달라지는 데이터가 많아 변동이 잦은 영역은 서버 캐싱보다 클라이언트 캐싱이 더 적합했습니다.
검색은 키워드, 정렬 옵션 조합이 다양하고 마이페이지(작성글/저장글), 댓글, 북마크처럼 인증 기반 데이터도 사용자마다 다른 값을 가지기 때문에 서버 측 캐싱 효율이 낮았습니다.
Next.js의 서버 캐시가 요청 컨텍스트 기반이라는 점을 고려하면 이런 데이터를 서버에 쌓을수록 메모리 부담이 커집니다. 그래서 변동이 많고 개인화된 데이터는 Tanstack Query로 관리하는 구조가 현실적이었습니다.
추가로 Suspense 기반의 선언적 데이터 패칭과 전역 에러 핸들링으로 UI 코드가 간결해졌고 무한 스크롤, 페이지네이션 같은 게시판 서비스의 핵심 기능도 제공해 요구사항에 잘 맞았습니다.
초기에 컴포넌트 외부의 순수 함수에서 전역 상태를 업데이트해야 하는 요구사항이 있었습니다. Context API는 훅 규칙 때문에 컴포넌트나 커스텀 훅 내부에서만 상태 변경이 가능해 이 요구사항을 해결할 수 없었습니다.
Zustand는 전역 스토어가 리액트와 분리된 순수 자바스크립트 객체로 구성되어 있어 컴포넌트 바깥에서도 상태를 직접 제어 가능했고, 초기에 필요한 요구사항을 해결해줬습니다.
프로젝트가 진행되면서 로그인, 알림, 에러 등 클라이언트 상태가 여러 영역으로 확장되었고 Context 기반으로 Provider를 계속 늘려가는 구조보다 각 상태를 독립된 스토어로 관리할 수 있는 Zustand 쪽이 더 단순하고 관리하기 편했습니다.
서비스를 실제로 운영한다는 관점에서 비용 구조가 예측 가능한지가 중요했습니다. Vercel은 자동 배포와 관리 편의성 면에서 뛰어나지만 Pro 플랜부터 Data Transfer 비용이 급격히 올라가는 구조였습니다.
서울 리전 기준으로 1TB 이후부터 1GB당 0.35달러가 과금되는데, 커뮤니티 서비스처럼 이미지 업로드, 조회, 검색 트래픽이 많은 구조에서는 비용 리스크가 커질 수 있었습니다.
같은 상황에서 AWS는 100GB까지 무료이고 이후 1GB당 0.126달러 수준으로 동일 트래픽에서 비용이 절반 이하로 떨어집니다. 트래픽이 장기적으로 증가할수록 이 차이는 더 크게 벌어집니다.
EC2는 고정 리소스 기반이라 서버 사양을 조정하지 않는 한 예측 불가능한 추가 비용이 발생하지 않는다는 점도 운영 관점에서 안정적이라고 판단했습니다.
사용자가 서비스에 접근해 페이지를 렌더링 받기까지의 흐름과 깃허브에서 AWS로 이어지는 CI/CD 파이프라인을 나타낸 구성도입니다.
- 브라우저에서 modu-review.com으로 접근하면 DNS가 EC2 인스턴스를 가리키고, Nginx가 실행 중인 Next.js 서버(3000/3001)로 트래픽을 프록시합니다.
- 깃허브에 코드의 변경이 감지되면 깃허브 액션에 의해 빌드 후 압축된 파일이 S3에 업르도됩니다. 이후 코드 디플로이가 해당 파일을 EC2로 가져와 새로운 버전을 배포합니다.
EC2 내부에서는 PM2에 의해 Next.js 프로세스가 관리되며, Blue-Green 방식으로 포트를 전환해 배포 시 자동 롤백과 다운타임을 최소화하고 있습니다.
이 프로젝트는 기능 확장과 유지보수성을 높이기 위해 Feature-Sliced Design(FSD)을 기반으로 디렉터리를 구성했습니다.
UI, 상호작용 로직, 도메인 데이터 모델을 분리해 각 기능이 독립적으로 확장될 수 있도록 설계했습니다.
- app: 전역 초기화(프로바이더, 레이아웃, 글로벌 에러 처리)
- views: 페이지 단위 화면 구성 (Next.js 라우팅 엔트리)
- widgets: 공통 UI 블록(Header, Footer, Pagination, Error UI)
- features: 사용자 상호작용 중심의 기능 단위(검색, 북마크, 댓글 등)
- entities: 도메인 데이터 모델, API, 캐싱 규칙, 재사용 UI(Card 등)
- shared: fetch 래퍼, 전역 상수, 유틸, 공통 UI 컴포넌트
레이어 간 의존성은 위에서 아래로만 허용해 결합도를 낮추고 각 기능은 해당 도메인 폴더 안에서 완전히 모여 있어 수정 및 탐색 비용을 크게 줄였습니다.
이 구조를 선택한 배경과 주요 의사결정 과정은 블로그에 자세히 정리했습니다.
메인 페이지 - ISR + CSR 하이브리드 렌더링 - 이동하기
SSR 병목으로 p99 9.4초, 실패율 50%가 발생하던 환경에서 ISR로 서버 렌더링 비용을 줄이고 실시간 후기 영역은 CSR + dynamic import로 분리해 서버 자원 사용량 최적화.
- p99 9.4s => 45ms (약 200배 개선)
- 요청 실패율 50% => 0%
테스트 도입 - 사용자 시나리오 기반 통합 테스트 작성과 CI 자동화 - 이동하기
실제 사용자 상호작용과 비즈니스 요구사항이 화면에서 어떻게 동작하는지 검증하는 통합 테스트를 중심으로 도입하고, CI 환경에서의 테스트 및 커버리지 검증 자동화.
- 총 532개 테스트 케이스 작성 및 코드 커버리지 약 94% 달성
- 큰 기능은 통합 테스트, 세부 UI 분기와 복잡한 순수 로직은 유닛 테스트로 나눠 검증
- API 응답만 테스트 데이터로 통제하고 내부 로직은 실제 동작에 가깝게 실행해 사용자가 경험하는 기능 단위를 중심으로 검증
- 통합 테스트 작성 과정에서 댓글 등록 실패 시 이전 페이지로 복구되지 않던 라우팅 버그를 발견하고 수정
- GitHub Actions를 통해
main브랜치에서는 테스트 통과 여부와 최소 커버리지 기준 확인, Pull Request 단계에서는 커버리지 변화 리포트 자동 생성
AI 챗봇 - 검색 결과 없음으로 인한 이탈을 줄이기 위해 외부 리뷰 검색 기능 구현 - 이동하기
내부 데이터 부족으로 검색 결과가 없어 사용자가 이탈하는 문제를 줄이기 위해, AI가 관련 후기를 대신 찾아 요약해주는 단계형 챗봇 기능 구현.
- 내부 데이터가 부족해 검색 결과가 없는 상황에서도 외부 리뷰를 대신 찾아주는 대체 탐색 경험 제공
- 검색어 검증, 카테고리 선택, 리뷰 검색 및 요약 과정 등을 단계형 챗봇 UX로 풀어내, 복잡한 AI 검색 절차를 사용자가 직접 다루지 않아도 되도록 구현
- 백엔드 리소스가 부족한 상황에서도 프론트 주도로 3일 만에 프로토타입을 구현하고 실제 서비스에 배포해, 핵심 기능의 가능성을 빠르게 검증
게시글 상세 페이지 - 태그 기반 On-Demand 캐싱 - 이동하기
클라이언트에서 서버 캐시를 무효화가 불가능한 문제를 해결하기 위해 쓰기 요청을 Next.js 서버로 우회시키는 프록시 패턴 적용.
- 첫 요청(MISS): 172ms 대비, 캐시 HIT: 59ms로 약 3배(172ms => 59ms)의 읽기 성능 개선
에러 중앙화를 통한 일관된 에러 UX 제공 - 이동하기
흩어져 있던 try/catch를 단일 request 함수로 모아 예외 처리를 중앙화하고, 에러를 전역 상태로 공유해 한 지점에서 처리함으로써 화면마다 다르던 에러 UX를 일관된 규칙(대체 UI/토스트)으로 제공.
- 에러 응답 구조 변경 등 수정 범위를 단일 지점으로 중앙화해 유지보수 범위 축소
- API 호출 위치와 상관 없이 일관된 에러 UX 적용
- 읽기 요청(GET) 실패 => 대체 UI
- 쓰기 요청(POST/PUT/DELETE) 실패 => 토스트
- 예측 가능한 에러 => 토스트
- 예측 불가능한 에러 => 대체 UI
- API가 늘어나도 에러 처리 비용이 증가하지 않는 구조 확보
Nginx 기반 Blue-Green 배포를 통한 단일 vCPU 환경에서의 다운타임 최소화 - 이동하기
PM2 graceful reload 불가 및 단일 vCPU 환경에서 발생한 배포 다운타임 문제를 포트 스위칭 기반 Blue-Green 배포로 해결.
- Health Check 기반 자동 롤백
- 릴리즈 폴더 및 심볼릭 링크 기반 전환
- 기존 프로세스 graceful shutdown
- 다운타임 4 ~ 5초 => 1 ~ 2초 (약 2배 이상 개선)
- 77회 배포 중 8회 실패했지 서비스 중단 0회 유지
Tiptap 기반 에디터 구현 - 이동하기
게시글 작성용 에디터 구현 및 서식, 이미지 업로드 기능 구현.
- shouldRerenderOnTransaction 제어와 에디터 영역 분리로 불필요한 리렌더링 제거
- NodeView 기반 이미지 업로드(진행률, 취소, 에러 처리) 구현
실시간 알림 - SSE 기반 안정적 스트림 유지 - 이동하기
서버와의 SSE 연결을 통해 작성한 게시글에 생성된 댓글/북마크 실시간 알림을 토스트 및 뱃지로 표시.
- SSE 기반 실시간 알림 기능 구현
- SSE 연결 실패만으로는 토큰 만료 여부를 구분하기 어려워 preflight 인증을 통한 검증 및 토큰 재발행 로직으로 안정적인 스트림 연결 유지
메인페이지는 댓글, 북마크, 조회수를 집계해 선정된 베스트 후기를 노출하는 핵심 페이지로 검색 유입을 위해 SSR이 필수였습니다. 하지만 단일 vCPU 환경에서 SSR로 인한 병목 현상으로 여러 사용자가 동시에 접속하는 환경(60초간 초당 20명)에서 응답 지연(p99 9.4s) 과 요청 실패(약 50%) 가 발생했습니다.
백엔드 API 병목의 가능성도 고려했으나 next.js의 fetch 캐싱으로 베스트 후기 데이터를 캐싱한 상태에서도 p99 응답 속도가 7.1s로 측정되어 SSR 렌더링 과정 자체가 병목의 주요 원인임을 확인했습니다.
export const revalidate = 3600;
export {MainPage as default} from '@/views/main';검색 유입이 필수적이었기 때문에 서버 렌더링을 유지하면서 서버 부하를 최소화할 수 있는 ISR(Incremental Static Regeneration)을 적용했습니다.
백엔드 집계 주기(3시간)를 고려해 1시간 간격으로 HTML 파일을 서버에서 정적으로 재생성해 최초 생성 후 캐싱된 HTML만 전달해 서버가 렌더링을 반복하지 않도록 구성했습니다.
const RecentReviewsCarousel = dynamic(() => import('./RecentReviewsCarousel'), {
ssr: false,
loading: () => <RecentReviewsCarouselLoading />,
});
export default function RecentReviewsClient() {
return (
<section>
<RQProvider LoadingFallback={<RecentReviewsCarouselLoading />}>
<RecentReviewsCarousel />
</RQProvider>
</section>
);
}SEO 우선순위가 낮은 실시간 후기 영역은 정적 구조만 서버 컴포넌트로 유지하고, 캐러셀 및 데이터 패칭 영역은 클라이언트 컴포넌트로 분리했습니다.
next/dynamic을 사용해 해당 컴포넌트의 SSR을 명시적으로 비활성화해 초기 렌더링 시 서버가 처리해야 할 자바스크립트 코드의 양을 줄여 서버 자원을 베스트 후기 영역으로 집중했습니다.
- 적용 후 동일한 조건의 부하테스트 결과 p99 응답 속도 9.4s => 45ms (약 200배 개선), 요청 실패율 50% => 0%로 개선
- 이후 60초간 초당 50명의 부하테스트에서도 요청 실패율 0%, p99 응답 속도 74ms를 확인해 단일 vCPU 환경 내에서도 안정적인 트래픽 처리 확인
보다 자세한 의사 결정 과정과 구현 배경을 블로그에 정리했습니다.
기능이 늘어날수록 새 기능을 추가한 뒤 예상하지 못한 곳에서 버그가 발생했고 배포 전에 기존 기능이 제대로 동작하는지 확신하기 어려웠습니다.
특히 댓글의 낙관적 업데이트, 정렬/필터링, 무한 스크롤처럼 여러 상태와 로직이 함께 엮인 기능은 단순 렌더링 확인이나 함수 호출 여부만으로는 충분히 검증하기 어려웠습니다.
테스트 대상을 개별 컴포넌트나 훅 단위로 모두 분리해 모킹하기보다 사용자가 화면에서 경험하는 기능 단위로 통합 테스트를 작성했습니다.
예를 들어 프로젝트의 알림 기능은 목록 조회, 알림 아이템 렌더링, 읽음 처리, 삭제 등 여러 역할을 위해 4개의 UI 컴포넌트와 3개의 커스텀 훅이 함께 실행되고 있었습니다.
이 모든 파일에 대해 유닛 테스트를 작성하지 않고, API 응답만 테스트 데이터로 통제해 실제 동작에 가까운 환경에서 검증했습니다.
it('알림이 있는 경우 알림 목록을 정상적으로 보여준다.', async () => {
mockGetNotifications.mockResolvedValue(createNotificationListStub(3));
await setupRender();
const notificationList = screen.getByRole('list', {name: '알림 목록'});
expect(within(notificationList).getAllByRole('listitem')).toHaveLength(3);
expect(screen.getByText('게시글2에 새로운 댓글이 달렸어요.')).toBeInTheDocument();
});
it('안 읽은 알림을 클릭하면 해당 게시글로 이동하고 읽음 처리 요청한다.', async () => {
const {user} = await setupRender();
const notificationItem = screen.getByLabelText('게시글2로 이동', {selector: 'button'});
await user.click(notificationItem);
expect(mockRouter.asPath).toBe('/reviews/2');
expect(mockMarkAsRead).toHaveBeenCalledWith(2);
});
it('알림 삭제 버튼 클릭 시 API 응답을 기다리지 않고 목록에서 즉시 제거된다.', async () => {
let resolveDeleteNotification: any;
mockDeleteNotification.mockImplementation(() => new Promise(resolve => {
resolveDeleteNotification = resolve;
}));
const {user} = await setupRender();
const notificationList = screen.getByRole('list', {name: '알림 목록'});
expect(within(notificationList).getAllByRole('listitem')).toHaveLength(4);
const deleteButton = screen.getByLabelText('알림 삭제', {selector: 'button'});
await user.click(deleteButton);
expect(within(notificationList).getAllByRole('listitem')).toHaveLength(3);
resolveDeleteNotification();
await waitFor(() => {
expect(within(notificationList).getAllByRole('listitem')).toHaveLength(3);
});
});이렇게 ‘조회, 읽음, 삭제’라는 3가지 사용자 시나리오만으로도 알림 기능에 관련된 여러 파일의 로직을 함께 검증할 수 있었습니다.
또한 내부 로직이나 컴포넌트 구조가 변경되더라도 사용자가 버튼을 눌렀을 때 화면이 갱신되고 API가 정상 호출된다는 결과가 동일하다면 테스트가 깨지지 않도록 작성했습니다.
모든 경우의 수를 통합 테스트에 담으면 테스트 코드가 지나치게 커지기 때문에 상태에 따른 세부 UI 분기나 엣지 케이스가 많은 순수 로직은 유닛 테스트로 분리해 검증했습니다.
예를 들어 단일 알림을 렌더링하는 컴포넌트는 알림 타입과 읽음 여부에 따라 아이콘, 배경색, 텍스트가 달라지기 때문에 이런 세부 UI 분기는 통합 테스트에 모두 넣지 않고 별도의 유닛 테스트로 분리했습니다.
describe('NotificationItem.tsx', () => {
const defaultNotificationStub = {
id: 1, board_id: 1, isRead: true, title: '테스트', type: 'bookmark'
};
it('북마크 타입의 알림은 검은색 배경과 Bookmark 아이콘을 표시한다.', () => {
const notification = { ...defaultNotificationStub, type: 'bookmark' };
render(withAllContext(<NotificationItem notification={notification} />));
const icon = screen.getByText('Bookmark');
expect(icon).toBeInTheDocument();
expect(icon.parentElement).toHaveClass('bg-black');
});
it('읽지 않은 알림은 배경 색을 흰색으로 표시한다.', () => {
const notification = { ...defaultNotificationStub, isRead: false };
render(withAllContext(<NotificationItem notification={notification} />));
const button = screen.getByLabelText('테스트 게시글로 이동', {selector: 'button'});
expect(button).toHaveClass('bg-white');
});
});또한 에디터 내부에서 링크 삽입 시 안전한 URL인지 확인하는 유틸리티 함수처럼 예외 처리가 많은 순수 로직도 유닛 테스트로 검증했습니다.
describe('validateLinkUrl', () => {
describe('보안 - 위험한 프로토콜 차단', () => {
it('javascript: 프로토콜은 false를 반환하고 에러를 호출한다.', () => {
const result = validateLinkUrl({ url: 'javascript:alert("XSS")', ctx: defaultCtx, onError: mockOnError });
expect(result).toBe(false);
expect(mockOnError).toHaveBeenCalledWith(createClientError('INVALID_LINK_PROTOCOL'));
});
});
describe('보안 - 상대경로 차단', () => {
it('./ 로 시작하는 상대경로는 false를 반환하고 에러를 호출한다.', () => {
const result = validateLinkUrl({ url: './path/to/page', ctx: defaultCtx, onError: mockOnError });
expect(result).toBe(false);
expect(mockOnError).toHaveBeenCalledWith(createClientError('INVALID_LINK_URL'));
});
});
});프로토콜이 없는 URL의 자동 파싱, javascript:나 data: 같은 위험한 프로토콜 차단, 상대 경로 제한 등의 엣지 케이스를 독립된 환경에서 검증했습니다.
이렇게 통합 테스트에 모든 분기를 몰아넣지 않고 많은 엣지 케이스는 유닛 테스트로 빠르게 검증함과 동시에 테스트 블록 자체가 복잡한 함수의 스펙을 설명하는 문서 역할이 되도록 작성했습니다.
드의 품질을 안정적으로 유지하기 위해 main 브랜치에 코드 변경이 감지될 경우, 빌드 및 배포 단계 이전에 테스트가 먼저 실행되도록 CI 워크플로우를 구성했습니다.
name: Modu-Review-Client CI
on:
push:
branches:
- main
jobs:
build:
steps:
# ... 의존성 설치 등
- name: Run Tests
run: pnpm run test:ci
# ...빌드 및 배포 단계이때 test:ci 스크립트 실행 과정에서 테스트가 하나라도 실패하거나, 전체 커버리지가 Jest 설정에 정의한 임계값(약 94%) 아래로 떨어질 경우 CI 파이프라인이 즉시 중단되도록 설정해 품질 이하의 코드 배포를 차단했습니다.
const jestConfig = {
...,
coverageThreshold: {
global: {
statements: 94,
branches: 92,
functions: 91,
lines: 94,
},
},
};추가로 코드 리뷰 단계에서부터 테스트 커버리지 변화를 확인할 수 있도록 Pull Request 전용 워크플로우도 별도로 구성했습니다.
name: Modu-Review-Client Test Report
on:
pull_request:
branches:
- main
jobs:
coverage:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
checks: write
steps:
# ... 의존성 설치 등
- name: Jest coverage report
uses: ArtiomTr/jest-coverage-report-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
package-manager: pnpm
test-script: pnpm run test:ci
annotations: all
custom-title: '모두의 후기 테스트 커버리지'이 워크플로우는 병합 대상 브랜치가 기존 main 브랜치 대비 전체 커버리지를 얼마나 변화시켰는지와 파일별 커버리지 변화를 분석해 PR 댓글에 자동으로 리포트를 생성합니다.
결과적으로 main 브랜치에서는 빌드와 배포 전에 일정 품질 유지를 강제하고, Pull Request 단계에서는 커버리지 변화를 코드 리뷰 과정에서 시각적으로 함께 확인할 수 있도록 테스트 환경을 자동화했습니다.
- 총 532개 테스트 케이스 작성 및 코드 커버리지 약 94% 달성
- 큰 기능은 통합 테스트, 세부 UI 분기와 복잡한 순수 로직은 유닛 테스트로 나눠 검증
- API 응답만 테스트 데이터로 통제하고 내부 로직은 실제 동작에 가깝게 실행해 사용자가 경험하는 기능 단위를 중심으로 검증
- 통합 테스트 작성 과정에서 댓글 등록 실패 시 이전 페이지로 복구되지 않던 라우팅 버그를 발견하고 수정
- GitHub Actions를 통해
main브랜치에서는 테스트 통과 여부와 최소 커버리지 기준 확인, Pull Request 단계에서는 커버리지 변화 리포트 자동 생성
보다 자세한 테스트 도입 배경과 구현 상세는 블로그에 정리했습니다.
서비스 초기 내부 DB에 리뷰가 거의 없어 검색했을 때 빈 결과 화면을 마주하는 경우가 잦았습니다. 검색 결과가 없을 때 아무 대안도 제시하지 못하면 사용자는 서비스에 원하는 정보가 없다고 느끼고 탐색을 중단할 수 있었습니다.
하지만 단순히 관련 리뷰를 대신 보여주는 것만으로는 충분하지 않았습니다. 실제 후기 탐색에 적절한 검색어인지 먼저 판단해야 했고, 검색 결과가 없을 때 사용자가 같은 키워드를 다시 입력하지 않아도 자연스럽게 다음 탐색으로 이어질 수 있는 UX도 필요했습니다.
프로젝트의 백엔드 리소스가 부족해 이 기능만을 위한 별도 API를 새로 설계하기 어려웠습니다. 그렇다고 클라이언트에서 외부 AI 서비스를 직접 호출하는 방식은 API 키 노출 위험이 있어 적절하지 않았습니다.
그래서 검색 기능에 필요한 서버 역할을 프론트 서버가 담당하도록 구성했습니다. 클라이언트는 /api/search로 요청만 보내고, 서버가 이후 검증과 검색 로직을 실행한 뒤 결과를 반환하도록 구현했습니다.
export async function POST(req: NextRequest) {
const {keyword, category} = await req.json();
// 이후 서버에서 검색 의도 검증, 리뷰 검색 및 요약 수행
// ...
return NextResponse.json({
status: 'success',
summary,
sources,
});
}이 방식을 통해 클라이언트는 외부 AI 서비스의 호출 방식이나 응답 구조를 직접 알 필요 없이 하나의 API만 호출하면 되도록 요청을 단순화할 수 있었습니다.
모든 검색어에 대해 곧바로 리뷰 검색을 수행하면 단순 정보 검색이나 카테고리와 맞지 않는 요청까지 모두 외부 검색 호출로 이어질 수 있었습니다.
// 외부 AI API 호출 함수
const validation = await validateQueryWithGroq({
keyword,
category,
TIMEOUT_MS: 2000,
});
if (!validation.isValid) {
return NextResponse.json({
status: 'fail',
summary: validation.message || '적절한 검색어가 아닌 것 같아요. 😅',
sources: [],
});
}한정된 사용량을 의미 없이 소진하지 않기 위해, 검색 이전 단계에서 LLM 기반 검증을 수행해 keyword와 category만으로 실제 후기 탐색에 적절한 요청인지 먼저 판단하도록 구성했습니다.
export async function validateQueryWithGroq({
category,
keyword,
TIMEOUT_MS,
}: Props): Promise<ValidationResult> {
const timeoutPromise = new Promise<ValidationResult>((_resolve, reject) => {
setTimeout(() => reject(new Error('Validate Timeout')), TIMEOUT_MS);
});
try {
return await Promise.race([
_fetchGroqValidation(keyword, category),
timeoutPromise,
]);
} catch (error) {
console.error('Groq GateKeeper Error:', error);
return {
isValid: false,
message: '현재 서비스가 원활하지 않아요. 잠시 후 다시 시도해주세요.',
};
}
}또한 외부 검증 API 지연이 길어질 경우 전체 대기 시간이 과도하게 늘어나는 것을 막기 위해 Promise.race를 사용해 타임아웃을 적용했습니다.
// 전달하는 프롬프트 일부
[Logic]
1. **REJECT (Category Mismatch):** If keyword is CLEARLY unrelated to "${category}"...
2. **ACCEPT (Ambiguity Rule):** If keyword is a Proper Noun (Title, Brand) that *could* be in "${category}", ACCEPT it...
3. **REJECT (Invalid Types):**
- Person/History (Founders, CEO) -> "인물이나 기업 정보는 알 수 없어요."
- Navigation/Facts (Where to buy, Stock, Weather) -> "단순 정보나 구매처는 알 수 없어요."마지막으로 부적절한 요청은 실제 검색 이전에 차단하고 애매한 고유명사는 과도하게 막지 않도록 프롬프트를 설계해 검증 정확도를 높였습니다.
검증을 통과한 요청에 한해 관련 리뷰를 검색하고 요약하도록 구현했습니다. 이때 사용자가 입력한 키워드만 그대로 사용하지 않고 카테고리별 suffix를 붙여 검색어를 구체화했습니다.
const suffix = CATEGORY_SUFFIX[category] || CATEGORY_SUFFIX['all'];
const enhancedQuery = `"${keyword}" ${suffix}`;
const tavilyResponse = await client.search(enhancedQuery, {
topic: 'general',
searchDepth: 'basic',
includeAnswer: 'advanced',
includeImages: false,
country: 'south korea',
maxResults: 8,
});카테고리별 suffix는 아래처럼 정의했습니다.
const CATEGORY_SUFFIX: Record<CategoryLabel, string> = {
food: '맛 평가 양 가성비 솔직 후기 메뉴 추천',
car: '시승기 승차감 연비 결함 장단점',
cosmetic: '발색 지속력 제형 트러블 솔직 후기',
clothes: '사이즈 팁 재질 핏 착샷 코디 후기',
device: '스펙 발열 배터리 성능 장단점 개봉기',
book: '책 서평 독후감 줄거리 요약 솔직 리뷰',
sports: '착용감 내구력 효과 사용기 장단점',
movie: '관람평 러닝타임 쿠키 개봉일',
all: '솔직 후기 장점 단점 내돈내산 추천',
};예를 들어 음식 카테고리에서는 "프레드 피자" 맛 평가 양 가성비 솔직 후기 메뉴 추천처럼 검색어를 보완해, 단순 정보성 결과보다 실제 후기 문서가 더 우선적으로 수집되도록 설정했습니다.
챗봇을 구현하더라도 사용자가 플로팅 버튼을 직접 발견하고 눌러주길 기대하긴 어렵다고 판단했습니다. 따라서 내부 검색 결과가 0건이고, 남은 사용 횟수가 있을 때 자동으로 챗봇이 열리도록 구현했습니다.
export default function ReviewWithPagination() {
const {keyword} = useParams<{keyword: string}>();
const {results, total_pages} = useKeywordReviews(keyword);
const {openChat, limitState} = useChatStore();
useEffect(() => {
if (results.length === 0 && limitState.remaining > 0) {
openChat();
}
}, [results.length, limitState.remaining, openChat]);
if (results.length === 0) {
return (
<NoSearchResults
title={`${decodeURIComponent(keyword)}에 대한 검색 결과가 없어요.`}
description="검색어의 철자가 정확한지 확인해주세요."
description2="또는 검색어를 변경해 다시 검색할 수 있어요."
/>
);
}
}추가로 사용자가 방금 검색한 키워드를 다시 입력하지 않도록 만들기 위해, 현재 검색 페이지 URL(/search/:keyword)에서 키워드를 추출해 챗봇 검색어에 주입했습니다.
export function useChatRouteSync() {
const pathname = usePathname();
const {setKeyword, setStep} = useChatStore();
useEffect(() => {
if (pathname.startsWith('/search/')) {
const segments = pathname.split('/');
const rawKeyword = segments[2];
if (rawKeyword) {
const decodedKeyword = decodeURIComponent(rawKeyword);
setKeyword(decodedKeyword);
setStep('ask');
}
}
}, [pathname, setKeyword, setStep]);
}이 기능을 통해 사용자는 빈 결과 화면에서 검색어를 다시 입력하지 않고도, “방금 찾지 못한 키워드에 대한 후기를 대신 검색해드릴까요?”와 같은 대안을 자연스럽게 제안받을 수 있었습니다.
외부 AI 검색은 키워드 입력 ⇒ 유효성 검사 ⇒ 카테고리 선택 ⇒ AI 검증 및 요약 대기 ⇒ 결과 확인처럼 여러 단계를 거칩니다. 이 과정을 하나의 정적인 폼으로 구성하면 사용자는 검색어 입력과 카테고리 선택, 에러 처리 등 많은 입력을 한 번에 요구받게 됩니다.
그래서 이 과정을 챗봇의 step으로 나누고, 봇의 질문에 하나씩 답할 수 있는 단계형 챗봇으로 풀어냈습니다. 사용자는 검색 필터를 직접 세팅한다는 느낌보다 챗봇이 안내하는 순서에 따라 자연스럽게 다음 단계로 이동하도록 구현했습니다.
function ChatWindow() {
const step = useChatStore();
return (
<section>
<ChatWindowHeader />
{step === 'input' && <Input />}
{step === 'ask' && <Ask />}
{step === 'search' && <Search />}
{step === 'result' && <Result />}
</section>
);
}예를 들어 Ask 단계에서는 사용자가 방금 찾지 못한 키워드를 다시 보여주고 “제가 대신 검색해서 요약해 드릴 수 있어요!”처럼 다음 행동을 제안하도록 구성했습니다.
export default function Ask() {
const {keyword, setStep} = useChatStore(
useShallow(state => ({
keyword: state.keyword,
setStep: state.setStep,
})),
);
return (
<Step>
<BotResponse>
<ChatBubble>
혹시 <strong>"{keyword}"</strong>에 대한 후기를 못 찾으셨나요?
</ChatBubble>
<ChatBubble>제가 대신 검색해서 요약해 드릴 수 있어요!</ChatBubble>
</BotResponse>
<button onClick={() => setStep('search')}>
네, 찾아주세요!
</button>
</Step>
);
}이렇게 사용자는 복잡한 검색 조건을 직접 세팅하고 있다는 부담 없이, 챗봇의 질문에 순서대로 답하는 것만으로 외부 리뷰 검색 및 요약 결과를 받을 수 있도록 구현했습니다.
- 내부 데이터가 부족해 검색 결과가 없는 상황에서도 외부 리뷰를 대신 찾아주는 대체 탐색 경험 제공
- 검색어 검증, 카테고리 선택, 리뷰 검색 및 요약 과정 등을 단계형 챗봇 UX로 풀어내, 복잡한 AI 검색 절차를 사용자가 직접 다루지 않아도 되도록 구현
- 백엔드 리소스가 부족한 상황에서도 프론트 주도로 3일 만에 프로토타입을 구현하고 실제 서비스에 배포해, 핵심 기능의 가능성을 빠르게 검증
구현 배경, 검색 공급자 선정 이유와 서버 구현 상세 내용은 블로그에 정리했습니다.
챗봇 UX를 선택한 이유와 구현 상세 내용은 블로그에 정리했습니다.
게시글 상세 페이지는 작성자가 수정하거나 삭제하기 전까지 모든 사용자에게 동리한 컨텐츠를 제공하는 전형적인 읽기 중심의 페이지입니다.
이 특성상 Next.js의 On-Demand 캐싱(fetch 캐시 + tag 기반 무효화)을 적용하기에 매우 적합했습니다. 다만 구조적으로 클라이언트에서 서버 캐시를 무효화할 방법이 없다는 문제가 있었습니다.
프로젝트의 모든 쓰기 요청은 리액트 쿼리의 useMutation으로 클라이언트에서 직접 백엔드로 호출하고 있었습니다. 하지만 Next.js의 서버 캐시 무효화를 위한 revalidateTag()는 서버 환경(Server Action 또는 Route Handler)에서만 실행 가능합니다.
따라서 클라이언트에서 게시글을 수정하거나 삭제해도 백엔드 데이터는 갱신되지만 Next.js 서버가 유지하고 있는 캐시는 무효화할 방법이 전혀 없는 상태였습니다.
결국 캐시 무효화는 서버에서만 가능하지만, 모든 쓰기 요청은 브라우저에서 발생하는 구조적 문제가 발생한 것입니다.
이 문제를 해결하기 위해 쓰기 요청 시 Next.js 서버를 한 단계 거치도록 변경했습니다.
/api/reviews/[id] (Next.js Route Handler)로 요청revalidateTag('${review-[id]}')를 호출해 캐시 무효화이렇게 쓰기 요청은 Next.js 서버가 처리하고 읽기 요청은 캐시가 처리하는 구조로 완전히 흐름을 재설계했습니다.
const res = await fetch(url, {
method: 'GET',
next: {
revalidate: false,
tags: ['review-13'],
},
});최초 요청만 백엔드에서 데이터를 가져오며 태그가 무효화될 때까지 모든 요청은 캐싱 중인 데이터를 반환합니다.
export async function DELETE(_: NextRequest, {params}: {params: Promise<{reviewId: string}>}) {
// 인증 정보 추출, 예외 처리 및 실제 요청
revalidateTag(`review-${reviewId}`); // 캐시 무효화
}Next.js 서버는 브라우저가 전달한 인증 정보를 활용해 백엔드 요청을 수행하고 성공 시 Next.js 서버 자체가 캐시 무효화를 실행합니다.
클라이언트는 서버 캐시를 직접 건드릴 필요 없이 정상적인 쓰기 요청만 보내도 캐시는 자동으로 최신 상태를 유지하게 됩니다.
const {mutate, ...rest} = useMutation({
mutationFn: ({reviewId}: MutationVariables) => deleteReview(reviewId),
onSuccess: (_data, {category}) => {
const invalidateKeys = [...];
invalidateKeys.forEach(key => {
queryClient.invalidateQueries({queryKey: key});
});
router.push('/search');
},
});Next.js 서버가 서버 캐시를 책임지기 때문에 클라이언트는 기존처럼 클라이언트 캐시만 관리합니다.
- 첫 요청(MISS): 172ms 대비, 캐시 HIT: 59ms로 약 3배(172ms => 59ms)의 읽기 성능 개선
쓰기 요청이 Next.js 서버를 한 번 더 거치기 때문에 네트워크 홉이 늘어나는 단점은 존재합니다. 하지만 서비스 특성상 읽기 요청이 압도적으로 많았고, 읽기 성능의 이점과 백엔드 부하 감소를 고려했을 때 쓰기 요청의 작은 비용은 충분히 합리적이었습니다.
보다 자세한 구현 과정은 블로그에 정리했습니다.
프로젝트 초기에는 각 API 요청 함수마다 개별적으로 try/catch를 작성해 예외를 처리했습니다. 하지만 엔드포인트가 늘어날수록 몇 가지 문제가 눈에 띄었습니다.
- 화면마다 에러 UX가 달라 일관성이 없었다.
- 어떤 곳은 상태 기반, 어떤 곳은 토스트, 또 어떤 곳은 에러 바운더리를 사용해 동일한 종류의 에러도 화면마다 다르게 제공됐습니다.
- 백엔드의 에러 응답이 변경될 때 유지보수 비용이 커졌다.
- 에러 구조가 바뀌면 요청 함수를 돌아다니며 모든 예외 처리문을 수정해야 했습니다.
API가 늘어날수록 예외 처리 비용이 점점 증가했고 개발 효율과 에러 UX 모두에 영향을 끼치는 구조였습니다. 이 문제를 해결하기 위해 에러를 중앙에서 관리하는 구조가 필요하다고 판단했습니다.
export async function request(url, options) {
const response = await fetch(url, {...options});
if (!response.ok) {
const errorInfo = await response.json()
const {status} = response;
if (options.method === 'GET') {
throw new RequestGetError({status, ...errorInfo});
}
throw new RequestError({status, ...errorInfo});
}
return response.json()
}request 함수는 백엔드와의 통신에서 유일한 진입점으로 다음을 담당합니다.
- 요청 실행
- 성공/실패 판단
- 요청 성격에 따라 에러 객체 생성 및 throw
API 요청 함수들은 request 함수만 호출하면 되고 에러 해석과 분기는 모두 request 함수에서 담당합니다.
이 구조가 중요한 이유는
- 어디에서 에러가 발생해도 동일한 구조의 에러 객체가 전달되고
- UI 레벨에서 GET/POST/토큰 만료 등을 직접 분기하지 않아도 되며
- 에러 처리의 제어권을 호출부가 아닌 request 함수가 갖게 됩니다.
GET 요청 실패는 페이지를 구성할 수 없는 경우가 많기 때문에 핸들링 지점에서 instanceof 연산자만으로 에러 처리 방식(대체 UI, 토스트 등)을 명확히 구분할 수 있도록 별도의 에러 클래스를 적용했습니다.
리액트 쿼리는 QueryCache와 MutationCache를 통해 각 요청의 에러를 가로챌 수 있습니다.
// 전역 에러 스토어
const globalErrorStore = create(set => ({
error: null,
updateError: error => set({error}),
}));
// 쿼리 프로바이더
throwOnError: (error: Error) => error instanceof RequestGetError && error.errorHandlingType === 'errorBoundary',
queryCache: new QueryCache({
onError(error) {
if (error instanceof RequestGetError && error.errorHandlingType === 'errorBoundary') return;
if (error instanceof RequestError) updateError(error); // 전역 스토어 업데이트 함수
},
}),
mutationCache: new MutationCache({
onError(error) {
if (error instanceof RequestGetError && error.errorHandlingType === 'errorBoundary') return;
if (error instanceof RequestError) updateError(error);
},
}),request 함수가 에러 객체의 성격을 분리해주기 때문에
- GET 요청이면서 대체 UI 핸들링 대상은 throwOnError로 상위로 전파하고
- 그 외 요청 에러는 전역 스토어에 저장해 공통 처리합니다.
export default function GlobalErrorDetector({children}: Props) {
const globalError = useGlobalError();
useEffect(() => {
if (!globalError) return;
if (예측가능한서버에러인지(globalError)) {
toast.error({
title: '에러가 발생했어요.',
description: SERVER_ERROR_MESSAGE[globalError.name],
});
}
showBoundary(globalError);
}, [globalError, router]);
return children;
}이 컴포넌트는 전역 에러 스토어를 구독해
- 예측 가능한 에러는 토스트를 표시
- 예측 불가능한 에러는 상위로 전파해 글로벌 에러 바운더리에서 처리 라는 규칙을 적용합니다.
이런 흐름으로 모든 화면은 동일한 에러 처리 전략을 따르게 되고 페이지마다 따로 에러를 처리할 필요가 없어지게 됩니다.
- 에러 처리 로직이 중앙화로 중복 코드 제거
- 실제로 중간에 백엔드 에러 응답 구조가 바뀌었으나 request 함수만 수정해도 전역에 반영 가능해 유지보수 비용이 줄었습니다.
- API 호출 위치와 상관 없이 일관된 에러 UX 적용
- 읽기 요청(GET) 실패 => 대체 UI
- 쓰기 요청(POST/PUT/DELETE) 실패 => 토스트
- 예측 가능한 에러 => 토스트
- 예측 불가능한 에러 => 대체 UI
- API가 늘어나도 에러 처리 비용이 증가하지 않는 구조 확보
- 개발자는 비즈니스 로직에만 집중하고, 에러 처리는 자동으로 중앙화된 규칙을 따릅니다.
보다 자세한 구현 과정은 블로그에 정리했습니다.
프로젝트는 AWS EC2 t2.micro(단일 vCPU) 환경에서 운영되고 있었습니다.
이 환경에서는 PM2의 graceful reload나 클러스터 모드를 사용할 수 없고, Next.js 기본 실행 방식도 PM2의 ready 신호를 지원하지 않아 일반적인 reload 기반 무중단 배포가 구조적으로 불가능했습니다.
배포 시 기존 프로세스를 종료한 뒤 새 프로세스를 실행해야 했고, 이 과정에서 약 4~5초의 다운타임이 반복적으로 발생하는 문제가 발생했습니.
- 단일 vCPU: PM2 클러스터 모드는 CPU 코어 수만큼 워커를 실행하는 구조로 단일 코어에서는 프로세스 병렬 실행이 불가능해 기존 프로세스를 종료해야만 새 버전이 실행 가능했습니다.
- Next.js 서버 런타임 특성: Next.js는 PM2의 realod 신호를 지원하지 않아 graceful reload를 사용할 수 없었습니다.
이 문제를 해결하기 위해 Nginx + PM2 + 포트 스위칭 기반 Blue-Green 배포 구조를 직접 설계했습니다.
배포 전체 흐름은 다음과 같습니다.
- 서버는 3000 / 3001 두 포트 중 하나를 서비스 포트로 사용합니다.
- 새 버전은 현재 사용 중이지 않은 포트에서 실행합니다.
- 정상적으로 작동되는게 확인되면 Nginx
proxy_pass를 새 포트로 전환합니다. - 새 버전이 실패하면 자동으로 롤백합니다.
- 전체 배포 파이프라인(Github Actions => S3 => CodeDeploy => EC2)은 자동화되어 있습니다.
location / {
include /etc/nginx/conf.d/service-url.inc;
proxy_pass $service_url;
}
Nginx는 service-url.inc에 정의된 포트를 사용합니다.
CURRENT_PORT=$(sed -n "s/^set \$service_url http:\/\/$SERVER_IP:\([0-9]*\);/\1/p" /etc/nginx/conf.d/service-url.inc)
if [ $CURRENT_PORT -eq 3000 ]; then
NEW_PORT=3001
OLD_NAME="next-blue"
NEW_NAME="next-green"
else
NEW_PORT=3000
OLD_NAME="next-green"
NEW_NAME="next-blue"
fi현재 연결된 포트를 기준으로 새 버전이 뜰 포트를 결정합니다.
mv $DEPLOY_PATH $RELEASE_PATH
mkdir $DEPLOY_PATH
ln -sfn $RELEASE_PATH $SYMLINK_PATH
pnpm install배포 시간 기반 디렉터리로 버전 이력을 남기고 심볼릭 링크(current)를 새 릴리즈로 전환합니다.
$PM2_PATH start "node_modules/next/dist/bin/next" --name $NEW_NAME --no-autorestart -- start --port $NEW_PORTBlue / Green 두 프로세스를 번갈아 사용해 충돌을 방지합니다.
HEALTH_CHECK_SUCCESS=false
SUCCESS_COUNT=0
for i in {1..20}; do
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://$SERVER_IP:$NEW_PORT)
if [ "$status_code" -eq 200 ]; then
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
if [ "$SUCCESS_COUNT" -eq 2 ]; then
HEALTH_CHECK_SUCCESS=true
break
fi
else
SUCCESS_COUNT=0
fi
sleep 5
done새 서버를 5초 간격으로 최대 20회 확인 후 HTTP 200을 연속 2회 받으면 정상으로 판단하고 실패 시 자동으로 롤백합니다.
if [ $HEALTH_CHECK_SUCCESS = false ]; then
$PM2_PATH delete $NEW_NAME >> $LOG_FILE 2>&1
ln -sfn $PREVIOUS_RELEASE $SYMLINK_PATH
FAIL_LOG=$(sed -n '/필요한 의존성을 설치합니다/,$p' $LOG_FILE | sed 's/"/\\"/g')
if [ -x "$SLACK_SCRIPT" ]; then
$SLACK_SCRIPT "배포 실패 (Rollback)" "Health Check 실패로 롤백되었습니다.\n\n*상세 로그:*\n\`\`\`$FAIL_LOG\`\`\`\n<@>" "fail"
fi
exit 1
fi
실패 시 새 프로세스를 제거하고 이전 릴리즈로 즉시 복구합니다. 추가로 실패 로그를 슬랙으로 전송합니다.
echo "set \$service_url http://$SERVER_IP:${NEW_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc
sudo nginx -s reloadservice_url만 새 포트로 변경해 트래픽을 전환합니다.
- Nginx의 graceful reload는 워커 프로세스를 CPU 수만큼만 늘릴 수 있어 재시작시 1~2초의 짧은 다운타임이 발생합니다. 하지만 서비스 중단을 막는 데에는 충분히 안정적인 방식이라고 판단했습니다.
$PM2_PATH sendSignal SIGINT $OLD_NAME
sleep 90
$PM2_PATH delete $OLD_NAMENext.js는 SIGINT 신호를 받으면 내부적으로 server.close()를 호출해 처리 중인 요청을 완료한 뒤 종료해 완전한 graceful shutdown을 보장합니다.
따라서 SIGINT 신호를 보내 기존 프로세스를 강제 종료하지 않고 90초의 킬타임 대기 후 프로세스를 종료합니다.
- 다운타임 약 5초 => 약 2초 수준으로 2배 이상 개선
- 새 버전 배포 실패 시 이전 버전으로 자동으로 롤백해 배포 안정성 확보
- 실제 운영 환경에서 77회 배포 중, 8회 배포 실패했으나 서비스 중단 0회 유지
보다 자세한 구현 과정은 블로그에 정리했습니다.
리뷰 작성을 위해 Tiptap 기반 에디터를 직접 구현했습니다. 서식 지원, 이미지 업로드, 미리보기, 수정 등 다양한 기능을 사용자에게 제공하며 아래는 구현 과정에서 해결한 주요 문제들입니다.
Tiptap은 기본 설정상 모든 트랜잭션마다 전체 에디터가 리렌더링됩니다. 데스크탑의 경우 큰 문제가 되지 않았지만, 모바일 환경에서 타이핑 시 프레임이 급격히 떨어졌습니다.
에디터는 입력 중인 컨텐츠 자체가 상태로 취급되기 때문에 에디터 영역을 4개의 독립 컴포넌트로 분리했습니다.
<section>
<EditorMetaForm onSubmit={handleSubmit} initialTitle={title} initialCategory={category} />
<EditorContainer onMount={handleSetContentGetter} initialContent={content} />
<EditorFooter onPreview={handleSetActionPreview} onSave={handleSetActionSave} isPending={isPending} />
{openModal && preview && (
<Modal onClose={handleModalClose}>
<ViewerModal>
<Viewer {...preview} />
</ViewerModal>
</Modal>
)}
{isPending && (
<div>
<LoadingSpinner text="리뷰를 저장하고 있어요." />
</div>
)}
</section>- EditorMetaForm: 제목, 카테고리 입력을 위한 영역
- EditorContainer: 실제 에디터 영역
- EditorFooter: 저장, 미리보기 버튼 표시 영역
- ViewerModal: 미리보기 모달
이렇게 각 영역은 서로 다른 상태를 담당해 불필요한 리렌더링이 발생하지 않습니다.
const editor = useEditor({
shouldRerenderOnTransaction: false,
});다음으로 모든 트랜잭션에 대한 리렌더링을 비활성화했으며
const editorState = useEditorState({
editor,
selector: snapshot => {
const {editor} = snapshot;
return {
isHeading1: editor.isActive('heading', {level: 1}),
isHeading2: editor.isActive('heading', {level: 2}),
}
}
})에디터의 상태가 변경될 때 변경되는 시점의 스냅샷에서 현재 문단 혹은 커서에 적용된 마크업 상태(boolean)를 외부로 반환하는 useEditorState를 사용했습니다.
const headingOptions: ToolbarConfig[] = [
{
icon: 'Heading1',
action: editor => editor.chain().focus().toggleHeading({level: 1}).run(),
stateKey: 'isHeading1',
text: '제목1',
},
// ...
];
// Toolbar.tsx
<ToolbarGroup>
{headingOptions.map(({icon, action, stateKey, text}) => (
<ToolbarButton {...} active={editorState[stateKey]} />
))}
</ToolbarGroup>상태 추적을 위해 editorState를 각 서식별 추적용 키 값과 비교해 활성화 상태를 조회해 필요한 상태만 리렌더링했습니다.
- 입력 시 불필요한 리렌더링이 발생하지 않고 초당 수십 번의 입력에도 성능 저하가 없는 에디터를 구현
Tiptap의 기본 Image 기능은 업로드 진행률, 실패, 취소 등의 상태를 UI로 표현할 수 없었습니다. 추가로 이미지가 에디터 내에 즉시 삽입되기 때문에 보안을 위한 요구사항인 presigned URL 기반 업로드를 적용할 수 없었습니다.
const ImageUploadNode = Node.create<ImageUploadNodeOptions>({
addOptions() {
// ...
},
});
const editor = useEditor({
ImageUploadNode.configure({
accept: 'image/*',
maxSize: 5 * 1024 * 1024,
upload: handleImageUpload,
onError: updateError,
}),
})리액트 컴포넌트만으로는 문서 모델과 업로드 상태를 동기화할 수 없어 NodeView 기반 커스텀 이미지 업로드 노드를 구현했습니다.
업로드 중인 노드와 최종 이미지 노드가 모두 문서 트리에 존재하도록 구현해 사용자의 드래그&드롭과 타이핑에 대응했습니다.
보안 요구사항을 위해 업로드에 사용되는 업로드 핸들러 함수를 외부로부터 주입받도록 구현했습니다.
async function handleImageUpload(file, onProgress, abortSignal) {
const {presignedUrl, uuid: imageId} = await getPresigned(fileType);
const url = await uploadImage({file, fileType, presignedUrl, imageId, onProgress, abortSignal});
return url;
}전달되는 외부 업로드 핸들러 함수는 서버로 이미지 타입을 검증 받아 임시 URL을 발급 받고 S3에 직접 업로드한 뒤 최종 이미지 URL을 반환하는 함수입니다.
editor.chain().focus()
.deleteRange({from: pos, to: pos + 1})
.insertContentAt(pos, [
{
type: 'image',
attrs: {
src: url,
alt: fileName,
title: fileName,
},
},
])이미지 업로드 노드는 반환된 url을 사용해 에디터 내에 진행률 표시 노드를 제거하고 실제 이미지 노드를 삽입합니다.
if (abortSignal) {
abortSignal.addEventListener('abort', () => {
xhr.abort();
reject(createClientError('UPLOAD_CANCELLED'));
});
}사용자가 취소 버튼을 누르면 AbortController 신호를 통해 업로드가 즉시 중단됩니다.
xhr.upload.onprogress = event => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
onProgress({progress});
}
};fetch API로는 진행률 추적이 불가능해 S3 업로드에 XHR 객체를 사용했습니다. xhr.upload.onprogress 이벤트를 활용해 업로드 상태를 업데이트하며 진행률은 NodeView UI에서 막대 형태로 표시합니다.
이미지 업로드 간 발생하는 에러 핸들링을 프로젝트의 에러 처리 흐름에 통합하기 위해 에러 핸들러 함수도 외부로부터 주입 가능하게 구현했습니다.
catch (error) {
if (abortController.signal.aborted) {
return null;
}
if (error instanceof ClientError || error instanceof RequestError) {
options.onError?.(error);
} else {
options.onError?.(createClientError('UPLOAD_FAILED'));
}
return null;
}외부로부터 주입받은 에러 핸들러 함수는 이미지 업로드 중 에러가 발생할 경우 예외 처리에 사용됩니다.
작성 페이지와 수정 페이지 모두 동일한 UI를 사용해야 하지만 처리 로직(API, 초기값 주입)이 완전히 달라져 결합도가 높아질 위험이 있었습니다.
type Props = {
onSave: (data: ReviewPayload) => void;
isPending: boolean;
} & EditorInitialData;
export default function Editor({title, category, content, onSave, isPending}: Props) {
...
}에디터 컴포넌트의 저장 버튼 클릭 시 실행되는 onSave 콜백과 초기값을 외부로부터 주입받을 수 있는 구조로 개선했습니다.
export default function 작성페이지() {
const {postReview, isPending} = usePostReview();
return (
<Editor
isPending={isPending}
onSave={data => postReview(data)}
/>
);
}
export default function 수정페이지({data, reviewId}: Props) {
const {patchReview, isPending} = usePatchReview();
return (
<Editor
title={data.title}
category={data.category}
content={data.content}
onSave={updated => patchReview({data: updated, reviewId})}
isPending={isPending}
/>
);
}사용하는 위치에서 각각 다른 함수(postReview, patchReview)를 전달해 에디터는 저장한다는 사실만 알고 정확히 어디로, 어떻게 저장되는지는 전혀 모르는 분리 구조로 설계했습니다.
수정 페이지의 경우 초기값을 함께 전달해 에디터 컴포넌트 내에서 각각 메타 데이터 영역과 에디터 영역으로 전달해 사용합니다.
- UI는 완전히 동일하게 유지되면서 저장 방식, API 호출, 초기값 등 로직은 모두 외부에서 제어
게시글 수정은 작성자 본인만 가능해야 하는데, 클라이언트에서 HttpOnly 쿠키를 읽을 수 없으며 단순 클라이언트 상태 검증만으로는 보안이 불충분했습니다.
export default async function getSessionUserNickname() {
const cookieStore = await cookies();
return cookieStore.get('userNickname')?.value ?? null;
}
export default async function 수정페이지({reviewId, sessionUserNickname}: Props) {
const data = await getReviewDetail(reviewId);
if (!data) notFound();
if (data.author_nickname !== sessionUserNickname) {
throw new Error('작성자만 리뷰를 수정할 수 있습니다.');
}
return <EditReviewClient data={data} reviewId={reviewId} />;
}
export default async function 상세보기페이지({params}: Props) {
// ...
const sessionUserNickname = await getSessionUserNickname();
const isAuthor = sessionUserNickname === author_nickname;
return (
<section>
{isAuthor && (
<div>
<Link href={`/reviews/${reviewId}/edit`}>
수정
</Link>
<DeleteButton category={category} reviewId={parsedReviewId} />
</div>
)}
// ...
</section>
)
}Next.js 서버 컴포넌트에서 상세 데이터의 닉네임과 쿠키 닉네임을 비교해 1차 검증을 수행하며 클라이언트는 단순히 결과만 받아 UI를 표시합니다. 이후 실제 API 요청 시 백엔드에서 2차 검증을 통해 재확인합니다.
일반적인 글쓰기에 사용 가능한 모든 서식들과 이미지 업로드 등을 지원하며 각 툴바의 버튼은 툴팁을 표시합니다. 툴바는 기능을 종류별로 그룹화해 구성했습니다.
- headings (h1, h2, h3)
- marks (bole, italic, strike)
- structure (blockquote, bullet list, ordered list)
- align (left, center, right)
- media (image, link)
const headingOptions: ToolbarConfig[] = [
{
icon: 'Heading1',
action: editor => editor.chain().focus().toggleHeading({level: 1}).run(),
stateKey: 'isHeading1',
text: '제목1',
},
...
];
const markOptions: ToolbarConfig[] = [
...
];
const structureOptions: ToolbarConfig[] = [
...
];
const alignOptions: ToolbarConfig[] = [
...
];각 옵션은 상수 파일 내에 그룹화해서 관리하며, 이런 구조 덕분에 새로운 서식을 추가할 때 옵션 배열에 한 줄만 추가하는 것으로 툴바에 반영이 가능합니다.
보다 자세한 구현 과정은 블로그에 시리즈1, 시리즈2로 정리했습니다.
댓글과 북마크가 내 글에 달리면 사용자는 '반응'을 기대합니다. 이 기능을 위해 WebSocket 대신 Server-Sent Events(SSE)를 선택해 서버 => 클라이언트 단방향 스트림을 유지하도록 구현했습니다.
알림처럼 단순 푸시 구조에선 클라이언트가 서버로 이벤트를 전송할 필요가 없어 SSE가 더 적합했습니다.
구현 흐름은 아래와 같습니다.
- 연결 전 preflight 요청으로 인증 상태 확인
- 필요 시 액세스 토큰 자동 재발행
- SSE 연결 후 meta, notification 이벤트 수신
- 전역 알림 상태 업데이트
- 토스트 컴포넌트로 표시 및 알림 뱃지 표시
SSE는 연결 실패 시 에러 정보를 이벤트로만 확인이 가능해 인증 오류 등 필요한 정보를 조회할 수 없었습니다. 따라서 연결 안정성을 위해 EventSource를 열기 전 아래 과정을 반드시 거치도록 구현했습니다.
- preflight API로 현재 인증 상태 확인
- 401일 경우 액세스 토큰 갱신
- 정상 인증이 확보된 이후에만 SSE 연결
- useRef 기반으로 단일 연결만 유지
- 컴포넌트 언마운트 시 명시적으로 연결을 닫은 후 객체 삭제
const connectSSE = useCallback(async () => {
if (eventSourceRef.current) return; // 중복 연결 방지
try {
const preflightResponse = await preflightNotifications();
// 정상 인증
if (preflightResponse.ok) {
initializeEventSource();
return;
}
// 인증 만료 시 토큰 재발행
if (preflightResponse.status === 401) {
const tokenRefreshResponse = await tokenRefresh();
if (tokenRefreshResponse.ok) {
initializeEventSource();
return;
}
throw new RequestError({...});
}
// 그 외 에러 처리
const errorInfo = await preflightResponse.json();
throw new RequestError({...});
} catch (error) {
if (error instanceof RequestError) {
updateGlobalError(error);
return;
}
throw error;
}
}, [initializeEventSource, updateGlobalError]);이렇게 토큰 만료 대비, 에러 종류에 따른 분기, 중복 연결 방지로 전체 앱에서 한 번만 실행되는 고정된 스트림을 유지했습니다.
const initializeEventSource = useCallback(() => {
const newEventSource = new EventSource(`${process.env.NEXT_PUBLIC_API_URL}/notifications/stream`, {
withCredentials: true,
});
newEventSource.addEventListener('meta', event => {
const data: MetaEvent = JSON.parse(event.data);
onMeta(data);
});
newEventSource.addEventListener('notification', event => {
const data: NotificationEvent = JSON.parse(event.data);
onNotification(data);
});
eventSourceRef.current = newEventSource;
}, [onMeta, onNotification]);해당 함수에서 실제 스트림을 어떻게 읽는가에만 집중합니다.
- withCredentials를 true로 설정해 인증에 사용되는 쿠키를 연결 시 전송합니다.
- meta 이벤트는 연결 초기에 unread 여부를 전달하는 이벤트로 외부에서 전달된 핸들러 함수에 데이터를 전달합니다.
- notification 이벤트는 댓글, 북마크 생성 시 발생하는 실시간 이벤트로 외부에서 전달된 핸들러 함수에 데이터를 전달합니다.
useEffect(() => {
if (isLoggedIn) {
connectSSE();
}
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [isLoggedIn, connectSSE]);- eventSourceRef에 만들어진 newEventSource를 할당해 중복 생성을 방지하고 cleanup 대상으로 사용합니다.
SSE 스트림에서 전달되는 meta, notification 이벤트를 앱이 사용 가능한 전역 상태로 변환하는 계층입니다. 앱 전체를 한 번만 감싸면 모든 페이지에서 동일한 알림 UX가 유지됩니다.
export default function NotificationProvider({children}: Props) {
const setHasNotification = useSetHasNotifications();
const handleMeta = useCallback(
(data: MetaEvent) => {
setHasNotification(data.hasNotification);
},
[setHasNotification],
);
const handleNotification = useCallback(
(data: NotificationEvent) => {
setHasNotification(true);
toast.notification({
title: data.title,
type: data.type,
board_id: data.board_id,
});
},
[setHasNotification],
);
useConnectSSE({
onMeta: handleMeta,
onNotification: handleNotification,
});
return children;
}- meta 이벤트는 연결 직후 unread 여부를 전달합니다.
- notification 이벤트는 댓글, 북마크 발생 시 호출됩니다. 두 이벤트 모두 전역 스토어의 알림 여부(hasNotification) 값을 갱신하며 댓글, 북마크는 알림 발생 시 즉시 토스트 메세지를 표시합니다.
실시간으로 수신한 알림을 사용자가 바로 확인 가능하게 토스트 UI를 구현했습니다. 댓글, 북마크 유형에 따라 다른 아이콘, 타이틀, 메세지를 표시하며 토스트 클릭 시 해당 게시글 상세 페이지로 이동합니다.
const NOTIFICATION_CONFIG = {
comment: {
icon: 'MessageCircle',
title: '누군가 댓글을 남겼어요.',
getMessage: title => `'${title}'에 댓글을 남겼어요!`,
bgColor: 'bg-red-300',
},
bookmark: {
icon: 'Bookmark',
title: '누군가 게시글을 저장했어요.',
getMessage: title => `'${title}'을 저장했어요!`,
bgColor: 'bg-black',
},
} as const;
function NotificationToast({id, board_id, title, type}: NotificationToastProps) {
const config = NOTIFICATION_CONFIG[type];
return (
<Link
href={`/reviews/${board_id}`}
className="flex bg-white p-4 rounded-lg shadow-lg ring-1 ring-black/5"
onClick={() => sonnerToast.dismiss(id)}
>
<div className={`${config.bgColor} p-2 rounded-lg mr-3`}>
<LucideIcon name={config.icon} className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium">{config.title}</p>
<p className="text-sm text-gray-500">{config.getMessage(title)}</p>
</div>
</Link>
);
}댓글과 북마크 알림을 시각적으로 구분하고 클릭 시 해당 게시글 상세로 이동합니다.
읽지 않은 알림이 존재하면 헤더의 종 아이콘에 뱃지를 표시합니다. 이 값은 SSE 이벤트 수신 시 전역 상태 갱신을 통해 즉시 반영됩니다.
export default function NotificationBell() {
const hasNotifications = useHasNotifications();
const setHasNotifications = useSetHasNotifications();
const handleReadNotifications = () => {
setHasNotifications(false);
};
return (
<Link href="/notifications" className="relative" onClick={handleReadNotifications}>
<LucideIcon name="Bell" className="w-6 h-6 hover:text-boldBlue md:hover:scale-105 transition-all" />
{hasNotifications && <div className="absolute -top-0.5 right-0.5 bg-boldBlue w-3 h-3 rounded-full" />}
</Link>
);
}unread 상태면 종 아이콘에 즉시 뱃지가 나타나며 알림 페이지 진입 시 읽음 처리합니다.














