들어가며

최근 회사에서 내부 어드민용 API 서비스를 구축하게 됐습니다. 사내 구성원만 사용하는 민감한 도구이다 보니, 처음 설계할 때부터 보안을 최우선으로 고려했습니다. 당연히 Internal ALB(Application Load Balancer) 를 사용해 외부 인터넷 접근을 원천 차단하고, 사내 VPC 내부에서만 접근 가능하도록 만들었죠.

여기까지는 아주 교과서적인 선택이었는데요. 문제는 '테스트 환경'을 연동하려고 할 때 발생했습니다.

회사에선 프로덕션(Production) 환경과 테스트(Test) 환경을 아예 다른 AWS 계정으로 분리해서 운영하고 있습니다. VPC도 당연히 분리되어 있고요. 그러다 보니 테스트 계정의 워크로드에서 프로덕션 계정의 Internal ALB를 호출해야 하는 상황인데, 네트워크가 단절되어 있으니 접근할 방법이 없더라고요.

이 문제를 해결하기 위해 고민했던 과정과, AWS PrivateLink를 통해 안전하게 파이프라인을 뚫어낸 경험을 공유해 보려고 합니다.

선택지 탐색: "어떻게 연결할 것인가?"

단순히 "연결만 되면 된다"면 방법은 여러 가지가 있습니다. 하지만 엔지니어링은 늘 제약 조건 속에서 최적의 해를 찾는 과정이잖아요. 제가 고려했던 선택지는 크게 세 가지였습니다.

옵션 A: Public ALB로 전환 + Security Group 제어

가장 쉬운 방법입니다. Internal ALB를 Internet-facing으로 바꾸고, Security Group(SG)에서 테스트 계정의 NAT Gateway IP만 허용하는 방식이죠.

다만 "내부 전용"이라는 설계 원칙이 깨집니다. 실수로 SG를 0.0.0.0/0으로 여는 순간 보안 사고로 이어질 수 있죠. 심리적으로도 꺼려졌습니다.

옵션 B: VPC Peering

가장 대중적인 방법입니다. 두 VPC를 네트워크 레벨에서 연결하는 거죠.

관리가 귀찮아집니다. 양쪽 라우팅 테이블을 다 건드려야 하고, 만약 두 VPC의 IP 대역(CIDR)이 겹치면 아예 사용 불가능합니다. (다행히 이번엔 안 겹쳤지만, 미래의 확장성을 생각하면 불안 요소였어요.)

옵션 C: AWS PrivateLink

VPC 엔드포인트를 통해 AWS 백본 네트워크로 통신하는 방식입니다.

CIDR 충돌 걱정이 없고, 라우팅 테이블을 건드릴 필요가 없습니다. 무엇보다 '필요한 서비스만' 콕 집어서 노출할 수 있다는 점이 매력적이었습니다. VPC 통째로 뚫는 Peering보다 보안상 훨씬 안전하다고 판단했습니다.

비용이 조금 더 들더라도, 운영 편의성보안 격리 측면에서 PrivateLink가 가장 합리적이라고 판단하게 되었습니다.

PrivateLink가 뭔가요?

보통 서로 다른 VPC 간에 통신하려면 인터넷 게이트웨이를 타거나 복잡한 Peering을 맺어야 한다고 생각하기 쉬운데요. PrivateLink는 AWS의 비공개 백본망(Backbone Network)을 통해 트래픽을 전송하는 기술입니다. 인터넷 구간을 전혀 타지 않기 때문에 보안적으로 매우 강력하죠.

이 기술을 구성하는 핵심 요소는 두 가지입니다.

  • Endpoint Service (서비스 제공자) "내 서비스를 다른 계정에 보여줄게." 서비스를 제공하는 쪽(여기서는 프로덕션 계정)에 만드는 문입니다.
  • VPC Endpoint (서비스 사용자) "그 서비스, 내가 좀 쓸게." 서비스를 사용하는 쪽(여기서는 테스트 계정)에 만드는 '접속 지점'입니다.

결국 [테스트 계정의 Endpoint] → [AWS 백본망] → [프로덕션 계정의 Endpoint Service] 로 이어지는 전용 터널을 뚫는 셈입니다. 이렇게 하면 VPC CIDR이 겹쳐도 상관없고, 라우팅 테이블을 복잡하게 관리할 필요도 없어집니다.

아키텍처 구상: ALB 앞에 NLB를?

PrivateLink를 쓰기로 결정하고 문서를 찾아보니, 한 가지 난관이 있었습니다. PrivateLink의 Endpoint Service는 Network Load Balancer(NLB)만 연결할 수 있다는 점이었어요.

이미 서비스는 L7 기능(Path-based routing 등)이 필요해서 ALB로 잘 돌아가고 있었거든요.

"그럼 ALB를 NLB로 바꿔야 하나?" 아닙니다. L7 기능은 포기할 수 없으니까요. 결국 [NLB → ALB → ECS] 형태의 구조를 잡아야 했습니다.

최종적인 큰 그림은 이렇습니다.

[테스트 계정 VPC]                   [프로덕션 계정 VPC]
     App                                 Service
      ↓                                     ↑
 VPC Endpoint ─── (AWS Backbone) ─── Endpoint Service
                                            ↓
                                           NLB
                                            ↓
                                       Internal ALB
                                            ↓
                                       ECS Fargate

구축 과정 (Step-by-Step)

이론은 완벽하니, 바로 실행에 옮겨봤습니다.

Step 1. NLB 및 타겟 그룹 생성

먼저 프로덕션 계정에서 NLB를 생성합니다. 이때 핵심은 Target Group 설정입니다.

None

1) Target Group 생성

  • Target type: Application Load Balancer
  • Protocol / Port: TCP / 443
  • VPC: ALB가 있는 그 VPC
  • Health Check: ALB가 443 포트로 헬스 체크에 응답하도록 설정 (보통 /health 같은 경로를 쓰는데, NLB는 TCP 레벨 체크가 기본이라 HTTP 상태 코드를 확인하려면 설정을 잘 봐야 합니다. 저는 간단하게 TCP 연결 확인으로 뒀습니다.)
None

2) NLB 생성

  • Scheme: Internal (PrivateLink-only로 운영하려면 Internal이 정석)
  • Listener: TCP 443 → 방금 만든 Target Group 연결

Step 2: VPC Endpoint Service 생성

NLB를 만들었으니, 이걸 외부(다른 계정)로 송출할 안테나를 세울 차례입니다.

None
  1. VPC 콘솔 → 엔드포인트 서비스 → 엔드포인트 서비스 생성으로 이동합니다.
  2. 로드 밸런서 유형은 Network를 선택하고 방금 만든 NLB를 지정합니다.
  3. 엔드포인트 수락 필수(Require acceptance)를 체크하세요. 이걸 체크 안 하면, 서비스 이름만 알면 아무나 연결할 수 있습니다. 보안을 위해 수동 승인 모드를 켰습니다.
  4. 생성이 완료되면 com.amazonaws.vpce... 로 시작하는 서비스 이름을 복사해 둡니다.
  5. [보안 주체 허용] 탭에 테스트 계정의 ARN(arn:aws:iam::<테스트계정ID>:root)을 추가합니다.

Step 3: 테스트 계정에서 연결 요청

이제테스트 계정으로 넘어갑니다.

None
  1. VPC 콘솔 → 엔드포인트 → 엔드포인트 생성을 클릭합니다.
  2. 서비스 카테고리에서 'NLB 및 GWLB를 사용하는 엔드포인트 서비스'를 선택합니다.
  3. 아까 복사한 서비스 이름을 입력하고 '확인'을 누릅니다. (초록색 체크가 뜨면 성공)
  4. VPC와 서브넷을 선택하고 생성 요청을 보냅니다.

Step 4: 연결 수락 및 최종 확인

None

다시 프로덕션 계정으로 돌아와 보면, 엔드포인트 연결 탭에 Pending 상태의 요청이 와 있습니다. 쿨하게 '수락(Accept)'을 눌러줍니다. 잠시 후 상태가 Available로 바뀌면 물리적인(?) 길은 뚫린 겁니다.

삽질의 기록: "왜 연결이 안 되지?"

길은 뚫렸는데 통신이 안 되는 상황을 마주했습니다.

문제 상황 1: 타임아웃 (Timeout)

테스트 계정의 EC2에서 curl을 날려봤습니다.

응답이 없고 그냥 멈춰버립니다. 이건 십중팔구 보안 그룹(Security Group) 문제입니다.

curl -v https://vpce-xxxx.vpce-svc-xxxx.ap-northeast-2.vpce.amazonaws.com
* Trying 172.31.x.x...
# (무한 대기)

원인 분석: 범인은 NLB의 보안 그룹이었습니다. NLB를 생성할 때 급한 마음에 'default' 보안 그룹을 대충 넣어놨는데, 여기에 인바운드 규칙이 없었거든요.

해결: NLB에 적용할 SG를 새로 만들고, 테스트 계정 VPC의 CIDR 대역(예: 172.31.0.0/16)에서 오는 443 포트를 허용해 줬습니다.

문제 상황 2: 그래도 여전히 멈춤 (Still Timeout)

NLB 보안 그룹을 열었는데도 여전히 같은 증상(Timeout)이었습니다. 이번엔 ALB의 보안 그룹을 의심해 봐야 했습니다.

여기서 중요한 개념이 등장하는데요, 바로 Client IP Preservation(클라이언트 IP 보존)입니다.

NLB는 타겟 그룹 설정에 따라 클라이언트의 원래 IP를 보존해서 ALB로 넘겨줍니다. 즉, ALB 입장에서는 요청을 보낸 소스 IP가 '테스트 계정 VPC의 IP'로 찍히는 거죠.

하지만 제 ALB의 보안 그룹은 '프로덕션 VPC 내부'에서만 접근 가능하도록 설정되어 있었습니다. (10.0.0.0/16만 허용) 그러니 테스트 계정(172.31.x.x)에서 날아온 패킷을 ALB가 가차 없이 드랍(Drop)하고 있었던 겁니다.

해결: ALB 보안 그룹의 인바운드 규칙에 테스트 계정 VPC CIDR를 추가해 주었습니다. (참고: 만약 IP 보존을 끄면 NLB의 내부 IP가 소스로 찍히므로, NLB의 IP를 허용해 주면 됩니다. 하지만 로그 분석을 위해 저는 IP 보존을 유지하고 CIDR을 추가하는 방식을 택했습니다.)

마지막: DNS 깔끔하게 다듬기

기능은 이제 잘 동작합니다. 하지만 엔드포인트 URL이 어려웠습니다. vpce-012345abcdef-xyz.vpce-svc...amazonaws.com

코드에 이런 URL을 사용하는건 추후 파악하기도 어렵습니다. 프로덕션 환경에서는 internal-api.test.io 같은 도메인을 쓰고 있으니, 테스트 환경에서도 같은 도메인으로 호출하고 싶었습니다.

이때 사용하는 것이 Route 53의 Private Hosted Zone입니다.

Private Hosted Zone은 쉽게 말해 "우리 VPC 안에서만 유효한 전용 전화번호부(DNS)"라고 보시면 됩니다. 이걸 사용하면 똑같은 도메인 주소라도 VPC에 따라 다른 IP를 알려주게(Split-horizon DNS) 만들 수 있습니다.

  1. 테스트 계정 Route 53에서 test.io라는 Private Hosted Zone을 생성합니다.
  2. 이 존을 테스트 계정 VPC에 연결합니다.
  3. A 레코드(Alias)를 생성합니다.
  • 레코드 이름: internal-api
  • 값: 위에서 만든 VPC Endpoint를 선택 (Alias to VPC Endpoint)

이렇게 설정하면 테스트 환경의 코드에서도 프로덕션과 똑같이 https://internal-api.test.io를 호출하면 됩니다. Route 53이 알아서 "아, 여긴 테스트 계정이네? 그럼 Endpoint 쪽으로 연결해 줄게" 하고 Private IP로 해석해 주니까요. 환경별로 설정을 분기할 필요가 없어져서 애플리케이션 코드가 훨씬 깔끔해졌습니다.

비용과 트레이드오프

모든 기술적 선택에는 비용이 따릅니다. PrivateLink 도입으로 얻은 것과 잃은 것을 정리해 보면 다음과 같습니다.

얻은 것 (Pros)

  • 강력한 보안: 인터넷 구간을 전혀 타지 않고, 필요한 서비스만 핀셋으로 노출할 수 있습니다.
  • 관리 편의성: VPC CIDR 충돌 걱정이 없고, 계정이 늘어나도 Endpoint Service에 권한만 추가하면 됩니다.
  • 아키텍처 일관성: Internal ALB 구조를 그대로 유지하면서 외부 연결을 지원할 수 있습니다.

잃은 것 (Cons)

  • 비용: VPC Peering은 데이터 전송료만 들지만, PrivateLink는 처리 데이터($0.01/GB) 외에도 엔드포인트 사용 시간당 요금($0.01/시간/AZ)이 발생합니다.
  • AZ 2개를 쓴다면 한 달에 약 $15~20 정도가 고정비로 나갑니다. (NLB 비용 별도)
  • 큰 금액은 아니지만, 연결해야 할 VPC가 수십 개라면 부담이 될 수 있습니다.
  • 복잡도: ALB 앞에 NLB가 붙고, 엔드포인트까지 생기니 네트워크 홉(Hop)이 늘어나고 디버깅 포인트가 많아집니다.

마치며

처음엔 "그냥 Peering을 맺을까?" 하는 생각도 있었습니다. 하지만 장기적으로 계정이 더 늘어나고, 보안 요건이 까다로워질 것을 대비해 PrivateLink를 도입했습니다.

특히 개발자 입장에서는 복잡한 네트워크 토폴로지를 신경 쓰지 않고, "그냥 도메인 호출하면 연결된다"는 단순함을 제공해 줄 수 있다는 점이 가장 컸어요.

혹시 멀티 계정 환경에서 내부 서비스 간 통신 때문에 골머리를 앓고 계신 분이 있다면, PrivateLink + NLB 조합도 고려해보시기 바랍니다.