시작하며
그동안 이런 저런 일로 오랜만에 글을 작성하게 되었는데, 앞으로는 글을 작성하는 스타일이 바뀔 것 같습니다.
단순 정보 전달, 개념 정리 보다는 실제로 개발을 하면서 겪었던 문제점, 고민, 개선 스토리 등을 공유하고자 합니다.
해당 시리즈에서는 '우리들의 코스, 우코(Wooco)' 서비스의 알림 기능을 어떻게 개선해 나갔는지를 소개하겠습니다.
'Wooco' 서비스(편의상 '우코' 라고 칭하겠습니다)는 특정 지역에서의 맛집, 카페, 놀거리 등을 하나의 코스로 묶어 공유할 수 있고, 사용자들은 이를 참고해 나만의 일정을 만들어나갈 수 있는 장소 아카이빙 및 공유 플랫폼 입니다. 우코에 대한 자세한 설명은 추후 홍보와 함께 자세히 하도록 하겠습니다. (뜨거운 관심 부탁드려요)
저는 알림 기능 개발을 맡았었고, 열심히 작업했습니다. 하지만 구현이 어느정도 끝난 지금에서야 문제점이 많다는 것을 하나 둘 인식하였습니다. 사실 처음엔 "알림 전송은 FCM에 위임하고, 서버에서 FCM에 전송 요청만 잘 하면 되는거 아니야?" 했지만, 이는 아주 위험한 생각이었습니다. 생각보다 관리 및 신경써야할 포인트가 많았고, 발견한 문제를 하나씩 해결해야겠다고 생각했습니다.
우코엔 어떤 알림이 있나요?
현재 우코 서비스에서 알림이 발생하는 상황은 아래처럼 크게 세 가지 경우가 있습니다.
- 자신이 작성한 코스에 다른 사용자가 댓글을 작성한 경우
- 플랜은 작성했지만 코스로 공유하지 않은 경우
- 시스템 알림
코스는 자신이 추천하고 싶은 장소들을 모아놓은 게시글입니다. 쉽게 자신이 작성한 글(코스)에 누군가 댓글을 추가하면 알림이 온다고 이해하시면 됩니다.
우코엔 '플랜' 기능이 있습니다. 코스와 비슷하게 추천 장소를 모아놓지만, 자신만 볼 수 있습니다. 방문 날짜를 정해 플래너처럼 사용할 수 있죠. 다른 사용자들에게도 공유하고 싶다면 플랜을 그대로 코스로 만들어 공유할 수 있습니다. 플랜은 만들었지만 공유하지 않은 경우, 코스로 공유해달라고 요청하는 알림이 발생하게 됩니다.
왜 Why, FCM 인가요?
우선 실시간 알림 기능을 구현하는 여러 방법이 있지만, 그중에서도 FCM 을 선택한 이유를 말씀드리겠습니다.
그 방법엔 대표적으로 SSE, WebSocket, Polling/Long Polling, FCM 등이 있습니다.
각 기술을 검토하였고, 최종적으로 FCM을 선택한 데엔 아래와 같은 이유들이 있었습니다.
1. SSE (Server-Sent Events)
저희 서비스는 PC 에서도 모바일 뷰이고, 추후 앱으로의 확장도 생각하고 있습니다. 따라서 모바일 환경에서도 푸시 알림이 전송되어야 했습니다. 하지만 SSE는 브라우저 기반 단방향 통신이므로, 앱이 백그라운드 상태나 화면 꺼짐시 연결이 끊기기 쉽습니다. (웹앱이라고 해도 항상 접속해있진 않으니까요.) 또한 네트워크 환경이 불안정한 모바일 환경에서는 지속적인 재연결 처리가 필요합니다. 사용자가 서비스를 종료하거나 화면을 꺼두면 알림 수신이 어렵겠다고 생각했습니다.
2. WebSocket
웹소켓은 클라이언트와 지속적인 양방향 연결로 인해 실시간성에 유리하다는 것이 장점이지만, 모바일에서는 앱이 백그라운드로 전환되면 커넥션이 끊길 가능성이 있고, 아예 알림 전송이 불가능해질 수 있습니다. 또한 저희 서비스의 알림 특성상 예측 불가능한 시점에 발생하기 때문에 항상 커넥션을 유지하는 것이 부담이라고 생각했습니다.
결국 클라이언트 수가 많아질수록 커넥션 관리에 신경을 써야하며, 현재 저희 서버의 사양이 크게 좋지 않기 때문에 적합하지 않겠다고 판단했습니다.
3. Polling / Long Polling
폴링 방식은 다른 방법들보다 실시간성이 부족하고, 지속적으로 서버에 알림이 존재하는지 요청을 보내야하기 때문에 리소스 낭비가 클 것이라고 생각했습니다. 다른 방법들보다 단점이 많은 것 같아 폴링은 처음부터 고려하지 않았습니다. 물론 Long Polling은 그나마 나은 방식이지만 여전히 서버 리소스 낭비가 클 수 있습니다.
4. FCM (Firebase Cloud Messaging)
FCM은 하나의 메시지로 여러 디바이스에 전송 가능한 멀티캐스트 기능이 있기에 여러 기기를 사용하더라도 알림을 전송할 수 있었습니다.
또한 레퍼런스가 많고 구현이 비교적 간단해 빠른 개발이 가능했고, 인프라를 직접 관리하지 않아도 되어 비용 관리 측면에서도 효율적이라고 생각했습니다.
FCM은 메시지 전송 요청을 수신하면 빠르게 응답하고, 실제 수신/전송은 비동기적으로 처리합니다. 저희 서버가 알림을 클라이언트에 직접 전송하지 않고, FCM 서버에 위임하는 구조라 실제 수신 시간은 예측이 어렵고 지연이 발생할 수 있습니다.
또한 전송 실패시 재시도 로직 또한 필요하다고 생각했습니다. (안정적이고 좋은 사용자 경험을 제공하는 서비스라면 이러한 상황까지 대응할 수 있어야한다고 생각합니다.)
그럼에도 모바일 환경에서의 안정적인 알림 수신, 백그라운드 지원, 낮은 유지비용, 높은 신뢰성, 빠른 구현 가능 등 얻는 것이 더 많다고 판단했습니다. 서비스 특성상 사용자가 앱을 꺼두거나 백그라운드 상태일 때에도 알림을 받아야 하므로, FCM은 현 시점에서 가장 현실적이고 신뢰할 수 있는 선택지였습니다.
초기 알림 전송 흐름
위에서 말씀드린 '자신이 작성한 코스에 다른 사용자가 댓글을 작성한 경우'를 예시로 초기 알림 기능의 흐름을 설명드리겠습니다.
* 해당 글에선 인프라가 아닌, 애플리케이션 레벨에서의 구조를 다루고자 합니다.
- 댓글 생성 도메인 이벤트가 발행됩니다.
- 이벤트 핸들러가 이벤트를 수신, 해당 정보를 바탕으로 알림 객체를 생성해 DB에 저장합니다.
- 저장된 알림과 사용자 식별 정보를 바탕으로 디바이스 토큰을 조회합니다.
- FCM 서버로 알림 전송을 요청하여 푸시 알림을 보냅니다.
일정을 맞추기 위해서 빠르고 정확하게 개발하는 것이 최우선 목표였기 때문에 "우선 요구사항에 맞게 돌아가는 코드를 작성하자" 생각했고, 위처럼 간단한 흐름으로 구현하였습니다. 실제로 프론트와 연동하여 테스트를 했을 때도 별 문제 없이 잘 동작했습니다.
하지만 코드를 찬찬히 뜯어보니 문제점이 꽤 보이기 시작했습니다. 다음은 위 그림의 흐름이 적용된 초기 코드입니다.
NotificationEventHandler
@Component
class NotificationEventHandler(
private val createNotificationUseCase: CreateNotificationUseCase,
private val sendNotificationUseCase: SendNotificationUseCase,
) {
@Async
@EventListener
fun handleCourseCommentCreatedEvent(event: CourseCommentCreatedEvent) {
val command = CreateNotificationUseCase.Command(
userId = event.courseWriterId,
targetId = event.courseId,
targetName = event.courseTitle,
type = NotificationType.COURSE_COMMENT_CREATED.name,
)
val notificationId = createNotificationUseCase.createNotification(command)
val query = SendNotificationUseCase.Query(notificationId)
sendNotificationUseCase.sendNotification(query)
}
// ...
}
NotificationCommandService
@Service
class NotificationCommandService(
private val notificationQueryPort: NotificationQueryPort,
private val notificationCommandPort: NotificationCommandPort,
private val deviceTokenQueryPort: DeviceTokenQueryPort,
private val deviceTokenCommandPort: DeviceTokenCommandPort,
) : CreateNotificationUseCase,
MarkAsReadNotificationUseCase,
RegisterDeviceTokenUseCase,
DeleteDeviceTokenUseCase {
@Transactional
override fun createNotification(command: CreateNotificationUseCase.Command): Long {
val target = Target(command.targetId, command.targetName)
val notification = Notification.create(
userId = command.userId,
target = target,
type = NotificationType(command.type),
)
return notificationCommandPort.saveNotification(notification).id
}
// ...
}
FcmNotificationSenderAdapter
@Component
internal class FcmNotificationSenderAdapter(
private val firebaseMessaging: FirebaseMessaging,
) : NotificationSenderPort {
override fun sendNotification(
notification: Notification,
tokens: List<Token>,
) {
val messages = MulticastMessage
.builder()
.addAllTokens(tokens.map { it.value })
.putData("notification_id", notification.id.toString())
.putData("user_id", notification.userId.toString())
.putData("target_id", notification.targetId.toString())
.putData("target_name", notification.targetName)
.putData("type", notification.type.name)
.build()
return sendWithLogging(messages)
}
private fun sendWithLogging(message: MulticastMessage) {
val response = firebaseMessaging.sendEachForMulticast(message)
if (response.failureCount > 0) {
log.warn { "FCM push failed count: ${response.failureCount}" }
}
}
// ...
}
어떤 문제가 있는지 보이시나요?
초기 알림 전송 로직의 문제점
의도와 달리 완전히 동기로 동작하는 구조
// NotificationEventHandler 메서드
@Async
@EventListener
fun handleCourseCommentCreatedEvent(event: CourseCommentCreatedEvent) {
// ...
}
// FcmNotificationSenderAdapter 메서드
private fun sendWithLogging(message: MulticastMessage) {
val response = firebaseMessaging.sendEachForMulticast(message)
if (response.failureCount > 0) {
log.warn { "FCM push failed count: ${response.failureCount}" }
}
}
첫째, 위 'handleCourseCommentCreatedEvent()' 메서드에 @Async가 있어 "비동기" 처럼 보이지만, 실제로는 FCM 전송(I/O) 로직 내부에서 동기 블로킹 메서드인 'sendEachForMulticast()'를 수행합니다. 따라서 이벤트 핸들러의 메서드만 비동기로 실행될 뿐, 메시지 전송엔 큰 영향이 없다는 것이죠. 다음과 같은 문제가 발생할 수 있다고 생각했습니다.
- API 지연 전파 : 전송 시간이 길어진다면, 해당 메서드를 호출한 스레드가 그대로 묶입니다.
- 스레드 풀 고갈 : 동시에 엄청난 양의 댓글이 생성되면 @Async 스레드 풀이 금방 고갈될 수 있습니다.
둘째, 만약 댓글 작성 트랜잭션이 롤백돼도 @EventListener은 즉시 실행됩니다. 그 결과 존재하지 않는 댓글에 대한 알림이 발송될 수 있고, 일관성이 깨질 수 있습니다.
대량 멀티캐스트의 한계와 병렬화 부재
현 구조에선 알림 1건 -> 수신자의 모든 디바이스 토큰을 단일 멀티캐스트로 처리합니다. 하지만 이는 다음과 같은 문제점이 있습니다.
- FCM 전송 제한 (최대 500개 토큰)을 초과하면 전송에 실패합니다.
- 전송 작업이 하나의 스레드에서 순차적으로 실행됩니다.
- 알림 N건 -> 디바이스 토큰 M개 로 전송하는 것이 불가능합니다.
결국 멀티캐스트로 전송하는 방식 외에도 각 토큰별 전송 로직을 병렬처리 하는 등 여러 방법을 고민하기 시작했습니다.
알림 전송에 실패한다면?
'sendEachForMulticast()' 메서드의 결과로는 실패 건수를 로그만 남기고 무시합니다. 'failureCount' 를 받긴 하지만, 몇 개의 알림이 전송에 실패했는지만 알 뿐, 해당 알림을 재전송한다거나 추적할 수는 없습니다.
물론 알림 조회시 이미 저장된 알림을 가져오기 때문에, 실시간 알림은 중요하지 않다고 생각할 수 있습니다. 하지만 모바일로 환경이라고 가정하면 항상 서비스에 접속하지 않을 것이고, 직접 접속해야만 알림을 확인 가능하다는 불편함이 있습니다. 따라서 실시간성은 조금 포기하더라도, 실패한 알림을 재전송하여 사용자에게 알림을 잘 전달하는 방법이 필요하다고 생각했습니다.
다음은..
이렇듯 알림 전송엔 문제가 특별한 없어보이지만 트랜잭션 경계와 I/O, 실패 처리까지 생각을 해보면 보완할 점이 꽤나 많았습니다.
물론 잘 돌아가게 기능 구현만 잘하면 되는거 아닌가? 생각할 수 있지만, 이는 개발자로서 당연히 갖춰야할 능력이라고 생각합니다.
여기에 더해 비즈니스를 이해하고 사용자들의 니즈를 파악하여 이를 개선한다면, 더 좋은 서비스를 만들 수 있을 것이라고 느꼈습니다.
해당 글에선 우코 서비스의 알림 기능을 개발하면서 만난 문제점들과 고민을 소개했고, 다음 시리즈에서 이를 어떻게 해결해나갔는지 소개해보도록 하겠습니다. 감사합니다.