[refactor] 피드 저장 시 일급컬렉션이 아닌 boolean으로 저장 여부 조회#250
Conversation
Walkthrough피드 저장 로직을 SavedFeeds 일급컬렉션 기반에서 boolean 존재 여부 검사 기반으로 전환했고, 이에 따라 도메인 객체 및 관련 에러 코드가 제거되었으며 포트/어댑터/레포지토리에 존재 여부 조회 API가 추가되고 서비스 로직이 해당 흐름으로 변경되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Controller
participant FeedSavedService
participant FeedQueryPort
participant FeedCommandPort
User->>Controller: changeSavedFeed(command)
Controller->>FeedSavedService: changeSavedFeed(command)
FeedSavedService->>FeedQueryPort: existsSavedFeedByUserIdAndFeedId(userId, feedId)
FeedQueryPort-->>FeedSavedService: alreadySaved (boolean)
FeedSavedService->>FeedSavedService: validateSaveFeedAction(isSaveRequest, alreadySaved)
alt isSaveRequest == true
FeedSavedService->>FeedCommandPort: saveSavedFeed(userId, feedId)
else
FeedSavedService->>FeedCommandPort: deleteSavedFeed(userId, feedId)
end
FeedSavedService-->>Controller: result(command.isSaved)
Controller-->>User: response
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Assessment against linked issues
Out-of-scope changes
Possibly related PRs
Suggested reviewers
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
Test Results407 tests 407 ✅ 31s ⏱️ Results for commit 0ac4b6d. ♻️ This comment has been updated with latest results. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java (1)
41-41: existsSavedFeedByUserIdAndFeedId 추가: 방향성 좋습니다만, 유사 API와 파라미터 순서 일관성 고려 권장신규 exists API는 본 PR 목적과 잘 맞습니다. 다만 동일 섹션의
findSavedFeedIdsByUserIdAndFeedIds(Set<Long> feedIds, Long userId)와 파라미터 순서가 서로 달라(여기선 userId, feedId 순 / 다른 메서드는 feedIds, userId 순) 혼선을 유발할 수 있습니다. 장기적으로는 두 메서드 모두userId를 앞에 두는 방향으로 통일하는 것을 권장합니다. (추후 마이그레이션을 위해 새 시그니처를 추가하고, 기존은 Deprecated 처리하는 전략 권장)src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java (1)
103-105: 인자 순서 스왑으로 인한 가독성 저하 가능성포트 시그니처가
(Set<Long> feedIds, Long userId)이고, 레포지토리 시그니처가(Long userId, Set<Long> feedIds)라 어댑터에서 순서를 뒤집어 호출하고 있습니다. 현재 구현은 올바르게 매핑하고 있으나, 추후 유지보수 시 실수 가능성이 있어 포트/레포지토리/서비스 전반의 파라미터 순서를userId우선으로 통일하는 것을 권장합니다. (이번 PR 범위를 넘는다면 후속 리팩터로도 충분합니다)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (6)
src/main/java/konkuk/thip/common/exception/code/ErrorCode.java(0 hunks)src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java(1 hunks)src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/SavedFeedJpaRepository.java(1 hunks)src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java(2 hunks)src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java(2 hunks)src/main/java/konkuk/thip/feed/domain/SavedFeeds.java(0 hunks)
💤 Files with no reviewable changes (2)
- src/main/java/konkuk/thip/common/exception/code/ErrorCode.java
- src/main/java/konkuk/thip/feed/domain/SavedFeeds.java
🧰 Additional context used
🧬 Code Graph Analysis (1)
src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java (4)
src/main/java/konkuk/thip/feed/application/service/BasicFeedShowAllService.java (1)
Service(20-65)src/main/java/konkuk/thip/feed/application/service/FollowingPriorityFeedShowAllService.java (1)
Service(20-64)src/main/java/konkuk/thip/feed/application/service/FeedShowAllOfUserService.java (1)
Service(20-72)src/main/java/konkuk/thip/feed/application/service/FeedShowSingleService.java (1)
Service(19-50)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (3)
src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java (1)
52-55: LGTM — 모든 태그 조회 시그니처 유지기존 시그니처를 유지하면서 위치만 재정렬된 것으로 보이며 문제 없습니다.
src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java (1)
108-110: LGTM — 존재 여부 위임 구현 적절
existsSavedFeedByUserIdAndFeedId를 레포지토리로 위임하는 구현이 간결하고 목적에 부합합니다.src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java (1)
43-50: 검증 로직 분리 좋습니다
validateSaveFeedAction으로 상태 전이 규칙을 명확히 캡슐화한 점 좋습니다. 예외 코드도 구체적이라 추적/대응 용이합니다.
| public FeedIsSavedResult changeSavedFeed(FeedIsSavedCommand command) { | ||
|
|
||
| // 1. 피드 검증 및 조회 | ||
| Feed feed = feedCommandPort.getByIdOrThrow(feedIsSavedCommand.feedId()); | ||
| Feed feed = feedCommandPort.getByIdOrThrow(command.feedId()); | ||
|
|
||
| // 2. 유저가 저장한 피드 목록 조회 | ||
| SavedFeeds savedFeeds = feedQueryPort.findSavedFeedsByUserId(feedIsSavedCommand.userId()); | ||
| // 2. 유저가 해당 피드를 저장했는지 여부 조회 | ||
| boolean alreadySaved = feedQueryPort.existsSavedFeedByUserIdAndFeedId(command.userId(), feed.getId()); | ||
| validateSaveFeedAction(command.isSaved(), alreadySaved); | ||
|
|
||
| if (feedIsSavedCommand.isSaved()) { | ||
| // 저장 요청 시 이미 저장되어 있으면 예외 발생 | ||
| savedFeeds.validateNotAlreadySaved(feed); | ||
| feedCommandPort.saveSavedFeed(feedIsSavedCommand.userId(), feed.getId()); | ||
| if (command.isSaved()) { | ||
| feedCommandPort.saveSavedFeed(command.userId(), feed.getId()); | ||
| } else { | ||
| // 삭제 요청 시 저장되어 있지 않으면 예외 발생 | ||
| savedFeeds.validateCanDelete(feed); | ||
| feedCommandPort.deleteSavedFeed(feedIsSavedCommand.userId(), feed.getId()); | ||
| feedCommandPort.deleteSavedFeed(command.userId(), feed.getId()); | ||
| } | ||
|
|
||
| return FeedIsSavedResult.of(feed.getId(), feedIsSavedCommand.isSaved()); | ||
| return FeedIsSavedResult.of(feed.getId(), command.isSaved()); | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
check-then-act 경쟁 상태 가능성: 유니크 제약 및 예외 매핑으로 보강 권장
현재 플로우는
- Line 31-33: 사전 존재 여부 조회
- Line 34-38: 결과에 따라 저장/삭제
형태의 check-then-act 패턴입니다. 동시 요청이 들어오면, 존재 여부 확인 직후 상태가 바뀌는 경쟁 상태가 발생할 수 있습니다. 이를 안전하게 만들기 위해 다음을 권장합니다.
필수 보강(권장 구현):
- SavedFeed(userId, feedId) 조합에 DB 유니크 제약을 두고,
- 저장 시
DataIntegrityViolationException을 잡아 FEED_ALREADY_SAVED로 변환하여 최종 일관성을 보장
예시 패치:
@@
- if (command.isSaved()) {
- feedCommandPort.saveSavedFeed(command.userId(), feed.getId());
- } else {
- feedCommandPort.deleteSavedFeed(command.userId(), feed.getId());
- }
+ if (command.isSaved()) {
+ try {
+ feedCommandPort.saveSavedFeed(command.userId(), feed.getId());
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // 동시성 등으로 인한 중복 저장 시도를 비즈니스 예외로 변환
+ throw new BusinessException(FEED_ALREADY_SAVED);
+ }
+ } else {
+ // 사전 검증으로 not-saved 삭제는 방지되나, 동시성으로 사라진 경우도 고려하여
+ // deleteSavedFeed 구현이 영향 행 수를 반환한다면 체크/로깅을 고려할 수 있습니다.
+ feedCommandPort.deleteSavedFeed(command.userId(), feed.getId());
+ }또한, SavedFeed 테이블에 유니크 인덱스가 없으면 위 보강만으로는 완전하지 않습니다. 아래 스크립트로 유니크 제약 및 예외 매핑 유무를 확인해 주세요. 필요 시 후속 PR에서 제약 추가/예외 매핑을 도와드릴 수 있습니다.
🏁 Script executed:
#!/bin/bash
# SavedFeed 유니크 제약 및 저장/삭제 구현에서의 예외 매핑 여부 확인
echo "== SavedFeedJpaEntity 정의 위치 =="
rg -n "class\s+SavedFeedJpaEntity\b" -C2
echo
echo "== @Table(uniqueConstraints=...), unique=true 등 유니크 제약 여부 =="
rg -n "@Table\(" -C2
rg -n "uniqueConstraints" -C2
rg -n "unique\s*=\s*true" -C2
echo
echo "== saveSavedFeed/deleteSavedFeed 구현 및 예외 매핑 흔적 =="
rg -n "saveSavedFeed\s*\(" -C3
rg -n "deleteSavedFeed\s*\(" -C3
rg -n "DataIntegrityViolationException|DuplicateKeyException|FEED_ALREADY_SAVED|FEED_NOT_SAVED_CANNOT_DELETE" -C2Length of output: 21004
DB 유니크 제약 및 예외 처리 보강 필요
현재 SavedFeed 엔티티에는 (user_id, feed_id) 복합 유니크 제약이 없고, 저장 로직에서도 동시 중복 저장 시 발생하는 예외를 잡아 변환하는 처리가 빠져 있습니다. 경쟁 상태를 방지하고 최종 일관성을 보장하기 위해 아래와 같이 반영을 권장합니다.
-
src/main/java/konkuk/thip/feed/adapter/out/jpa/SavedFeedJpaEntity.java
- @table 어노테이션에 uniqueConstraints 추가
@Entity @Table( name = "saved_feeds", uniqueConstraints = @UniqueConstraint( name = "uq_saved_feeds_user_feed", columnNames = {"user_id", "feed_id"} ) ) public class SavedFeedJpaEntity extends BaseJpaEntity { … }
- @table 어노테이션에 uniqueConstraints 추가
-
src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java
feedCommandPort.saveSavedFeed(...)호출부를 DataIntegrityViolationException 핸들링으로 래핑if (command.isSaved()) { - feedCommandPort.saveSavedFeed(command.userId(), feed.getId()); + try { + feedCommandPort.saveSavedFeed(command.userId(), feed.getId()); + } catch (DataIntegrityViolationException e) { + // 동시 중복 저장 시 비즈니스 예외로 변환 + throw new BusinessException(FEED_ALREADY_SAVED); + } } else { feedCommandPort.deleteSavedFeed(command.userId(), feed.getId()); }
-
선택 사항: delete 로직도
long deleteByUserIdAndFeedId(...)처럼 삭제 행 수를 반환하도록 리포지토리 시그니처를 변경하고, 0건 삭제 시 로깅 또는 예외 처리 고려
위 보강 작업 후, FeedChangeSavedApiTest에서 중복 저장/삭제 시나리오가 여전히 올바르게 동작하는지 재검증해주세요.
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java
around lines 25–41, the save path does not handle DB uniqueness violations under
concurrent saves; wrap the call to feedCommandPort.saveSavedFeed(...) in a
try-catch that catches DataIntegrityViolationException (or the project's
equivalent), treat it as idempotent (log and continue or translate to a
domain-safe response) so concurrent duplicate-insert errors do not bubble up,
and preserve current return semantics; additionally consider changing delete
flow to use a deleteByUserIdAndFeedId(...) that returns deleted count and log or
handle a 0-delete result. Also add the unique constraint to
src/main/java/konkuk/thip/feed/adapter/out/jpa/SavedFeedJpaEntity.java as
described by the reviewer to enforce (user_id, feed_id) uniqueness at the DB
level.
| private void validateSaveFeedAction(boolean isSaveRequest, boolean alreadySaved) { | ||
| if (isSaveRequest && alreadySaved) { | ||
| // 이미 저장되어 있는 피드를 다시 저장하려는 경우 예외 처리 | ||
| throw new BusinessException(FEED_ALREADY_SAVED); | ||
| } else if (!isSaveRequest && !alreadySaved) { | ||
| // 저장되지 않은 피드를 삭제하려는 경우 예외 처리 | ||
| throw new BusinessException(FEED_NOT_SAVED_CANNOT_DELETE); | ||
| } |
There was a problem hiding this comment.
Actionable comments posted: 0
🔭 Outside diff range comments (1)
src/main/java/konkuk/thip/book/adapter/out/persistence/repository/SavedBookJpaRepository.java (1)
15-16: DELETE @query에 @Param 누락 — 런타임 바인딩 실패 가능성 높음동일 파일 내 exists 메서드는 @Param을 추가하셨는데, delete 메서드는 네임드 파라미터(:userId, :bookId)를 사용하면서 @Param이 없습니다. 컴파일 옵션에 따라 메서드 파라미터 이름이 유지되지 않으면 런타임에 "Named parameter not bound" 예외가 발생할 수 있습니다. exists와 동일하게 @Param을 추가해 주세요.
아래 패치 제안:
- void deleteByUserIdAndBookId(Long userId, Long bookId); + void deleteByUserIdAndBookId(@Param("userId") Long userId, @Param("bookId") Long bookId);
🧹 Nitpick comments (2)
src/main/java/konkuk/thip/book/adapter/out/persistence/repository/SavedBookJpaRepository.java (2)
10-12: exists 쿼리는 파생 메서드로 더 단순화 가능 (선택사항)현재 @query + CASE WHEN COUNT 패턴도 동작에는 문제 없습니다. 다만 Spring Data JPA의 파생 메서드를 사용하면 쿼리 문자열을 제거하고 유지보수를 단순화할 수 있습니다.
아래처럼 파생 메서드로 대체 가능:
- @Query("SELECT CASE WHEN COUNT(s) > 0 THEN true ELSE false END FROM SavedBookJpaEntity s " + - "WHERE s.userJpaEntity.userId = :userId AND s.bookJpaEntity.bookId = :bookId") - boolean existsByUserIdAndBookId(@Param("userId") Long userId, @Param("bookId") Long bookId); + boolean existsByUserJpaEntity_UserIdAndBookJpaEntity_BookId(Long userId, Long bookId);참고:
- 중첩 프로퍼티 경로는 언더스코어(_)로 구분해 탐색 가능합니다.
- 쿼리 제거로 리팩터링 비용 및 오타 리스크를 낮출 수 있습니다.
10-16: 조회/삭제 조건 칼럼(userId, bookId)에 인덱스/유니크 제약 고려 (선택사항)
- 빈번한 exists/delete 패턴이므로 (user_id, book_id) 복합 인덱스가 있으면 I/O를 줄일 수 있습니다.
- 동일 사용자-도서 중복 저장을 방지하려면 유니크 제약까지 고려해 보세요. 애그리게이트 규칙을 DB 레벨에서 강제할 수 있습니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
src/main/java/konkuk/thip/book/adapter/out/persistence/repository/SavedBookJpaRepository.java(1 hunks)src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/SavedFeedJpaRepository.java(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/SavedFeedJpaRepository.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (2)
src/main/java/konkuk/thip/book/adapter/out/persistence/repository/SavedBookJpaRepository.java (2)
7-7: @Param import 추가 적절네임드 파라미터 바인딩을 위해 필요한 의존성 추가가 정확합니다.
14-16: @Modifying 쿼리의 트랜잭션 경계 보장 확인 요청@Modifying 메서드는 트랜잭션 내에서 실행되어야 합니다. 상위 서비스 계층에 @transactional이 적용되어 있는지 확인 부탁드립니다. 없다면 레포지토리 인터페이스(권장 X) 대신 호출하는 서비스 메서드에 @transactional을 명시해 주세요.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
.github/workflows/cd-workflow-dev.yml (1)
59-60: 서드파티 액션 버전 고정 제안
appleboy/ssh-action@master는 가변적입니다. 보안/재현성을 위해 태그(권장)나 커밋 SHA로 pinning 해 주세요. 예:appleboy/ssh-action@v0.1.x또는 특정 SHA.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
.github/workflows/cd-workflow-dev.yml(1 hunks)
🧰 Additional context used
🪛 YAMLlint (1.37.1)
.github/workflows/cd-workflow-dev.yml
[error] 72-72: syntax error: expected , but found ''
(syntax)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
| # sudo docker-compose down --rmi all | ||
| sudo docker-compose stop ${{ secrets.DOCKER_IMAGE }} | ||
| sudo docker rm -f ${{ secrets.DOCKER_IMAGE }} |
There was a problem hiding this comment.
YAML 블록 들여쓰기 오류 + 잘못된 대상(이미지명)으로 컨테이너 중지/삭제 시도
- Line 71:
script: |블록 내부 줄은 반드시 들여쓰기 되어야 합니다. 현재 줄 시작에 들여쓰기가 없어 블록이 조기 종료되어 YAML 파싱 오류가 납니다. YAMLlint 에러(“expected …”)가 이 문제를 가리킵니다. - Lines 72-73:
docker-compose stop은 “서비스명” 기준이며,docker rm은 “컨테이너명/ID” 기준입니다.secrets.DOCKER_IMAGE는 일반적으로 “이미지명”이라 중지/삭제 대상이 달라 실행 실패할 가능성이 높습니다.
단순하고 안전한 방법은 compose 프로젝트 단위로 컨테이너를 중지/정리하는 것입니다. 아래처럼 교체하면:
- YAML 들여쓰기 문제를 해소
- 대상 식별 오류를 제거
- 이미지는 유지(아래에서 prune로 정리됨)
적용 제안:
-# sudo docker-compose down --rmi all
- sudo docker-compose stop ${{ secrets.DOCKER_IMAGE }}
- sudo docker rm -f ${{ secrets.DOCKER_IMAGE }}
+ # docker-compose down --rmi all # 이미지는 유지하고 컨테이너만 교체합니다 (이미지는 아래 prune에서 정리)
+ sudo docker-compose down --remove-orphans참고:
- 특정 서비스만 중지/삭제하려면 “이미지명”이 아니라 “서비스명”이 필요합니다. 해당 경우 별도 secret(예:
DOCKER_SERVICE)을 두고docker-compose stop ${{ secrets.DOCKER_SERVICE }}/docker-compose rm -f ${{ secrets.DOCKER_SERVICE }}형태로 사용하세요.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| # sudo docker-compose down --rmi all | |
| sudo docker-compose stop ${{ secrets.DOCKER_IMAGE }} | |
| sudo docker rm -f ${{ secrets.DOCKER_IMAGE }} | |
| # docker-compose down --rmi all # 이미지는 유지하고 컨테이너만 교체합니다 (이미지는 아래 prune에서 정리) | |
| sudo docker-compose down --remove-orphans |
🧰 Tools
🪛 YAMLlint (1.37.1)
[error] 72-72: syntax error: expected , but found ''
(syntax)
🤖 Prompt for AI Agents
In .github/workflows/cd-workflow-dev.yml around lines 71–73 the lines inside the
script: | block are not indented and therefore break YAML parsing, and the
commands use secrets.DOCKER_IMAGE (an image name) with docker-compose stop /
docker rm which expect a service name and a container id/name respectively. Fix
by indenting the lines so they are inside the script block, and replace the two
lines with project-level compose teardown (e.g. run docker-compose stop and
docker-compose rm -f with no argument or run docker-compose down) so you
stop/remove the compose project instead of referencing an image; if you really
need to target a single service, introduce a secret like DOCKER_SERVICE and call
docker-compose stop ${{ secrets.DOCKER_SERVICE }} / docker-compose rm -f ${{
secrets.DOCKER_SERVICE }}.
| # sudo docker-compose down --rmi all | ||
| sudo docker-compose stop ${{ secrets.DOCKER_IMAGE }} | ||
| sudo docker rm -f ${{ secrets.DOCKER_IMAGE }} |
There was a problem hiding this comment.
굳굳 기존 컨테이너 강제로 내리면 개발서버가 좀 더 안정화될수도 있겠네요
There was a problem hiding this comment.
기존에는 mysql redis까지 모두 내렸다가 다시 올렸는데 생각해보니 두 컨테이너는 굳이 내릴 필요가 없다고 판단해서 그냥 서버 컨테이너만 내렸다가 올리려고 합니다!
| // 2. 유저가 해당 피드를 저장했는지 여부 조회 | ||
| boolean alreadySaved = feedQueryPort.existsSavedFeedByUserIdAndFeedId(command.userId(), feed.getId()); | ||
| validateSaveFeedAction(command.isSaved(), alreadySaved); |
#️⃣ 연관된 이슈
📝 작업 내용
📸 스크린샷
💬 리뷰 요구사항
📌 PR 진행 시 이러한 점들을 참고해 주세요
Summary by CodeRabbit
버그 수정
리팩터링
기타