Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 알림 기능을 적용한다 #41

Merged
merged 66 commits into from
Jul 29, 2024
Merged

feat: 알림 기능을 적용한다 #41

merged 66 commits into from
Jul 29, 2024

Conversation

devholic22
Copy link
Collaborator

@devholic22 devholic22 commented Jul 12, 2024

📄 Summary

  • FCM 방식을 이용한 알림 시스템을 적용하였습니다. 참고 글
  • 더 빠른 동작을 하기 위해 비동기 방식을 이용하였습니다.
  • 다른 도메인에서 알림을 보내고자 할 경우에는 AlertCreatedEvent를 발생시키면 됩니다.
    public record AlertCreatedEvent(
        AlertGroup group, // 알림 그룹 (좋아요, 알림 등)
        String title, // 알림 제목
        String body, // 알림 상세 내용 (내부 메시지 등)
        String sender, // 발신자의 닉네임
        Long receiverId // 수신자의 ID
    ) {
    }
  • 회원에 대한 FCM 토큰이 필요합니다. 이것을 위해 회원이 로그인할 시 토큰 생성 이벤트 (AlertTokenCreatedEvent)를 발생시키도록 했습니다. (로그인 테스트에 이벤트 발생 검증을 적용하였습니다.)
  • 회원이 로그아웃할 시 토큰을 없애야 하는데, 이 경우는 로그아웃 기능이 완료된 뒤 호출하면 될 것 같습니다.
  • 회원의 토큰을 관리하는 것으로는 레디스를 이용하였습니다. (갱신/삭제 빈번, 토큰 데이터가 key-value 형식인 점 고려)
  • 회원이 받은 알림을 단일 조회할 때는 읽음 여부가 업데이트 됩니다. AlertQueryService와 AlertService 중 어느 곳에 해당 메서드를 둘 지 고민했었는데, AlertQueryService는 순수한 조회, AlertService는 내부 작업이 포함된 모든 기능들을 관리하도록 하는 게 더 나을 것 같아 AlertService에 단일 조회를 두었습니다.
  • 모든 알림은 생성된 지 60일이 초과되면 (즉, 61일 이상) 삭제 처리됩니다. 다만 통계를 위해 사용해야 할 수도 있으므로 soft delete를 이용하였습니다. 이때, soft delete를 하면서도 부모 (BaseEntity)의 값을 정해줘야 하기에 @SuperBuilder를 적용해주었습니다.

🙋🏻 More

1차 내용

  • 파이어베이스 빈을 등록할 때 민감한 정보를 드러내지 않기 위해 @Value로 암호화된 값들을 가져오고, 이들을 Map으로 만든 뒤 바이트스트림 화 하는 것으로 문제를 해결했습니다. 특히 테스트 환경일 때 문제가 발생했는데, 다행히 파이어베이스에서 기본 GoogleCredentials을 발행해주는 코드가 있어 문제 발생 상황 (IOException, 테스트 환경일 때 발생)을 해결했습니다.
  • 알림 보냄 여부를 테스트할 때 Fake 객체로 만들긴 했으나 (AlertFakeManager) 이 방법이 충분할 지 의문이 듭니다. 혹시 더 좋은 방법이 있을지 더 고민해보도록 하겠습니다.
  • 로컬에서 테스트했을 때는 문제 없이 수행되다가 CI 환경에서만 실패했었는데, 이전에 호감 조회 기능을 개발했을 때에도 겪었던 이슈라 금방 해결할 줄 알았으나 오래 걸렸습니다. 그러다가 LocalDateTime 정밀도 관련 문제임을 알게 되었고, 다른 기능들을 개발할 때 (최신 순 정렬 조회가 필요한 경우 등) LocalDateTime을 쓰지 않고 시간 차이를 표현할 수 있는 다른 객체 (ex: 제목 1, 제목 2처럼 늘려가는 방법)만 검증하는 게 좋겠다는 생각이 들었습니다!

2차 내용 (분산 락)

참고 문서 - 컬리 기술 블로그 '풀필먼트 입고 서비스팀에서 분산 락을 사용하는 방법'

  • 해결하려는 문제: 주기적으로 생성된 지 60일 이상 된 알림들을 삭제 상태로 변환시키는 스케줄러를 작성하였습니다. 그러나 서버가 확장되어 스케일 아웃 될 경우, 기존 방식대로라면 여러 인스턴스에서 같은 시간에 UPDATE SQL을 보내는 이슈가 발생할 수 있겠다는 것을 파악했습니다.
  • 락 방식 선택 - synchronized / 낙관적 락 / 비관적 락 / 분산 락
    • synchronized: 어플리케이션 단위에서 제어되는 기술이기 때문에, 본 문제와는 관련이 없다고 생각했습니다.
    • 낙관적 or 비관적 락: 낙관적 락이나 비관적 락은 데이터베이스의 특정 레코드에 대해서 사용되는 기법이지만, 본 문제는 60일 이상 된 알림들을 모두 UPDATE하는 쿼리를 다루고 있기 때문에 관련이 없다고 생각했습니다.
    • 이러한 이유로 문제에서 요구되는, 다수의 서버에서 발생하는 동시성을 해결하기 위해서는 분산 락을 이용해야겠다고 생각했습니다.
  • 기술 선택 - MySQL / Zookeeper / Redis
    • MySQL을 이용한 분산 락 (네임드락)은 락을 사용하기 위해 별도의 커넥션 풀을 관리해야 하고, 락 부하를 RDB에서 받게 됩니다. 이미 Redis를 프로젝트에서 사용하기도 하고, 성능이 더 중요하다고 생각해 적용하지 않았습니다.
    • Zookeeper는 현재 프로젝트에서 Zookeeper를 사용하지 않으며, 학습 비용이 다른 두 가지에 비해 더 높다는 판단을 하여 적용하지 않았습니다.
    • Redis를 이용하면 인메모리 특성상 빠른 속도를 낼 수 있고, 별도의 설정이 필요하지만 Redis를 본 프로젝트에서 이미 사용한다는 점 때문에 Redis를 이용한 분산 락 방식이 스케줄러에 더 적합하다는 생각이 들었습니다.
  • 방식 선택 - Lettuce / Redisson
    • Lettuce: Lettuce는 공식적으로 분산 락을 지원하지 않기에 직접 구현해야 합니다. 또한, 스핀 락 방식으로 진행되어 락을 획득하지 못할 경우 락 획득을 위해 지속적으로 요청해야 하는 점이 있어 최대한 부하를 줄이고자 선택하지 않게 되었습니다.
    • Redisson: Redisson을 이용한 분산 락은 RLock을 이용하며, 이것의 내부 코드를 보면 발행/구독 (pub/sub)으로 이루어져 있음을 볼 수 있습니다. 이 덕분에 스핀 락 방식에 비해 추가적으로 부하가 발생하지 않으며, 락 획득 시도/반환 등의 코드를 쉽게 제공해주어 Redisson을 선택하게 되었습니다.

close #34

- Firebase 설정 파일 환경 등록
- 알림 도메인 기초 설계 작성
- 관련 예외 핸들러 등록
- 알림 전송 기능에 대한 서비스, 구현체 작성
- 보안을 위해 Firebase key gitignore에 등록
- 알림에 AlertGroup 정보 추가
- AlertManager 패키지 위치 수정
- 알림에 발신자 정보 추가 (닉네임)
- 보낸 발신자가 없을 경우 시스템이 보낸 것으로 간주
- 알림 이벤트 핸들러 추가
- CreateAlertRequest 삭제 대신 이벤트 인자로 변경
- 보낸 알림과 데이터베이스 연결 작성
- Soft delete를 적용하기 위해 SoftDeleteBaseEntity를 상속받도록 수정
- 알림 생성 시각 추가
- 알림에 쓰인 토큰을 관리하지 않도록 제거
- 알림의 대상이 되는 회원의 ID가 연결되도록 수정
- 로그인 시 토큰을 저장하도록 설정
- 토큰 관리 리포지터리 정의
- senderId 대신 receiverId를 관리하도록 수정
- 토큰을 직접 받기보다 서비스 내부에서 가져오도록 수정
- 토큰 조회 시 유효성 검사 진행
- 알림 조회 기능 개발 (페이징, 단일)
- 알림 단일 조회 시 read 작업 수행되도록 설정
- 알림 도메인 단위테스트 작성
- AlertJdbcRepository 테스트 작성
- 관련 Fixture 생성, 값을 작성하기 위해 빌더 패턴 추가
- AlertJpaRepository 테스트 작성
- 관련 Fixture 생성
- AlertQueryRepository 테스트 작성
- 페이징 조회 타입을 QueryResults로 변경
- AlertSearchResponse 반환 타입 변경
- 관련 Fixture 등록
- 레디스 의존성을 드러내기 위해 파일 이름 변경
- FakeAlertTokenRepository 작성
- FakeAlertRepository 작성
- AlertService 테스트 작성
- 관련 Fixture 등록
- FakeAlertRepository 문제 수정
- 알림 정렬 조건에 id 추가되도록 수정
- AlertQueryService 테스트 작성
- 관련 Fixture 등록
- FirebaseApp 중복 문제 해결
- AlertQueryRepositoryTest에 AuditingHandler 추가
- 값 비교 시 LocalDateTime 제외되도록 수정
- AuditingHandler 제거
- AlertGroup Enumerated value 수정
- 바뀐 경로 import 교체
- assertSoftly 문제 수정
- 레디스 분산 락을 적용하기 위해 redisson 등록
- AlertScheduler 인터페이스 분리
- 기존 AlertService 스케줄러 메서드 삭제 및 이동
- RedissonAlertSchedulerTest 테스트 작성
- Async 어노테이션으로 알림 전송 스레드 분리
- Async 전용 설정 파일 생성
- Firebase 전용 스레드 매니저 생성
- 트랜잭션 전파 수준 수정
Copy link
Collaborator

@sosow0212 sosow0212 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

몇 부분에 대해 질문이 있어서 남겨봤습니다 확인 부탁드립니다!

@Component
public class FirebaseThreadManager extends ThreadManager {

private static final int FIREBASE_THREADS_SIZE = 40;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

쓰레드 40개로 선정하신 이유가 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실 이 부분은 스레드를 얼마까지 정해뒀을 때 수요를 버틸 수 있을지 정확히 예측하기 힘들다고 판단하여 다른 글들을 참고해서 보수적으로 적용했었습니다. 실제 배포 후 모니터링한 뒤 더 늘릴 수 있다면 이후에 늘려보고 싶습니다.

Comment on lines +16 to +19
@Override
protected ExecutorService getExecutor(final FirebaseApp firebaseApp) {
return Executors.newFixedThreadPool(FIREBASE_THREADS_SIZE);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newFixedThreadPool로 쓰레드를 설정해주셨는데요.
만약에 40개의 코어 쓰레드가 모두 사용중이라면 그 이후에 오는 요청은 어떻게 되고 최대 몇 개까지 기다릴 수 있을까요?
그리고 만약 초과가 됐다면 이 경우 대기중인 요청은 어떻게 되나요?

Copy link
Collaborator Author

@devholic22 devholic22 Jul 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 우선 알림을 보낼 때 영향이 끼쳐지는 스레드는 FirebaseThreadManager에서 관리하는 스레드가 아닌, AsyncConfig에서 관리하는 스레드입니다. (AsyncConfig의 getAsyncExecutor 메서드, 스레드 이름을 로그로 남겨보면서 확인하였습니다.)

스크린샷 2024-07-28 오후 5 17 01

  • 지정한 MAX_THREAD_SIZE + QUEUE_SIZE개 만큼의 수요 (지금은 MAX_THREAD_SIZE 40, QUEUE_SIZE 100이므로 140개 가능)를 감당한 뒤 나머지 처리에 대해서는 ThreadPoolExecutor에서 전략을 설정할 수 있는데, 우선은 이벤트를 호출한 스레드 (즉, 스프링 메인 스레드)에서 나머지를 보내도록 설정하였습니다. (ThreadPoolExecutor.CallerRunsPolicy 전략)
    • AbortPolicy: TaskRejectedException이 발생되고 요청이 무시됩니다.
    • CallerRunsPolicy: 처리되지 못한 요청을 ThreadPool을 호출한 Thread에서 처리합니다.
    • DiscardPolicy: 처리되지 못한 요청을 무시합니다.
    • DiscardOldestPolicy: 가장 오래된 요청을 삭제하고 다시 execute를 실행합니다.
    • 이에 따라, 알림을 보내야 하는 상황에서 무시하거나 예외를 발생시키기보다는 메인 스레드를 이용해서라도 진행시켜야 하는 게 맞겠다는 생각이 들어 CallerRunsPolicy를 선택하였습니다.
  • 그럼에도 FirebaseThreadManager에서 스레드를 따로 지정한 이유는 기본적으로 파이어베이스가 요청이 n개 들어온다면 n개만큼의 스레드를 만들기 때문에, 극단적인 예로 10,000개의 알림을 보내야 한다면 10,000개의 스레드를 만들게 되고 이로 인해 OutOfMemory 현상이 발생할 수 있음을 확인하였어서 지정했었습니다.

Comment on lines 28 to 34
private int getNextPage(final int pageNumber, final Page<AlertSearchResponse> alerts) {
if (alerts.hasNext()) {
return pageNumber + NEXT_PAGE_INDEX;
}
return NO_MORE_PAGE;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 페이징을 사용할 때 중복 코드로 발생할 것 같은데
하나의 템플릿을 제네릭으로 만들고 페이징할 때 그걸 extends해서 사용하면 어떨까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 생각입니다! BaseQueryService 템플릿을 만들어두었습니다.

Comment on lines +43 to +50
public void send(final Alert alert, final String sender, final String token) {
Message firebaseMessage = createAlertMessage(alert, sender, token);
FirebaseMessaging firebase = FirebaseMessaging.getInstance();
ApiFuture<String> process = firebase.sendAsync(firebaseMessage);

Runnable task = () -> logAlertResult(process, firebaseMessage);
process.addListener(task, executor);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AlertManager.send()를 사용하는 부분에서 firebase는 외부 라이브러리일텐데 해당 메서드에서 firebase를 통해 알람을 전송하면 firebase 내부적으로 비동기로 작동이 되나요? (즉, send 메서드에서 firebase에서 알림을 전송할 때 반환을 받든 안 받든 상관없이 기다리지 않냐는 질문입니다!)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • firebase의 sendAsync 메서드를 이용하면 파이어베이스 내부적으로도 비동기적으로 작동되고, ApiFuture (Future의 자식 객체)를 반환합니다.
  • 이때 ApiFuture.get 메서드를 이용하면 응답 값을 받을 수 있으나, get 메서드를 이용하는 순간 동기적으로 작동하기 때문에 get을 통한 방식보다는 addListener 방식을 이용하여 콜백 메서드로 실행되게끔 하였습니다. (비동기-논블록킹)

스크린샷 2024-07-28 오후 7 46 41
예시로 첫 번째 경우는 firebase.sendAsync(firebaseMessage).get() 방식 (동기)으로 바로 이끌어낸 경우이며, 이 때는 알림 작업을 진행한 스레드가 알림 작업의 결과까지 받아옵니다.

스크린샷 2024-07-28 오후 7 51 36
그러나 두 번째 경우 (현재 코드, Runnable 및 addListener 방식)는 알림 작업을 진행한 스레드가 결과를 받는 게 아니라, 스레드 풀에 남아있는 다른 스레드가 알림 작업의 결과를 받아오게 됩니다.

}

private void retryAlert(final Message message, final ExecutionException exception) {
if (exception.getCause() instanceof FirebaseMessagingException e) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instanceof는 지양해주세요! 참고

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instanceof 대신 ClassCastException을 try-catch 하는 식으로 리팩터링 하였습니다.

return errorCode.equals(MessagingErrorCode.INTERNAL) || errorCode.equals(MessagingErrorCode.UNAVAILABLE);
}

private void retryInThreeTimes(final Message message) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

많은 양의 알람을 재전송하는데 firebase측의 문제로 쓰레드가 오랫동안 작업을 수행한다면 문제가 생길 수도 있을 것 같은데, redis에 적재해놓고 한 번에 배치처리 하는 것은 어떻다고 생각하시나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파이어베이스 알림 전송 과정에서 재전송을 해야 하는 경우가 빈번히 발생하지는 않을 것 같아 지금은 배치 처리를 하지 않았는데, 지금 단계에서 배치 작업을 진행해야 할 지 고민이 듭니다. 모니터링 과정을 거친 뒤에 실제 알림이 빈번히 재전송을 해야 할 필요성이 느껴질 때 반영해도 괜찮을까요?


@Scheduled(cron = MIDNIGHT)
@Override
public void deleteExpiredAlerts() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네임드락 공통 코드 부분도 AOP로 만들어주시면 재사용하기에 도움될 것 같습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RedissonDistributedLock로 AOP 등록 완료하였습니다!

- 페이지 조회 공통 메서드를 축약하기 위해 BaseQueryService 생성
- Redisson Lock 재사용을 위해 AOP 생성 및 적용
- AsyncUncaughtExceptionHandler 등록
- DB에 저장된 createdAt 속성 들어가도록 수정
- 메시지 바디 null 문제로 인해 data에 들어가도록 수정
- instanceof 대신 ClassCastException 탐지되도록 수정
@devholic22 devholic22 merged commit d171e79 into develop Jul 29, 2024
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

알림 기능을 적용한다
3 participants