feat(backend): 업데이트 내역 읽기 API 와 첫 글을 더한다

직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판의 서버 몫이다. 글은 운영자가 backend/release-notes/ 에 글마다 파일 하나로 쓰고
(올리는 API · DB 표 · 새 에러 코드 없음), GET /api/release-notes(커서 목록)와 GET /api/release-notes/{id}(글 + 바로 옛 글 · 새 글)로 웹 · 앱에 똑같이 준다.

- 글 폴더는 src 밖이라 개발 컨테이너(바인드 마운트)는 다시 띄우지 않아도 새 글이 보이고, 운영 이미지는 Dockerfile 이 폴더를 함께 복사한다
- 틀린 글은 실행 때 그 글만 건너뛰고 error 로그(파일 이름 · 줄 · 까닭만 — 기동 때 한 번 읽어 배포 직후 보인다), 실제 폴더를 엄격하게 읽는 시험이 CI 에서 운영자의 오타를 잡는다
- 파일은 O_NOFOLLOW 로 한 번 열어 일반 파일 · 256KB 까지만 읽고, 모양이 틀린 id 도 없는 글과 같은 404 RES_001 이다
- 첫 글(2026-11-18.md)은 다음 배포 소식이다 — 파일 이름(날짜)은 배포 날로 고친다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 19:36:59 +09:00
parent abe4c1364c
commit cd958f3bf4
19 changed files with 2679 additions and 1 deletions
@@ -0,0 +1,97 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
RELEASE_NOTE_HEADING_MAX_LENGTH,
RELEASE_NOTE_ITEM_MAX_LENGTH,
RELEASE_NOTE_TITLE_MAX_LENGTH,
} from '../release-notes.constants';
import type {
ReleaseNoteDetail,
ReleaseNoteSection,
ReleaseNoteSummary,
} from '../release-notes.types';
/** 목록의 한 줄 · 글 화면의 다른 글 줄 (계약 7절 `ReleaseNoteSummary`) */
export class ReleaseNoteSummaryDto implements ReleaseNoteSummary {
@ApiProperty({
description:
'글 id — 글 파일 이름에서 .md 를 뗀 것(`YYYY-MM-DD`, 같은 날 두 번째 글부터 `YYYY-MM-DD-2` …). 글 주소에 쓴다',
example: '2026-11-18',
})
id!: string;
@ApiProperty({
description:
'글의 날짜 — 달력 날짜 글(`YYYY-MM-DD`, 파일 이름에서). 시각이 아니다 — 화면은 시간대를 바꾸지 않고 그대로 쓴다(Date 로 읽지 않는다)',
example: '2026-11-18',
})
date!: string;
@ApiProperty({
description: `제목 — 글 파일의 첫 줄(# 제목), ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지`,
example: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
})
title!: string;
}
/** GET /api/release-notes 의 응답 */
export class ReleaseNotePageDto {
@ApiProperty({
type: [ReleaseNoteSummaryDto],
description:
'최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`)',
})
items!: ReleaseNoteSummaryDto[];
@ApiProperty({ type: PageInfoDto, description: 'total 은 글 수' })
pageInfo!: PageInfoDto;
}
/** 묶음 (계약 7절 `ReleaseNoteSection`) */
export class ReleaseNoteSectionDto implements ReleaseNoteSection {
@ApiProperty({
description: `묶음 머리(## 머리, ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지) — 머리 없이 쓴 묶음은 null(글의 맨 앞에만 온다)`,
type: String,
nullable: true,
example: '휴대폰 앱',
})
heading!: string | null;
@ApiProperty({
description:
`묶음의 줄(- 한 줄, ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지 — 하나 이상). 서버가 다듬은 글 그대로(앞뒤 빈칸 지움 · 안의 빈칸 여럿은 하나로 · 이어 쓴 줄은 띄어쓰기 하나로 · NFC). ` +
'마크다운 · HTML 로 해석하지 않고 글로만 그린다',
type: [String],
example: [
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
],
})
items!: string[];
}
/** GET /api/release-notes/{id} 의 응답 (계약 7절 `ReleaseNote`) */
export class ReleaseNoteDto
extends ReleaseNoteSummaryDto
implements ReleaseNoteDetail
{
@ApiProperty({
type: [ReleaseNoteSectionDto],
description: '묶음 — 파일에 쓴 차례, 하나 이상',
})
sections!: ReleaseNoteSectionDto[];
@ApiProperty({
description: '이전 글 — 바로 더 옛 글(목록에서 이 글 아래). 없으면 null',
type: ReleaseNoteSummaryDto,
nullable: true,
})
previous!: ReleaseNoteSummaryDto | null;
@ApiProperty({
description: '다음 글 — 바로 더 새 글(목록에서 이 글 위). 없으면 null',
type: ReleaseNoteSummaryDto,
nullable: true,
})
next!: ReleaseNoteSummaryDto | null;
}
@@ -0,0 +1,538 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
classifyReleaseNoteFile,
compareReleaseNotes,
describeReleaseNoteProblem,
isCalendarDate,
isReleaseNoteId,
parseReleaseNoteText,
type ReleaseNoteProblem,
} from './release-note-format';
import {
RELEASE_NOTE_HEADING_MAX_LENGTH,
RELEASE_NOTE_ITEM_MAX_LENGTH,
RELEASE_NOTE_PROBLEMS_MAX,
RELEASE_NOTE_TITLE_MAX_LENGTH,
} from './release-notes.constants';
/** 줄들을 이어 글 파일의 글로 */
const lines = (...rows: string[]) => rows.join('\n');
/** 맞는 글의 결과 — 틀리면 문제를 보이며 실패한다 */
function parsed(text: string) {
const result = parseReleaseNoteText(text);
if (!result.ok) {
throw new Error(
`틀린 글로 읽었다: ${result.problems.map(describeReleaseNoteProblem).join(', ')}`,
);
}
return result;
}
/** 틀린 글의 문제 — 맞는 글로 읽으면 실패한다 */
function problemsOf(text: string): readonly ReleaseNoteProblem[] {
const result = parseReleaseNoteText(text);
if (result.ok) throw new Error('맞는 글로 읽었다');
return result.problems;
}
const LINE_SEPARATOR = String.fromCharCode(0x2028);
const BYTE_ORDER_MARK = String.fromCharCode(0xfeff);
const BELL = String.fromCharCode(0x07);
/** 이모지 하나 — UTF-16 으로 두 칸 */
const EMOJI = String.fromCodePoint(0x1f600);
describe('parseReleaseNoteText — 맞는 글', () => {
it('보드 C 의 글 — 제목 · 메모 · 머리 · 줄, 빈칸 두 개로 시작한 줄은 앞 줄에 띄어쓰기 하나로 이어 붙는다', () => {
const result = parsed(
lines(
'# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
'<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->',
'',
'## 휴대폰 앱',
'- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.',
' 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
'',
'## 메일 검색',
'- 찾은 말에는 노란 표시가 붙습니다.',
'',
),
);
expect(result).toEqual({
ok: true,
title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
sections: [
{
heading: '휴대폰 앱',
items: [
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
],
},
{ heading: '메일 검색', items: ['찾은 말에는 노란 표시가 붙습니다.'] },
],
});
});
it('머리 없이 바로 쓴 줄은 머리 없는 묶음(heading null) — 뒤에 머리가 오면 새 묶음', () => {
expect(
parsed(
lines(
'# 왼쪽 메뉴를 정리했습니다',
'- 아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.',
'## 주소록',
'- 한 줄',
),
).sections,
).toEqual([
{
heading: null,
items: ['아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.'],
},
{ heading: '주소록', items: ['한 줄'] },
]);
});
it('앞뒤 빈칸은 지우고 안의 빈칸 · 탭 여럿은 하나로 — 탭으로 시작한 줄도 이어 쓰기, 굵게 · 링크 꼴은 글 그대로', () => {
const result = parsed(
lines(
'# 제목 ',
'##\t머리\t ',
'- 한 줄\t\t끝 ',
'\t탭으로 이어 씀',
' 빈칸 여럿으로 이어 씀',
'- **굵게** [링크](https://example.com) `코드`',
),
);
expect(result.title).toBe('제목');
expect(result.sections).toEqual([
{
heading: '머리',
items: [
'한 줄 끝 탭으로 이어 씀 빈칸 여럿으로 이어 씀',
'**굵게** [링크](https://example.com) `코드`',
],
},
]);
});
it('빈 줄 · 메모(여러 줄 · 제목 앞 · 줄 사이)는 화면에 없다 — 빈 줄 · 메모를 지나도 이어 쓰기는 앞 - 줄에 붙는다', () => {
const result = parsed(
lines(
'<!-- 맨 앞 메모',
' 여러 줄 -->',
'',
'# 제목',
' <!-- 들여 쓴 메모 -->',
'- 첫 줄',
'<!-- 줄 사이 메모 -->',
'',
' 이어 쓴 글',
'<!-->',
'<!--->',
'- 둘째 줄',
),
);
expect(result).toEqual({
ok: true,
title: '제목',
sections: [{ heading: null, items: ['첫 줄 이어 쓴 글', '둘째 줄'] }],
});
});
it('CRLF 줄 끝 · 앞의 BOM 은 지운다', () => {
const result = parsed(
`${BYTE_ORDER_MARK}# 제목\r\n## 머리\r\n- 한 줄\r\n 이어 씀\r\n`,
);
expect(result.title).toBe('제목');
expect(result.sections).toEqual([
{ heading: '머리', items: ['한 줄 이어 씀'] },
]);
});
it('NFC 로 모은다 — 풀어 쓴 한글(NFD)도 같은 글 · 같은 길이', () => {
const decomposed = '업데이트 내역'.normalize('NFD');
expect(decomposed.length).toBeGreaterThan('업데이트 내역'.length);
const result = parsed(lines(`# ${decomposed}`, `- ${decomposed}`));
expect(result.title).toBe('업데이트 내역');
expect(result.sections[0].items).toEqual(['업데이트 내역']);
});
it('길이 한도 — 제목 100 · 머리 40 · 한 줄 500 까지는 맞다(UTF-16 — 이모지는 두 칸)', () => {
const title = '가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 2) + EMOJI;
const heading = EMOJI + '나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH - 2);
const item = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH);
const result = parsed(lines(`# ${title}`, `## ${heading}`, `- ${item}`));
expect(result.title).toHaveLength(RELEASE_NOTE_TITLE_MAX_LENGTH);
expect(result.sections[0].heading).toHaveLength(
RELEASE_NOTE_HEADING_MAX_LENGTH,
);
expect(result.sections[0].items[0]).toHaveLength(
RELEASE_NOTE_ITEM_MAX_LENGTH,
);
});
it('- 만 쓴 줄도 이어 쓰기가 글을 채우면 맞다', () => {
expect(parsed(lines('# 제목', '-', ' 이어 쓴 글')).sections).toEqual([
{ heading: null, items: ['이어 쓴 글'] },
]);
});
});
describe('parseReleaseNoteText — 틀린 글 (줄 번호 · 까닭)', () => {
it('첫 글 줄이 # 제목이 아니면 그 줄에 title-missing — 나머지도 읽어 함께 알린다', () => {
expect(problemsOf(lines('', '## 머리', '- 한 줄', '맨 글'))).toEqual([
{ line: 2, reason: 'title-missing' },
{ line: 4, reason: 'unknown-line' },
]);
});
it('빈 파일 · 메모뿐인 파일은 title-missing · no-items (파일 전체 — 줄 번호 없음)', () => {
for (const text of ['', '\n\n', lines('<!-- 메모만', '-->')]) {
expect(problemsOf(text)).toEqual([
{ line: null, reason: 'title-missing' },
{ line: null, reason: 'no-items' },
]);
}
});
it('# 제목을 또 쓰면 title-again, 비면 title-empty, 길면 title-too-long', () => {
expect(problemsOf(lines('# 제목', '- 한 줄', '# 또 제목'))).toEqual([
{ line: 3, reason: 'title-again' },
]);
expect(problemsOf(lines('# ', '- 한 줄'))).toEqual([
{ line: 1, reason: 'title-empty' },
]);
expect(
problemsOf(
lines(
`# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 1)}${EMOJI}`,
'- 줄',
),
),
).toEqual([{ line: 1, reason: 'title-too-long' }]);
});
it('머리가 비면 heading-empty, 길면 heading-too-long, 아래에 줄이 없으면 heading-without-items', () => {
expect(
problemsOf(
lines(
'# 제목',
'##',
'- 줄',
`## ${'나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH + 1)}`,
'- 줄',
'## 줄 없는 머리',
'',
'## 마지막 머리',
'- 줄',
),
),
).toEqual([
{ line: 2, reason: 'heading-empty' },
{ line: 4, reason: 'heading-too-long' },
{ line: 6, reason: 'heading-without-items' },
]);
});
it('줄이 비면 item-empty, 이어 붙인 뒤 500자를 넘으면 그 줄의 시작에 item-too-long', () => {
const half = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH / 2);
expect(
problemsOf(
lines('# 제목', '- ', '- 맞는 줄', `- ${half}`, ` ${half}`),
),
).toEqual([
{ line: 2, reason: 'item-empty' },
// 둘을 띄어쓰기 하나로 이으면 501자
{ line: 4, reason: 'item-too-long' },
]);
});
it('글에 줄이 하나도 없으면 no-items', () => {
expect(problemsOf(lines('# 제목', '<!-- 줄은 나중에 -->'))).toEqual([
{ line: null, reason: 'no-items' },
]);
});
it.each([
['맨 글', '그냥 쓴 글'],
['###', '### 작은 머리'],
['별표 목록', '* 별표 줄'],
['더하기 목록', '+ 더하기 줄'],
['번호 목록', '1. 번호 줄'],
['표', '| 칸 | 칸 |'],
['인용', '> 인용'],
['가로줄', '---'],
['한 칸 들여 쓴 줄', ' - 한 칸 들여 쓴 줄'],
['띄어쓰기 없는 머리', '##머리'],
['띄어쓰기 없는 줄', '-줄'],
])('쓸 수 없는 줄(%s)은 unknown-line', (_name, row) => {
expect(problemsOf(lines('# 제목', '- 한 줄', row))).toEqual([
{ line: 3, reason: 'unknown-line' },
]);
});
it('띄어쓰기 없는 제목(#제목)은 제목이 아니다 — 첫 글 줄이면 title-missing · unknown-line', () => {
expect(problemsOf(lines('#제목', '- 한 줄'))).toEqual([
{ line: 1, reason: 'title-missing' },
{ line: 1, reason: 'unknown-line' },
]);
});
it('이어 쓰기 — 앞에 - 줄이 없으면(제목 · 머리 바로 아래) continuation-without-item, 들여 쓴 - 는 nested-list', () => {
expect(
problemsOf(
lines(
'# 제목',
' 제목에 이어 씀',
'## 머리',
' 머리에 이어 씀',
'- 한 줄',
' - 하위 목록',
),
),
).toEqual([
{ line: 2, reason: 'continuation-without-item' },
{ line: 4, reason: 'continuation-without-item' },
{ line: 6, reason: 'nested-list' },
]);
});
it('메모 — 닫지 않으면 시작 줄에 memo-unclosed, 닫은 뒤 같은 줄에 글이 있으면 memo-trailing, 글 안의 <!-- 는 memo-inside-text', () => {
expect(
problemsOf(
lines(
'# 제목 <!-- 제목 메모 -->',
'- 한 줄 <!-- 줄 메모 -->',
'<!-- 메모 --> 뒤에 글',
'<!-- 여러 줄',
'x --> 닫은 뒤 글',
'- 둘째 줄',
'<!-- 닫지 않은 메모',
'- 메모 안이라 읽지 않는 줄',
),
),
).toEqual([
{ line: 1, reason: 'memo-inside-text' },
{ line: 2, reason: 'memo-inside-text' },
{ line: 3, reason: 'memo-trailing' },
{ line: 5, reason: 'memo-trailing' },
{ line: 7, reason: 'memo-unclosed' },
]);
});
it('보이지 않는 글자(제어 문자 · 줄 끝이 아닌 CR · 줄 구분 문자)는 control-character', () => {
expect(
problemsOf(
lines(
`# 제목${BELL}`,
`## 머리\r중간`,
`- 한 줄${LINE_SEPARATOR}둘째`,
'- 맞는 줄',
` 이어 씀${BELL}`,
),
),
).toEqual([
{ line: 1, reason: 'control-character' },
{ line: 2, reason: 'control-character' },
{ line: 3, reason: 'control-character' },
{ line: 5, reason: 'control-character' },
]);
});
it(`문제는 줄 차례로 앞의 ${RELEASE_NOTE_PROBLEMS_MAX}개까지 — 파일 전체의 문제가 먼저`, () => {
const many = Array.from({ length: 300 }, (_, index) => `맨 글 ${index}`);
const problems = problemsOf(lines('# 제목', ...many));
expect(problems).toHaveLength(RELEASE_NOTE_PROBLEMS_MAX);
expect(problems[0]).toEqual({ line: null, reason: 'no-items' });
expect(problems.slice(1)).toEqual(
Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 1 }, (_, index) => ({
line: index + 2,
reason: 'unknown-line',
})),
);
});
it('다 읽은 뒤에 찾는 앞쪽 줄의 문제(1행 제목 길이 · 3행 줄 없는 머리)도 뒤에 줄 문제가 많아 앞의 20개에 든다', () => {
const many = Array.from({ length: 500 }, (_, index) => `맨 글 ${index}`);
const problems = problemsOf(
lines(
`# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH + 1)}`,
'- 줄',
'## 줄 없는 머리',
...many,
),
);
expect(problems).toEqual([
{ line: 1, reason: 'title-too-long' },
{ line: 3, reason: 'heading-without-items' },
...Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 2 }, (_, index) => ({
line: index + 4,
reason: 'unknown-line',
})),
]);
});
it('문제를 글로 — 줄 번호 · 까닭 · 설명만 (글 내용은 없다)', () => {
expect(
describeReleaseNoteProblem({ line: 12, reason: 'item-too-long' }),
).toBe('12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))');
expect(describeReleaseNoteProblem({ line: null, reason: 'encoding' })).toBe(
'파일 encoding(UTF-8 글이 아닙니다)',
);
});
});
describe('파일 이름 · id · 날짜', () => {
it('글 파일 — 이름이 id, 앞 10자가 날짜, 같은 날 둘째부터 번호(2~99)', () => {
expect(classifyReleaseNoteFile('2026-11-18.md')).toEqual({
kind: 'note',
id: '2026-11-18',
date: '2026-11-18',
sequence: 1,
});
expect(classifyReleaseNoteFile('2026-11-18-2.md')).toEqual({
kind: 'note',
id: '2026-11-18-2',
date: '2026-11-18',
sequence: 2,
});
expect(classifyReleaseNoteFile('2026-11-18-99.md')).toMatchObject({
kind: 'note',
sequence: 99,
});
expect(classifyReleaseNoteFile('2024-02-29.md')).toMatchObject({
kind: 'note',
date: '2024-02-29',
});
});
it('쓰는 사람용 안내(README.md)는 글이 아니고 문제도 아니다', () => {
expect(classifyReleaseNoteFile('README.md')).toEqual({ kind: 'readme' });
});
it.each([
'2026-11-18-1.md',
'2026-11-18-0.md',
'2026-11-18-02.md',
'2026-11-18-100.md',
'2026-11-18-2-3.md',
'2026-11-18.MD',
'2026-11-18.txt',
'2026-11-18',
'2026-1-18.md',
'20261118.md',
'readme.md',
'notes.md',
'.gitkeep',
'.DS_Store',
])('글 파일 이름이 아니다: %s', (name) => {
expect(classifyReleaseNoteFile(name)).toEqual({
kind: 'not-a-note',
reason: 'file-name',
});
});
it.each([
'2026-02-30.md',
'2026-02-29.md',
'2100-02-29.md',
'2026-13-01.md',
'2026-00-10.md',
'2026-04-31.md',
'2026-11-00.md',
'0000-01-01.md',
])('없는 날짜: %s', (name) => {
expect(classifyReleaseNoteFile(name)).toEqual({
kind: 'not-a-note',
reason: 'date',
});
});
it('있는 날짜 — 윤년(4로 나누어지되 100은 아니고 400은 맞다)', () => {
expect(isCalendarDate(2024, 2, 29)).toBe(true);
expect(isCalendarDate(2000, 2, 29)).toBe(true);
expect(isCalendarDate(1900, 2, 29)).toBe(false);
expect(isCalendarDate(2026, 12, 31)).toBe(true);
expect(isCalendarDate(2026, 6, 31)).toBe(false);
});
it('경로 id 의 모양 — 맞는 모양만, 32자 넘거나 경로 · 확장자 · 다른 숫자 글자가 섞이면 아니다', () => {
for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-42']) {
expect(isReleaseNoteId(id)).toBe(true);
}
for (const id of [
'',
'2026-11-18-1',
'2026-11-18.md',
'../2026-11-18',
'2026-11-18/../x',
'2026-11-18 ',
`2026-11-18${'-2'.repeat(20)}`,
'2026-11-18',
'x'.repeat(33),
]) {
expect(isReleaseNoteId(id)).toBe(false);
}
for (const value of [20261118, null, undefined, ['2026-11-18']]) {
expect(isReleaseNoteId(value)).toBe(false);
}
});
it('차례 — 최근 글부터(날짜 내림차순), 같은 날은 뒤 번호가 먼저', () => {
const notes = [
{ id: '2026-11-03', date: '2026-11-03', sequence: 1 },
{ id: '2026-11-18', date: '2026-11-18', sequence: 1 },
{ id: '2026-11-18-10', date: '2026-11-18', sequence: 10 },
{ id: '2025-12-31', date: '2025-12-31', sequence: 1 },
{ id: '2026-11-18-2', date: '2026-11-18', sequence: 2 },
];
expect([...notes].sort(compareReleaseNotes).map((note) => note.id)).toEqual(
[
'2026-11-18-10',
'2026-11-18-2',
'2026-11-18',
'2026-11-03',
'2025-12-31',
],
);
});
});
describe('parseReleaseNoteText — 입력 길이에 비례', () => {
it('20만 줄(이어 쓰기 · 메모 · 줄)과 빈칸이 많은 1MB 한 줄도 금방 끝난다', () => {
const continuation = Array.from({ length: 200_000 }, () => ' 이어 씀');
const memo = Array.from({ length: 200_000 }, () => '메모 안 글');
const items = Array.from(
{ length: 100_000 },
(_, index) => `- 줄 ${index}`,
);
// 배열로 잇는다 — 수십만 줄을 함수 인자로 펼치면 호출 스택이 넘친다
const text = ['# 제목', '- 첫 줄']
.concat(continuation, ['<!-- 긴 메모'], memo, ['-->'], items, [
`- ${'가 \t'.repeat(300_000)}`,
])
.join('\n');
const cpuMs = startCpuTimer();
const result = parseReleaseNoteText(text);
// 지금 Linux 컨테이너에서 수백 ms — 줄마다 앞의 글을 다시 훑는(제곱) 길로 돌아가면 이 한도를 크게 넘는다
expect(cpuMs()).toBeLessThan(5_000);
expect(result.ok).toBe(false);
});
});
@@ -0,0 +1,448 @@
import {
RELEASE_NOTE_FILE_EXTENSION,
RELEASE_NOTE_FILE_MAX_BYTES,
RELEASE_NOTE_FIRST_SEQUENCE,
RELEASE_NOTE_HEADING_MAX_LENGTH,
RELEASE_NOTE_ID_MAX_LENGTH,
RELEASE_NOTE_ID_PATTERN,
RELEASE_NOTE_ITEM_MAX_LENGTH,
RELEASE_NOTE_PROBLEMS_MAX,
RELEASE_NOTE_TITLE_MAX_LENGTH,
RELEASE_NOTES_README,
} from './release-notes.constants';
import type { ReleaseNoteSection } from './release-notes.types';
/**
* 업데이트 내역 글 파일 해석 (순수 함수 — 계약 7절 "글 파일").
*
* 파일 이름 `YYYY-MM-DD.md`(같은 날 둘째부터 `YYYY-MM-DD-2.md` …)가 글 id · 날짜이고, 파일 안은 마크다운의 아주 작은 일부(글만)다:
* 첫 글 줄 `# 제목`(꼭 — 한 번만), `## 머리`(묶음 시작), `- 한 줄`(그 묶음의 줄 — 머리 없이 쓰면 머리 없는 묶음),
* 빈칸 두 개 이상(또는 탭)으로 시작한 줄은 앞 `- ` 줄에 띄어쓰기 하나로 이어 붙인다. 빈 줄 · `<!-- 메모 -->`(여러 줄도)는 어디든 —
* 메모는 줄 앞에서 시작한다(앞 빈칸은 괜찮다 · 글 뒤에 붙이지 않는다).
* 그 밖의 줄(맨 글 · `###` · 표 · 한 칸 들여 쓴 줄 등)은 틀림. 굵게 · 링크 꼴은 해석하지 않는다(글 그대로).
*
* 틀리면 줄 번호를 단 문제 목록을 돌려준다 — 시험(CI)은 실패하고 서버는 그 글만 건너뛴다. 글 내용은 문제에 싣지 않는다(로그에 남는다).
* 입력 길이에 비례한다 — 줄을 한 번씩만 보고, 정규식은 앞부분만 보거나 한 번 훑는다
*/
/** 문제의 까닭 → 쓰는 사람에게 보일 말 (로그 · 시험 출력 — 글 내용은 넣지 않는다) */
export const RELEASE_NOTE_PROBLEM_TEXTS = {
'file-name':
'글 파일 이름이 아닙니다 — YYYY-MM-DD.md 로, 같은 날 두 번째 글부터 YYYY-MM-DD-2.md 로 씁니다',
'not-a-file': '보통 파일이 아닙니다(폴더 · 링크)',
date: '없는 날짜입니다',
'too-large': `파일이 너무 큽니다(${RELEASE_NOTE_FILE_MAX_BYTES / 1024}KB까지)`,
unreadable: '파일을 읽지 못했습니다',
encoding: 'UTF-8 글이 아닙니다',
'title-missing': '첫 줄은 # 제목이어야 합니다',
'title-again': '# 제목은 첫 줄에 한 번만 씁니다',
'title-empty': '제목이 비었습니다',
'title-too-long': `제목은 ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지입니다`,
'heading-empty': '묶음 머리가 비었습니다',
'heading-too-long': `묶음 머리는 ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지입니다`,
'heading-without-items': '묶음 머리 아래에 줄(- )이 없습니다',
'item-empty': '줄이 비었습니다',
'item-too-long': `한 줄은 ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지입니다(이어 붙인 뒤)`,
'continuation-without-item': '이어 붙일 줄(- )이 앞에 없습니다',
'nested-list': '하위 목록은 쓸 수 없습니다',
'unknown-line':
'쓸 수 없는 줄입니다 — # 제목, ## 머리, - 한 줄, 빈칸 두 개로 시작한 이어 쓰기, <!-- 메모 --> 만 씁니다',
'memo-unclosed': '메모(<!--)를 닫지(-->) 않았습니다',
'memo-trailing': '메모를 닫은(-->) 뒤 같은 줄에 글이 있습니다',
'memo-inside-text':
'글 안에 메모(<!--)가 있습니다 — 메모는 글 뒤에 붙이지 않고 줄 앞에서 시작합니다',
'control-character': '보이지 않는 글자(제어 문자)가 있습니다',
'no-items': '글에 줄(- )이 하나도 없습니다',
} as const;
export type ReleaseNoteProblemReason = keyof typeof RELEASE_NOTE_PROBLEM_TEXTS;
/** 글 파일의 문제 하나 — 줄 번호는 1부터, 파일 전체의 문제(이름 · 크기 · 글자 · 줄 없음 등)면 null */
export interface ReleaseNoteProblem {
readonly line: number | null;
readonly reason: ReleaseNoteProblemReason;
}
// ── 파일 이름 · id ──────────────────────────────────────────────────────
/** `YYYY-MM-DD` 의 길이 — id 의 앞부분이 날짜다 */
const DATE_LENGTH = 'YYYY-MM-DD'.length;
const MONTHS_PER_YEAR = 12;
const DAYS_PER_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const FEBRUARY = 2;
interface ReleaseNoteIdParts {
readonly year: number;
readonly month: number;
readonly day: number;
readonly sequence: number;
}
/** id 를 날짜 · 차례 번호로 — 모양(길이 · 글자)이 틀리면 null. 있는 날짜인지는 보지 않는다 */
function releaseNoteIdParts(id: string): ReleaseNoteIdParts | null {
if (id.length > RELEASE_NOTE_ID_MAX_LENGTH) return null;
const match = RELEASE_NOTE_ID_PATTERN.exec(id);
if (!match) return null;
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
sequence:
match[4] === undefined ? RELEASE_NOTE_FIRST_SEQUENCE : Number(match[4]),
};
}
/** 경로의 글 id 모양인가 — 길이를 먼저 보고 정규식(앞에서 한 번 훑는다). 맞아도 그런 글이 있는지는 목록이 정한다 */
export function isReleaseNoteId(value: unknown): value is string {
return typeof value === 'string' && releaseNoteIdParts(value) !== null;
}
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
/** 있는 날짜인가 (그레고리력 — 2026-02-30 · 2026-13-01 · 0000-01-01 은 없다) */
export function isCalendarDate(
year: number,
month: number,
day: number,
): boolean {
if (!Number.isInteger(year) || year < 1) return false;
if (!Number.isInteger(month) || month < 1 || month > MONTHS_PER_YEAR) {
return false;
}
const days =
month === FEBRUARY && isLeapYear(year)
? DAYS_PER_MONTH[month - 1] + 1
: DAYS_PER_MONTH[month - 1];
return Number.isInteger(day) && day >= 1 && day <= days;
}
/** 글 폴더의 파일 이름을 가른다 — 안내(README) · 글 · 글이 아님(이름 모양 · 없는 날짜) */
export type ReleaseNoteFileKind =
| { readonly kind: 'readme' }
| {
readonly kind: 'note';
readonly id: string;
readonly date: string;
readonly sequence: number;
}
| { readonly kind: 'not-a-note'; readonly reason: 'file-name' | 'date' };
export function classifyReleaseNoteFile(fileName: string): ReleaseNoteFileKind {
if (fileName === RELEASE_NOTES_README) return { kind: 'readme' };
if (!fileName.endsWith(RELEASE_NOTE_FILE_EXTENSION)) {
return { kind: 'not-a-note', reason: 'file-name' };
}
const id = fileName.slice(0, -RELEASE_NOTE_FILE_EXTENSION.length);
const parts = releaseNoteIdParts(id);
if (parts === null) return { kind: 'not-a-note', reason: 'file-name' };
if (!isCalendarDate(parts.year, parts.month, parts.day)) {
return { kind: 'not-a-note', reason: 'date' };
}
return {
kind: 'note',
id,
date: id.slice(0, DATE_LENGTH),
sequence: parts.sequence,
};
}
/** 목록 차례 — 최근 글부터: 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`) */
export function compareReleaseNotes(
a: { readonly date: string; readonly sequence: number },
b: { readonly date: string; readonly sequence: number },
): number {
if (a.date !== b.date) return a.date < b.date ? 1 : -1;
return b.sequence - a.sequence;
}
// ── 파일 안 ─────────────────────────────────────────────────────────────
/** 줄의 꼴 — 앞부분만 본다(빈칸 두 개 · 탭으로 시작하면 이어 쓰기, `###` 은 쓰지 않는 줄) */
const CONTINUATION_LINE = /^(?:\t|[ \t]{2})/;
const DEEPER_HEADING_LINE = /^###/;
const HEADING_LINE = /^##(?:[ \t]|$)/;
const TITLE_LINE = /^#(?:[ \t]|$)/;
const ITEM_LINE = /^-(?:[ \t]|$)/;
const HEADING_MARKER_LENGTH = '##'.length;
const TITLE_MARKER_LENGTH = '#'.length;
const ITEM_MARKER_LENGTH = '-'.length;
const MEMO_START = '<!--';
const MEMO_END = '-->';
/** `<!-->` · `<!--->` 도 닫힌 메모다(HTML 과 같다) — 닫는 표시를 여는 표시의 셋째 글자부터 찾는다 */
const MEMO_END_SEARCH_OFFSET = 2;
/** 보이지 않는 글자 — C0(탭 빼고) · DEL · C1(아래 줄 · 문단 구분 문자와 함께). 화면에 글로 나가지 않게 틀린 글로 본다 */
const CONTROL_CHARACTER =
// eslint-disable-next-line no-control-regex
/[\u0000-\u0008\u000a-\u001f\u007f-\u009f]/;
/**
* 줄 · 문단 구분 문자(U+2028 · U+2029) — 보이지 않는 글자와 같이 본다.
* 글자 코드로 만든다 — 그 글자가 정규식 리터럴에 그대로 들어가면 줄 끝이라 문법 오류다(이스케이프가 풀린 채 저장되면)
*/
const LINE_SEPARATORS = [0x2028, 0x2029].map((code) =>
String.fromCharCode(code),
);
/** 보이지 않는 글자가 있는가 — 한 번씩 훑는다 */
function hasInvisibleCharacter(text: string): boolean {
return (
CONTROL_CHARACTER.test(text) ||
LINE_SEPARATORS.some((separator) => text.includes(separator))
);
}
/** 안의 빈칸 · 탭 여럿 — 하나로 (웹 · 앱이 같은 글을 그리게) */
const INNER_BLANKS = /[ \t]+/g;
/** 글 앞의 BOM(U+FEFF) — 지운다 (UTF-8 풀기가 이미 지우지만 해석만 따로 부를 때도) */
const BYTE_ORDER_MARK = String.fromCharCode(0xfeff);
const CARRIAGE_RETURN = '\r';
type LineKind = 'title' | 'heading' | 'item' | 'continuation' | 'other';
function lineKindOf(line: string): LineKind {
if (CONTINUATION_LINE.test(line)) return 'continuation';
if (DEEPER_HEADING_LINE.test(line)) return 'other';
if (HEADING_LINE.test(line)) return 'heading';
if (TITLE_LINE.test(line)) return 'title';
if (ITEM_LINE.test(line)) return 'item';
return 'other';
}
/** 화면에 나갈 글로 다듬는다 — NFC · 안의 빈칸 · 탭 여럿은 하나로 · 앞뒤 빈칸 지움 */
function tidy(text: string): string {
return text.normalize('NFC').replace(INNER_BLANKS, ' ').trim();
}
interface DraftItem {
readonly line: number;
readonly parts: string[];
}
interface DraftSection {
/** `## 머리` 의 줄 — 머리 없는 묶음은 null */
readonly line: number | null;
readonly heading: string | null;
readonly items: DraftItem[];
}
/** 해석 결과 — 맞으면 제목 · 묶음(다듬은 글), 틀리면 줄 차례의 문제(앞의 RELEASE_NOTE_PROBLEMS_MAX 개) */
export type ReleaseNoteTextParse =
| {
readonly ok: true;
readonly title: string;
readonly sections: readonly ReleaseNoteSection[];
}
| { readonly ok: false; readonly problems: readonly ReleaseNoteProblem[] };
/** 모아 둔 문제 하나와 차례 키(줄 번호 — 파일 전체의 문제는 0) */
interface KeptProblem {
readonly problem: ReleaseNoteProblem;
readonly order: number;
}
/**
* 문제 모음 — 줄 차례(파일 전체의 문제가 먼저 · 같은 줄은 찾은 차례)로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개만 늘 지킨다.
* 다 읽은 뒤에 더하는 앞쪽 줄의 문제(1행 제목 길이 · 머리 아래 줄 없음 등)도 그 자리에 들어가고 뒤의 것이 밀려난다.
* 메모리는 상한만큼 — 쓰레기 파일이어도 일정하다. 상한이 작아(20) 자리를 뒤에서부터 훑어도 문제마다 일정한 비용이다
*/
class ProblemList {
private readonly kept: KeptProblem[] = [];
private count = 0;
/** 문제가 하나라도 있었나 (지킨 것 밖으로 밀려난 것까지) */
get hasProblems(): boolean {
return this.count > 0;
}
add(line: number | null, reason: ReleaseNoteProblemReason): void {
this.count += 1;
const order = line ?? 0;
// 같은 줄은 먼저 찾은 것이 앞 — 새 것은 차례 키가 더 작을 때만 앞으로 간다
let index = this.kept.length;
while (index > 0 && order < this.kept[index - 1].order) index -= 1;
if (index >= RELEASE_NOTE_PROBLEMS_MAX) return;
this.kept.splice(index, 0, { problem: { line, reason }, order });
if (this.kept.length > RELEASE_NOTE_PROBLEMS_MAX) this.kept.pop();
}
/** 글 조각(제목 · 머리 · 줄)에 메모 · 보이지 않는 글자가 없는지 */
checkText(text: string, line: number): void {
if (text.includes(MEMO_START)) this.add(line, 'memo-inside-text');
if (hasInvisibleCharacter(text)) this.add(line, 'control-character');
}
/** 줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개 */
sorted(): ReleaseNoteProblem[] {
return this.kept.map((entry) => entry.problem);
}
}
/** 한 줄씩 읽으며 제목 · 묶음 · 줄을 모은다 (다듬기 · 길이는 다 읽은 뒤) */
class ReleaseNoteReader {
readonly problems = new ProblemList();
readonly sections: DraftSection[] = [];
/** 제목 — 첫 글 줄이 `# ` 가 아니면 null */
title: { readonly text: string; readonly line: number } | null = null;
/** 글 줄(빈 줄 · 메모 밖)을 하나라도 보았나 — 첫 글 줄이 제목이어야 한다 */
private sawContent = false;
private section: DraftSection | null = null;
/** 이어 쓰기가 붙을 줄 — 머리 · 제목 줄에서 끊긴다(빈 줄 · 메모는 끊지 않는다) */
private item: DraftItem | null = null;
/** 열린 메모의 시작 줄 */
private memoLine: number | null = null;
read(text: string): void {
const source = text.startsWith(BYTE_ORDER_MARK) ? text.slice(1) : text;
const lines = source.split('\n');
for (let index = 0; index < lines.length; index += 1) {
const raw = lines[index];
const line = raw.endsWith(CARRIAGE_RETURN) ? raw.slice(0, -1) : raw;
this.readLine(line, index + 1);
}
if (this.memoLine !== null)
this.problems.add(this.memoLine, 'memo-unclosed');
if (!this.sawContent) this.problems.add(null, 'title-missing');
}
private readLine(line: string, lineNumber: number): void {
if (this.memoLine !== null) {
const end = line.indexOf(MEMO_END);
if (end < 0) return;
this.memoLine = null;
this.checkAfterMemo(line, end, lineNumber);
return;
}
const trimmed = line.trim();
if (trimmed === '') return;
if (trimmed.startsWith(MEMO_START)) {
const start = line.indexOf(MEMO_START);
const end = line.indexOf(MEMO_END, start + MEMO_END_SEARCH_OFFSET);
if (end < 0) {
this.memoLine = lineNumber;
return;
}
this.checkAfterMemo(line, end, lineNumber);
return;
}
const kind = lineKindOf(line);
if (!this.sawContent) {
this.sawContent = true;
if (kind === 'title') {
const text = line.slice(TITLE_MARKER_LENGTH);
this.problems.checkText(text, lineNumber);
this.title = { text, line: lineNumber };
return;
}
this.problems.add(lineNumber, 'title-missing');
}
switch (kind) {
case 'title':
this.problems.add(lineNumber, 'title-again');
this.item = null;
return;
case 'heading': {
const heading = line.slice(HEADING_MARKER_LENGTH);
this.problems.checkText(heading, lineNumber);
this.section = { line: lineNumber, heading, items: [] };
this.sections.push(this.section);
this.item = null;
return;
}
case 'item': {
const text = line.slice(ITEM_MARKER_LENGTH);
this.problems.checkText(text, lineNumber);
if (this.section === null) {
// 머리 없이 바로 쓴 줄 — 머리 없는 묶음 (글의 맨 앞에만 생긴다)
this.section = { line: null, heading: null, items: [] };
this.sections.push(this.section);
}
this.item = { line: lineNumber, parts: [text.trim()] };
this.section.items.push(this.item);
return;
}
case 'continuation':
if (this.item === null) {
this.problems.add(lineNumber, 'continuation-without-item');
} else if (ITEM_LINE.test(trimmed)) {
// 들여 쓴 `- ` — 마크다운에서는 하위 목록이지만 화면은 한 단계만 그린다
this.problems.add(lineNumber, 'nested-list');
} else {
this.problems.checkText(trimmed, lineNumber);
this.item.parts.push(trimmed);
}
return;
case 'other':
this.problems.add(lineNumber, 'unknown-line');
return;
}
}
/** 메모를 닫은 뒤 같은 줄에는 빈칸만 */
private checkAfterMemo(line: string, end: number, lineNumber: number): void {
if (line.slice(end + MEMO_END.length).trim() !== '') {
this.problems.add(lineNumber, 'memo-trailing');
}
}
}
/**
* 글 파일의 글(UTF-8 을 푼 것)을 해석한다 — 앞의 BOM · 줄 끝의 CR 은 지운다.
* 맞으면 다듬은 제목 · 묶음(파일에 쓴 차례), 틀리면 줄 번호를 단 문제(줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개)
*/
export function parseReleaseNoteText(text: string): ReleaseNoteTextParse {
const reader = new ReleaseNoteReader();
reader.read(text);
const { problems } = reader;
let title = '';
if (reader.title !== null) {
title = tidy(reader.title.text);
if (title === '') problems.add(reader.title.line, 'title-empty');
if (title.length > RELEASE_NOTE_TITLE_MAX_LENGTH) {
problems.add(reader.title.line, 'title-too-long');
}
}
let itemCount = 0;
const sections = reader.sections.map((draft): ReleaseNoteSection => {
let heading: string | null = null;
if (draft.heading !== null && draft.line !== null) {
heading = tidy(draft.heading);
if (heading === '') problems.add(draft.line, 'heading-empty');
if (heading.length > RELEASE_NOTE_HEADING_MAX_LENGTH) {
problems.add(draft.line, 'heading-too-long');
}
if (draft.items.length === 0) {
problems.add(draft.line, 'heading-without-items');
}
}
const items = draft.items.map((item) => {
const text = tidy(item.parts.join(' '));
if (text === '') problems.add(item.line, 'item-empty');
if (text.length > RELEASE_NOTE_ITEM_MAX_LENGTH) {
problems.add(item.line, 'item-too-long');
}
return text;
});
itemCount += items.length;
return { heading, items };
});
if (itemCount === 0) problems.add(null, 'no-items');
if (problems.hasProblems) {
return { ok: false, problems: problems.sorted() };
}
return { ok: true, title, sections };
}
/** 문제 하나를 로그 · 시험 출력용 글로 — `12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))` (글 내용은 없다) */
export function describeReleaseNoteProblem(
problem: ReleaseNoteProblem,
): string {
const where = problem.line === null ? '파일' : `${problem.line}행`;
return `${where} ${problem.reason}(${RELEASE_NOTE_PROBLEM_TEXTS[problem.reason]})`;
}
@@ -0,0 +1,34 @@
import { BusinessException } from '@/common/errors/business.exception';
import { ReleaseNoteIdPipe } from './release-note-id.pipe';
describe('ReleaseNoteIdPipe — 경로의 글 id', () => {
const pipe = new ReleaseNoteIdPipe();
it('글 id 모양이면 그대로', () => {
for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-99']) {
expect(pipe.transform(id)).toBe(id);
}
});
it('모양이 틀리면 400 이 아니라 404 RES_001 — 없는 글과 같다(경로로 쓰지 않는다)', () => {
for (const value of [
'',
'latest',
'2026-11-18-1',
'2026-11-18.md',
'../../etc/passwd',
'..%2F2026-11-18',
'x'.repeat(33),
42,
undefined,
]) {
let code: string | undefined;
try {
pipe.transform(value);
} catch (error) {
if (error instanceof BusinessException) code = error.code;
}
expect(code).toBe('RES_001');
}
});
});
@@ -0,0 +1,19 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { isReleaseNoteId } from './release-note-format';
/**
* 경로의 업데이트 내역 글 id (`/api/release-notes/{id}`) — `YYYY-MM-DD` · `YYYY-MM-DD-N`(N 은 2~99) · 32자까지.
* 모양이 틀린 id 도 "그런 글이 없다"와 같으므로 400 이 아니라 404 RES_001 이다(손으로 고친 주소도 화면은 "없는 글").
* 파일 경로로 쓰지 않는다 — 서비스가 읽어 둔 목록에서 찾는다(경로 탈출 없음)
*/
@Injectable()
export class ReleaseNoteIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
if (isReleaseNoteId(value)) return value;
throw new BusinessException('RES_001', {
context: { reason: 'malformed-release-note-id' },
});
}
}
@@ -0,0 +1,347 @@
import { Logger } from '@nestjs/common';
import {
mkdirSync,
mkdtempSync,
rmSync,
statSync,
symlinkSync,
unlinkSync,
utimesSync,
writeFileSync,
} from 'fs';
import { open } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { RELEASE_NOTE_FILE_MAX_BYTES } from './release-notes.constants';
import {
ReleaseNotesFolder,
readReleaseNoteFile,
readUpTo,
} from './release-notes-folder';
/** 맞는 글 하나 — 제목 · 줄 */
const note = (title: string, item = '한 줄') => `# ${title}\n- ${item}\n`;
describe('ReleaseNotesFolder — 글 폴더 읽기', () => {
let dir: string;
let errorLog: jest.SpyInstance;
let warnLog: jest.SpyInstance;
const pathOf = (name: string) => join(dir, name);
const write = (name: string, text: string | Buffer) =>
writeFileSync(pathOf(name), text);
/** 바뀐 시각을 옮긴다 (초) — 같은 크기로 고친 파일을 "바뀌었다"고 알리게 */
const touch = (name: string, seconds: number) => {
const { atime, mtime } = statSync(pathOf(name));
utimesSync(
pathOf(name),
atime,
new Date(mtime.getTime() + seconds * 1_000),
);
};
const loggedText = (spy: jest.SpyInstance) =>
spy.mock.calls.map((call: unknown[]) => String(call[0])).join('\n');
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-'));
errorLog = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
warnLog = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('글 파일만 최근 글부터 읽는다 — 쓰는 사람용 안내(README.md)는 건너뛰고 로그도 없다', async () => {
write('README.md', '# 안내\n아무 글이나 써도 된다\n');
write('2026-11-05.md', note('화면의 안내 글을 다듬었습니다'));
write('2026-11-18.md', note('휴대폰 앱이 나왔습니다'));
write('2026-11-18-2.md', note('같은 날 두 번째 글'));
const snapshot = await new ReleaseNotesFolder(dir).read();
expect(snapshot.problems).toEqual([]);
expect(snapshot.notes).toEqual([
{
id: '2026-11-18-2',
date: '2026-11-18',
sequence: 2,
title: '같은 날 두 번째 글',
sections: [{ heading: null, items: ['한 줄'] }],
},
{
id: '2026-11-18',
date: '2026-11-18',
sequence: 1,
title: '휴대폰 앱이 나왔습니다',
sections: [{ heading: null, items: ['한 줄'] }],
},
{
id: '2026-11-05',
date: '2026-11-05',
sequence: 1,
title: '화면의 안내 글을 다듬었습니다',
sections: [{ heading: null, items: ['한 줄'] }],
},
]);
expect(errorLog).not.toHaveBeenCalled();
expect(warnLog).not.toHaveBeenCalled();
});
it('이름 · 크기 · 바뀐 시각이 같으면 앞서 읽은 결과를 쓴다 — 하나라도 바뀌면 그 파일을 다시 읽는다', async () => {
// 바뀐 시각을 초 단위의 같은 값으로 둔다 — 파일 시스템의 ns 시각을 그대로 되돌리지는 못한다
const fixed = new Date('2026-11-18T00:00:00.000Z');
const folder = new ReleaseNotesFolder(dir);
write('2026-11-18.md', note('가나다'));
utimesSync(pathOf('2026-11-18.md'), fixed, fixed);
const first = await folder.read();
// 아무것도 바뀌지 않았다 — 앞의 결과 그대로
expect(await folder.read()).toBe(first);
// 같은 크기 · 같은 바뀐 시각으로 글만 고치면 알아채지 못한다 — 이름 · 크기 · 시각만 본다는 증거
write('2026-11-18.md', note('가나라'));
utimesSync(pathOf('2026-11-18.md'), fixed, fixed);
expect((await folder.read()).notes[0].title).toBe('가나다');
// 바뀐 시각이 다르면 다시 읽는다
touch('2026-11-18.md', 10);
expect((await folder.read()).notes[0].title).toBe('가나라');
// 새 파일 · 지운 파일
write('2026-11-19.md', note('새 글'));
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-19',
'2026-11-18',
]);
unlinkSync(pathOf('2026-11-18.md'));
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-19',
]);
});
it('틀린 글 · 글이 아닌 파일 · 폴더는 건너뛰고 바뀔 때마다 error 로그 한 번 — 파일 이름 · 줄 번호 · 까닭만(글 내용은 없다)', async () => {
const folder = new ReleaseNotesFolder(dir);
write('2026-11-18.md', note('맞는 글'));
write('2026-11-19.md', '# 틀린 글\n비밀스러운 맨 글\n- 한 줄\n');
write('notes.txt', '메모');
write('2026-02-30.md', note('없는 날짜'));
mkdirSync(pathOf('2026-11-20.md'));
const snapshot = await folder.read();
expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']);
expect(snapshot.problems).toEqual([
{ file: '2026-02-30.md', line: null, reason: 'date' },
{ file: '2026-11-19.md', line: 2, reason: 'unknown-line' },
{ file: '2026-11-20.md', line: null, reason: 'not-a-file' },
{ file: 'notes.txt', line: null, reason: 'file-name' },
]);
expect(errorLog).toHaveBeenCalledTimes(4);
const logged = loggedText(errorLog);
expect(logged).toContain('file="2026-11-19.md"');
expect(logged).toContain('2행 unknown-line');
expect(logged).toContain('[SYS_001]');
expect(logged).not.toContain('비밀스러운');
expect(logged).not.toContain('틀린 글');
// 그대로면 다시 남기지 않는다
await folder.read();
expect(errorLog).toHaveBeenCalledTimes(4);
// 그 파일이 바뀌면 그 파일만 다시 읽고 다시 남긴다
touch('2026-11-19.md', 10);
await folder.read();
expect(errorLog).toHaveBeenCalledTimes(5);
// 고치면 목록에 들어온다
write('2026-11-19.md', note('고친 글'));
touch('2026-11-19.md', 20);
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-19',
'2026-11-18',
]);
expect(errorLog).toHaveBeenCalledTimes(5);
});
it('UTF-8 이 아닌 글은 encoding, 너무 큰 파일은 읽지 않고 too-large', async () => {
// EUC-KR 의 "가"(0xB0 0xA1) — UTF-8 로는 틀린 바이트
write('2026-11-18.md', Buffer.from([0x23, 0x20, 0xb0, 0xa1, 0x0a]));
write(
'2026-11-19.md',
`# 큰 글\n- ${'가'.repeat(RELEASE_NOTE_FILE_MAX_BYTES)}\n`,
);
const snapshot = await new ReleaseNotesFolder(dir).read();
expect(snapshot.notes).toEqual([]);
expect(snapshot.problems).toEqual([
{ file: '2026-11-18.md', line: null, reason: 'encoding' },
{ file: '2026-11-19.md', line: null, reason: 'too-large' },
]);
});
it('폴더가 없으면 빈 목록 + warn 한 번 — 생기면 읽고, 또 없어지면 다시 warn', async () => {
const missing = join(dir, 'release-notes');
const folder = new ReleaseNotesFolder(missing);
expect(await folder.read()).toEqual({ notes: [], problems: [] });
expect(await folder.read()).toEqual({ notes: [], problems: [] });
expect(warnLog).toHaveBeenCalledTimes(1);
expect(loggedText(warnLog)).toContain(missing);
mkdirSync(missing);
writeFileSync(join(missing, '2026-11-18.md'), note('생긴 글'));
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-18',
]);
rmSync(missing, { recursive: true });
expect(await folder.read()).toEqual({ notes: [], problems: [] });
expect(warnLog).toHaveBeenCalledTimes(2);
expect(errorLog).not.toHaveBeenCalled();
});
it('함께 온 요청은 한 번 읽은 결과를 나눈다 — 틀린 글의 로그도 한 번', async () => {
write('2026-11-18.md', note('맞는 글'));
write('2026-11-19.md', '맨 글\n');
const folder = new ReleaseNotesFolder(dir);
const [a, b, c] = await Promise.all([
folder.read(),
folder.read(),
folder.read(),
]);
expect(b).toBe(a);
expect(c).toBe(a);
expect(errorLog).toHaveBeenCalledTimes(1);
});
it('링크로 둔 글 파일은 따라가지 않고 not-a-file (링크를 만들 수 없는 곳에서는 건너뛴다)', async () => {
const outside = mkdtempSync(join(tmpdir(), 'arasystem-release-outside-'));
try {
writeFileSync(join(outside, 'target.md'), note('폴더 밖의 글'));
if (!trySymlink(join(outside, 'target.md'), pathOf('2026-11-21.md'))) {
return;
}
write('2026-11-18.md', note('맞는 글'));
const snapshot = await new ReleaseNotesFolder(dir).read();
expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']);
expect(snapshot.problems).toEqual([
{ file: '2026-11-21.md', line: null, reason: 'not-a-file' },
]);
expect(await readReleaseNoteFile(pathOf('2026-11-21.md'))).toEqual(
// Windows 에는 O_NOFOLLOW 가 없다 — 그곳에서 링크를 만들 수 있었으면 여기서 따라간다(Linux · CI 가 본다)
process.platform === 'win32'
? expect.objectContaining({ ok: true })
: { ok: false, reason: 'not-a-file' },
);
} finally {
rmSync(outside, { recursive: true, force: true });
}
});
it('기동 때 한 번 읽어 틀린 글을 바로 로그에 남긴다 — 읽지 못해도 던지지 않는다', async () => {
write('2026-11-19.md', '맨 글\n');
const folder = new ReleaseNotesFolder(dir);
await folder.onApplicationBootstrap();
expect(errorLog).toHaveBeenCalledTimes(1);
expect(loggedText(errorLog)).toContain('file="2026-11-19.md"');
// 요청 때는 기동 때 읽은 결과 그대로 — 다시 남기지 않는다
await folder.read();
expect(errorLog).toHaveBeenCalledTimes(1);
jest.spyOn(folder, 'read').mockRejectedValue(new Error('EACCES 흉내'));
await expect(folder.onApplicationBootstrap()).resolves.toBeUndefined();
expect(errorLog).toHaveBeenCalledTimes(2);
expect(loggedText(errorLog)).toContain('기동 때 읽지 못했습니다');
});
});
/** 링크를 만든다 — 권한이 없는 곳(Windows 의 보통 계정)에서는 false */
function trySymlink(target: string, path: string): boolean {
try {
symlinkSync(target, path);
return true;
} catch {
return false;
}
}
describe('readReleaseNoteFile — 한 번 열어 그 파일로만 본다', () => {
let dir: string;
const pathOf = (name: string) => join(dir, name);
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-file-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('일반 파일은 바이트 그대로', async () => {
writeFileSync(pathOf('2026-11-18.md'), note('글'));
const read = await readReleaseNoteFile(pathOf('2026-11-18.md'));
expect(read).toEqual({ ok: true, bytes: Buffer.from(note('글')) });
});
it('폴더는 not-a-file, 없는 파일은 unreadable', async () => {
mkdirSync(pathOf('2026-11-19.md'));
expect(await readReleaseNoteFile(pathOf('2026-11-19.md'))).toEqual({
ok: false,
reason: 'not-a-file',
});
expect(await readReleaseNoteFile(pathOf('2026-11-20.md'))).toEqual({
ok: false,
reason: 'unreadable',
});
});
it('크기 한도를 넘으면 연 파일의 크기(fstat)로 too-large — 한도 그대로는 읽는다', async () => {
writeFileSync(
pathOf('large.md'),
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES + 1, 0x61),
);
writeFileSync(
pathOf('limit.md'),
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES, 0x61),
);
expect(await readReleaseNoteFile(pathOf('large.md'))).toEqual({
ok: false,
reason: 'too-large',
});
const limit = await readReleaseNoteFile(pathOf('limit.md'));
expect(limit.ok && limit.bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES);
});
it('readUpTo 는 정한 바이트까지만 읽는다 — 그사이 커진 파일도 끝까지 읽지 않는다', async () => {
writeFileSync(
pathOf('grown.md'),
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES * 2, 0x61),
);
const handle = await open(pathOf('grown.md'), 'r');
try {
const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1);
expect(bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES + 1);
expect((await readUpTo(handle, 10)).toString()).toBe('a'.repeat(10));
} finally {
await handle.close();
}
});
});
@@ -0,0 +1,333 @@
import { Logger, type OnApplicationBootstrap } from '@nestjs/common';
import { constants } from 'fs';
import { lstat, open, readdir, type FileHandle } from 'fs/promises';
import { join } from 'path';
import { traceTag } from '@/shared/logger/request-context';
import {
classifyReleaseNoteFile,
compareReleaseNotes,
describeReleaseNoteProblem,
parseReleaseNoteText,
type ReleaseNoteProblem,
type ReleaseNoteProblemReason,
} from './release-note-format';
import {
RELEASE_NOTE_FILE_MAX_BYTES,
RELEASE_NOTES_DIR,
} from './release-notes.constants';
import type { ReleaseNote } from './release-notes.types';
/** 건너뛴 파일의 문제 하나 — 파일 이름 · 줄 번호(파일 전체면 null) · 까닭. 글 내용은 없다 */
export interface ReleaseNoteFileProblem {
readonly file: string;
readonly line: number | null;
readonly reason: ReleaseNoteProblemReason;
}
/** 폴더를 읽은 결과 — 글(최근 글부터)과 건너뛴 파일의 문제(파일 이름 차례) */
export interface ReleaseNotesSnapshot {
readonly notes: readonly ReleaseNote[];
readonly problems: readonly ReleaseNoteFileProblem[];
}
const EMPTY_SNAPSHOT: ReleaseNotesSnapshot = Object.freeze({
notes: Object.freeze([]),
problems: Object.freeze([]),
});
/** 폴더에서 본 이름 하나 — 이름 · 보통 파일인지 · 크기 · 바뀐 시각(ms) */
interface FolderEntry {
readonly name: string;
readonly isFile: boolean;
readonly size: number;
readonly mtimeMs: number;
}
/** 이름 하나를 읽은 결과 — 글 · 안내(README — 건너뜀) · 문제 */
type EntryOutcome =
| { readonly kind: 'note'; readonly note: ReleaseNote }
| { readonly kind: 'skipped' }
| {
readonly kind: 'problems';
readonly problems: readonly ReleaseNoteProblem[];
};
interface CachedEntry {
readonly entry: FolderEntry;
readonly outcome: EntryOutcome;
}
/** UTF-8 만 — 틀린 바이트가 있으면 던진다(EUC-KR 로 저장한 글을 깨진 글로 내보내지 않게). 앞의 BOM 은 지운다 */
const UTF8 = new TextDecoder('utf-8', { fatal: true });
/**
* 글 파일 열기 — 링크를 따라가지 않고(O_NOFOLLOW — 그 경로가 링크면 ELOOP), 파이프 · 장치를 열 때 멈추지 않게(O_NONBLOCK — 일반 파일 읽기에는 영향이 없다).
* 일반 파일인지 · 크기는 연 뒤 fstat 으로 본다. Windows 에는 두 표시가 없다(0) — modules/app-releases/apk-file.ts 와 같다
*/
const OPEN_FLAGS =
constants.O_RDONLY |
(constants.O_NOFOLLOW ?? 0) |
(constants.O_NONBLOCK ?? 0);
function errorCodeOf(error: unknown): string | undefined {
return (error as NodeJS.ErrnoException | null)?.code;
}
function reasonOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** 글 파일 하나를 읽은 결과 — 바이트, 또는 글로 읽지 않는 까닭 */
export type ReleaseNoteFileRead =
| { readonly ok: true; readonly bytes: Buffer }
| {
readonly ok: false;
readonly reason: 'not-a-file' | 'too-large' | 'unreadable';
};
/** 연 파일의 처음부터 `limit` 바이트까지만 읽는다 — 더 길어도 거기서 멈춘다(넘었는지는 부르는 쪽이 길이로 본다) */
export async function readUpTo(
handle: FileHandle,
limit: number,
): Promise<Buffer> {
const buffer = Buffer.alloc(limit);
let filled = 0;
while (filled < limit) {
const { bytesRead } = await handle.read(
buffer,
filled,
limit - filled,
filled,
);
if (bytesRead === 0) break;
filled += bytesRead;
}
return buffer.subarray(0, filled);
}
/** 열지 못한 까닭 — 링크(ELOOP) · 폴더 · 특수 파일은 글이 아니고, 그 밖(권한 · 그사이 지워짐 등)은 읽지 못함 */
function openFailureReason(error: unknown): 'not-a-file' | 'unreadable' {
switch (errorCodeOf(error)) {
case 'ELOOP':
case 'EISDIR':
case 'ENXIO':
return 'not-a-file';
default:
return 'unreadable';
}
}
/**
* 글 파일을 한 번 열어(OPEN_FLAGS) 그 열린 파일로만 본다 — 일반 파일인지 · 크기(fstat), 그리고 크기 한도 + 1 바이트까지만 읽는다.
* 이름을 본(lstat) 뒤 같은 경로가 링크로 바뀌어도 따라가지 않고, 그사이 커진 파일도 끝까지 읽지 않는다
*/
export async function readReleaseNoteFile(
path: string,
): Promise<ReleaseNoteFileRead> {
let handle: FileHandle;
try {
handle = await open(path, OPEN_FLAGS);
} catch (error) {
return { ok: false, reason: openFailureReason(error) };
}
try {
const stats = await handle.stat();
if (!stats.isFile()) return { ok: false, reason: 'not-a-file' };
if (stats.size > RELEASE_NOTE_FILE_MAX_BYTES) {
return { ok: false, reason: 'too-large' };
}
const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1);
if (bytes.length > RELEASE_NOTE_FILE_MAX_BYTES) {
return { ok: false, reason: 'too-large' };
}
return { ok: true, bytes };
} catch {
return { ok: false, reason: 'unreadable' };
} finally {
await handle.close();
}
}
function problemsOf(reason: ReleaseNoteProblemReason): EntryOutcome {
return { kind: 'problems', problems: [{ line: null, reason }] };
}
function sameEntry(a: FolderEntry, b: FolderEntry): boolean {
return a.isFile === b.isFile && a.size === b.size && a.mtimeMs === b.mtimeMs;
}
/** 읽어 둔 결과로 글 목록 · 문제를 만든다 — 캐시는 파일 이름 차례로 넣었다 */
function snapshotOf(
cache: ReadonlyMap<string, CachedEntry>,
): ReleaseNotesSnapshot {
const notes: ReleaseNote[] = [];
const problems: ReleaseNoteFileProblem[] = [];
for (const [file, { outcome }] of cache) {
if (outcome.kind === 'note') notes.push(outcome.note);
if (outcome.kind === 'problems') {
for (const { line, reason } of outcome.problems) {
problems.push({ file, line, reason });
}
}
}
notes.sort(compareReleaseNotes);
return { notes, problems };
}
/**
* 업데이트 내역 글 폴더 (계약 7절 "읽기") — 요청 때 폴더를 본다: 파일 이름 · 크기 · 바뀐 시각이 앞과 같으면 앞서 읽은 결과를 쓴다
* (폴더가 통째로 그대로면 앞의 결과 그대로 — 파일 수십 개라 이름 보기 · lstat 비용은 작다). 개발 서버는 다시 띄우지 않아도 새 글이 보이고,
* 운영은 이미지 안의 폴더라 배포 때 바뀐다. 함께 온 요청은 한 번 읽은 결과를 나눈다.
*
* 틀린 글 · 글이 아닌 파일(다른 이름 · 폴더 · 링크)은 건너뛰고 바뀔 때마다 error 로그 한 번(파일 이름 · 줄 번호 · 까닭 — 글 내용은 싣지 않는다).
* 기동은 막지 않는다 — 기동 때 한 번 읽어 틀린 글의 로그가 배포 직후에 보인다. 폴더가 없으면 빈 목록 + warn 한 번.
* 시험(CI)은 실제 폴더를 읽어 문제가 하나도 없어야 한다(release-notes.content.spec.ts).
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 시험은 임시 폴더를 넘긴다
*/
export class ReleaseNotesFolder implements OnApplicationBootstrap {
private readonly logger = new Logger(ReleaseNotesFolder.name);
private cache = new Map<string, CachedEntry>();
/** 앞서 본 폴더의 모양(이름 · 크기 · 바뀐 시각) — 같으면 앞의 결과를 그대로 */
private signature: string | null = null;
private snapshot: ReleaseNotesSnapshot = EMPTY_SNAPSHOT;
private loading: Promise<ReleaseNotesSnapshot> | null = null;
private warnedMissing = false;
constructor(readonly dir: string = RELEASE_NOTES_DIR) {}
/**
* 기동 때 한 번 읽는다 — 틀린 글의 error 로그가 첫 요청이 아니라 배포 직후의 로그에 보이게.
* 읽지 못해도 던지지 않는다(기동을 막지 않는다 — 요청 때 다시 읽는다)
*/
async onApplicationBootstrap(): Promise<void> {
try {
await this.read();
} catch (error) {
this.logger.error(
`[SYS_001] 업데이트 내역 폴더를 기동 때 읽지 못했습니다 — 요청 때 다시 읽습니다: dir=${this.dir}, reason=${reasonOf(error)}`,
);
}
}
/** 지금 폴더의 글 · 문제 — 읽는 중이면 그 결과를 함께 기다린다 */
read(): Promise<ReleaseNotesSnapshot> {
if (this.loading !== null) return this.loading;
const loading = this.load().finally(() => {
this.loading = null;
});
this.loading = loading;
return loading;
}
private async load(): Promise<ReleaseNotesSnapshot> {
const entries = await this.listEntries();
if (entries === null) return this.folderMissing();
this.warnedMissing = false;
const signature = JSON.stringify(entries);
if (signature === this.signature) return this.snapshot;
const next = new Map<string, CachedEntry>();
for (const entry of entries) {
const cached = this.cache.get(entry.name);
next.set(
entry.name,
cached !== undefined && sameEntry(cached.entry, entry)
? cached
: { entry, outcome: await this.readEntry(entry) },
);
}
this.cache = next;
this.signature = signature;
this.snapshot = snapshotOf(next);
return this.snapshot;
}
/** 폴더의 이름들(이름 차례) · 크기 · 바뀐 시각 — 폴더가 없으면 null. 링크는 따라가지 않는다(lstat) */
private async listEntries(): Promise<FolderEntry[] | null> {
let names: string[];
try {
names = await readdir(this.dir);
} catch (error) {
const code = errorCodeOf(error);
if (code === 'ENOENT' || code === 'ENOTDIR') return null;
throw error;
}
names.sort();
const entries: FolderEntry[] = [];
for (const name of names) {
try {
const stats = await lstat(join(this.dir, name));
entries.push({
name,
isFile: stats.isFile(),
size: stats.size,
mtimeMs: stats.mtimeMs,
});
} catch (error) {
// 이름을 본 뒤 지워졌다 — 없는 것으로
if (errorCodeOf(error) === 'ENOENT') continue;
throw error;
}
}
return entries;
}
/** 바뀐(또는 처음 본) 이름 하나를 읽는다 — 문제가 있으면 여기서 한 번 error 로그 */
private async readEntry(entry: FolderEntry): Promise<EntryOutcome> {
const outcome = await this.readOutcome(entry);
if (outcome.kind === 'problems') {
this.logger.error(
`[SYS_001]${traceTag()} 업데이트 내역 글을 건너뜁니다 — 글 파일을 고쳐 주세요: file=${JSON.stringify(entry.name)}, problems=${outcome.problems.map(describeReleaseNoteProblem).join(' · ')}`,
);
}
return outcome;
}
private async readOutcome(entry: FolderEntry): Promise<EntryOutcome> {
const kind = classifyReleaseNoteFile(entry.name);
if (kind.kind === 'readme') return { kind: 'skipped' };
if (!entry.isFile) return problemsOf('not-a-file');
if (kind.kind === 'not-a-note') return problemsOf(kind.reason);
if (entry.size > RELEASE_NOTE_FILE_MAX_BYTES)
return problemsOf('too-large');
// 이름을 본 뒤 링크로 바뀌었거나 커졌을 수 있다 — 연 파일로 다시 본다
const read = await readReleaseNoteFile(join(this.dir, entry.name));
if (!read.ok) return problemsOf(read.reason);
let text: string;
try {
text = UTF8.decode(read.bytes);
} catch {
return problemsOf('encoding');
}
const parsed = parseReleaseNoteText(text);
if (!parsed.ok) return { kind: 'problems', problems: parsed.problems };
return {
kind: 'note',
note: {
id: kind.id,
date: kind.date,
sequence: kind.sequence,
title: parsed.title,
sections: parsed.sections,
},
};
}
/** 폴더가 없다 — 빈 목록, warn 은 없어질 때마다 한 번 */
private folderMissing(): ReleaseNotesSnapshot {
if (!this.warnedMissing) {
this.warnedMissing = true;
this.logger.warn(
`[SYS_001]${traceTag()} 업데이트 내역 폴더가 없어 빈 목록으로 답합니다: dir=${this.dir}`,
);
}
this.cache = new Map();
this.signature = null;
this.snapshot = EMPTY_SNAPSHOT;
return EMPTY_SNAPSHOT;
}
}
@@ -0,0 +1,54 @@
import { join } from 'path';
/**
* 업데이트 내역 (계약 7절 "업데이트 내역") — 한도 · 형식 · 자리를 한 곳에 모은다.
* 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판이고, 글은 운영자가 저장소의 글 파일로 쓴다(올리는 API 는 없다).
* 글자 수는 앞뒤 빈칸을 지운 값의 JS 문자열 길이(UTF-16 코드 단위 — 웹의 `.length` 와 같다)로 센다.
*/
/** 라우트 경로 (`/api/release-notes`) */
export const RELEASE_NOTES_ROUTE = 'release-notes';
/**
* 글 폴더 — 저장소의 `backend/release-notes/`(src 밖). `__dirname` 기준이라 ts(src/modules/release-notes)와
* 빌드 산출물(dist/modules/release-notes) 양쪽에서 같은 곳이다 — 운영 이미지는 `/app/release-notes`(backend/Dockerfile 의 COPY),
* 개발 컨테이너는 backend 를 `/app` 에 바인드 마운트하므로 파일을 더하면 다시 띄우지 않아도 보인다
*/
export const RELEASE_NOTES_DIR = join(
__dirname,
'..',
'..',
'..',
'release-notes',
);
/** 쓰는 사람용 안내 — 글이 아니라 건너뛴다(로그도 남기지 않는다) */
export const RELEASE_NOTES_README = 'README.md';
/** 글 파일의 확장자 — 이름은 `YYYY-MM-DD.md` · 같은 날 둘째부터 `YYYY-MM-DD-2.md` … (이름 = 글 id) */
export const RELEASE_NOTE_FILE_EXTENSION = '.md';
/**
* 글 id — 글 파일 이름에서 확장자를 뗀 것. 같은 날 둘째부터 `-2` … `-99`(`-1` 은 없다 — 첫 글은 번호 없이).
* 경로의 id 도 이 모양(과 길이)만 본다 — 파일 경로로 쓰지 않고 읽어 둔 목록에서 찾는다
*/
export const RELEASE_NOTE_ID_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})(?:-([2-9]|[1-9]\d))?$/;
/** 경로 id 의 길이 한도 — 모양을 보기 전에 본다 */
export const RELEASE_NOTE_ID_MAX_LENGTH = 32;
/** 번호 없는 글(그날 첫 글)의 차례 번호 */
export const RELEASE_NOTE_FIRST_SEQUENCE = 1;
/** 길이 한도 — 제목 100자 · 묶음 머리 40자 · 한 줄 500자 (이어 붙인 뒤) */
export const RELEASE_NOTE_TITLE_MAX_LENGTH = 100;
export const RELEASE_NOTE_HEADING_MAX_LENGTH = 40;
export const RELEASE_NOTE_ITEM_MAX_LENGTH = 500;
/** 글 파일 하나의 크기 한도 — 넘으면 읽지 않고 건너뛴다(글은 몇 KB — 잘못 넣은 큰 파일을 막는다) */
export const RELEASE_NOTE_FILE_MAX_BYTES = 256 * 1024;
/** 글 하나에서 알려 주는 문제 수 — 고치고 다시 보면 나머지가 나온다 (로그 한 줄이 끝없이 길어지지 않게) */
export const RELEASE_NOTE_PROBLEMS_MAX = 20;
/** 목록 커서의 범위 — 거르는 값이 없는 목록 하나 (shared/utils/offset-cursor.ts) */
export const RELEASE_NOTES_CURSOR_SCOPE = 'release-notes';
@@ -0,0 +1,43 @@
import { Logger } from '@nestjs/common';
import { existsSync } from 'fs';
import { join } from 'path';
import { describeReleaseNoteProblem } from './release-note-format';
import {
RELEASE_NOTES_DIR,
RELEASE_NOTES_README,
} from './release-notes.constants';
import { ReleaseNotesFolder } from './release-notes-folder';
/**
* 실제 글 폴더(backend/release-notes)를 엄격하게 읽는다 — 운영자가 더한 글의 오타를 CI 가 잡는다.
* 서버는 틀린 글을 건너뛰고 뜨지만(로그만), 여기서는 틀린 글 · 글이 아닌 파일이 하나라도 있으면 실패한다.
* 실패하면 "파일 이름 줄 번호 까닭(설명)"이 보인다 — 글을 고치는 법은 그 폴더의 README.md
*/
describe('업데이트 내역 글 폴더 (backend/release-notes — 엄격하게)', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('틀린 글 · 글이 아닌 파일이 하나도 없고 글이 하나 이상 있다', async () => {
const snapshot = await new ReleaseNotesFolder(RELEASE_NOTES_DIR).read();
expect(
snapshot.problems.map(
(problem) => `${problem.file} ${describeReleaseNoteProblem(problem)}`,
),
).toEqual([]);
expect(snapshot.notes.length).toBeGreaterThan(0);
});
it('쓰는 사람용 안내(README.md)가 있다', () => {
expect(existsSync(join(RELEASE_NOTES_DIR, RELEASE_NOTES_README))).toBe(
true,
);
});
});
@@ -0,0 +1,87 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { NoStore } from '@/common/decorators/no-store.decorator';
import {
CursorPaginationQueryDto,
type PaginatedResult,
} from '@/common/dto/pagination.dto';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { ReleaseNoteIdPipe } from './release-note-id.pipe';
import {
RELEASE_NOTE_ID_MAX_LENGTH,
RELEASE_NOTES_ROUTE,
} from './release-notes.constants';
import { ReleaseNotesService } from './release-notes.service';
import type {
ReleaseNoteDetail,
ReleaseNoteSummary,
} from './release-notes.types';
import { ReleaseNoteDto, ReleaseNotePageDto } from './dto/release-note.dto';
/**
* 업데이트 내역 (계약 7절) — 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판. 읽기만 있다 —
* 글은 운영자가 저장소의 `backend/release-notes/` 에 글 파일로 쓴다(올리는 API 는 없다).
* 로그인한 직원만(웹 쿠키 · 앱 Bearer — JwtAuthGuard). 제한된 세션(새 비밀번호 · 2단계 켜기 전)은 403 AUTH_010 — `@AllowRestrictedSession()` 을 달지 않는다.
* 요청 수는 기본 한도. 응답은 private, no-store — 글을 고친 배포가 바로 보이게
*/
@ApiTags('ReleaseNotes')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors()
@Controller(RELEASE_NOTES_ROUTE)
export class ReleaseNotesController {
constructor(private readonly releaseNotes: ReleaseNotesService) {}
@Get()
@NoStore()
@ApiOperation({
summary: '업데이트 내역 목록 (커서 페이지네이션 · total 포함)',
description:
'최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`). total 은 글 수. ' +
'커서는 위치라 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. 형식이 틀린 글 파일은 건너뛴다(목록에 없다). ' +
'Cache-Control: private, no-store.',
})
@ApiStandardResponse(ReleaseNotePageDto, {
description: '업데이트 내역 목록',
})
@ApiErrorResponse(
'VAL_001',
'잘못된 커서 · limit 범위 밖(1~100) · 모르는 쿼리',
)
list(
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReleaseNoteSummary>> {
return this.releaseNotes.list(query);
}
@Get(':id')
@NoStore()
@ApiOperation({
summary: '업데이트 내역 글 하나 (묶음 · 이전 글 · 다음 글)',
description:
'previous 는 바로 더 옛 글(목록에서 이 글 아래), next 는 바로 더 새 글(목록에서 이 글 위) — 없으면 null. ' +
'글은 서버가 다듬은 글 그대로다 — 마크다운 · HTML 로 해석하지 않고 글로만 그린다. ' +
'없는 글 · 모양이 틀린 id 모두 404 RES_001(400 이 아니다 — 손으로 고친 주소도 "없는 글"). Cache-Control: private, no-store.',
})
@ApiParam({
name: 'id',
description: `글 id — 글 파일 이름(\`YYYY-MM-DD\` · 같은 날 두 번째 글부터 \`YYYY-MM-DD-2\` …, ${RELEASE_NOTE_ID_MAX_LENGTH}자까지)`,
example: '2026-11-18',
})
@ApiStandardResponse(ReleaseNoteDto, { description: '업데이트 내역 글' })
@ApiErrorResponse('RES_001', '없는 글 (모양이 틀린 id 포함)')
get(@Param('id', ReleaseNoteIdPipe) id: string): Promise<ReleaseNoteDetail> {
return this.releaseNotes.get(id);
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ReleaseNotesController } from './release-notes.controller';
import { ReleaseNotesFolder } from './release-notes-folder';
import { ReleaseNotesService } from './release-notes.service';
/**
* 업데이트 내역 모듈 (계약 7절 — `GET /api/release-notes` · `/api/release-notes/{id}`) — 읽기만.
* 글은 저장소의 `backend/release-notes/`(글 파일 하나가 글 하나 — release-notes.constants.ts 의 RELEASE_NOTES_DIR).
* 폴더는 useFactory 로 만든다 — 시험은 ReleaseNotesFolder 를 임시 폴더로 바꿔 끼운다(env 를 따로 두지 않는다).
* 이 모듈은 다른 기능 모듈을 가져오지 않는다 (DB 표 없음)
*/
@Module({
controllers: [ReleaseNotesController],
providers: [
{
provide: ReleaseNotesFolder,
useFactory: () => new ReleaseNotesFolder(),
},
ReleaseNotesService,
],
})
export class ReleaseNotesModule {}
@@ -0,0 +1,186 @@
import { Logger } from '@nestjs/common';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
import { ReleaseNotesFolder } from './release-notes-folder';
import { ReleaseNotesService } from './release-notes.service';
/** 목록 쿼리 — 전역 파이프가 만든 것처럼(limit 기본 20) */
function queryOf(
values: Partial<Pick<CursorPaginationQueryDto, 'cursor' | 'limit'>> = {},
): CursorPaginationQueryDto {
return Object.assign(new CursorPaginationQueryDto(), values);
}
/** 던진 업무 오류의 코드 */
async function codeOf(work: Promise<unknown>): Promise<string> {
try {
await work;
} catch (error) {
if (error instanceof BusinessException) return error.code;
throw error;
}
throw new Error('던지지 않았다');
}
describe('ReleaseNotesService — 목록 · 글 (실제 폴더를 읽는다)', () => {
let dir: string;
let service: ReleaseNotesService;
const write = (name: string, text: string) =>
writeFileSync(join(dir, name), text);
/** 제목 · 묶음 하나의 글 */
const note = (title: string) =>
`# ${title}\n\n## 묶음\n- ${title}의 첫 줄\n- 둘째 줄\n`;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-'));
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
service = new ReleaseNotesService(new ReleaseNotesFolder(dir));
// 파일을 만든 차례와 상관없이 날짜 · 번호로 줄 세운다
write('2026-11-05.md', note('11월 5일 글'));
write('2026-11-18-2.md', note('18일 두 번째 글'));
write('2026-11-03.md', note('11월 3일 글'));
write('2026-11-18.md', note('18일 첫 글'));
write('2026-11-18-10.md', note('18일 열 번째 글'));
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('목록은 최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저, 칸은 id · date · title 만, total 은 글 수', async () => {
const page = await service.list(queryOf());
expect(page).toEqual({
items: [
{ id: '2026-11-18-10', date: '2026-11-18', title: '18일 열 번째 글' },
{ id: '2026-11-18-2', date: '2026-11-18', title: '18일 두 번째 글' },
{ id: '2026-11-18', date: '2026-11-18', title: '18일 첫 글' },
{ id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' },
{ id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' },
],
pageInfo: { hasNext: false, nextCursor: null, total: 5 },
});
});
it('쪽 나누기 — nextCursor 로 끝까지 받고, 마지막 쪽은 hasNext false · nextCursor null', async () => {
const first = await service.list(queryOf({ limit: 2 }));
expect(first.items.map((item) => item.id)).toEqual([
'2026-11-18-10',
'2026-11-18-2',
]);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 5 });
expect(typeof first.pageInfo.nextCursor).toBe('string');
const second = await service.list(
queryOf({ limit: 2, cursor: first.pageInfo.nextCursor ?? undefined }),
);
expect(second.items.map((item) => item.id)).toEqual([
'2026-11-18',
'2026-11-05',
]);
const last = await service.list(
queryOf({ limit: 2, cursor: second.pageInfo.nextCursor ?? undefined }),
);
expect(last).toEqual({
items: [{ id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' }],
pageInfo: { hasNext: false, nextCursor: null, total: 5 },
});
});
it('틀린 커서 · 다른 목록의 커서는 폴더를 읽기 전에 400 VAL_001', async () => {
for (const cursor of [
'not-a-cursor',
'x'.repeat(300),
encodeOffsetCursor(2, 'contacts'),
]) {
expect(await codeOf(service.list(queryOf({ cursor })))).toBe('VAL_001');
}
});
it('글 — 묶음과 previous(바로 더 옛 글) · next(바로 더 새 글), 칸은 계약의 것만(차례 번호 없음)', async () => {
const detail = await service.get('2026-11-18');
expect(detail).toEqual({
id: '2026-11-18',
date: '2026-11-18',
title: '18일 첫 글',
sections: [{ heading: '묶음', items: ['18일 첫 글의 첫 줄', '둘째 줄'] }],
previous: { id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' },
next: {
id: '2026-11-18-2',
date: '2026-11-18',
title: '18일 두 번째 글',
},
});
expect(Object.keys(detail)).toEqual([
'id',
'date',
'title',
'sections',
'previous',
'next',
]);
});
it('맨 위 글은 next 가 null, 맨 아래 글은 previous 가 null', async () => {
expect(await service.get('2026-11-18-10')).toMatchObject({
previous: { id: '2026-11-18-2' },
next: null,
});
expect(await service.get('2026-11-03')).toMatchObject({
previous: null,
next: { id: '2026-11-05' },
});
});
it('없는 글은 404 RES_001', async () => {
for (const id of ['2026-11-19', '2026-11-18-3', '2026-02-30']) {
expect(await codeOf(service.get(id))).toBe('RES_001');
}
});
it('틀린 글은 목록 · 글에 없다 — 앞뒤 글은 맞는 글끼리 잇는다', async () => {
write('2026-11-10.md', '# 제목만 있는 글\n');
const page = await service.list(queryOf());
expect(page.items.map((item) => item.id)).not.toContain('2026-11-10');
expect(page.pageInfo.total).toBe(5);
expect(await codeOf(service.get('2026-11-10'))).toBe('RES_001');
expect(await service.get('2026-11-18')).toMatchObject({
previous: { id: '2026-11-05' },
});
});
it('글 파일을 더하면 다시 띄우지 않아도 다음 요청이 본다', async () => {
expect((await service.list(queryOf())).pageInfo.total).toBe(5);
write('2026-11-25.md', note('새 글'));
const page = await service.list(queryOf({ limit: 1 }));
expect(page.items).toEqual([
{ id: '2026-11-25', date: '2026-11-25', title: '새 글' },
]);
expect(page.pageInfo.total).toBe(6);
});
it('폴더가 없으면 빈 목록 — 글은 404 RES_001', async () => {
const empty = new ReleaseNotesService(
new ReleaseNotesFolder(join(dir, 'none')),
);
expect(await empty.list(queryOf())).toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
expect(await codeOf(empty.get('2026-11-18'))).toBe('RES_001');
});
});
@@ -0,0 +1,87 @@
import { Injectable } from '@nestjs/common';
import {
type CursorPaginationQueryDto,
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
decodeOffsetCursor,
encodeOffsetCursor,
} from '@/shared/utils/offset-cursor';
import { RELEASE_NOTES_CURSOR_SCOPE } from './release-notes.constants';
import { ReleaseNotesFolder } from './release-notes-folder';
import type {
ReleaseNote,
ReleaseNoteDetail,
ReleaseNoteSummary,
} from './release-notes.types';
/** 응답에 싣는 칸만 — 읽어 둔 글의 차례 번호는 싣지 않는다 */
function summaryOf(note: ReleaseNote): ReleaseNoteSummary {
return { id: note.id, date: note.date, title: note.title };
}
/** 커서의 위치 — 없으면 0, 틀리면 400 VAL_001 (폴더를 읽기 전에 본다) */
function cursorOffset(cursor: string | undefined): number {
if (cursor === undefined) return 0;
const reading = decodeOffsetCursor(cursor, RELEASE_NOTES_CURSOR_SCOPE);
if ('problem' in reading) {
throw new BusinessException('VAL_001', {
context: { reason: `invalid-release-note-cursor:${reading.problem}` },
});
}
return reading.offset;
}
/**
* 업데이트 내역 (계약 7절) — 글 폴더(ReleaseNotesFolder)를 읽어 목록 · 글을 준다. 올리고 고치는 길은 없다(운영자가 글 파일을 더해 배포).
* 차례는 최근 글부터(날짜 내림차순, 같은 날은 뒤 번호가 먼저) — previous 는 바로 더 옛 글(목록의 아래), next 는 바로 더 새 글(목록의 위).
* 커서는 위치(offset-cursor) — 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다(주소록과 같다)
*/
@Injectable()
export class ReleaseNotesService {
constructor(private readonly folder: ReleaseNotesFolder) {}
/** 목록 한 쪽 — total 은 글 수 */
async list(
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReleaseNoteSummary>> {
const start = cursorOffset(query.cursor);
const { notes } = await this.folder.read();
const page = notes.slice(start, start + query.limit).map(summaryOf);
const next = start + page.length;
const hasNext = next < notes.length;
return toPaginatedResult(page, {
hasNext,
nextCursor: hasNext
? encodeOffsetCursor(next, RELEASE_NOTES_CURSOR_SCOPE)
: null,
total: notes.length,
});
}
/** 글 하나와 앞뒤 글 — 없으면 404 RES_001 (id 의 모양은 파이프가 보았다 — 여기서는 읽어 둔 목록에서만 찾는다) */
async get(id: string): Promise<ReleaseNoteDetail> {
const { notes } = await this.folder.read();
const index = notes.findIndex((note) => note.id === id);
if (index < 0) {
throw new BusinessException('RES_001', {
context: { reason: 'release-note-not-found', id },
});
}
const note = notes[index];
return {
id: note.id,
date: note.date,
title: note.title,
sections: note.sections.map((section) => ({
heading: section.heading,
items: [...section.items],
})),
previous: index + 1 < notes.length ? summaryOf(notes[index + 1]) : null,
next: index > 0 ? summaryOf(notes[index - 1]) : null,
};
}
}
@@ -0,0 +1,30 @@
/**
* 업데이트 내역의 모양 (계약 7절) — 응답과 읽어 둔 글이 함께 쓴다.
* `date` 는 달력 날짜 글(`YYYY-MM-DD` — 파일 이름에서)이고 시각이 아니다. 화면은 시간대를 바꾸지 않고 그대로 쓴다
*/
/** 목록의 한 줄 · 글 화면의 다른 글 줄 */
export interface ReleaseNoteSummary {
readonly id: string;
readonly date: string;
readonly title: string;
}
/** 묶음 — 머리(`## 머리`, 머리 없이 쓴 묶음은 null)와 줄(`- 한 줄`, 하나 이상) */
export interface ReleaseNoteSection {
readonly heading: string | null;
readonly items: readonly string[];
}
/** 글 하나 — 응답 (previous = 바로 더 옛 글, next = 바로 더 새 글 — 없으면 null) */
export interface ReleaseNoteDetail extends ReleaseNoteSummary {
readonly sections: readonly ReleaseNoteSection[];
readonly previous: ReleaseNoteSummary | null;
readonly next: ReleaseNoteSummary | null;
}
/** 읽어 둔 글 — 차례 번호(`sequence` — 같은 날 둘째부터 2 …, 번호 없는 글은 1)는 줄 세우기에만 쓰고 응답에 싣지 않는다 */
export interface ReleaseNote extends ReleaseNoteSummary {
readonly sequence: number;
readonly sections: readonly ReleaseNoteSection[];
}