티스토리 뷰
2023년 10월, 원티드 프리온보딩 프론트엔드 챌린지 요약
로그인
사용자가 시스템에 접근하거나 동작을 수행하는 것을 제어하고, 기록하기 위한 컴퓨터 보안 절차이다.
토큰 및 세션의 로그인 동작 과정은 이전 시간까지 계속 다루어왔기 때문에 여기서 따로 메모하지 않겠다.
권한에 따라 적절한 자원에 접근하기
로그인 회원의 권한에 따라 유저 전용 페이지 및 데이터에 접근하거나 사장 전용 페이지 및 데이터에 접근하는 방법은 무엇일까?
유저 확인 & 권한 관리
우선 서버와의 역할 분담에서 항상 지켜져야 하는 것을 알아야 한다.
이 때, '서버 벨리베이션'은 필수적으로 구현해야 된다. 서버에서는 권한에 따라 본인의 자원에만 접근할 수 있도록 구현할 수 있다. 즉, 권한에 따라 동작을 제어하는 것이다.
그렇다면, 프론트엔드에서 해야할 일은 무엇일까? 바로 권한에 따라 적절한 자원에 접근하도록 하는 일을 구현해야 한다.
if (userProfile?.userInfo.roles.length === 0) {
routeTo('/login')
return <></>
}
실습을 해보자
실무에서는 사용할 수 없으니, 과정을 알아가는 학습 용도로만 보자
여기서 해야 할 것은 어드민 페이지를 Router에 추가하고, 어드민 유저만 접근할 수 있도록 하는 것이다.
const routerData: RouterElement[] = [
{
id: 0,
path: "/",
label: "Home",
element: <Home />,
withAuth: false,
},
{
id: 1,
path: "/login",
label: "로그인",
element: <Login />,
withAuth: false,
},
{
id: 2,
path: "/page-a",
label: "페이지 A",
element: <PageA />,
withAuth: true,
},
{
id: 3,
path: "/page-b",
label: "페이지 B",
element: <PageB />,
withAuth: true,
},
{
id: 4,
path: "/page-c",
label: "페이지 C",
element: <PageC />,
withAuth: true,
},
{
id: 5,
path: "/admin",
label: "어드민 페이지",
element: <AdminPage />,
withAuth: true,
isAdminPage: true,
},
];
export const routers: RemixRouter = createBrowserRouter(
routerData.map((router) => {
if (router.withAuth) {
return {
path: router.path,
element: (
<GeneralLayout
isAdminPage={"isAdminPage" in router && router.isAdminPage}
>
{router.element}
</GeneralLayout>
),
};
} else {
return {
path: router.path,
element: router.element,
};
}
})
);
기존 코드에서 가장 하단에 어드민 페이지를 추가하였다.
그리고 로그인이 필요한 페이지의 경우에 해당 계정이 어드민 계정인지 일반 계정인지 'GeneralLayout' 컴포넌트에 값을 넘겨주도록 하였다.
export const SidebarContent: SidebarElement[] = routerData.reduce(
(prev, router) => {
if (!router.withAuth) return prev;
return [
...prev,
{
id: router.id,
path: router.path,
label: router.label,
isAdminOnly: "isAdminPage" in router && router.isAdminPage,
},
];
},
[] as SidebarElement[]
);
그리고 나서, 어드민 계정인지 아닌지 확인하여 사이드 메뉴의 데이터들을 다르게 표시할 수 있도록 어드민 유효 값을 설정하였다.
// GeneralLayout 컴포넌트
if (userProfile?.userInfo.roles.length === 0) {
routeTo("/login");
return <></>;
}
if (isAdminPage && !userProfile?.userInfo.roles.includes(AdminRole)) {
routeTo("/page-a");
return <></>;
}
이제 'GeneralLayout'에서 로그인이 되어 있지 않으면 로그인 페이지로 이동시키고, 어드민 페이지 접근 시 어드민 계정이 아니라면 일반 페이지로 이동하도록 하였다.
// SidebarMenu 컴포넌트
<ul>
{sidebarContent
.filter((element) => {
return element.isAdminOnly
? userProfile?.userInfo.roles.includes("admin")
: !!userProfile;
})
.map((element) => {
return (
<li
key={element.path}
className={
currentPath === element.path
? "sidebar-menu selected"
: "sidebar-menu"
}
onClick={() => sidebarMenuClickHandler(element.path)}
>
{element.label}
</li>
);
})}
</ul>
그리고 나서, 사이드바 메뉴가 일반 계정 또는 어드민 계정에 따라 선택적으로 렌더링이 되도록 하였다.
어드민 계정일 때, 어드민 계정에게만 보여줄 메뉴를 나타내도록 하였다.
일반 계정일 경우에는 어드민 페이지를 보여주지 않고, 공통적으로 나타내는 페이지만 나타나도록 하였다.
그렇다면, 이제 권한 별로 자원을 조회하고 로그아웃을 하는 실습을 진행해보자
각 유저의 개인 아이템을 조회하고, 어드민 계정일 경우에는 모든 유저의 아이템을 조회할 수 있도록 한다.
마지막으로 로그아웃을 구현한다.
export const getItems = async (): Promise<Item[] | null> => {
const itemRes = await fetch(`${BASE_URL}/items`, {
method: "GET",
headers: {
"Content-Type": "application/json",
credentials: "include",
},
});
return itemRes.ok ? itemRes.json() : null;
};
export const getAllItems = async (): Promise<Item[] | null> => {
const itemRes = await fetch(`${BASE_URL}/all-items`, {
method: "GET",
headers: {
"Content-Type": "application/json",
credentials: "include",
},
});
return itemRes.ok ? itemRes.json() : null;
};
우선, 아이템들을 가져오도록 구현하였다. 이 때, 세션을 쿠키에 담기 위해서 "credentials: 'include'"를 포함하였다.
export const logout = async (): Promise<void> => {
await fetch(`${BASE_URL}/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
credentials: "include",
},
});
};
그리고 로그아웃 기능을 구현하였다. 세션 기반의 로그아웃은 브라우저에서 확인할 때는 남아있지만, 서버에서는 남아있지 않다. 그 이유는 서버에서 'Requests'를 통해 전달받은 'session-id'를 완전히 파괴시켜 버리기 때문이다. 브라우저(클라이언트)에서 세션이 남아있다고 요청을 계속해봤자 서버에서는 이미 파괴시켜 버렸기 때문에 로그인이 되지 않는다.
즉, 어떤 서비스를 요청해도 이미 세션을 파괴시켰기 때문에 다시 로그인을 해야 한다.
const GeneralLayout: React.FC<GeneralLayoutProps> = ({
children,
isAdminPage,
}) => {
const [userProfile, setUserProfile] = useRecoilState(UserAtom);
const { routeTo } = useRouter();
const fetchUserProfile = useCallback(async () => {
const userProfileResponse = await getCurrentUserInfo();
if (userProfileResponse === null) {
routeTo("/login");
return;
}
setUserProfile(userProfileResponse);
}, []);
이제는 유저 정보를 받아서 처리하는 코드를 작성한다. 이전에는 유저 정보를 Props로 받아서 사용했지만, 과도한 드릴다운 등을 개선하기 위해 유저 정보를 전역 상태로 저장할 수 있는 'Recoil'을 이용해서 유저 정보를 넘긴다.
그래서 기존에 Props로 작성된 유저 정보 값을 제거하고, Recoil로 선언하여 처리한다.
const Sidebar: React.FC<SidebarProps> = ({ sidebarContent }) => {
const userProfile = useRecoilValue(UserAtom);
const { currentPath, routeTo } = useRouter();
const sidebarMenuClickHandler = (path: string) => {
routeTo(path);
};
const logoutHandler = async () => {
await logout();
routeTo("/");
};
마찬가지로, Props로 유저 정보를 전달받았던 사이드바 컴포넌트에서도 유저 정보 Props를 제거하고, 'Rocoil'로 선언한 유저 정보 값을 가져와서 사용하도록 한다.
const PageA = () => {
const [items, setItems] = useState<Item[] | null>(null);
const isUserItemsFetched = useRef(false);
const fetchUserItems = useCallback(async () => {
const userItems = await getItems();
if (userItems !== null) setItems(userItems);
isUserItemsFetched.current = true;
}, []);
useEffect(() => {
if (!isUserItemsFetched.current) fetchUserItems();
}, []);
return (
<div>
<h1>Page A</h1>
<ItemList items={items} />
</div>
);
};
const AdminPage = () => {
const [items, setItems] = useState<Item[] | null>(null);
const isUserItemsFetched = useRef(false);
const fetchUserItems = useCallback(async () => {
const userItems = await getAllItems();
if (userItems !== null) setItems(userItems);
isUserItemsFetched.current = true;
}, []);
useEffect(() => {
if (!isUserItemsFetched.current) fetchUserItems();
}, []);
return (
<div>
<h1>AdminPage</h1>
<ItemList items={items} />
</div>
);
};
이제, 사이드바에서 아이템을 가져와서 출력하도록 한다. 이미 회원이 어드민 계정인지, 일반 계정인지 확인하고 나서 아이템을 출력하기 때문에 어드민 계정일 경우에는 'getAlItems'를 가져오고, 아니면 'getItems'를 가져온다.
그렇다면, 서버에서는 무슨 일이 일어날까? 정말 간단하게만 알아보자.
우선 소유자를 확인한다. 그리고 나서 권한 조회를 하고 필터링한다.
그 다음으로 로그아웃을 구현한다.
async getUserItems(user: User): Promise<Item[]> {
return this.Items.filter(item => {
return item.owner.userId === user.userId
})
}
@UseGuards(AuthenticatedGuard)
@Get('/all-items')
async getAllItems(@Request() req) {
if (!req.user.userInfo.roles.includes('admin')) throw new HttpException('권한 없음', 403)
return this.userService.getAllItems()
}
async getAllItems(): Promise<Item[]> {
return this.Items
}
@UseGuards(AuthenticatedGuard)
@Post('/logout')
getProfile(@Request() req) {
req.session.destroy()
return {
message: '로그아웃이 되었습니다.'
}
}
만약, 로그아웃이 토큰으로 진행되었다면? 클라이언트에 저장된 토큰은 어떻게 삭제할까?
해당 토큰을 사용하지 말아야 한다고 명시해줘야 한다.
서버에서 따로 해당 값을 저장해서 사용할 지, 말아야 할 지 서버에서 처리한다.
리프레시 토큰은 어떻게 유지되는가
Fetch 클라이언트에 'header'의 'Include'처럼 만들며, 'Refresh-token'도 이 처럼 만든다고 생각하면 된다.
'axios'의 'interceptors'에서 요청이 왔다 갔다하는 것에서 토큰도 처리한다.
보통, 'Access-token'은 짧은 시간동안 유지되도록 하며, 만약 시간이 지나 'Access-token' 이 만료된다면, 'Refresh-token'을 이용해서 'Access-token'을 재발급한다.
다시 한 번 'Refresh-token'의 동작 과정을 살펴보자.
클라이언트가 서비스에 인증을 요청하면, 서비스에서는 'Access-token'을 짧은 시간동안 유효하게 로컬에 발급하고, 'Refresh-token'은 'HttpOnly Cookie'로 발급한다. 이 때, HttpOnly는 자바스크립트로 접근할 수 없어 XSS 공격을 예방한다. 이 때, CSRF 공격으로 악성 사이트에서 'Refresh-token' 재사용을 시도하게 되면, 악성 사이트에서 토큰을 재발급을 시도하고, 서비스에서는 이 공격에 대해 여러가지 방어 정책을 설계하여 문제를 예방한다. 예를 들면, 'Access-token'이 유요한 동안은 재발급이 불가되도록 말이다.
잠깐!
'401 에러'와 '403 에러'의 차이는 무엇일까?
둘의 공통점은 요청에 대한 권한이 없는 것이다. 반대로 둘의 차이점은 '401 에러'는 요청자에 대해 누군지 모를 때의 에러이고, '403 에러'는 누구인지는 알지만 허가할 수 없다는 에러이다.
'401 에러'가 발생할 때, 리프레시 토큰이 있을 때를 확인하고, 있다면 리프레시 토큰을 가져와 처리하도록 하는데 이 때 무한 루프에 빠질 수 있다. 비동기적으로 동작하도록 구현하여 이를 완전히 받기 전에 다음 코드가 에러가 나서 다시 '401 에러' 처리가 되는 경우가 발생하기 때문이다.
oAuth(소셜 로그인, Open Authorization)
허가된 다른 서비스를 통해 기존 서비스(구글 등)의 권한을 '위임'하는 것을 말한다.
oAuth의 동작 과정은 어떻게 될까?
- oAuth 인증 요청
- oAuth 로그인 용 링크 전달
- 인증 요청 & 타 서비스 정보 전달
- 타 서비스 전용 토큰 전달
- 타 서비스용 토큰과 동작 요청
- 받은 토큰과 시크릿 키를 이용해 동작 요청
- 전달된 토큰과 시크릿 키 확인 후 동작 승인
- 동작 수행 및 응답
클라이언트가 서비스에 소셜 로그인을 하고 싶다고 요청을 하면, 특정 링크를 준다.
그 특정 링크를 가지고 구글에 로그인을 요청하면, 구글에서 시크릿 값과 접근 허용 허가 주소를 준다.
그 시크릿 값과 허용 허가 주소를 가지고 서비스에 다시 요청을 하고, 서비스는 그 값을 가지고 구글에 다시 확인한다.
클라이언트에서는 공개되지 않는 서비스와 구글끼리 확인할 수 있는 시크릿 값을 확인하고(보증완료) 나면 인증이 성공되어 요청이 승인되고 소셜 로그인이 진행된다.
oAuth는 권한에 따라 oAuth 원본 서버(구글 등)의 유저 정보를 가져와서 사용하거나 권한을 위임받은 동작을 할 수 있다. 예를 들면, 캘린더에 일정을 등록하는 일처럼 말이다. 그리고 인증 자체만을 사용할 수도 있다.
클라이언트 & 싱글톤 패턴
하나의 인스턴스만을 가지고 필요한 동작들을 하는 것을 말한다. 함수형으로 매 번 실행하며, 윈도우에서는 하나의 클래스를 만들고 그 클래스를 사용하는 것을 생각하면 된다.
싱글톤 패턴과 비슷한 것은 '라우터 객체'이다. 하나의 데이터가 여러 용도로 분화시킬 때 사용한다. 중요한 것은 섞이지 않는 데이터이다. API 호출까지 겹친다면 이는 큰 문제가 될 것이다.
const routerData: RouterElement[] = [
{
id: 0,
path: "/",
label: "Home",
element: <Home />,
withAuth: false,
},
{
id: 1,
path: "/login",
label: "로그인",
element: <Login />,
withAuth: false,
},
]
우리가 'PageA'로 경로를 설정하고 나서, 호출을 'page-a'로 했을 때, 과연 이는 옳은 경로일까?
{ path: '/PageA' } /page-a
{ path: '/PageB' } → /page-b
{ path: '/PageC' } /page-c
{ path: '/PageA' }
{ path: '/PageB' } → ?
{ path: '/PageC' }
그래서 '데이터의 원천'을 생각해야 한다.
이는 서비스가 커질수록 중요하다. 하나의 데이터가 하나의 맥락에서 관리가 되고 있는지 확인해야 한다.
백엔드?
프론트엔드를 준비하면서 백엔드를 알아볼 시간도, 여유도, 생각도 없을 수 있다.
로그인을 구현하면서 로그인과 관련된 백엔드 작업을 얼마나 알고 있을까?
기본적으로 작업을 하면서 필요한 간단한 지식은 알고 있어야 한다. 서로의 지식과 작업 범위를 크로스하는 작업들이 많기 때문이다.
- 요청할 때, 'Set-Cookie'를 보내는 것, 브라우저의 'Storage' 내 'Cookies'에서 쿠키 값을 확인할 수 있는 것 등
역할을 완전히 나누는 게 나을까? R&R?
역할을 나누는 것이 책임 범위를 나누는 것은 아니다. 프론트엔드는 프론트엔드에서만 해야 하는 일이니까, 백엔드는 백엔드에서만 해야 하는 일이니까 모르는 일이다라는 생각은 버려야 한다.
프론트엔드도 백엔드도 각자의 분야라고 다 아는 것은 아니다. 모를 수도 있다는 것을 이해하고 도움이 필요하면 알려줄 수 있어야 한다. 무언가를 '막연하게' 알고 있는 것인지, 본질에 대해 질문해보자.
리액트 함수형 컴포넌트는 무엇일까?
혹시, 리액트 함수형 컴포넌트에 대해 알고 있는가?
'useState'같은 'hook'을 사용할 수 있고, 내부에 'JSX' 문법을 사용할 수 있으며, 'Props'를 전달받을 수도 있는 것이라고 답하고 있지 않는가?
여기서 '~것'이라는 답변은 '~하는 무언가'라는 뜻과 같다고 볼 수 있다. 현상과 관념을 분리할 필요가 있으며, 어떤 대상에게서 일어나는 현상은 대상을 정의하는 것이 아니다.
그렇다면, 어떻게 말할 수 있을까?
리액트 함수형 컴포넌트는 무엇인가? 'JSX.Element' 형태의 값을 반환하는 순수 자바스크립트 함수이다.
리액트 함수형 컴포넌트는 어떤 기능이 있는가? 'useState'같은 'hook'을 사용할 수 있고, 내부에 'JSX' 문법을 사용할 수 있으며, 'Props'를 전달받을 수도 있다.
FormData vs JSON
둘은 크기의 차이가 있다. 무엇을 사용해야 하는가보다는 프로젝트에 맞춰서 사용하자.
useRouter 커스텀훅을 사용하는 이유
export const useRouter = () => {
const router = useNavigate()
return {
currentPath: window.location.pathname,
routeTo: (path: string) => router(path),
back: () => router(-1),
forward: () => router(1),
isActiveRouter: (path: string) => window.location.pathname === path,
}
}
최근 페이지를 저장하거나, 페이지를 이동하거나, 뒤로가기&앞으로가기 그리고 페이지 확인 등 다양한 기능을 동작하도록 구현할 수 있다.
정보
세션을 쿠키에 담는 것은 header의 credentials: 'include'
'여러가지 활동 > 프리온보딩 프론트엔드 챌린지' 카테고리의 다른 글
클린코드에 대해 (0) | 2023.11.10 |
---|---|
원티드 프리온보딩 10월 프론트엔드 챌린지 후기 (0) | 2023.10.15 |
프론트엔드에서의 세션과 쿠키 (1) | 2023.10.12 |
프론트엔드에서의 토큰과 보안 (1) | 2023.10.07 |
프론트엔드에서 로그인의 개념과 기초 실습 (0) | 2023.10.05 |
- Total
- Today
- Yesterday
- #포스텍애플디벨로퍼아카데미
- 고민한 부분
- 조코딩과함께
- 개발 이력서 지원 팁
- Singleton
- 코딩테스트 대비
- 포스텍애플디벨로퍼아카데미
- 원티드 프리온보딩 챌린지
- Default Branch
- 최종추가합격
- 깃허브 Merge
- node
- 싱글톤
- javascript
- 설명회느낌점
- 개발자이력서꿀팁
- DB Error MongooseServerSelectionError
- Express
- 스프링
- 프론트엔드 챌린지
- LottieFiles
- if(kakao)dev2022
- 그룹인터뷰후기
- React
- 원티드 프리온보딩
- Frontend
- 자바스크립트
- 신입개발자가 준비해야 할 것들
- PostechAppleDeveloperAcademy
- 포스텍애플아카데미
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |