본문으로 건너뛰기

5월 회고

약 27분
Ju young Lee
A contribution-driven developer

서론

지난달, 유지보수 하기 쉬운 프론트엔드 설계를 고민해 보고 5월에 적용해보려고 했다. '어떻게 유지보수 하기 좋은 소프트웨어를 만들 수 있을까?'에 대한 생각과 고민은 OOP로 수렴되고 있다. 혹자는 프론트엔드와는 큰 관련이 없는 것은 아니지 않은가 싶을 수 있다. 하지만 공부하면 공부할수록 리액트에 적용할 부분이 많다는 것을 느낀다. 주옥같은 내용들이 많았고 이를 소화해서 체득하는 시간을 가지고 있다. 깨끗한 코드를 작성하는 초석을 다듬을 수 있게 됐다!

5월 Action Point

  • FSD 블로그 2편 작성
  • 단위/통합 테스트 적용 및 학습
  • 객체 지향 프로그래밍 (클린 코더스 1회독)
  • 캡슐화하여 프로그램 유지보수 쉽게 리팩터링 (current)
  • Next.js 사이드 프로젝트 진행 (fillsLog 진행 중) -> 웹 뷰를 경험해보고 싶어서 관련된 사이드 프로젝트 진행해볼 예정
  • 사용자 권한 정책 도입 정리
  • 백엔드 CI/CD 활용하여 EB에 컨테이너 자동 배포 파이프라인 구축

계획하진 않았지만 해낸 것

  • 백엔드 API 첫 수정 및 배포
  • 백엔드 로그인 기록 특정 기간 외 데이터는 자동 삭제 기능 추가!
  • 백엔드 권한명 기존 User -> Client로 변경 성공! (유지보수하기 좋은 코드로 만들어라!)

5월은 가정의 달이니 만큼 많은 휴일이 있었고 공부할 수 있는 시간이 다른 달보다 상대적으로 많았다. 그래서 시간을 확보해서 github action에 대한 강의, docker에 대한 강의, OOP를 설명하는 강의, 단위 테스트의 기술이라는 책 절반을 읽으면서 공부했다. 이를 실무에 어떻게 녹여낼 수 있을까를 고민할 수 있는 시간을 가졌다. 5월 내내 들어간 Input이 6월에 드러나길 소망해 본다.

하나 깨달은 것은 "프론트엔드도 OOP를 알고 있어야 한다."이다. 그 이유는 차차 살펴보자.

본론

이번 달은 비젼 시스템 주요 기능 구현과 개발의 기본기를 익히는 시간을 가졌다. 아는 지식들을 활용해서 사내 개발자 경험을 향상하고 회사에 도움이 되는 것들을 만드는데 노력했다. 그러던 와중 깨달은 것은 내가 좋아하는 건 리액트 개발이 아니라 결국 동료들을 돕고 고객들을 돕는 게 기쁘다는 것이다. 리액트가 아니어도 될지 모른다는 시야가 생겼다.

중요한 데이터를 보호하자 - 사용자의 역할과 책임 기능 구현

기존 어플리케이션의 권한은 ADMINUSER, 2가지만 존재했다. 사내 모든 인원들이 모두 어드민 권한을 가진 각 계정을 소유하고 있었고 어플리케이션 내부에는 고객의 중요한 데이터 및 민감한 정보들이 존재했다.

What if 이 데이터를 활용해서 악의적인 일을 시도 한다면...?

최근 들어 워낙 많은 해킹 사건들이 많아서... 가만히 있으면 안 되겠다는 생각이 들어 기능을 도입하자고 제안했고 받아들여졌다.

그래서 어드민은 권한은 대표자에게만 부여하고 사내 직원은 Staff로 권한을 따로 만들어, 어드민이 스태프가 사용 가능한 권한을 부여하는 방식으로 보안을 강화하기로 결정했다.

어떻게 구현할 것인가?

데이터 자료 구조 형식은?

AWS IAM 정책을 참고하여 아이디어를 제안했다. 결론적으로 RBAC (Role-Based Access Control) 방식으로 구현하기로 결정됐고 데이터는 아래와 같은 형태이다.

{
"role": "admin",
"privileges": ["read:domain", "write:domain", "delete:domain",...]
}

위와 같은 형태로 각 역할에 따른 privileges를 넣어주는 방식으로 합의했다.

사용자 권한 확인 커스텀 훅 구현

권한을 확인하고 해당 권한이 유저에게 있는지 확인하는 로직을 한 군데에서 관리해야하는데 해당 기능을 수행하는 훅을 만들어보았다.

// usePrivilege.ts
import { useQuery } from "@tanstack/react-query";

import { AuthQueries } from "@/entities/auths";

import { Privilege } from "../constants/privileges";
import { hasPrivilege } from "../utils";

export type Mode = "or" | "and";

export type PrivilegeProps = {
required: Privilege | Privilege[];
mode?: Mode;
};
export function usePrivilege({ required, mode }: PrivilegeProps): boolean {
const { data: user } = useQuery(AuthQueries.getMyInfo());
const privileges = user?.privileges || [];

return hasPrivilege(privileges, required, mode);
}

// utils > hasPrivilege
export function hasPrivilege(
userPrivileges: string[] = [],
requiredPrivilege: Privilege | Privilege[],
mode: Mode = "or",
): boolean {
const requiredList = Array.isArray(requiredPrivilege)
? requiredPrivilege
: [requiredPrivilege];

if (mode === "and") {
return requiredList.every((requiredPrivilege) =>
userPrivileges.includes(requiredPrivilege),
);
}

return requiredList.some((requiredPrivilege) =>
userPrivileges.includes(requiredPrivilege),
);
}

먼저는 usePrivilege 훅을 만들었다. 그 안에선 유저의 상태를 가지고 온 후, privilege 배열을 확인한 후, 인자로 받은 권한이 유저가 가지고 있는 권한 중에 있는지 비교하는 로직이 필요했다. 그래서 처음엔 useprivilege 훅 내부에 로직을 두었는데 생각해 보니 복잡한 경우가 있을 수 있었다.

권한에 따른 UI를 제어할 때 두 개의 권한중 하나만 있을 경우, 5개의 권한이 모두 있을 경우 등 AND, OR와 같은 논리 연산을 하는 경우가 있다는 것을 알게 됐다.

그래서 hasPrivilege라는 유틸함수로 분리했고 every와 some 메소드를 활용해서 구현했다. 더 나은 구조가 있거나 의문이 드눈 부분이 있다면 정말 편하게 말씀해 주세요~ 더 좋은 방법이 있는지 궁금해서 공유해요!

프론트 UI 제어는 이렇게 해도 되지 않을까?

그럼 모든 화면 곳곳에 존재하는 UI에 따른 기능은 어떻게 제어하는 게 좋을까? 우선 UI 제어를 담당하는 컴포넌트를 만들어야겠다고 생각했다.

import { ReactElement, ReactNode, cloneElement, isValidElement } from 'react';

import { useToast } from '../lib';
import { PrivilegeProps, usePrivilege } from '../lib/hooks/usePrivilege';
import NoPrivilegeOverlay from './NoPrivilegeOverlay';

type PrivilegeGateProps = PrivilegeProps & {
children: ReactNode;
render?: 'hide' | 'disabled' | 'custom';
variant?: 'page' | 'card' | 'section' | 'inline';
};

const PrivilegeGate = ({
required,
children,
mode,
render = 'hide',
variant = 'page',
}: PrivilegeGateProps) => {
const hasAccess = usePrivilege({ required, mode });
const { toast } = useToast();

if (hasAccess) return <>{children}</>;

if (render === 'disabled') {
if (isValidElement(children)) {
const onClick = () => {
toast({
title: 'You don’t have permission to perform this action.',
});
};

return cloneElement(children as ReactElement, {
onClick,
style: { pointerEvents: 'auto', opacity: 0.5, cursor: 'not-allowed' },
title: '권한이 없습니다',
});
}

return null;
}
if (render === 'custom') {
return <NoPrivilegeOverlay variant={variant} />;
}

return null;
};

export default PrivilegeGate;

현시점 PrivilegeGate 컴포넌트인데 요구 사항이 조금씩 추가되면서 if 분기가 많아지는데... 문제가 발생할 때 방법을 간구해보려고 한다.

우선 자식 컴포넌트를 받고 조건을 통해 return 값을 바꿔주는 역할을 담당하는 컴포넌트이다.

예를 들어 특정 버튼이 권한에 따라 제어되어야 한다면 아래와 같이 구현할 수 있다.

import ...

export const CreateHistoricalSnapshotButton = ({
dashboardItem,
openModal,
closeModal,
}: { dashboardItem: Dashboard } & ModalHandlerProps) => {
const handleCreateClick = () => {
openModal(
<CreateSnapshotForm
accountId={dashboardItem.accountId}
onClose={closeModal}
/>,
);
};

return (
<PrivilegeGate
render="hide"
required={PRIVILEGES.CREATE_HISTORICAL_ACCOUNT_SNAPSHOT}
>
<Button onClick={handleCreateClick} variant="auth">
Create Snapshot Records
</Button>
</PrivilegeGate>
);
};

컴포넌트 내부 최상위에 PrivilegeGate 컴포넌트를 통해 제어하는 게 처음에는 좋다고 생각했다. 하지만 곰곰이 생각해 보면 CreateHistoricalSnapshotButton라는 컴포넌트 입장에서는 권한에 대해 알 필요가 없음을 자각했다. 그래서 분리해서 컴포넌트 외부로 옮겼다.

import ...

export const CreateHistoricalSnapshotForm = ({...}) => {

return (
<>
...
<PrivilegeGate
render="hide"
required={PRIVILEGES.CREATE_HISTORICAL_ACCOUNT_SNAPSHOT}
>
<CreateHistoricalSnapshotButton .../>
</PrivilegeGate>
</>
);
};

이런 식으로 UI를 제어하는 컴포넌트는 대상 컴포넌트 상위에서 확인할 수 있도록 하는 게 맞는 것 같다. 보기에는 지저분한데, 컴포넌트 입장에서 생각해 보면 이게 맞는 것 같다.

그런데... 서버에서 권한이 업데이트되면 일일이 프런트엔드 개발자에게 말해줘야 하나?!

이 부분도 고민이었다. 각 권한에 따른 책임 문자열 배열이 있는데 이를 서버에서 CRUD 할 경우, 클라이언트에서 어떻게 동기화할 수 있을지가 고민이었다. 현재는 변경될 때 말해주면 바로 수정할 수 있다만...

스크립트와 CI/CD를 활용해 보면 해결할 수 있다고 생각했다.

import ...

// dotenv 로드
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const API_BASE = "개발_서버_URL";
const LOGIN_EMAIL = "개발_서버_이메일 ";
const LOGIN_PASSWORD = "개발_서버_비밀번호";

const OUTPUT_FILE = path.resolve(
__dirname,
"../src/shared/lib/constants/privileges.ts",
);

// 권한 문자열을 상수 키 형태로 변환
function toConstKey(privilege: string): string {
return privilege.toUpperCase().replace(/[^A-Z0-9]/g, "_");
}


async function loginAndGetToken(): Promise<string> {
const res = await axios.post(
`${API_BASE}/v1/login`,
{
username: LOGIN_EMAIL,
password: LOGIN_PASSWORD,
},
{
headers: {
"Content-Type": "multipart/form-data", // FormData 전송 형식 설정
},
},
);

const token = res.data.access_token;
if (!token) {
throw new Error("❌ 로그인 성공했지만 토큰이 없습니다.");
}
return token;
}

async function fetchPrivileges(token: string): Promise<string[]> {
const res = await axios.get(`${API_BASE}/v1/administrators/me`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return res.data.data.privileges;
}

async function generate() {
try {
const token = await loginAndGetToken();
const privileges = await fetchPrivileges(token);

const entries = privileges
.map((p) => `${toConstKey(p)}: '${p}',`)
.join("\n ");

const content = `// ⚠️ This file is auto-generated for role ADMIN. Do not edit manually.
export const PRIVILEGES = {
${entries}
} as const;


export type Privilege = (typeof PRIVILEGES)[keyof typeof PRIVILEGES];
`;
fs.writeFileSync(OUTPUT_FILE, content, "utf-8");
console.log(
`✅ privileges.ts generated for role ADMIN with ${privileges.length} privileges.`,
);
} catch (err) {
console.error("❌ Failed to generate privileges.ts");
console.error(err);
}
}

generate();

명석님의 클린 코더쓰를 3주간 학습하고 내 코드를 보는데 고칠 것들이 많이 보인다... 그럼에도 불구하고 공유한다. 피드백을 기다립니다!!

우선 로그인을 하고 privilege를 가지고 오고 이를 상수 파일로 generate해주는 역할을 수행하는 스크립트인데 이를 빌드하기 전 시점에 실행시키도록 했다. 찾아보니 prebuild라는 게 있었다.

//package.json
{
...
"scripts": {
"dev": "vite --host 0.0.0.0",
"generate:privileges": "node --loader ts-node/esm scripts/generate-privileges.ts",
"prebuild": "npm run generate:privileges",
"build": "vite build --debug",
"preview": "vite preview --port 8080",
"prettier": "prettier --write .",
"test": "vitest",
"prepare": "husky",
"lint-staged": "lint-staged",
"steiger": "npx steiger ./src --watch"
},
}

이렇게 하면 빌드되기 전 해당 스크립트가 실행되면서 권한을 동기화가 된다. 그럼 Privilege 체크 박스가 만들어진 배열을 map하도록 하면 서버에서 Privilege가 변경되어도 클라이언트에 말해주지 않아도 되지 않을까 싶다.

독자님, 위에 코드는 어떤 문제가 있을 것 같아요? 더 좋은 방법이 있을까요?

2. 불편한건 고쳐야지!

결론부터 정리해 보면 백앤드 API를 수정하였다.

1번에서 봤듯 권한에 따른 책임을 부여하는 기능 구현하는 과정에서 처음엔 API의 end point가 각각 나눠져 있었다.

예를 들어

  • GET / 어드민 목록 : /members/administrators/all
  • GET / 스태프 목록 : /members/staff/all
  • GET / 유저 목록 : /members/users/all

  • GET / 어드민 단일 : /members/administrator
  • GET / 스태프 단인 : /members/staff
  • GET / 유저 단일 : /members/user

이런식으로 나눠져있었다.

이로 인해 권한에 따라 다른 API를 호출해야 하는 분기가 생겨 불필요하게 UI 및 기능을 구현하는데 복잡해져 버렸다.

  • GET / 맴버 목록 : /members/all?role=${role}

  • GET / 맴버 단일 : /members?role=${role}&id=${id}

그래서 아래와 같이 바꾸려고 했다.

아무리 생각해도 어드민에서만 사용하는 기능이고 멤버를 조회하기 때문에 API의 Endpoint가 다를 필요가 없다고 느꼈다. 차라리 분기 처리가 서버에 있는 게 좋을 것 같다고 생각했다. 그래서 아래와 같이 백엔드 서버를 수정했다.

백엔드는 fast API로 구성되어 있고 repository 디자인 패턴이 적용되어 있다. 완벽하지 않지만 공유한다. 보고 이상한 부분이 있으면 댓글로 남겨주신다면 많은 도움이 될 것 같습니다!!

// 어드민 컨트롤러
@router.get(
"/members/all",
response_model=CommonResponse,
tags=[Tag],
responses={...}
)
async def get_all_member_async(
role: Role,
administrator_token_data: AdministratorTokenData = Depends(JWTHandler.is_administrator())) -> JSONResponse:
if role == Role.Administrator:
return await administrator_service.get_all_administrators_async(administrator_token_data=administrator_token_data)

if role == Role.Staff:
return await administrator_service.get_all_staff_async(administrator_token_data=administrator_token_data)

if role == Role.Client:
return await administrator_service.get_all_users_async(administrator_token_data=administrator_token_data)

이런 식으로 구현했다. 받는 role에 따라 다른 서비스를 불러오도록 했다. 이러면 프론트에서의 복잡했던 문제를 단방에 해결할 수 있을 것 같았다! 하지만 이 코드를 수정하고 배포하는 과정이 험난했다...

백엔드 소스 코드 수정 및 반영하는 과정에서 어려웠지만 자동 CI/CD를 구축하여 협업할 수 있는 환경을 구현했다. (3일간 업무 이외에 시간을 모두 투자했다.)

AS-IS

현재 동료 OS는 Window이며 난 Mac OS이다. 백엔드 개발 환경은 Python, Fast API 이며 배포 플랫폼은 AWS Elastic Bean stalk을 활용하고 있고 배포는 수동으로 진행하고 있었다. 3개월 전, git branch 세션을 진행했고 그 결과 브랜치를 나눠서 관리하고 있었지만 수동으로 각 브랜치의 소스 코드를 zip 변환하여 수동으로 배포하고 있었다.

TO-BE 결과적으로 여러 시행 착오 끝에 아래의 yml으로 어느 환경에서든 자동 배포가 되도록 구현하였다.

name: Deploy to the Development, AWS Elastic Beanstalk

on:
pull_request:
types: [closed]
branches:
- develop

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python3 -m pip install pipreqs
pipreqs . --force
pip install -r requirements.txt

- name: Zip the application
run: |
zip -r vision-system.zip .

- name: Deploy to AWS Elastic Beanstalk
uses: einaregilsson/beanstalk-deploy@v22
with:
application_name: Vision-System
environment_name: Vision-System-development-backend-server
version_label: ${{ github.sha }}
region: ap-northeast-2
aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
deployment_package: vision-system.zip
use_existing_version_if_available: true
name: Deploy to the Production, AWS Elastic Beanstalk

on:
push:
branches:
- main
pull_request:
types: [closed]
branches:
- main

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Zip the application
run: |
zip -r vision-system.zip .

- name: Deploy to AWS Elastic Beanstalk
uses: einaregilsson/beanstalk-deploy@v22
with:
application_name: Vision-System
environment_name: Vision-System-production-backend-server
version_label: ${{ github.sha }}
region: ap-northeast-2
aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
deployment_package: vision-system.zip
use_existing_version_if_available: true

우선 develop 브랜치에 merge 됐을 때와 main 브랜치에 merge 됐을 때 트리거 되도록 구현했다. 실은 코드가 다른 게 거의 없다. 이벤트가 발동되는 조건과 AWS EB의 서버 이름정도다. 중복을 없앨 수 있을 것 같은데 우선!! 5월에는 이렇게 했다. 6월에 개선해 보자!

3. 동료의 잠을 어떻게 확보할 수 있을까?

5월 21일, 전체 미팅에서 고객들이 많아져, 고객 응대에 에너지가 너무 많이 들어 육체적으로 버티기가 어렵다는 이야기가 나왔다. 그 이유는 우리의 고객들은 대부분 해외에 있고 손실이 있을 경우 텔레그램으로 문의가 들어오는 구조인데... 그 시간대가 정해져 있지 않은 것은 문제라는 것. 밤낮없이 대응해야 하기에 예민해지고 잠을 설치게 된다고 한다. 스타트업으로써 우리의 경쟁력을 빠른 응대인데 그럼 어떻게 할 것인가?

개발로 도와줄 수 있는 게 있을까?

그래서 나온 이야기가 월간 및 주간 수익과 손실을 알려주고 이유와 정보들을 선제적으로 주는 게 어떤지 회의 시간이 이야기가 나왔다.

그때 난 점점 고객들이 많아지는데 일일이 수동으로 가능할까라는 의문이 들었다. 리포트 안에는 각 고객의 수익률과 관련된 정보들이 많이 있다. 그래서 해당 기능을 제안했다.

동료가 너무 좋아했다. 그때 든 생각은 이런 게 좋은 거 구나 싶었다. 이런 게 재밌다. 도와줄 수 있는 게 있다는 게!!

그럼 이제 어떻게 할 것인가를 고민해봐야 한다. 일을 벌였는데 6월에 재밌을 것 같고 만날 문제가 산적하다. 가보자!

4. OOP에 눈을 뜨다.

4월부터 유지보수하기 좋은 소프트웨어를 만들기 위해 학습하면서 수렴된 곳은 OOP였다. OOP와 관련된 부분은 따로 포스팅을 작성해보려고 한다. 명석님의 강의가 인프런에 나와서 클린 코더쓰라는 강의를 먼저 2 회독을 진행했고 회독을 거듭할수록 깨닫는 게 많아졌다. 정말로 처음부터 60%까지는 지식이 달게 느껴졌고 그 이후는 TDD와 관련된 것이어서 모든 내용을 씹어먹진 못했다. 몇 번 더 보면 더 이해되는 것들이 많은 것 같다.

5. Secure Coding, 커리어 방향성 잡다.

최근 가상 자산 업계에 해킹 사건이 많이 발생했다. 최근 언론에 알려진 두 가지 이외에도 말이다.

  1. Bybit 거래소 해킹 (2025년 2월)

    두바이에 본사를 둔 가상자산 거래소 Bybit에서 약 15억 달러 상당의 이더리움이 탈취되었습니다. 미국 FBI는 이 사건의 배후로 북한의 라자루스 그룹을 지목했습니다.

  2. WazirX 거래소 해킹 (2024년 7월)

    인도 기반의 WazirX 거래소에서 약 2억 3,490만 달러 상당의 가상자산이 탈취되었습니다. 이 사건 역시 라자루스 그룹과 연관되어 있습니다.

그 외도 수많은 해킹이 크고 작게 일어나고 있다. 보고만 있을 수가 없다. 프론트엔드 개발자로서 웹 브라우저에 대해 누구보다 잘 알아야 한다고 느낀다. 그래서 공부를 시작하려고 한다. 어떤 부분에 취약점이 있는지 알아야 한다. 그래서 우선 브라우저에 어떤 취약점이 있는지 알아야 하고 서버 사이드에선 어떤 취약점이 있는지 알아보려고 한다.

이 지식이 당장은 보이는 성과는 없을 수 있으나 웹 브라우저가 얼마나 보안을 고민하고 있는지를 리버스 엔지니어링할 수 있는 기회일 뿐 아니라 브라우저 자체에 대한 이해도도 증가할 것으로 예상된다.

그래서 5월에는 실질적으로 보안과 관련하여 무엇을 개선했는가?

비젼 시스템 프로젝트에 인증/인가 부분을 개선하였다. 리프레시 토큰을 js로 접근하지 못하게 했고. 서버에서 HTTP 헤더를 제어함으로 보안을 강화하였다. 그리고 1번에서 권한 시스템을 도입하였다.

결론

5월 한 달은 유지보수하기 좋은 코드를 위해 OOP, 단위 테스트등을 공부하며 실무에선 보안과 관련한 작업들을 많이 진행했다. 그에 따라 보안에 관심이 많이 가게 됐고 생각보다 어플리케이션, 회사, 사회를 악한 공격에서로부터 보호하는데 흥미가 있을 수도 있다고 생각이 들었다. 먼저는 웹에 대해 전문가가 되어야겠다고 생각하며 5월을 마무리했다. 그래서 6월에는 업무 이외에 프론트엔드 분야에서 일어났던, 그리고 일어날 수 있는 해킹에 대해 기본 자료들보다 훨씬 자세하고 실질적으로 정리해보려고 한다. 긴 글 읽어주셔서 감사합니다. 모두 각자 있는 자리에서 파이팅!

6월 Action Point

개발 관련

  • 월간 리포트 생성 및 저장 기능 구현하기 (동료의 잠을 확보해보자!! 가보자)
  • 퓨랩 디자인 시스템 업무 프로세스 정착
  • 객체 지향의 오해와 진실 읽고 생각 정리
  • 단위 테스트 기술 책 읽고 생각 정리 -> 프론트엔드 가장 만만한 로직부터 단위 테스트 적용하자!
  • Next.js 스터디를 통해 Insight 정리해서 공유하기 (생각없이 스터디 금지)
  • 네트워크 공부를 통해 사고를 확장시켜보자
  • DB 공부하자. postgresql

보안 관련

  • XSS에 대해 학습 및 결과물 정리
  • CSRF에 대해 학습 및 결과물 정리
  • OAuth에 대해 깊이 학습 및 결과물 정리