Spring, Apache, Java

라이브러리 직접 만들고 다른 프로젝트에 자동 구성으로 적용해보기

cwchoiit 2024. 6. 13. 18:10
728x90
반응형
SMALL

이번 포스팅에서 해 볼 내용은 라이브러리를 직접 만들고 만든 라이브러리를 스프링 부트 프로젝트에 적용하는데 자동 구성으로 적용해보는 방법을 작성해 보고자 한다. 왜 라이브러리를 직접 만들어보냐면, 이 라이브러리를 직접 만들고 제공하는 제공자 입장이 되어야 왜 스프링 부트의 자동구성이 필요하고 얼마나 효율적인지를 알게 되기 때문이다.

 

가정을 해보자:

스프링 부트를 사용하는 프로젝트를 진행중인 회사에서 누군가가 라이브러리를 만들었는데 그 라이브러리가 너무 좋아서 이팀 저팀에서 모두 그 라이브러리를 가져다가 사용하고 싶다는 요청이 들어왔다고 가정해보자. 

 

우선 라이브러리를 간단하게 만들어보자. 이 라이브러리는 현재 시스템의 메모리 상태를 알려주는 라이브러리다.

나만의 라이브러리 만들기

build.gradle

plugins {
    id 'java'
}

group = 'memory'
sourceCompatibility = '18'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web:3.0.2'
    compileOnly 'org.projectlombok:lombok:1.18.24'
    annotationProcessor 'org.projectlombok:lombok:1.18.24'
    testImplementation 'org.springframework.boot:spring-boot-starter-test:3.0.2'
}

test {
    useJUnitPlatform()
}

 

Memory

package memory;

public class Memory {
    private long used;
    private long max;

    public Memory(long used, long max) {
        this.used = used;
        this.max = max;
    }

    public long getUsed() {
        return used;
    }

    public long getMax() {
        return max;
    }

    @Override
    public String toString() {
        return "Memory{" +
                "used=" + used +
                ", max=" + max +
                '}';
    }
}

 

MemoryFinder

package memory;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MemoryFinder {

    public Memory get() {
        long max = Runtime.getRuntime().maxMemory();
        long total = Runtime.getRuntime().totalMemory();
        long free = Runtime.getRuntime().freeMemory();
        long used = total - free;

        return new Memory(used, max);
    }

    @PostConstruct
    public void init() {
        log.info("MemoryFinder init");
    }
}

 

MemoryController

package memory;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
public class MemoryController {

    private final MemoryFinder memoryFinder;

    @GetMapping("/memory")
    public Memory system() {
        Memory memory = memoryFinder.get();
        log.info("System memory: {}", memory);

        return memory;
    }
}

 

이렇게 만들면, 스프링 부트를 사용하는 특정 프로젝트에서 "/memory"로 접속하면 현재 시스템의 메모리 상태를 알려주는 그런 기능이다. 기능 자체에 중점을 두지 말고 라이브러리를 직접 만들어서 외부 프로젝트에서 가져다가 사용하는 것에 중점을 두자.

 

이제 이렇게 다 만들었으면 빌드를 해야한다. 빌드하는 법은 간단하다.

./gradlew clean build

커맨드로 빌드를 해도 되고, 인텔리제이를 사용하면 우측에 편의 기능이 있다.

build 버튼을 클릭하면 된다.

빌드를 하면 프로젝트 루트 경로에서 "build/libs" 해당 경로에 .jar 파일이 생긴다.

 

.jar 파일만 있으면 된다. 이제 다른 어떤 프로젝트에서 이 라이브러리를 사용한다고 가정해보자.

해당 프로젝트 루트 경로에 libs 라는 폴더를 하나 만든다. 그 폴더안에 저 .jar 파일을 넣으면 된다.

 

그 후에, build.gradle 파일에서 해당 jar 파일을 읽을 수 있게 해주면 끝난다.

다음 라인을 dependencies 안에 추가해주자.

implementation files('libs/memory-v1.jar')

 

build.gradle

plugins {
    id 'org.springframework.boot' version '3.0.2'
    id 'io.spring.dependency-management' version '1.1.0'
    id 'java'
}

group = 'hello'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation files('libs/memory-v1.jar') // 직접 만든 라이브러리를 다운로드 받는법
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

이러면 프로젝트에서 내가 만든 외부 라이브러리를 파일로 읽어들이는게 가능해진다. 이게 가능해졌다면 다음과 같이 .jar 파일의 내부 파일들도 보여야한다.

 

그러면 이 라이브러리가 제공하는 기능을 사용하기 위해 라이브러리가 제공해주는 컨트롤러를 빈으로 등록해야 한다. 왜냐하면 저 라이브러리는 "/memory" 라는 path에 요청을 해야 메모리 상태를 알려주지 않았던가?

 

그래서 빈을 등록한다.

MemoryConfig

package hello.config;

import memory.MemoryController;
import memory.MemoryFinder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MemoryConfig {
    @Bean
    public MemoryFinder memoryFinder() {
        return new MemoryFinder();
    }

    @Bean
    public MemoryController memoryController() {
        return new MemoryController(memoryFinder());
    }
}

 

 

이렇게 컨트롤러를 등록하고 서버를 실행하면 다음과 같이 외부 라이브러리를 가져다가 사용할 수 있게 된 것이다.

근데, 너무 좋은데 이건 이 라이브러리가 어떤 것들을 빈으로 등록해야 하는지 우리가 알고 있기 때문이다. 무슨 말이냐면 이 라이브러리를 가져다가 사용하는 임의의 팀은 저렇게 빈을 등록해야 사용할 수 있다는 사실을 못 들었을 수도 있고 간과할 수도 있다. 그리고 불특정 다수의 팀들이 가져다가 사용한다면 그 팀이 10팀이면 10팀 모두 저런 빈을 등록해주는 귀찮은 과정이 필요하다는 사실이다. 그렇다면 라이브러리에서 자체적으로 자동 구성을 해준다면 얼마나 편할까? 이게 스프링 부트가 해주는 방식이고 이 방식을 그대로 적용해보자!

 

외부 라이브러리 자체에서 자동 구성 기능을 제공해주기

우선, 라이브러리의 소스를 수정하자. 먼저 @AutoConfiguration 애노테이션을 사용해서 필요한 빈을 등록해보자.

MemoryAutoConfig

package memory;

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;

@AutoConfiguration
@ConditionalOnProperty(name = "memory", havingValue = "on")
public class MemoryAutoConfig {

    @Bean
    public MemoryController memoryController() {
        return new MemoryController(memoryFinder());
    }

    @Bean
    public MemoryFinder memoryFinder() {
        return new MemoryFinder();
    }
}

그 다음, @ConditionalOnProperty 애노테이션을 사용해서, 환경변수로 들어오는 'memory'라는 키의 값이 'on'인 경우 이 자동 구성을 활성화한다. 

 

이렇게 한 다음에, 이 자동 구성을 사용할거라고 스프링 부트한테 알려줘야 한다. 그 방법은 다음 경로로 파일 하나를 추가한다.

src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

경로가 상당히 중요하다. 스프링 부트가 띄워질 때 이 파일을 최초에 찾아보고 있으면 해당 파일에 적용된 자동 구성이 필요한 것들을 찾아가기 때문에. 해당 파일에 다음 한 줄을 추가해주자. 

pathyourpackage.MemoryAutoConfig

패키지명 + 클래스명으로 자동 구성 파일을 적어준 후, 이 상태에서 빌드를 해서 만든 .jar 파일이 있으면 된다.

이제 .jar 파일을 만들기 위해 빌드를 하자.

 

이 다음에 이 새롭게 만든 .jar 파일을 프로젝트에 새롭게 적용해주자.

위에서 했던대로 libs 폴더에 .jar 파일을 넣어주고 build.gradle 파일에서 이 파일을 읽을 수 있게 해주면 된다.

이렇게 자동 구성을 하게 설정했으면 굳이 빈으로 직접 등록해줄 필요가 없다. 그래서 MemoryConfig 파일은 삭제해버리자.

 

 

그런 다음에 지금 당장 하면 될까? 안된다. 왜냐하면 자동 구성을 하려면 조건을 추가했다. 환경변수로 memory=on 이라는 키/밸류쌍을 가질 경우에만 자동 구성이 등록되도록 말이다. 그래서 JVM 옵션에 이것을 추가해 준 다음에 다시 실행해보면 잘 될것이다.

이렇게 설정하고 실행하면 로그에 다음과 같이 메모리 파인더의 init()이 실행된다.

 

"/memory"로 가보면 다음과 같이 잘 나온다.

 

스프링 부트의 자동 구성을 좀 더 이해해보기

신기한 점은 아래 경로의 파일이다.

src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

이 파일에 자동 구성하고자 하는 패키지 + 클래스 명을 작성하면 스프링 부트가 알아서 자동 구성을 해주는게 너무 신기하지 않은가?

이 파일은 스프링 부트가 제공하는 autoconfigure 라이브러리에도 있다.

그리고 실제로 이 파일안에는 무수히 많은 스프링 부트가 해주는 자동 구성 대상이 등록되어 있다.

그럼 스프링 부트는 어떻게 이 자동 구성을 다 읽어올 수 있을까?

 

비밀은 이 애노테이션이다.

@SpringBootApplication - @EnableAutoConfiguration - @Import({AutoConfigurationImportSelector.class})

스프링 부트 프로젝트의 시작 클래스의 붙어있는 애노테이션인 @SpringBootApplication 이 안에 들어가보면 @EnableAutoConfiguration이 애노테이션이 있다. 이름 그대로 자동 구성을 활성화하겠다는 애노테이션이다. 그리고 또 이 안에 들어가보면 @Import({AutoConfigurationImportSelector.class})이 애노테이션이 있는데, 이게 중요하다.

 

@Import로 설정 정보를 추가하는 방법은 두가지가 있다.

  • 정적인 방법: @Import({A.class, B.class})처럼, 정적으로 코드에 대상이 딱 박혀있는 경우가 있다. 이 경우 사용할 대상을 동적으로 변경할 수 없다. 딱 저 코드 그대로 A클래스와 B클래스를 추가하는 것
  • 동적인 방법: @Import(ImportSelector) 코드로 프로그래밍해서 설정으로 사용할 대상을 동적으로 선택할 수 있다.

그러니까, 다음 코드를 보자.

@Configuration
@Import({AConfig.class, BConfig.class})
public class AppConfig {...}

이런 경우처럼 딱 정해진 게 아니라, 특정 조건에 따라서 설정 정보를 선택해야 하는 경우에는 어떻게 해야할까?

동적인 방법인 `ImportSelector` 인터페이스를 사용하면 된다.

 

ImportSelector 라는 인터페이스는 스프링 부트가 제공해주는 동적으로 설정 정보를 추가하고 빼고 할 수 있게 해주는데 이 인터페이스를 직접 구현해보자.

 

HelloImportSelector

package hello.selector;

import org.springframework.context.annotation.ImportSelector;
import org.springframework.core.type.AnnotationMetadata;

public class HelloImportSelector implements ImportSelector {
    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        return new String[]{"hello.selector.HelloConfig"};
    }
}

이 인터페이스는 selectImports()를 구현해야 한다. 그리고 이 메서드가 반환하는 문자열 배열안에 들어가 있는 건 클래스의 정보나, 그 정보들이 들어있는 파일명이 된다. (파일명이라고 하니까 위에서 만든 긴 파일명을 가진 파일이 갑자기 떠오른다.)

 

저 `hello.selector.HelloConfig`는 다음과 같이 생겼다.

package hello.selector;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class HelloConfig {

    @Bean
    public HelloBean helloBean() {
        return new HelloBean();
    }
}

그냥 단순히 빈을 등록한 설정 클래스이다. 

 

그럼 이제 이렇게 빈을 등록하는 설정 클래스를 ImportSelector가 반환하게끔 설정한 후 저 ImportSelector를 어떻게 사용하면 되냐?

package hello.selector;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

import static org.assertj.core.api.Assertions.*;

public class ImportSelectorTest {

    @Test
    void selectorConfig() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SelectorConfig.class);
        HelloBean bean = ac.getBean(HelloBean.class);
        assertThat(bean).isNotNull();
    }

    @Configuration
    @Import(HelloImportSelector.class)
    public static class SelectorConfig {

    }
}

@Import()안에 ImportSelector를 구현한 구현체를 넣어주면 된다. 그러면? 스프링 부트는 저 ImportSelectorselectImports()를 실행해서 얻게되는 결과값을 동적으로 자동 구성할 수 있게 해준다.

 

그리고, 실제로 스프링 부트는 

@SpringBootApplication - @EnableAutoConfiguration - @Import({AutoConfigurationImportSelector.class})

이 애노테이션에서 AutoConfigurationImportSelector라는 ImportSelector를 구현한 구현체를 통해 파일명이 

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

이러한 모든 파일들을 모든 라이브러리에서 다 찾아낸다. 이게 스프링 부트가 자동 구성을 해주는 방식이다.

 

결론

  • 라이브러리를 직접 만들어서 외부 프로젝트에서 사용하는 방법을 알아봤다.
  • 라이브러리를 사용할 때 자동 구성을 통해 build.gradle 파일에서 라이브러리를 다운만 받으면 아무것도 따로 해 줄 필요없이 손쉽게 라이브러리 기능을 사용하는 것도 알아봤다.
  • 스프링 부트에서 수많은 것들이 이런식으로 자동 구성을 통해 개발자대신 여러 설정을 해주고 있다는 것도 알아봤다.

 

728x90
반응형
LIST

'Spring, Apache, Java' 카테고리의 다른 글

외부 설정과 프로필 관리 Part.2  (0) 2024.06.14
외부 설정과 프로필 관리 Part.1  (0) 2024.06.14
Spring Boot를 사용하는 이유  (0) 2024.05.30
Bean Scope  (0) 2024.05.27
Bean LifeCycle Callback  (0) 2024.05.27