Repository navigation
[Feat/#114] 알림 발송 공통 처리 추가 #118
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
Open
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
20fef27
feat: 알림 종류와 문구 템플릿 추가
tnals0924 cd7c412
feat: 알림함 저장소 추가
tnals0924 905d30e
feat: 푸시 대상 조회 추가
tnals0924 1324dab
feat: 무효 FCM 토큰 일괄 정리 추가
tnals0924 6f834cc
chore: slf4j-api를 모든 모듈 공통 의존성으로 적용
tnals0924 a2c12f5
feat: 알림 발송 처리 추가
tnals0924 46e9d7e
refactor: 조사 처리를 common 유틸로 분리
tnals0924 5fbcda2
feat: 알림함에 이동 대상 ID 추가
tnals0924 a5c9eb5
chore: 공통 로깅 의존성을 spring-boot-starter-logging으로 변경
tnals0924 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
79 changes: 79 additions & 0 deletions
79
core/common/src/main/java/kr/ac/kookmin/stream/common/JosaUtil.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package kr.ac.kookmin.stream.common; | ||
|
|
||
| import java.util.List; | ||
| import java.util.regex.Matcher; | ||
| import java.util.regex.Pattern; | ||
| import lombok.AccessLevel; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| /** | ||
| * 조사 유틸. 문구 템플릿의 {@code %s}에 값을 차례로 넣고, 템플릿에 적어 둔 "을(를)"·"이(가)"·"은(는)"·"과(와)"를 | ||
| * 앞 단어의 받침에 맞는 조사 하나로 바꾼다. | ||
| * <p> | ||
| * 앞 단어는 따옴표·괄호·문장부호·공백을 건너뛴 마지막 글자로 판단한다("'봄 축제!'을(를)" → "'봄 축제!'를"). | ||
| * 한글 음절과 숫자만 판단하고, 영문처럼 판단할 수 없으면 "을(를)" 표기를 그대로 둔다. | ||
| * 값(행사명 등) 안에 같은 표기가 있어도 바꾸지 않도록 템플릿 부분만 바꾼다. | ||
| */ | ||
| @NoArgsConstructor(access = AccessLevel.PRIVATE) | ||
| public final class JosaUtil { | ||
|
|
||
| private static final String PLACEHOLDER = "%s"; | ||
| private static final Pattern PARTICLE = Pattern.compile("(을|이|은|과)\\((를|가|는|와)\\)"); | ||
| // 0(영)·1(일)·3(삼)·6(육)·7(칠)·8(팔)은 받침이 있다 | ||
| private static final String DIGITS_WITH_FINAL_CONSONANT = "013678"; | ||
|
|
||
| private static final char HANGUL_SYLLABLE_FIRST = '가'; | ||
| private static final char HANGUL_SYLLABLE_LAST = '힣'; | ||
| private static final int FINAL_CONSONANT_COUNT = 28; | ||
|
|
||
| public static String format(String template, List<String> values) { | ||
| String[] segments = template.split(PLACEHOLDER, -1); | ||
| StringBuilder formatted = new StringBuilder(); | ||
| for (int i = 0; i < segments.length; i++) { | ||
| appendResolved(formatted, segments[i]); | ||
| if (i < segments.length - 1) { | ||
| formatted.append(values.get(i)); | ||
| } | ||
| } | ||
| return formatted.toString(); | ||
| } | ||
|
|
||
| // 조사 표기 앞의 단어는 이미 붙여 둔 앞부분과 이 조각에서 표기 앞까지를 이어서 본다 | ||
| private static void appendResolved(StringBuilder formatted, String segment) { | ||
| Matcher matcher = PARTICLE.matcher(segment); | ||
| int copiedUntil = 0; | ||
| while (matcher.find()) { | ||
| formatted.append(segment, copiedUntil, matcher.start()); | ||
| Boolean hasFinalConsonant = hasFinalConsonant(lastLetterOrDigit(formatted)); | ||
| formatted.append(hasFinalConsonant == null | ||
| ? matcher.group() | ||
| : hasFinalConsonant ? matcher.group(1) : matcher.group(2)); | ||
| copiedUntil = matcher.end(); | ||
| } | ||
| formatted.append(segment, copiedUntil, segment.length()); | ||
| } | ||
|
|
||
| private static Character lastLetterOrDigit(CharSequence text) { | ||
| for (int i = text.length() - 1; i >= 0; i--) { | ||
| char c = text.charAt(i); | ||
| if (Character.isLetterOrDigit(c)) { | ||
| return c; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // 판단할 수 없으면 null | ||
| private static Boolean hasFinalConsonant(Character c) { | ||
| if (c == null) { | ||
| return null; | ||
| } | ||
| if (c >= HANGUL_SYLLABLE_FIRST && c <= HANGUL_SYLLABLE_LAST) { | ||
| return (c - HANGUL_SYLLABLE_FIRST) % FINAL_CONSONANT_COUNT != 0; | ||
| } | ||
| if (Character.isDigit(c)) { | ||
| return DIGITS_WITH_FINAL_CONSONANT.indexOf(c) >= 0; | ||
| } | ||
| return null; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
26 changes: 23 additions & 3 deletions
26
...er/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,28 +1,48 @@ | ||
| package kr.ac.kookmin.stream.member.domain.notification.domain; | ||
|
|
||
| import java.util.List; | ||
| import lombok.AccessLevel; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.EqualsAndHashCode; | ||
| import lombok.Getter; | ||
|
|
||
| /** | ||
| * 알림함의 알림 한 건. 문구는 저장하지 않고 종류와 문구 값으로 다시 만든다({@link NotificationType}). | ||
| */ | ||
| @Getter | ||
| @EqualsAndHashCode | ||
| @AllArgsConstructor(access = AccessLevel.PRIVATE) | ||
| public class Notification { | ||
|
|
||
| // 문구 값을 한 컬럼에 순서대로 이어 붙일 때 쓰는 구분자. 행사명에 쉼표가 들어갈 수 있어 쉼표 대신 단위 구분 문자(US)를 쓴다 | ||
| private static final String FORMAT_VALUE_DELIMITER = "\u001F"; | ||
|
|
||
| private Long id; | ||
| private Long memberId; | ||
| private String type; | ||
| private NotificationType type; | ||
| // 문구 값을 순서대로 이어 붙인 문자열. 값이 없으면 null | ||
| private String formatValues; | ||
| // 알림을 눌렀을 때 이동할 대상의 ID. 무엇의 ID인지는 type이 정한다 | ||
| private Long targetId; | ||
| private boolean read; | ||
|
|
||
| public static Notification create(Long memberId, NotificationType type, List<String> formatValues, Long targetId) { | ||
| String joined = formatValues.isEmpty() ? null : String.join(FORMAT_VALUE_DELIMITER, formatValues); | ||
| return new Notification(null, memberId, type, joined, targetId, false); | ||
| } | ||
|
|
||
| public static Notification of( | ||
| Long id, | ||
| Long memberId, | ||
| String type, | ||
| NotificationType type, | ||
| String formatValues, | ||
| Long targetId, | ||
| boolean read | ||
| ) { | ||
| return new Notification(id, memberId, type, formatValues, read); | ||
| return new Notification(id, memberId, type, formatValues, targetId, read); | ||
| } | ||
|
|
||
| public List<String> getFormatValueList() { | ||
| return formatValues == null ? List.of() : List.of(formatValues.split(FORMAT_VALUE_DELIMITER, -1)); | ||
| } | ||
| } | ||
11 changes: 11 additions & 0 deletions
11
...ain/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationCategory.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package kr.ac.kookmin.stream.member.domain.notification.domain; | ||
|
|
||
| /** | ||
| * 알림 카테고리. 알림 설정의 수신 여부를 고르는 기준이고, 푸시 data의 {@code category} 값으로 앱에 그대로 내려간다. | ||
| */ | ||
| public enum NotificationCategory { | ||
| BILLILGE, // 빌릴게 (rental_enabled) | ||
| EVENT, // 행사 (event_enabled) | ||
| LOCKER, // 사물함 (locker_enabled) | ||
| NOTICE // 게시판: 새 공지, 열린피드백 답변 (notice_enabled) | ||
| } |
49 changes: 49 additions & 0 deletions
49
...rc/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package kr.ac.kookmin.stream.member.domain.notification.domain; | ||
|
|
||
| import java.util.List; | ||
| import kr.ac.kookmin.stream.common.JosaUtil; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| /** | ||
| * 알림 종류와 문구. 제목은 문구표의 굵은 첫 문장, 본문은 둘째 문장이다. 한 문장짜리는 본문이 없다. | ||
| * <p> | ||
| * 문구의 {@code %s}에는 알림을 만들 때 넘긴 값 목록이 앞에서부터 들어간다. 제목과 본문은 같은 값 목록을 각각 처음부터 쓴다. | ||
| * 조사는 "을(를)"처럼 적어 두면 앞 단어의 받침에 맞춰 바뀐다({@link JosaUtil}). | ||
| * 상수 이름이 {@code notifications.type}에 저장되므로 이름을 바꾸면 쌓인 알림함 행을 읽지 못한다. | ||
| */ | ||
| @Getter | ||
| @AllArgsConstructor | ||
| public enum NotificationType { | ||
|
|
||
| // 값: 행사명 | ||
| EVENT_APPLIED(NotificationCategory.EVENT, "'%s'을(를) 신청했어요.", "신청 내역에서 자세한 내용을 확인할 수 있어요."), | ||
| EVENT_APPLICATION_CANCELED(NotificationCategory.EVENT, "'%s' 참여 신청을 취소했어요.", null), | ||
| EVENT_STARTING_TOMORROW(NotificationCategory.EVENT, "'%s'이(가) 내일 열려요.", "행사 일정과 장소를 확인해 주세요."), | ||
| EVENT_APPLY_CLOSING(NotificationCategory.EVENT, "'%s' 신청이 곧 마감돼요.", "참여하려면 기간 내에 신청해 주세요."), | ||
|
|
||
| // 값: 없음 | ||
| LOCKER_ASSIGNED(NotificationCategory.LOCKER, "사물함을 신청했어요.", "배정된 사물함 번호를 확인해 주세요."), | ||
| LOCKER_APPLY_CLOSING(NotificationCategory.LOCKER, "사물함 신청이 내일 마감돼요.", "이용하려면 기간 내에 신청해 주세요."), | ||
| LOCKER_USAGE_ENDING(NotificationCategory.LOCKER, "사물함 이용 기간이 얼마 남지 않았어요.", "만료 전에 개인 물품을 미리 정리해 주세요."), | ||
|
|
||
| // 값: 물품명 | ||
| RENTAL_DUE_SOON(NotificationCategory.BILLILGE, "반납까지 1시간 남았어요.", "늦지 않게 %s을(를) 반납해 주세요."), | ||
| RENTAL_OVERDUE(NotificationCategory.BILLILGE, "반납 기한이 지났어요.", "학생회실에 방문해 %s을(를) 반납해 주세요."), | ||
|
|
||
| // 값: 없음 | ||
| NOTICE_CREATED(NotificationCategory.NOTICE, "새로운 공지사항이 올라왔어요.", "자세한 내용을 확인해 주세요."); | ||
|
|
||
| private final NotificationCategory category; | ||
| private final String title; | ||
| private final String body; | ||
|
|
||
| public String formatTitle(List<String> formatValues) { | ||
| return JosaUtil.format(title, formatValues); | ||
| } | ||
|
|
||
| // 본문이 없는 알림은 null | ||
| public String formatBody(List<String> formatValues) { | ||
| return body == null ? null : JosaUtil.format(body, formatValues); | ||
| } | ||
| } |
11 changes: 11 additions & 0 deletions
11
...mber/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushTarget.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package kr.ac.kookmin.stream.member.domain.notification.domain; | ||
|
|
||
| /** | ||
| * 푸시를 받을 수 있는 회원(토큰이 있는 활성 회원)과 그 회원의 알림 설정. | ||
| */ | ||
| public record PushTarget(Long memberId, String fcmToken, MemberNotificationSetting setting) { | ||
|
|
||
| public boolean accepts(NotificationCategory category) { | ||
| return setting.isEnabled(category); | ||
| } | ||
| } |
16 changes: 16 additions & 0 deletions
16
...kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package kr.ac.kookmin.stream.member.domain.notification.repository; | ||
|
|
||
| import java.util.Collection; | ||
| import java.util.List; | ||
| import kr.ac.kookmin.stream.member.domain.notification.domain.PushTarget; | ||
|
|
||
| /** | ||
| * 알림 수신자 조회. 알림함 대상은 활성 회원이고, 푸시 대상은 그중 토큰과 알림 설정이 있는 회원이다. | ||
| * FCM이 무효라고 응답한 토큰 정리도 여기서 한다. | ||
| */ | ||
| public interface NotificationRecipientRepository { | ||
| List<Long> findAllActiveMemberIds(); | ||
| List<PushTarget> findPushTargets(Collection<Long> memberIds); | ||
| List<PushTarget> findAllPushTargets(); | ||
| int clearFcmTokens(Collection<String> fcmTokens); | ||
| } |
8 changes: 8 additions & 0 deletions
8
...va/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRepository.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package kr.ac.kookmin.stream.member.domain.notification.repository; | ||
|
|
||
| import java.util.List; | ||
| import kr.ac.kookmin.stream.member.domain.notification.domain.Notification; | ||
|
|
||
| public interface NotificationRepository { | ||
| void saveAll(List<Notification> notifications); | ||
| } |
107 changes: 107 additions & 0 deletions
107
.../kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| package kr.ac.kookmin.stream.member.domain.notification.service.impl; | ||
|
|
||
| import java.util.Collection; | ||
| import java.util.LinkedHashSet; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import kr.ac.kookmin.stream.member.domain.notification.client.PushNotificationClient; | ||
| import kr.ac.kookmin.stream.member.domain.notification.domain.Notification; | ||
| import kr.ac.kookmin.stream.member.domain.notification.domain.NotificationType; | ||
| import kr.ac.kookmin.stream.member.domain.notification.domain.PushMessage; | ||
| import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendResult; | ||
| import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendStatus; | ||
| import kr.ac.kookmin.stream.member.domain.notification.domain.PushTarget; | ||
| import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRecipientRepository; | ||
| import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| /** | ||
| * 알림 한 건을 수신자에게 보낸다. 알림함에는 수신자 전원에게 저장하고, 푸시는 그 카테고리를 켜 두고 토큰이 있는 회원에게만 보낸다. | ||
| * <p> | ||
| * 아웃박스 릴레이가 부르는 리스너에서 트랜잭션 없이 호출된다. 알림함 저장은 {@code saveAll}의 트랜잭션으로 한꺼번에 반영하고, | ||
| * 저장이 끝난 뒤 트랜잭션 밖에서 푸시를 보낸다. 알림함 저장이 실패하면 예외가 리스너까지 올라가 아웃박스가 다시 발행한다. | ||
| */ | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| class NotificationDispatcher { | ||
|
|
||
| private static final Logger log = LoggerFactory.getLogger(NotificationDispatcher.class); | ||
|
|
||
| private final NotificationRepository notificationRepository; | ||
| private final NotificationRecipientRepository notificationRecipientRepository; | ||
| private final PushNotificationClient pushNotificationClient; | ||
|
|
||
| /** 지정한 회원에게 보낸다. */ | ||
| void dispatch(NotificationType type, List<String> formatValues, long targetId, Collection<Long> memberIds) { | ||
| List<Long> recipients = List.copyOf(new LinkedHashSet<>(memberIds)); | ||
| List<PushTarget> pushTargets = notificationRecipientRepository.findPushTargets(recipients); | ||
| send(type, formatValues, targetId, recipients, pushTargets); | ||
| } | ||
|
|
||
| /** 활성 회원 전체에서 제외할 회원(예: 이미 신청한 회원)을 뺀 나머지에게 보낸다. */ | ||
| void dispatchToAll(NotificationType type, List<String> formatValues, long targetId, Collection<Long> excludedMemberIds) { | ||
| Set<Long> excluded = Set.copyOf(excludedMemberIds); | ||
| List<Long> recipients = notificationRecipientRepository.findAllActiveMemberIds().stream() | ||
| .filter(memberId -> !excluded.contains(memberId)) | ||
| .toList(); | ||
| List<PushTarget> pushTargets = notificationRecipientRepository.findAllPushTargets().stream() | ||
| .filter(target -> !excluded.contains(target.memberId())) | ||
| .toList(); | ||
| send(type, formatValues, targetId, recipients, pushTargets); | ||
| } | ||
|
|
||
| private void send( | ||
| NotificationType type, | ||
| List<String> formatValues, | ||
| long targetId, | ||
| List<Long> recipients, | ||
| List<PushTarget> pushTargets | ||
| ) { | ||
| if (recipients.isEmpty()) { | ||
| return; | ||
| } | ||
| notificationRepository.saveAll(recipients.stream() | ||
| .map(memberId -> Notification.create(memberId, type, formatValues, targetId)) | ||
| .toList()); | ||
|
|
||
| // 한 기기의 토큰이 여러 회원 행에 남아 있을 수 있어 토큰 단위로 중복을 지운다. 그대로 보내면 같은 기기에 여러 번 간다 | ||
| List<String> tokens = pushTargets.stream() | ||
| .filter(target -> target.accepts(type.getCategory())) | ||
| .map(PushTarget::fcmToken) | ||
| .distinct() | ||
| .toList(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 수신자들의 알림함에는 모두 저장하고 푸시는 알림을 켠 사람한테만 보내는 규칙을 |
||
| int invalidTokenCount = 0; | ||
| if (!tokens.isEmpty()) { | ||
| PushSendResult result = pushNotificationClient.send(tokens, toPushMessage(type, formatValues, targetId)); | ||
| invalidTokenCount = clearInvalidTokens(result.tokensWith(PushSendStatus.INVALID_TOKEN)); | ||
| } | ||
| log.info("알림 발송: type={}, targetId={}, recipientCount={}, pushTokenCount={}, invalidTokenCount={}", | ||
| type, targetId, recipients.size(), tokens.size(), invalidTokenCount); | ||
| } | ||
|
|
||
| private PushMessage toPushMessage(NotificationType type, List<String> formatValues, long targetId) { | ||
| Map<String, String> data = Map.of( | ||
| "type", type.name(), | ||
| "category", type.getCategory().name(), | ||
| "targetId", String.valueOf(targetId) | ||
| ); | ||
| return new PushMessage(type.formatTitle(formatValues), type.formatBody(formatValues), data); | ||
| } | ||
|
|
||
| // 푸시는 이미 나갔으므로 정리가 실패해도 예외를 던지지 않는다. 던지면 아웃박스가 다시 발행해 같은 알림이 한 번 더 간다 | ||
| private int clearInvalidTokens(List<String> invalidTokens) { | ||
| if (invalidTokens.isEmpty()) { | ||
| return 0; | ||
| } | ||
| try { | ||
| notificationRecipientRepository.clearFcmTokens(invalidTokens); | ||
| } catch (RuntimeException e) { | ||
| log.warn("무효 FCM 토큰 정리 실패: tokenCount={}", invalidTokens.size(), e); | ||
| } | ||
| return invalidTokens.size(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
값만 이어 붙여 저장해서 문구를 고치면 이전 알림들도 바뀌는 것으로 이해했는데, 오히려 오타나 잘못된 정보를 수정할 수 있다는 점에서 좋은것 같습니다 !!