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로 사용 가능)
jti JWT 토큰 고유 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

Apps Script로 캘린더 예약을 만들 때 쓰는 건 결국 API 두 개다.

Calendar.Freebusy.query로 바쁜 일정을 받아 근무 요일·근무 시간과 겹치는 슬롯을 빼면 예약 가능한 슬롯이 남는다. 예약은 CalendarApp.getCalendarById로 이벤트를 만들고 sendInvites: true로 초대 메일까지 보낸다.

Freebusy는 슬롯을 계산할 때와 실제로 예약할 때 두 번 조회한다 — 그 사이에 일정이 생길 수 있으니.


#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
30 중 13페이지