728x90
반응형

서비스 모듈에 Java Test Fixtures 플러그인 적용 가이드

이 문서는 각 서비스 모듈에 java-test-fixtures 플러그인을 적용하고 테스트 픽스처 모듈을 구성하는 방법을 설명합니다.

개요

java-test-fixtures 플러그인은 테스트에 필요한 공통 데이터와 유틸리티를 모듈 내에서 공유할 수 있게 해주는 Gradle 플러그인입니다. 이 가이드는 각 서비스 모듈에서 테스트 픽스처 모듈을 구성하고 활용하는 방법을 설명합니다.

이점

  • 코드 중복 감소: 여러 테스트에서 반복적으로 사용되는 테스트 데이터를 중앙 집중화
  • 유지보수성 향상: 테스트 데이터 변경 시 한 곳에서만 수정하면 됨
  • 테스트 가독성 향상: 명확한 팩토리 메소드를 통해 테스트 데이터 생성 의도 전달
  • 독립성 확보: 각 서비스 모듈이 자신의 테스트 픽스처를 직접 관리
  • 빌드 성능 최적화: 모듈별로 필요한 테스트 픽스처만 컴파일하고 사용

적용 방법

1. 테스트 픽스처 모듈 생성

서비스 모듈 내에 test-fixtures 디렉토리를 생성하고 다음과 같이 구성합니다

services/
  your-service/           # 예: rate-plan
    test-fixtures/        # 테스트 픽스처 모듈
      build.gradle        # 테스트 픽스처 모듈 빌드 스크립트
      README.md           # 사용 방법 문서
      src/
        testFixtures/     # 테스트 픽스처 소스 코드
          java/
            com/you-service/test/fixtures/
              data/       # 데이터 모델 픽스처
              util/       # 테스트 유틸리티
              annotation/ # 편의 어노테이션
              {domain}/   # 도메인별 테스트 픽스처

 

 

2. 빌드 스크립트 설정

test-fixtures/build.gradle 파일을 다음과 같이 설정합니다

plugins {
    id 'java'
    id 'java-test-fixtures'
    id 'java-library'
}

group = 'com.your-service'  // 예: com.rate
version = '1.0.0'

sourceCompatibility = JavaVersion.VERSION_11  // 서비스에 맞게 조정
targetCompatibility = JavaVersion.VERSION_11  // 서비스에 맞게 조정

repositories {
    mavenCentral()
    maven { url "<https://<nexus>/repository/maven-public/>" }
}

dependencies {
    // 기본 테스트 라이브러리
    api 'org.junit.jupiter:junit-jupiter:5.9.2'
    api 'org.assertj:assertj-core:3.24.2'
    api 'org.mockito:mockito-core:5.2.0'
    api 'org.mockito:mockito-junit-jupiter:5.2.0'
    
    // Spring Boot 테스트 관련 의존성
    api 'org.springframework.boot:spring-boot-starter-test:2.7.6'  // 버전은 서비스에 맞게 조정
    
    // TestContainers
    api 'org.testcontainers:testcontainers:1.18.3'
    api 'org.testcontainers:junit-jupiter:1.18.3'
    api 'org.testcontainers:mysql:1.18.3'  // 필요한 DB에 맞게 조정
    
    // 도메인 특화 의존성
    // 예: Kafka 관련 의존성이 필요한 경우
    // api 'org.apache.avro:avro:1.8.2'
    // api 'io.confluent:kafka-avro-serializer:5.0.0'
    
    // Lombok
    compileOnly 'org.projectlombok:lombok'
    testFixturesCompileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testFixturesAnnotationProcessor 'org.projectlombok:lombok'
}

java {
    withSourcesJar()
    withJavadocJar()
}

test {
    useJUnitPlatform()
}
 

3. 서비스 모듈의 settings.gradle 수정

서비스 모듈의 settings.gradle 파일에 테스트 픽스처 모듈을 포함합니다

rootProject.name = 'your-service'  // 예: 'rate-plan'

include 'your-service-module-1'    // 예: 'rate-plan-api'
include 'your-service-module-2'    // 예: 'rate-plan-management'
// ... 기존 모듈들 ...

include 'test-fixtures'  // 테스트 픽스처 모듈 추가
 

4. 서비스 하위 모듈에 테스트 픽스처 의존성 추가

각 서비스 하위 모듈의 build.gradle 파일에 테스트 픽스처 의존성을 추가합니다

dependencies {
    // 기존 의존성 ...
    
    // 테스트 픽스처 의존성 추가
    testImplementation testFixtures(project(':test-fixtures'))
}

구현 가이드

1. 기본 테스트 컨테이너 유틸리티 구현

src/testFixtures/java/com/yourservice/test/fixtures/util/MySQLTestContainer.java:

package com.yourservice.test.fixtures.util;

import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.utility.DockerImageName;

public class MySQLTestContainer {
    private static final String MYSQL_VERSION = "mysql:8.0";
    private static MySQLContainer<?> container;
    
    public static MySQLContainer<?> getInstance() {
        if (container == null) {
            container = new MySQLContainer<>(DockerImageName.parse(MYSQL_VERSION))
                    .withDatabaseName("testdb")
                    .withUsername("test")
                    .withPassword("test")
                    .withReuse(true);
        }
        return container;
    }
    
    public static void start() {
        getInstance().start();
    }
    
    public static void stop() {
        if (container != null && container.isRunning()) {
            container.stop();
        }
    }
    
    // 편의 메소드
    public static String getJdbcUrl() {
        return getInstance().getJdbcUrl();
    }
    
    public static String getUsername() {
        return getInstance().getUsername();
    }
    
    public static String getPassword() {
        return getInstance().getPassword();
    }
}
 

2. 통합 테스트 어노테이션 구현

src/testFixtures/java/com/yourservice/test/fixtures/annotation/IntegrationTest.java:

package com.yourservice.test.fixtures.annotation;

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Testcontainers
@ActiveProfiles("test")
public @interface IntegrationTest {
}

 

3. 기본 데이터 픽스처 구현

package com.yourservice.test.fixtures.data;

import lombok.Builder;
import lombok.Data;

import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;

@Data
@Builder
public class TestUserFixture {
    private Long id;
    private String username;
    private String email;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    public static TestUserFixture createDefault() {
        return TestUserFixture.builder()
                .id(1L)
                .username("testUser")
                .email("test@example.com")
                .createdAt(LocalDateTime.now())
                .updatedAt(LocalDateTime.now())
                .build();
    }
    
    public static List<TestUserFixture> createTestUsers(int count) {
        return Arrays.asList(
            TestUserFixture.builder().id(1L).username("user1").email("user1@example.com").build(),
            TestUserFixture.builder().id(2L).username("user2").email("user2@example.com").build(),
            TestUserFixture.builder().id(3L).username("user3").email("user3@example.com").build()
        );
    }
}

4. 도메인 픽스처 구현

서비스의 도메인에 맞는 픽스처 클래스를 구현합니다. 예를 들어 요금제 서비스의 경우:

src/testFixtures/java/com/yourservice/test/fixtures/rate/RatePlanFixture.java

package com.yourservice.test.fixtures.rate;

import lombok.Builder;
import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDate;

@Data
@Builder
public class RatePlanFixture {
    private Long id;
    private String name;
    private String code;
    private BigDecimal basePrice;
    private LocalDate startDate;
    private LocalDate endDate;
    private boolean active;
    
    public static RatePlanFixture createDefault() {
        return RatePlanFixture.builder()
                .id(1L)
                .name("Standard Rate")
                .code("STD-RATE")
                .basePrice(new BigDecimal("100000"))
                .startDate(LocalDate.now())
                .endDate(LocalDate.now().plusDays(30))
                .active(true)
                .build();
    }
    
    public static RatePlanFixture createPromotionalRate() {
        return RatePlanFixture.builder()
                .id(2L)
                .name("Summer Promotion")
                .code("SUMMER-PROMO")
                .basePrice(new BigDecimal("80000"))
                .startDate(LocalDate.now())
                .endDate(LocalDate.now().plusDays(14))
                .active(true)
                .build();
    }
}

 

테스트 컨테이너 사용

@IntegrationTest
public class ServiceTest {

    @Container
    private static final MySQLContainer<?> mysqlContainer = MySQLTestContainer.getInstance();
    
    @DynamicPropertySource
    static void registerDatabaseProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", MySQLTestContainer::getJdbcUrl);
        registry.add("spring.datasource.username", MySQLTestContainer::getUsername);
        registry.add("spring.datasource.password", MySQLTestContainer::getPassword);
    }
    
    @Test
    void testService() {
        // 테스트 코드
    }
}
 

데이터 픽스처 사용

@Test
void testWithFixtures() {
    // given
    TestUserFixture user = TestUserFixture.createDefault();
    RatePlanFixture ratePlan = RatePlanFixture.createDefault();
    
    // when
    // 테스트 로직 실행
    
    // then
    assertThat(user.getUsername()).isEqualTo("testUser");
    assertThat(ratePlan.getName()).isEqualTo("Standard Rate");
}

모범 사례

  1. 팩토리 메소드 패턴 사용: createDefault(), createCustom(...) 등의 팩토리 메소드로 픽스처 생성
  2. 충분한 주석: 픽스처의 목적과 데이터 특성을 주석으로 설명
  3. 불변 객체 선호: 가능한 경우 픽스처 객체를 불변으로 설계
  4. 의미 있는 데이터: 실제 비즈니스 시나리오를 반영하는 의미 있는 테스트 데이터 사용
  5. 테스트 컨테이너 재사용: withReuse(true) 옵션을 사용하여 테스트 컨테이너 재사용
  6. 모듈화: 관련 픽스처를 적절한 패키지로 그룹화
728x90
반응형
728x90
반응형

개요

OpenRewrite는 소스 코드를 자동으로 리팩토링하고 마이그레이션할 수 있는 도구입니다. 이 문서에서는 OpenRewrite를 활용하여 다음과 같은 레시피를 적용해서 마이그레이션을 수행하는 방법을 설명합니다.

  • Spring Boot 3.X으로 마이그레이션
  • Java 17/21로 마이그레이션
  • Java 코드 자동 포맷팅

레시피 선택하기

OpenRewrite는 레시피라는 코드 변환 규칙 세트를 사용합니다. 이번에 살펴 볼 레시피는 다음과 같습니다.

1. Spring Boot 3.0 마이그레이션

Spring Boot 2.x 애플리케이션을 Spring Boot 3.0으로 업그레이드하기 위한 레시피입니다.

org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0
 

관련 레시피 : https://docs.openrewrite.org/recipes/java/spring/boot3/upgradespringboot_3_0

2. Java 버전 마이그레이션

Java 17 마이그레이션

org.openrewrite.java.migrate.UpgradeToJava17
 

관련 레시피 : https://docs.openrewrite.org/recipes/java/migrate/upgradetojava17

Java 21 마이그레이션

org.openrewrite.java.migrate.UpgradeToJava21
 

관련 레시피 : https://docs.openrewrite.org/running-recipes/popular-recipe-guides/migrate-to-java-21

3. Java 코드 포맷팅

org.openrewrite.java.format.AutoFormat

관련 레시피 : https://docs.openrewrite.org/recipes/java/format/autoformat

적용 방법

OpenRewrite를 프로젝트에 적용하는 방법은 여러 가지가 있습니다. 여기서는 Gradle을 사용한 방법을 설명합니다.

방법 1: Gradle Init Script 사용

init.gradle 파일을 생성하고 다음 내용을 추가합니다

initscript {
    repositories {
        maven { url "<https://plugins.gradle.org/m2>" }
    }
    dependencies { classpath("org.openrewrite:plugin:latest.release") }
}
rootProject {
    plugins.apply(org.openrewrite.gradle.RewritePlugin)
    dependencies {
        rewrite("org.openrewrite:rewrite-java")
    }
    rewrite {
        // Java 코드 포맷팅 레시피
        activeRecipe("org.openrewrite.java.format.AutoFormat") 
        setExportDatatables(true)
    }
    afterEvaluate {
        if (repositories.isEmpty()) {
            repositories {
                mavenCentral()
            }
        }
    }
}
 

실행

./gradlew --init-script init.gradle rewriteRun

방법 2: Build.Gradle 사용

build.gradle 파일에 다음 내용을 추가합니다

 plugins {
    id("org.openrewrite.rewrite") version("7.2.1")
}

rewrite {
    // Java 코드 포맷팅 레시피
    activeRecipe("org.openrewrite.java.format.AutoFormat")
    setExportDatatables(true)
}

repositories {
    mavenCentral()
}
 

실행

./gradlew --init-script init.gradle rewriteRun

 

728x90
반응형
728x90
반응형

개요

OpenRewrite는 소스 코드를 대규모로 리팩토링하기 위한 오픈소스 도구입니다. 특히 Java, SpringBoot 버전 업그레이드와 같은 복잡한 마이그레이션 작업을 자동화하는 데 매우 유용합니다.

1. OpenRewrite 소개

주요 특징

  • 소스 코드의 LST(Lossless Semantic Trees) 기반 분석 및 변환
  • 다양한 마이그레이션 레시피 제공
  • 대규모 코드베이스에서 일관된 스타일로 코드 리팩토링 변경 지원
  • 변경 사항에 대한 미리보기(dryRun) 및 검증 기능

사용 시나리오

  • Java 버전 업그레이드 (e.g. 8 → 11 → 17 → 21)
  • SpringBoot 버전 업그레이드 (e.g. 2.x → 3.x)
  • 대대적인 보안 취약점 업데이트 (e.g. 2021년 log4j 보안취약점)
  • 종속성 버전 업데이트
  • 코드 스타일 및 패턴 업데이트

2. OpenRewrite의 예시

마이그레이션의 고통 포인트

SpringBoot 2.x에서 3.x로의 마이그레이션은 다음과 같은 귀찮은 작업들이 필요합니다

  • javax.*jakarta.* 패키지 변경 (수백 개의 import 문 수정)
  • deprecated API들의 대체 작업
  • Spring Security 설정 방식 변경
  • 수많은 라이브러리 버전 호환성 체크
  • Java 17 마이그레이션
 
// 이런 코드가 프로젝트에 수백 개...
import javax.persistence.Entity;
import javax.validation.constraints.NotNull;

// 모두 아래와 같이 변경 필요
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotNull;

3. OpenRewrite의 기술적 해결책

1. LST(Lossless Semantic Trees) 기반 분석

LST의 타입 속성(Type Attribution)

// 원본 코드
List<String> names = getNames();
names.stream()
     .filter(name -> name.length() > 5)
     .collect(Collectors.toList());

// LST는 다음과 같은 타입 정보를 보존
List<String> names = getNames();          // names: java.util.List<java.lang.String>
names.stream()                            // stream(): java.util.stream.Stream<java.lang.String>
     .filter(name -> name.length() > 5)   // filter(): Stream<T> 
     .collect(Collectors.toList());       // collect(): java.util.List<java.lang.String>
 

2. 포맷 보존(Format Preservation)

주석과 공백 보존

 
/* 원본 코드의 복잡한 포맷팅 */
public class User {
    
    private final String name;    // 사용자 이름
    
    // 생성자 주석
    public User(String name) {
        this.name = name;         // 필드 초기화
    }                             // 후행 주석
}

// LST는 위의 모든 주석, 공백, 들여쓰기를 그대로 보존

 

// 기존 코드의 스타일
public class Example {
    private void method1() {
        if (condition) {
            doSomething();
        }
    }
    
    // OpenRewrite가 새로 추가하는 코드는 주변 스타일을 따름
    private void newMethod() {    // 동일한 들여쓰기 적용
        if (condition) {          // 중괄호 스타일 매칭
            doSomethingNew();
        }
    }
}

2. 정교한 의존성 분석

  • 프로젝트의 모든 pom.xml/build.gradle 파일 스캔
  • 호환되지 않는 라이브러리 버전 자동 감지
  • 전이 의존성 충돌 해결

4. 실제 사용 예시

1. Gradle 프로젝트 설정

plugins {
    id("org.openrewrite.rewrite") version("5.x.x")
}

rewrite {
    activeRecipe("org.openrewrite.java.spring.boot3.SpringBoot3Migration")
}
 

2. 마이그레이션 실행

# Gradle의 경우
./gradlew rewriteDryRun # 변경 예정 사항 미리보기
./gradlew rewriteRun    # 실제 변경 적용

Junit Assert 리팩토링

// 변경 전 
import org.junit.Assert;

Assert.assertTrue(condition);

// 변경 후
import static org.junit.Assert.assertTrue;

assertTrue(condition);

Spring Security 설정 마이그레이션

// 변경 전 WebSecurityConfigurerAdapter 사용
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/api/**").authenticated();
    }
}

// 변경 후 컴포넌트 기반 설정
@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> 
            auth.requestMatchers("/api/**").authenticated()
        );
        return http.build();
    }
}

JPA 엔티티 마이그레이션

// 변경 전
import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class User {
    @Id
    private Long id;
}

// 변경 후
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class User {
    @Id
    private Long id;
}

5. 마이그레이션 전략

  1. 코어 모듈부터 시작
  2. 테스트가 많은 모듈 우선 적용
  3. 의존성이 적은 모듈부터 진행
# 변경 사항 미리보기
./gradlew rewriteDryRun

# 실제 변경 적용
./gradlew rewriteRun

# 특정 모듈만 적용
./gradlew :core:rewriteRun

 

6. 레퍼런스

728x90
반응형
728x90
반응형

Kotlin과 Spring Cloud Function 기반 애플리케이션을 AWS EventBridge와 Lambda에 배포하기개요

새롭게 B2C 서비스를 개발하며 배치 작업의 필요성이 발생했는데 기존 서비스의 배치는 SpringBatch로 작성하고 Jenkins를 트리거로 사용하고 있었습니다. 처음엔 기존 서비스와 같은 기술 스택을 고민했지만 과연 현재 요구사항에서 SpringBatch와 Jenkins를 사용할 정도의 규모일까라는 의문이 들었고 인프라 관리 부담 또한 적지 않은 고민이 되었습니다.

이와 같은 고민을 해결하기 위해 개발자가 쉽게 조작할 수 있어야 하고 트리거가 백엔드 API를 호출할 수 있는 형태를 고민했고 KotlinSpring Cloud Function으로 애플리케이션을 개발하고 AWS EventBridge와 Lambda를 같이 사용하는 구조를 생각했습니다.

아키텍쳐

AWS EventBridge, Lambda를 사용해 서비스 API를 호출하는 과정

 

이번 글에서는 위와 같은 고민을 해결한 과정과 애플리케이션 배포 과정을 소개하도록 하겠습니다.

Kotlin과 Spring Cloud Function의 조합

Kotlin 적용 이유

일반적으로 Lambda를 사용하면 Python과 Node.js을 많이 사용하지만 백엔드팀이 익숙한 기술 스택이 Kotlin이고 Lambda에서 JDK 최신 버전인 JVM 21 런타임도 지원하므로 Kotlin을 사용하게 됐습니다.

Kotlin의 장점

  • 간결한 문법
  • 안전한 타입 시스템
  • 자바와의 높은 호환성
  • 코루틴과 같은 현대적 동시성 모델 지원

Spring Cloud Function 이란

Spring Cloud Function은 서버리스 아키텍처를 위한 Spring Cloud 프로젝트 중 하나로 AWS Lambda, Azure Function, GCP Function 등 클라우드 위에서 동작하는 서버리스 플랫폼과 통합을 Adapter로써 지원하며 비즈니스 로직을 간단한 함수 형태로 작성할 수 있게 해줍니다.

제가 경험해본 Spring Cloud Function의 다른 사용 예시는 주식 시세 애플리케이션을 개발하며 Spring Cloud Stream 을 사용해 Source, Processor, Sink 단계별로 비즈니스 로직을 함수 단위 애플리케이션으로 만들어주던 경험이 너무 좋았는데 Spring Cloud Stream도 각 함수 단위 애플리케이션을 개발할때 Spring Cloud Function을 사용하고 있습니다.

Spring Cloud Function의 장점

  • 서버리스 환경에 최적화된 스프링 부트 모델
  • 다양한 클라우드 플랫폼과의 호환성
  • 함수 단위의 비즈니스 로직 구현 용이
  • 특정 플랫폼에 의존성이 없는 프로그래밍 모델 지원

AWS EventBridge와 Lambda

AWS EventBridge는 서버리스 이벤트 버스 서비스로, 다양한 AWS 서비스 및 외부 애플리케이션 간 이벤트 드리븐 아키텍쳐 형태로 쉽게 구축할 수 있도록 지원합니다. AWS Lambda는 서버리스 컴퓨팅을 제공하여, 코드 실행을 위한 서버 관리 필요 없이 비즈니스 로직에 집중할 수 있게 합니다.

EventBridge의 장점

  • 높은 확장성과 느슨한 결합 제공
  • 실시간 데이터 처리 및 반응형 시스템 구축 가능
  • cron, rate 기반의 스케쥴러 지원

Lambda의 장점

  • 빠른 시작 시간과 효율적인 리소스 관리
  • 다양한 프로그래밍 언어 지원
  • 간편한 배포 및 관리

Spring Cloud Function 애플리케이션 개발 과정

프로젝트 구성

예제로 사용한 기술 스택

  • Spring Boot 3.2.1
  • Spring Cloud 2023.0.0
  • Kotlin + Gradle Kotlin

Spring Initializr에 접속해서 아래와 같이 사용할 버전과 Spring Cloud Function 의존성을 추가한 뒤 초기 프로젝트 구성을 다운로드 받습니다.

다운로드한 프로젝트를 Intellij IDEA에 import 한 뒤 build.gradle.kts 를 열어 아래와 같이 설정합니다.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "3.2.1"
	id("io.spring.dependency-management") version "1.1.4"
	id("com.github.johnrengelman.shadow") version "7.1.2"
	kotlin("jvm") version "1.9.21"
	kotlin("plugin.spring") version "1.9.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
	sourceCompatibility = JavaVersion.VERSION_21
}

repositories {
	mavenCentral()
}

extra["springCloudVersion"] = "2023.0.0"

dependencies {
	implementation("org.springframework.cloud:spring-cloud-function-web")
	implementation("org.springframework.cloud:spring-cloud-function-adapter-aws")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("com.github.kittinunf.fuel:fuel:3.0.0-alpha1")

	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
	imports {
		mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
	}
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs += "-Xjsr305=strict"
		jvmTarget = "21"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

tasks.withType<Jar> {
	manifest {
		attributes["Start-Class"] = "com.example.function.FunctionApplicationKt"
	}
}

tasks.assemble {
	dependsOn("shadowJar")
}

tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
	archiveClassifier.set("aws")
	archiveFileName.set("batch.jar")
	dependencies {
		exclude("org.springframework.cloud:spring-cloud-function-web")
	}
	mergeServiceFiles()
	append("META-INF/spring.handlers")
	append("META-INF/spring.schemas")
	append("META-INF/spring.tooling")
	append("META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports")
	append("META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports")
	transform(com.github.jengelman.gradle.plugins.shadow.transformers.PropertiesFileTransformer::class.java) {
		paths.add("META-INF/spring.factories")
		mergeStrategy = "append"
	}
}

위 설정에서 핵심적인 부분은 shadowJar 입니다. shadowJar는 프로젝트의 공통 의존성을 하나의 jar안에 모두 결합하기 위해 사용하는 Gradle 플러그인입니다. 이런 형태를 일반적으로 fat-jar라고도 부릅니다.

애플리케이션 개발

resources/application.yml 파일을 생성한 뒤 아래와 같이 작성한다. 이때 definition에 매핑되는 값은 Lambda를 통해 호출될 함수명과 일치해야합니다.

spring:
  cloud:
    function:
      definition: batchCaller

애플리케이션 코드

application.yml의 definition에 설정한대로 batchCaller 라는 이름의 함수를 Bean으로 구성한 애플리케이션입니다.

package com.example.function

import com.fasterxml.jackson.databind.ObjectMapper
import fuel.Fuel
import fuel.get
import kotlinx.coroutines.runBlocking
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean

@SpringBootApplication
class FunctionApplication(
    private val objectMapper: ObjectMapper,
) {

    data class Payload(
        val url: String,
    )

    @Bean
    fun batchCaller(): (String) -> (String) {
        return { payloadAsString ->
            val payload = objectMapper.readValue(payloadAsString, Payload::class.java)

            runBlocking {
                val response = Fuel.get(payload.url)
                response.statusCode.toString()
            }

        }
    }

}

fun main(args: Array<String>) {
    runApplication<FunctionApplication>(*args)
}

예제의 batchCaller Bean은 아래와 순서로 동작합니다.

  1. EventBridge로부터 넘어온 JSON payload를 함수의 인자로 전달
  2. 인자로 전달받은 JSON payload를 Kotlin의 data class인 Payload로 변환
  3. Fuel client를 사용해 Payload.url을 그대로 호출. 이때 호출된 url은 서비스 API의  엔드 포인트입니다.</aside>
  4. <aside> 💡 Fuel client는 코루틴 기반의 경량 HTTP Client입니다. 예제는 Spring Web이나 Spring WebFlux 의존성이 없으므로 Spring에서 기본 제공하는 RestClient, WebClient등을 사용할 수 없고 서버리스 플랫폼에 올라가는 특성상 가벼운 Client가 유리하므로 Fuel을 선택했습니다.

애플리케이션 빌드

프로젝트 최상위에서 shadowJar 플러그인를 사용해 애플리케이션을 jar로 빌드합니다.

./gradlew shadowJar

빌드 완료 예시

빌드가 완료되면 프로젝트의 build/libs/ 디렉토리에 jar 파일이 생성됩니다. 예제의 경우 batch.jar 로 빌드 되게 설정된 상태입니다. batch.jar는 이후 Lambda에 코드를 업로드할 때 사용됩니다.

AWS EventBridge와 Lambda에 애플리케이션 배포하기

Lambda에 배포

AWS Lambda 콘솔에 접속해서 아래와 같이 함수를 생성합니다.

저희가 중요한 것은 런타임이므로 아키텍쳐나 함수 이름 등 기타 설정은 상황에 맞게 설정하면 됩니다.

함수가 생성되면 코드를 업로드 합니다. 이때 S3 버킷에 링크를 연결하거나 .jar 파일을 직접 업로드할 수 있습니다.

업로드가 완료되었다면 런타임 설정에서 편집을 눌러 아래와 같이 설정을 변경합니다.

핸들러 설정을 org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest 로 변경해주셔야 정상적으로 동작합니다.

설정이 완료되었다면 정상적으로 동작하는지 테스트해 보겠습니다. 테스트 탭에 들어가서 이벤트 JSON을 수정합니다. 이벤트 JSON은 람다 함수로 전달되는 Payload이고 앞서 Spring Cloud Function 애플리케이션을 만들 때 data class로 만들었던 Payload 형태가 되어야 합니다.

아래와 같이 함수 실행 테스트가 완료되었습니다.

이제 EventBridge의 스케쥴러와 앞서 설정한 Lambda 함수를 연결해주겠습니다.

EventBridge 스케쥴러 설정

EventBridge 콘솔에 접속해 일정 생성 버튼을 눌러 스케쥴러를 생성해보겠습니다. 예제의 경우 Rate 기반 일정을 사용해 1분 마다 Event가 발생하도록 설정했습니다. EventBridge는 Rate 기반 일정과 Cron 기반 일정을 같이 지원하므로 편리하게 스케쥴러를 설정할 수 있습니다.

다음엔 EventBridge와 Lambda를 연결합니다. 페이로드에는 Lambda 테스트시 사용했던 것과 같이 Lambda에서 호출할 서비스 API 엔드포인트를 적어줍니다.

모든 설정이 완료되었습니다.

결론

AWS EventBridge와 Lambda에 애플리케이션을 배포하는 것은 서버리스 아키텍처를 활용하는 효과적인 방법입니다. 이 과정을 통해 개발자는 인프라 관리의 부담 없이 비즈니스 로직에 집중할 수 있으며, 빠르고 유연한 클라우드 애플리케이션 개발이 가능해집니다.

일반적으로 JVM 언어만 사용해본 개발자들은 Lambda 기반의 서버리스 애플리케이션을 개발하는데 있어서 부담을 느끼는 상황을 많이 봐왔습니다. 주로 Lambda 함수를 Python과 Node.js로 만드는 경우가 많기 때문인데 Spring Cloud Function을 사용하면 Java나 Kotlin과 같은 JVM 언어에서도 빠르고 쉽게 서버리스 애플리케이션을 개발할 수 있습니다. 또한 Spring Cloud Function은 플랫폼에 상관없이 독립 실행형(Standalone) 애플리케이션을 만들어주므로 비즈니스 로직에 집중한 서비스를 만드는데 도움을 줍니다.

예제 코드

https://github.com/digimon1740/spring-cloud-function-kotlin-aws-lambda

Reference

개요

새롭게 B2C 서비스를 개발하며 배치 작업의 필요성이 발생했는데 기존 서비스의 배치는 SpringBatch로 작성하고 Jenkins를 트리거로 사용하고 있었습니다. 처음엔 기존 서비스와 같은 기술 스택을 고민했지만 과연 현재 요구사항에서 SpringBatch와 Jenkins를 사용할 정도의 규모일까라는 의문이 들었고 인프라 관리 부담 또한 적지 않은 고민이 되었습니다.

이와 같은 고민을 해결하기 위해 개발자가 쉽게 조작할 수 있어야 하고 트리거가 백엔드 API를 호출할 수 있는 형태를 고민했고 KotlinSpring Cloud Function으로 애플리케이션을 개발하고 AWS EventBridge와 Lambda를 같이 사용하는 구조를 생각했습니다.

아키텍쳐

AWS EventBridge, Lambda를 사용해 서비스 API를 호출하는 과정

이번 글에서는 위와 같은 고민을 해결한 과정과 애플리케이션 배포 과정을 소개하도록 하겠습니다.

Kotlin과 Spring Cloud Function의 조합

Kotlin 적용 이유

일반적으로 Lambda를 사용하면 Python과 Node.js을 많이 사용하지만 백엔드팀이 익숙한 기술 스택이 Kotlin이고 Lambda에서 JDK 최신 버전인 JVM 21 런타임도 지원하므로 Kotlin을 사용하게 됐습니다.

Kotlin의 장점

  • 간결한 문법
  • 안전한 타입 시스템
  • 자바와의 높은 호환성
  • 코루틴과 같은 현대적 동시성 모델 지원

Spring Cloud Function 이란

Spring Cloud Function은 서버리스 아키텍처를 위한 Spring Cloud 프로젝트 중 하나로 AWS Lambda, Azure Function, GCP Function 등 클라우드 위에서 동작하는 서버리스 플랫폼과 통합을 Adapter로써 지원하며 비즈니스 로직을 간단한 함수 형태로 작성할 수 있게 해줍니다.

제가 경험해본 Spring Cloud Function의 다른 사용 예시는 주식 시세 애플리케이션을 개발하며 Spring Cloud Stream 을 사용해 Source, Processor, Sink 단계별로 비즈니스 로직을 함수 단위 애플리케이션으로 만들어주던 경험이 너무 좋았는데 Spring Cloud Stream도 각 함수 단위 애플리케이션을 개발할때 Spring Cloud Function을 사용하고 있습니다.

Spring Cloud Function의 장점

  • 서버리스 환경에 최적화된 스프링 부트 모델
  • 다양한 클라우드 플랫폼과의 호환성
  • 함수 단위의 비즈니스 로직 구현 용이
  • 특정 플랫폼에 의존성이 없는 프로그래밍 모델 지원

AWS EventBridge와 Lambda

AWS EventBridge는 서버리스 이벤트 버스 서비스로, 다양한 AWS 서비스 및 외부 애플리케이션 간 이벤트 드리븐 아키텍쳐 형태로 쉽게 구축할 수 있도록 지원합니다. AWS Lambda는 서버리스 컴퓨팅을 제공하여, 코드 실행을 위한 서버 관리 필요 없이 비즈니스 로직에 집중할 수 있게 합니다.

EventBridge의 장점

  • 높은 확장성과 느슨한 결합 제공
  • 실시간 데이터 처리 및 반응형 시스템 구축 가능
  • cron, rate 기반의 스케쥴러 지원

Lambda의 장점

  • 빠른 시작 시간과 효율적인 리소스 관리
  • 다양한 프로그래밍 언어 지원
  • 간편한 배포 및 관리

Spring Cloud Function 애플리케이션 개발 과정

프로젝트 구성

예제로 사용한 기술 스택

  • Spring Boot 3.2.1
  • Spring Cloud 2023.0.0
  • Kotlin + Gradle Kotlin

Spring Initializr에 접속해서 아래와 같이 사용할 버전과 Spring Cloud Function 의존성을 추가한 뒤 초기 프로젝트 구성을 다운로드 받습니다.

다운로드한 프로젝트를 Intellij IDEA에 import 한 뒤 build.gradle.kts 를 열어 아래와 같이 설정합니다.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "3.2.1"
	id("io.spring.dependency-management") version "1.1.4"
	id("com.github.johnrengelman.shadow") version "7.1.2"
	kotlin("jvm") version "1.9.21"
	kotlin("plugin.spring") version "1.9.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
	sourceCompatibility = JavaVersion.VERSION_21
}

repositories {
	mavenCentral()
}

extra["springCloudVersion"] = "2023.0.0"

dependencies {
	implementation("org.springframework.cloud:spring-cloud-function-web")
	implementation("org.springframework.cloud:spring-cloud-function-adapter-aws")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("com.github.kittinunf.fuel:fuel:3.0.0-alpha1")

	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
	imports {
		mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
	}
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs += "-Xjsr305=strict"
		jvmTarget = "21"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

tasks.withType<Jar> {
	manifest {
		attributes["Start-Class"] = "com.example.function.FunctionApplicationKt"
	}
}

tasks.assemble {
	dependsOn("shadowJar")
}

tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
	archiveClassifier.set("aws")
	archiveFileName.set("batch.jar")
	dependencies {
		exclude("org.springframework.cloud:spring-cloud-function-web")
	}
	mergeServiceFiles()
	append("META-INF/spring.handlers")
	append("META-INF/spring.schemas")
	append("META-INF/spring.tooling")
	append("META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports")
	append("META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports")
	transform(com.github.jengelman.gradle.plugins.shadow.transformers.PropertiesFileTransformer::class.java) {
		paths.add("META-INF/spring.factories")
		mergeStrategy = "append"
	}
}

위 설정에서 핵심적인 부분은 shadowJar 입니다. shadowJar는 프로젝트의 공통 의존성을 하나의 jar안에 모두 결합하기 위해 사용하는 Gradle 플러그인입니다. 이런 형태를 일반적으로 fat-jar라고도 부릅니다.

애플리케이션 개발

resources/application.yml 파일을 생성한 뒤 아래와 같이 작성한다. 이때 definition에 매핑되는 값은 Lambda를 통해 호출될 함수명과 일치해야합니다.

spring:
  cloud:
    function:
      definition: batchCaller

애플리케이션 코드

application.yml의 definition에 설정한대로 batchCaller 라는 이름의 함수를 Bean으로 구성한 애플리케이션입니다.

package com.example.function

import com.fasterxml.jackson.databind.ObjectMapper
import fuel.Fuel
import fuel.get
import kotlinx.coroutines.runBlocking
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean

@SpringBootApplication
class FunctionApplication(
    private val objectMapper: ObjectMapper,
) {

    data class Payload(
        val url: String,
    )

    @Bean
    fun batchCaller(): (String) -> (String) {
        return { payloadAsString ->
            val payload = objectMapper.readValue(payloadAsString, Payload::class.java)

            runBlocking {
                val response = Fuel.get(payload.url)
                response.statusCode.toString()
            }

        }
    }

}

fun main(args: Array<String>) {
    runApplication<FunctionApplication>(*args)
}

예제의 batchCaller Bean은 아래와 순서로 동작합니다.

  1. EventBridge로부터 넘어온 JSON payload를 함수의 인자로 전달
  2. 인자로 전달받은 JSON payload를 Kotlin의 data class인 Payload로 변환
  3. Fuel client를 사용해 Payload.url을 그대로 호출. 이때 호출된 url은 서비스 API의  엔드 포인트입니다.</aside>
  4. <aside> 💡 Fuel client는 코루틴 기반의 경량 HTTP Client입니다. 예제는 Spring Web이나 Spring WebFlux 의존성이 없으므로 Spring에서 기본 제공하는 RestClient, WebClient등을 사용할 수 없고 서버리스 플랫폼에 올라가는 특성상 가벼운 Client가 유리하므로 Fuel을 선택했습니다.

애플리케이션 빌드

프로젝트 최상위에서 shadowJar 플러그인를 사용해 애플리케이션을 jar로 빌드합니다.

./gradlew shadowJar

 

빌드 완료 예시

빌드가 완료되면 프로젝트의 build/libs/ 디렉토리에 jar 파일이 생성됩니다. 예제의 경우 batch.jar 로 빌드 되게 설정된 상태입니다. batch.jar는 이후 Lambda에 코드를 업로드할 때 사용됩니다.

 

AWS EventBridge와 Lambda에 애플리케이션 배포하기

Lambda에 배포

AWS Lambda 콘솔에 접속해서 아래와 같이 함수를 생성합니다.

 

저희가 중요한 것은 런타임이므로 아키텍쳐나 함수 이름 등 기타 설정은 상황에 맞게 설정하면 됩니다.

함수가 생성되면 코드를 업로드 합니다. 이때 S3 버킷에 링크를 연결하거나 .jar 파일을 직접 업로드할 수 있습니다.

 

업로드가 완료되었다면 런타임 설정에서 편집을 눌러 아래와 같이 설정을 변경합니다.

 

핸들러 설정을 org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest 로 변경해주셔야 정상적으로 동작합니다.

설정이 완료되었다면 정상적으로 동작하는지 테스트해 보겠습니다. 테스트 탭에 들어가서 이벤트 JSON을 수정합니다. 이벤트 JSON은 람다 함수로 전달되는 Payload이고 앞서 Spring Cloud Function 애플리케이션을 만들 때 data class로 만들었던 Payload 형태가 되어야 합니다.

 

아래와 같이 함수 실행 테스트가 완료되었습니다.

이제 EventBridge의 스케쥴러와 앞서 설정한 Lambda 함수를 연결해주겠습니다.

EventBridge 스케쥴러 설정

EventBridge 콘솔에 접속해 일정 생성 버튼을 눌러 스케쥴러를 생성해보겠습니다. 예제의 경우 Rate 기반 일정을 사용해 1분 마다 Event가 발생하도록 설정했습니다. EventBridge는 Rate 기반 일정과 Cron 기반 일정을 같이 지원하므로 편리하게 스케쥴러를 설정할 수 있습니다.

 

다음엔 EventBridge와 Lambda를 연결합니다. 페이로드에는 Lambda 테스트시 사용했던 것과 같이 Lambda에서 호출할 서비스 API 엔드포인트를 적어줍니다.

 

모든 설정이 완료되었습니다.

결론

AWS EventBridge와 Lambda에 애플리케이션을 배포하는 것은 서버리스 아키텍처를 활용하는 효과적인 방법입니다. 이 과정을 통해 개발자는 인프라 관리의 부담 없이 비즈니스 로직에 집중할 수 있으며, 빠르고 유연한 클라우드 애플리케이션 개발이 가능해집니다.

일반적으로 JVM 언어만 사용해본 개발자들은 Lambda 기반의 서버리스 애플리케이션을 개발하는데 있어서 부담을 느끼는 상황을 많이 봐왔습니다. 주로 Lambda 함수를 Python과 Node.js로 만드는 경우가 많기 때문인데 Spring Cloud Function을 사용하면 Java나 Kotlin과 같은 JVM 언어에서도 빠르고 쉽게 서버리스 애플리케이션을 개발할 수 있습니다. 또한 Spring Cloud Function은 플랫폼에 상관없이 독립 실행형(Standalone) 애플리케이션을 만들어주므로 비즈니스 로직에 집중한 서비스를 만드는데 도움을 줍니다.

예제 코드

https://github.com/digimon1740/spring-cloud-function-kotlin-aws-lambda

Reference

728x90
반응형

+ Recent posts