
프로젝트 시작 동기
처음 개발자로 일을 하게 되면서 마음먹은 것이 3년 내에 개인 블로그를 만들겠다는 것이었다. 블로그에 일기를 오래 써온 터라 다른 프로젝트는 생각하지 않았다. 개발자로서 1년이 넘어가는 시점에 회사에서 사용하던 기술인 스프링부트와 VueJS를 사용하여 첫 시도를 하였다. 그러나 초급 개발자가 개인 프로젝트를 회사 업무와 병행하기는 쉽지 않았고 필요한 지식의 양과 깊이도 턱없이 부족했다. 만 2년이 되어가는 시점에서 우연히 부서 이동을 하게 되었고 거기에서 리액트를 사용하는 프로젝트에 참여할 수 있었다. 그렇게 나는 노드에 흥미를 가지게 되었다. 사람들이 언급하는 단점들에도 불구하고 그 범용성에 매료된 것이다. 노드 계열 프레임워크를 사용하여 블로그 프로젝트를 재시작해야겠다는 생각도 그즈음부터 하게 됐던 것 같다.
백엔드 구성에 대해서
이 프로젝트를 처음 시작할 때만 해도 사람들은 내게 스프링부트로 백엔드 서버를 만들 것을 권하였다. 미리 만들어 놓은 템플릿이 있었기 때문에 만약 그렇게 했다면 좀 더 빨리 프로젝트를 진행할 수도 있었을 것이다. 그러나 나는 Nest.js를 선택하였다. 노드 진영의 스프링이라는 수식어만큼 사용 방식이 스프링과 유사하다는 말이 있었기 때문에, 노드를 사용해보고 싶은 내 의지와의 절충안이 될 것이라고 생각했기 때문이었다. 네스트의 경우 좀 더 기능 중심적으로 파일을 배치해야 했고, 스프링이 자동적으로 빈을 등록해주는 것과는 달리 파일들을 직접 모듈에 명시적으로 등록해줘야 한다는 점을 제외한다면 '절충'이라는 표현이 완전히 틀린 표현이라고는 생각하지 않는다. 그러나 이런 의견을 가지게 되기까지 많은 시행착오가 있었고(공식문서를 읽어가며 진행했는데도 불구하고) 예상보다 더 많은 시간이 소요됐다.
프레임워크뿐만 아니라 DB와 연결 방식에도 시간이 소요되었다. 회사 업무에서는 MariaDB와 MyBatis를 사용했었고, 개인 프로젝트에서는 MariaDB와 JPA를 사용했기 때문에 RDB와 ORM 개념을 이해하는 데 어려움은 없었다. 그럼에도 불구하고 처음 사용해보는 PostgreSQL DB와 TypeORM의 설정과 사용방식에 익숙해지는 데 어려움이 있었다.
이러한 고민과 경험을 바탕으로 개인 블로그 프로젝트의 초기 기능을 완성하게 되었고, 이제 그 결과물의 기술적 구성과 주요 기능에 대해 소개하고자 한다. 다음은 내가 구현한 블로그 백엔드 시스템의 핵심 구성 요소들이다.
1. 프로젝트 개요
기술 스택
프레임워크: NestJS
데이터베이스: PostgreSQL + TypeORM
스토리지: Supabase
인증: JWT, 리프레시 토큰 로테이션, 구글 소셜 로그인
배포: Docker, AWS Lightsail, Nginx
기타: 이메일 인증, 클라우드 스토리지
아키텍처
기본적인 모놀리식 아키텍처이다.
Nginx 설정을 통해 CORS는 물론 모든 허용 경로를 관리하며, NestJS 인스턴스에서는 CORS를 false로 설정해 로컬호스트의 접근만을 허용한다. 즉, NestJS 인스턴스 자체적으로는 일체의 외부 접근을 허용하지 않는다.
NestJS 인스턴스는 도커 컨테이너 내부에서 구동된다. 도커 파일에 직접적인 환경변수가 적혀있지는 않으며, 구동 시 환경변수 파일(예. env.production)등을 지정해 구동한다.
DB는 도커를 사용하지 않고 네이티브로 설치하였다. Lightsail의 사양이 높지 않았기 때문에 API 서버 인스턴스를 제외한 요소에 대해서는 메모리 사용을 줄여야 했다. 더불어 처음 사용해보는 DBMS이기 때문에 괜히 문제 발생을 유발할 변수를 늘리고 싶지 않았다.
프론트엔드에서 이미지 파일 업로드 시 스토리지에 직접 업로드 된다. 백엔드를 거쳐 스토리지(Supabase)에 접근하는 편이 보안에 더욱 안전하고 파일 관리에 효율적이겠지만 Lightsail의 제한된 네트워크 트래픽과 사양 때문에 이런 선택을 할 수 없었다. 추후 miniO를 사용한 직접 스토리지 구축 계획도 가지고 있으니 그 때 적용해 볼 수도 있다.

2. 인증 시스템 구현
JWT와 리프레시 토큰 로테이션
보안을 강화하기 위해 짧은 수명의 액세스 토큰(15분)과 긴 수명의 리프레시 토큰(자동 로그인 선택 시 7일, 미선택 시 24시간)을 사용하는 토큰 인증 시스템을 구현했다. 리프레시 토큰 로테이션 기법을 선택하여 토큰 갱신 시 액세스 토큰 뿐만 아니라 리프레시 토큰도 갱신하도록 하였다. 이에 따라 리프레시 토큰을 관리하는 테이블이 존재하게 되며, 갱신 요청 시 이 테이블에서 해당 토큰을 조회하여 유효성을 확인한다.
클라이언트로 토큰을 전송할 때에는, 리프레시 토큰의 경우 응답 헤더의 HttpOnly 쿠키에 담기고, 액세스 토큰의 경우 객체의 속성(access_token)으로 담기게 된다. 리프레시 토큰 폐기의 경우 사용자의 브라우저 새로고침/로그아웃/자동만료로 인해 발생하게 되며 폐기된 토큰은 크론잡을 통해 일괄 삭제된다.
// 토큰 발급
// src/auth/services/auth.service.ts
private async createTokens(member: AuthMember, clientType: ClientType, keepLoggedIn:boolean) {
const payload: JwtPayload = {
sub: member.uuid,
role: member.role,
tokenVersion: member.tokenVersion,
keepLoggedIn: keepLoggedIn,
};
// 짧은 수명의 액세스 토큰 (15분)
const accessToken = await this.jwtService.signAsync(payload, {
expiresIn: '15m',
});
// 긴 수명의 리프레시 토큰 (자동 로그인 여부에 따라 7일 또는 24시간)
const refreshToken = await this.jwtService.signAsync(payload, {
expiresIn: keepLoggedIn ? '7d' : '24h',
});
// 리프레시 토큰 DB 저장 (기존 토큰 무효화 + 새 토큰 발급)
await this.authRepository.saveRefreshToken(member.id, refreshToken, keepLoggedIn);
return {
access_token: accessToken,
refresh_token: refreshToken,
keepLoggedIn: keepLoggedIn,
};
}
// 토큰 갱신
// src/auth/services/auth.service.ts
async refreshAccessToken(refreshToken: string, clientType: ClientType): Promise<any> {
// 토큰 검증
const payload = await this.jwtService.verifyAsync(refreshToken);
// DB에서 리프레시 토큰 조회 및 유효성 확인
const tokenData = await this.authRepository.findByRefreshToken(refreshToken);
if (!tokenData || tokenData.revoked) {
throw new InvalidRefreshTokenException();
}
// 회원 정보 불일치 확인
if (payload.sub !== tokenData.member.uuid) {
throw new UnauthorizedException('Invalid token ownership');
}
// 새 토큰 발급 시 tokenData.member의 정보 사용
const authMember: AuthMember = {
id: tokenData.member.id,
uuid: tokenData.member.uuid,
// ... 기타 회원 정보
tokenVersion: tokenData.tokenVersion
};
// 새 액세스 토큰과 리프레시 토큰 모두 재발급 (로테이션)
return this.createTokens(authMember, clientType, tokenData.keepLoggedIn);
}// 토큰 저장소 관리
// src/auth/repositories/auth.repository.ts
async saveRefreshToken(memberId: number, token: string, keepLoggedIn: boolean): Promise<void> {
await this.refreshTokenRepository.manager.transaction(async transactionalEntityManager => {
// 회원 정보 조회
const member = await transactionalEntityManager.getRepository(Member).findOne({
where: { id: memberId },
// ... 필요한 회원 정보 필드들
});
// 기존 토큰 무효화 (로테이션)
await transactionalEntityManager
.createQueryBuilder()
.update(RefreshToken)
.set({
revoked: true,
revokedAt: new Date(),
revokedReason: 'NEW_TOKEN_ISSUED'
})
.where('member = :memberId AND revoked = :revoked', {
memberId: member.id,
revoked: false
})
.execute();
// 새 토큰 저장
const tokenEntity = new RefreshToken();
tokenEntity.token = token;
tokenEntity.member = member;
tokenEntity.tokenVersion = member.tokenVersion;
tokenEntity.keepLoggedIn = keepLoggedIn;
tokenEntity.expiresAt = new Date(Date.now() + (keepLoggedIn ? 7 * 24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000));
await transactionalEntityManager.save(RefreshToken, tokenEntity);
});
}// 토큰 저장소 관리
// src/common/interceptors/auth-response.interceptor.ts
@Injectable()
export class AuthResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map(data => {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const clientType = request.body.clientType || request.headers['x-client-type'];
if (clientType === ClientType.WEB) {
if (data.refresh_token) {
// 리프레시 토큰은 HttpOnly 쿠키로 전송
response.cookie('refresh_token', data.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
// 자동 로그인 여부에 따라 쿠키 만료 시간 설정
maxAge: data.keepLoggedIn ? 7 * 24 * 60 * 60 * 1000 : undefined,
});
// 응답 객체에서는 리프레시 토큰 삭제
delete data.refresh_token;
delete data.keepLoggedIn;
}
}
// 액세스 토큰은 JSON 응답으로 전송 (data.access_token)
return data;
}),
);
}
} // 크론잡을 이용한 만료 토큰 삭제
// src/auth/schedulers/token-cleanup.service.ts
@Injectable()
export class TokenCleanupService {
constructor(
@InjectRepository(RefreshToken)
private refreshTokenRepository: Repository<RefreshToken>,
) {}
@Cron('0 0 * * *') // 매일 자정에 실행
async cleanupOldTokens() {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
await this.refreshTokenRepository.createQueryBuilder()
.delete()
.where('(revoked = :revoked AND revokedAt < :revokedAt)', {
revoked: true,
revokedAt: thirtyDaysAgo
})
.orWhere('expiresAt < :expiresAt', {
expiresAt: new Date()
})
.execute();
}
}사용자 정보 암호화
사용자 비밀번호는 bycript 라이브러리 사용하여 암호화하여 저장한다. 사용자 비밀번호는 복호화 할 수 없도록 bcrypt 라이브러리를 사용해 랜덤 솔트를 사용해 해시하고, 이메일과 같은 개인정보는 환경변수로 등록한 솔트와 키를 사용해 해시처리하여 필요 시 복호화 할 수 있도록 처리하였다.
// src/members/members.service.ts의 create 메서드 일부
import * as bcrypt from 'bcrypt'; // 비밀번호 해시화를 위한 패키지
async create(createMemberDto: CreateMemberDto): Promise<MemberResponseDto> {
// ...
// 비밀번호 암호화: bcrypt를 사용한 단방향 해시화 (복호화 불가능)
// 두 번째 파라미터 10은 해시 강도(salt rounds)로, 높을수록 보안성 증가하나 처리 시간도 증가
// 솔트(salt)는 bcrypt 내부적으로 자동 생성되어 해시에 포함됨
memberEntity.password = await bcrypt.hash(createMemberDto.password, 10);
// ...
}
// src/auth/services/auth.service.ts 파일의 일부
private async validatePassword(password: string, hashedPassword: string): Promise<boolean> {
// ...
const result = await bcrypt.compare(password, hashedPassword);
return result;
} // src/common/utils/email-util.util.ts 파일의 일부
private static getEncryptionConfig() {
const key = process.env.EMAIL_ENCRYPTION_KEY;
const salt = process.env.EMAIL_ENCRYPTION_SALT;
// ...
const derivedKey = crypto.scryptSync(key, salt, 32);
return { key, salt, derivedKey };
}이메일 인증 및 구글 소셜 로그인

일반 회원가입 시에는 이메일 링크를 통한 인증 방식을 사용하였다. 회원가입 요청 시 계정이 생성되고, 백엔드에서는 토큰을 만들어 메일로 보낸다. 링크를 클릭하면 프론트엔드 페이지로 이동하게 되고, 해당 페이지의 함수가 URL에서 토큰을 잘라내어 백엔드로 보내면 인증이 완료된다.

구글 소셜 로그인은 Google OAuth를 사용하였다. 과정은 아래의 과정을 거친다.
(프론트) 사용자가 구글 로그인 버튼 클릭
(백엔드) OAuth URL 생성해서 프론트에 반환
(프론트) 사용자를 구글 로그인 페이지로 리다이렉트
(구글) 사용자가 로그인하고 권한 동의
(구글) 사용자를 직접 백엔드의 콜백 URL(/auth/social/google/callback)로 리다이렉트 + 인증 코드 포함
(백엔드) 인증 코드로 토큰 발급 및 사용자 정보 처리
(백엔드) 처리 완료 후 사용자를 프론트엔드 페이지로 리다이렉트 + 토큰 포함
/**
* 소셜 로그인 제공자의 인증 URL 생성
* @param provider 소셜 로그인 제공자 (예: GOOGLE)
* @throws BadRequestException 지원하지 않는 제공자
* @return 소셜 로그인 인증 URL
*/
async getSocialLoginUrl(provider: AuthProvider): Promise<string> {
const providerKey = provider.toUpperCase();
let url = AuthProviderUrl[providerKey];
if (!url) {
throw new BadRequestException(`URL not found for provider: ${provider}`);
}
switch (provider) {
case AuthProvider.GOOGLE:
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: process.env.GOOGLE_REDIRECT_URI,
response_type: 'code', // 필수 파라미터
// scope: 'openid email profile https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email',
scope: 'openid profile email',
access_type: 'offline',
prompt: 'consent'
});
url = `${url}?${params.toString()}`;
break;
default:
throw new BadRequestException(`Unsupported provider: ${provider}`);
}3. 데이터베이스 설계
TypeORM을 활용한 데이터 모델링
NestJS와 TypeORM을 사용하여 엔티티 간 관계를 설계하였다. 블로그라는 사이트의 특성 상 콘텐츠 관리를 위해 post 테이블을 중심으로 기능을 분리하여 확장성과 유지보수성을 높이려 하였다. 처음에는 포스팅과 관련된 대부분의 속성들을 post 테이블에 넣어서 개발하였다. 그러나 개발이 진행됨에 따라 추후 수정해야할 범위가 넓어질 것이 우려되어 중간에 분리하는 과정을 거쳤다. 또한 추후 메인 페이지 및 주요 페이지에 큐레이션을 구현하기 위해서 미리 테이블을 만들어 놓았다.

회원 탈퇴 처리
회원 탈퇴 시 즉시 데이터를 삭제하지 않고, 소프트 딜리트 방식을 채택했다. 회사에서 ISMS 서비스를 관리하면서 일정 기간 회원 정보를 유지해야 하는 법령이 있다는 것을 알고 있었고, 추후 결제를 붙일 가능성이 있었기 때문에 처음부터 염두에 두었다. 회원 테이블에서 사용자의 기본 정보는 마스킹 처리하거나 제거하되 ID는 유지하여 데이터 무결성을 보존한다. 삭제된 회원 정보는 회원 테이블의 주키를 외래키로 받은 채로 별도의 테이블에 이관된다. 이 때 탈퇴 회원의 데이터(이메일)는 최초 회원 가입 시 환경변수 솔트와 키로 해시된 상태 그대로 입력된다. 추후 탈퇴 회원의 조회가 필요할 시 복호화 가능성을 염두에 둔 것이다. 완전히 삭제하면 편리하지만 추후 분쟁과 같은 일이 발생했을 때 가입 여부를 판별할 수 없다. 반복적인 가입/탈퇴 방지를 막고자 할 때에도 마찬가지이다. 일정 기간 보관했다가 완전 삭제 하는 방법이 맞을 것 같지만 정확하지 않다. 이와 관련해서는 추후 법령을 찾아볼 필요가 있다.
// 멤버 삭제 (Soft Delete)
async withdrawMember(member: Member, withdrawnMember: WithdrawnMember): Promise<boolean> {
try {
return await this.dataSource.transaction(async (manager) => {
// 탈퇴 정보 저장
await manager.save(WithdrawnMember, withdrawnMember);
// 기존 회원 정보 삭제 처리
await manager.update(Member, { uuid: member.uuid }, {
email: `withdrawn_${member.uuid}@deleted.account`, // not null 때문에 특수한 이메일 형식으로 변경
hashedEmail: `withdrawn_${member.uuid}`, // not null 때문에 해시된 이메일도 특수한 형식으로 변경
name: '[withdrawn member]',
nickname: '[withdrawn member]',
phoneNumber: null,
socialProfiles: null,
status: MemberStatus.WITHDRAWAL,
deletedAt: new Date(),
// 추가적인 개인정보 필드들도 null 처리
// 보안 관련 필드 초기화 추가
password: null,
passwordChangedAt: null,
passwordResetToken: null,
passwordResetTokenExpiresAt: null,
verificationToken: null,
verificationTokenExpiresAt: null,
twoFactorSecret: null
});
return true;
});
} catch (error) {
// 에러 로깅
this.logger.error(`Failed to withdraw member: ${member.uuid}`, error.stack);
throw new InternalServerErrorException('회원 탈퇴 처리 중 오류가 발생했습니다.');
}
}
4. 콘텐츠 관리 시스템
TipTap 에디터 통합
프론트엔드에서는 TipTap 에디터를 사용하여 HTML문서가 아닌 JSON형식으로 요청을 전송한다. 이러한 요청이 저장될 때, 주키는 자동 증가값을 사용한다. 이는 내부 로직 처리용이며 외부로 노출하지 않는다. 따라서 클라이언트에게 제공할 식별자로 다른 값이 필요한데, 그것이 public_id이다. 이 값은 nanoId 라이브러리를 사용하여 생성한다. 이 값은 생성된 후 즉시 테이블 칼럼에 등록되지 않고, 먼저 테이블을 조회하여 동일값 유무를 확인한다. 동일값이 존재하는 경우 생성 로직을 재수행한다.
슬러그는 SEO와 사용자 편의성을 염두에 두고 만들었다. 슬러그의 경우 식별자(public_id)와 '/'로 구분하는 경우도 있고 '-'로 붙여 사용하는 경우도 있었는데, 나의 경우는 나이키 페이지를 참고하여 '-'로 붙여 사용하는 방식을 채택하였다. 슬러그는 현재 포스팅 최초 등록 시 제목을 '-'로 이어 만든 값을 사용하고 있다. 추후 슬러그 자체 입력/수정 기능을 추가할 예정이다.

이미지 처리 및 스토리지 연동
포스트 작성 시 이미지는 클라이언트에서 직접 스토리지(Supabase)로 전송한다. 스토리지는 저장 성공 시 URL을 클라이언트에게 반환하고, 클라이언트는 해당 파일 경로만 백엔드에 전달한다. 즉, 현재 서버에서는 이미지를 전혀 관리할 수 없는 상태이며, 앞서 언급했듯 보안에 취약하고 가공에 비효율적이다. 그러나 현재 운영환경의 사양을 고려할 때 그것이 나은 방법이라고 여겨 선택하였다. 추후 프론트의 로직을 변경하여 서버에서 이미지를 처리하고, 스토리지의 경우 Supabase가 아닌 자체 스토리지 구축을 희망하고 있다.
5. 배포 인프라
AWS Lightsail 설정
AWS Lightsail을 활용하여 배포 환경을 구축했다. 원래는 회사 업무에서 하듯 라즈베리파이4에서 온프레미스로 구축하려고 했으나 개발 효율성이 너무 떨어져 결국 AWS를 사용하게 되었다. Route53을 통해 도메인을 구매하였고 고정 IP와 AWS 웹콘솔 방화벽 설정을 사용하였다. 원래대로라면 인스턴스에 설치된 운영체제의 방화벽을 웹콘솔 방화벽과 동일하게 맞춰주는 것이 좋겠지만 거기까지 진행하지는 않았다.
Docker와 Nginx
현재 백엔드 애플리케이션은 Docker 컨테이너로 패키징하여 구동되고 있다. 원래는 사양을 최대한 절약하려 도커를 사용하지 않으려 했지만 운영체제와 애플리케이션에서 사용하는 C++로 작성된 bycript 라이브러리의 버전 충돌 문제로 인해 배포에 실패하고 말았다. 비용을 절약하기 위해 개발서버를 구축하지 않고 로컬 환경에서만 개발을 진행하다가 생긴 문제였다. 물론 JS로 작성된 bcrypt 라이브러리를 사용하면 해결 할 수 있지만 이 경우 속도 문제는 물론 새로운 라이브러리를 설치해야 하고 코드를 수정해야 할 가능성이 생기기 때문에 결국 도커를 사용하고 말았다.
Nginx는 리버스 프록시로 사용하여 요청을 백엔드로 라우팅한다. 모든 CORS 관련 설정과 경로를 관리하며, 백엔드 애플리케이션 자체는 CORS: false 처리하여 일체의 외부 접근을 받지 않는다.
6. 보안 고려사항
HTTPS 적용
운영 환경에서의 모든 통신은 HTTPS를 통해 이루어지도록 설정하였다. SSL인증서의 경우 Let'sEncrypt를 사용하여 무료로 생성하였다
CORS 설정
프로덕션 환경의 경우 API에 대한 접근은 Nginx에서 관리하며 허용된 도메인(프론트엔드 도메인, 소셜 로그인 도메인 등)에서만 가능하도록 CORS 정책을 관리한다. 백엔드 애플리케이션은 '운영환경의 경우' 일체의 외부 접근을 받지 않는다.
에러 처리 및 로깅
웹서버의 액세스 및 에러 로그는 Nginx 설치 시 설정되는 디렉토리에 보관하며, 현재는 크론잡을 통해 30일을 기준으로 삭제한다. 애플리케이션 로그는 nestJS에서 기본 제공되는 Logger 라이브러리를 사용하며, warning과 error만 보관된다.
7. 개선점 및 학습 내용
프로젝트를 통해 배운 점
NestJS를 이용한 백엔드 아키텍처 구성과 모듈 간 의존성 관리를 경험했다.
리프레시 토큰 로테이션 방식으로 JWT 인증 시스템을 구현하며 보안과 사용자 경험의 균형을 맞추는 법을 배웠다.
TypeORM을 활용한 데이터 모델링 과정에서 초기 설계와 실제 개발 사이의 간극을 경험했다. 특히 포스팅 테이블을 분리하는 과정에서 리팩토링의 중요성을 깨달았다.
클라이언트와 스토리지 간 직접 통신 방식이 서버 부하는 줄이지만 보안과 이미지 처리에 제약이 있음을 경험했다.
실제 서비스 배포 과정에서 환경 차이(로컬과 운영)로 인한 문제(bcrypt 버전 충돌)를 Docker로 해결하는 과정을 경험했다.
향후 개선하고 싶은 부분
백엔드에서 이미지 처리를 담당하도록 변경하고, Supabase가 아닌 자체 스토리지 시스템 구축
탈퇴 회원 데이터 관리에 대한 법적 요구사항 조사 및 자동 삭제 시스템 구현
인스턴스 내 OS 방화벽 설정을 AWS 웹콘솔 방화벽과 일치시켜 보안 강화
슬러그 자체 입력/수정 기능 추가
데이터베이스 백업 및 복구 시스템 구축
Redis 도입을 통한 성능 최적화: 현재 자주 조회되는 데이터(인기 포스트, 최근 게시물 등)를 매번 데이터베이스에서 조회하고 있어 성능 병목이 발생하고 있다. Redis를 캐싱 레이어로 도입하여 빈번한 조회 요청에 대한 응답 시간을 단축하고 데이터베이스 부하를 줄이고자 한다. 또한 JWT 블랙리스트 관리에도 활용하여 로그아웃된 토큰의 재사용을 방지할 계획이다.
Redis를 활용한 분산 락 구현: 현재는 단일 인스턴스 내에서 세마포어를 사용하여 동시성을 제어하고 있으나, 추후 서비스 확장 시 여러 인스턴스로 부하를 분산할 경우 이 방식은 한계가 있다. Redis의 원자적 명령어(SETNX, EVAL 등)를 활용한 분산 락을 구현하여 다중 인스턴스 환경에서도 포스팅 조회수 증가, 연속 새로고침으로 인한 중복 요청 처리 등에서 데이터 일관성을 유지하고자 한다.
Elasticsearch 도입을 통한 검색 기능 강화: 현재 블로그 내 검색 기능은 단순 SQL LIKE 쿼리를 사용하고 있어 검색 속도와 정확도가 낮다. 컨텐츠가 증가함에 따라 이러한 한계가 더욱 두드러질 것으로 예상된다. Elasticsearch를 도입하여 전문 검색(Full-text search)을 구현하고, 한글 형태소 분석을 통해 검색 품질을 향상시키고자 한다. 특히 블로그 성격상 콘텐츠 검색 기능은 사용자 경험에 직접적인 영향을 미치는 중요한 요소이다.
결론
이번 NestJS 백엔드 개발을 통해 실제 서비스 운영에 필요한 다양한 기술적 도전을 경험했다. 초기 설계와 구현 과정에서의 선택들이 추후 개발 과정에 미치는 영향을 직접 체감했으며, 특히 데이터베이스 설계와 사용자 인증 시스템 구현에서 많은 배움을 얻었다. 실무에서 직면할 수 있는 여러 문제(환경 차이, 리소스 제약, 보안 고려사항)를 해결하는 과정은 개발자로서 큰 자산이 되었다. 앞으로 Redis와 Elasticsearch 같은 특화된 기술을 도입하여 성능, 동시성 제어 및 검색 기능을 강화하고, 보안과 확장성을 더욱 개선해 나갈 계획이다. 이 프로젝트는 완성형이 아닌 지속적으로 발전시켜 나갈 대상이며, 이를 통해 백엔드 개발에 대한 이해와 기술력을 계속해서 향상시키고자 한다.