구글 스프레드시트에서 중복 값을 찾고 그 값에 별도의 스타일을 적용하는 방법
=COUNTIF(A:A, A:A) > 1
-
셀 범위 선택: 중복 값을 확인하고 스타일을 적용할 셀 범위를 선택합니다. 예를 들어, A 열 전체를 선택하려면 A 열을 클릭합니다.
-
조건부 서식 열기: 상단 메뉴에서 **“서식”**을 클릭한 다음 **“조건부 서식”**을 선택합니다.
-
서식 규칙 설정: 조건부 서식 규칙 창이 열리면, “서식 규칙 추가” 옵션을 클릭합니다.
-
맞춤 수식 사용:
서식 규칙
드롭다운에서 **“맞춤 수식을 사용하여 서식 지정”**을 선택합니다. -
중복 값 조건 수식 입력: 중복을 찾는 수식을 입력합니다. 이 수식은 A 열 전체에서 A1과 같은 값을 가지는 셀의 개수를 세고, 그 개수가 1보다 크면 중복으로 간주합니다.
-
서식 선택: 스타일을 지정하려면 서식 스타일에서 텍스트 색상, 배경 색상 등을 원하는 대로 설정합니다.
-
완료: 설정이 완료되면 완료를 클릭하여 적용합니다.
조건부 렌더링을 한다고 했을때 예전에는 주로 B로 처리했던 것 같은데 디버깅 때문에 고생해서 그런지 생각이 바뀌었다.
function renderA() {
return <Item isPacked={true} name="Space suit" />
}
function renderB() {
return <>{isPacked ? <Item name="Space suit" /> : null}</>
}
조건부 렌더링을 보다 간결하게 표현하기 위해 If
, Then
, 그리고 Else
컴포넌트 개념을 차용하는 방법. 이 컴포넌트는 If
에서 condition
prop을 통해 조건을 받아들이고, 자식으로 Then
과 Else
를 받아 각각의 내용을 렌더링하도록 한다.
type Props = {
/** 렌더링할 조건 */
condition: boolean
/** 자식 컴포넌트 (Then, Else 포함) */
children: React.ReactNode
}
/**
* If 컴포넌트는 조건부 렌더링을 위한 컴포넌트
*/
function If({ condition, children }: Props) {
let thenChild = null
let elseChild = null
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return
if (child.type === Then) thenChild = child
if (child.type === Else) elseChild = child
})
return condition ? thenChild : elseChild
}
/**
* Then, Else 컴포넌트는 조건이 참 또는 거짓일 때 렌더링되는 콘텐츠를 포함
*/
const Then = ({ children }: React.PropsWithChildren) => <>{children}</>
const Else = ({ children }: React.PropsWithChildren) => <>{children}</>
빈 PDF 파일이 필요해서 찾아본 방법들
import { createCanvas } from 'skia-canvas'
import { writeFile } from 'node:fs/promises'
/**
* A4 크기의 빈 PDF 파일을 생성합니다.
*
* @param {string} filename - 생성할 PDF 파일의 이름입니다.
* @returns {Promise<void>}
*/
async function createEmptyPDF(filename) {
// A4 크기 (595x842 포인트)
const width = 595
const height = 842
// PDF 형식의 캔버스를 생성합니다
const canvas = createCanvas(width, height, 'pdf')
const ctx = canvas.getContext('2d')
// 아무 내용도 그리지 않고 현재 상태를 저장합니다
ctx.save()
// 캔버스를 버퍼로 변환하여 PDF로 저장합니다
const buffer = await canvas.toBuffer()
await writeFile(filename, buffer)
console.log(`${filename} 파일이 생성되었습니다!`)
}
createEmptyPDF('empty_skia.pdf')
# Ghostscript를 사용한 빈 PDF 생성
gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=empty.pdf -c "[/PageSize [595 842]] setpagedevice" -f /dev/null
# ImageMagick의 `convert` 명령어로 빈 PDF 생성
convert xc:white -page A4 empty.pdf
# `touch` 명령어와 PDF 헤더 직접 작성
echo -e "%PDF-1.4\n1 0 obj\n<<>>\nendobj\nxref\n0 1\n0000000000 65535 f \ntrailer\n<<>>\nstartxref\n9\n%%EOF" > empty.pdf
fetchAvailability
시간 슬롯 조회
지정된 조건(근무 요일, 시간, 이벤트 충돌 여부)에 따라 예약 가능한 시간 슬롯을 가져옵니다. 결과는 사용자에게 제공할 수 있는 예약 가능한 시간 슬롯 목록과 해당 시간 슬롯의 지속 시간(분 단위)입니다.
flowchart LR
A[시작] --> B[가장 가까운 슬롯 계산]
B --> C[예약 기간 설정]
C --> D[바쁜 일정 조회]
D --> E[가능한 슬롯 필터링]
E --> F[예약 가능한 슬롯 반환]
F --> G[종료]
- 현재 시간을 기준으로 가장 가까운 시간 슬롯을 계산합니다. (슬롯 길이는
TIMESLOT_DURATION
에 따라 설정됩니다.) - 28일(
DAYS_IN_ADVANCE
) 동안의 일정 기간을 설정합니다. Calendar.Freebusy.query
를 사용하여 지정된 캘린더(CALENDAR
)의 바쁜 일정(busy events)을 조회합니다.- 조회된 이벤트를 기반으로 조건에 맞지 않는 시간 슬롯을 제외합니다:
- 지정된 근무 시간(
WORKHOURS.start
,WORKHOURS.end
) 외의 시간. - 근무일(
WORKDAYS
)이 아닌 요일. - 다른 이벤트와 시간이 겹치는 경우.
- 지정된 근무 시간(
- 예약 가능한 시간 슬롯을 ISO 8601 형식의 문자열로 저장하고 반환합니다.
bookTimeslot
시간 슬롯 예약
사용자가 선택한 시간 슬롯에 이벤트를 생성하여 예약합니다. 예약이 성공하면 Google Calendar에 이벤트를 추가하고 사용자에게 확인 메시지를 반환합니다.
flowchart TD
A[시작] --> B[사용자 입력 수신]
B --> C[시간 슬롯 유효성 검증]
C --> D[이벤트 충돌 확인]
D --> E{이벤트가 존재합니까?}
E -- 예 --> F[에러 반환]
E -- 아니오 --> G[이벤트 생성]
G --> H[성공 메시지 반환]
F --> I[종료]
H --> I
- 사용자가 선택한 시간 슬롯(
timeslot
)과 추가 정보(이름, 이메일, 전화번호, 메모)를 인수로 받습니다. - 시간 슬롯의 유효성을 검증하고,
TIMESLOT_DURATION
을 기준으로 종료 시간을 계산합니다. Calendar.Freebusy.query
를 통해 선택한 시간 동안 다른 이벤트가 있는지 확인합니다.- 이벤트가 겹치면 예약이 불가능하다는 에러 메시지를 반환합니다.
- 겹치는 이벤트가 없다면,
CalendarApp.getCalendarById
를 사용하여 Google Calendar에 새로운 이벤트를 생성합니다:- 이벤트 제목: 사용자 이름이 포함된 약속 제목.
- 이벤트 설명: 전화번호와 메모를 포함합니다.
- 초대된 손님: 제공된 이메일로 초대합니다.
- 초대 메일 발송(
sendInvites: true
).
- 예약 성공 여부를 메시지로 반환합니다.
사용 플로우 및 사례
-
사용자가 예약 가능한 시간 조회: 사용자는 시스템에 접속하여 자신의 예약 가능 시간을 확인합니다. 이는 캘린더 기반 예약 시스템에서 Google Calendar와 동기화하여 관리됩니다.
-
사용자가 특정 시간에 예약: 사용자가 특정 시간을 선택하면,
- 선택된 시간과 정보를
bookTimeslot
에 전달하여 캘린더 이벤트가 생성됩니다. - 충돌이 없을 경우, 예약이 성공적으로 완료되며 이벤트 초대 이메일이 사용자의 메일로 전송됩니다. 이 과정은 이메일 알림으로 사용자가 이벤트에 초대받음을 알려줍니다.
- 선택된 시간과 정보를
-
자동화: 시스템은 특정 근무 시간과 일정을 고려하여 예약 가능성을 실시간으로 확인합니다. 사용자는 이를 통해 보다 효율적으로 예약을 진행할 수 있습니다.
타이머를 제어하기 위해 vi.useFakeTimers
와 vi.advanceTimersByTime
을 사용하기.
describe('Countdown 컴포넌트', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test('타이머가 동작하며 시간을 업데이트한다.', () => {
const endTime = new Date(Date.now() + 60000).toISOString()
const renderChild = vi.fn(({ targetRef }) => (
<div ref={targetRef} data-testid="countdown" />
))
render(<Countdown end_at={endTime}>{renderChild}</Countdown>)
expect(screen.getByTestId('countdown')).toBeEmptyDOMElement()
act(() => {
vi.advanceTimersByTime(10000)
})
expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:50')
act(() => {
vi.advanceTimersByTime(50000)
})
expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:00')
})
})
const color =
(isUnchecked && 'palette.label.alternative') ||
(isChecked && 'palette.primary.normal') ||
'palette.label.normal'
기존 접근과 문제점
기존 접근
- 중첩된 삼항 연산자로 상태에 따라 값을 반환하는 코드 작성.
- 간결하지만 가독성이 떨어지고, 상태가 늘어나면 유지보수가 어려워짐.
문법적 접근
if
문,switch
문, 객체 매핑,&&
와||
논리 연산자 등 다양한 문법으로 해결 가능.- 하지만 이는 상태 관리의 본질적인 문제를 해결하지 못할 수 있음.
핵심 문제
status
모델을 어떻게 정의하고 참조할 것인가?- 상태의 의미를 명확히 표현할 수 있어야 함.
- 상태와 그에 따른 동작이 쉽게 확장 가능해야 함.
- 코드의 가독성과 유지보수성을 높여야 함.
class StatusManager {
static Status = {
UNCHECKED: 'UNCHECKED',
CHECKED: 'CHECKED',
DEFAULT: 'DEFAULT',
} as const
static getColor(status: keyof typeof this.Status) {
switch (status) {
case this.Status.UNCHECKED:
return 'palette.label.alternative'
case this.Status.CHECKED:
return 'palette.primary.normal'
default:
return 'palette.label.normal'
}
}
}
const color = StatusManager.getColor(StatusManager.Status.CHECKED)
변수의 prefix는 역할과 의미를 명확히 하기 위해 사용된다.
/**
* 초기값을 나타내며, 상태나 값이 처음 설정될 때 사용된다.
* 적합한 상황: 프로그램이 시작되거나 객체가 처음 생성될 때의 값을 정의한다.
*/
const initialCount = 0 // 카운터의 초기값
const initialState = { loggedIn: false, user: null } // 초기 상태
const initialPosition = { x: 0, y: 0 } // 초기 좌표
/**
* 기본값으로 널리 사용되는 표준 값이다.
* 적합한 상황: 값이 없을 경우 사용할 기본값을 정의한다.
*/
const defaultTheme = 'light' // 기본 테마
const defaultUser = { name: 'Guest', role: 'viewer' } // 기본 사용자
const defaultPageSize = 20 // 페이지당 기본 항목 수
/**
* 기준값 또는 다른 값의 참조점이 되는 값이다.
* 적합한 상황: 값을 계산하거나 파생할 때 기준이 되는 값을 정의한다.
*/
const baseSalary = 3000 // 기준 급여
const baseUrl = 'https://api.example.com' // API의 기준 URL
const baseColor = '#FFFFFF' // 기준 색상
/**
* 변경되기 전 원래 상태 또는 초기 상태를 강조한다.
* 적합한 상황: 데이터를 변경하기 전에 원래 값을 유지해야 할 때 사용한다.
*/
const originalText = 'Hello World' // 변경 전 텍스트
const originalSettings = { theme: 'dark', notifications: true } // 원래 설정값
const originalImage = image.clone() // 원본 이미지 복사
사용 가이드
-
initial*
vsdefault*
:initial*
은 주로 초기 상태를 강조하며, 값이 재설정될 가능성이 적다.default*
는 기본값으로 자주 사용되며 필요에 따라 다른 값으로 교체될 수 있다.
-
base*
vsoriginal*
:base*
는 기준값으로 사용되어 다른 값의 비교나 계산에 활용된다.original*
은 원래 상태를 보존하거나 복구를 위해 필요할 때 사용된다.
그런데 이 패턴이 “상태 머신”처럼 들린다면 놀랄 일도 아닙니다. 선택은 실제로 상태 머신을 구축하느냐 마느냐가 아니라 암시적으로 구축하느냐 명시적으로 구축하느냐의 문제입니다.
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)
})