×
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>
)}

핵심 문제: Next.js Image 컴포넌트 인스턴스가 매번 새로 생성되면서 내부 최적화 로직이 재실행되어 네트워크 재요청이 발생했습니다.

브라우저 HTTP 캐시는 유지되지만, Next.js Image 컴포넌트의 내부 상태와 최적화 메커니즘이 초기화되면서 동일한 이미지에 대해서도 재요청이 발생하는 것이었습니다.

해결 접근

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

컴포넌트를 DOM에서 완전히 제거하지 않고 CSS visibility: hidden이나 opacity: 0으로 숨김 처리하여 컴포넌트 인스턴스와 Next.js Image 최적화 상태를 보존했습니다.

추가 최적화

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

// useMemo로 객체 참조 안정화
const universityData = useMemo(() => {
return universities.mapmap((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

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

새로운 도메인 중심 구조

# 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%
중복 코드 감소---43%

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

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

도메인 구조 설계 과정 보기

하이브리드 캐싱 전략

강의 목록 페이지는 준정적 데이터(강의 기본 정보)동적 데이터(사용자 평점)가 혼재된 복잡한 데이터 구조였습니다. 기존에는 모든 데이터를 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.mapmap((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={dehydratedehydrate(queryClient)}>
<LectureListHybrid lectures={staticLectures} />
</HydrationBoundary>
);
}

# prefetch 함수의 실제 구현

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

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

개선 결과

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

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

캐싱 전략 구현 과정 보기

UI Flickering 해결 과정 보기

E2E 테스트 구축

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

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

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

테스트 커버리지

• 핵심 사용자 플로우 4개 구축
• 전체 기능 커버리지 77.3% 달성
• Tier 1 핵심 플로우 완전 검증

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').typetype(`${lectureName} 수업 후기`{lectureName} 수업 후기`);
cy.byTest('star-5'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"]'[data-test="email-input"]').typetype(Cypress.env('TEST_EMAIL'));
cy.get('[data-test="password-input"]'[data-test="password-input"]').typetype(Cypress.env('TEST_PASSWORD'));
cy.get('[data-test="login-submit"]'[data-test="login-submit"]').click();
});
// API 호출 자동화
Cypress.Commands.add('getMyLectures', () => {
return cy.request('GET', `${Cypress.env('API_BASE_URL')}/api/my-lectures`{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점+45%
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%
배럴 익스포트11개45개+300%
개발 효율성-70% 향상리뷰 기능 수정 시 접근 폴더 10개→3개
파일 탐색 개선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+ 최적화

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

전체 성과 분석 보기

주요 학습 포인트

이 프로젝트를 통해 얻은 핵심 인사이트들입니다.

초기 설계의 중요성 해커톤에서 "일단 돌아가게" 만든 코드의 기술 부채를 해결하는 과정에서 초기 아키텍처 설계의 중요성을 깨달았습니다.

테스트 자동화의 가치 테스트 코드가 직접 버그를 발견해주는 경험을 통해 테스트 자동화의 진정한 가치를 체감했습니다.

하이브리드 전략의 효과 React Query와 Next.js ISR을 조합한 하이브리드 캐싱으로 성능과 실시간성을 모두 확보할 수 있음을 확인했습니다.

완주의 의미 창업팀 해체 상황에서도 혼자 끝까지 완주한 경험은 앞으로의 개발 여정에서 큰 자산이 될 것입니다.

Gallery

Loading gallery...