MongoDB TTL 인덱스 (자동 문서 삭제)

// 데이터 삽입
db.collection.insertOne({
  name: 'Temporary',
  expireAt: new Date('2024-11-30T00:00:00Z'),
})

// TTL 인덱스 생성 (expireAt 시간 기준 삭제)
db.collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
  • 60초 간격 백그라운드 체크로 삭제 (즉시 아님)
  • expireAt은 ISODate 형식 필수
  • 활용: 세션 만료, 캐시 관리, 임시 데이터

SQLite는 TTL 미지원 → 트리거 또는 외부 스케줄러로 구현

#334

Google Auth ID Token 클레임

클레임의미
sub사용자 고유 식별자 (user_id로 사용 가능)
jtiJWT 토큰 고유 ID (토큰 무효화/추적용)
iss토큰 발급자 (accounts.google.com)
aud대상 애플리케이션 (Client ID)
iat발급 시각
exp만료 시각

sub를 user_id로 사용 시 고려사항:

  • Google 계정 삭제 후 재생성 시 새 sub 할당됨
  • 여러 소셜 로그인 지원 시 별도 user_id + google_sub 매핑 권장
#333

SQLite 게시물 업데이트 내역 저장

CREATE TABLE posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE post_history (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  post_id INTEGER NOT NULL,
  title TEXT,
  content TEXT,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
);

-- 업데이트 전 내역 저장
INSERT INTO post_history (post_id, title, content, updated_at)
SELECT id, title, content, updated_at FROM posts WHERE id = 1;

-- 게시물 업데이트
UPDATE posts SET title = '새 제목', updated_at = CURRENT_TIMESTAMP WHERE id = 1;

posts : post_history = 1 : N 관계

#332

SQLite JSON 태그 빈도 정렬

-- 태그별 빈도 계산
SELECT json_each.value AS tag, COUNT(*) AS frequency
FROM example, json_each(example.tags)
GROUP BY json_each.value
ORDER BY frequency DESC, tag ASC;

-- 빈도순 정렬된 태그를 JSON 배열로
SELECT json_group_array(tag) AS tags_by_frequency
FROM (
  SELECT json_each.value AS tag
  FROM example, json_each(example.tags)
  GROUP BY json_each.value
  ORDER BY COUNT(*) DESC, tag ASC
);
#331

빈 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
#330

Google Apps Script 캘린더 예약 시스템 - 시간 슬롯 조회 및 예약 구현

fetchAvailability - 지정된 조건(근무 요일, 시간, 이벤트 충돌)에 따라 예약 가능한 시간 슬롯 조회

flowchart LR
    A[시작] --> B[가장 가까운 슬롯 계산]
    B --> C[예약 기간 설정]
    C --> D[바쁜 일정 조회]
    D --> E[가능한 슬롯 필터링]
    E --> F[예약 가능한 슬롯 반환]
    F --> G[종료]
  1. 현재 시간을 기준으로 가장 가까운 시간 슬롯을 계산합니다. (슬롯 길이는 TIMESLOT_DURATION에 따라 설정됩니다.)
  2. 28일(DAYS_IN_ADVANCE) 동안의 일정 기간을 설정합니다.
  3. Calendar.Freebusy.query를 사용하여 지정된 캘린더(CALENDAR)의 바쁜 일정(busy events)을 조회합니다.
  4. 조회된 이벤트를 기반으로 조건에 맞지 않는 시간 슬롯을 제외합니다:
    • 지정된 근무 시간(WORKHOURS.start, WORKHOURS.end) 외의 시간.
    • 근무일(WORKDAYS)이 아닌 요일.
    • 다른 이벤트와 시간이 겹치는 경우.
  5. 예약 가능한 시간 슬롯을 ISO 8601 형식의 문자열로 저장하고 반환합니다.

bookTimeslot - 선택한 시간 슬롯에 이벤트 생성. 성공 시 Calendar에 추가, 확인 메시지 반환.

flowchart TD
    A[시작] --> B[사용자 입력 수신]
    B --> C[시간 슬롯 유효성 검증]
    C --> D[이벤트 충돌 확인]
    D --> E{이벤트가 존재합니까?}
    E -- 예 --> F[에러 반환]
    E -- 아니오 --> G[이벤트 생성]
    G --> H[성공 메시지 반환]
    F --> I[종료]
    H --> I
  1. 사용자가 선택한 시간 슬롯(timeslot)과 추가 정보(이름, 이메일, 전화번호, 메모)를 인수로 받습니다.
  2. 시간 슬롯의 유효성을 검증하고, TIMESLOT_DURATION을 기준으로 종료 시간을 계산합니다.
  3. Calendar.Freebusy.query를 통해 선택한 시간 동안 다른 이벤트가 있는지 확인합니다.
    • 이벤트가 겹치면 예약이 불가능하다는 에러 메시지를 반환합니다.
  4. 겹치는 이벤트가 없다면, CalendarApp.getCalendarById를 사용하여 Google Calendar에 새로운 이벤트를 생성합니다:
    • 이벤트 제목: 사용자 이름이 포함된 약속 제목.
    • 이벤트 설명: 전화번호와 메모를 포함합니다.
    • 초대된 손님: 제공된 이메일로 초대합니다.
    • 초대 메일 발송(sendInvites: true).
  5. 예약 성공 여부를 메시지로 반환합니다.

사용 플로우: 시간 조회 → 시간 선택 → bookTimeslot → 이벤트 생성 → 초대 이메일 발송


#329

AuthContext 테스트 패턴

// localStorage 함수 테스트
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
  if (key === 'auth_token') return 'test_token'
  return null
})

// AuthProvider 상태 테스트
const TestComponent = () => {
  const { token, login, logout } = useAuth()

  return (
    <div>
      <p data-testid="token">{token || 'null'}</p>
      <button onClick={() => login('test_token')}>Login</button>
      <button onClick={logout}>Logout</button>
    </div>
  )
}

// useAuth 훅 테스트
it('AuthProvider 외부에서 호출하면 에러', () => {
  expect(() => renderHook(() => useAuth())).toThrowError()
})

it('AuthProvider 내부에서 정상 동작', () => {
  const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>
  const { result } = renderHook(() => useAuth(), { wrapper })
  expect(result.current).toHaveProperty('token', null)
})
#328

타이머를 제어하기 위해 vi.useFakeTimersvi.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')
  })
})

#327

폼 제출 시 FormData 테스트

test('폼 제출 시 formData 확인', () => {
  const handleSubmit = jest.fn((e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.target as HTMLFormElement)
    const data = Object.fromEntries(formData.entries())
    expect(data).toEqual({ username: 'testuser', password: 'password' })
  })

  render(<LoginForm onSubmit={handleSubmit} />)

  fireEvent.change(screen.getByPlaceholderText('아이디'), {
    target: { value: 'testuser' },
  })
  fireEvent.change(screen.getByPlaceholderText('비밀번호'), {
    target: { value: 'password' },
  })
  fireEvent.click(screen.getByRole('button', { name: '로그인' }))

  expect(handleSubmit).toHaveBeenCalled()
})
#326

거리 만km 포맷팅

function formatToManKm(distance: number): string {
  const manKm = (distance / 10000).toFixed(1)

  return `${manKm}만km`
}

formatToManKm(104335) // "10.4만km"
#325
25 중 9페이지