AllClass

AllClass

부산 지역 대학생을 위한 강의 리뷰 플랫폼

완료

Description

2023.11 - 2025.084명 → 1명 (리라이팅)프론트엔드 개발

부산 지역 대학생들의 타 대학 강의 정보 접근을 돕는 교육 플랫폼입니다. 약 2년간 진행된 장기 프로젝트로, 실사용자 50명을 대상으로 서비스를 제공했습니다. 4인 스쿼드에서 9개월간 예비창업을 진행하며 프론트엔드 리드를 담당했고, 레거시 코드를 Next.js App Router 기반으로 전면 리라이팅하여 성능과 유지보수성을 대폭 개선했습니다.

기술:Next.js, TypeScript, React Query, Zustand, CSS Modules, Cypress, GitHub Actions

History

문제 발견

2024년 해커톤에서 개발한 강의 리뷰 플랫폼을 9개월간 예비창업 프로젝트로 운영하면서 심각한 성능 문제를 발견했습니다.

성능 지표 분석

Lighthouse 측정 결과:

LCP 최대 8초 - 첫 콘텐츠 렌더링까지 과도한 대기시간
Lighthouse 성능 점수 69점
모든 페이지가 getServerSideProps로 구현 - 불필요한 서버 사이드 렌더링
Next.js의 정적 최적화 기능 완전 미활용

특히 강의 목록 페이지와 그 상세 페이지의 경우 매번 서버에서 모든 강의 데이터를 fetch하고 있어 사용자가 페이지를 방문할 때마다 로딩 시간을 경험해야 했습니다.

개발 경험상 문제점

코드베이스 분석을 통해 발견한 구조적 문제들:

분산된 폴더 구조로 인한 유지보수 어려움
하나의 기능 수정을 위해 여러 파일 탐색 필요
타입 안전성 부족으로 런타임 에러 빈발
중복 코드일관성 없는 패턴 남발

예를 들어, 리뷰 관련 기능을 수정하려면 /components, /pages, /utils, /hooks 등 여러 폴더를 넘나들며 파일을 찾아야 했고, JavaScript로 작성된 코드였기 때문에 타입 안전성이 부족했습니다.

이러한 문제점들을 해결하기 위해 기술 부채를 정리하고 제대로 된 아키텍처로 리라이팅하기로 결정했습니다.

성능 문제 분석 과정 보기

이미지 로딩 최적화

리라이팅 초기 단계에서 조건부 렌더링으로 인한 Next.js Image 최적화 문제를 발견했습니다.

문제 상황

React의 조건부 렌더링으로 인해 컴포넌트가 DOM에서 완전히 제거되면서 Next.js Image 컴포넌트 인스턴스가 매번 새로 생성되는 문제가 발생했습니다.

원인 분석

// Before: 조건부 렌더링으로 인한 컴포넌트 인스턴스 재생성
{isDropdownOpen && (
<div className={styles.dropdownMenu}>
<UniversityList />
</div>
)}

핵심 문제: 조건부 렌더링으로 <img> DOM 엘리먼트가 매번 삭제됐다가 재생성되면서, 브라우저가 새로 생긴 엘리먼트의 이미지를 캐시 정책에 따라 다시 검증하는 것이었습니다.

브라우저 HTTP 캐시는 유지되지만, 이미지 응답의 캐시 헤더가 짧아 동일한 이미지에 대해서도 재검증 요청이 발생했습니다.

해결 접근

// After: CSS 기반 가시성 제어로 컴포넌트 인스턴스 보존
<div
className={`${styles.dropdownMenu} ${isDropdownOpen ? styles.open : ''}`}
onMouseLeave={handleDropdownMouseLeave}
onMouseEnter={handleDropdownMouseEnter}
>
<UniversityList />
</div>

컴포넌트를 DOM에서 완전히 제거하지 않고 CSS visibility: hidden이나 opacity: 0으로 숨김 처리하여 <img> 엘리먼트를 DOM에 유지했습니다. 엘리먼트가 남아 있으니 브라우저가 이미지를 다시 검증할 이유도 없어졌습니다.

추가 최적화

단계적으로 추가 문제들을 발견하고 해결했습니다:

// useMemo로 객체 참조 안정화
const universityData = useMemo(() => {
return universities.map((university) => ({
...university,
imageSrc: university.logo.src // 문자열로 추출
}));
}, []);

3단계 - 컴포넌트 최적화: React.memouseCallback을 활용해 불필요한 리렌더링 방지

결과 확인

개선 후 DevTools Network 탭에서 드롭다운 호버 시 이미지 재요청이 완전히 사라지는 것을 확인했습니다. 사용자 경험상으로는 즉시 이미지가 표시되어 매끄러운 인터랙션을 구현할 수 있었습니다.

이미지 로딩 최적화 전체 과정 보기

도메인 단위 아키텍처

기존의 기술 중심 구조에서 도메인 중심 구조로 완전히 재설계했습니다.

기존 구조의 문제점

레거시 코드에서는 기능별로 파일들이 흩어져 있어 하나의 기능을 수정하려면 여러 폴더를 돌아다녀야 했습니다:

# Before: 기술별 분산 구조 - 리뷰 기능 하나를 위해
/components/common/reviews/reviewcard/ReviewCard.jsx
/components/common/modal/writereview/ReviewModal.jsx
/pages/class/[universityName]/detail/[lectureId].jsx
/api/reviews/index.js
/hooks/useReviewHandlers.js
/stores/useReviewStateStore.js

이런 구조에서는 리뷰 관련 버그 하나를 수정하기 위해 6개의 다른 폴더를 탐색해야 하는 등 전체 맥락을 파악하기 어려웠습니다.

새로운 도메인 중심 구조

# After: 도메인 응집 구조
/domains/review/
/client/
/components/ # WriteReviewModal, ReviewList
/hooks/ # usePostReview, useReviewActions
/server/
/components/ # ReviewListServer, ReviewCard
/shared/
/types/ # review.ts, api.ts
/utils/ # getLectureName.ts

클라이언트/서버/공용 계층 분리

또한 Next.js App Router의 특성을 고려해 각 도메인을 3개 계층으로 분리했습니다:

auth - 인증 및 사용자 관리 (로그인, 회원가입, 권한)
lecture - 강의 정보 및 검색 (강의 목록, 상세, 필터링)
review - 리뷰 작성 및 조회 (평점, 댓글, 수정/삭제)
mypage - 개인 페이지 관리 (프로필, 작성한 리뷰 조회)

이렇게 분리하면서 서버 컴포넌트와 클라이언트 컴포넌트가 명확히 구분되어 Next.js 13+에서 권장하는 패턴을 활용할 수 있게 되었습니다.

정량적 개선 결과

지표BeforeAfter개선율
전체 코드량6,750줄5,488줄-18.7%
평균 파일 크기80줄51줄-36%
배럴 익스포트11개45개+300%

코드량이 줄어든 이유는 중복 제거와 더 효율적인 컴포넌트 분할 때문이었고, 배럴 익스포트가 늘어난 것은 모듈화가 잘 되었다는 증거였습니다.

하나의 기능을 수정하기 위해 여러 폴더를 돌아다니던 문제가 완전히 해결되었고, 새로운 개발자가 프로젝트에 참여해도 빠르게 구조를 이해할 수 있게 되었습니다.

도메인 구조 설계 과정 보기

하이브리드 캐싱 전략

강의 목록 페이지는 준정적 데이터(강의 기본 정보)동적 데이터(사용자 평점)가 혼재된 복잡한 데이터 구조였습니다. 리라이팅 초기에는 모든 데이터를 force-cache로 일괄 처리했는데, 이 방식은 초기 로딩은 빠르지만 사용자가 자주 확인하는 평점 정보가 실시간으로 반영되지 않는 문제가 있었습니다.

데이터 분석 및 전략 수립

강의 목록 데이터의 특성을 분석한 결과:

정적 특성 데이터 (변경 빈도 낮음):
• 강의명, 교수명, 학과, 학점, 시간표
• 한 학기 동안 거의 변경되지 않음
• ISR로 사전 렌더링하여 빠른 첫 로딩 제공
동적 특성 데이터 (변경 빈도 높음):
• 평점, 리뷰 수, 최근 리뷰
• 사용자 활동에 따라 실시간 변경
• 클라이언트 캐싱으로 최신성 보장

하이브리드 캐싱 구현

// 모든 대학교 페이지를 빌드 시점에 정적 생성
export const revalidate = false;
export default async function UniversityPage({ params }) {
const staticLectures = await getLectureListStatic(universityName);
return <LectureListHybrid lectures={staticLectures} />;
}
// 동적 평점 데이터 - React Query로 실시간 관리
export function useLectureRating({ lectureId, universityName }) {
return useQuery({
queryKey: ['lecture-rating', lectureId, universityName],
queryFn: () => getLectureRating(lectureId, universityName)
});
}

컴포넌트 구조 재정비

데이터 특성에 맞게 컴포넌트를 분리하여 각각 최적의 렌더링 전략을 적용했습니다:

// 하이브리드 렌더링 구조 - 정적 데이터 + 동적 평점
const LectureListHybrid = ({ lectures }) => (
<div className={styles.lectureGrid}>
{lectures.map((lecture) => (
<LectureCardHybrid key={lecture.lectureId} staticData={lecture} />
))}
</div>
);
// 평점만 클라이언트에서 동적 로딩
const DynamicRating = ({ lectureId, universityName }) => {
const { data: rating } = useLectureRating({ lectureId, universityName });
return <StarRating rating={rating?.averageRating || 0} />;
};

상태 동기화 및 UI Flickering 해결

리라이팅 과정에서 페이지 새로고침 시 평점 컴포넌트가 깜빡이는 현상이 발생했습니다. 문제의 원인은 서버 사이드에서 생성된 HTML과 클라이언트에서 hydration 된 후의 상태가 달랐기 때문입니다.

# 문제 상황 분석

// 서버에서는 평점 데이터를 fetch할 수 없음
<LectureCardSkeleton /> // 스켈레톤 UI 표시
// 클라이언트에서 실제 평점 로드 후
<StarRating rating={4.2} /> // 실제 평점으로 변경

이러한 불일치로 인해 스켈레톤 UI → 실제 평점 숫자로 갑자기 변경되는 깜빡임 현상이 발생했습니다.

# 해결 방법: dehydrate/hydrate 패턴

SSR 시점에서 React Query 데이터를 미리 prefetch하여 서버와 클라이언트 상태를 동기화했습니다:

// App Router에서 React Query 데이터 prefetch
export default async function UniversityPage({ params }) {
const staticLectures = await getLectureListStatic(universityName);
const queryClient = createQueryClient();
await prefetchMultipleLectureRatings(queryClient, staticLectures, universityName);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<LectureListHybrid lectures={staticLectures} />
</HydrationBoundary>
);
}

# prefetch 함수의 실제 구현

// 여러 강의의 평점을 한번에 prefetch
export async function prefetchMultipleLectureRatings(
queryClient: QueryClient,
lectures: StaticLectureData[],
universityName: string
) {
await Promise.allSettled(
lectures.map((lecture) =>
queryClient.prefetchQuery({
queryKey: ['lecture-rating', lecture.lectureId, universityName],
queryFn: () => getLectureRating(lecture.lectureId, universityName)
})
)
);
}

이 방식으로 서버 렌더링 시점에 모든 평점 데이터를 미리 fetch하여 클라이언트 hydration 시에도 동일한 상태를 유지할 수 있게 되었습니다. 결과적으로 스켈레톤에서 실제 평점으로 갑자기 변경되는 깜빡임이 완전히 사라졌습니다.

개선 결과

구분Before (Hydration 없음)After (Hydration 적용)
초기 렌더링스켈레톤 UI → 실제 데이터즉시 완전한 데이터 표시
렌더링 속도데이터 도착까지 체감 지연체감상 즉시
사용자 경험Flickering 발생깜빡임 없는 즉시 로딩
데이터 신뢰성클라이언트 요청 의존서버 검증 포함

이 과정을 통해 데이터 최신성 보장과 성능 최적화를 동시에 달성할 수 있었고, 사용자가 정렬을 변경하거나 새로운 리뷰를 작성해도 무중단으로 데이터가 전환되는 매끄러운 경험을 구현했습니다.

캐싱 전략 구현 과정 보기

UI Flickering 해결 과정 보기

E2E 테스트 구축

실사용자 50명 서비스 운영 중 발생할 수 있는 기능 장애를 사전 방지하기 위해 Cypress 기반 E2E 테스트를 구축했습니다.

실사용자 서비스 테스트 전략

비즈니스적 가치: 50명 실사용자 서비스에서 기능 장애 시 사용자 이탈과 신뢰도 직접적 타격 전략적 선택: 빠른 기능 개발 vs 라이브 서비스 안정성 → E2E 테스트로 핵심 플로우 우선 검증 실제 환경 검증: Mock 데이터가 아닌 실제 API 기반으로 사용자 경험 검증

테스트 커버리지

• 핵심 사용자 플로우 4개 구축
• Tier 1 핵심 플로우 완전 검증, Tier 2는 그 과정에서 함께 커버

Tier 분류 기반 테스트 전략

Tier 1 (서비스 핵심 가치): 로그인 → 강의 검색 → 리뷰 작성 → 마이페이지 리뷰 관리 - 이 플로우가 망가지면 서비스 자체가 무의미해지는 필수 기능들

Tier 2 (사용자 경험 향상): 리뷰 정렬/필터링, 좋아요, 대학교 전환 - 중요하지만 서비스 핵심 가치 대비 상대적으로 우선순위가 낮은 기능들

테스트 전략

describe('사용자는 로그인을하고 자신이 들은 강의에 리뷰를 작성할 수 있다.', () => {
it('사용자는 자신이 수강한 강의를 찾고 리뷰를 작성할 수 있다.', () => {
cy.uiLogin();
cy.getMyLectures().then((response) => {
const lectureName = response.body.data[0].lectureName;
cy.visitUniversity();
cy.clickLectureByName(lectureName);
cy.byTest('write-review').click();
cy.byTest('review-title-input').type(`${lectureName} 수업 후기`);
cy.byTest('star-5').click({ force: true });
cy.byTest('write-submit').click({ force: true });
});
});
});

Custom Commands를 통한 테스트 효율성 확보

초기에는 수동 테스트로 모든 기능을 검증했지만, 리라이팅 과정에서 매번 반복되는 테스트로 인한 시간 소모가 심각했습니다.

수동 테스트의 문제점:

• 리라이팅 과정에서 매번 반복되는 테스트로 인한 시간 소모

• 수동 검증으로 인한 일관성 부족과 테스트 누락

• 회귀 테스트의 어려움

// 로그인 프로세스 자동화
Cypress.Commands.add('uiLogin', () => {
cy.visit('/auth/login');
cy.get('[data-test="email-input"]').type(Cypress.env('TEST_EMAIL'));
cy.get('[data-test="password-input"]').type(Cypress.env('TEST_PASSWORD'));
cy.get('[data-test="login-submit"]').click();
});
// API 호출 자동화
Cypress.Commands.add('getMyLectures', () => {
return cy.request('GET', `${Cypress.env('API_BASE_URL')}/api/my-lectures`);
});

실제 버그 발견 사례

테스트 자동화 도입 후 리뷰 작성 후 캐시 revalidate 버그를 발견했습니다.

발견된 문제: 리뷰 작성 완료 후 강의 목록 페이지로 돌아가면 새로 작성한 리뷰가 반영되지 않는 현상

원인: ISR 캐시가 수동으로 revalidate되지 않아 정적 데이터가 업데이트되지 않음

해결: 리뷰 작성 API에서 revalidatePath() 호출 추가

이 버그는 수동 테스트로는 발견하기 어려웠지만, E2E 테스트의 일관된 검증 과정에서 명확하게 드러났습니다.

CI/CD 통합

GitHub Actions 자동화 테스트
우선순위 체계화 (tier1-critical / tier2-experience)

E2E 테스트 구축 과정 보기

최종 성과

리라이팅으로 달성한 정량적 개선 결과입니다.

성능 지표 개선

지표BeforeAfter개선율
Lighthouse69점100점+31점
LCP2.5초0.7초-72%
FCP2.1초0.5초-76%
Speed Index4.6초0.9초-80%

아키텍처 개선 성과

지표BeforeAfter개선율
코드량6,750줄5,488줄-18.7%
평균 파일 크기80줄51줄-36%
컴포넌트 재사용률30.4%58.8%+28.4%p
배럴 익스포트11개45개+300%
기능 수정 시 접근 폴더10개3개-70%
파일 탐색 개선5단계3단계-40%

번들 크기 최적화

항목BeforeAfter개선율
강의 목록 페이지183kB138kB-25%
강의 상세 페이지249kB146kB-41%
정적 페이지8개84개+950%
외부 의존성23개6개-74%

기술적 개선

렌더링 최적화: 페이지별 SSG/ISR/SSR 전략 적용
정적 페이지 생성: generateStaticParams로 84개 페이지 빌드 타임 생성
TypeScript 전환: 커버리지 100% 달성으로 코드 안정성 강화
의존성 정리: 17MB → 5.3MB 리소스 최적화
도메인 설계: 서버/클라이언트/공용 계층 분리로 Next.js 13+ 최적화

해커톤에서 시작된 레거시 코드를 성능과 유지보수성이 개선된 애플리케이션으로 리라이팅했습니다.

전체 성과 분석 보기

기술적 판단과 트레이드오프

이 프로젝트에서 내린 주요 기술적 판단과 그 근거입니다.

리팩토링이 아닌 리라이팅 렌더링 전략과 데이터 페칭 구조가 근본부터 잘못 설계되어 있었고 코드베이스가 작았기 때문에, 부분 수정보다 재작성이 비용이 낮다고 판단했습니다. 대신 기존 백엔드 API 호환성을 유지하는 조건을 걸어 리스크를 제한했습니다.

제약 조건 안에서의 캐싱 분리 API를 변경할 수 없는 상황에서 준정적 데이터는 ISR, 실시간 데이터는 React Query로 갱신 주기를 분리했습니다. 최초 네트워크 요청이 늘어나는 대가를 받아들이는 대신, 서버 리소스 절약과 평점 데이터의 즉시성을 확보했습니다.

테스트를 리라이팅의 안전망으로 한 번에 크게 바뀌는 코드일수록 회귀 검증이 필요하다고 판단해 E2E 테스트를 먼저 구축했습니다. 실제로 수동 테스트에서 놓쳤던 캐시 revalidate 버그를 테스트가 발견했습니다.

Gallery

Loading gallery...