diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3d38e96
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [develop]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: "ci-${{ github.ref }}"
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Test
+ run: npm test
+
+ - name: Build
+ run: npm run build
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 97328b7..eea7c5c 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -30,6 +30,9 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Test
+ run: npm test
+
- name: Build
run: npm run build
diff --git a/content/posts/hello-world.md b/content/posts/hello-world.md
index e9e59aa..36532fa 100644
--- a/content/posts/hello-world.md
+++ b/content/posts/hello-world.md
@@ -2,8 +2,7 @@
title: "Hello World - 첫 번째 블로그 포스트"
date: "2026-01-13"
description: "Next.js와 TypeScript로 만든 블로그의 첫 번째 포스트입니다."
-category: "개발"
-subcategory: "Next.js"
+category: "블로그"
tags: ["tutorial", "beginner"]
thumbnail: "/images/thumbnails/nextjs"
---
diff --git a/content/posts/pgjdbc-jsonb-typehandler.md b/content/posts/pgjdbc-jsonb-typehandler.md
index f4cc481..924433a 100644
--- a/content/posts/pgjdbc-jsonb-typehandler.md
+++ b/content/posts/pgjdbc-jsonb-typehandler.md
@@ -3,9 +3,9 @@ title: "pgjdbc 42.7.11 JSONB 반환 타입 변경과 TypeHandler 대응"
date: "2026-05-06"
description: "pgjdbc 42.7.11에서 JSONB 컬럼의 반환 타입이 String에서 PGobject로 변경되었습니다. MyBatis 커스텀 TypeHandler를 글로벌 등록하여 기존 코드 변경 없이 호환성을 유지하는 방법을 정리합니다."
category: "개발"
-subcategory: "Java"
+subcategory: "Spring"
tags: ["guide", "intermediate"]
-thumbnail: "/images/thumbnails/java"
+thumbnail: "/images/thumbnails/springboot"
glossary:
- id: "pgobject"
term: "PGobject"
diff --git a/content/posts/spring-boot-i18next-architecture.md b/content/posts/spring-boot-i18next-architecture.md
index d590a6e..48ca944 100644
--- a/content/posts/spring-boot-i18next-architecture.md
+++ b/content/posts/spring-boot-i18next-architecture.md
@@ -3,7 +3,7 @@ title: "Spring Boot에서 i18next로 다국어 구현하기 - localStorage SWR
date: "2026-02-27"
description: "Spring Boot + Thymeleaf 환경에서 i18next와 localStorage SWR 캐싱을 결합한 서버-클라이언트 하이브리드 다국어 아키텍처를 설계합니다. 기존 jquery.i18n.properties.js의 한계를 극복하고, 캐시 히트 시 네트워크 요청 0회를 달성하는 구조를 다룹니다."
category: "개발"
-subcategory: "Spring Boot"
+subcategory: "Spring"
tags: ["guide", "intermediate"]
relatedSlugs:
- localstorage-swr-caching-pattern
diff --git a/src/components/__tests__/ImageHoverPreview.test.tsx b/src/components/__tests__/ImageHoverPreview.test.tsx
deleted file mode 100644
index f6afba0..0000000
--- a/src/components/__tests__/ImageHoverPreview.test.tsx
+++ /dev/null
@@ -1,291 +0,0 @@
-import { render, screen, act, fireEvent } from "@testing-library/react";
-import ImageHoverPreview from "../ImageHoverPreview";
-
-/**
- * ImageHoverPreview 통합 테스트.
- *
- * document 레벨 mouseover/mouseout 이벤트 위임으로 .prose 내 img를 감지하고,
- * createPortal로 document.body에 확대 프리뷰를 렌더한다.
- */
-
-/** .prose 래퍼 안에 이미지가 있는 페이지를 렌더한다. */
-function renderWithProseImage(alt = "sample image") {
- return render(
-
-
-

-
-
-

-
-
-
- );
-}
-
-/** matchMedia를 hover 지원 환경으로 모킹한다. */
-function mockHoverSupported(supported: boolean) {
- Object.defineProperty(window, "matchMedia", {
- writable: true,
- value: jest.fn().mockImplementation((query: string) => ({
- matches: supported && query === "(hover: hover)",
- media: query,
- onchange: null,
- addListener: jest.fn(),
- removeListener: jest.fn(),
- addEventListener: jest.fn(),
- removeEventListener: jest.fn(),
- dispatchEvent: jest.fn(),
- })),
- });
-}
-
-describe("ImageHoverPreview 통합 테스트", () => {
- beforeEach(() => {
- mockHoverSupported(true);
- });
-
- // ─────────────────────────────────────────────────────
- // 1. 이미지 hover 시 프리뷰 표시
- // ─────────────────────────────────────────────────────
- describe("프리뷰 표시", () => {
- test("prose 내 이미지에 마우스를 올리면 프리뷰가 표시된다", () => {
- renderWithProseImage("테스트 이미지");
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
-
- const image = screen.getByTestId("prose-image");
-
- act(() => {
- fireEvent.mouseOver(image, { clientX: 200, clientY: 300 });
- });
-
- const preview = document.querySelector(".image-hover-preview");
- expect(preview).toBeInTheDocument();
-
- const previewImg = preview?.querySelector("img");
- expect(previewImg?.getAttribute("src")).toContain("test.png");
- expect(previewImg).toHaveAttribute("alt", "테스트 이미지");
- });
-
- test("마우스를 이미지에서 벗어나면 프리뷰가 사라진다", () => {
- renderWithProseImage();
-
- const image = screen.getByTestId("prose-image");
-
- act(() => {
- fireEvent.mouseOver(image, { clientX: 200, clientY: 300 });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).toBeInTheDocument();
-
- act(() => {
- fireEvent.mouseOut(image);
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
- });
- });
-
- // ─────────────────────────────────────────────────────
- // 2. 필터링 - prose 외부 이미지
- // ─────────────────────────────────────────────────────
- describe("필터링", () => {
- test("prose 외부 이미지에서는 프리뷰가 표시되지 않는다", () => {
- renderWithProseImage();
-
- const outsideImage = screen.getByTestId("outside-image");
-
- act(() => {
- fireEvent.mouseOver(outsideImage, { clientX: 200, clientY: 300 });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
- });
-
- test("Lightbox가 열려있을 때 hover 프리뷰가 표시되지 않는다", () => {
- renderWithProseImage();
-
- // Lightbox 오버레이가 존재하는 상태 시뮬레이션
- const overlay = document.createElement("div");
- overlay.className = "lightbox-overlay";
- document.body.appendChild(overlay);
-
- const image = screen.getByTestId("prose-image");
-
- act(() => {
- fireEvent.mouseOver(image, { clientX: 200, clientY: 300 });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
-
- document.body.removeChild(overlay);
- });
- });
-
- // ─────────────────────────────────────────────────────
- // 3. 비정상 프로토콜 이미지 필터링
- // ─────────────────────────────────────────────────────
- describe("프로토콜 검증", () => {
- test("data: URI 이미지에서는 프리뷰가 표시되지 않는다", () => {
- render(
-
-
-

-
-
-
- );
-
- const image = screen.getByTestId("data-image");
-
- act(() => {
- fireEvent.mouseOver(image, { clientX: 200, clientY: 300 });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
- });
-
- test("blob: URI 이미지에서는 프리뷰가 표시되지 않는다", () => {
- render(
-
-
-

-
-
-
- );
-
- const image = screen.getByTestId("blob-image");
-
- act(() => {
- fireEvent.mouseOver(image, { clientX: 200, clientY: 300 });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
- });
- });
-
- // ─────────────────────────────────────────────────────
- // 4. 프리뷰 해제 - scroll, Escape, visibilitychange
- // ─────────────────────────────────────────────────────
- describe("프리뷰 해제", () => {
- test("스크롤 시 프리뷰가 사라진다", () => {
- renderWithProseImage();
-
- const image = screen.getByTestId("prose-image");
-
- act(() => {
- fireEvent.mouseOver(image, { clientX: 200, clientY: 300 });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).toBeInTheDocument();
-
- act(() => {
- fireEvent.scroll(window);
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
- });
-
- test("Escape 키 입력 시 프리뷰가 사라진다", () => {
- renderWithProseImage();
-
- const image = screen.getByTestId("prose-image");
-
- act(() => {
- fireEvent.mouseOver(image, { clientX: 200, clientY: 300 });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).toBeInTheDocument();
-
- act(() => {
- fireEvent.keyDown(document, { key: "Escape" });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
- });
-
- test("탭 전환(visibilitychange) 시 프리뷰가 사라진다", () => {
- renderWithProseImage();
-
- const image = screen.getByTestId("prose-image");
-
- act(() => {
- fireEvent.mouseOver(image, { clientX: 200, clientY: 300 });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).toBeInTheDocument();
-
- act(() => {
- Object.defineProperty(document, "hidden", {
- writable: true,
- value: true,
- });
- document.dispatchEvent(new Event("visibilitychange"));
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
-
- // cleanup
- Object.defineProperty(document, "hidden", {
- writable: true,
- value: false,
- });
- });
- });
-
- // ─────────────────────────────────────────────────────
- // 5. 모바일 환경 비활성화
- // ─────────────────────────────────────────────────────
- describe("모바일 환경", () => {
- test("hover 미지원 환경에서는 프리뷰가 표시되지 않는다", () => {
- mockHoverSupported(false);
- renderWithProseImage();
-
- const image = screen.getByTestId("prose-image");
-
- act(() => {
- fireEvent.mouseOver(image, { clientX: 200, clientY: 300 });
- });
-
- expect(
- document.querySelector(".image-hover-preview")
- ).not.toBeInTheDocument();
- });
- });
-});
diff --git a/src/hooks/__tests__/useImageHoverPreview.test.ts b/src/hooks/__tests__/useImageHoverPreview.test.ts
deleted file mode 100644
index bcc61dc..0000000
--- a/src/hooks/__tests__/useImageHoverPreview.test.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { renderHook, act } from "@testing-library/react";
-import { useImageHoverPreview } from "../useImageHoverPreview";
-
-describe("useImageHoverPreview", () => {
- // ─────────────────────────────────────────────────────
- // 1. 초기 상태
- // ─────────────────────────────────────────────────────
- describe("초기 상태", () => {
- test("초기 상태에서 프리뷰가 보이지 않는다", () => {
- const { result } = renderHook(() => useImageHoverPreview());
-
- expect(result.current.isVisible).toBe(false);
- expect(result.current.imageSrc).toBeNull();
- expect(result.current.imageAlt).toBe("");
- });
- });
-
- // ─────────────────────────────────────────────────────
- // 2. showPreview 호출
- // ─────────────────────────────────────────────────────
- describe("showPreview", () => {
- test("showPreview 호출 시 프리뷰 상태가 업데이트된다", () => {
- const { result } = renderHook(() => useImageHoverPreview());
-
- act(() => {
- result.current.showPreview("/test.png", "테스트 이미지");
- });
-
- expect(result.current.isVisible).toBe(true);
- expect(result.current.imageSrc).toBe("/test.png");
- expect(result.current.imageAlt).toBe("테스트 이미지");
- });
- });
-
- // ─────────────────────────────────────────────────────
- // 3. hidePreview 호출
- // ─────────────────────────────────────────────────────
- describe("hidePreview", () => {
- test("hidePreview 호출 시 초기 상태로 돌아간다", () => {
- const { result } = renderHook(() => useImageHoverPreview());
-
- act(() => {
- result.current.showPreview("/test.png", "테스트");
- });
-
- expect(result.current.isVisible).toBe(true);
-
- act(() => {
- result.current.hidePreview();
- });
-
- expect(result.current.isVisible).toBe(false);
- expect(result.current.imageSrc).toBeNull();
- expect(result.current.imageAlt).toBe("");
- });
- });
-});