멀티모듈 + spring boot AutoConfiguration으로 MSA 중복 코드 줄이기

유알·2024년 2월 9일
0

MSA로 서버를 개발하다가 생긴 문제점

프로젝트를 진행하며 서버가 늘어나다 보니 중복 코드가 많이 발생한다.
몇가지 예시를 들면, JWT 관련 필터라던가, 서로 다른 서비스를 호출하는 RestTemplate 코드라던가, enum 도메인 객체 등
이 모든 객체들이 각각의 서버에 작성이 되게 되었다.

  • 무한 복사 붙여넣기
  • 하나의 서버가 변경되면 나머지 N-1개의 서버의 내용을 전부 바꿔줘야함
  • 이것은 현실적으로 불가능함

그래서 이것을 해결하기 위해서, 일종의 라이브러리가 필요하다고 생각했다. 하지만

  • 공개하기 애매한 코드도 많았고
  • 라이브러리로 등록하면, 관리가 어려울 것 같았다.

멀티모듈 도입

따라서 나는 Gradle 에서 제공하는 멀티모듈(멀티 프로젝트, multi project, subproject)를 사용하기로 하였다.

처음에 그 개념이 다소 헷갈렸는데, 매우 간단하게 설명하면,

  • 한개의 프로젝트(서버)를 빌드할 때, 의존성이 있는 프로젝트까지 포함해서 빌드 해줄게~

이렇게 이해하면 된다.

이렇게 하면 무슨 장점이 생기냐면, 공통적으로 사용하는 코드는 단 하나의 코드를 참조하므로, 계속해서 중복 코드를 작성할 필요도, 버전이 안맞을까봐 걱정할 필요도 없다.

나의 경우 다음과 같은 대략적인 모듈 구조를 잡았다.

shared 안에는 전역적으로 참조하는 객체들이 있고, 각각의 서비스는 common, connector, service 세개의 모듈로 이루어진다.

common 에서는 각각의 서비스 도메인(엔티티 아님), 주요 api 인터페이스 등이 담겨 있다. 이 클래스들은 connector와 service에 공유되므로, 중복코드를 한번 더 줄여준다.

connector 에는 각각의 서비스를 호출하는 객체가 담겨있다.(일종의 SDK)라고 보면 된다.
즉 각자의 서비스를 호출하는 코드의 책임을 각자 서비스에 넘긴 것이다. 다른 서비스는 이 코드를 가져다가 메서드를 호출하면 된다.

Account 서비스를 예로 들면 위와 같은데, 신기한 것은 Account Service 와 Connector가 common 에 있는 하나의 Interface를 구현한다는 것이다.

즉 외부 서비스에서도 저 인터페이스로 주입을 받아 사용하고, 호출되는 과정은 투명(transparent)하게 처리된다.

서비스 입장에서는 controller는 일종에 service 메서드를 Http 로 외부에 공개하는 포트 역할만 하게 된다.

이렇게 되면 장점이 하나 더 있는데, 클라이언트(다른 서버) 측에서 저 connector, 일종의 sdk를 호출할 때 클라이언트 측에서 검증을 진행한다는 것이다. 따라서 무효한 요청은 네트워크를 거치지 않고 검증이 가능하다.

동일한 메서드에 대한 보장이 된다는 점 또한 장점이 된다.

또한 각각의 SDK가 무엇으로 구현되었는지 상관하지 않아도 된다. 그것은 Connector를 구현하는 쪽에서만 신경 쓰면 된다.

이제 Account에 호출을 할 일이 생기면, 단순하게 다음 코드를 dependency에 추가만 하면 된다.

그 다음 빈에 등록하고 사용하면 된다. (너무 간단하지 않은가?)
그런데 자꾸 바뀌는 ip 주소를 어떻게 라이브러리에 반영한다는 말인가?
뭐 여러가지 방법이 있겠지만(properties 설정 등) 나는 spring cloud eureka를 활용해서 service discovery에 접속하도록 설정을 해 놓았다.

이렇게 하니, 각각의 서비스의 중복 코드가 압도적으로 줄어들었고 유지보수도 매우 쉬워졌다.

Multi Module 설정법

https://docs.gradle.org/current/userguide/intro_multi_project_builds.html
위 링크를 참조하는 것을 추천한다. 다만 공식문서에서 좀 명시하지 않은 부분이 있는데, 폴더 마다 프로젝트가 존재해야하는 것은 아니다. 나의 경우 shared 폴더는 프로젝트가 아니다. 그 안에 여러개의 프로젝트가 있는데 상관 없다.

자세한 방법은 다음에 다루기로 하고, 핵심만 요약하자면, 프로젝트 루트에 settings.gradle를 배치하고, 내부 모듈에는 build.gradle를 배치한다(settings 있으면 안됨)

그리고 settings쪽에다가 다음과 같이 모듈을 포함한다고 명시해주면 된다.

rootProject.name = 'managed_travel_service'
include(':services:account:account_service')
include(':services:account:account_common')
include(':services:account:account_connector')
include(':services:optimization:optimization_service')
include(':services:optimization:optimization_common')
include(':services:optimization:optimization_connector')
include(':services:place_plan:travel_core_service')
include(':services:place_plan:place_common')
include(':services:place_plan:place_connector')
include(':services:place_plan:plan_common')
include(':services:place_plan:plan_connector')
include(':services:api_gateway:api_gateway_service')
include(':services:service_discovery:eureka_service')
include(':services:config:config_service')
include(':shared:common')
include(':shared:security')
include(':shared:connector')

그리고 각각의 모듈에서는

dependencies {
    implementation project(':shared:security')
    implementation project(':shared:common')
    implementation project(':services:optimization:optimization_common')

이런식으로 사용하면 된다.

Spring Boot AutoConfiguration 도입

이렇게 하다보니까 또 중복 코드가 발생했다. 설정코드가 자꾸 중복되는 것이다. 라이브러리쪽에 @Configuration 이 설정되어 있어 도, 기본적으로는 component 스캔 범위에 포함되지 않으므로, 각각의 프로젝트에서 다시 설정을 해주어야한다.

처음에는 Import로 불러왔지만, 또 중복코드가 왕창 늘어나자, 좀 기본적인 설정들은 자동으로 되었으면 좋겠다고 생각했다.

Spring Boot Starter 비슷한것을 만들었다.

https://docs.spring.io/spring-boot/docs/current/reference/html/using.html#using.build-systems.starters
이 문서를 보고, 패키지 구조를 특정 네이밍 컨벤션으로 작성해야한다고 잘못 쓴 블로그들이 있는데, 그건 아니다. 개인적으로 사용하는 라이브러리들은 상관 없다. 외부에 공개할때..

아주아주 간단하다. 데모 프로젝트로 살펴보자

마찬가지로 멀티모듈로 구성되었고, 저 starter 프로젝트를 service 프로젝트가 참조할 것이다.

라이브러리쪽에서

//starter에서
@AutoConfiguration
public class TestConfiguration {

    @Bean
    public Todo todo() {
        return new Todo(
                "title",
                true,
                1,
                "url",
                "id"
        );
    }
}

이런식으로 정의해 놓고

resource/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
이러한 파일을 만들고 그 안에 정의하면 된다.

click.porito.onjee.TestConfiguration

서비스 쪽에서

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation project(':onjee-spring-boot-starter')

프로젝트를 로드하고

@SpringBootTest
class ServiceApplicationTests {

    @Autowired
    ApplicationContext context;

    @Test
    void contextLoads() {
    }

    @Test
    @DisplayName("Todo bean is loaded")
    void testContext() {
        assertTrue(context.containsBean("todo"));
    }

성공이다!! 아무런 설정도 하지 않았는데 말이다.

이것은 라이브러리쪽에서 설정한 것만 적용되는 것은 아니고,

  • 라이브러리 + 현재 프로젝트에 저 파일이 정의되어 있으면, 그 클래스를 로드해서 설정한다
  • 꼭 Configuration 이 아니라 Bean(Component)도 저런식으로 등록이 가능하다.

AutoConfiguration 테스트 작성법

그러면 이 AutoConfiguration은 어떻게 검증하는가?

class ConfigurationTest {

    static ApplicationContextRunner contextRunner = new ApplicationContextRunner()
            .withConfiguration(AutoConfigurations.of(TestConfiguration.class));

    @Test
    void testTodoBean() {
        contextRunner.run(context -> {
            assertTrue(context.containsBean("todo"));
        });
    }

}

저런 식으로 검증하면 된다. ApplicationContextRunner 저게 은근히 편리한게, 특정 빈이나 프로퍼티 등등 정확히 제어된 환경에서 테스트가 가능하다

AutoConfiguration 원리가 뭔데?

생각보다는 간단하다.

@SpringBootApplication
public class ServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(ServiceApplication.class, args);
    }
}

우리가 흔히 보는 main 클래스 이다. 저 @Spring BootApplication -> @EnableAutoConfiguration -> @Import 순으로 따라 들어가보면, AutoConfigurationImportSelector가 나온다.

	protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
		List<String> configurations = ImportCandidates.load(AutoConfiguration.class, getBeanClassLoader())
			.getCandidates();
		Assert.notEmpty(configurations,
				"No auto configuration classes found in "
						+ "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. If you "
						+ "are using a custom packaging, make sure that file is correct.");
		return configurations;
	}

	private static final String LOCATION = "META-INF/spring/%s.imports";
    
    
	public static ImportCandidates load(Class<?> annotation, ClassLoader classLoader) {
		Assert.notNull(annotation, "'annotation' must not be null");
		ClassLoader classLoaderToUse = decideClassloader(classLoader);
		String location = String.format(LOCATION, annotation.getName());
		Enumeration<URL> urls = findUrlsInClasspath(classLoaderToUse, location);
		List<String> importCandidates = new ArrayList<>();
		while (urls.hasMoreElements()) {
			URL url = urls.nextElement();
			importCandidates.addAll(readCandidateConfigurations(url));
		}
		return new ImportCandidates(importCandidates);
	}

코드를 찬찬히 읽어보면 이해가 갈 것이다. META-INF/spring/%s.imports 경로에 있는 클래스들을 Import 하는 것이다.

결과

결과적으로 멀티 모듈과 autoConfiguration으로 추후 서버 추가나, 유지 보수가 매우매우 쉬워졌다.

특히 저 자동 설정의 경우에는, 각각의 connector나 Common 모듈들에 광범위하게 적용하였는데, 따라서 각각의 서비스 코드에서는 중복 코드 없이, 의존성을 추가하는 것 만으로 자동으로 bean까지 등록되게 되고, 그냥 주입받아서 사용만 하면 되도록 만들었다.

진행 중인 프로젝트의 코드가 필요한 분들은 저의 github project를 참고하길 바란다. https://github.com/onjik/Managed-Travel-Service

profile
더 좋은 구조를 고민하는 개발자 입니다

0개의 댓글