스프링 부트 3.1.2 버전에서 AWS SNS 클라이언트 여러 개 사용하기
📌 서론
기존에 이미 프로젝트에서 서울 리전에 SNS, SQS를 사용하기 위한 SnsClient가 빈으로 등록되어 있던 상황이다. 그런데 회원가입을 할 때 사용자가 입력한 휴대폰 번호로 문자를 보내는 기능이 추가되었다. 이 기능을 개발하기 위해 AWS SNS 기능 중 SMS 문자 메시지를 보내는 서비스를 이용하기로 했다.
그러나 AWS 서울 리전에는 SMS를 보내는 기능이 없기 때문에 도쿄 리전에서 SMS를 보내는 기능을 사용했다. 그리고 스프링 부트에 도쿄 리전에서 SMS 전송을 위한 SNS 클라이언트를 생성하려다 보니 동일한 SnsClient 가 두 개 생겨버리는 문제가 발생했다.
🔻 AWS SNS, SnsClient가 무엇인지 잘 모르겠다면 아래 글을 읽고 오면 도움이 될 것이다. 🔻
Spring Boot 3 버전에서 AWS SNS를 통한 SMS 발송하기
기존 코드 분석
우리 프로젝트에는 이미 서울 리전에서 SnsClient를 빈으로 생성해주고 있었다. 해당 설정파일은 아래와 같다.
SnsConfig
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sns.SnsClient;
@Getter
@Configuration
public class SnsConfig {
@Value("${spring.cloud.aws.credentials.access-key}")
private String awsAccessKey;
@Value("${spring.cloud.aws.credentials.secret-key}")
private String awsSecretKey;
@Value("${spring.cloud.aws.region.static}")
private String awsRegion;
@Value("${spring.cloud.aws.sns.topics.nickname-change}")
private String snsTopicNicknameChangeARN;
@Bean
public SnsClient getSnsClient() {
return SnsClient.builder()
.region(Region.of(awsRegion))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(awsAccessKey, awsSecretKey)))
.build();
}
}
application.yml
위 설정 파일에서 사용되는 환경 변수는 아래와 같다. 이 코드는 톰캣 환경 변수에 값을 넣은 모습인데 이렇게 톰캣 환경변수를 사용하지 않고 직접 값을 입력해도 전혀 상관없다.
SnsService
그리고 SnsConfig 설정파일에서 생성한 SnsClient 빈을 사용하는 서비스 클래스는 다음과 같다.
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.services.sns.SnsClient;
import software.amazon.awssdk.services.sns.model.MessageAttributeValue;
import software.amazon.awssdk.services.sns.model.PublishRequest;
import software.amazon.awssdk.services.sns.model.PublishResponse;
@Slf4j
@RequiredArgsConstructor
@Service
public class SnsService {
private final SnsClient snsClient;
private final SnsConfig snsConfig;
private final Tracer tracer;
public PublishResponse publishNicknameToTopic(String message, String traceId) {
// 기존 로직 처리 ...
// SNS 클라이언트를 통해 메시지 발행
PublishResponse response = snsClient.publish(publishRequest);
// 기존 로직 처리 ...
}
}
문제 상황
기존에 서울 리전(ap-northeast-2)을 사용하는 SnsClient가 SnsConfig 설정 파일에서 이미 빈(Bean)으로 등록되어 있었다. 새로운 기능 개발을 위해 도쿄 리전(ap-northeast-1) 클래스를 추가하려고 SnsConfig와 구별되는 TokyoSnsConfig 클래스를 만들고 도쿄 리전 전용 SnsClient 빈을 생성하게 된다면 같은 타입(SnsClient)의 빈이 두 개가 되어 문제가 발생할 것이다.
해결 시도 1 - @Qualifier 사용
서울 리전과 도쿄 리전을 구분하기 위해 각각의 설정 파일(Config 클래스)에서 SnsClient에 @Bean 어노테이션과 함께 name 속성을 부여하려고 했다.
서울 리전은 @Bean(name="seoulSnsClient"), 도쿄 리전은 @Bean(name="tokyoSnsClient")로 설정하고 각각의 서비스에서 @Qualifier 어노테이션을 사용하여 해당 빈을 지정하려 했다.
@Qualifier어노테이션을 적용한 결과, 에러 발생
@Qualifier를 적용하고 프로젝트를 실행했을 때 다음과 같은 에러 로그가 발생했다.
2023-12-29T16:16:38.074+09:00 ERROR [member,,] 20473 --- [ restartedMain] o.s.b.d.LoggingFailureAnalysisReporter :
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of method snsWebMvcConfigurer in io.awspring.cloud.autoconfigure.sns.SnsAutoConfiguration$SnsWebConfiguration required a single bean, but 2 were found:
- seoulSnsClient: defined by method 'getSnsClient' in class path resource [com/recipia/member/config/aws/SnsConfig.class]
- tokyoSnsClient: defined by method 'getTokyoSnsClient' in class path resource [com/recipia/member/config/aws/TokyoSnsConfig.class]
에러 메시지를 분석하자면, SnsAutoConfiguration이 단일 SnsClient 빈을 요구하고 있지만, 서울과 도쿄 리전에 대한 두 개의 SnsClient 빈이 정의되어 있어 충돌이 발생한다는 내용이다.
일반적인 상황에서는 빈을 사용하는 곳에 @Qualifier 어노테이션을 추가해 어떤 빈을 가져와야 하는지 직접 설정할 수 있다. 그러나 SnsAutoConfiguration은 AWS SDK의 일부로, 직접적인 코드 수정이 불가능하기 때문에 @Qualifier 어노테이션을 이용한 일반적인 해결 방법이 적용되지 않는다.
아래는 AWS SDK의 일부인 SnsAutoConfiguration 클래스의 캡쳐본이다. 빨간색 박스로 강조되어 있는 부분에서 SnsClient를 사용하는 부분인데 해당 코드에서 위 에러를 발생시킨다.
이 에러를 해결하기 위해 사용되지 않은 해결 방법 - @Primary 어노테이션 사용
서울 리전 또는 도쿄 리전 중 하나의 SnsClient 빈에 @Primary 어노테이션을 추가하여 기본 빈으로 지정할 수 있다. 그러나 이 방법은 다른 리전의 SnsClient에 접근할 때 문제가 될 수 있다. 우리는 서울 리전과 도쿄 리전을 사용하는 SnsClient를 동시에 사용해야 하기 때문에 @Primary 어노테이션을 사용할 수 없는 상황이었다.
해결 시도 2 - 서울 리전만 빈으로 등록, 도쿄 리전은 서비스 클래스에서 객체 생성
기존의 SnsClient 빈 충돌 문제에 대응하기 위해, 서울 리전의 SnsClient는 스프링 빈으로 등록하고, 도쿄 리전의 SnsClient는 TokyoSnsService 클래스 내에서 직접 생성하기로 결정했다. 이 방법은 두 리전을 동시에 사용하면서 스프링 빈 충돌 문제를 피할 수 있는 효과적인 해결책이다.
TokyoSnsService 클래스에서 SnsClient를 생성할 때 주의해야 할 점은, 싱글톤 패턴을 적용해 인스턴스를 최적화하는 것이다. 이렇게 하면 서비스 클래스 내에서 SnsClient 인스턴스가 한 번만 생성되어 재사용된다. 이는 객체 생성 비용을 절감하고, 자원 관리를 효율적으로 할 수 있게 해 준다. 서비스 내에서 SnsClient를 여러 번 호출할 때 동일한 인스턴스가 재사용되어 성능 향상에도 기여한다.
그러나! 일반적으로 스프링 프레임워크에서 관리하는 빈(Bean) 내에서 static 필드나 메소드를 사용하는 것은 권장되지 않는다. 이는 스프링의 의존성 주입 및 빈 생명주기 관리와 상충할 수 있기 때문이다. static 요소는 클래스 레벨에서 관리되므로, 스프링 컨테이너의 라이프사이클이나 의존성 관리 시스템의 영향을 받지 않는다.
하지만 지금과 같은 특별한 상황에서는 @Service 클래스 안에서 static 변수를 사용할 수 밖에 없었다. 이러한 결정이 필요한 상황을 이해해 주길 바란다...
(사실 TokyoSnsClient를 생성해 주는 로직을 따로 TokyoSnsConfig 클래스로 빼주고 이 클래스 안에서 TokyoSnsClient를 static으로 선언하려고 했는데 AWS Access Key와 AWS Secret Key 데이터를 @Value로 가져오려다 보니 @Value 어노테이션은 인스턴스 변수만 접근할 수 있어서 이 방법도 실패했다..)
따라서 도쿄 리전의 SnsClient를 서비스 클래스 내에서 객체로 생성하는 방식은, AWS SDK와 같은 외부 라이브러리를 사용할 때 스프링의 일반적인 빈 관리 방식에 의존하지 않는, 적절한 해결 방법이라고 할 수 있다.
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sns.SnsClient;
import software.amazon.awssdk.services.sns.model.PublishRequest;
import software.amazon.awssdk.services.sns.model.PublishResponse;
import java.util.Optional;
@Slf4j
@RequiredArgsConstructor
@Service
public class TokyoSnsService {
private final SnsConfig snsConfig;
private static final String TOKYO_REGION = "ap-northeast-1";
private static Optional<SnsClient> tokyoSnsClient = Optional.empty(); // Optional 사용
private SnsClient tokyoSnsClient() {
if (tokyoSnsClient.isEmpty()) { // Optional의 isEmpty() 메소드를 사용
tokyoSnsClient = Optional.of(SnsClient.builder()
.region(Region.of(TOKYO_REGION))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(snsConfig.getAwsAccessKey(), snsConfig.getAwsSecretKey())))
.build());
}
return tokyoSnsClient.get(); // Optional에서 값을 가져옴
}
public void sendVerificationCode(String phoneNumber, String verificationCode) {
// 메소드 로직...
PublishResponse response = tokyoSnsClient().publish(request);
// 메소드 로직...
}
}
🔥 결론
결론적으로, 복잡한 SnsClient 빈 충돌 문제는 서울 리전을 위한 SnsClient는 빈으로 유지하고, 도쿄 리전을 위한 SnsClient는 TokyoSnsService 클래스 내에서 Optional을 사용해 직접 생성하는 방식으로 해결했다.
두 리전을 동시에 효율적으로 사용할 수 있도록 하면서, Spring 빈 관리의 한계를 우회하는 새로운 방향으로 해결해 봤다. 또한, 싱글톤 패턴을 적용해 리소스 관리를 최적화하고 성능을 향상하는 경험도 해서 뿌듯했다.
🔻 이 외에도 다양한 SNS, SQS가 궁금하다면? 🔻
[AWS] SNS, SQS 연동하기 (1) - SNS, SQS 생성하기
📣 이 글은 내가 소속된 Team Chillwave에서 진행한 사이드 프로젝트에 적용한 내용을 다시 공부하고 정리한 것이다.
다른 팀원인 "개발자의 서랍" 님의 블로그도 방문하면 도움이 될 것 같다 :)
'Spring > Spring Boot' 카테고리의 다른 글
RequestDto에서 MultipartFile 필드 사용 방법: 객체 바인딩 방법부터 테스트 코드까지 (3) | 2024.01.13 |
---|---|
[Spring Boot] 스프링 부트 3에 레디스 적용하기 (1) | 2024.01.09 |
Spring Boot 3 버전에서 AWS SNS를 통한 SMS 발송하기 (3) | 2023.12.29 |
헥사고날 아키텍처 실전 적용 (1) - 클래스 의존성 주입 및 도메인, 엔티티의 객체 변환 과정 (0) | 2023.12.16 |
[Spring Boot] Spring Boot 3.X 버전에 p6spy 적용하기 (0) | 2023.12.08 |