feat(backend): 소셜 로그인·JWT 인증과 회원·파일 API 추가

앱의 기록을 서버로 옮기려면 회원과 인증이 먼저 있어야 한다.

- 소셜 로그인: 앱이 SDK 로 받은 토큰을 서버가 제공자에게 직접 확인한다
  (카카오·네이버는 사용자 정보 API, 구글·Apple 은 id_token JWKS 검증)
- 소셜 앱 키가 생기기 전까지 쓰는 개발용 로그인(기기 키) — 운영에서는 닫힌다
- 액세스 JWT(1시간) + 리프레시 토큰(해시 저장·회전·재사용 탐지)
- 전역 JwtAuthGuard — 기본으로 모든 라우트가 로그인을 요구하고 @Public() 으로만 연다
- 내 정보 조회·수정·탈퇴(모든 기록과 파일 정리), 파일 소유자 기록과 내려받기,
  업로드 시 매직 바이트로 내용 형식 검사
- 컬럼은 snake_case 이름 규칙, 테스트 환경은 기동 시 마이그레이션 실행 (CI e2e 는 빈 DB)
- 템플릿 예제 사용자 API(in-memory)를 실제 회원 API 로 교체하고 e2e 계약 테스트를 옮겼다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 17:06:43 +09:00
parent 1f7693898c
commit 7e0e4c36b5
53 changed files with 2943 additions and 274 deletions
+13 -2
View File
@@ -1,6 +1,7 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { SnakeNamingStrategy } from '@/shared/config/snake-naming.strategy';
import { parsePositiveInt } from '@/shared/utils/env';
/**
@@ -30,6 +31,15 @@ export function isProductionEnv(): boolean {
return process.env.NODE_ENV === 'production';
}
/**
* 기동할 때 미적용 마이그레이션을 실행할지.
* 운영과 테스트(e2e — 빈 DB 에서 시작한다)는 자동으로 실행하고,
* 개발은 `migration:run` 으로 직접 적용한다 (생성한 SQL 을 확인한 뒤 적용하도록).
*/
function shouldRunMigrations(): boolean {
return isProductionEnv() || process.env.NODE_ENV === 'test';
}
/**
* 스키마 자동 동기화 사용 여부.
*
@@ -69,9 +79,10 @@ export function buildDatabaseOptions(): DataSourceOptions {
password: process.env.DB_PASSWORD ?? DEV_DB_DEFAULTS.password,
database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database,
synchronize: shouldSynchronize(),
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
migrationsRun: shouldRunMigrations(),
logging: !isProduction,
// 컬럼은 snake_case, 엔티티 속성은 camelCase
namingStrategy: new SnakeNamingStrategy(),
extra: { ...POOL_OPTIONS },
entities: paths.entities,
migrations: paths.migrations,
@@ -12,6 +12,7 @@ const prod = (extra: Record<string, string> = {}) => ({
DB_USER: 'app_user',
DB_PASSWORD: 'a-real-secret',
DB_NAME: 'project_db',
JWT_SECRET: 'a-production-secret-that-is-long-enough-123',
...extra,
});
@@ -27,6 +28,18 @@ describe('validateEnv', () => {
).toThrow(/PORT 는 양의 정수/);
});
it('토큰 만료 기간 형식이 틀리면 실패한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: '1시간',
}),
).toThrow(/JWT_ACCESS_EXPIRES_IN 는 15m · 1h · 14d/);
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '14d' }),
).not.toThrow();
});
it('빈 문자열은 통과한다 (compose 가 미정의 변수를 빈 값으로 주입한다)', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
@@ -39,6 +52,15 @@ describe('validateEnv', () => {
expect(() => validateEnv(prod())).not.toThrow();
});
it('JWT_SECRET 이 없거나 짧으면 실패한다', () => {
expect(() => validateEnv(omit(prod(), 'JWT_SECRET'))).toThrow(
/JWT_SECRET 가 설정되지 않았습니다/,
);
expect(() => validateEnv(prod({ JWT_SECRET: 'short-secret' }))).toThrow(
/JWT_SECRET 는 32자 이상/,
);
});
it('필수 DB 설정이 없으면 실패한다', () => {
expect(() => validateEnv(omit(prod(), 'DB_PASSWORD'))).toThrow(
/DB_PASSWORD 가 설정되지 않았습니다/,
@@ -7,6 +7,8 @@
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
import { parseDurationMs } from '@/shared/utils/duration';
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
const PLACEHOLDER_PATTERNS = [/change[-_]?me/i, /^password$/i, /^secret$/i];
@@ -16,6 +18,17 @@ const REQUIRED_IN_PRODUCTION = [
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
// 비워 두면 개발용 고정 키로 서명하게 된다 — 운영에서는 기동을 막는다 (modules/auth/auth.config.ts)
'JWT_SECRET',
] as const;
/** 운영 JWT 서명 키 최소 길이 — 짧은 키는 무차별 대입으로 풀린다 */
const JWT_SECRET_MIN_LENGTH = 32;
/** 값이 있다면 `15m`·`1h`·`14d` 형식이어야 하는 키 */
const DURATION_KEYS = [
'JWT_ACCESS_EXPIRES_IN',
'JWT_REFRESH_EXPIRES_IN',
] as const;
/** 값이 있다면 양의 정수여야 하는 키 */
@@ -95,6 +108,16 @@ export function validateEnv(
}
}
// 기간 형식 — 틀리면 토큰 만료가 조용히 기본값이 되므로 기동 시점에 막는다
for (const key of DURATION_KEYS) {
const value = readString(config, key);
if (value !== undefined && parseDurationMs(value) === null) {
errors.push(
`${key} 는 15m · 1h · 14d 같은 기간 형식이어야 합니다. (현재 값: "${value}")`,
);
}
}
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
@@ -113,6 +136,13 @@ export function validateEnv(
}
}
const jwtSecret = readString(config, 'JWT_SECRET');
if (jwtSecret !== undefined && jwtSecret.length < JWT_SECRET_MIN_LENGTH) {
errors.push(
`JWT_SECRET 는 ${JWT_SECRET_MIN_LENGTH}자 이상이어야 합니다. (운영 환경)`,
);
}
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
const allowLocalhostUrl =
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
@@ -0,0 +1,55 @@
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
import { snakeCase } from '@/shared/utils/case';
/**
* 엔티티 속성(camelCase)을 DB 컬럼(snake_case)으로 바꾸는 이름 규칙.
* Postgres 는 대문자가 섞인 이름을 따옴표로 감싸야 해서, 컬럼은 snake_case 로 통일한다.
* 응답(JSON)은 엔티티 속성 이름 그대로 camelCase 다 (.claude/rules/api-design.md).
*/
export class SnakeNamingStrategy
extends DefaultNamingStrategy
implements NamingStrategyInterface
{
override tableName(className: string, customName?: string): string {
return customName ?? snakeCase(className);
}
override columnName(
propertyName: string,
customName: string | undefined,
embeddedPrefixes: string[],
): string {
const prefix = embeddedPrefixes.map((p) => `${snakeCase(p)}_`).join('');
return prefix + (customName ?? snakeCase(propertyName));
}
override relationName(propertyName: string): string {
return snakeCase(propertyName);
}
override joinColumnName(
relationName: string,
referencedColumnName: string,
): string {
return snakeCase(`${relationName}_${referencedColumnName}`);
}
override joinTableName(
firstTableName: string,
secondTableName: string,
firstPropertyName: string,
): string {
return snakeCase(
`${firstTableName}_${firstPropertyName.replace(/\./g, '_')}_${secondTableName}`,
);
}
override joinTableColumnName(
tableName: string,
propertyName: string,
columnName?: string,
): string {
return snakeCase(`${tableName}_${columnName ?? propertyName}`);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { snakeCase } from './case';
describe('snakeCase', () => {
it('camelCase 를 snake_case 로 바꾼다', () => {
expect(snakeCase('photoFileId')).toBe('photo_file_id');
expect(snakeCase('createdAt')).toBe('created_at');
});
it('PascalCase 와 연속 대문자도 단어 단위로 나눈다', () => {
expect(snakeCase('RefreshToken')).toBe('refresh_token');
expect(snakeCase('HTTPServer')).toBe('http_server');
});
it('이미 snake_case 면 그대로 둔다', () => {
expect(snakeCase('user_id')).toBe('user_id');
});
it('점·하이픈은 밑줄로 바꾼다 (임베디드 접두어)', () => {
expect(snakeCase('archive.from')).toBe('archive_from');
});
});
+13
View File
@@ -0,0 +1,13 @@
// 문자열 표기 변환 — 부수효과 없는 순수 함수
/**
* camelCase · PascalCase 를 snake_case 로 바꾼다.
* DB 컬럼·테이블 이름에 쓴다 (예: `photoFileId` → `photo_file_id`, `HTTPServer` → `http_server`).
*/
export function snakeCase(value: string): string {
return value
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z\d]+)/g, '$1_$2')
.replace(/[\s.-]+/g, '_')
.toLowerCase();
}
+52
View File
@@ -0,0 +1,52 @@
import { decodeCursor, encodeCursor, keysetPage } from './cursor';
describe('커서 인코딩', () => {
it('인코딩한 값을 그대로 되돌린다', () => {
const cursor = encodeCursor(['2026-10-11T00:00:00.000Z', 3, 'a1b2']);
expect(decodeCursor(cursor, 3)).toEqual([
'2026-10-11T00:00:00.000Z',
3,
'a1b2',
]);
});
it('커서는 URL 에 그대로 넣을 수 있는 문자만 쓴다', () => {
expect(encodeCursor(['한글 값', '?&='])).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('키 개수가 다르면 null 이다', () => {
expect(decodeCursor(encodeCursor(['a']), 2)).toBeNull();
});
it('해석할 수 없는 값이면 null 이다', () => {
expect(decodeCursor('not-a-cursor', 1)).toBeNull();
expect(decodeCursor(encodeCursor([]), 0)).toEqual([]);
expect(
decodeCursor(Buffer.from('{"a":1}').toString('base64url'), 1),
).toBeNull();
expect(
decodeCursor(Buffer.from('[{"a":1}]').toString('base64url'), 1),
).toBeNull();
});
});
describe('keysetPage', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
it('limit 보다 하나 더 읽혔으면 다음 페이지가 있다', () => {
const page = keysetPage(rows, 2, (row) => [row.id]);
expect(page.items).toEqual([{ id: 'a' }, { id: 'b' }]);
expect(page.hasNext).toBe(true);
expect(decodeCursor(page.nextCursor ?? '', 1)).toEqual(['b']);
});
it('마지막 페이지면 nextCursor 가 null 이다', () => {
const page = keysetPage(rows, 3, (row) => [row.id]);
expect(page.items).toHaveLength(3);
expect(page.hasNext).toBe(false);
expect(page.nextCursor).toBeNull();
});
});
+55
View File
@@ -0,0 +1,55 @@
// 커서 페이지네이션 — 부수효과 없는 순수 함수
/** 커서에 담는 정렬 키 하나 (예: 생성 시각 ISO 문자열, 정렬 순서, id) */
export type CursorValue = string | number;
/**
* 정렬 키 목록을 불투명한 커서 문자열로 만든다.
* 클라이언트는 값을 해석하지 않고 다음 요청에 그대로 돌려준다.
*/
export function encodeCursor(values: readonly CursorValue[]): string {
return Buffer.from(JSON.stringify(values), 'utf8').toString('base64url');
}
/**
* 커서를 정렬 키 목록으로 되돌린다.
* 형식이 틀렸거나 키 개수가 [arity] 와 다르면 null — 호출한 쪽이 VAL_001 로 응답한다.
*/
export function decodeCursor(
cursor: string,
arity: number,
): CursorValue[] | null {
try {
const parsed: unknown = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
);
if (!Array.isArray(parsed) || parsed.length !== arity) return null;
const values: CursorValue[] = [];
for (const value of parsed as unknown[]) {
if (typeof value !== 'string' && typeof value !== 'number') return null;
values.push(value);
}
return values;
} catch {
return null;
}
}
/** [limit] + 1 개를 읽어 온 결과를 한 페이지와 다음 커서로 나눈다 */
export function keysetPage<T>(
rows: readonly T[],
limit: number,
keyOf: (row: T) => readonly CursorValue[],
): { items: T[]; hasNext: boolean; nextCursor: string | null } {
const hasNext = rows.length > limit;
const items = rows.slice(0, limit);
const last = items.at(-1);
return {
items,
hasNext,
nextCursor:
hasNext && last !== undefined ? encodeCursor(keyOf(last)) : null,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { QueryFailedError } from 'typeorm';
/** Postgres 오류 코드 — https://www.postgresql.org/docs/current/errcodes-appendix.html */
const PG_UNIQUE_VIOLATION = '23505';
const PG_FOREIGN_KEY_VIOLATION = '23503';
function pgCodeOf(error: unknown): string | null {
if (!(error instanceof QueryFailedError)) return null;
const driverError: unknown = error.driverError;
if (
typeof driverError === 'object' &&
driverError !== null &&
'code' in driverError &&
typeof driverError.code === 'string'
) {
return driverError.code;
}
return null;
}
/** 유니크 제약 위반 — 같은 값이 동시에 들어온 경우 */
export function isUniqueViolation(error: unknown): boolean {
return pgCodeOf(error) === PG_UNIQUE_VIOLATION;
}
/** 외래 키 위반 — 아직 다른 행이 참조하고 있는 행을 지우려 한 경우 등 */
export function isForeignKeyViolation(error: unknown): boolean {
return pgCodeOf(error) === PG_FOREIGN_KEY_VIOLATION;
}
+24
View File
@@ -0,0 +1,24 @@
import { parseDurationMs } from './duration';
describe('parseDurationMs', () => {
it('단위별로 밀리초를 계산한다', () => {
expect(parseDurationMs('500ms')).toBe(500);
expect(parseDurationMs('30s')).toBe(30_000);
expect(parseDurationMs('15m')).toBe(900_000);
expect(parseDurationMs('1h')).toBe(3_600_000);
expect(parseDurationMs('14d')).toBe(1_209_600_000);
});
it('앞뒤 공백은 무시한다', () => {
expect(parseDurationMs(' 1h ')).toBe(3_600_000);
});
it('형식이 틀리거나 0 이면 null 이다', () => {
expect(parseDurationMs(undefined)).toBeNull();
expect(parseDurationMs('')).toBeNull();
expect(parseDurationMs('1 hour')).toBeNull();
expect(parseDurationMs('h')).toBeNull();
expect(parseDurationMs('0d')).toBeNull();
expect(parseDurationMs('-1h')).toBeNull();
});
});
+25
View File
@@ -0,0 +1,25 @@
// 기간 문자열 파싱 — 부수효과 없는 순수 함수
const UNIT_TO_MS: Readonly<Record<string, number>> = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
const DURATION_PATTERN = /^(\d+)(ms|s|m|h|d)$/;
/**
* `15m` · `1h` · `14d` 같은 기간 문자열을 밀리초로 바꾼다.
* 형식이 틀리거나 0 이하면 null 을 돌려준다 (호출한 쪽이 기본값·검증 실패를 정한다).
*/
export function parseDurationMs(value: string | undefined): number | null {
if (value === undefined) return null;
const match = DURATION_PATTERN.exec(value.trim());
if (!match) return null;
const amount = Number(match[1]);
if (amount <= 0) return null;
return amount * UNIT_TO_MS[match[2]];
}
+11
View File
@@ -0,0 +1,11 @@
import { createHash, randomBytes } from 'crypto';
/** SHA-256 해시 (hex) — 토큰처럼 원문을 저장하면 안 되는 값을 찾을 때 쓴다 */
export function sha256Hex(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex');
}
/** 추측할 수 없는 무작위 문자열 (base64url) */
export function randomToken(bytes: number): string {
return randomBytes(bytes).toString('base64url');
}