fix(backend): 리뷰 반영 — 코드 남용 막기·인증 기본 닫힘·한도 동시성·파일 정리

브랜치 리뷰(main..14e2bdd)에서 나온 서버 쪽 문제를 고친다.
- 일정 연동 코드는 한 번 연동에 쓰이면(코드 주인), 나가면(나간 사람) 바꾼다 — 예전에 받은 코드로 다시 들어올 수 없다
- 코드 조회·참여 7개 엔드포인트가 회원 단위 한 바구니(분당 30회)를 같이 쓴다 (@InviteCodeLookup, 인증 뒤에 세는 가드)
- 펫 명함 상세히 공유는 마지막으로 공유한 뒤 7일만 — 그 뒤에는 간략히로 보여 주고 저장한다
- 개발용 로그인·개발용 서명 키는 NODE_ENV 가 development·test 일 때만 (그 밖은 JWT_SECRET 필수, 기동 차단)
- 다이어리 6권·명함 10장·받은 명함 한도를 회원별 advisory lock 으로 세어 동시 요청에 뚫리지 않게 한다
- 받은 명함: 같은 명함 중복(CARD_004)·보관함 100장(CARD_005), 사본 복사 실패 시 만든 사본 정리
- 탈퇴하면 내 다이어리의 다른 멤버 사진도 정리하고, 24시간 넘게 쓰이지 않는 파일을 매시간 지운다
- 대상 id 소문자 저장, GET /calendar-sharing 은 쓰지 않음(코드는 가입 때 만들고 기존 회원은 채움), 파일·작성자 FK 인덱스

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 23:21:04 +09:00
parent 043e14a3fa
commit 02c7ec0079
44 changed files with 1422 additions and 282 deletions
@@ -47,6 +47,44 @@ describe('validateEnv', () => {
});
});
describe('JWT_SECRET — development·test 밖에서는 필수 (allowlist)', () => {
it('development·test 에서는 비어 있어도 통과한다 (개발용 키를 쓴다)', () => {
expect(() => validateEnv({ NODE_ENV: 'development' })).not.toThrow();
expect(() => validateEnv({ NODE_ENV: 'test' })).not.toThrow();
});
it('staging·오타·미설정처럼 목록 밖의 환경은 JWT_SECRET 이 없으면 실패한다', () => {
for (const config of [
{ NODE_ENV: 'staging' },
{ NODE_ENV: 'prod' },
{ NODE_ENV: 'Development' },
{},
]) {
expect(() => validateEnv(config)).toThrow(
/JWT_SECRET 가 설정되지 않았습니다/,
);
}
});
it('목록 밖의 환경에서는 짧은 키·예시 값도 막는다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'staging', JWT_SECRET: 'short-secret' }),
).toThrow(/JWT_SECRET 는 32자 이상/);
expect(() =>
validateEnv({
NODE_ENV: 'staging',
JWT_SECRET: 'change-me-please-change-me-please-123',
}),
).toThrow(/JWT_SECRET 가 예시 값 그대로입니다/);
expect(() =>
validateEnv({
NODE_ENV: 'staging',
JWT_SECRET: 'a-staging-secret-that-is-long-enough-123',
}),
).not.toThrow();
});
});
describe('운영 환경', () => {
it('정상 설정이면 통과한다', () => {
expect(() => validateEnv(prod())).not.toThrow();
+24 -16
View File
@@ -7,6 +7,7 @@
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
import { isDevNodeEnv } from '@/shared/config/node-env';
import { parseDurationMs } from '@/shared/utils/duration';
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
@@ -18,11 +19,9 @@ const REQUIRED_IN_PRODUCTION = [
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
// 비워 두면 개발용 고정 키로 서명하게 된다 — 운영에서는 기동을 막는다 (modules/auth/auth.config.ts)
'JWT_SECRET',
] as const;
/** 운영 JWT 서명 키 최소 길이 — 짧은 키는 무차별 대입으로 풀린다 */
/** JWT 서명 키 최소 길이 (development·test 밖) — 짧은 키는 무차별 대입으로 풀린다 */
const JWT_SECRET_MIN_LENGTH = 32;
/** 값이 있다면 `15m`·`1h`·`14d` 형식이어야 하는 키 */
@@ -40,11 +39,7 @@ const POSITIVE_INT_KEYS = [
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
const NO_PLACEHOLDER_IN_PRODUCTION = [
'DB_PASSWORD',
'REDIS_PASSWORD',
'JWT_SECRET',
] as const;
const NO_PLACEHOLDER_IN_PRODUCTION = ['DB_PASSWORD', 'REDIS_PASSWORD'] as const;
/**
* 운영에서 **공개 주소**여야 하는 키.
@@ -94,7 +89,8 @@ function looksNonPublic(value: string): boolean {
export function validateEnv(
config: Record<string, unknown>,
): Record<string, unknown> {
const isProduction = readString(config, 'NODE_ENV') === 'production';
const nodeEnv = readString(config, 'NODE_ENV');
const isProduction = nodeEnv === 'production';
const errors: string[] = [];
// 1. 숫자여야 하는 값 — 비어 있으면 코드의 기본값을 쓰므로 통과시킨다
@@ -118,6 +114,25 @@ export function validateEnv(
}
}
// JWT 서명 키 — 개발용 키·개발용 로그인은 development·test 에서만 열린다 (shared/config/node-env.ts).
// 그 밖의 환경(staging·production·미설정 …)에서 비워 두면 개발용 고정 키로 서명하게 되므로 기동을 막는다
if (!isDevNodeEnv(nodeEnv)) {
const jwtSecret = readString(config, 'JWT_SECRET');
if (jwtSecret === undefined) {
errors.push(
'JWT_SECRET 가 설정되지 않았습니다. (development·test 밖에서는 필수)',
);
} else if (looksLikePlaceholder(jwtSecret)) {
errors.push(
'JWT_SECRET 가 예시 값 그대로입니다. 운영용 값으로 교체해 주세요.',
);
} else if (jwtSecret.length < JWT_SECRET_MIN_LENGTH) {
errors.push(
`JWT_SECRET 는 ${JWT_SECRET_MIN_LENGTH}자 이상이어야 합니다. (development·test 밖)`,
);
}
}
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
@@ -136,13 +151,6 @@ 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';
+10
View File
@@ -0,0 +1,10 @@
/**
* 개발용 서명 키·개발용 로그인을 허용하는 실행 환경 (allowlist).
* 이 목록 밖의 값(production·staging·오타·미설정 등)은 모두 운영처럼 다룬다 — 실수로 열리지 않게 닫힌 쪽이 기본이다.
*/
export const DEV_NODE_ENVS: readonly string[] = ['development', 'test'];
/** 개발·테스트 환경인지 — 앞뒤 공백은 무시한다 */
export function isDevNodeEnv(nodeEnv: string | undefined): boolean {
return DEV_NODE_ENVS.includes(nodeEnv?.trim() ?? '');
}
+34
View File
@@ -0,0 +1,34 @@
import type { EntityManager } from 'typeorm';
/**
* 트랜잭션 잠금(pg_advisory_xact_lock)의 용도 — 키가 서로 겹치지 않도록 한 곳에서 번호를 정한다.
* 잠금은 트랜잭션이 끝나면(커밋·롤백) 저절로 풀린다.
*/
export const LOCK_SCOPE = {
/** 다이어리 책장 한도 — 만들기·참여 (회원별) */
diaryShelf: 1,
/** 펫 명함 한도 (회원별) */
petCards: 2,
/** 받은 명함 보관함 한도·중복 (회원별) */
receivedCards: 3,
/** 일정 연동 참여·해제 (전체 하나) */
calendarSharing: 4,
} as const;
export type LockScope = (typeof LOCK_SCOPE)[keyof typeof LOCK_SCOPE];
/**
* [scope] 안에서 [key] 하나에 대한 일을 한 줄로 세운다 — 예: 한 회원의 "세고 → 만들기"가 동시에 두 번 돌아
* 한도를 넘지 않게. 행 잠금(FOR UPDATE)과 달리 셀 행이 하나도 없을 때도 잠긴다.
* [key] 는 hashtext 로 정수가 되므로 드물게 다른 키와 같은 잠금을 쓸 수 있다 — 그래도 순서만 늦어질 뿐 틀리지 않는다.
*/
export async function lockFor(
manager: EntityManager,
scope: LockScope,
key: string,
): Promise<void> {
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
scope,
key,
]);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { randomInt } from 'crypto';
// 초대 코드 `RUP-XXXX…` — 부수효과 없는 순수 함수 (무작위 값만 crypto 에서 얻는다).
// 다이어리(6자)·일정 연동(4자)처럼 뒷부분 길이만 다르게 쓴다.
// 종류마다 뒷부분 길이를 정해 쓴다 — 지금은 다이어리·일정 연동 모두 6자.
/** 코드 앞머리 — 앱이 보여 주는 형식 */
const INVITE_CODE_PREFIX = 'RUP-';