Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 이윤하 · 백엔드 포트폴리오

Astro와 MDX로 만든 정적 포트폴리오입니다. 홈에서 GGUK·Purple 상세로 이동하며 여섯 사례의 본문을 웹과 인쇄 화면이 공유합니다.
Astro와 MDX로 만든 정적 포트폴리오입니다. 홈에서 GGUK·Purple 상세로 이동하며 모든 사례의 본문을 웹과 인쇄 화면이 공유합니다.

## 개발

Expand All @@ -14,7 +14,7 @@ npm run build
npm run preview
```

사이트는 루트 경로(`/`)에서 제공됩니다. `build`는 콘텐츠 스키마 검사, 여섯 Mermaid SVG 생성, 정적 HTML 생성을 순서대로 실행합니다. Mermaid CLI는 Puppeteer의 Chrome을 사용합니다. 브라우저 설치가 비활성화된 환경에서는 `npx puppeteer browsers install chrome`을 실행하거나 `PUPPETEER_EXECUTABLE_PATH`에 설치된 Chrome 실행 파일을 지정하세요.
사이트는 루트 경로(`/`)에서 제공됩니다. `build`는 콘텐츠 스키마 검사, 사례별 Mermaid SVG 생성, 정적 HTML 생성을 순서대로 실행합니다. Mermaid CLI는 Puppeteer의 Chrome을 사용합니다. 브라우저 설치가 비활성화된 환경에서는 `npx puppeteer browsers install chrome`을 실행하거나 `PUPPETEER_EXECUTABLE_PATH`에 설치된 Chrome 실행 파일을 지정하세요.

## 콘텐츠

Expand Down
3 changes: 1 addition & 2 deletions scripts/build-diagrams.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { tmpdir } from "node:os";
const diagrams = (await readdir("src/diagrams"))
.filter((name) => name.endsWith(".mmd"))
.sort();
if (diagrams.length !== 6)
throw new Error(`Expected six diagrams; received ${diagrams.length}`);
if (diagrams.length === 0) throw new Error("No diagrams found in src/diagrams");
await mkdir("public/diagrams", { recursive: true });
const configPath = join(tmpdir(), `portfolio-puppeteer-${process.pid}.json`);
const config = process.env.PUPPETEER_EXECUTABLE_PATH
Expand Down
5 changes: 3 additions & 2 deletions scripts/export-pdf.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { chromium } from '@playwright/test';
import { mkdir, rm, rename, readFile } from 'node:fs/promises';
import { mkdir, rm, rename, readFile, readdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { serveDist } from './serve-dist.mjs';

Expand All @@ -9,6 +9,7 @@ const editions = [
{ route: 'portfolio/print/', output: 'dist/portfolio/downloads/younha-portfolio.pdf' },
];

const expectedCases = (await readdir('src/content/cases')).filter(name => name.endsWith('.mdx')).length;
const server = await serveDist();
let browser;
try {
Expand Down Expand Up @@ -40,7 +41,7 @@ async function exportEdition(browser, { route, output }) {
if (!image.naturalWidth) throw new Error(`Image not loaded: ${image.src}`);
}));
});
if (await page.locator('.case-study').count() !== 6) throw new Error('Expected all six case studies');
if (await page.locator('.case-study').count() !== expectedCases) throw new Error(`Expected all ${expectedCases} case studies`);
if (failures.length) throw new Error(`Print resources failed: ${failures.join(', ')}`);
await page.pdf({ path: temporary, format: 'A4', preferCSSPageSize: true, printBackground: true, displayHeaderFooter: true, headerTemplate: '<span></span>', footerTemplate: '<div style="font-size:8px;color:#61655e;width:100%;text-align:center"><span class="pageNumber"></span> / <span class="totalPages"></span></div>' });
const pdf = await readFile(temporary);
Expand Down
3 changes: 2 additions & 1 deletion src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const cases = defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./src/content/cases" }),
schema: z.object({
project: z.enum(["gguk", "purple"]),
order: z.number().int().min(1).max(3),
order: z.number().int().min(1),
title: requiredText,
summary: requiredText,
role: requiredText,
Expand All @@ -19,6 +19,7 @@ const cases = defineCollection({
"billing",
"query",
"recovery",
"consent",
]),
diagramCaption: requiredText,
sources: z.array(
Expand Down
36 changes: 36 additions & 0 deletions src/content/cases/purple-consent.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
project: purple
order: 4
title: "재결제 약관 동의를 서버가 대신하지 않게 하기"
summary: "신규 자동결제 필수 약관 미동의로 셀프 재결제가 실패했습니다. 서버가 대신 동의하면 바로 해결됐지만, 사용자가 보지 않은 약관에 동의가 기록되는 구조라 리스크로 판단해 보고했습니다. 정식 과제로 맡아 사용자가 직접 체크한 경우에만 동의를 기록하도록 바꿨습니다."
role: "리스크 보고, 청구 직전 미동의 차단, 필수 약관 조회·동의 API, 재결제 화면 약관 UI"
decision: "서버 자동 동의를 쓰지 않고, 미동의 필수 약관이 남아 있으면 청구를 차단했습니다. 화면에 표시된 미동의 약관을 모두 체크해야만 동의를 기록합니다."
tradeoff: "사용자는 재결제 전에 약관 동의 단계를 한 번 더 거칩니다. 미동의 약관이 남아 있으면 재결제가 진행되지 않습니다."
diagram: consent
diagramCaption: "사용자가 직접 체크한 약관만 동의로 기록합니다. 청구 직전에 미동의 필수 약관이 남아 있으면 청구하지 않습니다."
sources: []
---

### 문제

Toss 브랜드페이에 자동결제 필수 약관이 새로 추가되면서, 기존 구독 고객의 셀프 재결제가 약관 미동의로 실패했습니다. 기존 구독 고객은 약관이 생긴 뒤 결제 위젯을 다시 거치지 않아 Toss에 동의 기록이 없었습니다.

가장 빠른 해결은 서버가 청구 직전에 대신 동의하는 것이었습니다. 하지만 셀프 재결제는 위젯을 거치지 않고 저장된 결제수단으로 바로 청구하는 경로라, 그렇게 하면 사용자가 확인하지 않은 약관에 동의가 기록됩니다.

### 내 기여와 해결

이 구조를 리스크로 판단해 경영지원팀과 경영진에 보고했고, 정식 과제로 맡아 진행했습니다.

재결제 API는 청구 직전에 미동의 필수 약관이 있는지만 확인하고, 남아 있으면 청구를 차단합니다. 이 경로에서는 서버가 동의 API를 호출하지 않습니다. 별도 조회 API가 필수 약관 전체를 항목별 동의 여부와 함께 내려주고, 동의 API는 화면에 표시된 미동의 약관을 모두 받은 경우에만 Toss에 동의를 기록합니다. 일부만 보내 우회하면 서버가 다시 거절합니다.

재결제 화면에서는 Toss 위젯의 약관 박스를 숨겼습니다. 이 화면은 결제 요청 호출을 거치지 않아 그 박스의 체크 상태가 Toss로 전송되지 않는, 실제로는 동작하지 않는 UI였습니다. 대신 같은 형태의 자체 약관 박스를 만들고, 이미 동의한 항목은 체크된 채 비활성으로 표시했습니다. 약관 전문은 Toss가 내려주는 원문 링크를 그대로 써서 사본과 원문이 어긋나지 않게 했습니다.

### 구현하며 확인한 점

Toss의 약관 동의 기록은 상점별로 따로 관리됩니다. 같은 고객이라도 상점이 다르면 동의 상태가 다르기 때문에, 그 주문의 결제 이력에 남은 상점 기준으로 조회하고 동의하도록 맞췄습니다. 또 Toss의 약관 조회 API는 이름과 달리 이미 동의한 약관까지 함께 내려주어, 화면에서는 전체를 보여주고 미동의 항목만 체크를 받도록 했습니다.

### 검증 결과

재결제·약관 조회·동의 관련 Jest 22건이 통과했습니다. DEV 배포 후 미동의 계정에서 약관 3건이 미체크 상태로 표시되고, 전부 체크하기 전에는 결제 버튼이 비활성인 것을 확인했습니다. 전부 체크한 뒤 동의 API가 호출되고, Toss 조회에서 해당 약관이 미동의에서 동의로 바뀐 것을 직접 확인했습니다. 이미 전부 동의한 계정은 약관이 체크된 채 비활성으로 표시됐습니다.

운영 서버에 반영한 뒤, 같은 약관 미동의 오류가 더 이상 발생하지 않는 것을 확인했습니다.
2 changes: 1 addition & 1 deletion src/content/projects/purple.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"period": "2026.04 — 현재",
"stack": ["TypeScript", "NestJS", "MySQL", "TypeORM", "Redis", "BullMQ"],
"focus": "결제 경합 · 검색 SQL · 실패 복구",
"scope": "주문 검색 SQL 개선, 구독 단위 청구 경합 제어, 승인·자동결제 응답 유실 복구를 맡았습니다. 공용 락·기본 결제 모델과 이후 팀의 보완은 담당 범위와 구분했습니다. 회사 코드 대신 설계와 검증 결과를 정리했습니다."
"scope": "주문 검색 SQL 개선, 구독 단위 청구 경합 제어, 승인·자동결제 응답 유실 복구, 셀프 재결제 약관 명시 동의를 맡았습니다. 공용 락·기본 결제 모델과 이후 팀의 보완은 담당 범위와 구분했습니다. 회사 코드 대신 설계와 검증 결과를 정리했습니다."
}
9 changes: 9 additions & 0 deletions src/diagrams/consent.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
flowchart TB
U[학부모 셀프 재결제 화면] --> G[필수 약관 전체 조회 · 항목별 동의 여부]
G --> K{미동의 약관을 전부 체크?}
K -->|아니요| N[동의 기록 거절]
K -->|예| A[Toss에 동의 기록]
A --> R[재결제 요청]
R --> Q{청구 직전 미동의 필수 약관?}
Q -->|남아 있음| X[청구 차단 · 동의 화면으로]
Q -->|없음| P[기존 청구 처리]
7 changes: 4 additions & 3 deletions src/lib/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { getCollection, type CollectionEntry } from 'astro:content';

class CaseContractError extends Error {
constructor(readonly project: string) {
super(`Project ${project} must contain exactly three cases with unique orders 1, 2 and 3.`);
super(`Project ${project} must contain at least three cases numbered 1 to N without gaps.`);
this.name = 'CaseContractError';
}
}

export async function getProjectCases(project: string): Promise<CollectionEntry<'cases'>[]> {
const entries = await getCollection('cases', ({ data }) => data.project === project);
if (entries.length !== 3 || new Set(entries.map(entry => entry.data.order)).size !== 3) {
const sorted = entries.sort((a, b) => a.data.order - b.data.order);
if (sorted.length < 3 || sorted.some((entry, index) => entry.data.order !== index + 1)) {
throw new CaseContractError(project);
}
return entries.sort((a, b) => a.data.order - b.data.order);
return sorted;
}
10 changes: 6 additions & 4 deletions tests/portfolio.spec.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { test, expect } from '@playwright/test';

for (const project of ['gguk', 'purple']) {
const caseCounts = { gguk: 3, purple: 4 };

for (const [project, caseCount] of Object.entries(caseCounts)) {
test(`project card opens ${project}`, async ({ page }) => {
await page.goto('./');
await page.locator(`.project-card[href$="/${project}/"]`).click();
await expect(page).toHaveURL(new RegExp(`/projects/${project}/$`));
await expect(page.locator('.case-study')).toHaveCount(3);
await expect(page.locator('.case-study')).toHaveCount(caseCount);
});
test(`${project} direct URL survives reload`, async ({ page }) => {
await page.goto(`projects/${project}/`);
await page.reload();
await expect(page.locator('h1')).toContainText(new RegExp(project, 'i'));
await expect(page.locator('.diagram-frame img')).toHaveCount(3);
await expect(page.locator('.diagram-frame img')).toHaveCount(caseCount);
expect(await page.locator('.diagram-frame img').evaluateAll(images => images.every(image => image.complete && image.naturalWidth > 0))).toBe(true);
});
}
Expand Down Expand Up @@ -41,7 +43,7 @@ test('content and original diagram work without JavaScript', async ({ browser, b
const context = await browser.newContext({ javaScriptEnabled: false, baseURL });
const page = await context.newPage();
await page.goto('projects/purple/');
await expect(page.locator('.case-study')).toHaveCount(3);
await expect(page.locator('.case-study')).toHaveCount(caseCounts.purple);
await page.locator('.diagram-open').first().click();
await expect(page).toHaveURL(/\/diagrams\/query.svg$/);
await expect(page.locator('svg')).toBeVisible();
Expand Down
Loading