
새로운 프로젝트를 준비하면서 인증 방식부터 다시 고민하게 되었다. 이전 프로젝트에서는 JWT를 사용해 유저 인증과 상태 관리를 처리했지만, 이번에는 Redis를 활용한 세션 방식으로 전환하기로 결정했다. 이 방식은 유저 상태를 서버에서 직접 관리할 수 있어 보안 측면에서도 유리하고, 필요할 경우 특정 세션을 즉시 무효화할 수 있다는 점에서 운영 측면에서도 장점이 있다. Redis를 도입한 김에 인증 외의 일부 기능에도 캐싱을 적용해보자는 이야기가 자연스럽게 나왔다.
처음에는 세션 관리와 캐싱이 같은 방식으로 처리될 것이라 막연히 생각했다. 둘 다 Redis를 사용하고, 결국 key-value 저장이라는 점에서는 동일해 보였기 때문이다. 하지만 실제로 구현에 들어가 보니 두 기능은 Redis를 사용한다는 점만 같을 뿐, 접근 방식과 구조는 꽤나 달랐다. 세션 관리는 사용자별 상태를 일정 시간 유지하고, 요청이 들어올 때마다 갱신하거나 만료시키는 흐름이 중심이 되는 반면, 캐싱은 특정 요청에 대한 결과를 빠르게 재사용하기 위한 조회·저장 로직이 핵심이었다. 결국 두 기능은 목적도, 책임도 다르기 때문에 분리된 설계가 필요하다는 결론에 이르게 되었다.
세션 관리
세션 관리를 위해 가장 먼저 해야 할 일은 main.ts에서 Redis 클라이언트를 생성하고, express-session과 연결해주는 것이다. 세션 옵션은 많지만, 실제로 중요한 설정은 몇 가지로 압축된다.
resave: false
세션 데이터에 변경이 없는 경우 매 요청마다 다시 저장하지 않는다. 일반적으로 비활성화하는 것이 권장된다.saveUninitialized: false
세션에 아무런 데이터가 없는 초기 상태라면 세션을 생성하지 않는다. 로그인 이후에만 세션을 만들고 싶은 경우 유용하다.rolling: true
요청이 들어올 때마다 세션 만료 시간을 갱신한다. 사용자가 활동 중인 동안 세션이 만료되지 않도록 하는 데 사용한다.
// src/main.ts
const redisClient = createClient();
await redisClient.connect();
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
maxAge: 1000 * 60 * 60,
},
}),
);이후 Passport를 초기화하고, 세션 기반 인증을 활성화한다.
app.use(passport.initialize());
app.use(passport.session());
const authService = app.get(AuthService);
passport.serializeUser(authService.serializeUser.bind(authService));
passport.deserializeUser(authService.deserializeUser.bind(authService));serializeUser와 deserializeUser는 세션 기반 인증의 핵심이다. 로그인에 성공하면 serializeUser가 호출되어 세션에 저장할 최소한의 정보(보통 user id)를 결정하고, 이후 요청이 들어올 때마다 deserializeUser가 해당 id를 기반으로 실제 유저 정보를 조회해 req.user에 주입한다.
serializeUser(user: User, done: Function) {
done(null, user.id);
}
async deserializeUser(id: string, done: Function) {
const user = await this.authRepository.findById(id);
done(null, user);
}컨트롤러 단에서는 req.login, req.logout을 사용해 세션을 직접 제어할 수 있다. 이는 Passport가 Express의 req 객체에 주입해주는 메서드다.
OAuth 2.0과 세션
OAuth 2.0 역시 세션 기반 인증과 자연스럽게 결합된다. OAuth는 인증 수단일 뿐이고, 실제 로그인 상태를 유지하는 역할은 세션이 담당한다. OAuth 인증이 성공하면 req.login을 호출해 해당 유저를 세션에 저장하면 된다. 이 구조에서는 OAuth로 로그인했든, 로컬 로그인으로 로그인했든 이후의 인증 흐름은 완전히 동일하다.
@UseGuards(Auth('google'))
@Post('google/callback')
async loginGoogle(@Req() req: Request, @Res() res: Response) {
await new Promise<void>((resolve, reject) => {
req.login(req.user, (err) => {
if (err) return reject(err);
resolve();
});
});
return res.json({ message: '로그인 성공', user: req.user });
}캐싱
세션과 달리 캐싱은 유저 상태와 무관하다. 특정 요청에 대한 결과를 빠르게 반환하는 것이 목적이며, 데이터의 생명주기도 훨씬 단순하다. 그래서 Redis 클라이언트를 서비스나 컨트롤러에서 직접 사용하는 대신, CacheRepository라는 얇은 추상화를 두었다.
@Injectable()
export class CacheRepository {
constructor(
@Inject('REDIS_CLIENT')
private readonly redisClient: RedisClientType,
) {}
async get<T>(key: string): Promise<T | null> {
const data = await this.redisClient.get(key);
return data ? JSON.parse(data) : null;
}
async set<T>(key: string, value: T, ttl = 300) {
await this.redisClient.set(key, JSON.stringify(value), { EX: ttl });
}
async del(key: string) {
await this.redisClient.del(key);
}
async getOrSet<T>(
key: string,
fetcher: () => Promise<T>,
ttl = 300,
): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) return cached;
const fresh = await fetcher();
await this.set(key, fresh, ttl);
return fresh;
}
}getOrSet 메서드를 사용하면 캐시 조회, 캐시 미스 처리, TTL 설정 로직을 한 번에 처리할 수 있어 서비스 계층의 코드가 훨씬 깔끔해진다.
WebSocket 유저 상태 관리
단일 서버 환경에서는 소켓 연결 정보를 메모리(Map)에 저장해도 문제가 없었다. 하지만 서버를 여러 대로 확장하는 순간 이 방식은 바로 한계를 드러낸다. 각 서버가 서로 다른 메모리를 사용하기 때문이다. 그래서 소켓 연결 상태 역시 Redis로 관리하기로 했다. 구조는 단순하다. 유저가 소켓에 연결되면 userId → socketId 매핑을 Redis에 저장하고, 연결이 끊기면 이를 삭제한다.
@SubscribeMessage('userConnect')
async handleUserConnect(client: Socket, { userId }: { userId: string }) {
client.data.userId = userId;
await this.redisClient.set(`socket:user:${userId}`, client.id, { EX: 3600 });
}메시지를 보낼 때는 Redis에서 socketId를 조회해 해당 소켓으로 emit하면 된다.
async sendMessage(userId: string, payload: Notification) {
const socketId = await this.redisClient.get(`socket:user:${userId}`);
if (!socketId) return;
this.server.to(socketId).emit('notification', payload);
}이 방식은 서버 수와 관계없이 동일하게 동작하며, 소켓 상태를 서버 프로세스와 분리할 수 있다는 장점이 있다.
더 읽어보기
2025.01.30
비즈니스 로직은 어디에 있어야 할까
Nest.js에서 컨트롤러와 각각의 프로바이더는 분명한 책임을 갖는다. 하지만 막상 서버를 개발하다 보면, 이 책임들을 깊이 고민하지 않은 채 코드를 작성하게 되고, 그 결과 클래스들이 서로의 영역을 침범하는 상황이 반복된다. 나 역시 컨트롤러에 비즈니스 로직이 섞이거나, 서비스가 지나…
2024.12.07
13. Jest 테스트 구성
이 포스트를 시작하기에 앞서 한 가지 분명히 밝혀두고 싶은 점이 있다. 나는 컨트롤러(혹은 GraphQL 기준으로는 리졸버) 와 레포지토리 클래스에 대한 테스트를 거의 작성하지 않는다.이유는 단순하다. 애플리케이션에서 가장 많은 비즈니스 규칙이 응집되어 있는 곳은 서비스 계층이며, 테스…
2024.11.14
12. GraphQL로 요청 처리
GraphQL은 페이스북에서 개발한 데이터 쿼리 언어이자 런타임으로, 기존 REST API가 가진 구조적 한계를 보완하기 위해 등장했다. 전통적인 REST API에서는 각 엔드포인트마다 반환되는 데이터 구조가 고정되어 있고, 동일한 리소스에 대해 서로 다른 작업을 수행하기 위해 HTTP…
2024.11.11
11. webSocketGateway를 사용한 알림 기능
웹소켓은 클라이언트와 서버 간에 지속적인 연결을 유지함으로써 실시간 양방향 통신을 가능하게 하는 프로토콜이다. 일반적인 HTTP 요청이 요청–응답 단위로 연결을 맺고 끊는 방식이라면, 웹소켓은 한 번 연결을 맺은 뒤 그 연결을 계속 유지하며 데이터를 주고받는다. 이 덕분에 알림, 채팅과…
2026.04.11
Trie 자료구조
문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…
2026.03.19
Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까
웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…
2026.03.19
Streams API 부록 1. HTTP 다운로드 진행률은 어떻게 계산될까
파일을 다운로드할 때 가끔 몇 퍼센트 진행되었는지 혹은 진행 막대(progress bar)가 조금씩 채워지는 모습을 볼 수 있다. 그런데 모든 다운로드가 이런 식으로 진행률을 보여 주는 것은 아니다. 어떤 다운로드는 퍼센트가 표시되지만, 어떤 경우에는 진행 막대 없이 로딩 스피너만 계속…
2026.03.13
Streams API 4. 왜 모든 언어에는 Stream API가 존재할까
Streams API를 공부하다 보면 묘한 기시감을 느끼게 된다. JavaScript에서 ReadableStream, WritableStream, TransformStream을 살펴보고 있는데, 어딘가 낯설지 않다. Java를 써 본 사람이라면 InputStream, OutputStre…
댓글
댓글을 불러오는 중...