그런데 이 패턴이 “상태 머신”처럼 들린다면 놀랄 일도 아닙니다. 선택은 실제로 상태 머신을 구축하느냐 마느냐가 아니라 암시적으로 구축하느냐 명시적으로 구축하느냐의 문제입니다.
https://massimilianomirra.com/notes/expressive-components-in-vanilla-react-part-1-type-states
stateDiagram-v2
[*] --> Mounting
Mounting --> AwaitingEmailInput
Mounting --> AwaitingCodeInput
AwaitingEmailInput --> SubmittingEmail
SubmittingEmail --> AwaitingCodeInput
SubmittingEmail --> AwaitingEmailInput
AwaitingCodeInput --> SubmittingCode
SubmittingCode --> Success
SubmittingCode --> AwaitingCodeInput
Success --> [*]
Blob URL 방식은 Firefox 58+에서 CSP 제한이 있는 페이지에서도 스크립트를 주입할 수 있는 방법입니다. 이 방식을 사용할 때, 비동기성 때문에 발생할 수 있는 문제를 방지하려면 초기화 코드가 준비된 후 스크립트를 전달해야 합니다.
const b = new Blob([script], { type: 'text/javascript' })
const u = URL.createObjectURL(b)
const s = document.createElement('script')
s.src = u
document.body.appendChild(s)
document.body.removeChild(s)
URL.revokeObjectURL(u)
vitest에서 사용할 수 있는 사용자 지정 assertion을 추가하기.
import { expect } from 'vitest'
import type { ZodTypeAny } from 'zod'
expect.extend({
/**
* @param received 테스트할 Response 객체
* @param schema 검증할 Zod 스키마
*/
async toMatchSchema(received: Response, schema: ZodTypeAny) {
const response = await received.json()
const result = await schema.safeParseAsync(response)
return {
message: () => '',
pass: result.success,
} satisfies ExpectationResult
},
})
vitest.d.ts
에 기본 인터페이스를 확장하기.
import type { ZodTypeAny } from 'zod'
interface CustomMatchers<R = unknown> {
toMatchSchema(schema: ZodTypeAny): Promise<R>
}
todoResponse
의 응답 데이터가 todoSchema
에 정의된 Zod 스키마와 일치하는지 확인한다.
test('todo', async () => {
expect(todoResponse.ok).toBeTruthy()
expect(todoResponse).toMatchSchema(todoSchema)
})
button
또는 a
요소를 선택적으로 렌더링할 수 있는 ButtonOrLink
컴포넌트를 구현한 부분. props로 전달된 as
값에 따라 해당 태그를 렌더링하며, React.JSX.IntrinsicElements
의 타입을 활용하여 각 태그에 맞는 props를 받을 수 있도록 한다.
import * as React from 'react'
type ComponentPropsWithAs<T extends keyof React.JSX.IntrinsicElements> = {
as: T
} & React.ComponentProps<T>
type ButtonOrLinkProps =
| ComponentPropsWithAs<'a'>
| ComponentPropsWithAs<'button'>
export function ButtonOrLink(props: ButtonOrLinkProps) {
switch (props.as) {
case 'a':
return <a {...props} />
case 'button':
return <button {...props} />
default:
return null
}
}
vitest와 jest에서 가짜 타이머를 사용하는 방법. 내부적으로는 @sinonjs/fake-timers를 사용. vitest와 jest 모두 시스템 시간을 조작할 수 있도록 하여 테스트가 일관되게 실행되도록 하고 다양한 시간과 날짜를 시뮬레이션하여 다양한 조건에서 코드가 예상대로 작동하는지 확인할 수 있음.
describe('isSameDate', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime('2024-07-20')
})
afterEach(() => {
vi.useRealTimers()
})
it('현재 날짜와 동일한 날짜를 전달하면 true를 반환한다', () => {
expect(isSameDate('2024-07-20')).toBe(true)
})
it('현재 날짜와 다른 날짜를 전달하면 false를 반환한다', () => {
expect(isSameDate('2024-07-19')).toBe(false)
expect(isSameDate('2024-07-21')).toBe(false)
})
})
Never Call
new Date()
Inside Your Components never-call-new-date-inside-your-components
function Today({ date = formatDate(new Date()) }) {
return <label>{date}</label>
}
컴포넌트 내에서 new Date()
와 같은 비순수한 함수를 호출하면, 호출할 때마다 결과가 달라집니다. 이는 테스트에서 일관된 결과를 기대할 수 없게 만들어, 테스트가 불안정해질 수 있습니다. 코드 변경 없이도 테스트가 때로는 통과하고, 때로는 실패하는 Flaky Test를 유발할 수 있습니다.
순수하지 않은 함수의 결과나 함수를 prop
으로 전달함으로써 컴포넌트가 더 예측 가능해지고, 테스트하기 쉬워집니다. 이렇게 하면 테스트 시 특정 날짜를 제어할 수 있어, 일관적이고 신뢰할 수 있는 테스트 결과를 얻을 수 있습니다.
기본 매개변수를 사용하면 개발자 경험이 향상됩니다. 기본 동작(new Date()
로 오늘 날짜를 얻는 것)을 유지하면서도, 테스트 중 특정 값을 제공할 수 있는 유연성을 유지할 수 있습니다.
대화 상자(Dialog Box)는 사용자에게 정보를 전달하고 응답을 요청하는 그래픽 제어 요소이다. Dialog box
Modal
대화 상자를 연 소프트웨어와의 상호작용을 차단합니다.
- System Modal - 이 대화 상자를 닫기 전까지 다른 작업을 할 수 없게 하며, 과거 단일 작업 시스템에서 주로 사용되었습니다.
- Application Modal - 프로그램을 일시적으로 중단시키며, 대화 상자가 닫히기 전까지 다른 작업을 할 수 없습니다. 이는 워크플로우를 방해하거나 사용자 오류를 초래할 수 있어 종종 비판받습니다.
- Document Modal - 부모 창만 차단하며, 다른 창에서 작업을 계속할 수 있습니다. macOS에서 주로 사용되며, 부모 창에 연결된 시트 형태로 나타납니다.
Modeless
소프트웨어의 다른 부분과의 상호작용을 허용합니다. 이 대화 상자는 소프트웨어와의 상호작용을 차단하지 않으며, 대화 상자가 열려 있는 동안에도 사용자가 작업을 계속할 수 있습니다. 툴바가 모델리스 대화 상자의 예입니다.
고려 사항
- 모달 대화 상자의 문제점 - 모달 대화 상자는 사용자 흐름을 방해하고, 반복적인 사용으로 인해 사용자가 실수로 잘못된 선택을 하게 만들 수 있습니다. 사용자는 습관적으로 확인을 누르는 경향이 있으며, 이는 작업 손실로 이어질 수 있습니다.
- 경고 대신 실행 취소 - 경고 메시지로 실수를 방지하려는 접근은 한계가 있습니다. 경고를 더 강하게 만들어도 사용자는 이를 빠르게 무시하고 실수를 반복할 수 있습니다. 대신, 실행 취소(Undo) 기능을 제공하여 사용자가 언제든지 실수를 되돌릴 수 있도록 하는 것이 중요합니다. 이는 사용자의 스트레스를 줄이고 더 나은 사용자 경험을 제공합니다.
- 인간 중심의 디자인 - 소프트웨어 디자인은 사용자의 습관을 존중해야 합니다. 사용자가 실수를 하더라도 복구할 수 있는 실행 취소 기능을 제공하는 것이 인간 중심의 디자인입니다. 경고를 사용하는 대신, 실행 취소를 제공하라는 원칙이 바람직한 디자인 방향입니다.1
Footnotes
Excel View1 라이브러리를 보던 중, Excel과 Node의 상호작용에 대한 궁금증이 생겼다. 아무래도 웹개발을 하다보니 어플리케이션과 통신할 수 있는 부분에 대해서 전혀 생각을 안했었다는 걸 깨닫고 이러한 부분을 보완하기 위해 Excel과의 통신 방식을 찾아봄.
우선, 위 라이브러리 코드를 통해 ActiveXObject('Excel.Application')
2로 Excel 객체를 생성하고, 이를 통해 Excel의 다양한 기능에 접근할 수 있다는 것을 확인했다. 그리고 node-activex의 문서에서 링크를 통해 추가적인 정보들을 확인할 수 있었는데 아무래도 자주 보던 영역이 아니라 일단 확인만 하는 단계에서 멈춤.
이 접근을 통해, Excel과 상호작용하는 방법에 대해 실마리를 찾을 수 있었음. 그러나 모든 과정이 순조롭지만은 않은게. ActiveXObject
를 사용하는 부분에서 개념적 이해가 부족했고, 그밖에 Excel의 객체모델에 대해서도 배경지식이 많이 부족하다는 걸 알게됨.
이번 경험을 통해, 웹 개발자가 어플리케이션 레벨의 개념들도 이해하면 좋겠다는 생각을 하게 됨. 나중에 기회가 되면 살펴보고 일단 view 기능을 스프레드시트로 구현해 봐야겠다.
Footnotes
-
Excel에서 긴 줄을 탐색하는 데 도움이 되는 도구입니다. 활성 셀을 추적하고 전체 행 데이터를 자동으로 가져와 별도의 창에 표시합니다. 이를 통해 좌우로 스크롤하지 않고도 행의 모든 열을 쉽게 볼 수 있습니다. ↩
-
https://github.com/timepp/excelview/blob/master/excel.js#L70 ↩
Avoiding premature abstraction with Unstyled React Components (buildui.com)
React 컴포넌트를 작성할 때, 불필요한 추상화를 피하고 컴포넌트의 유연성을 유지하는 방법. 특히 스타일이 없는 컴포넌트를 통해 어떻게 컴포넌트의 기능에 집중할 수 있는지를 설명.
import React, { ComponentProps, FC, ReactNode } from 'react'
function Spinner() {
return (
<span className="absolute inset-0 flex items-center justify-center">
`<Spinner />`
</span>
)
}
type LabelProps = ComponentProps<'span'>
function Label({ children, ...rest }: LabelProps) {
return <span {...rest}>{children}</span>
}
type LoadingButtonProps = ComponentProps<'button'> & {
children: FC | ReactNode
}
/**
* @description 버튼이 비활성화되었을 때 로딩 스피너를 표시할 수 있는 버튼 컴포넌트입니다. 이 컴포넌트는 children 속성으로 함수나 React 노드를 받을 수 있으며, 버튼이 비활성화되면 스피너가 표시되고, 텍스트는 보이지 않게 됩니다.
*/
function LoadingButton({
disabled,
className,
children,
...rest
}: LoadingButtonProps) {
return (
<button {...rest} className={`${className} relative`} disabled={disabled}>
{typeof children === 'function' ? (
children({})
) : (
<>
{disabled && <Spinner />}
<Label className={disabled ? 'invisible' : ''}>{children}</Label>
</>
)}
</button>
)
}
LoadingButton.Spinner = Spinner
LoadingButton.Label = Label
이 패턴은 컴포넌트를 작성할 때 불필요한 스타일링이나 구조를 미리 정의하지 않고, 각 컴포넌트가 자신의 역할에 충실할 수 있도록 도와줍니다. 이를 통해 코드의 유연성을 유지하고, 필요에 따라 컴포넌트를 확장하거나 수정할 수 있는 여지를 남겨두게 됩니다.
React 애플리케이션에서 라우팅은 중요한 부분. 특히, 사용자가 링크를 클릭할 때 적절한 페이지로 이동하는지 테스트하는 것은 중요. 다음은 React Router의 <Link />
컴포넌트를 테스트하는 코드.
describe('Link', () => {
it('이동', () => {
const Home = () => <Link to="/about">About</Link>
const About = () => <h1>About</h1>
const { getByRole } = render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</MemoryRouter>
)
fireEvent.click(getByRole('link'))
expect(screen.getByRole('heading')).toHaveTextContent('About')
})
})
가끔 <Link />
도 테스트해야 할 때가 있다. 위 코드는 사용자가 “About” 링크를 클릭하면 “About” 페이지로 이동하는지 테스트. fireEvent.click을 사용해 링크 클릭 이벤트를 발생시키고, screen.getByRole을 통해 헤딩 요소의 텍스트가 ‘About’인지 확인. 이 테스트를 통해 라우팅이 제대로 동작하는지 확인할 수 있음.