QA, UT 피드백 반영
3차례의 QA와 5회의 유저 테스트(UT)를 진행하며, 자잘한 버그를 해결하고 테스트 유저로부터 피드백을 수집했습니다.
테스트 과정 중 "글 수정 기능이 있으면 좋겠다"는 피드백을 받아 수정 기능을 구현하기로 결정했습니다. 하지만 새로운 풀이 작성 페이지와 풀이 수정 페이지를 같은 MissionSubmitPage로 재사용하고자 했기에, 이를 어떻게 설계할지에 대해 고민이 들었습니다. 그래서 일단 흐름도를 그려보기로 했습니다.
글 작성 페이지를 (1) 새로운 글을 작성하기 위해 방문하는 경우와, (2) 수정하기 위해 방문하는 경우를 나누기 위해 url에 solutionId 파라미터를 추가했습니다. 페이지 로드 시 파라미터를 확인하여, 데이터가 존재하면 서버에서 기존 데이터를 가져와 수정 작업으로 간주합니다. 반대로 파라미터가 없을 경우, 새로운 글을 작성하기 위한 작업으로 처리하도록 설계했습니다.
데이터의 안정성을 확보하기 위해 URL 파라미터의 유무로 isEditMode를 확인하는 동시에, 서버에서 받아온 데이터의 작성자 ID를 검증하는 조건을 추가했습니다. 이미 해당 작성 페이지는 PrivateRouter로 보호되어 로그인된 사용자만 접근 가능하지만, 다른 사용자가 solutionId를 임의로 입력해 접근할 가능성을 대비하기 위함입니다.
1차 구현
// 글을 수정하는 경우 input의 초기값을 해당 값으로 변경
useEffect(() => {
if (isEditMode && member?.id === userInfo?.id) {
if (inputTitle)
handleSolutionTitle({
target: { value: inputTitle },
} as React.ChangeEvent<HTMLInputElement>);
if (inputDescription)
handleDescription({
target: { value: inputDescription },
} as React.ChangeEvent<HTMLTextAreaElement>);
if (inputUrl)
handleUrl({ target: { value: inputUrl } } as React.ChangeEvent<HTMLInputElement>);
}
}, [isEditMode, inputTitle, inputDescription, inputUrl, member?.id, userInfo?.id]);
폼 제출 로직도 isEditMode 값에 따라 분기처리를 해주었습니다.
const solutionId = Number(searchParams.get('solutionId')) ?? null;
const isEditMode = !!solutionId;
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isEditMode && solutionId) {
solutionPatchMutation({
solutionId,
title: solutionTitle,
description,
url,
});
} else {
handleSubmitSolution(e);
}
};
isEditMode를 통해 solutionId param의 유무를 체크하고, isEditMode가 true이면서 solutionId param이 있다면 글을 수정하는 Mutation을 실행합니다. 반대로 하나라도 통과되지 않으면 글을 제출하는 핸들러를 실행합니다.
1차 구현의 전체 코드
import * as S from './MissionSubmitPage.styled';
import SubmitBanner from '@/components/MissionSubmit/SubmitBanner';
import PRLink from '@/components/MissionSubmit/PRLink';
import OneWord from '@/components/MissionSubmit/OneWord';
import SubmitButton from '@/components/MissionSubmit/SubmitButton';
import SubmitSuccessPopUp from '@/components/PopUp/SubmitSuccessPopUp';
import { useParams, useSearchParams } from 'react-router-dom';
import useMission from '@/hooks/useMission';
import { ERROR_MESSAGE } from '@/constants/messages';
import useSubmitSolution from '@/hooks/useSubmitSolution';
import LoadingSpinner from '@/components/common/LoadingSpinner/LoadingSpinner';
import MissionTitle from '@/components/MissionSubmit/MissionTitle';
import useUserInfo from '@/hooks/useUserInfo';
import { useEffect } from 'react';
import { useUpdateSolution } from '@/hooks/useUpdateSolution';
import useSolution from '@/hooks/useSolution';
export default function MissionSubmitPage() {
const { id } = useParams();
const missionId = Number(id) || 0;
const [searchParams] = useSearchParams();
const solutionId = Number(searchParams.get('solutionId')) ?? null;
const { data: mission } = useMission(missionId);
const { data: solution } = useSolution(solutionId);
const { data: userInfo } = useUserInfo();
const { solutionPatchMutation } = useUpdateSolution(solutionId);
const missionName = new URL(mission.url).pathname.split('/').pop() ?? '';
const {
solutionTitle,
url,
description,
handleDescription,
handleMarkDownDescription,
handleUrl,
handleSubmitSolution,
handleSolutionTitle,
isPending,
isModalOpen,
isUrlError,
isDescriptionError,
isSolutionTitleError,
isSubmitSolutionError,
isValidSolutionTitle,
} = useSubmitSolution({ missionId, missionName });
const { title: inputTitle, url: inputUrl, description: inputDescription, member } = solution;
const isEditMode = !!solutionId;
// 글을 수정하는 경우 input의 초기값을 해당 값으로 변경
useEffect(() => {
if (isEditMode && member?.id === userInfo?.id) {
if (inputTitle)
handleSolutionTitle({
target: { value: inputTitle },
} as React.ChangeEvent<HTMLInputElement>);
if (inputDescription)
handleDescription({
target: { value: inputDescription },
} as React.ChangeEvent<HTMLTextAreaElement>);
if (inputUrl)
handleUrl({ target: { value: inputUrl } } as React.ChangeEvent<HTMLInputElement>);
}
}, [isEditMode, inputTitle, inputDescription, inputUrl, member?.id, userInfo?.id]);
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isEditMode && solutionId) {
solutionPatchMutation({
solutionId,
title: solutionTitle,
description,
url,
});
} else {
handleSubmitSolution(e);
}
};
return (
<S.Container>
{isPending && <LoadingSpinner />}
<div style={{ maxWidth: '1000px', margin: '0 auto' }}>
<SubmitBanner mission={mission} />
<form onSubmit={handleFormSubmit}>
<MissionTitle
value={solutionTitle}
onChange={handleSolutionTitle}
danger={isSolutionTitleError || !isValidSolutionTitle}
/>
<PRLink
value={url}
onChange={handleUrl}
missionId={missionId}
danger={isUrlError || isSubmitSolutionError}
/>
<OneWord
danger={isDescriptionError}
dangerMessage={ERROR_MESSAGE.no_content}
value={description ?? ''}
onChange={handleMarkDownDescription}
/>
<SubmitButton />
</form>
</div>
<SubmitSuccessPopUp isModalOpen={isModalOpen} thumbnail={mission.thumbnail} />
</S.Container>
);
}
모든 로직을 한 파일에 작성하다 보니 코드가 길어지고 가독성이 떨어졌습니다. 역할을 명확히 분리하고 재사용성을 높이기 위해 커스텀 훅으로 분리하기로 결정했습니다.
2차 구현
로직은 다음과 같습니다.
- 풀이 작성 및 수정 : MissionSubmitPage -> useSubmitSolutionHandlers -> 초깃값 설정, 폼 제출
- 디스커션 작성 및 수정 : DiscussionSubmitPage -> useSubmitDiscussionHandlers -> 초깃값 설정, 폼 제출
useSubmitSolutionHandlers와 useSubmitDiscussionHandlers라는 중간 레이어를 추가하여 두 페이지 간 공통된 로직을 분리했습니다.
또한, 초깃값 설정과 폼 제출 로직이 두 곳에서 반복적으로 사용되어 이를 각각 useFormSubmission과 useInitializeInputs라는 커스텀 훅으로 추상화했습니다.
하지만 useFormSubmission에서 patchMutation을 실행하려면 특정 인자를 전달해야 했는데, Mission과 Discussion의 Mutation 핸들러에서 요구하는 인자가 서로 달랐습니다.
Mission의 경우 solutionId, title, description, url을 전달해야 했지만
export interface SolutionPatchMutationProps {
solutionId: number;
title: string;
description: string;
url: string;
}
Discussion의 경우 discussionId, title, content, missionId, hashTagIds를 전달해야 했습니다.
export interface DiscussionPatchMutationProps {
discussionId: number;
title: string;
content: string;
missionId?: number;
hashTagIds: number[];
}
이러한 차이를 해결하기 위해 확장성을 고려한 설계를 적용했습니다. id 값을 필수로 받고, 나머지 props는 제네릭을 활용해 외부에서 주입받도록 구현했습니다.
// useFormSubmission.ts의 타입
interface FormSubmissionParams<T> {
isEditMode: boolean;
id: number;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
patchMutation: (props: T) => void;
props: T;
}
// 아래와 같이 props를 전달합니다.
const handleEditSubmit = {
patchMutation({
...props,
id,
});
}
// 외부에서 주입하는 경우 아래와 같이 사용 가능합니다.
// 폼 제출 로직
const handleFormSubmit = useFormSubmission<SolutionPatchMutationProps>({
isEditMode,
id: solutionId,
handleSubmit,
patchMutation: solutionPatchMutation,
props: {
solutionId,
title: solutionTitle,
description,
url,
},
});
useCallback 메모이제이션
제출 페이지는 form 관련 컴포넌트와 Tag 관련 컴포넌트를 포함하여 최소 3개 이상의 하위 컴포넌트로 구성되어 있었습니다. 이 경우, 함수의 참조값 변경으로 인해 자식 컴포넌트의 리렌더링이 발생할 가능성이 있었습니다. 이를 방지하기 위해 함수 메모이제이션을 적용했습니다.
따라서 handleEditSubmit 함수에 useCallback을 사용하여 참조값이 변경되지 않도록 설정했습니다.
const handleEditSubmit = useCallback(() => {
patchMutation({
...props,
id,
});
}, [patchMutation, props, id]);
useInitializeInputs 훅은 페이지 렌더링 시 초깃값을 설정하는 역할을 합니다. 내부적으로 useEffect를 사용하여 초깃값을 불러오는데, 이때 useEffect에서 호출하는 setInitialInputValues 핸들러를 메모이제이션했습니다.
setInitialInputValues 핸들러를 메모이제이션하지 않으면, useEffect의 의존성 배열이 변경되어 의도치 않은 재실행이 발생할 수 있습니다. 이를 방지하기 위해 useCallback을 사용해 다음과 같이 구현했습니다.
const setInitialInputValues = useCallback(() => {
// ... 관련 로직
}, [... 필요한 의존성 배열]);
useEffect(() => {
setInitialInputValues();
}, [setInitialInputValues]);
useCallback을 통해 핸들러의 참조값을 고정함으로써 useEffect의 의존성 배열을 안정적으로 유지하고 불필요한 업데이트를 방지할 수 있게 되었습니다.
1차 런칭 이후 피드백 반영
1차 런칭 후 받은 주요 피드백은 모바일 환경을 고려해야 한다는 점이었습니다.
서비스의 주요 진입점이 카카오톡이었기 때문에 대부분의 사용자가 모바일로 접속하는 상황이었습니다. 특히, 메신저를 통해 주변 사람들에게 서비스를 홍보하는 경우가 많아 사용자들이 처음 마주하는 랜딩 페이지가 모바일 환경에 최적화될 필요가 있다는 의견이 있었습니다. 이러한 피드백을 반영해 반응형 디자인 적용을 결정했습니다.
저는 랜딩 페이지의 소개 이미지를 코드로 변환 및 반응형 작업을 맡았습니다. 또한, 헤더를 반응형으로 수정해 모바일과 데스크탑 환경 모두에서 최적의 사용자 경험을 제공하고자 했습니다.
반응형 랜딩 페이지 구현
기존에는 UT 피드백에 따라 랜딩 페이지를 소개 이미지로 대체했지만 화질 저하와 낮은 퀄리티의 문제점이 있었습니다. 이를 개선하기 위해 소개 이미지를 코드로 구현하면서 애니메이션과 다양한 디자인 요소를 추가했습니다.
- 결과물 미리보기
랜딩 페이지에서 스크롤에 따라 뷰포트에 들어오는 컴포넌트에 fade-in 애니메이션을 적용하고 싶었습니다. 다양한 방법을 시도했지만 원하는 결과를 얻지 못하던 중, 쿠키의 Intersection Observer 아이디어를 참고해 최적화된 랜딩 페이지를 구현할 수 있었습니다.
1차 구현: 독립적인 Observer 사용
초기 구현에서는 랜딩 페이지에 등장할 세 개의 컴포넌트 각각에 Intersection Observer를 개별적으로 설정했습니다. 하지만 이 방식에는 여러 문제가 있었습니다:
- Observer 중복 생성: 각 컴포넌트에 독립적으로 Observer가 생성되어, 불필요한 리소스 낭비가 발생했습니다.
- 관리의 어려움: 화면에 동시에 여러 컴포넌트가 보이거나, 특정 위치에서 컴포넌트가 반복적으로 렌더링 되는 버그가 발생했습니다.
// AboutPage.ts
import React, { useRef } from 'react';
import * as S from './AboutPage.styled';
import DiscussionSpace from './DiscussionSpace';
import LevelMission from './LevelMission';
import Rocket from './Rocket';
import { useScrollComponent } from '@/hooks/useScrollComponent';
import Solution from './Solution';
export default function AboutPage() {
const componentRefs = [
useRef<HTMLOptionElement>(null),
useRef<HTMLOptionElement>(null),
useRef<HTMLOptionElement>(null),
];
const { isVisible: isLevelMissionVisible } = useScrollComponent(componentRefs[0], {
threshold: 0.5,
index: 0,
});
const { isVisible: isDiscussionSpaceVisible } = useScrollComponent(componentRefs[1], {
threshold: 0.5,
index: 1,
});
const { isVisible: isSolutionVisible } = useScrollComponent(componentRefs[2], {
threshold: 0.5,
index: 2,
});
const handleScrollDown = () => {
const nextIndex = 0;
if (nextIndex < componentRefs.length && componentRefs[nextIndex].current) {
componentRefs[nextIndex].current.scrollIntoView({ behavior: 'smooth' });
}
};
return (
<S.Container>
<Rocket handleScrollDown={handleScrollDown} />
<LevelMission ref={componentRefs[0]} isVisible={isLevelMissionVisible} />
<DiscussionSpace ref={componentRefs[1]} isVisible={isDiscussionSpaceVisible} />
<Solution ref={componentRefs[2]} isVisible={isSolutionVisible} />
</S.Container>
);
}
// useScrollComponent.ts
import { useState, useEffect, type RefObject } from 'react';
interface UseScrollComponentOptions {
threshold?: number;
index: number;
}
export const useScrollComponent = (
ref: RefObject<HTMLElement>,
{ threshold = 0.5, index }: UseScrollComponentOptions,
) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
} else {
setIsVisible(false);
}
});
},
{
threshold,
},
);
if (ref.current) {
observer.observe(ref.current);
}
return () => {
if (ref.current) {
observer.unobserve(ref.current);
}
};
}, [ref, threshold, index]);
return { isVisible };
};
아래 이미지에서 반복 렌더링 문제를 볼 수 있습니다. 특정 스크롤 위치에서 동일한 컴포넌트가 여러 번 나타나는 현상이 있었습니다.
2차 구현: Observer 단일화 및 상태 관리
이 문제를 해결하기 위해 리팩토링을 진행했습니다. 2차 구현에서는 아래와 같은 방식으로 개선했습니다:
- Observer 단일화: 모든 컴포넌트가 동일한 Intersection Observer를 공유하도록 변경했습니다.
- 상태 기반 관리: 화면에 표시되는 컴포넌트의 인덱스를 상태로 관리하여, 항상 하나의 컴포넌트만 표시되도록 설정했습니다.
이 접근법을 통해 효율적인 리소스 관리와 버그 해결이라는 두 가지 목표를 달성할 수 있었습니다. 또한, Observer 단일화를 통해 코드의 가독성과 유지보수성도 크게 향상되었습니다.
// AboutPage.tsx
import React, { useRef } from 'react';
import * as S from './AboutPage.styled';
import DiscussionSpace from './DiscussionSpace';
import LevelMission from './LevelMission';
import Rocket from './Rocket';
import Solution from './Solution';
import { useScrollComponent } from '@/hooks/useScrollComponent';
export default function AboutPage() {
const componentRefs = [
useRef<HTMLOptionElement>(null),
useRef<HTMLOptionElement>(null),
useRef<HTMLOptionElement>(null),
];
const { visibleIndex } = useScrollComponent(componentRefs, {
threshold: 0.6,
});
const handleScrollDown = () => {
const nextIndex = visibleIndex ?? 0;
if (nextIndex < componentRefs.length && componentRefs[nextIndex].current) {
componentRefs[nextIndex].current.scrollIntoView({ behavior: 'smooth' });
}
};
return (
<S.Container>
<Rocket handleScrollDown={handleScrollDown} />
<LevelMission ref={componentRefs[0]} isVisible={visibleIndex === 0} />
<DiscussionSpace ref={componentRefs[1]} isVisible={visibleIndex === 1} />
<Solution ref={componentRefs[2]} isVisible={visibleIndex === 2} />
</S.Container>
);
}
// useScrollComponent.ts
import { useState, useEffect, type RefObject } from 'react';
interface UseScrollComponentOptions {
threshold?: number;
}
export const useScrollComponent = (
refs: RefObject<HTMLOptionElement>[],
{ threshold = 0.5 }: UseScrollComponentOptions,
) => {
const [visibleIndex, setVisibleIndex] = useState<number | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const index = refs.findIndex((ref) => ref.current === entry.target);
if (entry.isIntersecting && index !== -1) {
setVisibleIndex(index);
}
});
},
{ threshold },
);
refs.forEach((ref) => {
if (ref.current) {
observer.observe(ref.current);
}
});
return () => {
refs.forEach((ref) => {
if (ref.current) {
observer.unobserve(ref.current);
}
});
};
}, [refs, threshold]);
return { visibleIndex };
};
반응형 헤더 구현
헤더 작업은 다양한 레퍼런스를 참고하며 진행했습니다. 특히, 여러 사이트 중 인프런을 참고했는데, 이곳에서는 데스크탑과 모바일 환경에 따라 헤더를 두 가지 형태로 구현하고 있었습니다.
- 데스크탑 전용 헤더를 데스크탑 사이즈에서 확인했을 때
- 데스크탑 전용 헤더를 모바일 사이즈에서 확인했을 때
데스크탑 환경에서 데스크탑 전용 헤더는 display: block 상태이고, 화면을 줄여 모바일 환경이 되면 display: none이 됩니다.
- 모바일 전용 헤더를 모바일 사이즈에서 확인했을 때
- 모바일 전용 헤더를 데스크탑 사이즈에서 확인했을 때
모바일 환경에서 모바일 전용 헤더는 display: block 상태이고, 화면을 키워 데스크탑 환경이 되면 display: none이 됩니다.
이 점을 차용해서 구현해 보기로 했습니다.
결과물
- 데스크탑
- 모바일
인프런처럼 화면 사이즈에 따라 display 속성이 변하는 값으로 설정했습니다. display 설정을 바꾸게 되면 reflow가 일어나 잘 바꾸지 않는 편인데, 일반적으로 처음 접근하는 기기로 화면 사이즈가 고정된다고 생각하여 display 값을 조정해도 성능에는 큰 문제가 되지 않겠다고 생각했습니다.
import useUserInfo from '@/hooks/useUserInfo';
import useLogoutMutation from '@/hooks/useLogoutMutation';
import Desktop from './Desktop';
import Mobile from './Mobile';
export default function Header() {
const { data: userInfo } = useUserInfo();
const { handleUserLogout } = useLogoutMutation();
return (
<>
<Desktop userInfo={userInfo} handleUserLogout={handleUserLogout} />
<Mobile userInfo={userInfo} handleUserLogout={handleUserLogout} />
</>
);
}
// Desktop header
import { useLocation } from 'react-router-dom';
import { ROUTES } from '@/constants/routes';
import * as S from './Header.styled';
import HeaderMenu from './HeaderMenu';
import type { UserInfo } from '@/types/user';
import { API_URL } from '@/apis/clients/develupClient';
import { PATH } from '@/apis/paths';
interface DesktopProps {
userInfo: UserInfo | undefined;
handleUserLogout: () => void;
}
export default function Desktop({ userInfo, handleUserLogout }: DesktopProps) {
const { pathname } = useLocation();
return (
<>
<S.Container>
// ... 기타 코드들
</S.Container>
<S.Spacer />
</>
);
}
// Desktop header의 container 속성
export const Container = styled.nav`
z-index: 100;
width: 100%;
height: 6rem;
position: fixed;
background: ${(props) => props.theme.colors.white};
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
${media.medium`
display: none;
`}
`;
// Mobile Header
import type { UserInfo } from '@/types/user';
import * as S from './Header.styled';
import HeaderMenu from './HeaderMenu';
import { ROUTES } from '@/constants/routes';
import { API_URL } from '@/apis/clients/develupClient';
import { PATH } from '@/apis/paths';
import { Link, useLocation } from 'react-router-dom';
import { useState } from 'react';
interface MobileProps {
userInfo: UserInfo | undefined;
handleUserLogout: () => void;
}
export default function Mobile({ userInfo, handleUserLogout }: MobileProps) {
const { pathname } = useLocation();
const [isToggleOpen, setIsToggleOpen] = useState(false);
const handleToggle = () => {
setIsToggleOpen(!isToggleOpen);
};
return (
<>
<S.MobileContainer>
// ... 기타 코드들
</S.MobileContainer>
</>
);
}
// Mobile Header의 Container 속성
export const MobileContainer = styled.nav`
display: none;
z-index: 100;
width: 100%;
position: fixed;
background: ${(props) => props.theme.colors.white};
top: 0;
left: 0;
flex-direction: column;
justify-content: space-between;
align-items: center;
white-space: nowrap;
padding: 0 2rem;
${media.medium`
display: block;
`}
`;
Mobile Header의 가로폭이 좁아져 메뉴가 겹치는 현상이 있었습니다. 따라서 햄버거바와 Dropdown을 이용하여 구현했습니다.
전체 코드
import type { UserInfo } from '@/types/user';
import * as S from './Header.styled';
import HeaderMenu from './HeaderMenu';
import { ROUTES } from '@/constants/routes';
import { API_URL } from '@/apis/clients/develupClient';
import { PATH } from '@/apis/paths';
import { Link, useLocation } from 'react-router-dom';
import { useState } from 'react';
interface MobileProps {
userInfo: UserInfo | undefined;
handleUserLogout: () => void;
}
export default function Mobile({ userInfo, handleUserLogout }: MobileProps) {
const { pathname } = useLocation();
const [isToggleOpen, setIsToggleOpen] = useState(false);
const handleToggle = () => {
setIsToggleOpen(!isToggleOpen);
};
return (
<>
<S.MobileContainer>
<S.MobileWrapper>
<S.MobileLeft>
<S.HamburgerToggleIcon onClick={handleToggle} />
</S.MobileLeft>
<S.MobileCenter>
<Link to={ROUTES.main}>
<S.LogoImage />
</Link>
</S.MobileCenter>
<S.RightPart>
{!userInfo ? (
<a href={`${API_URL}${PATH.githubLogin}?next=${pathname}`}>
<S.LoginButton aria-label="클릭하면 깃허브 로그인으로 이동합니다.">
로그인
</S.LoginButton>
</a>
) : (
<S.LoginButton onClick={handleUserLogout}>로그아웃</S.LoginButton>
)}
</S.RightPart>
</S.MobileWrapper>
{isToggleOpen && (
<S.ToggleMenu>
<HeaderMenu
name="미션"
path={ROUTES.missionList}
currentPath={pathname}
handleToggle={handleToggle}
/>
<HeaderMenu
name="풀이"
path={ROUTES.solutions}
currentPath={pathname}
handleToggle={handleToggle}
/>
<HeaderMenu
name="디스커션"
path={ROUTES.discussions}
currentPath={pathname}
handleToggle={handleToggle}
/>
{userInfo && (
<HeaderMenu
name="대시보드"
path={ROUTES.dashboardHome}
currentPath={pathname}
handleToggle={handleToggle}
/>
)}
</S.ToggleMenu>
)}
</S.MobileContainer>
</>
);
}
import { useLocation } from 'react-router-dom';
import { ROUTES } from '@/constants/routes';
import * as S from './Header.styled';
import HeaderMenu from './HeaderMenu';
import type { UserInfo } from '@/types/user';
import { API_URL } from '@/apis/clients/develupClient';
import { PATH } from '@/apis/paths';
interface DesktopProps {
userInfo: UserInfo | undefined;
handleUserLogout: () => void;
}
export default function Desktop({ userInfo, handleUserLogout }: DesktopProps) {
const { pathname } = useLocation();
return (
<>
<S.Container>
<S.Wrapper>
<S.LeftPart>
<S.LogoWrapper to={ROUTES.main}>
<S.LogoImage />
<S.Logo> DEVEL UP</S.Logo>
</S.LogoWrapper>
</S.LeftPart>
<S.MenuWrapper>
<HeaderMenu name="미션" path={ROUTES.missionList} currentPath={pathname} />
<HeaderMenu name="풀이" path={ROUTES.solutions} currentPath={pathname} />
<HeaderMenu name="디스커션" path={ROUTES.discussions} currentPath={pathname} />
</S.MenuWrapper>
<S.RightPart>
{userInfo && (
<HeaderMenu name="대시보드" path={ROUTES.dashboardHome} currentPath={pathname} />
)}
{!userInfo ? (
<a href={`${API_URL}${PATH.githubLogin}?next=${pathname}`}>
<S.LoginButton aria-label="클릭하면 깃허브 로그인으로 이동합니다.">
로그인
</S.LoginButton>
</a>
) : (
<S.LoginButton onClick={handleUserLogout}>로그아웃</S.LoginButton>
)}
</S.RightPart>
</S.Wrapper>
</S.Container>
<S.Spacer />
</>
);
}
마무리
랜딩 페이지를 구현하는 데만 약 3일을 꼬박 투자했는데, 디자인과 UI/UX에 완성도를 높일 수 있었습니다. 결과적으로 모바일 환경에서도 매끄럽게 작동하는 반응형 랜딩 페이지를 구현했고, 사용자 경험이 크게 향상되었습니다. 이번 리팩토링을 통해 랜딩 페이지의 등장 애니메이션이 더 자연스러워졌고, 유지보수와 관리가 편리해졌습니다. 이 경험을 통해 문제를 명확히 정의하고, 불필요한 중복을 제거하는 것이 얼마나 중요한지 다시금 배울 수 있었습니다.
감사합니다.
'우아한테크코스' 카테고리의 다른 글
Intersection Observer와 display를 활용한 랜딩 페이지 및 헤더 구현기 (0) | 2024.11.16 |
---|---|
프로젝트 성능 개선기 (번들 사이즈 최적화를 통한 웹 성능 최적화) (1) | 2024.10.31 |
우아한테크코스 최종 데모데이 회고🍀 (0) | 2024.10.28 |
'위로 가기 버튼' 스크롤 이벤트 최적화하기 (0) | 2024.10.20 |
웹 사이트의 성능을 높여보자 (2) (같은 건 매번 새로 요청하지 않기, 최소한의 변경만 일으키기) (0) | 2024.09.17 |