Notion에서 보기

 

iOS 푸시 알림 구현하기 구현편 - Spring, AWS SQS, Lambda, Firebase

개요

cabi.oopy.io

개요

현재 구현된 구조

이전 글인(iOS 푸시 알림 구현하기 세팅편 - AWS SQS, Lambda, Firebase )에서 SQS와 Lambda를 이용한 푸시 알림 부분까지는 세팅, 구현이 되었다.

이제 WAS에서 AWS 인스턴스와 관련한 세팅을 완료하고, 원하는 때에 원하는 방식으로 내부 앱 알림을 사용할 수 있도록 구현해야한다.

주의사항 : 제 개인적인 생각을 통한 구현으로, 항상 올바른 구현이나 정답이 아님을 인지해주세요!


Spring으로 AWS 인스턴스 연결하기

먼저 내부적으로 AWS와 관련한 설정을 통해, SQS를 사용할 수 있도

개요

현재 구현된 구조

이전 글인(iOS 푸시 알림 구현하기 세팅편 - AWS SQS, Lambda, Firebase )에서 SQS와 Lambda를 이용한 푸시 알림 부분까지는 세팅, 구현이 되었다.

이제 WAS에서 AWS 인스턴스와 관련한 세팅을 완료하고, 원하는 때에 원하는 방식으로 내부 앱 알림을 사용할 수 있도록 구현해야한다.

주의사항 : 제 개인적인 생각을 통한 구현으로, 항상 올바른 구현이나 정답이 아님을 인지해주세요!


Spring으로 AWS 인스턴스 연결하기

먼저 내부적으로 AWS와 관련한 설정을 통해, SQS를 사용할 수 있도록 설정해보자.

AWS 인스턴스 연결하기

  • yml에 정보를 작성하자.
cloud:
  aws:
    credentials:
      access-key: [access-key]
      secret-key: [secret-key]
    region:
      static: ap-northeast-2 #seoul
    stack:
      auto: false
    sqs:
      queue:
        name: [SQS Queue Name]
        url: [SQS URL]

Properties와 Config

AWS 서비스의 인스턴스 사용을 위해 로그인 - Bean 등록을 구현해보자.

  • Config
/**
 * AWS 인스턴스를 Bean으로 등록하는 Config입니다.
 */
@Configuration
public class AwsConfig {
	@Value("${cloud.aws.credentials.access-key}")
	private String accessKey;
	@Value("${cloud.aws.credentials.secret-key}")
	private String secretKey;
	@Value("${cloud.aws.region.static}")
	private String region;

	/**
	 * AWS 인증 정보를 담고 있는 객체를 생성합니다.
	 *
	 * @return {@link AWSStaticCredentialsProvider}
	 */
	private AWSStaticCredentialsProvider createAwsCredentialsProvider() {
		BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(this.accessKey, this.secretKey);
		return new AWSStaticCredentialsProvider(basicAWSCredentials);
	}

	/**
	 * SQS Client를 생성, 빈으로 등록합니다.
	 *
	 * @return {@link AmazonSQS}
	 */
	@Bean
	public AmazonSQS amazonSQS() {
		return AmazonSQSAsyncClient.asyncBuilder()
				.withRegion(region)
				.withCredentials(createAwsCredentialsProvider())
				.build();
	}
}
/**
 * AWS SQS 관련 정보를 담고 있는 Properties입니다.
 */
@Component
@Getter
public class AwsSqsProperties {
	@Value("${cloud.aws.sqs.queue.name}")
	private String queueName;
	@Value("${cloud.aws.sqs.queue.url}")
	private String queueUrl;
	@Value("${cloud.aws.sqs.queue.message-delay-seconds}")
	private Integer messageDelaySecs;
}

이 프로퍼티는 직접적으로 SQS를 사용하게 되는 클래스에 주입한다.

 

위와 같이 Config과 Properties를 세팅했다면, 실행했을 때 아래와 같은 에러가 발생한다.

 

로컬 에러 잡기 - (com.amazonaws.SdkClientException: Failed to connect to service endpoint)

기본적으로 AWS 인스턴스를 내부에서 사용하게 되는 경우, EC2임을 가정하여 설정되는 값들이 있는데, 이로 인해서 에러가 발생할 수 있다. - 로컬은 EC2 환경이 아니기 때문이다.

VM 옵션에 Dcom.amazonaws.sdk.disableEc2Metadata=true를 추가한다. 혹은 아래와 같이 설정해줌으로써 해당 에러를 방지할 수 있다.

@SpringBootApplication
public class ExchangediaryApplication {

	public static void main(String[] args) {
		System.setProperty("com.amazonaws.sdk.disableEc2Metadata", "true");
		SpringApplication.run(ExchangediaryApplication.class, args);
	}

}

그럼에도 시끄럽게 **“com.amazonaws.AmazonClientException: EC2 Instance Metadata Service is disabled”**를 로그에 뱉으므로, 아래 부분을 yaml profile에 추가해서, 조용히 시켜주자.

아래처럼 설정하게되면 error 레벨에 해당하는 로그만 띄우게 된다.

logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

SQS 인스턴스를 이용하여 메시징 구현하기

이제 SQS를 사용하는 도메인을 작성해보자.

Event - Handler - Manager

  • SqsMessageEvent
/**
 * SQS 메시지 - 푸시 알림에 필요한 정보를 담는 이벤트 클래스입니다.
 * <p>
 * 제목, 내용, 디바이스 토큰(알림을 받는 기기의 식별 토큰)을 필드로 가집니다.
 */
@Getter
@Builder(builderClassName = "SqsMessageEventBuilder")
public class SqsMessageEvent {

	private final String title;
	private final String content;
	private final String deviceToken;

	private SqsMessageEvent(String title, String content, String deviceToken) {
		this.title = title;
		this.content = content;
		this.deviceToken = deviceToken;
		ifFalse(this.isValid(), new DomainException(INVALID_ARGUMENT));
	}

	public static SqsMessageEventBuilder builder(String title, String content, String deviceToken) {
		return new SqsMessageEventBuilder()
				.title(title)
				.content(content)
				.deviceToken(deviceToken);
	}

	private boolean isValid() {
		return content != null
				&& !content.isEmpty()
				&& deviceToken != null
				&& !deviceToken.isEmpty();
	}

	public static class SqsMessageEventBuilder {

	}
}

내부에서 별도의 처리로 푸시 알림을 보낼 때, 제목(title)과 내용(content) 그리고 그 푸시 알림을 수신하는 deviceToken(FCM 기반)을 위 이벤트로 생성하여 publish한다.


  • SqsEventHandler
/**
 * SQS 메시지 이벤트에 대한 핸들러, AwsSqsManager를 Invoke하는 도메인 클래스입니다.
 * <br>
 * 이벤트를 핸들링하여 SQS 메시지를 생성하고, SQS 메시지를 발행합니다.
 *
 * @see SqsMessageEvent
 */
@Component
@RequiredArgsConstructor
@Log4j2
public class SqsEventHandler {

	private final AwsSqsManager awsSqsManager;

	/**
	 * SQS 메시지 이벤트를 핸들링해서 AwsSqsManager에 전달합니다.
	 * <br>
	 * {@link TransactionalEventListener}를 통해 {@link NoticeEventHandler}의 트랜잭션 커밋 이후에 핸들러가 동작하도록 합니다.
	 *
	 * @param event {@link SqsMessageEvent}
	 */
	@TransactionalEventListener
	public void handleEvent(SqsMessageEvent event) {
		log.info("handleEvent event = {}", event);
		SendMessageRequest request = awsSqsManager.createPushAlarmMessageRequest(event);
		SendMessageResult result = awsSqsManager.sendMessageToQueue(request);
		log.info("handleEvent result = {}", result);
	}
}

이후에 설명하겠지만, SqsMessageEvent를 핸들링 - SqsManager를 호출하여 AWS SQS 인스턴스에 메시징을 등록해준다.


  • AwsSqsManager
  • 위에서 작성했던 Properties와 Sqs Bean을 이용해 AWS에서 제공하는 API에 맞추어 Request를 작성한다. 이 때, 필요한 내용들을 붙여준다(제목, 디바이스 토큰, 내용).
/**
 * AWS SQS와 직접적으로 상호 작용하는 도메인 클래스입니다.
 */
@Component
@RequiredArgsConstructor
@Log4j2
public class AwsSqsManager {

	private final AwsSqsProperties awsSqsProperties;
	private final AmazonSQS sqs;

	/**
	 * SQS에 메시지를 전송합니다.
	 *
	 * @param request {@link SendMessageRequest}
	 * @return SQS에 전송한 메시지의 결과 {@link SendMessageResult}
	 */
	public SendMessageResult sendMessageToQueue(SendMessageRequest request) {
		log.debug("sendMessageToQueue = {}", request);
		return sqs.sendMessage(request);
	}

	/**
	 * 푸시 알림 메시지를 생성합니다.
	 * <p>
	 * SQS의 URL, 메시지 딜레이 시간을 설정합니다.
	 * <br>
	 * 메시지 속성(메타데이터)으로 title, deviceToken을 설정합니다.
	 *
	 * @param event {@link SqsMessageEvent}
	 * @return SQS에 전송할 메시지
	 */
	public SendMessageRequest createPushAlarmMessageRequest(SqsMessageEvent event) {
		log.debug("createPushAlarmMessageRequest = {}", event);
		SendMessageRequest request = new SendMessageRequest()
				.withQueueUrl(awsSqsProperties.getQueueUrl())
				.withDelaySeconds(awsSqsProperties.getMessageDelaySecs());
		request.addMessageAttributesEntry("title", convertToAttributeValue(event.getTitle()));
		request.addMessageAttributesEntry("deviceToken", convertToAttributeValue(event.getDeviceToken()));
		request.withMessageBody(event.getContent());
		return request;
	}

	/**
	 * SQS의 메시지에 메타데이터로 설정할 수 있는 형태로 String을 변환합니다.
	 *
	 * @param value
	 * @return {@link MessageAttributeValue}
	 */
	private MessageAttributeValue convertToAttributeValue(String value) {
		log.debug("convertToAttributeValue = {}", value);
		return new MessageAttributeValue().withDataType("String").withStringValue(value);
	}
}

위 도메인을 통해 SQS - Lambda - FCM - Device까지 이어지는 푸시 알림 흐름을 구현할 수 있다.

해당 메시지에 대한 파싱 및 FCM으로의 메시지 송신은 이전 글(iOS 푸시 알림 구현하기 세팅편 - AWS SQS, Lambda, Firebase )의 **“FCM에 메시지를 전송하는 Lambda 함수 작성하기”**에 적어 놓았다.


알람, 공지사항 이벤트 구현

위 SQS 내부 도메인을 구현하였다면, 현재의 구조는 위와 같다.

이제 본격적으로 요구사항에 대해 정의해보고, 비즈니스 로직을 구성할 단계다.

별도의 기획자가 없기 때문에, 알아서 생각해서 결정해야 했다.

어떤 식으로 앱 내 알림과 외부 서비스를 사용하는 푸시 알림을 구분하고, 저장 및 이벤트 발생을 관리할지 고민했다.


DeviceToken의 등록과 변경, 추가

DeviceToken은 FCM을 기반으로한 디바이스 식별 토큰이다.

FCM을 사용하는 앱을 실행하면, 해당 실행 기기의 식별 토큰인 DeviceToken이 발행된다.

이 토큰을 기준으로 푸시 알림이 발송할 수 있다.

내가 생각하는 구현할만한 요구사항은 다음과 같았다.

  • Push 알림은 ‘제목’ 그리고 ‘내용’ 두 가지로 구성된다.
  • 한 유저가 여러 기기를 사용할 때, 이 기기들에 동일한 Push 알림이 간다.
  • 다른 기기로 로그인하면 그 새로운 기기의 DeviceToken을 그 유저에 대한 것으로 새로 추가한다.
  • 이미 등록되어있는 기기(DeviceToken)에 대해서, 다른 사람이 로그인 할 때에 대해서도 고려해야 한다.

위 요구사항을 유연하게 받아들이기 위해서, 다음과 같은 방법을 생각했다.

  • 회원가입 시와 매 로그인 시에 해당 기기의 DeviceToken을 받는다.
  • → 이는 앱 프론트엔드 단에서 FCM 모듈을 이용해서 제공해준다.
  • 현재 로그인한 유저의 ID로 DeviceToken의 주인(Member)을 변경한다. 이 DeviceToken을 기준으로 없다면 생성, 있다면 덮어쓰기(Append)한다.

따라서 DeviceToken은 Member 엔티티에 대해 ManyToOne 연관관계를 갖는 엔티티로 설정했다.

@Table(name = "DEVICE_TOKEN")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class DeviceToken extends IdentityIdDomain implements ValidatableObject {

	@Column(name = "DEVICE_TOKEN", nullable = false)
	private String deviceToken;
	@Column(name = "CREATED_AT", nullable = false)
	private LocalDateTime createdAt;

	@JoinColumn(name = "MEMBER_ID")
	@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
	private Member member;

	private DeviceToken(Member member, String deviceToken, LocalDateTime createdAt) {
		this.member = member;
		this.deviceToken = deviceToken;
		this.createdAt = createdAt;
	}

	public static DeviceToken of(Member member, String deviceToken, LocalDateTime createdAt) {
		DeviceToken token = new DeviceToken(member, deviceToken, createdAt);
		RuntimeExceptionThrower.validateDomain(token);
		return token;
	}

	// ... 생략
}

이를 위해서, 인증/인가를 담당하는 도메인에서 프론트엔드와 함께 해결해야하는 부분이라고 판단했고 나는 유저 ID에 알맞게 DeviceToken이 있음을 가정하고 작업했다.


알림 이벤트가 내부에서 발행 - 구독되는 방식

이제 SqsMessageEvent와 DeviceToken만 잘 이용하면, 원하는 기기에 적절한 푸시 알림을 보낼 수 있다.

한편, 앱 내 알림의 경우에는 푸시 알림과 다르다.

앱 내 알림에 대해 떠오른 요구사항은 다음과 같았다.

  • 사용자는 앱 내 알림을 삭제, 조회할 수 있다.
  • 사용자는 알림에서 나타나는 도메인들에 대해 내부 내비게이션을 사용할 수 있다.
  • **내부 네비게이션(Internal Navigation)**은 앱 내에서 특정 링크를 누르면 본인 앱의 다른 페이지로 넘어가는 기능이다. 예를 들어 ‘sanan님이 팔로우했습니다.’ 라는 알림이 있다면, 여기서 ‘sanan’을 눌렀을 때, 프로필 뷰로 넘어가게끔 만드는 방식이다.

알림의 삭제 및 조회는 간단한 API이지만, 내부 서비스 계층에서 각 알림 별 원하는 제목과 내용에 대한 포매팅, 그리고 동시에 푸시 알림까지 처리하는 로직을 생각해내야 했다.

그렇게 해서 떠오른 방법은 알림의 Type을 구분해서, 특정 양식을 프론트엔드와 명세하여 구현하기로 했다.

예를 들어, FOLLOW_CREATE_FROM 타입의 알림과 ‘{sanan_1}님이 팔로우했습니다.’라는 내용을 받게되면, 앱 단에서는 알림 타입과 ‘{sanan_1}’를 근거로 ‘sanan님이 팔로우했습니다.’ 라는 내부 네비게이션이 가능한 알림으로 변경하는 것이다.

NoticeType으로 분리

결국 앱 서버 내부적으로 원하는 케이스가 생길 때 마다, 그리고 문구를 바꾸고 싶을 때 마다 적용할 수 있는 방식이어야 한다고 생각했다. 이를 위해서 NoticeType의 열거형 클래스를 작성했다.

/**
 * 알림 타입을 정의하는 클래스입니다.
 *
 * <p>
 * 알림 타입은 [도메인]_[내용]_[FROM]_[TO?]의 구조로 이뤄집니다.
 * NoticeType은 각 알림 타입에 대한 이름, 제목 및 내용 형식을 지정합니다.
 * 또한, 알림 타입에 따라 내용을 생성하는 기능을 제공합니다.
 * </p>
 */
@Getter
public enum NoticeType {
	ANNOUNCEMENT("%s", "%s"), // 공지사항의 경우 별도로 제목을 설정하여 사용합니다.
	DIARY_NOTE_FROM_TO("새 일기", "%s님이 %s에 일기를 남겼습니다."),
	DIARY_MEMBER_FROM_TO("새 멤버", "%s님이 %s에 가입하셨습니다."),
	NOTE_LIKE_FROM_TO("일기 좋아요", "%s님이 회원님의 %s를 좋아합니다."),
	FOLLOW_CREATE_FROM("새 팔로우", "%s님이 회원님을 팔로우했습니다."),
	;

	private final String title;
	private final String contentFormat;
	private final String navigatablePlaceholder = "{%s_%d}";
	private final String plainPlaceholder = "%s";

	NoticeType(String title, String contentFormat) {
		this.title = title;
		this.contentFormat = contentFormat;
	}

	/**
	 * TODO : Notice 저장 전용, Push 전용 구분하여 포매팅 및 반환 메서드 분리
	 *
	 * @param fromName
	 * @param fromId
	 * @param toName
	 * @param toId
	 * @return
	 */

	public String createNavigatableNameFormattedContent(String fromName, Long fromId, String toName, Long toId) {
		ifTrue(this.equals(ANNOUNCEMENT), new DomainException(HttpStatus.BAD_REQUEST, "공지사항의 내용은 포매팅할 수 없습니다."));
		String fromPlaceholder = String.format(navigatablePlaceholder, fromName, fromId);
		String toPlaceholder = String.format(navigatablePlaceholder, toName, toId);
		return String.format(contentFormat, fromPlaceholder, toPlaceholder);
	}

	public String createPlainNameFormattedContent(String fromName, String toName) {
		ifTrue(this.equals(ANNOUNCEMENT), new DomainException(HttpStatus.BAD_REQUEST, "공지사항의 내용은 포매팅할 수 없습니다."));
		String fromPlaceholder = String.format(plainPlaceholder, fromName);
		String toPlaceholder = String.format(plainPlaceholder, toName);
		return String.format(contentFormat, fromPlaceholder, toPlaceholder);
	}
}

도메인 - 누가 - 누구에게 와 같은 양식으로 제목과 내용 포맷을 구분하여 매개변수를 받았을 때, 원하는 양식에 맞게끔 content를 반환하도록 구현했다.


NoticeEvent

@Getter
@ToString
@Builder(builderClassName = "NoticeEventBuilder")
public class NoticeEvent {

	private final Long fromId;
	private final String fromName;
	private final Long toId;
	private final String toName;
	private final Long memberId;
	private final NoticeType noticeType;
	private final LocalDateTime createdAt;

	private NoticeEvent(Long fromId, String fromName, Long toId, String toName, Long memberId, NoticeType noticeType, LocalDateTime createdAt) {
		this.fromId = fromId;
		this.fromName = fromName;
		this.toId = toId;
		this.toName = toName;
		this.memberId = memberId;
		this.noticeType = noticeType;
		this.createdAt = createdAt;
		ifFalse(this.isValid(), new DomainException(INVALID_ARGUMENT));
	}

	public static NoticeEventBuilder builder(NoticeType noticeType, Long memberId, LocalDateTime createdAt) {
		return new NoticeEventBuilder()
				.noticeType(noticeType)
				.createdAt(createdAt)
				.memberId(memberId);
	}

	private boolean isValid() {
		return this.memberId != null
				&& this.noticeType != null
				&& this.createdAt != null;
	}

	public String getNavigatableNameFormattedContent() {
		return this.noticeType.createNavigatableNameFormattedContent(this.fromName, this.fromId, this.toName, this.toId);
	}

	public String getPlainNameFormattedContent() {
		return this.noticeType.createPlainNameFormattedContent(this.fromName, this.toName);
	}

	public static class NoticeEventBuilder {
	}
}

/**
 * 공지사항 이벤트입니다.
 * <p>
 * 서비스 내의 모든 {@link DeviceToken}을 기준으로 해당 사항을 알립니다.
 * <p>
 * {@link #isPushOnly}가 true인 경우, 알림만 전송하고, {@link Notice} 엔티티는 저장하지 않습니다.
 *
 * @see AnnouncementEventBuilder
 */
@Getter
@ToString
@Builder(builderClassName = "AnnouncementEventBuilder")
public class AnnouncementEvent {
	private final String title;
	private final String content;
	private final Boolean isPushOnly;
	private final LocalDateTime createdAt;

	private AnnouncementEvent(String title, String content, Boolean isPushOnly, LocalDateTime createdAt) {
		this.title = title;
		this.content = content;
		this.isPushOnly = isPushOnly;
		this.createdAt = createdAt;
		ifFalse(this.isValid(), new DomainException(INVALID_ARGUMENT));
	}

	public static AnnouncementEventBuilder builder(Boolean isPushOnly, LocalDateTime createdAt) {
		return new AnnouncementEventBuilder()
				.isPushOnly(isPushOnly)
				.createdAt(createdAt);
	}

	private boolean isValid() {
		return isPushOnly != null
				&& createdAt != null;
	}

	public boolean isPushOnly() {
		return isPushOnly;
	}

	public static class AnnouncementEventBuilder {
	}
}

메인이 되는 Event 객체다. 서비스 계층에서 필요한 경우에 원하는 NoticeType에 맞추어 필요한 요소들을 builder를 이용해 ApplicationEventPublisher(Spring 기본 Bean)로 publish하게끔 구현했다.

공지사항의 경우 이후에 복잡해질 경우에 대비해서 일반 알림 이벤트와 별도로 관리하고자 했다.


세션의 지연 저장 이후에 메시지 전송

처음에는 Event의 내용을 이용해서 NoticeRepository에 save하고, SQS에 Message를 보내는 행위를 묶어서 해보려고 생각했다. 한편, 이 방식을 사용했을 때에는, 해당 메서드가 끝나고 JPA의 지연 저장이 수행될 때 커밋이 롤백되는 경우에도 메시지가 전송이 되므로, 이에 대한 분리가 필요했다.

EntityManager를 이용한 트랜잭션 수동 제어를 생각하였으나, 이는 다른 코드들에 비해 일관성(EntityManager를 특별히 사용하는 곳이 없음)이 부족해보였고, 다른 사람이 보았을 때 왜 이렇게 설계했는지 알아보기 어려울 것 같아 유지보수 측면에서도 문제가 있을 것 같았다.

좀 더 고민해본 결과, 애초에 Notice라는 엔티티의 생성 및 저장과 이 커밋결과에 따른 메시지 큐 발송은 분리되는 것이 맞다고 생각이 들었고, 이에 대한 별도의 이벤트를 만들어서 처리하기로 하였다.

NoticeEventHandler

@Component
@RequiredArgsConstructor
@Log4j2
public class NoticeEventHandler {

	private final ApplicationEventPublisher eventPublisher;
	private final NoticeRepository noticeRepository;
	private final DeviceTokenRepository deviceTokenRepository;

	/**
	 * {@link NoticeEvent}를 처리하는 핸들러입니다.
	 * <br>
	 * {@link NoticeEvent}를 기반으로 {@link Notice}를 생성하고, SQS 메시지 이벤트를 발행합니다.
	 * <br>
	 * {@link NoticeEvent}를 발행한 트랜잭션과 별개의 트랜잭션에서 동작합니다.
	 * <br>
	 * {@link TransactionalEventListener}를 통해 이벤트를 발행하는 클래스의 트랜잭션 커밋 이후에 핸들러가 동작하도록 합니다.
	 *
	 * @param event {@link NoticeEvent}
	 */
	@Transactional(propagation = Propagation.REQUIRES_NEW)
	@TransactionalEventListener
	public void handleNoticeEvent(NoticeEvent event) {
		log.info("handleNoticeEvent: {}", event);
		Notice notice = Notice.fromNoticeEvent(event);
		noticeRepository.save(notice);
		List<String> deviceTokens = deviceTokenRepository.findByMemberId(event.getMemberId());
		if (deviceTokens.isEmpty()) {
			throw new DomainException(HttpStatus.NOT_FOUND, "등록된 디바이스 토큰이 없습니다.");
		}
		deviceTokens.forEach(token -> eventPublisher.publishEvent(
				SqsMessageEvent.builder(
								notice.getTitle(),
								event.getPlainNameFormattedContent(),
								token)
						.build())
		);
	}

	/**
	 * {@link AnnouncementEvent}를 처리하는 핸들러입니다.
	 * <br>
	 * {@link AnnouncementEvent}를 기반으로 {@link Notice}를 생성하고, SQS 메시지 이벤트를 발행합니다.
	 * <br>
	 * {@link AnnouncementEvent}를 발행한 트랜잭션과 별개의 트랜잭션에서 동작합니다.
	 * <br>
	 * {@link TransactionalEventListener}를 통해 이벤트를 발행하는 클래스의 트랜잭션 커밋 이후에 핸들러가 동작하도록 합니다.
	 *
	 * @param announcementEvent {@link AnnouncementEvent}
	 */
	@Transactional(propagation = Propagation.REQUIRES_NEW)
	@TransactionalEventListener
	public void handleAnnouncementEvent(AnnouncementEvent announcementEvent) {
		log.info("handleAnnouncementEvent: {}", announcementEvent);
		List<DeviceToken> allDeviceTokens = deviceTokenRepository.findAll();
		allDeviceTokens.forEach(deviceToken -> {
			String token = deviceToken.getDeviceToken();
			if (!announcementEvent.isPushOnly()) {
				noticeRepository.save(
						Notice.fromAnnouncementEvent(
								announcementEvent,
								deviceToken.getMember()));
			}
			eventPublisher.publishEvent(
					SqsMessageEvent.builder(
									announcementEvent.getTitle(),
									announcementEvent.getContent(),
									token)
							.build());
		});
		log.info("handleAnnouncementEvent: {} messages have published.", allDeviceTokens.size());
	}
}
  •  

이 부분에서 이뤄지는 주요한 사항은 해당 이벤트에 대한 알림을 받아야하는 사용자의 기기에 푸시 알림을 보내주기 위해 deviceToken을 조회한다는 점이다.

서비스 계층에서 원하는 알림을 생성했을 때, 앱 내 알림, 푸시 알림 둘 다 생성해야 했기 때문에 위와 같은 방식으로 하나의 이벤트에 대해서 앱 내 알림을 1차적으로 엔티티로 저장했다.

별도의 이벤트로 해당 엔티티 저장 커밋이 성공하는 경우에 SqsMessageEvent를 발행하도록 구현하였다. 이런 구조로 작성했을 시 아래와 같은 구조로 나타내볼 수 있다.

@TransactionalEventListener

TransactionalEventListener는 Spring에서 제공하는 어노테이션으로, 트랜잭션의 특정 단계에서 이벤트를 처리하도록 리스너를 등록하는데 사용된다.

이 어노테이션은 트랜잭션 생명주기(lifecycle)에 동기적(synchronously) 또는 비동기적(asynchronously)으로 이벤트를 수신하는 데 유용하다. 이 어노테이션은 다음과 같은 멤버를 갖는다.

phase : 트랜잭션 단계에서 이벤트가 트리거될 때를 정의한다. TransactionPhase의 값은 AFTER_COMMIT(기본값), AFTER_COMPLETION, AFTER_ROLLBACK, BEFORE_COMMIT 이 있다.

fallbackExecution : 설정된 트랜잭션 단계가 누락된 경우에 EventHandler가 실행될지를 설정한다. 기본값은 false다. 이 값이 true로 설정되면, 그리고 phase가 AFTER_ROLLBACK이나 AFTER_COMPLETION인 경우, 트랜잭션이 없거나 비활성 상태인 경우에도 이벤트 핸들러가 호출된다.

즉, Publish하는 서비스에서의 트랜잭션 세션의 여부에 따라 이벤트 리스너가 동작하게끔 설정한다.

@Component
public class MyEventListener {

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void handleCustomEvent(SomeEvent event) {
      // 트랜잭션이 커밋 된 후에 실행할 코드
  }
}

@Transactional과는 별개의 것이다.

해당 Listener에서 Repository를 이용해서 Event에 대한 값을 별도로 저장하는 경우에 대한 Transaction은 해당 클래스나 메서드에 별도로 @Transactional을 달아주어야 한다. → 이 때, 기존의 Transactional 세션이 있다면 해당 이벤트는 그 트랜잭션에 따라 처리된다.

If a transaction is running, the event is handled according to its TransactionPhase.

  • @Transactional JavaDoc

그러므로, TransactionalEventListener로 이벤트가 트리거되면, 해당 이벤트가 Publish된 세션을 받아온다는 의미인데, 이에 대해서 별도의 트랜잭션으로 이벤트에 대한 엔티티 저장을 관리할 것이므로 전파 단계를 REQUIRES_NEW(새로 생성함)로 설정해준다.

	@Transactional(propagation = Propagation.REQUIRES_NEW)
	@TransactionalEventListener
	public void handleNoticeEvent(NoticeEvent event) {
		log.info("handleNoticeEvent: {}", event);
		Notice notice = Notice.fromNoticeEvent(event);
		noticeRepository.save(notice);
		List<String> deviceTokens = deviceTokenRepository.findByMemberId(event.getMemberId());
		if (deviceTokens.isEmpty()) {
			throw new DomainException(HttpStatus.NOT_FOUND, "등록된 디바이스 토큰이 없습니다.");
		}
		deviceTokens.forEach(token -> eventPublisher.publishEvent(
				SqsMessageEvent.builder(
								notice.getTitle(),
								event.getPlainNameFormattedContent(),
								token)
						.build())
		);
	}

알림 구현 확인해보기

예제로는 다음과 같이 작성했다.

@Service
@RequiredArgsConstructor
public class NoticeTestService {

	private final ApplicationEventPublisher eventPublisher;

	@Transactional
	public void createNoticeEvent() {
		NoticeEvent noticeEvent =
				NoticeEvent.builder(NoticeType.FOLLOW_CREATE_FROM, 1L, LocalDateTime.now())
						.fromId(1L)
						.fromName("sanan")
						.build();
		AnnouncementEvent announcementEvent =
				AnnouncementEvent.builder(false, LocalDateTime.now())
						.title("공지사항입니다")
						.content("공지사항 내용입니다")
						.build();
		eventPublisher.publishEvent(noticeEvent);
		eventPublisher.publishEvent(announcementEvent);
	}

}

위 메서드를 실행하면, 아래와 같이 알림을 받아볼 수 있다(FCM을 등록한 앱과 디바이스 토큰은 DB에 저장해두었다).

푸시 알림이 온다!


정리

최종 흐름은 다음과 같다.

(0) iOS 앱에서 사용자가 로그인, DeviceToken을 서버에 전달, 저장되고 해당 토큰에 대한 사용자가 갱신된다.

(1 - 1) … 사용자에게 알림이 필요한 비즈니스 로직이 수행된다!

(1 - 2) 해당 서비스에서 알림 이벤트(NoticeEvent)를 발행한다.

(2 - 1) NoticeEventHandler에서 해당 NoticeEvent를 앱 내 알림인 Notice 엔티티로 변환, 저장한다.

(2 - 2) NoticeEventHandler에서 SqsEventHandler에 푸시 알림 이벤트(SqsMessageEvent)를 발행한다.

(3) SqsEventHandler에서 해당 SqsMessageEvent의 내용을 기준으로, 외부 서비스인 AWS SQS 인스턴스에 송신한다.

(4) SQS가 해당 메시지를 이벤트로써 Lambda에 트리거한다.

(5) Lambda는 해당 이벤트의 내용을 파싱, FCM에 메시지로 [’제목’, ‘내용’]을 해당하는 device token의 기기에 전달한다.

(6) FCM에서 Lambda로부터 전달 받은 device token과 메시지 내용을 기준으로 푸시 알림을 전송한다.

처음 사용해보는 메시지 전달 기반 이벤트 방식과, SQS, Lambda, FCM 등에 대한 세팅에서 애를 먹었다.

NoSQL을 사용해서 더 유연하게 작성해보려고 했었는데, 요구되는 시간 내에 NoSQL까지 학습하여 원활하게 적용하기에는 어려울 것 같아서 RDBMS로 우선 구현했다.

메시지 기법과 이벤트, 그리고 알림 서버와 외부 서비스를 이용한 서버 리소스 절감등에 대해서 공부해보는 재밌는 기회였다.

참고 자료

Spring Event, @TransactionalEventListener 사용하기

AWS SQS + SPRING BOOT 사용법

[Spring] - Spring Cloud AWS 의존성 추가시 "Failed to connect to service endpoint" 에러 발생원인 및 해결방법

스프링에서 이벤트 구현하기

복사했습니다!