BFF와 서버 캐싱을 활용한 성능 최적화

BFF(Backend for Frontend)와 서버 캐싱을 활용한 성능 최적화

November 20, 2024

최근 프론트엔드 성능을 향상 시키기 위해 BFF의 API 계층을 설계하는 기능을 개발하였습니다. 이 글에서는 BFF를 추가하게 된 배경과 서버 캐싱을 적용한 이유, 그리고 캐싱 구현 과정을 담았습니다.


🎯 BFF(Backend For Frontend) 도입 배경

제가 운영하는 웹 서비스는 Client Side Rendering(CSR) 기반의 서비스입니다. 따라서 모든 네트워크 요청은 클라이언트에서 이루어지며, 사용자 경험에 직접적인 영향을 미칩니다.

최근 개발한 기능에서는 여러 개의 API 호출이 필요했는데, 이를 클라이언트에서 개별적으로 처리하면 다음과 같은 문제점이 발생했습니다.

  1. 네트워크 지연 증가 : 여러 개의 API 요청을 순차적으로 수행하면 응답을 기다리는 시간이 길어집니다.

  2. 비즈니스 로직의 복잡성 증가: 여러 데이터들을 조합해야 하는 경우, 클라이언트 코드에서 비즈니스 로직이 섞이게 되어 유지보수가 어려워집니다.

  3. 서버 부담 증가: 동일한 요청이 반복적으로 발생하면 서버 리소스를 낭비하게 됩니다.

이를 해결하기 위해 BFF(Backend for Frontend) 계층을 추가하여 API 요청을 중앙에서 처리하도록 설계하는 방향을 떠올리게 되었습니다. 추가적인 최적화로 서버 캐싱을 적용하여 네트워크 지연 문제를 해결하고자 하였습니다.


import express from 'express';

const app = express();

app.get('/providers', async (req, res) => {

const resp = await generateProviders(req)
    res.send({ data: resp });
});

app.get('/providers/:addr', async (req, res) => {

    const resp = await generateProvider(req)
    res.send({ data: resp });

});

Restful 한 API에 맞도록 작성하고 요청별로 비즈니스 로직을 수행합니다.

generateProviders, generateProviders 함수안에서 여러개의 API를 호출하고, 비즈니스 로직을 수행하게 됩니다.

관심도에 대한 분리는 해결되었습니다.

하지만, 사용자는 아직도 데이터를 받기 까지 긴 시간을 기다려야 합니다. 또한 서버의 입장에서는 같은 요청에 대해 동일한 로직을 수행하게 됩니다. 이를 해결하고자 서버에 캐싱을 도입하고자 하였습니다.


⚡ Cache

캐시(Cache)는 자주 사용하는 데이터를 빠르게 접근할 수 있도록 저장하는 메모리 공간입니다. 일반적으로 속도가 느린 저장소(예: 데이터베이스, 원격 API 등)에서 데이터를 가져오는 대신, 더 빠른 저장소(예: RAM, Redis, 브라우저 캐시 등)에 데이터를 임시로 저장하여 성능을 최적화 할 수 있습니다.

저는 Redis를 사용하여 캐시를 구축하고 합니다. 도입에는 아래와 같은 이유가 있습니다.

  1. Redis는 메모리 기반 데이터 저장소이므로, 디스크 기반 DB보다 훨씬 빠르게 데이터를 제공할 수 있습니다.

  2. 메모리는 프로그램이 종료되면 소멸됩니다. 하지만 캐시 데이터의 경우, 데이터의 영구성은 필요하지 않으므로 좋은 선택이 될 수 있습니다.

저는 Lazy Loading 방식으로 캐싱을 구현하였습니다. 클라이언트가 요청 할 때만 캐시를 업데이트하고, TTl을 설정하여 오래된 데이터가 남아있지 않도록 하였습니다. 싱글톤 패턴으로 관리하였으며 functions 함수가 로드되면 인스턴스를 통해 redis client와 connect 합니다.


import { createClient, RedisClientType } from 'redis';

class Cache {
private client: RedisClientType = createClient();

    public async conn() {
    	try {
    		console.log('Start to Connect Redis');

    		await this.client.connect();

    		console.log('Redis Connected');
    	} catch (e) {
    		this.onError(new Error('Redis Connection Failed'));
    		console.log('Redis Connect Failed', e);
    	}
    }

    public async set(arg: { key: string; value: any; ttl?: number }) {
    	const { key, value, ttl = 60000 } = arg;

    	if (!(await this.ready)) return;

    	try {
    		await this.client.set(key, value, { PX: ttl });
    		console.log('Cache Set Succeed', key);
    	} catch (e) {
    		console.log('Cache Set Failed', key, e);
    	}
    }

    public async get(key: string) {
    	try {
    		await this.ready;
    		const result = await this.client.get(key);
    		if (!result) return null;

    		return result
    	} catch (e) {
    		console.log('Cache Get Failed', key);
    		return null;
    	}
    }

}

export const cache = new Cache();

위 설계 방향에서 한 가지 걸리는 점이 있습니다. 바로 Functions를 재배포하면 redis client는 다시 connect를 시도합니다.

redis client에 connect 요청을 보내고 응답이 오기 전에, 클라이언트로부터 요청이 들어오면 어떻게 될까요?

아직 client는 연결이 되지 않았으므로, get 함수에서 catch문으로 빠지게 되고 캐시되지 않은 데이터를 사용자에게 내려줍니다.

캐시의 주 목적은 사용자에게 빠르게 데이터를 제공해준다 입니다. 따라서 위 방식은 나쁘지 않아 보입니다.

"나는 connect를 시도하는 중간에 반복되는 로직을 실행하고 싶지 않아!" 라는 관점으로 설계를 한다면, 사용자의 요청이 들어왔을 때 Connect가 되기 전까지 대기해야합니다. 그렇다고 무한정 대기하는 것은 캐시의 목적에 맞지 않으므로, 타이머를 설정해주어 문제를 해결 할 수 있어 보입니다.

import { createClient, RedisClientType } from 'redis';

class Cache {
	private client: RedisClientType = createClient({
				socket: {
					host: ---,
					port: 6379,
				},
			});

	private onError: (arg: Error) => void = () => {};
	private onReady: (arg: boolean) => void = () => {};
	public readonly ready: Promise<boolean> = new Promise((resolve, reject) => {
		this.onReady = resolve;
		this.onError = reject;
	});

	public async conn() {
		try {
			console.log('Start to Connect Redis');

			const timeout = setTimeout(() => this.onError(new Error('Redis Connection Timeout')), 1000);
			await this.client.connect();

			clearTimeout(timeout);
			this.onReady(true);

			console.log('Redis Connected');
		} catch (e) {
			this.onError(new Error('Redis Connection Failed'));
			console.log('Redis Connect Failed', e);
		}
	}

	public async set(arg: { key: string; value: any; ttl?: number }) {
		const { key, value, ttl = 60000 } = arg;

		if (!(await this.ready)) return;

		try {
			await this.client.set(key, value, { PX: ttl });
			console.log('Cache Set Succeed', key);
		} catch (e) {
			console.log('Cache Set Failed', key, e);
		}
	}

	public async get(key: string) {
		if (!(await this.ready)) return null;
		try {
			await this.ready;
			const result = await this.client.get(key);
			if (!result) return null;

			return JSON.parse(value);
		} catch (e) {
			console.log('Cache Get Failed', key);
			return null;
		}
	}
}

export const cache = new Cache();

ready 함수는 onReady와 onError에게 각각 resolve와 reject 함수의 레퍼런스를 전달입니다. 따라서 onReady나 onError 함수가 실행될 때까지 ready는 pending 상태가 되고, 이후에 성공과 실패에 따라 값을 갖게 됩니다.

이렇게 구현하면 모든 get 요청은 redis client가 connect 되기 전까지 기다릴 수 있게 됩니다. onError 함수를 호출하여 ready의 pending 상태를 풀어주어야 합니다.

여기서 중요한건 타이머를 설정해, 일정 시간이 지나면 onError를 호출에 ready의 pending 상태를 풀어주어야 합니다.

이렇게 BFF를 구현하여 프론트앤드와의 관심도를 분리하여 프론트엔드의 부하를 줄이고, 성능을 개선하기 위해 Redis 를 사용해 서버 캐싱을 구현해 보았습니다.