Skip to content

fix: 약속 나간 이후 스케줄링 알림 전송되는 버그 해결 #989

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

Merged
merged 5 commits into from
Apr 4, 2025

Conversation

eun-byeol
Copy link
Contributor

@eun-byeol eun-byeol commented Mar 24, 2025

🚩 연관 이슈

close #988


📝 작업 내용

QA에서 발견한 버그 수정했습니다. 시간이 없어서 테스트 코드로 남기진 못했지만, 로컬에서 여러 케이스 실제로 돌려보며 해결된 것 확인했습니다.

1. 약속 시간 이전, 약속방 나가기 -> 스케줄링 재시도 알림 가는 버그 수정

  • 원인: 최초1회 약속방 모든 참여자를 조회하여 레디스에 addAll() 해주는 로직 중, 삭제된 참여자도 조회해서 발생한 버그
  • 해결: findFetchedAllByMeetingId() 쿼리에서 삭제된 mate를 필터링 하는 로직 추가

2. 약속 시간 이후, 약속방 나가기 -> 스케줄링 재시도 알림 가는 버그 수정

  • 원인: TTL 만료 이벤트가 발생하면, mate의 상태를 체크하지 않고 스케줄링 알림을 보내기 때문(sendFallbackNotice())
  • 해결: mate가 약속방을 나가는 이벤트에서, redis의 키를 삭제함(이 때는, TTL 만료 이벤트가 트리거되지 않음)

고려한 점:
방법1) sendFallbackNotice() 메서드에서, mate 상태를 체크하여 필터링한다.

  • 장점: 책임이 EtaSchdulingService에 모인다. 전송/레디스 update의 주체가 EtaSchdulingService이므로 발송 검증 책임도 EtaSchdulingService이다.
  • 단점: 매번 fallbackNotice가 동작 할 때, DB를 조회해야 한다. mate 조회를 위한 mateId가 필요할 수도 있다.

방법2) 약속방을 나가는 이벤트 시점(방 나가기/탈퇴)에, 레디스에 key를 지워 TTL 만료 트리거가 동작하지 않도록 한다.

  • 장점: 방법1 단점 개선
  • 단점: 예외처리 책임이 mate에게 분산된다.

레디스를 사용한만큼 DB 조회를 피하기 위해, 2번 방법으로 수정을 했는데요,
fallbackNotice가 많지 않다는 가정 하에는 1번도 괜찮다고 생각합니다. 의견주세요!

3. 00:00 ~ 05:00 구간 약속은 서버 재부팅시 스케줄링이 걸리지 않는 버그 수정

  • 원인: 스케줄링할 약속 조회 로직에서, [오늘 새벽 05:00, 내일 새벽 05:00) 구간만 조회했기 때문. 오늘 새벽 02:00에 약속이 있는데, 오늘 새벽 01:50에 서버 재부팅 된 경우, 스케줄링이 걸리지 않았음
  • 해결: 조회 시점 [현재 시:분:00초, 내일 새벽 00:00)으로 수정

🏞️ 스크린샷 (선택)


🗣️ 리뷰 요구사항 (선택)

탈퇴 후에도, 스케줄링 재시도 알림이 안 가는지 확인해보려했으나
로컬 테스트에서는 회원 탈퇴 api 호출시 에러가 발생하여 하지 못했어요.
그러나, 탈퇴 api 에서도 결국 delete() 메서드를 호출하기 때문에, 방 나가기와 동일하게 동작할겁니다!

2025-03-25 01:46:23.051 [INFO] [http-nio-8080-exec-4] [682951f1-241a-4a81-8b77-c6428c7fe5aa] [c.o.r.c.RouteClientLoggingInterceptor] - [RouteClient Request] Method: POST, URI: https://kapi.kakao.com/v1/user/unlink
2025-03-25 01:46:23.119 [ERROR] [http-nio-8080-exec-4] [682951f1-241a-4a81-8b77-c6428c7fe5aa] [c.o.c.e.GlobalExceptionHandler] - exception: {}
org.springframework.web.client.ResourceAccessException: I/O error on POST request for "https://kapi.kakao.com/v1/user/unlink": Server returned HTTP response code: 400 for URL: https://kapi.kakao.com/v1/user/unlink
	at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.createResourceAccessException(DefaultRestClient.java:575)
	at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeInternal(DefaultRestClient.java:498)
	at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.retrieve(DefaultRestClient.java:460)
	at com.ody.auth.service.kakao.KakaoAuthUnlinkClient.unlink(KakaoAuthUnlinkClient.java:45)
	at com.ody.member.service.MemberService.deleteV2(MemberService.java:66)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:568)
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
	at org.springframework.aop.framework.adapter.MethodBeforeAdviceInterceptor.invoke(MethodBeforeAdviceInterceptor.java:58)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:173)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
	at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392)
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
	at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768)
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720)
	at com.ody.member.service.MemberService$$SpringCGLIB$$0.deleteV2(<generated>)
	at com.ody.member.controller.MemberController.deleteV2(MemberController.java:31)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:568)
	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255)
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188)
	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831)
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
	at org.springframework.web.servlet.FrameworkServlet.doDelete(FrameworkServlet.java:936)
	at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:596)
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
	at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:195)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at com.ody.common.filter.LoggingFilter.doFilter(LoggingFilter.java:31)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:107)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)
	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)
	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482)
	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)
	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)
	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344)
	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:389)
	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:904)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1741)
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1190)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
	at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.io.IOException: Server returned HTTP response code: 400 for URL: https://kapi.kakao.com/v1/user/unlink
	at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1997)
	at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1589)
	at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:224)
	at org.springframework.http.client.SimpleClientHttpResponse.getBody(SimpleClientHttpResponse.java:89)
	at org.springframework.http.client.BufferingClientHttpResponseWrapper.getBody(BufferingClientHttpResponseWrapper.java:66)
	at com.ody.route.config.RouteClientLoggingInterceptor.intercept(RouteClientLoggingInterceptor.java:29)
	at org.springframework.http.client.InterceptingClientHttpRequest$InterceptingRequestExecution.execute(InterceptingClientHttpRequest.java:88)
	at org.springframework.http.client.InterceptingClientHttpRequest.executeInternal(InterceptingClientHttpRequest.java:72)
	at org.springframework.http.client.AbstractBufferingClientHttpRequest.executeInternal(AbstractBufferingClientHttpRequest.java:48)
	at org.springframework.http.client.AbstractClientHttpRequest.execute(AbstractClientHttpRequest.java:66)
	at org.springframework.web.client.DefaultRestClient$DefaultRequestBodyUriSpec.exchangeInternal(DefaultRestClient.java:492)
	... 82 common frames omitted

Copy link

github-actions bot commented Mar 24, 2025

Test Results

 69 files  ±0   69 suites  ±0   15s ⏱️ ±0s
230 tests ±0  230 ✅ ±0  0 💤 ±0  0 ❌ ±0 
232 runs  ±0  232 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit bec36eb. ± Comparison against base commit 65f0e8f.

This pull request removes 14 and adds 14 tests. Note that renamed tests count towards both.
com.ody.auth.JwtTokenProviderTest$validateAccessToken ‑ [1] accessToken=com.ody.auth.token.AccessToken@23b403b2
com.ody.auth.JwtTokenProviderTest$validateAccessToken ‑ [2] accessToken=com.ody.auth.token.AccessToken@e4d194d
com.ody.auth.controller.AuthControllerTest$authKakao ‑ [1] authRequest=com.ody.auth.dto.request.KakaoAuthRequest@62a36cd9
com.ody.auth.controller.AuthControllerTest$authKakao ‑ [2] authRequest=com.ody.auth.dto.request.KakaoAuthRequest@69e995d
com.ody.auth.controller.AuthControllerTest$authKakao ‑ [3] authRequest=com.ody.auth.dto.request.KakaoAuthRequest@aa49351
com.ody.common.validator.FutureOrPresentDateTimeValidatorTest ‑ [1] date=2025-03-24, time=15:13:23.702532795, expected=false
com.ody.common.validator.FutureOrPresentDateTimeValidatorTest ‑ [2] date=2025-03-24, time=16:13:23.702550799, expected=true
com.ody.common.validator.FutureOrPresentDateTimeValidatorTest ‑ [3] date=2025-03-24, time=14:13:23.702541301, expected=false
com.ody.common.validator.FutureOrPresentDateTimeValidatorTest ‑ [4] date=2025-03-25, time=15:13:23.702532795, expected=true
com.ody.util.TimeUtilTest ‑ [1] targetDateTime=2025-03-24T14:13, expected=false
…
com.ody.auth.JwtTokenProviderTest$validateAccessToken ‑ [1] accessToken=com.ody.auth.token.AccessToken@32cc7ce0
com.ody.auth.JwtTokenProviderTest$validateAccessToken ‑ [2] accessToken=com.ody.auth.token.AccessToken@4de4462d
com.ody.auth.controller.AuthControllerTest$authKakao ‑ [1] authRequest=com.ody.auth.dto.request.KakaoAuthRequest@7d711899
com.ody.auth.controller.AuthControllerTest$authKakao ‑ [2] authRequest=com.ody.auth.dto.request.KakaoAuthRequest@249e8953
com.ody.auth.controller.AuthControllerTest$authKakao ‑ [3] authRequest=com.ody.auth.dto.request.KakaoAuthRequest@63403ab1
com.ody.common.validator.FutureOrPresentDateTimeValidatorTest ‑ [1] date=2025-04-04, time=01:13:44.360698834, expected=false
com.ody.common.validator.FutureOrPresentDateTimeValidatorTest ‑ [2] date=2025-04-04, time=02:13:44.360720464, expected=true
com.ody.common.validator.FutureOrPresentDateTimeValidatorTest ‑ [3] date=2025-04-04, time=00:13:44.360707810, expected=false
com.ody.common.validator.FutureOrPresentDateTimeValidatorTest ‑ [4] date=2025-04-05, time=01:13:44.360698834, expected=true
com.ody.util.TimeUtilTest ‑ [1] targetDateTime=2025-04-04T00:14, expected=false
…

♻️ This comment has been updated with latest results.

Copy link

github-actions bot commented Mar 24, 2025

📝 Test Coverage Report

Overall Project 79.75%
Files changed 100% 🍏

File Coverage
EtaSchedulingRedisTemplate.java 100% 🍏
EtaSchedulingService.java 99.24% 🍏
MateService.java 95.87% 🍏
MeetingService.java 95.74% 🍏

@@ -44,8 +44,7 @@ public void add(EtaSchedulingKey etaSchedulingKey) {
);
}

public String get(EtaSchedulingKey etaSchedulingKey) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

사용하지 않는 메서드인데, 전에 지우는걸 잊었나봐요ㅎㅎ

@@ -172,11 +174,11 @@ public void leaveByMeetingIdAndMemberId(Long meetingId, Long memberId) {
delete(mate);
}

@Transactional
public void delete(Mate mate) {
private void delete(Mate mate) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

외부에서 호출하는 케이스가 없어 private로 닫아두었습니다.
인텔리제이 노란줄이 거슬려서요하하

@@ -166,20 +166,20 @@ public void scheduleOverdueMeetings() {

@Transactional
@EventListener(ApplicationReadyEvent.class)
@Scheduled(cron = "0 30 4 * * *", zone = "Asia/Seoul")
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

기존 새벽 4:30에 돌던 스케줄링을 임의로 자정으로 바꿨는데, 문제없겠지요?
기존 로직을 살리려면, 자정~새벽5시 구간을 별도로 처리해줘야 해서
계산하기 단순하게 바꾸었습니다.
운영상으로도 자정엔 약속잡는 사람이 거의 없지 않을까요?

Copy link
Member

@mzeong mzeong left a comment

Choose a reason for hiding this comment

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

조조🐥~ QA를 꼼꼼하게 진행해주셨군요!!
description을 잘 작성해주셔서 이해가 잘 되었습니다

2번 같은 경우는 fallback이 많지 않을 것 같아 1번 방식이 좋아 보입니다!
+저는 deviceToken, meetingId를 가지고 mate가 삭제된 경우인지 체크하면 된다고 생각했는데
조조가 언급한 mate 조회를 위한 mateId가 필요할 수도 있다.는 어떤 내용일까요?

2번 방식도 성능 면에서 장점이 있기 때문에 버그 픽스는 잘 되었다고 판단하고 approve 남깁니다
고생하셨어요!!

@eun-byeol
Copy link
Contributor Author

@mzeong 답변 남깁니다!

조조가 언급한 mate 조회를 위한 mateId가 필요할 수도 있다.는 어떤 내용일까요?

제리가 말한대로 deviceToken, meetingId를 가지고 mate가 삭제된 경우인지 체크해도 됩니다! 다만, 성능상 이점을 아주 조금 아주아주 조금 더 보는 것을 가정한 말이었어요ㅎㅎ 좀 짜치긴 해도 where 조건에 deviceToken이라는 긴 문자열보단 id값이 더 낫기도 하고, meeting_id, member_id, deleted_at 가 복합 유니크키로 걸려있으니까요~

@@ -51,6 +51,7 @@ public interface MateRepository extends JpaRepository<Mate, Long> {
join fetch mate.member
join fetch mate.meeting
where mate.meeting.id = :meetingId
and mate.deletedAt is null
Copy link
Contributor

Choose a reason for hiding this comment

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

image

개인적으로 코드결과를 예상하지 못했던 부분은 findFetchedAllByXXX 의 컨벤션을 가진 쿼리가 두 개임에도 어떤 쿼리에서는 삭제된 mate를 가져오고, 어떤 쿼리에서는 삭제된 mate를 가져오지 않는다는 점이에요.

현재는 findFetchedAllByMateId는 삭제된 mate를 함께 가져옵니다.
그러나, findFetchedAllByMeetingId는 삭제된 mate를 안가져옵니다.

이를 위해 제리가 aop를 통해 기본적으로 deletefitler를 enable하되 필터를 열고 닫을 수 있는 기능을 만들어 놓은 것으로 알고 있습니다.

괜찮다면 삭제 관련 필터링 on&off는 해당 aop에서 응집성있게 다루면 어떨까요?
포인트 컷을 추가함으로써 충분히 다룰 수 있다고 생각되는데 조조의 의견이 궁금합니다!

Copy link
Contributor Author

@eun-byeol eun-byeol Apr 3, 2025

Choose a reason for hiding this comment

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

동의합니다. 빠르게 구현하고자 우선 쿼리에 바로 붙였었던 거였어요😅
AOP에서 다루도록 수정했습니다. 03d319a

EtaSchedulingRedisTemplate@Service으로 바꾸고, @Transactional(readOnly = true)를 붙여줬어요.
기존엔 mateFilter가 기본적으로 on 되어있는대도 findFetchedAllByMeetingId 에 null 체크되지 않았었거든요.(3번을 놓치고 있었어요..ㅎ)

참고) deleteFilter가 on 되기 위한 조건

  1. @Service 있을 것
  2. @DisabledDeletedFilter 없을 것
  3. 영속성 컨텍스트가 열려있을 것 (entityManager에 접근)

RedisTemplate에 트랜잭션이 붙는게 좀 찝찝했는데, 안히 외않되? 괜찮기도 하네요
상위 서비스에 붙이고 싶었으나, private 메서드이거나 다른 스레드 동작(스케줄러에 의해 스레드가 다를 수 있음)이라 할 수 없었어요.

Copy link
Contributor

Choose a reason for hiding this comment

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

직장과 병행하느라 바쁠텐데도 빠른 피드백 감사합니다. approve 하겠습니다 👍

Copy link
Contributor

@coli-geonwoo coli-geonwoo left a comment

Choose a reason for hiding this comment

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

조조! 늦은 리뷰 죄송합니다...
또 꼼꼼히 QA 해준 덕분에 크게 리뷰 남길 부분이 많지는 않았습니다.

한가지 궁금한 점에 대한 질문 남겼으니 의견 부탁드려요!

@eun-byeol eun-byeol requested a review from coli-geonwoo April 3, 2025 16:16
@eun-byeol eun-byeol merged commit 946baa1 into develop Apr 4, 2025
3 checks passed
@eun-byeol eun-byeol deleted the feature/988 branch April 4, 2025 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

fix: 약속 나간 이후 스케줄링 알림 전송되는 버그 해결
3 participants