blog

Vercel 서버리스에서 고정 아웃바운드 IP 확보하기: 프록시, 캐싱, Lightsail

Front-end / DevOps#Vercel#서버리스#프록시#캐싱#AWS Lightsail#Squid

토스증권 OpenAPI는 호출 서버의 IP를 미리 등록해 두는 IP 허용 목록 방식으로 접근을 제한한다. 로컬 개발은 문제없었다. 집과 회사 PC는 IP가 고정이라 그 IP만 등록하면 됐다.

문제는 배포 환경이었다. 이 프로젝트(토스증권 매매 대시보드)는 Vercel에 배포되어 있는데, Vercel 함수의 아웃바운드 IP는 인스턴스에 따라 바뀐다. "이 IP만 허용"하는 방식과는 근본적으로 맞지 않는다. 배포 직후 대시보드에서 토스 API 호출이 간헐적으로 403·502로 실패했고, 로컬에서는 재현되지 않아 원인을 찾는 데도 시간이 걸렸다.

이 글은 이 문제를 세 단계로 푼 기록이다.

1단계: 프록시로 아웃바운드 IP 고정

Vercel 함수의 IP는 바꿀 수 없으니, 고정 IP를 가진 중계 서버를 두고 모든 토스 API 호출을 그 서버를 거쳐 나가게 했다. 어느 인스턴스에서 실행되든 토스 서버가 보는 발신 IP는 프록시의 고정 IP가 된다.

먼저 관리형 프록시 애드온으로 빠르게 검증했다. 프록시 URL을 환경 변수로 주입하고, axios 인스턴스에 https-proxy-agent를 연결했다.

import { HttpsProxyAgent } from 'https-proxy-agent';

const OUTBOUND_PROXY_URL = process.env.OUTBOUND_PROXY_URL;
const proxyAgent = OUTBOUND_PROXY_URL ? new HttpsProxyAgent(OUTBOUND_PROXY_URL) : undefined;

const tossClient = axios.create({
  baseURL: TOSS_API_BASE_URL,
  httpsAgent: proxyAgent,
  proxy: false, // axios 자체 프록시 설정과 겹치지 않게 끔
  timeout: TOSS_REQUEST_TIMEOUT_MS,
});

환경 변수가 없으면(로컬 개발) 직접 호출한다. 로컬 IP는 이미 등록되어 있으니 프록시 트래픽을 쓸 필요가 없다. 토큰 발급부터 계좌·시세 조회까지 모든 토스 API 호출에 같은 클라이언트를 썼다.

동시 요청이 몰리자 407

403·502는 사라졌지만, 대시보드 첫 진입 때 여러 API 라우트가 거의 동시에 토스 API를 부르면서 관리형 프록시 무료 플랜의 동시 연결 한도를 넘었다. 일부 요청이 407 Proxy Authentication Required로 거부되고 다시 502로 이어졌다.

유료 플랜으로 올리기 전에, 서버에서 프록시를 거치는 동시 요청 수를 세마포어로 제한했다.

class Semaphore {
  private available: number;
  private readonly queue: Array<() => void> = [];

  constructor(limit: number) {
    this.available = limit;
  }

  async acquire(timeoutMs: number): Promise<void> {
    if (this.available > 0) {
      this.available -= 1;
      return;
    }
    await new Promise<void>((resolve, reject) => {
      const onGranted = () => {
        clearTimeout(timer);
        resolve();
      };
      const timer = setTimeout(() => {
        const index = this.queue.indexOf(onGranted);
        if (index !== -1) this.queue.splice(index, 1);
        reject(new Error('프록시 동시 요청 한도 대기 시간 초과'));
      }, timeoutMs);
      this.queue.push(onGranted);
    });
  }

  release(): void {
    const next = this.queue.shift();
    if (next) next();
    else this.available += 1;
  }
}
  • 타임아웃은 두 곳 모두에 건다. 요청 하나가 응답도 에러도 없이 멈추면 슬롯이 반환되지 않고 뒤의 요청이 줄줄이 멈춰 함수 타임아웃(504)으로 번진다. axios 요청 타임아웃과 세마포어 대기 타임아웃을 각각 두고, finally에서 반드시 release()한다.
  • 세마포어는 인스턴스 단위다. Vercel Fluid Compute에서는 한 인스턴스가 여러 요청을 동시에 처리하므로 모듈 스코프 세마포어가 그 인스턴스 안의 동시성을 제한한다. 하지만 인스턴스가 여러 개 뜨면 전체 동시 연결 수는 "인스턴스 수 × 한도"까지 늘 수 있다.

처음에는 한도를 2로 잡았는데, 유료 플랜으로 올린 뒤에도 "대기 시간 초과"로 실패했다. 대시보드 한 번에 실제 동시 호출이 8~10개라, 한도 2는 병목을 프록시에서 우리 큐로 옮겼을 뿐이었다. 6으로 올리고 나서 정상화되었다. 한도는 실제 동시 호출 수를 측정한 뒤 정해야 한다.

2단계: 캐싱으로 프록시 호출 줄이기

다음 문제는 비용이었다. 관리형 프록시는 월 요청 횟수로 과금되었고, 전송량보다 횟수 한도가 먼저 찼다. 매번 토스 API를 새로 부를 필요가 없는 데이터는 캐싱하기로 했다.

여기서 서버리스 특유의 함정이 있다. 라우트별 모듈 스코프 캐시가 이미 있었지만, 함수는 여러 인스턴스로 뜨고 콜드 스타트가 잦아서 한 인스턴스 안에서만 유효한 캐시는 효과가 작았다. 새로고침할 때마다 콜드 인스턴스가 같은 요청을 다시 보냈다.

L1(메모리) + L2(DB) 공유 캐시

계좌와 무관한 공개 데이터(지수, 섹터, 거래일 캘린더, 환율)부터 적용했다. 인스턴스 간에 캐시를 공유하려면 외부 저장소가 필요한데, 이미 쓰던 Postgres(Neon)에 캐시 테이블 하나를 두었다.

export async function withSharedCache<T>(
  key: string,
  ttlMs: number,
  compute: () => Promise<T>,
): Promise<T> {
  const now = Date.now();

  const local = l1.get(key);
  if (local && now < local.expiresAt) return local.payload as T;

  const pending = inFlight.get(key);
  if (pending) return pending as Promise<T>;

  const run = (async () => {
    try {
      const shared = await readL2(key, now).catch(() => undefined);
      if (shared) {
        l1.set(key, shared);
        return shared.payload as T;
      }

      const fresh = await compute();
      const entry = { payload: fresh, expiresAt: Date.now() + ttlMs };
      l1.set(key, entry);
      await writeL2(key, entry).catch(() => {});
      return fresh;
    } finally {
      inFlight.delete(key);
    }
  })();

  inFlight.set(key, run);
  return run;
}
  1. 요청 병합(in-flight): 같은 인스턴스에서 같은 키를 동시에 요청하면 실제 조회는 한 번만 한다.
  2. L1(메모리): 인스턴스가 살아 있는 동안 DB 조회도 생략한다.
  3. L2(DB): 인스턴스가 바뀌어도 다른 인스턴스가 채워 둔 값을 재사용해 토스 API 호출을 막는다.

TTL은 데이터 성격에 맞췄다(거래일 캘린더 60분, 환율 10분). 서버 캐시 TTL을 늘린 만큼 클라이언트 React Query staleTime도 5분 → 15분으로 맞춰, 서버는 15분짜리 캐시를 주는데 클라이언트가 5분마다 다시 묻는 불일치를 없앴다.

compute()가 실패하면 아무것도 캐시하지 않는다. L2 쓰기가 실패해도 무시한다. 다음 요청이 다시 조회해 채우면 되므로, 캐시 계층의 장애가 본 기능(시세 조회)까지 끌어내리지 않게 했다.

계좌 데이터는 별도 모듈로

보유 종목, 매수 가능 금액처럼 계좌마다 다른 데이터는 공유 캐시와 다른 모듈로 분리했다. 공개 데이터는 TTL만으로 관리하면 되지만, 계좌 데이터는 주문이 체결되는 순간 즉시 무효화되어야 한다는 다른 규칙을 갖기 때문이다. 둘을 섞으면 "TTL이 남았는데 지워야 하는 경우"와 "TTL만 보면 되는 경우"의 경계가 흐려진다.

계좌 데이터는 사용자별 30초 TTL로 캐시하고, 주문 체결 시 해당 사용자 캐시를 명시적으로 지운다. 그리고 체결과 겹쳐 진행 중이던 조회의 결과는 캐시에 쓰지 않도록 막았다. 그러지 않으면 "체결 → 무효화 → 아직 끝나지 않은 이전 조회가 체결 전 데이터를 다시 채움" 순서로 무효화가 무력화된다.

3단계: 관리형 프록시 → AWS Lightsail 자체 호스팅

관리형 프록시는 검증에는 좋았지만, 요청 횟수 과금이라 트래픽이 늘면 결국 플랜을 올려야 했다. "고정 IP 하나"가 목적이라면 과했다.

그래서 AWS Lightsail에 가장 작은 인스턴스를 만들고 Squid를 HTTP 포워드 프록시로 올렸다. Lightsail의 고정 IP(Static IP)를 인스턴스에 붙이고 그 IP를 토스 콘솔에 등록했다.

# .env - 형식: http://<사용자>:<비밀번호>@<고정IP>:3128
OUTBOUND_PROXY_URL=http://user:pass@x.x.x.x:3128

보안상 주의한 점:

  • 인증 필수: Squid에 basic auth를 걸어 아무나 이 프록시로 나가지 못하게 했다. 열린 프록시는 금방 악용된다.
  • 프록시 인증 정보는 평문으로 전송된다: http:// 프록시 주소를 쓰면 HTTPS 요청 내용은 CONNECT 터널 안에서 암호화되지만, Proxy-Authorization 헤더는 Vercel과 프록시 사이에서 평문이다. 긴 무작위 비밀번호를 쓰고 주기적으로 바꾸며, 가능하면 프록시 앞에 TLS를 두는 구성을 고려한다.
  • 허용 목적지 제한: Squid ACL로 나갈 수 있는 목적지를 토스 API 도메인으로만 제한하면, 인증 정보가 새도 피해 범위가 줄어든다.

프록시 URL을 처음부터 환경 변수로 추상화해 둔 덕분에, 이전 작업은 값 하나를 바꾸는 것으로 끝났다.

참고로 Vercel도 상위 플랜에서 고정 아웃바운드 IP 기능을 제공한다. 팀 규모와 비용에 따라 자체 프록시와 비교해 보고 선택하면 된다.

정리

  • 프록시로 고정 IP 확보: 발신 IP를 통제할 수 없다면, 발신지를 고정 IP 중계 서버로 바꾼다. 대신 프록시의 동시 연결 한도가 새 병목이 되므로 애플리케이션에서도 동시성을 제어한다.
  • 캐싱으로 호출 자체를 줄이기: 서버리스 다중 인스턴스에서는 메모리 캐시만으로 부족하다. 이미 쓰는 DB를 L2로 활용해 인스턴스 간에 공유한다. 무효화 규칙이 다른 데이터는 처음부터 다른 모듈로 나눈다.
  • 관리형에서 자체 호스팅으로: 요구사항이 "고정 IP 하나"처럼 명확해지면 자체 호스팅이 비용과 통제 면에서 유리했다. 다만 운영·보안 책임도 함께 가져온다.