Skip to content
@FISA-BOCE

FISA-BOCE

[우리FISA 6기] 클라우드 엔지니어링 과정 2팀

클엔의 정석 : 서가영(PM), 이채유(PL), 박성준, 김종연


프로젝트 개요

💁 주제

일상 소비를 자산으로 자동 전환하는 그룹사 내 락인(Lock-in) 플랫폼

🚀 프로젝트 기획 배경

  • 소멸되거나 방치되는 카드 포인트의 비효율을 해결하기 위해 기획된 프로젝트입니다. 카드 결제로 적립된 포인트를 고객이 직접 관리하지 않아도 ETF 투자로 자동 투자함으로써, 흩어진 소액 포인트를 자산 형성의 수단으로 연결합니다.
  • 이는 카드 포인트가 같은 계열 증권사의 투자 자산으로 이어지는 구조이기에 고객은 계열사 금융 서비스를 지속적으로 이용하게 되어 자연스러운 락인(Lock-in)시너지 효과가 발생하며, 충성 고객 확보와 자산 이탈 방지로 이어집니다.
  • 고객에게는 포인트 활용도를 높여주는 동시에 금융사에게는 계열사 간 자산 선순환과 고객 유지라는 비즈니스 가치를 함께 제공하는 것을 목표로 합니다.
  • 또한 금융권 망분리 규제를 준수하는 하이브리드 클라우드 아키텍처 상에서 카드사·증권사 간 안전한 데이터 연계 구조를 구현합니다.

⚒️ 기술 스택

Category Stack
Infra AWS VMware Azure Linux
Database MySQL Redis Neo4J ProxySQL
Development Java Spring Boot TypeScript React Native Expo
DevOps Kubernetes Prometheus Grafana Thanos Docker Terraform
Collaboration Slack Notion Figma Discord Swagger n8n

2. 아키텍쳐

🌐 시스템 아키텍쳐

시스템 아키텍처 drawio

설명

금융 서비스의 보안성, 확장성, 고가용성을 확보하기 위해 온프레미스 데이터센터와 AWS 클라우드, Microsoft Azure를 연계한 하이브리드 멀티 클라우드 아키텍처 구조로 설계했습니다. 핵심 원장 및 주요 데이터베이스 영역은 온프레미스 환경에 배치하고, 사용자 요청을 처리하는 채널계 서비스는 AWS 클라우드 기반으로 구성하여 금융권 환경에 적합한 망 분리와 서비스 확장 구조를 함께 고려했습니다.

AWS

사용자 요청은 Route 53을 통해 유입되며, WAF에서 웹 공격 및 비정상 트래픽을 1차적으로 차단합니다. 이후 API Gateway가 각 서비스 도메인별 요청을 분기하고, VPC Link를 통해 외부에 직접 노출되지 않는 AWS Private Subnet 내부로 트래픽을 전달합니다. 이를 통해 실제 애플리케이션 서버와 내부 서비스는 외부 접근으로부터 보호됩니다.

AWS 영역은 카드망, 증권망, 공통망 VPC로 분리하였습니다. 카드망과 증권망은 각각 독립적인 서비스 영역으로 구성하여 SQS를 통해 장애 전파를 최소화하고, 공통망은 인증 및 공통 기능을 담당하는 영역으로 설계하였습니다. 각 VPC는 DMZ, EKS, Data Layer, Public Subnet으로 세분화되어 역할별 네트워크 경계를 명확히 수행합니다.

카드망과 증권망의 DMZ 영역에는 Nginx 기반 EC2 인스턴스와 Internal NLB를 배치하여 외부 API Gateway와 내부 WAS 계층 사이의 중계 구간을 구성하였습니다. 이후 트래픽은 EKS 내부 서비스로 전달되며, EKS는 업무 WAS Pod와 모니터링 Pod를 운영합니다. Auto Scaling Group과 Node Group을 통해 서비스 부하 증가 및 노드 장애 상황에서도 안정적인 서비스 운영이 가능하도록 하였습니다.

데이터 계층은 Redis, PostgreSQL, MySQL 등으로 분리 구성하였습니다. Redis는 Sentinel 기반으로 장애 조치가 가능하도록 설계하였으며, 서비스별 DB를 분리하여 장애 영향 범위를 줄였습니다. 온프레미스 계정계 DB와의 연계를 통해 중요 원장 데이터는 내부망에서 관리하고, 클라우드는 채널계 처리 중심으로 운영합니다.

온프레미스

온프레미스 데이터센터는 핵심 계정계 시스템과 DB 고가용성 구성을 담당합니다. Nginx, 애플리케이션 VM, ProxySQL, Orchestrator, MySQL 이중화 구조를 통해 DB 장애 발생 시 Primary 전환과 접속 경로 변경이 가능하도록 구성하였습니다. 이를 통해 계정계 DB 장애 상황에서도 서비스 연속성을 확보할 수 있습니다.

VPN 터널링

AWS와 온프레미스 간 통신은 각각 이중화된 Site-to-Site VPN 및 WireGuard 기반 사설망 연결을 통해 수행됩니다. 이를 통해 클라우드 채널계 서비스와 온프레미스 계정계 시스템이 공용 인터넷에 직접 노출되지 않고 안전하게 연계됩니다.

Azure

Microsoft Azure 영역은 외부 AI 서비스 연계를 위한 별도 망으로 구성하였습니다. Azure Container Apps, Managed Identity, Key Vault, Private Endpoint, Azure OpenAI 연계 구조를 통해 AI 기능을 금융 서비스 영역과 분리된 환경에서 안전하게 처리할 수 있도록 설계하였습니다.


🏡 소프트웨어 아키텍처

소프트웨어 아키텍처

설명

사용자 접근 계층부터 업무 서비스, AI 처리, 플랫폼 공통 기능, 데이터 계층까지 역할에 따라 분리된 계층형 구조로 구성되어 있습니다. 사용자의 요청은 모바일 APP, 관리자 대시보드, 내부 운영 호출을 통해 유입되며, 이후 각 도메인 서비스로 전달되어 카드·증권·공통 업무 로직이 수행됩니다.

또한 AI 지능형 처리 계층을 별도로 두어 챗봇 질의 분석, 데이터 조회 분기, 비동기 이벤트 처리, 배치 작업을 업무 서비스와 분리했습니다.

인증/인가, 감사 로그, 배포, 보안 연동과 같은 공통 운영 기능은 플랫폼 계층에서 관리하며, 데이터 계층에서는 서비스별 DB, 캐시, AI 분석 DB, 그래프 DB를 목적에 따라 분리하여 확장성과 유지보수성을 고려한 구조로 설계되었습니다.


3. 주요 기능 소개

👩🏻‍💻 핵심 기술 구성

서비스 핵심 기술

🧩 통합 워크플로우 다이어그램

통합 워크플로우 다이어그램

👀 세부 기능 소개

1️⃣ Keepalived와 Route Table 전환 기반 WireGuard VPN Tunnel HA

  • 기능 설명

    1. WireGuard EC2 및 VM의 Health Check 과정

      WireGuard 이중화 환경에서는 온프레미스 VM과 AWS EC2가 각각 3초 주기로 Health Check를 수행합니다. VM은 Keepalived vrrp_script로 AWS EC2 터널 IP를 확인하고, EC2는 systemd timer 기반 스크립트로 온프레미스 VM과 Peer EC2의 Private IP를 확인합니다. 2회 연속 실패 시 장애로 판단하며, VM 구간은 VIP 이동, EC2 구간은 Route Table ENI 전환으로 장애에 대응합니다.

    2. VPN Tunnel 장애 시 EC2 Failover

      AWS WireGuard EC2는 온프레미스 VM과의 터널 상태를 ping으로 확인하고, 2회 연속 실패 시 장애로 판단합니다. 장애 발생 시 Route Table의 10.1.200.0/24 대상 ENI를 조회하여 자신이 Active인 경우 replace-route 명령으로 Peer EC2 ENI로 라우팅을 전환합니다. Standby 상태라면 별도 변경 없이 로그만 남깁니다.

    3. VPN Tunnel 장애 시 VM Failover

      온프레미스 WireGuard VM은 Keepalived 기반으로 VIP 10.1.200.60을 공유하며, 내부 시스템은 해당 VIP를 Next-hop으로 사용합니다. 각 VM은 AWS EC2 터널 대상에 3초마다 ping을 수행하고, 2회 연속 실패 시 장애로 판단합니다. Active VM 장애 시 Standby VM이 VRRP를 통해 VIP를 인계받아 내부 라우팅 변경 없이 트래픽을 정상 VM으로 전환합니다.

  • 핵심 코드(스크립트)

    온프레미스 VM과의 터널 장애를 감지한 뒤, 현재 EC2가 Active인 경우에만 AWS Route Table의 대상 ENI를 Peer EC2로 변경하여 트래픽을 정상 터널로 전환합니다.

     #!/usr/bin/env bash
     set -euo pipefail
     source /etc/wg-ha.env
    
     ping_ok() {
       ping -c 1 -W 1 "$1" >/dev/null 2>&1
     }
    
     get_current_route_target() {
       aws ec2 describe-route-tables \
         --region "$REGION" \
         --route-table-ids "$ROUTE_TABLE_ID" \
         --query "RouteTables[0].Routes[?DestinationCidrBlock=='$DEST_CIDR'].NetworkInterfaceId |    [0]" \
         --output text
     }
    
     switch_route() {
       aws ec2 replace-route \
         --region "$REGION" \
         --route-table-id "$ROUTE_TABLE_ID" \
         --destination-cidr-block "$DEST_CIDR" \
         --network-interface-id "$PEER_ENI"
     }
    
     if ! ping_ok "$MY_VM_IP"; then
       current_target="$(get_current_route_target)"
    
       if [[ "$current_target" == "$MY_ENI" ]]; then
         echo "Tunnel failure detected. Switch route to peer EC2."
         switch_route
       else
        echo "Standby node detected failure. No route change."
       fi
     fi
  • 코드(스트립트) 링크: FISA-BOCE/WON-Infra-IaC#12


2️⃣ Thanos 기반 Prometheus 이중화 및 Grafana 통합 관측 플랫폼 구축

  • 기능 설명

    Kubernetes 환경에서 Prometheus 단일 장애점을 제거하기 위해 kube-prometheus-stack의 Prometheus를 2개의 replica로 구성하고, 각 Prometheus Pod에 Thanos Sidecar를 함께 배치하였습니다.

    각 Prometheus는 WAS Pod, Kubernetes 리소스, Node 등의 메트릭을 수집하고, Thanos Query는 여러 Prometheus Sidecar의 데이터를 통합 조회하며 replica 라벨을 기준으로 중복 메트릭을 제거합니다. Grafana는 개별 Prometheus가 아닌 Thanos Query를 단일 DataSource로 사용하여, Prometheus 장애 상황에서도 안정적인 통합 모니터링 화면을 제공합니다.

  • 핵심 코드(스크립트)

    해당 설정은 Prometheus를 replicas: 2로 이중화하고, 각 Prometheus Pod에 Thanos Sidecar를 배치하여 메트릭을 Thanos Query로 통합 조회하도록 구성한 설정입니다.

    Grafana는 개별 Prometheus가 아닌 Thanos Query를 DataSource로 사용하며, replicaExternalLabelName을 기준으로 중복 메트릭을 제거해 하나의 통합 모니터링 화면을 제공합니다.

     prometheus:
       enabled: true
    
       thanosService:
         enabled: true
         clusterIP: "None"
    
       prometheusSpec:
         replicas: 2
    
         externalLabels:
           cluster: won-card-eks
    
         replicaExternalLabelName: replica
    
         thanos:
           image: quay.io/thanos/thanos:v0.39.0
    
     grafana:
       enabled: true
    
       sidecar:
         datasources:
           defaultDatasourceEnabled: false
    
       additionalDataSources:
         - name: Thanos
           type: prometheus
           url: http://thanos-query.monitoring.svc.cluster.local:9090
           isDefault: true
  • 코드(스트립트) 링크: FISA-BOCE/WON-Infra-IaC#50


3️⃣ ProxySQL + Orchestrator 및 Redis Sentinel 기반 DB/Cache 계층 HA 구성

  • 기능 설명

    1. MySQL Primary-Replica 기반 DB 고가용성 구성 계정계 DB의 단일 장애점을 줄이기 위해 MySQL을 Primary-Replica 구조로 구성하고, GTID 기반 복제를 통해 장애 복구 및 Replica 재합류 절차를 단순화하였습니다.

    Orchestrator가 MySQL 상태와 복제 토폴로지를 모니터링하고, Primary 장애 발생 시 Replica를 새로운 Primary로 승격하도록 구성하였습니다.

    Orchestrator의 PostFailover hook을 통해 승격된 DB를 ProxySQL writer hostgroup에 자동 반영하여, 애플리케이션 설정 변경 없이 신규 Primary로 트래픽이 전달되도록 하였습니다.

    1. Redis Sentinel 기반 캐시 계층 고가용성 구성 카드망과 증권망 각각에 Redis EC2 3대를 배치하고, 1 Master + 2 Replica와 Redis Sentinel 3개로 HA 구조를 구성하였습니다.

      Sentinel은 quorum 2 기준으로 Master 장애를 감지하고 Replica를 자동 승격하며, 애플리케이션은 Sentinel을 통해 현재 Master를 조회하여 설정 변경 없이 Redis 연결을 유지합니다.

  • 핵심 코드(스크립트)

    1. Failover 이후 ProxySQL writer를 신규 Primary로 전환하는 Hook Script

    Orchestrator가 전달한 successorHost 값을 기반으로 승격된 DB를 식별하고, ProxySQL writer hostgroup을 신규 Primary DB로 갱신하여 애플리케이션 트래픽이 자동으로 전환되도록 한다.

    #!/bin/bash
    
    SUCCESSOR_HOST="${4:-}"
    SUCCESSOR_PORT="${5:-3306}"
    
    WRITER_HOSTGROUP=10
    MYSQL_CNF="/etc/orchestrator/secrets/proxysql-admin.cnf"
    
    map_host() {
      case "$1" in
        mysql-01) echo "10.1.200.201" ;;
        mysql-02) echo "10.1.200.202" ;;
        *) echo "$1" ;;
      esac
    }
    
    SUCCESSOR_PROXYSQL_HOST="$(map_host "${SUCCESSOR_HOST}")"
    
    mysql --defaults-extra-file="${MYSQL_CNF}" -e "
    DELETE FROM mysql_servers WHERE hostgroup_id=${WRITER_HOSTGROUP};
    
    INSERT INTO mysql_servers(hostgroup_id, hostname, port, max_connections)
    VALUES (${WRITER_HOSTGROUP}, '${SUCCESSOR_PROXYSQL_HOST}', ${SUCCESSOR_PORT}, 1000);
    
    LOAD MYSQL SERVERS TO RUNTIME;
    SAVE MYSQL SERVERS TO DISK;
    "
    1. Terraform 기반 Redis 3노드 배치 & Sentinel 기반 장애 감지 및 자동 Failover
    resource "aws_instance" "this" {
      for_each = var.nodes
    
      ami                    = var.ami_id
      instance_type          = var.instance_type
      subnet_id              = each.value.subnet_id
      private_ip             = each.value.private_ip
      vpc_security_group_ids = [aws_security_group.this.id]
    
      tags = {
        Name = each.value.name
        Role = each.value.is_master ? "redis-master" : "redis-replica"
      }
    }
    port 26379
    
    sentinel monitor card-redis-master 10.11.31.101 6379 2
    sentinel auth-pass card-redis-master ${REDIS_PASSWORD}
    sentinel down-after-milliseconds card-redis-master 5000
    sentinel failover-timeout card-redis-master 60000
  • 코드(스트립트) 링크: FISA-BOCE/WON-Infra-IaC#46


4️⃣ Neo4j + MySQL + Azure AI API 기반 질문 유형별 응답 최적화

  • 기능 설명

    챗봇 기능은 사용자의 자연어 질문을 Azure OpenAI로 분석해 queryType, dbTarget, dataSource를 분류하고, 분류 결과에 따라 카드/증권/그래프 DB 조회 흐름으로 분기하는 기능입니다. 이후 조회 결과와 원본 질문을 다시 Azure OpenAI에 전달하여 사용자가 이해하기 쉬운 자연어 답변으로 변환합니다.

  • 핵심 코드(스크립트)

    1. 질문 유형 분류 Enum

    사용자 질문을 어떤 조회 유형으로 처리할지 구분하는 queryType 목록입니다.

    public enum QueryType {
      CARD_MONTHLY_TOTAL_SPEND,
      POINT_CURRENT_BALANCE,
      POINT_MONTHLY_EARNED,
      ETF_LIST,
      ETF_AMOUNT,
      SAME_ETF_AVERAGE_POINT,
      MY_POINT_INVESTMENT_PATH,
      UNKNOWN
    }
    1. 사용자 질문 의도 분류 로직

    Azure OpenAI에 사용자 질문을 전달하여 조회 유형, 대상 DB, 데이터 출처를 JSON 형태로 분류하고 검증하는 로직입니다.

    public ClassifyResponse classify(ClassifyRequest request) {
      String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
      String userMessage = String.format("[오늘 날짜: %s]\n%s", today, request.message());
    
      String json = openAiClient.callWithJsonResponse(SYSTEM_PROMPT, userMessage);
      ClassifyResponse response = objectMapper.readValue(json, ClassifyResponse.class);
    
      validateClassifyResponse(response);
      return response;
    }
    1. 조회 결과 기반 자연어 답변 생성 로직

    원본 질문, 조회 유형, DB 조회 결과를 프롬프트로 구성하여 Azure OpenAI로 최종 답변을 생성하는 로직입니다.

    public AnswerResponse generateAnswer(AnswerRequest request) {
      String userMessage = String.format(
          "질문: %s\n조회 유형: %s\nDB 조회 결과: %s",
          request.originalMessage(),
          request.queryType(),
          serializeDbResult(request)
      );
    
      String answer = openAiClient.callWithTextResponse(SYSTEM_PROMPT, userMessage);
      return new AnswerResponse(answer);
    }
  • 코드(스트립트) 링크: FISA-BOCE/WON-AI-SERVER#2


5️⃣ SQS와 스케줄러 기반 카드 포인트 ETF 전환 비동기 배치 처리 + 자동 투자

  • 기능 설명

    카드 포인트를 ETF 투자로 전환하는 과정에서 카드 채널과 증권 채널 간 직접 동기 호출 의존도를 줄이기 위해 SQS 기반 비동기 메시지 구조를 적용하였습니다.

    새벽 스케줄러가 스윕 대상 포인트를 선점하고, 카드 채널계는 스윕 요청과 Outbox 이벤트를 저장한 뒤 SQS FIFO Queue로 발행합니다. 증권 채널계는 해당 요청을 수신하여 증권 계정계의 자동 투자 로직을 호출하고, 처리 결과를 Result Queue로 다시 카드 채널계에 전달합니다.

    이를 통해 망분리 환경에서도 메시지 유실과 중복 처리 위험을 줄이고, 카드 포인트 전환 요청부터 ETF 자동투자 결과 반영까지 비동기 기반으로 안정적으로 처리할 수 있도록 구성하였습니다.

  • 핵심 코드(스크립트)

    1. 스윕 대상 포인트 원장 선점

    스윕 대상 포인트 원장을 REQUESTED 상태로 변경하여 동일 포인트가 중복으로 전환 요청되지 않도록 처리하는 코드입니다.

    public void markSweepRequested(
          Long batchExecutionId,
          String idempotencyKey,
          LocalDateTime requestedAt
    ) {
        this.batchExecutionId = batchExecutionId;
        this.sweepRequestId = this.pointLedgerId;
        this.idempotencyKey = idempotencyKey;
        this.sweepRequestedAt = requestedAt;
        this.sweepStatus = SweepStatus.REQUESTED;
        this.sweepFailureCode = null;
        this.sweepFailureMessage = null;
    }
    1. 스윕 요청과 Outbox 이벤트 저장

    스윕 요청 데이터와 SQS 발행 대상 Outbox 이벤트를 함께 저장하여 메시지 발행 실패 시에도 재시도할 수 있도록 구성한 코드입니다.

    Sweep sweep = Sweep.createPendingPublish(
          target,
          correlationId,
          idempotencyKey,
          requestedAt
    );
    
    Sweep savedSweep = sweepRepository.save(sweep);
    
    SweepRequestedEvent event = SweepRequestedEvent.from(savedSweep, eventId);
    String payload = objectMapper.writeValueAsString(event);
    
    SweepOutbox outbox = SweepOutbox.pending(
            savedSweep.getSweepRequestId(),
            eventId,
            SweepEventType.SWEEP_REQUESTED,
            payload,
            correlationId,
            idempotencyKey
    );
    
    sweepOutboxRepository.save(outbox);
    1. SQS FIFO Queue로 스윕 요청 발행

    Outbox에 저장된 스윕 요청 이벤트를 SQS FIFO Queue로 발행하고, 성공 시 발행 완료 상태로 변경하는 코드입니다.

    SendMessageRequest request = SendMessageRequest.builder()
          .queueUrl(sqsProperties.sweepRequestQueueUrl())
          .messageBody(message.payload())
          .messageDeduplicationId(message.idempotencyKey())
          .messageGroupId(createMessageGroupId(message.cardUserUuid()))
          .build();
    
    sqsClient.sendMessage(request);
    
    statusService.markPublished(outboxEventId);
    1. 증권 채널계의 스윕 요청 수신

    증권 채널계가 SQS Request Queue에서 스윕 요청을 읽고, 처리 성공 후에만 메시지를 삭제하여 유실 위험을 줄이는 코드입니다.

    ReceiveMessageResponse response = sqsClient.receiveMessage(
          ReceiveMessageRequest.builder()
                  .queueUrl(sqsProperties.sweepRequestQueueUrl())
                  .maxNumberOfMessages(properties.maxMessages())
                  .waitTimeSeconds(properties.waitTimeSeconds())
                  .build()
    );
    
    for (Message message : response.messages()) {
        executor.execute(() -> handleSafely(message));
    }
    
    processService.process(claimResult.inboxEventId(), event);
    deleteMessage(message);
    1. 증권 계정계 자동 투자 실행

    증권 채널계가 받은 스윕 요청을 증권 계정계로 전달하고, 계좌/ETF 검증 후 포인트 금액을 ETF 매수 수량으로 계산하는 코드입니다.

    InvestCoreSweepExecutionResponse response =
          investCoreApi.executeSweep(InvestCoreSweepExecutionRequest.from(event));
    
    if (response == null || response.status() == null) {
        throw new BusinessException(SweepErrorCode.SWEEP_CORE_UNAVAILABLE);
    }
    
    if (!response.completed() && !response.failed()) {
        throw new BusinessException(SweepErrorCode.SWEEP_CORE_UNAVAILABLE);
    }
    
    successService.saveResultAndMarkProcessed(inboxEventId, event, response);
    1. Result Queue로 처리 결과 발행

    증권 처리 결과를 SQS Result Queue로 발행하여 카드 채널계가 최종 스윕 상태를 반영할 수 있도록 하는 코드입니다.

    SendMessageRequest request = SendMessageRequest.builder()
          .queueUrl(sqsProperties.sweepResultQueueUrl())
          .messageBody(message.payload())
          .messageGroupId(message.correlationId())
          .messageDeduplicationId(message.idempotencyKey())
          .build();
    
    sqsClient.sendMessage(request);
    
    statusService.markPublished(outboxEventId);
  • 코드(스트립트) 링크: [카드 채널계]

    [카드 계정계]

    [증권 채널계]

    [증권 계정계]

Popular repositories Loading

  1. WON-FRONT WON-FRONT Public

    TypeScript

  2. WON-Card-Core-SERVER WON-Card-Core-SERVER Public

    Java

  3. WON-Card-Channel-SERVER WON-Card-Channel-SERVER Public

    Java

  4. WON-Invest-Core-SERVER WON-Invest-Core-SERVER Public

    Java

  5. WON-Invest-Channel-SERVER WON-Invest-Channel-SERVER Public

    Java

  6. WON-Common-SERVER WON-Common-SERVER Public

    Java

Repositories

Showing 10 of 12 repositories

People

This organization has no public members. You must be a member to see who’s a part of this organization.

Top languages

Loading…

Most used topics

Loading…