새소식

Java

[Spring] 결제시스템, Spring Boot + MSA 로 리팩토링 [구현]

  • -
728x90
이전 글에서 기획과 설계에 대한 부분을 다뤘고, 이제는 이것을 구현하는 과정을 담는 글을 기록, 

 

 

 

[Spring] 결제시스템, Spring Boot + MSA 로 리팩토링 [기획 & 설계]

실무에서 결제 프로세스를 다루며 많은 문제점들이 발견했다. 레거시한 코드와 절차지향의 프로세스를 통해 처리를 하다 보니 성능이 떨어졌으며, 약간의 과부하만 발생해도 지연 문제가 빈번

seung-seok.tistory.com

 

 

아키텍처와 플로우를 설명했고 이제는 이것을 직접 구현해보자. 각각의 서비스에 대한 로직과 코드에 대해 깊이 설명하지 않고, 이를 구성하는 것에 초점을 두고 글을 작성한다.

 

Docker 를 사용한 서비스 구성

각각의 서비들을 Docker image 로 생성해서 이를 Docker Compose 를 통해서 여러 개의 서비스들을 컨테이너로 구성하고 이를 하나로 통합하여 관리하는 방식으로 서비스를 구현할 것이다.

 

💡 docker-compose란?
여러개의 컨테이너로부터 이루어진 서비스를 구축, 실행하는 순서를 자동으로 하여 관리를 간단하게 하는 툴.
하나의 docker-compose.yaml 파일에 컨테이너에 사용 될 이미지, 옵션 등 여러개의 컨테이너 설정 내용을 모아서 사용한다. compose 파일을 준비해서 커맨드를 1번 실행하는 것만으로 그 파일로부터 설정을 읽어들여 모든 컨테이너 서비스를 실행시킬 수 있기 때문에 더 간편하고 직관적으로 여러 컨테이너를 관리할 수 있다.

 

먼저 간단하게 순서를 정리하면 아래와 같다.

1. 각 서비스들을 image 로 구성

2. 이를 docker-compose 를 통해 서비스화

 

우선 intellij 에서 Dockerfile 을 통해서 image 를 build 하는 방법을 알아보자.

 

Dockerfile 생성 및 코드 작성

Dockerfile은 docker 의 이미지를 직접 생성하기 위한 용도로 작성하는 파일이다. 이에 대해 실행한 코드를 첨부하고 하나하나 설명해 보겠다!

 

Dockerfile

# Base, ubuntu 이미지
FROM ubuntu:18.04

# jdk 설치
RUN apt-get update && \
    apt-get install -y openjdk-17-jdk && \
    apt-get clean;

# work directory 지정
WORKDIR /app

COPY build/libs/OrderService.jar /app/app.jar

# 수행될 Command
CMD ["java", "-jar", "app.jar"]

 

FROM ubuntu:18.04 : Ubuntu 18.04 버전의 공식 Docker 이미지를 기반 이미지로 사용하겠다고 선언, Docker 이미지를 구축할 때 이 기반 이미지에서 시작

 

RUN : 이미지 빌드 과정 중 명령어를 실행한다. 패키지 목록을 업데이트하고 OpenJDK 17 버전을 설치하며, apt-get clean 을 통해 생성된 캐시를 정리하여 Docker 이미지의 크기를 줄인다.

 

WORKDIR : 해당 지시어는 Docker 컨테이너 내에서 명령어가 실행될 기본 디렉토리를 생성한다. 이 지시어 다음에 실행되는 모든 RUN, CMD, ENTRYPOINT, COPY, ADD 지시어는 /app 디렉토리에서 실행된다.

 

COPY : 빌드 컨텍스트의 파일이나 디렉토리를 Docker 이미지 내의 지정된 경로로 복사한다. 여기서는 호스트의 build/libs/OrderService.jar 파일을 컨테이너의 /app/app.jar 로 복사한다는 의미다.

 

CMD : 컨테이너가 시작될 때 실행될 기본 명령어를 정의한다. 이 경우 컨테이너가 실행되면 자바 애플리케이션인 app.jar 파일을 실행한다. java -jar app.jar 명령이 실행되며, 이는 /app 디렉토리 내에 위치한 app.jar 을 실행시킨다.

 

2. Docker build 전초 작업

이렇게 Dockerfile 을 모두 작성했으면 이제 Docker build 를 해야 한다. intellij 의 Edit Configurations 를 누르고 Add New Configuration > Docker > Docker Image 를 선택한다.

 

Docker build

 

image-tag 는 해당 이미지의 이름이 될 것이다. 또한 이미지 생성만 할 것이기 대문에 다른 옵션은 선택하지 않았다.

 

3. application.yml 파일 수정

다중 컨테이너 환경에서, Docker  Compose 를 사용하면 서비스(컨테이너) 간 네트워킹을 용이하게 만들어 준다. 서비스 각각이 Docker Compose 파일에 정의 된 이름으로 서로를 찾을 수 있게 된다. 만약 mysql 을 Docker Compose 에서 mysql-service 로 지칭했다면 url 을 jdbc:mysql://mysql-service:3306/{DB_NAME} 과 같이 표기해서 데이터베이스에 접근할 수 있다.

 

4. bootJar, Jar 파일로 생성

 

Dockerfile 에서 COPY 할 jar 파일을 생성해준다.

 

5. Docker build

 

이러한 과정을 통해서 개발한 Spring Boot 서비스를 Docker 의 이미지로 만들었다.


⭐️ Event 를 이용한 리팩토링

베타 버전에선 모두 동기적으로 처리하며 외부 서비스를 사용할 땐, feign Client 를 사용해서 처리하는 방식으로 진행했다. 이번 알파 버전에선 Kafka 를 통해 비동기적인 방식으로 재구축을 진행한다. feign Client 에 대한 사용법은 따로 포스팅한 내용이 있어서 이를 첨부한다!

 

[Spring] feign 에 대하여

프로젝트를 MSA 구조로 진행하며, 서비스 로직에서 다른 서비스의 API 를 호출해야 하는 경우가 빈번하게 생겼다. 이를 RestTemplate 을 통해 해결할 수도 있지만 보다 더 나은, 간편한 기능을 통해 풀

seung-seok.tistory.com

 

1. kafka 설정

Kafka 를 Spring Boot 에서 사용하기 위해 몇가지 설정을 해주어야 한다. 카프카 연결에 대한 대략적인 내용도 따로 포스팅한 기록이 있다. 이를 참조하면 도움이 될 것 같아 첨부!

 

[Docker] Docker Compose 로 Kafka Cluster를 !

프로젝트를 진행하며 이를 비동기로 처리하기 위한 방안을 모색하던 중, 메세지 브로커인 Kafka 를 사용하기로 결정했다. 처음 접해보는 기술인 만큼 직접 실습해보며, kafka 를 선택한 이유와 그

seung-seok.tistory.com


build.gradle

// kafka
implementation 'org.springframework.kafka:spring-kafka'

 

우선 의존성 설정, build.gradle 에 카프카 의존성을 추가해 주어야한다.

 

application.yml

spring:
  kafka:
    bootstrap-servers:
      # kafka INTERNAL HOST
      - kafka1:9092
      - kafka2:9092
      - kafka3:9092
    consumer:
      group-id: testgroup
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.ByteArraySerializer

 

docker-compose.yml

services:
  zookeeper:
    image: confluentinc/cp-zookeeper:latest
    environment:
      ZOOKEEPER_SERVER_ID: 1
      ZOOKEEPER_CLIENT_PORT : 2181
    ports:
      - "22181:2181"
  
  kafka1:
    image: confluentinc/cp-kafka:latest
    depends_on:
      - zookeeper
    ports:
      - "19092:19092"
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:9092,EXTERNAL://localhost:19092
      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL

  kafka2:
    image: confluentinc/cp-kafka:latest
    depends_on:
      - zookeeper
    ports:
      - "19093:19093"
    environment:
      KAFKA_BROKER_ID: 2
      KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka2:9092,EXTERNAL://localhost:19093
      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL

  kafka3:
    image: confluentinc/cp-kafka:latest
    depends_on:
      - zookeeper
    ports:
      - "19094:19094"
    environment:
      KAFKA_BROKER_ID: 3
      KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka3:9092,EXTERNAL://localhost:19094
      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL

  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    depends_on:
      - kafka1
      - kafka2
      - kafka3
    ports:
      # 호스트 포트 : 컨테이너 내부 포트
      - 8090:8080
    environment:
      KAFKA_CLUSTERS_0_NAME: MyKafkaCluster
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka1:9092,kafka2:9092,kafka3:9092
      KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181

 

 

application.yml 파일이다. 동일하게 같은 Docker Compose 안에 서비스를 구성할 것이기 때문에 직접적인 URL 이 아닌 서비스 명을 기재할 것인데,  docker-compose Kafka 의 KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:9092 를 지정해뒀다. Kafka 서비스의 내부 포트를 그대로 사용해주면 된다. 

 

💡 옵션 설명

group-id : Kafka Consumer Group 의 ID 를 지정. 같은 group-id를 가진 컨슈머들은 메세지를 공유하여 처리한다.

auto-offset-reset : Kafka 에서 해당 컨슈머 그룹의 이전 오프셋이 없거나 유효하지 않을 경우 사용할 초기 오프셋 위치를 지정한다. ealiest 는 가장 초기의 메세지부터 처리하겠다는 것을 의미한다.

key-deserializer : Kafka 의 메세지의 키를 역직렬화하는데 사용할 클래스다. StringDeserializer는 Kafka 메세지의 키를 문자열로 변환한다.

value-deserializer : 메세지 본문을 역직렬화하는데 사용할 클래스다. ByteArrayDeserializer 는 메세지의 값을 바이트 배열로 반환한다.

 

 

2. Protobuf

Protobuf(Protocol Buffers) 는 구글에서 개발한 이진 직렬화 포맷이다. Protobuf 는 플랫폼 및 언어에 독립적이며, 다양한 언어와 시스템에서 데이터를 효율적으로 전송하기 위해 사용된다. 주요 장점은 작은 메세지 크기, 빠른 직렬화/역직렬화 속도 및 강렬한 타입 안정성을 제공한다는 것이다.

 

⭐️ Kafka Event 전달에 쓰이는 데이터 구조의 일관성을 보장하며 손쉽게 정의할 수 있다. 또한 빠르게 데이터를 직렬화/역직렬화 하기 위해 이 기술을 채택했다.

 

build.gradle

plugins {
	id 'com.google.protobuf' version '0.9.4'
}

dependencies {
	implementation 'com.google.protobuf:protobuf-java:3.25.2'
}

// compile 을 위한 task
protobuf {
    protoc {
        artifact = 'com.google.protobuf:protoc:3.25.2'
    }
}

 

위와 같이 플러그인과 라이브러리 의존성을 추가해 주었다. 

 

프로토콜 버퍼 스키마 작성, eda_message.proto 파일

// version
syntax = 'proto3';

// 경로
package payment.protobuf;

message ProductTag {
  int64 product_id = 1;
  repeated string tags = 2;
}

 

프로젝트 내에 원하는 데이터 구조를 정의한다. 주의할 점은 main/proto 경로에 해당 파일이 존재해야 한다는 것이다. 

 

 

프로젝트를 빌드하면 .proto 파일에 대한 Java 코드가 자동으로 생성된다. 이렇게 생성된 코드를 사용하여 데이터를 직렬화/역직렬화 할 수 있다.

 

2. 기존의 프로세스, Kafka 를 통한 EDA 적용

@Autowired
private KafkaTemplate<String, byte[]> kafkaTemplate;

 

서비스 코드에 kafkaTemplate 을 선언해준다. byte[] 로 지정한 이유는 특정 Object 를 직렬화해서 보내기 위함이다.

 

AS-IS

// Open feign
var dto = new ProductTagsDto();
dto.tags = tags;
dto.productId = product.id;

searchClient.addTagCache(dto);

 

기존의 로직은 외부 서비스의 메서드를 feign Client 를 사용하여 직접 호출했다. 이는 동기적인 프로세스이며 효율적이지 않다고 판단하여 수정하면 ..

 

TO-BE

var message = EdaMessage.ProductTags.newBuilder()
                .setProductId(product.id)
                .addAllTags(tags)
                .build();

kafkaTemplate.send("product_tags_added", message.toByteArray());

 

메세지를 생성해서 보내주는 형식으로 수정했다. 이제 메세지를 발행했으니, 소비하는 케이스를 정의해보자. 

 

EventListener

// Bean 등록
@Component
public class EventListener {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    public SearchService searchService;

    @KafkaListener(topics = "product_tags_added")
    public void consumeTagAdded(byte[] message) throws InvalidProtocolBufferException {
        var object = EdaMessage.ProductTags.parseFrom(message);

        logger.info("[product_tags_added] consumer : {}", object);

        searchService.addTagCache(object.getProductId(), object.getTagsList());
    }
}

 

@KafkaListener 어노테이션에 지정한 토픽, "product_tags_added" 이 생성되면 자동으로 consumeTagAdded 메서드가 실행된다. feign Client 를 통해서 사용했던 searchService.addTagCache 를 그대로 메세지 기반으로 동작하기만 하면 되는 방식이다.

 

⭐️ 프로젝트 고도화

정리하면 이렇다!  각각의 서비스를 Docker image 로 생성, 그리고 그것들을 docker-compose 를 사용해 다중 컨테이너의 환경을 하나로 통합하여 관리한다. 기존의 동기적인 서비스들은 Kafka 를 통해 Event 기반의 비동기적 프로세스로 수정하여 다른 서비스들과의 느슨한 결합, 변경 영향도도 최소화를 할 수 있었다.

 

실무에서 아쉬웠던 결제 프로세스에 대한 점들을 이번 프로젝트를 통해 개선해보며 많은 것들을 느꼈다. 이제는 코드 레벨 뿐만 아니라 인프라, 아키텍처에 대한 이해도와 설계 능력도 중요해지는 시기인 만큼 어느 하나라도 놓을 수 없으며 이를 발전시키기 위해 많은 공부를 해야 할 것 같다. 이렇게 이번 프로젝트는 마무리  🖌️

728x90
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.