팀에서 개발중인 서버들이 많아져 공통기능들을 하나로 묶고싶은 니즈가 생겼다.
아직 개발중이라 이른감이 있긴 한데 그동안 고민했던 내용을 정리해보겠다.
여러 서버에서 사용하기 위한 기능을 통합하기 위한 방법은 크게 두가지가 있다.
1. 모노레포 + 멀티모듈을 통한 코드 관리
2. SDK를 통한 독립적 관리
사용 서비스들을 한 레포에 묶을 수만 있으면 1번이 편할 것 같긴한데, 이미 독립적인 레포로 구성되어있는 서비스 코드들을 새로 멀티모듈로 구성할 생각을하면 벌써 가슴이 답답하다.
따라서 2번을 선택하기로 했다.
공통 SDK를 개발하고 사용하기 위해선 이를 저장할 저장소가 필요하다.
다행히도 사내에서는 Nexus라는 오픈소스를 래핑한 서비스가 있어서 이를 이용하면 됐기에 별다른 고민없이 이를 사용했다.
Nexus 사용법은 다른 블로그 글에도 좋은 내용이 많아서 따로 정리하지는 않겠다. 귀찮아서가 아님
공통 유틸리티
아무래도 크게 같은 도메인을 다루고 같은 팀내에서 개발된 여러 서비스 코드들은 공통되는 유틸리티 함수들을 사용할 가능성이 높다.
예를 들어 시간, 로깅 및 분산락등의 기능들이 대표적이다.
public class TimeUtility {
private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd'T'HH:mm:ss")
.appendFraction(ChronoField.MILLI_OF_SECOND, 0, 9, true)
.appendPattern("X")
.toFormatter();
private final ZoneOffset offset = ZoneOffset.UTC;
public LocalDateTime parseTime(String time) {
return LocalDateTime.parse(time, formatter);
}
public LocalDateTime parseTime(String time, String pattern) {
return LocalDateTime.parse(time, DateTimeFormatter.ofPattern(pattern));
}
...
}
위와 같이 시간과 관련된 유틸리티 클래스를 SDK 프로젝트에서 구현해 놓았다.
서비스 코드에서 이를 사용하기 위한 방법이 크게 두가지다.
1. 전역 함수로 사용
위 SDK 코드에서 사용할 함수를 static으로 선언한 뒤, 서비스 코드에서 다음과 같이 사용하면 된다.
TimeUtility.parseTime(~);
실제로 많은 라이브러리들에서 해당 방식을 사용하기 때문에 친숙한 방식이지만, 커스텀하게 만들어지는 SDK 특성상 클래스 내부에 여러 상태값들이 존재할 수도 있는데, 이때 관리 주체가 좀 애매해진다는 단점이 있다.
별다른 기능없이 순수함수용으로만 사용할 것이라면 이 방법도 괜찮은 방식이다.
2. 싱글톤 패턴 활용
Spring이 제공하는 강점인 싱글톤 패턴을 활용할 수도 있다.
만약 위와같이 SDK 프로젝트에서 단순 클래스로만 선언을 해놓았다면 서비스 코드에서 이를 Bean으로 등록해주는 작업을 해야한다.
@Configuration
public class UtilityConfig {
@Bean
public TimeUtility timeUtility() {
return new TimeUtility();
}
}
조금 귀찮긴 하지만 서비스 코드에서 직접 선언하기 때문에 사용성에서 조금 직관적이고, 필요한 서비스 코드에서만 선언할 수 있는 장점이 있다.
하지만 아무 파라미터도 받지 않는 유틸리티 클래스 + 거의 모든 서비스 코드에서 사용한다는 조건이 깔린다면 다음과 같은 방식도 고려할 수 있다.
@Component
public class TimeUtility {
...
}
SDK 프로젝트에서 바로 @Component를 선언해주는 방식인데, 이를 사용하려면 SDK 프로젝트도 스프링 프레임워크를 사용해야 한다는 단점이 있다. 또한 사용하지 않는 서비스 코드에서도 SDK 라이브러리를 의존받는 순간 Bean으로 등록되기 때문에 리소스 낭비가 발생할 수도 있다.
그럼에도 불구하고 서비스 코드에서 별다른 선언없이 사용만하면 되는 편리함이 큰 장점이다.
순수 Java 프로젝트만을 이용해 SDK를 구성중이라면 아래 방법은 사용할 수 없으므로 고려대상이 아니다.
공통 어노테이션
어노테이션 또한 SDK에서 관리할 수 있다.
메소드를 전반적으로 로깅하는 어노테이션을 공통화 해보자.
@Target(value = {ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodLogger {
...
}
@Aspect
@Component
@Order(1)
public class LoggerAspect {
// around, after, before 등 구현
}
Spring 기반으로 SDK를 작성하면 위와 같은 방식을 통해 손쉽게 Spring AOP를 적용할 수 있다.
위 방식을 사용하면 어노테이션 순서지정도 간편하다.
어노테이션 순서 지정이 필요한 이유는 아래 포스팅에 정리해놓았다.
[Spring AOP] Custom AOP와 @Transactional Order에 관하여
최근 특정 요청이 중복되어 처리되는 이슈가 발생했다. 어떤 요청의 중복 처리 요청은 우선적으로 클라이언트에서 막아주면 좋지만, 서버는 모든 상황을 대비해야 하기에 이를 사전에 대비해
seongmok.com
만약 순수 Java SDK 개발시, AspectJ를 사용하면 된다.
Spring AOP와 AspectJ 를 비교하는 글을 참고해서 비교하면 좋다.
동적 파라미터를 통한 초기화
지금까지는 초기화에 별다른 파라미터가 필요없는 공통 유틸리티성 기능 클래스만을 다뤘다.
하지만 예를 들어 Redis를 접근하는 공통 유틸리티 클래스를 만든다고 가정한다면, 각 서비스마다 연결할 host:port, 사용할 모드등이 다르기 때문에 동적으로 파라미터를 주입해 SDK Bean을 사용할 수 있어야 한다.
서비스 코드에서 파라미터를 주입해 SDK 공통 클래스를 싱글톤으로 사용하기 위해선 크게 두가지 방식을 떠올릴 수 있다.
1. 서비스 코드의 properties를 이용한 Bean 자동 등록
서비스 코드의 설정파일에 알맞게 값을 설정하고 이를 SDK에서 가져와 Bean을 등록하는 방식이다.
S3 기능을 이용하기 위해 공통 클래스를 구현하면 다음과 같이 구현할 수 있다.
sdk.redis.host=localhost
sdk.redis.port=6379
위와 같이 서비스 코드에서 프로퍼티에 값을 넣어놓은 뒤 다음과 같이 SDK 프로젝트에서 사용하면 된다.
@ConfigurationProperties(prefix = "sdk.redis")
public class SdkRedisProperties {
...
}
@Configuration
@EnableConfigurationProperties(SdkRedisProperties.class)
public class SdkConfiguration {
@Bean
public RedisCommand redisCommand(SdkRedisProperties redisProperties) {
return new RedisCommand(~);
}
}
사용하기 편하다는 장점이 있지만 사용하지 않는 서비스에서도 Bean을 관리해줘야 한다는 단점이 있고(제어할 수 있긴 한데 별도의 프로그래밍이 또 필요함), 무엇보다 공통 유틸리티성과는 거리가 좀 멀다.
사용하기 위한 서비스에서 별도로 파라미터를 주입하여 선택적으로 Bean을 등록하는 점이 좀 더 직관적이라고 생각했다.
따라서 다음 방식을 사용했다.
2. 서비스 코드에서 Configuration을 통한 Bean 등록
우리는 보다 편한 사용을 위해 SDK 프로젝트에서 세가지 단계로 구성된 패키지를 준비했다.
1. setting 인터페이스 (선언)
2. configuration 클래스 (등록)
3. command 클래스 (사용)
위와 같이 구성한 이유는 서비스 코드에서 직접 외부 라이브러리를 의존하지 않기 위함이다.
예를 들어 Redis 커넥션을 위해 의존을 주입받는다고 가정하면, 서비스 코드는 Redis와 관련된 라이브러리(aws sdk 또는 spring 래핑 라이브러리)를 의존받지 않고, SDK만을 사용할 수 있기 위함이다.
위 방식을 사용하면 서비스 코드에서는 외부 라이브러리 버전을 신경쓰지 않아도 되고 래핑된 SDK 기능만을 사용하기에 보다 독립적인 의존성 관리가 가능해질 것이라고 생각했다.
예시는 다음과 같다.
public interface RedisSettings {
String getHost();
Integer getPort();
}
@Configuration
@RequiredArgsConstructor
@ConditionalOnBean(RedisSettings.class)
public class RedisConfiguration {
private final RedisSettings redisSettings;
// redisSettings값을 활용한 redisTemplate, lettuceConnectionFactory 등의 bean 등록
@Bean
public RedisCommand<String, Object> redisCommand() throws Exception {
return new RedisCommand<>(redisTemplate());
}
}
@RequiredArgsConstructor
public class RedisCommand<K, V> {
private final RedisTemplate<K, V> redisTemplate;
public V get(K key) {
return redisTemplate.opsForValue().get(key);
}
...
}
위처럼 SDK 내부에서 세단계의 구성으로 관리한다.
실제 redisTemplate에서 사용하기 위한 기능을 모두 별도로 래핑해야하는 단점이 있지만 의존성 독립의 장점이 이 단점을 완전히 보완한다.
사용 서비스에선 다음과 같이 사용하면 된다.
@Configuration
public class RedisConfig implements RedisSettings {
@Value("${aws.redis.host}")
private String host;
@Value("${aws.redis.port}")
private Integer port;
@Override
public String getHost() {
return host;
}
@Override
public Integer getPort() {
return port;
}
}
@Service
@RequiredArgsConstructor
public class RedisService {
private final RedisCommand<String, Object> redisCommand;
...
}
서비스에서 Redis SDK 기능을 이용하고 싶으면 RedisSettings를 구현하는 설정 클래스를 선언하면 된다.
해당 Setting Bean이 등록되면 SDK에서 @ConditionalOnBean(RedisSettings.class)를 통해 이를 감지하고 관련 설정 클래스 Bean이 띄워지는 방식이다.
때문에 이를 이용하면 해당 SDK 기능을 사용할 서비스 코드에서만 Bean을 동적으로 관리할 수 있고, 오로지 SDK의 의존성만을 받아 레디스 커넥션을 유지하며 기능을 사용할 수 있게 된다.
편의를 위해 host와 port만을 파라미터로 사용했는데, 클러스터 모드나 다른 설정값(메시지 리스너 함수도 가능)등도 유연하게 적용할 수 있다.
마무리
아직 개발과 테스트 단계를 거치고 있지만 위와 같은 구조를 유지할 생각이다.
생각보다 유연하게 기능들을 공통화할 수 있기에 여러 서비스에서의 개발이 편해질 것을 기대하고 있다.
다만 어디까지가 서비스의 범주이고 어디까지가 SDK의 범주인지 나누기가 애매한 부분이 좀 많아서 이를 고민해야할 것 같다.
'내가 잊어버리기 싫어서 적는 개발 지식' 카테고리의 다른 글
[Spring AOP] Custom AOP와 @Transactional Order에 관하여 (0) | 2024.12.24 |
---|---|
[Spring + Kafka] 대용량 스트림 메시지 구독 설계 (1) | 2024.11.01 |
[Spring Batch] Job Instance 추가시, 데드락 문제 해결 (7) | 2024.05.24 |
[MySQL] DB 재시작과 auto_increment의 관계성 (0) | 2024.01.30 |
[Spring JPA] 한 트랜잭션 내부에서의 외부 API 콜과 엮인 영속성 문제 (3) | 2024.01.12 |