지난 포스팅에 이어서 새로운 메시지를 사용자에게 알려주는 기능을 더 추가해보도록 하겠습니다. 채팅방 목록에서 각 채팅방의 마지막 메시지를 보여주고, 새로운 메시지가 도착했다는 푸시알림도 전송해보겠습니다.
① ChatRoomController에 아래의 API를 추가한다.
// 채팅방에 참여한 유저의 닉네임 List를 String으로 반환
@GetMapping("/userList/{roomId}")
public BaseResponse<String> getUserStrList(@PathVariable String roomId) {
try {
return new BaseResponse<>(chatRoomService.getUserStrList(roomId));
} catch (BaseException exception) {
return new BaseResponse<>(exception.getStatus());
}
}
② 이제 GetChatRoomRes에서도 nickNameList를 반환할 필요가 없다.
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class GetChatRoomRes {
private String chatRoomId;
private String roomName;
}
① ChatRoom에서 userList는 지우고, lastMessage 필드를 추가한다.
data class ChatRoom(
val chatRoomId : String? = null,
val roomName : String? = null,
val unreadCount : Int? = 0,
val lastMessage : String = ""
)
② ChatListFragment의 getUnreadMessageCount 메서드에서 마지막 메시지를 가져와 기존의 userList가 있던 자리에 띄워주도록 하겠다.
private fun getUnreadMessageCount(chatRoomId : String) {
val postListener = object : ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
var count = 0
var lastMessage = ""
for (datamModel in dataSnapshot.children) {
val uidList = datamModel.child("readerUids").getValue(object : GenericTypeIndicator<MutableMap<String, Boolean>>() {})
if (uidList != null) {
Log.d("readerUids", uidList.toString())
if (!uidList.containsKey(FirebaseAuthUtils.getUid())) {
// readerUid에 내 uid가 없으면
count++
}
}
val lastDataModel = datamModel.getValue(MessageModel::class.java)
if(lastDataModel != null) {
lastMessage = lastDataModel.contents
}
}
FirebaseRef.chatRoom.child(FirebaseAuthUtils.getUid()).child(chatRoomId).child("unreadCount").setValue(count)
FirebaseRef.chatRoom.child(FirebaseAuthUtils.getUid()).child(chatRoomId).child("lastMessage").setValue(lastMessage)
}
override fun onCancelled(databaseError: DatabaseError) {
Log.w("MyMessage", "onCancelled", databaseError.toException())
}
}
FirebaseRef.message.child(chatRoomId).addValueEventListener(postListener)
}
③ chat_listview_item.xml 파일의 기존 User List TextView를 아래와 같이 수정한다.
<TextView
android:id="@+id/lvLastMessageArea"
android:text="Last Message"
android:textSize="15sp"
android:layout_marginHorizontal="40dp"
android:layout_marginBottom="20dp"
android:maxWidth="170dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
④ ChatRoomAdapter를 아래와 같이 수정한다.
class ChatRoomAdapter(private val context: Context, private val dataList : List<ChatRoom>) : BaseAdapter() {
override fun getCount(): Int {
return dataList.size
}
override fun getItem(position: Int): Any {
return dataList[position]
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
var convertView = convertView
if (convertView == null) {
convertView = LayoutInflater.from(context).inflate(R.layout.chat_listview_item, parent, false)
}
val listViewChatRoomName = convertView?.findViewById<TextView>(R.id.lvChatRoomName)
val listViewLastMessage = convertView?.findViewById<TextView>(R.id.lvLastMessageArea)
val listViewUnreadCount = convertView?.findViewById<TextView>(R.id.unreadMessageCountTextView)
val unreadMessageContainer = convertView?.findViewById<FrameLayout>(R.id.unreadMessageContainer)
listViewUnreadCount!!.text = dataList[position].unreadCount.toString()
val unreadCount = listViewUnreadCount!!.text.toString().toInt()
if (unreadCount > 0) {
unreadMessageContainer!!.visibility = View.VISIBLE
listViewUnreadCount.visibility = View.VISIBLE
} else {
unreadMessageContainer!!.visibility = View.GONE
listViewUnreadCount.visibility = View.GONE
}
listViewChatRoomName!!.text = dataList[position].roomName
listViewLastMessage!!.text = dataList[position].lastMessage
return convertView!!
}
}
⑤ 이외의 기존 user list와 관련된 모든 내용을 삭제한다.
⑥ ChatApi에 아래의 API를 추가한다.
@GET("/chat/userList/{roomId}")
suspend fun getUserStrList(@Path("roomId") roomId : String) : BaseResponse<String>
⑦ ChatRoomActivity에 getUserStrList를 추가하고 Coroutine을 이용해 호출한다.
override fun onCreate(savedInstanceState: Bundle?) {
...
CoroutineScope(Dispatchers.IO).launch {
val response = getUserCount(chatRoomId!!)
Log.d("userCount", response.toString())
if (response.isSuccess) {
count = response.result.toString()
userCount.text = count
} else {
Log.d("UserListFragment", "유저의 정보를 불러오지 못함")
}
}
...
private suspend fun getUserStrList(roomId: String): BaseResponse<String> {
return RetrofitInstance.chatApi.getUserStrList(roomId)
}
...
이제 코드를 실행해보자. 채팅방 목록에는 마지막 메시지가 보여야 하고, 채팅방에 입장했을 때에는 여전히 유저 수와 유저의 닉네임 리스트가 보여야 한다.
푸시 알림을 사용하려면, device token이 필요하다. 현재 device token은 파이어베이스에만 저장되어 있고, 서버에서는 저장하고 있지 않다. 단체 채팅의 경우 채팅방에 속해 있는 모든 유저의 device token 목록이 필요하므로, 서버에서 device token을 저장해두었다가 채팅방에 참여한 유저의 디바이스 토큰을 List로 반환해주기로 하자.
① User에 deviceToken 필드를 nullable로 추가한다.
@Column(nullable = true)
private String deviceToken;
② User의 createUser메서드도 아래와 같이 수정한다.
public User createUser(String nickName, String email, String password, String uid) {
this.nickName= nickName;
this.email = email;
this.password = password;
this.uid = uid;
this.deviceToken = null;
return this;
}
③ UserController에 유저의 디바이스 토큰을 set하는 API를 추가한다.
/**
* 디바이스 토큰 저장
*/
@PostMapping("/device-token")
public BaseResponse<String> saveDeviceToken(@RequestBody PostDeviceTokenReq postDeviceTokenReq) {
try {
return new BaseResponse<>(userService.saveDeviceToken(postDeviceTokenReq));
} catch (BaseException exception) {
return new BaseResponse<>(exception.getStatus());
}
}
④ UserService에 아래의 메서드를 추가한다.
/**
* 디바이스 토큰 저장
*/
public String saveDeviceToken(PostDeviceTokenReq postDeviceTokenReq) throws BaseException {
User user = utilService.findByUserUidWithValidation(postDeviceTokenReq.getUid());
user.setDeviceToken(postDeviceTokenReq.getDeviceToken());
userRepository.save(user);
return "디바이스 토큰이 저장되었습니다.";
}
⑤ User > dto에 PostDeviceTokenReq를 추가한다.
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class PostDeviceTokenReq {
private String uid;
private String deviceToken;
}
⑥ ChatRoomController에 채팅방에 참여한 유저의 디바이스 토큰 목록을 반환하는 API를 추가한다.
// 채팅에 참여한 유저 중 본인을 제외한 유저의 디바이스 토큰 목록 반환
@GetMapping("/tokenList/{roomId}")
public BaseResponse<List<String>> getTokenList(@PathVariable String roomId) {
try {
Long userId = jwtService.getUserIdx();
return new BaseResponse<>(chatRoomService.getTokenList(userId, roomId));
} catch (BaseException exception) {
return new BaseResponse<>(exception.getStatus());
}
}
⑦ ChatRoomService에 아래의 메서드를 추가한다.
// 채팅에 참여한 유저 중 본인을 제외한 유저의 디바이스 토큰 목록 반환
public List<String> getTokenList(Long userId, String chatRoomId) throws BaseException {
User user = utilService.findByUserIdWithValidation(userId);
utilService.findChatRoomByChatRoomIdWithValidation(chatRoomId);
List<UserChatRoom> userChatRooms = userChatRoomRepository.findUserChatRoomByRoomId(chatRoomId);
String userToken = user.getDeviceToken(); // Get user's token
List<String> tokenList = userChatRooms.stream()
.map(userChatRoom -> userChatRoom.getUser().getDeviceToken())
.filter(token -> token != null && !token.equals(userToken)) // Filter out null tokens and user's token
.collect(Collectors.toList());
return tokenList;
}
① push라는 이름의 새로운 패키지를 생성하고 이 패키지 하위로, FirebaseService라는 이름의 kotlin class를 생성한다.
② AndroidManifest.xml 파일에 아래의 내용을 추가한다.
<application
...
<service
android:name=".utils.FirebaseService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<activity
...
③ FirebaseService에 아래의 내용을 입력한다.
class FirebaseService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
val title = message.data["title"].toString()
val content = message.data["content"].toString()
createNotificationChannel()
sendNotification(title, content)
}
private fun createNotificationChannel() {
// Create the NotificationChannel, but only on API 26+ because
// the NotificationChannel class is new and not in the support library
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "name"
val descriptionText = "description"
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel("chatting message", name, importance).apply {
description = descriptionText
}
// Register the channel with the system
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
private fun sendNotification(title : String, body : String) {
if(NotificationManagerCompat.from(this).areNotificationsEnabled()) {
var builder = NotificationCompat.Builder(this, "test")
.setSmallIcon(R.drawable.icon)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
with(NotificationManagerCompat.from(this)) {
if (ActivityCompat.checkSelfPermission(
this@FirebaseService,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notify(1, builder.build())
}
}
else {
Log.w("notification", "알림 수신이 차단된 상태입니다.")
}
}
}
④ push 패키지 하위로, PushRepository라는 이름의 kotlin class를 생성한다.
class PushRepository {
companion object {
const val BASE_URL = "https://fcm.googleapis.com"
const val SERVER_KEY = "{Server-Key}"
const val CONTENT_TYPE = "application/json"
}
}
⑤ 이번에는 push 패키지 하위로, NoticeApi 인터페이스, NoticeModel, PushNotice라는 이름의 kotlin 클래스를 추가한다.
interface NoticeApi {
@Headers("Authorization: key=${PushRepository.SERVER_KEY}", "Content-Type:${PushRepository.CONTENT_TYPE}")
@POST("fcm/send")
suspend fun postNotification(@Body notification: PushNotice) : retrofit2.Response<ResponseBody>
}
data class NoticeModel (
val title : String = "",
val content : String = ""
)
data class PushNotice (
val data : NoticeModel,
val to : String
)
⑥ ChatApi에 아래의 API를 추가한다.
@GET("/chat/tokenList/{roomId}")
suspend fun getTokenList(
@Header("Authorization") accessToken : String,
@Path("roomId") roomId : String
) : BaseResponse<List<String>>
⑦ RetrofitInstance를 아래와 같이 수정한다.
class RetrofitInstance {
companion object {
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl(ApiRepository.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val userApi = retrofit.create(UserApi::class.java)
val myPageApi = retrofit.create(MyPageApi::class.java)
val chatApi = retrofit.create(ChatApi::class.java)
private val noticeRetrofit by lazy {
Retrofit.Builder()
.baseUrl(PushRepository.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val noticeApi = noticeRetrofit.create(NoticeApi::class.java)
}
}
⑧ ChatRoomActivity를 아래와 같이 수정한다.
class ChatRoomActivity : AppCompatActivity() {
lateinit var count : String // 채팅방 참여 인원수
lateinit var messageAdapter : MessageAdapter
lateinit var recyclerView : RecyclerView
val messageList = mutableListOf<MessageModel>()
var tokenList = listOf<String>() // 채팅 참여자의 토큰 목록
val readerUidMap = mutableMapOf<String, Boolean>()
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_chat_room)
readerUidMap[FirebaseAuthUtils.getUid()] = true
val roomName = findViewById<TextView>(R.id.chatRoomName)
val nickNameList = findViewById<TextView>(R.id.nickNameList)
val inviteBtn = findViewById<Button>(R.id.invite)
val userCount = findViewById<TextView>(R.id.userCount)
// Intent로부터 데이터를 가져옴
val chatRoomName = intent.getStringExtra("chatRoomName")
roomName.text = chatRoomName
val roomId = intent.getStringExtra("chatRoomId")
val chatRoomId = roomId
CoroutineScope(Dispatchers.IO).launch {
val response = getUserCount(chatRoomId!!)
Log.d("userCount", response.toString())
if (response.isSuccess) {
count = response.result.toString()
userCount.text = count
} else {
Log.d("UserListFragment", "유저의 정보를 불러오지 못함")
}
}
CoroutineScope(Dispatchers.IO).launch {
val response = getUserStrList(chatRoomId!!)
Log.d("UserNickNameList", response.toString())
if (response.isSuccess) {
nickNameList.text = response.result.toString()
} else {
Log.d("UserNickNameList", "유저의 정보를 불러오지 못함")
}
}
getAccessToken { accessToken ->
if (accessToken.isNotEmpty()) {
CoroutineScope(Dispatchers.IO).launch {
val response = getTokenList(chatRoomId!!)
if (response.isSuccess) {
tokenList = response.result!!
Log.d("TokenList", response.toString())
} else {
Log.d("TokenList", "유저의 정보를 불러오지 못함")
}
}
} else {
Log.e("TokenList", "Invalid Token")
}
}
recyclerView = findViewById(R.id.messageRV)
messageAdapter = MessageAdapter(this, messageList)
recyclerView.adapter = messageAdapter
recyclerView.layoutManager = LinearLayoutManager(this)
getMessageList(chatRoomId!!)
Log.d("MessageList", messageList.toString())
inviteBtn.setOnClickListener {
val intent = Intent(this, InviteActivity::class.java)
intent.putExtra("chatRoomId", chatRoomId)
intent.putExtra("chatRoomName", chatRoomName)
startActivity(intent)
}
val participantBtn = findViewById<ImageView>(R.id.participants)
participantBtn.setOnClickListener {
val intent = Intent(this, ParticipantsActivity::class.java)
intent.putExtra("chatRoomId", chatRoomId)
startActivity(intent)
}
val message = findViewById<TextInputEditText>(R.id.message)
val sendBtn = findViewById<ImageView>(R.id.send)
lateinit var myNickName : String
lateinit var myProfileUrl : String
lateinit var messageModel: MessageModel
val myUid = FirebaseAuthUtils.getUid()
sendBtn.setOnClickListener {
val contents = message.text.toString()
if(contents.isEmpty()) {
Toast.makeText(this, "메시지를 입력해주세요", Toast.LENGTH_SHORT).show()
}
else {
val sendTime = getSendTime()
CoroutineScope(Dispatchers.IO).launch {
val response = getUserInfo(myUid)
if (response.isSuccess) {
myNickName = response.result?.nickName.toString()
myProfileUrl = response.result?.imgUrl.toString()
val readerUids = mutableMapOf<String, Boolean>()
messageModel = MessageModel(myUid, myNickName, myProfileUrl, contents,
sendTime, readerUids, 0)
Log.d("readerUid", readerUids.size.toString())
FirebaseRef.message.child(chatRoomId!!).push().setValue(messageModel)
val noticeModel = NoticeModel(myNickName, contents)
for(token in tokenList) { // 채팅방의 모든 유저에게 채팅 푸시알림을 전송
val pushNotice = PushNotice(noticeModel, token)
Log.d("Push", pushNotice.toString())
Log.d("Push", tokenList.toString())
createNotificationChannel()
pushNotification(pushNotice)
}
} else {
Log.d("ChatRoomActivity", "유저의 정보를 불러오지 못함")
}
}
message.text?.clear()
}
}
}
override fun onBackPressed() {
readerUidMap.remove(FirebaseAuthUtils.getUid())
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
}
private suspend fun getUserCount(roomId: String) : BaseResponse<String> {
return RetrofitInstance.chatApi.getUserCount(roomId)
}
private suspend fun getUserInfo(uid: String): BaseResponse<GetUserRes> {
return RetrofitInstance.myPageApi.getUserInfo(uid)
}
//메시지 보낸 시각 정보 반환
@RequiresApi(Build.VERSION_CODES.O)
private fun getSendTime(): String {
try {
val localDateTime = LocalDateTime.now()
val dateTimeFormatter = DateTimeFormatter.ofPattern("M/d a h:mm")
return localDateTime.format(dateTimeFormatter)
} catch (e: Exception) {
e.printStackTrace()
throw Exception("시간 정보를 불러오지 못함")
}
}
private fun getMessageList(chatRoomId: String) {
val postListener = object : ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
messageList.clear()
val newMessages = mutableListOf<MessageModel>() // 새로운 메시지를 저장할 리스트 생성
for (dataModel in dataSnapshot.children) {
val messageModel = dataModel.getValue(MessageModel::class.java)
val messageId = dataModel.key
if (messageModel != null) {
if (messageId != null) {
// readerUidMap을 업데이트
FirebaseRef.message.child(chatRoomId!!).child(messageId).child("readerUids")
.updateChildren(readerUidMap as Map<String, Boolean>)
.addOnCompleteListener { readerUidTask ->
if (readerUidTask.isSuccessful) {
// readerUid 업데이트가 성공한 경우 unreadUserCount를 계산하여 업데이트합니다.
val unreadUserCount = count.toInt() - messageModel.readerUids.size
FirebaseRef.message.child(chatRoomId!!).child(messageId).child("unreadUserCount")
.setValue(unreadUserCount)
} else {
Log.d("ChatRoomActivity", "reader UID를 업데이트하지 못함")
}
}
}
if (messageModel.senderUid != FirebaseAuthUtils.getUid()) {
messageModel.viewType = MessageModel.VIEW_TYPE_YOU
if(!readerUidMap.containsKey(FirebaseAuthUtils.getUid())) {
}
}
newMessages.add(messageModel)
}
}
messageList.addAll(newMessages)
messageAdapter.notifyDataSetChanged()
Log.d("MessageList", messageList.toString())
recyclerView.post {
recyclerView.scrollToPosition(recyclerView.adapter?.itemCount?.minus(1) ?: 0)
}
}
override fun onCancelled(databaseError: DatabaseError) {
Log.w("MyMessage", "onCancelled", databaseError.toException())
}
}
FirebaseRef.message.child(chatRoomId).addValueEventListener(postListener)
}
private suspend fun getUserStrList(roomId: String): BaseResponse<String> {
return RetrofitInstance.chatApi.getUserStrList(roomId)
}
private suspend fun getTokenList(roomId: String): BaseResponse<List<String>> {
return RetrofitInstance.chatApi.getTokenList(roomId)
}
private fun createNotificationChannel() {
// Create the NotificationChannel, but only on API 26+ because
// the NotificationChannel class is new and not in the support library
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "name"
val descriptionText = "description"
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel("test", name, importance).apply {
description = descriptionText
}
// Register the channel with the system
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
private fun pushNotification(notification: PushNotice) = CoroutineScope(Dispatchers.IO).launch {
RetrofitInstance.noticeApi.postNotification(notification)
}
private fun getAccessToken(callback: (String) -> Unit) {
val postListener = object : ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
val data = dataSnapshot.getValue(com.chrome.chattingapp.authentication.UserInfo::class.java)
val accessToken = data?.accessToken ?: ""
callback(accessToken)
}
override fun onCancelled(databaseError: DatabaseError) {
Log.w("NickNameActivity", "onCancelled", databaseError.toException())
}
}
FirebaseRef.userInfo.child(FirebaseAuthUtils.getUid()).addListenerForSingleValueEvent(postListener)
}
}
⑨ UserApi에 아래의 API를 추가한다.
@POST("users/device-token")
suspend fun saveDeviceToken(@Body postDeviceTokenReq : PostDeviceTokenReq) : BaseResponse<String>
⑩ api > dto에 PostDeviceTokenReq data class를 추가한다.
data class PostDeviceTokenReq(
@SerializedName("uid")
val uid : String,
@SerializedName("deviceToken")
val deviceToken : String,
)
⑪ LoginActivity에서 device token을 저장하는 API를 호출한다.
else {
auth.signInWithEmailAndPassword(email.text.toString(), password.text.toString())
.addOnCompleteListener(this) { task ->
if (task.isSuccessful) {
CoroutineScope(Dispatchers.IO).launch {
val response = loginUser(postLoginReq)
Log.d("LoginActivity", response.toString())
if (response.isSuccess) {
FirebaseMessaging.getInstance().token.addOnCompleteListener(
OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("MyToken", "Fetching FCM registration token failed", task.e
return@OnCompleteListener
}
val deviceToken = task.result
val userInfo = UserInfo(uid, response.result?.userId,
deviceToken, response.result?.accessToken, response.result?.refre
Log.d("userInfo", userInfo.toString())
FirebaseRef.userInfo.child(uid).setValue(userInfo)
CoroutineScope(Dispatchers.IO).launch {
val postDeviceTokenReq = PostDeviceTokenReq(uid, deviceToken)
val response = saveDeviceToken(postDeviceTokenReq)
Log.d("DeviceToken", response.toString())
if (response.isSuccess) {
Log.d("DeviceToken", "디바이스 토큰 저장 완료")
} else {
Log.d("DeviceToken", "디바이스 토큰 저장 실패")
}
}
val intent = Intent(this@LoginActivity, MainActivity::class.java)
startActivity(intent)
})
Log.d("LoginActivity", "로그인 완료")
} else {
// 로그인 실패 처리
Log.d("LoginActivity", "로그인 실패")
val message = response.message
Log.d("JoinActivity", message)
withContext(Dispatchers.Main) {
Toast.makeText(this@LoginActivity, message, Toast.LENGTH_SHORT).show()
}
}
}
} else {
Log.d("LoginActivity", "로그인 실패")
Toast.makeText(this@LoginActivity, "이메일 또는 비밀번호를 확인해주세요", Toast.LENGTH_SHORT).show()
}
}
}
...
private suspend fun saveDeviceToken(postDeviceTokenReq: PostDeviceTokenReq): BaseResponse<String> {
return RetrofitInstance.userApi.saveDeviceToken(postDeviceTokenReq)
}
⑫ 마지막으로, AndroidManifest.xml에 아래의 권한을 추가한다.
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
코드를 실행시켜보면, 새로운 메시지가 도착할 때마다 푸시 알림이 도착하는 것을 확인할 수 있을 것이다.
다만, 채팅방에 입장한 상태이더라도 푸시 알림이 계속해서 간다는 문제가 있다. 이 문제에 대한 해결방법에는 다소 복잡한 로직이 필요하므로, 다음 포스팅에서 다루기로 하겠다.