1. ESLint와 Prettier 사용하기

import/order 까지 설정해두면 복잡한 import 구문의 가독성을 조금이나마 개선할 수 있다.

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
31
32
33
34
// .eslintrc.json
{
...
"rules": {
"import/order": [
2,
{
"newlines-between": "always",
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"unknown",
"object",
"type"
],
"alphabetize": {
"order": "asc",
"caseInsensitive": true
},
"pathGroups": [
{
"pattern": "react*",
"group": "external",
"position": "before"
}
]
}
]
}
}

2. 네이밍 컨벤션

컴포넌트, interface, type에는 PascalCase를 써라

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// React component
const BannersEditForm = () => {
...
}

// Typescript interface
interface TodoItem {
id: number;
name: string;
value: string;
}

// Typescript type alias
type TodoList = TodoItem[];

JavaScript 데이터(변수, 배열, 객체, 함수 등)은 camelCase를 써라

1
2
3
const getLastDigit = () => { ... }

const userTypes = [ ... ]

또한, 폴더와 컴포넌트가 아닌 파일 이름은 camelCase를 사용하고 컴포넌트 파일에는 PascalCase를 써라

1
2
3
src/utils/form.ts
src/hooks/useForm.ts
src/components/banners/edit/Form.tsx

3. TypeScript 통(barrels)을 사용해라

barrels는 여러 export를 하나의 파일에서 다루는 방법이다.

1
2
3
4
5
6
// barrel file example
export * from "./DropDown";
export * from "./TextBox";
export * from "./CheckBox";
export * from "./DateTimePicker";
export * from "./Slider";
1
2
3
4
5
6
7
import {
DropDown,
TextBox,
CheckBox,
DateTimePicker,
Slider,
} from "./src/controls";
  • 이렇게 하면 import를 여러 파일에서 하지 않고 하나의 파일에서 할 수 있어 간편하다.
  • 이렇듯 타입스크립트도 barrels를 사용하여 관리하면 클린 코드에 좋을 것이다.

4. 기본 내보내기 (default export)를 피하라

기본 내보내기는 내보낼 항목과 어떤 이름도 연결하지 않는다.

즉, 개발자가 내보내려는 이름대로 가져오는 것이 클린 코드에 보다 적합하다.

1
2
3
4
5
6
7
// ❌
export default MyComponent;

// ✅
export { MyComponent };
export const MyComponent = ...;
export type MyComponentType = ...;

5. 컴포넌트 구조 통일하기

모든 컴포넌트의 구조를 다음과 같이 통일해라

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// 1. Imports - Prefer destructuring imports to minimize writen code
import React, { PropsWithChildren, useState, useEffect } from "react";

// 2. Types
type ComponentProps = {
someProperty: string;
};

// 3. Styles - with @mui use styled API or sx prop of the component
const Wrapper = styled("div")(({ theme }) => ({
color: theme.palette.white,
}));

// 4. Additional variables
const SOME_CONSTANT = "something";

// 5. Component
function Component({ someProperty }: PropsWithChildren<ComponentProps>) {
// 5.1 Definitions
const [state, setState] = useState(true);
const { something } = useSomething();

// 5.2 Functions
function handleToggleState() {
setState(!state);
}

// 5.3 Effects
// ❌
React.useEffect(() => {
// ...
}, []);

// ✅
useEffect(() => {
// ...
}, []);

// 5.5 Additional destructures
const { property } = something;

return (
<div>
{/* Separate elements if not closed on the same line to make the code clearer */}
{/* ❌ */}
<div>
<div>
<p>Lorem ipsum</p>
<p>Pellentesque arcu</p>
</div>
<p>Lorem ipsum</p>
<p>Pellentesque arcu</p>
</div>
<div>
<p>
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Pellentesque
arcu. Et harum quidem rerum facilis est et expedita distinctio.
</p>
<p>Pellentesque arcu</p>
<p>
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Pellentesque
arcu. Et harum quidem rerum facilis est et expedita distinctio.
</p>
</div>

{/* ✅ */}
<Wrapper>
<div>
<p>Lorem ipsum</p>
<p>Pellentesque arcu</p>
</div>

<p>Lorem ipsum</p>
<p>Pellentesque arcu</p>
</Wrapper>

<div>
<div>
<p>
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
Pellentesque arcu. Et harum quidem rerum facilis est et expedita
distinctio.
</p>

<p>Pellentesque arcu</p>

<p>
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
Pellentesque arcu. Et harum quidem rerum facilis est et expedita
distinctio.
</p>
</div>
</div>
</div>
);
}

// 6. Exports
export { Component };
export type { ComponentProps };

6. PropsWithChildren 을 사용해라

1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { PropsWithChildren } from "react";

type ComponentProps = {
someProperty: string,
};

// ✅
function Component({
someProperty,
children,
}: PropsWithChildren<ComponentProps>) {
// ...
}
  • props로 children을 내려주고 children 타입을 설정해주는 작업이 반복적으로 발생할 때 번거로움을 해소하고자 PropsWithChildren을 사용할 수 있다.

PropsWithChildren의 children 타입은 optional 하다. 그러므로 꼭 children이 들어가야하는 컴포넌트에서 보다 엄격하게 타입을 지정해주기 위해서는 children:ReactNode로 타입을 지정해주는 방법이 있다.

7. JSX에서 함수가 한줄 이상이라면 분리하라

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌
<button
onClick={() => {
setState(!state);
resetForm();
reloadData();
}}
/>

// ✅
<button onClick={() => setState(!state)} />

// ✅
const handleButtonClick = () => {
setState(!state);
resetForm();
reloadData();
}

<button onClick={handleButtonClick} />

8. Key props로 index를 사용을 피해라

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
// ❌
const List = () => {
const list = ["item1", "item2", "item3"];

return (
<ul>
{list.map((value, index) => {
return <li key={index}>{value}</li>;
})}
</ul>
);
};

// ✅
const List = () => {
const list = [
{ id: "111", value: "item1" },
{ id: "222", value: "item2" },
{ id: "333", value: "item3" },
];

return (
<ul>
{list.map((item) => {
return <li key={item.id}>{item.value}</li>;
})}
</ul>
);
};

공시군서에 따르면 배열 내에서만 고유한 값을 전달해주면 된다고 나와있으니 map 고차함수를 사용하여 index를 key prop로 전달해도 될 것 같다.

하지만 이렇게 할 경우, React에서는 props가 변경되면 컴포넌트를 재렌더링하는데, 배열에서 리스트를 추가, 삭제하면 index가 변경되므로 변경되지 않은 다른 리스트들도 불필요한 재렌더링이 발생하게 된다.

그러므로 key prop에는 item의 id, react uid 라이브러리로 고유한 key를 지정해줘야한다.

9. fragments를 써라

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌
const ActionButtons = ({ text1, text2 }) => {
return (
<div>
<button>{text1}</button>
<button>{text2}</button>
</div>
);
};

// ✅
const Button = ({ text1, text2 }) => {
return (
<>
<button>{text1}</button>
<button>{text2}</button>
</>
);
};

불필요한 div 태그 대신 Fragment를 사용하자

10. 구조분해할당 사용하라

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌
const Button = (props) => {
return <button>{props.text}</button>;
};

// ✅
const Button = (props) => {
const { text } = props;

return <button>{text}</button>;
};

// ✅
const Button = ({ text }) => {
return <button>{text}</button>;
};

11. 관심사를 분리해라

presentation 컴포넌트에서 business 로직을 분리하는 것은 컴포넌트 코드의 가독성을 높힐 수 있다.

대부분의 page, screen, container 컴포넌트에 다수의 hook과 useEffect를 사용하려고 할 때 business 로직을 분리하는 것을 시도할 수 있다.

custom hook

관심사(책임)을 분리하기 위해서 useEffect나 다수의 useState를 컴포넌트에 직접 넣는 대신 Custom hook을 사용해라

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// ❌
const ScreenDimensions = () => {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});

useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}

window.addEventListener("resize", handleResize);
handleResize();

return () => window.removeEventListener("resize", handleResize);
}, []);

return (
<>
<p>Current screen width: {windowSize.width}</p>
<p>Current screen height: {windowSize.height}</p>
</>
);
};

// ✅
const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});

useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}

window.addEventListener("resize", handleResize);
handleResize();

return () => window.removeEventListener("resize", handleResize);
}, []);

return windowSize;
};

const ScreenDimensions = () => {
const windowSize = useWindowSize();

return (
<>
<p>Current screen width: {windowSize.width}</p>
<p>Current screen height: {windowSize.height}</p>
</>
);
};

12. 거대 컴포넌트를 피해라

거대 컴포넌트가 가능하더라도, 컴포넌트를 작은 단위로 분리해라.

주로 조건부 렌더링을 할 때 사용할 수 있다.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// ❌
const SomeSection = ({ isEditable, value }) => {
if (isEditable) {
return (
<Section>
<Title>Edit this content</Title>
<Content>{value}</Content>
<Button>Clear content</Button>
</Section>
);
}

return (
<Section>
<Title>Read this content</Title>
<Content>{value}</Content>
</Section>
);
};

// ✅
const EditableSection = ({ value }) => {
return (
<Section>
<Title>Edit this content</Title>
<Content>{value}</Content>
<Button>Clear content</Button>
</Section>
);
};

const DetailSection = ({ value }) => {
return (
<Section>
<Title>Read this content</Title>
<Content>{value}</Content>
</Section>
);
};

const SomeSection = ({ isEditable, value }) => {
return isEditable ? (
<EditableSection value={value} />
) : (
<DetailSection value={value} />
);
};

13. 가능하다면 state를 그룹화해라

1
2
3
4
5
6
// ❌
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");

// ✅
const [user, setUser] = useState({});

14. boolean shorthand를 사용해라

1
2
3
4
5
// ❌
<Form hasPadding={true} withError={true} />

// ✅
<Form hasPadding withError />

15. curly braces를 피해라

1
2
3
4
5
// ❌
<Title variant={"h1"} value={"Home page"} />

// ✅
<Title variant="h1" value="Home page" />

16. inline 스타일을 피해라

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
// ❌
const Title = (props) => {
return (
<h1 style={{ fontWeight: 600, fontSize: "24px" }} {...props}>
{children}
</h1>
);
};

// ✅
const useStyles = (props) => {
return useMemo(
() => ({
header: { fontWeight: props.isBold ? 700 : 400, fontSize: "24px" },
}),
[props]
);
};

const Title = (props) => {
const styles = useStyles(props);

return (
<h1 style={styles.header} {...props}>
{children}
</h1>
);
};

17. 조건부 렌더링은 삼항 연산자 사용해라

1
2
3
4
5
6
7
8
9
10
11
const { role } = user;

// ❌
if (role === ADMIN) {
return <AdminUser />;
} else {
return <NormalUser />;
}

// ✅
return role === ADMIN ? <AdminUser /> : <NormalUser />;

18. 타입 별칭을 사용해라

1
2
3
4
5
6
7
8
9
10
11
export type TodoId = number;
export type UserId = number;

export interface Todo {
id: TodoId;
name: string;
completed: boolean;
userId: UserId;
}

export type TodoList = Todo[];

19. 써드 파티 라이브러리를 직접 사용하는 것을 피해라

1
2
// src/lib/store.ts
export { useDispatch, useSelector } from "react-redux";
1
2
// src/lib/query.ts
export { useQuery, useMutation, useQueryClient } from "react-query";

20. 직접 구현 대신 추상화에 의존해라

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
31
32
33
34
35
36
37
38
// ❌ directly using momemt
import moment from "moment";

const updateProduct = (product) => {
const payload = {
...product,
// ❌ we are bound to the moment interface implementation
updatedAt: moment().toDate(),
};

return await fetch(`/product/${product.id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
};

// ✅ creating the abstraction, a.k.a. helper function which wraps the functionality

// utils/createDate.ts
import moment from "moment";

export const createDate = (): Date => moment().toDate();

// updateProduct.ts
import { createDate } from "./utils/createDate";

const updateProduct = (product) => {
const payload = {
...product,
// ✅ using the abstracted helper function
updatedAt: createDate(),
};

return await fetch(`/product/${product.id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
};
  • moment를 사용하여 직접 구현하기 보다 createDate 함수사용하여 추상화하면 간결하다.

21. 선언적 프로그래밍을 해라

1
2
3
4
5
6
7
8
9
10
11
// ❌ imperative: dealing with internals of array iteration
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
let sum = 0;

for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}

// ✅ declarative: we don't deal with internals of iteration
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const sum = arr.reduce((acc, v) => acc + v, 0);

22. 변수 이름을 이쁘게 지어라

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ Avoid single letter names
const n = "Max";
// ✅
const name = "Max";

// ❌ Avoid abbreviations
const sof = "Sunday";
// ✅
const startOfWeek = "Sunday";

// ❌ Avoid meaningless names
const foo = false;
// ✅
const appInit = false;

23. 함수 인자를 3개 이상 넘기지 말아라

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌
function createPerson(firstName, lastName, height, weight, gender) {
// ...
}

// ✅
function createPerson({ firstName, lastName, height, weight, gender }) {
// ...
}

// ✅
function createPerson(person) {
const { firstName, lastName, height, weight, gender } = person;
// ...
}

24. template literal 사용해라

1
2
3
4
5
// ❌
const userName = user.firstName + " " + user.lastName;

// ✅
const userDetails = `${user.firstName} ${user.lastName}`;

25. 간단한 함수에서 암묵적 return 사용해라

1
2
3
4
5
6
7
// ❌
const add = (a, b) => {
return a + b;
};

// ✅
const add = (a, b) => a + b;

참고

React Code Conventions and best practices - Medium

댓글 공유

:not 셀렉터

1
2
3
4
5
<ul class="grid">
<li class="grid__child"></li>
<li class="grid__child"></li>
<li class="grid__child"></li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, 15rem);
grid-gap: 1rem;
}

.grid__child {
background: rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
aspect-ratio: 1/1;
}
  • 이렇게 생긴 그리드 아이템에 마우스 올린 요소만 hover 효과를 주고 나머지 요소는 공통적으로 다른 효과를 주고 싶을 때, :not, :hover 셀렉터가 유용하다.
1
2
3
.grid:hover .grid__child:not(:hover) {
opacity: 0.3;
}

  • 이렇게 마우스가 올라간 요소만 제외하고 opacity가 변경되는 것을 볼 수 있다.

하지만 한가지 문제점은 grid의 gap이 있을 경우 item에 마우스가 올라갔을 때 뿐만 아니라 gap에 마우스가 올라갔을 때에도 해당 css가 적용된다.

이를 해결하기 위해서 부모 요소에는 pointer-events: none을 주고 자식 요소에는 pointer-events: auto를 줘서 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, 15rem);
grid-gap: 2rem;
pointer-events: none;
}
.grid__child {
background: rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
aspect-ratio: 1/0.5;
pointer-events: auto;
transition: opacity 0.3s;
}

.grid:hover .grid__child:not(:hover) {
opacity: 0.3;
}

TroubleShooting

pointer-events: none 속성은 hover이벤트 뿐만 아니라 다른 모든 이벤트도 무시한다. 그래서 scroll이 되어야하는 경우에 스크롤이 되지 않는 문제가 발생할 수 있다.

이를 해결하기 위해서는 해당 부모요소를 감싸는 container 박스를 생성하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
<div class="container">
<ul class="grid">
<li class="grid__child"></li>
<li class="grid__child"></li>
<li class="grid__child"></li>
<li class="grid__child"></li>
<li class="grid__child"></li>
<li class="grid__child"></li>
<li class="grid__child"></li>
</ul>
</div>
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
.container {
width: 400px;
height: 300px;
overflow: auto;
}

.grid {
display: grid;
grid-template-columns: repeat(auto-fit, 15rem);
grid-gap: 2rem;
pointer-events: none;
}
.grid__child {
background: rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
aspect-ratio: 1/0.5; // 가로 세로 비율
pointer-events: auto;
transition: opacity 0.3s;
}

.grid:hover .grid__child:not(:hover) {
opacity: 0.3;
}

li {
list-style-type: none;
}
  • grid 요소를 감싸는 container 요소에 scroll을 가능하게 overflow:auto 속성을 주면 스크롤도 작동하고 gap 부분에서 hover 이벤트도 방지할 수 있다.

댓글 공유

Omit<T,K> 타입

1
2
3
4
5
6
7
8
9
10
11
interfact Todo {
title:string;
description:string;
completed:boolean;
}

type TodoPreview = MyOmit<Todo, 'description' | 'title'>

const todo: TodoPreview {
completed:false
}
  • TypeScript Omit타입은 해당 객체 타입에서 Key 타입을 제외할 때 사용한다.

댓글 공유

리액트 무한 스크롤 라이브러리 사용해보기

react-infinite-scroller

위 라이브러리를 사용하여 리액트에서 무한 스크롤을 사용해보자.

1
2
3
4
npm i react-infinite-scroller

# typeScript를 사용한다면 추가
npm i --save-dev @types/react-infinite-scroller
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { useEffect, useState } from "react";
import InfiniteScroll from "react-infinite-scroller";

function App() {
const [data, setData] = useState<{ label: string }[]>([]);
// const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

function handleLoadMore() {
console.log("more load!");
}

// if (loading) {
// return <div>Loading ...</div>;
// }

if (error) {
return <div>Error: {error}</div>;
}

useEffect(() => {
fetch(Markdown)
.then((res) => res.text())
.then((data) => setMarkdown(data));
}, [markdown]);

useEffect(() => {
// setLoading(true);
try {
setData(customAssetList);
// setLoading(false);
} catch (error) {
setError(error);
// setLoading(false);
}
});

return (
<div className="App">
<InfiniteScroll
// 사용자에 의해 많은 아이템이 요청될 실행하는 콜백함수
loadMore={handleLoadMore}
// 이벤트 리스너를 component의 부모요소에 추가
useWindow={false}
// 해당 아이템을 감싸는 태그
element="article"
className="index"
// loading 중일 보여줄 요소
loader={
<div className="loader" key={0}>
Loading ...
</div>
}
>
{data.map((da, index) => (
<div key={index}>
<h3>종목: {index + 1}</h3>
<p>{da.label}</p>
</div>
))}
</InfiniteScroll>
</div>
);
}
  • data를 state로 관리해주고 useEffect 훅을 사용하여 처음 화면이 렌더링 될 때, loading 상태를 변경시키고 data를 업데이트한다.
  • InfiniteScroll 컴포넌트는 내부적으로 loading 상태일 때를 관리하고 있어 개발자가 따로 loading state를 가질 필요가 없다.

검색 기능 활성화 시키기

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import debounce from "lodash/debounce";

function App() {
const [data, setData] = useState<{ label: string }[]>([]);
const [searchValue, setSearchValue] = useState("");
const [filteredData, setFilteredData] = useState<{ label: string }[]>([]);

const handleSearchValue = debounce((value) => {
setSearchValue(value);
const filtered = data.filter((item) => item.label.includes(value));
setFilteredData(filtered);
}, 300);

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.currentTarget.value;
handleSearchValue(value);
};

return (
<div>
<InfiniteScroll
loadMore={handleLoadMore}
useWindow={false}
element="article"
className="index"
loader={
<div className="loader" key={3}>
Loading ...
</div>
}
>
{searchValue !== ""
? filteredData.map((item, index) => (
<div key={index}>
<h3>종목: {index + 1}</h3>
<p>{item.label}</p>
</div>
))
: data.map((da, index) => (
<div key={index}>
<h3>종목: {index + 1}</h3>
<p>{da.label}</p>
</div>
))}
</InfiniteScroll>
</div>
);
}
  • handleChange를 onChange 이벤트 핸들러로 등록하여 change 이벤트가 발생할 때 마다 검색값으로 필터된 데이터를 보여준다.
  • onChange 이벤트가 너무 많이 발생하면 1만개의 데이터가 자주 렌더링 되므로 성능에 문제가 있을 수 있어 debounce를 사용하여 이벤트가 끝난 후 0.3초 후에 이벤트를 캐치하도록 구현

댓글 공유

여러 input 값 상태 관리하기

이번 프로젝트를 하다가 한 페이지에 3가지 케이스의 멀티 input을 가지는 화면이 렌더링되는 부분이 있었다.

가령 코드 구조는 다음과 같다.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
// Backtest.tsx
import { ChangeEvent, useState } from "react";
import { useRecoilState } from "recoil";
import { backtestState } from "../atom";
import BacktestInputs from "../components/BacktestInputs/BacktestInputs";

function Backtest() {
const [backtest, setBacktest] = useRecoilState(backtestState);

const [backtestInputs, setBacktestInputs] = useState({
invest: "",
port: "",
dol: "",
});
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value, name } = e.target;
setBacktestInputs({ ...backtestInputs, [name]: value });
};

const onSave = () => {
setBacktest(backtestInputs);
};

const onReset = () => {
setBacktestInputs({ invest: "", port: "", dol: "" });
};
return (
<>
<button onClick={onSave}>저장</button>
<BacktestInputs value={backtestInputs} onChange={onChange} />
<button onClick={onReset}>input 값 리셋</button>

<h2>전역상태</h2>
<ul>
<li>invest: {backtest["invest"]}</li>
<li>port: {backtest["port"]}</li>
<li>dol: {backtest["dol"]}</li>
</ul>
</>
);
}

export default Backtest;
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// BacktestInputs.tsx
import styled from "@emotion/styled";
import { ChangeEvent } from "react";

interface Props {
value: { invest: string, port: string, dol: string };
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}

function BacktestInputs({ value, onChange }: Props) {
const { invest, port, dol } = value;

return (
<>
<h1>Backtest</h1>
<InputWrap>
<label htmlFor="invest">Investment: </label>
<input
type="text"
name="invest"
value={invest}
onChange={onChange}
id="invest"
/>
</InputWrap>
<InputWrap>
<label htmlFor="port">Port: </label>
<input
type="text"
name="port"
value={port}
onChange={onChange}
id="port"
/>
</InputWrap>
<InputWrap>
<label htmlFor="dol">Dual: </label>
<input
type="text"
name="dol"
value={dol}
onChange={onChange}
id="dol"
/>
</InputWrap>
</>
);
}

export default BacktestInputs;

const InputWrap = styled.div`
height: 50px;
`;

나는 고민을 하였다. Backtest 페이지에서 Input을 감싸고 있는 컴포넌트가 3종류가 있고, 3종류의 컴포넌트 내부에는 다수의 input 값을 가지고 있을 때, 어떻게 input을 관리하면 좋을지 고민했다.

그렇게 하다가 생각해낸 방법이 각 컴포넌트의 input 값의 name을 가지는 객체로 input들의 상태를 관리하기로 했다.

전역상태와 input 상태

나는 전역상태를 직접 onChange 이벤트가 일어날 때 마다 바꿔주는 코드를 작성했었다. 전역상태는 최소한으로 조작을 해야지만 에러가 덜 발생하기 때문에 최종적으로 저장 버튼을 클릭했을 때만 전역상태에 저장하기로 했다.

그래서 input 상태를 객체로 가지고 저장 버튼을 클릭했을 때, 전역 상태 update 함수에게 input 상태를 파라미터로 전달하도록 구현하였다.

소감

처음에는 그 많은 input을 직접 전달해줘야하나 … 걱정을 했었다. 하지만 객체로 input을 관리하고, input 마다 name 속성을 사용하여 onChange 이벤트로 controlled input을 다룰 수 있다는 것을 알게되어서 앞으로 다중 input 상태관리는 이러한 방법으로 접근하면 된다는 것을 알게되었다.

댓글 공유

React로 카카오 로그인 연동하기

이번 프로젝트에서 카카오 로그인 기능을 구현하였다. 배우는 곳은 무조건 공식문서가 짱이지 !

하고 공식문서에 들어가봤다.

인증과 인가

인증과인가

오… OAuth 2.0 기반으로 인증과 인가를 간편, 안전하게 처리할 수 있다는 장점이 있군?!

토큰

토큰이라는 사용자의 카카오 로그인 인증 및 인가 정보를 담은 권한 증명을 제공한다. Access Token, Refresh Token 두 종류를 발급해준다.

token

로그인 과정 이해하기

로그인 과정

1단계. 카카오 로그인

  1. 클라이언트에서 백엔드서버로 로그인 요청을 보낸다.

  2. 백엔드 서버에서 인가 코드를 카카오에게 요청한다.

  3. 카카오는 인증과 동의 요청을 클라이언트에게 보낸다.

  4. 클라이언트가 동의 항목 체크하고 로그인한다.

  5. 앱에 등록된 Redirect URI로부터 인가 코드를 받고 이를 가지고 토큰을 요청한다.

  6. 카카오가 토큰을 발급해준다.

  7. 카카오 로그인이 완료된다 !

2단계. 회원 확인 및 가입

  1. 클라이언트가 발급받은 토큰으로 카카오 API 서버로 요청을 보내 사용자 정보를 가져온다.

  2. 카카오 API 서버는 요청의 토큰을 검증하여 처리한다.

  3. 백엔드 서버는 제공받은 사용자 정보로 서비스 회원인지 확인하고 신규 사용자인 경우 회원가입 시킨다.

3단계. 서비스 로그인

  1. 백엔드에서 세션을 발급해주고 클라이언트에서 로그인 완료 처리해준다.

필수 설정 항목

1. 플랫폼 등록

나의 서비스를 연결해줄 플랫폼을 등록한다.

플랫폼 등록에 가보면 Android, ios, Web을 선택할 수 있고 생성하면 API Key를 발급해준다.

  • 사이트 도메인을 설정해줘야한다.
  • http://, https://, file:// 형식의 도메인을 등록할 수 있으며, http와 https 도메인은 둘 중 한 가지만 등록해도 사용할 수 있습니다.
  • 최대 10개의 도메인을 등록할 수 있습니다.

2. 카카오 로그인 활성화

카카오 로그인에 가서 활성화 버튼을 ON으로 바꿔줘야한다.

3. Redirect URI 등록

인가 코드를 요청받은 URI를 등록해준다.

1
http://localhost:3000/oauth/callback/kakao

해당 Redirect URI로 요청을 보낼 때 필수 파라미터들이 있다.

  • cliend_id : 내 애플리케이션의 REST API 앱 키
  • redirect_uri : Redirect URI
  • response_type : code 고정값

4. 동의 항목

어떤 동의항목을 받을 것인지 설정해준다. 필수만 체크해주자 편의상

5. 구현 방법 선택

  • REST API
  • JavaScript
  • Android
  • IOS
  • Flutter

로 구현할 수 있는데, REST API로 하자.

실습

1. 카카오 로그인 요청을 보낼 버튼을 하나 만들자

1
2
3
// api_key.js
export const REST_API_KEY = `alksdjfoasidnfaofj`;
export const REDIRECT_URI = "http://localhost:3000/auth/callback/kakao";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { REST_API_KEY, REDIRECT_URI } from "./api_key.js";

const App = () => {
const KAKAO_AUTH_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code`;

const handleLogin = () => {
window.location.href = KAKAO_AUTH_URL;
};

return (
<div className="App">
<button onClick={handleLogin}>카카오 로그인</button>
</div>
);
};
1
2
GET /oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code HTTP/1.1
Host: kauth.kakao.com
  • 카카오 로그인 인가 코드받기 부분에 가면 Host와 GET 요청 URI가 나와있다.
  • 이렇게 하여 로그인 버튼을 누르면 URL이 이동하여 카카오 로그인과 회원인증을 처리한다.

2. 카카오 로그인 동안 수행 내용을 컴포넌트에 작성하자

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
function KakaoLogin() {
const location = useLocation();
const navigate = useNavigate();
const KAKAO_CODE = location.search.split("=")[1];

const getKakaoToken = () => {
fetch(`https://kauth.kakao.com/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
body: `grant_type=authorization_code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&code=${AUTHORIZE_CODE}`,
})
.then((res) => res.json())
.then((data) => {
if (data.access_token) {
localStorage.setItem("kakao_token", data.access_token);
} else {
navigate("/");
}
});
};

useEffect(() => {
if (!location.search) return;
getKakaoToken();
}, []);
}
1
2
3
POST /oauth/token HTTP/1.1
Host: kauth.kakao.com
Content-type: application/x-www-form-urlencoded;charset=utf-8
1
2
3
4
5
6
7
<!-- Request -->
curl -v -X POST "https://kauth.kakao.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=${REST_API_KEY}" \
--data-urlencode "redirect_uri=${REDIRECT_URI}" \
-d "code=${AUTHORIZE_CODE}"
  • 토큰을 얻기 위해서는 POST 요청을 위 URL로 요청을 보내라고 알려주고 있다.
  • Request에서 나온대로 fetch 함수에 적어주면 된다.

-d로 나와있는 부분이 body부분에 넣어주면된다. “”로 구분된 것들은 &기호로 구분해준다.

  • 인가 코드를 사용하기 위해서 Redirect URI에서 localhost:3000/auth/callback/kakao?code= 뒷부분을 사용하기 위해 split 코드를 사용하였다.

URLSearchParams를 사용하여도 가능하다.

결과

결과

이렇게 코드를 짜면 localStorage에 토큰까지 저장되며 로그인이 성공한 것을 알 수 있다.

이제는 토큰을 가지고 로그인 했다고 처리를 해주면 된다.

소감

이번에 OAuth2.0 기반의 카카오 로그인을 연동해보았는데 처음 해봐서 신기하기도 하고 토큰을 받고 이후에 어떻게 로그인 유지와 로그아웃 처리를 해줘야하는지도 고민할 수 있는 시간이 있어서 배운 점이 많았다.

앞으로 OAuth 연동에 대해 두려움이 줄어든 것 같다.

댓글 공유

React에서 TypeScript 올바르게 사용하기

Select 컴포넌트를 만든다고 가정해보자.

1
2
3
4
5
6
7
8
9
10
11
12
const Select = ({ label, value, options, onChange }) => {
return (
<label>
{label}
<select value={value} onChange={onChange}>
{options.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};

1. 무지성으로 타입 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const Select = ({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: { label: string; value: string }[];
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
}) => {
return (
<label>
{label}
<select value={value} onChange={onChange}>
{options.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};

2. type, interface로 타입 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Option = { label: string; value: string };

type Options = Option[];

type SelectProps = {
label: string;
value: string;
options: Options;
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
};

const Select = ({ label, value, options, onChange }: SelectProps) => {
return (
<label>
{label}
<select value={value} onChange={onChange}>
{options.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};

3. 타입 파일 분리하기

1
2
3
4
5
6
7
8
9
10
11
// Select.d.ts
type Option = { label: string; value: string };

type Options = Option[];

type SelectProps = {
label: string;
value: string;
options: Options;
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Select.tsx
const Select: React.FC<SelectProps> = ({
label,
value,
options,
onChange,
}): JSX.Element => {
return (
<label>
{label}
<select value={value} onChange={onChange}>
{options.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};
  • 정답은 없지만 팀 내부에서 하나를 결정하고 통일할 필요는 있다.

댓글 공유

Markdown 파일 React에서 불러오기

이번 프로젝트에서 .md 파일로 저장된 데이터를 React 화면에 렌더링해야하는 작업이 필요했다.

그래서 찾아본 결과 마크다운 파일을 리액트에서 불러오기 편하도록 react-markdown 라이브러리를 찾았다.

설치

1
npm install react-markdown

사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import "./App.css";
import { useEffect, useState } from "react";
import { ReactMarkdown } from "react-markdown/lib/react-markdown";
import Markdown from "./assets/markdown.md";

function App() {
const [markdown, setMarkdown] = useState("");

useEffect(() => {
fetch(Markdown)
.then((res) => res.text())
.then((data) => setMarkdown(data));
}, [markdown]);
return (
<div className="App">
<ReactMarkdown children={markdown} />
</div>
);
}

export default App;
  • 마크다운 파일을 import 해온 뒤 useEffect를 사용하여 컴포넌트가 마운트 될 때, 지정된 경로로 마크다운 파일을 로드한다.

결과

마크다운 업로드 사진

  • 마크다운 형식에 맞춰서 HTML형식으로 변환까지 잘 되었다.

TroubleShooting

1. import type 오류

나는 vite를 사용하여 프로젝트를 시작하였다.

이 때, 마크다운 파일을 리액트에서 import 해오면 vite에서는 md 파일을 경로에서 가져온다는 것을 기본적으로 포함하고 있지 않기 때문에 경로에 빨간 밑줄(error)이 생긴다.

이를 해결하기 위해서는 TypeScript에게 md 파일을 경로에서 가져온다는 타입을 선언해줘야한다.

1
2
3
4
5
// d.ts
declare module "*.md" {
const content: string;
export default content;
}

2. vite md파일 형식 오류

markdown 오류

vite 에서는 md 파일을 다룰 적절한 플러그인을 설치하거나 만약 이것이 asset이라면, vite.config.ts안에서 **/*.md를 assetsInclude에 할당하라고 알려주고 있다.

1
2
3
4
5
6
7
8
9
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
assetsInclude: ["**/*.md"],
});
  • 나는 **/*.md를 assetsInclude에 할당하라는 것이 어떤 파일에서 해야하는지 몰라서 tsconfig.json에 넣어서 해결하는데 시간이 걸렸다.

댓글 공유

useEffect 초기 렌더링 스킵하기

종속성 배열에 요소에 의존하여 어떤 로직을 실행하고 싶을 때, useEffect를 사용한다.

그런데 useEffect는 무조건 처음에 렌더링 되기 전에 실행된다.

이번 프로젝트에서 Nickname 페이지에서 Nickname을 수정하고 Mypage로 이동하여 Modal을 띄워야하는 경우가 있었다.

처음에는 Mypage에서 useEffect로 종속성 배열에 user를 넣어서 user가 변경되었음을 감지하고 effect 함수가 실행되기를 바랬지만, 이는 두가지 문제점이 있었다.

문제점1. effect는 처음에 한번 무조건 실행된다.

Nickname이 변경되었을 때만 Modal을 띄워야 하는데, Mypage가 렌더링 될 때마다 모달이 뜨는 문제가 발생하였다.

이를 해결하기 위해서는 effect 함수의 초기 렌더링때는 스킵해줘야한다.

1
2
3
4
5
6
7
8
9
10
11
12
import { useEffect, useRef } from "react";

export function useDidUpdateEffect(fn, inputs) {
const didMountRef = useRef(false);

useEffect(() => {
if (didMountRef.current) {
return fn;
}
didMountRef.current = true;
}, inputs);
}

useRef를 사용하여 초기 렌더링 때 effect 함수가 실행되는 것을 스킵할 수 있다.

문제점2. user 상태를 종속성배열로 넣어도 변화를 감지하지 못한다.

Mypage 안의 useEffect는 결국 Mypage 컴포넌트가 불러와져서 렌더링 되기 이전에 실행되는 코드이다.

그러므로 Mypage가 렌더링 되기 이전에 Nickname 페이지에서 상태를 변경한 것을 감지할 수 없다는 것이다.

이를 망각한 체 useEffect에서 어떻게하면 전역 상태의 변경을 감지할 수 있을지에 대해 오랜 시간 고민하며 시간을 허비했다…

이를 해결하기 위해서는 전역 상태로 nickname 수정이 완료되었다는 상태(NicknameModal)을 갖도록 하여 간단하게 해결할 수 있었다.

댓글 공유

onClick 이벤트 전파

카테고리 Daily

onClick 이벤트 전파

React에서 onClick 이벤트 핸들러를 등록하여 클릭 이벤트가 발생하면 이벤트가 발생한 요소 뿐만 아니라 부모 요소까지 이벤트가 전파된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function App() {
return (
<div className="App" onClick={() => console.log("container clicked.")}>
<div className="parent" onClick={() => console.log("parent clicked.")}>
<button
className="child"
onClick={() => {
console.log("child clicked.");
}}
>
버튼
</button>
</div>
</div>
);
}

export default App;
1
2
3
child clicked
parent clicked
container clicked
  • child를 클릭하면 console 창에 다음과 같이 출력된다.
  • 이벤트가 발생한 요소부터 이벤트 핸들러가 호출되고 부모 방향으로 이벤트가 전파된다.

간혹 부모 요소에서 이벤트가 발생하기를 원하지 않을 경우 이러한 이벤트 전파를 막아야한다.

그 방법으로는 event.stopPropagation()이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function App() {
return (
<div className="App" onClick={() => console.log("container clicked.")}>
<div className="parent" onClick={() => console.log("parent clicked.")}>
<button
className="child"
onClick={(e) => {
e.stopPropagation();
console.log("child clicked.");
}}
>
버튼
</button>
</div>
</div>
);
}

export default App;
1
child clicked.
  • 이벤트 전파가 발생하지 않고 이벤트가 발생한 요소만 이벤트 핸들러가 호출된다.

댓글 공유

  • page 1 of 1

loco9939

author.bio


author.job