PUBLISHED
14. Redis를 사용한 세션 관리 및 캐싱
작성일: 2025.05.21

새로운 프로젝트를 준비하면서 인증 방식부터 다시 고민하게 되었다. 이전 프로젝트에서는 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);
}이 방식은 서버 수와 관계없이 동일하게 동작하며, 소켓 상태를 서버 프로세스와 분리할 수 있다는 장점이 있다.