Skip to content
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

[BE] Refactor/#634: Bookmark 객체 의존성 분리 및 복합키 적용 #636

Merged
merged 8 commits into from
Dec 15, 2023

Conversation

cpot5620
Copy link
Collaborator

@cpot5620 cpot5620 commented Dec 3, 2023

작업 대상

#634

📄 작업 내용

Bookmark 의존성 분리(간접 참조) 및 PK 복합키로 수정

설명이 필요한 부분은 아래에 간단히 남겨놓겠습니다.
또한, 코드에도 남겨놓을게요 !
또또한, 이야기 나누어야할 부분이 있어서 남겨놓을게요 ㅎㅎ..

1. Bookmark API 명세 수정
Bookmark API 변경 전 : /bookmarks/topics?topicId=1
Bookmark API 변경 후 : /bookmarks/topcis/1

쿼리 파라미터로 사용한 이유를 잘 모르겠어서.. PathVariable로 변경하였습니다.
또한, API 요청에 bookmarkId를 사용하지 않고 topicId를 사용하고 있어서 즐겨찾기 추가시 201 상태코드만 반환하도록 하였습니다.

2. TopicRepository 조회용 JPQL 작성
Topic은 이번 PR의 대상이 아니지만, 간접 참조로 변경 및 양방향 의존성 제거 과정에서 토픽 조회시 문제가 발생하더라구요.
그래서, 이전 PR(#635)에서 도이가 의견으로 남겨주셨던 application 계층에서 사용하는 DTO를 별도로 사용해보는 것을 적용했습니다.

3. 간접 참조로의 변경에 따른, BookmarkCount 정합성(?)을 어떻게 맞추어야 할지 고민이 됩니다.
현재는 BookmarkCommandService의 로직에서 topic.increase/decreaseBookmarkCount를 의도적으로 호출하고 있습니다.
해당 행위가 조금 부자연스러워 보일뿐만 다음과 같은 문제가 있습니다.

  • Bookmark 생성시에는 권한 검증 과정에서 Topic을 조회할 필요가 있지만, 삭제시에는 사실 조회할 필요가 없습니다. (성능 이슈..?)
    사실 이 문제도 추후에 AuthMember를 통한 검증 방식을 수정하면 생성시에도 조회할 필요가 없을수도 있을 것 같아요.
    또한, 변경감지로 처리하면 동시성 이슈가 발생할 수도 있겠죠.. (기존 코드에서의 문제점이기도 합니다.)

위 상황에 대한 적절한 해결방안이 떠오르지 않습니다..
제가 생각해본 해결방안은 아래와 같아요.

a) 즐겨찾기 추가/삭제 로직 수행.
b) 단, 즐겨찾기 개수 수정 로직은 위와 분리되어야 한다고 판단하여 이벤트 발행.
c) 이벤트 발행을 통해, 비동기로 BookmarkCount 수정 쿼리 수행.
d) 이벤트 유실을 대비하여, 스케쥴링 도입을 통해 12시간 혹은 1일 간격으로 데이터 정합성 맞추기.

물론 기존 방법도 JPQL을 통해 즐겨찾기 개수 수정을 수행하면 성능 이슈는 마찬가지로 해결될 것 같아요.
굳이 이벤트를 적용하지 않아도 될 것 같습니다.

뭔가 서론이 엄청 길어진 것 같은데, 여쭤보고 싶은걸 정리하면 아래와 같아요.

  1. 변경감지가 아닌, JPQL을 통해 BookmarkCount 처리 로직은 어떤가 ?
    -> 동시성 이슈 해결, 성능 이슈 해결
  2. 이벤트 발행이 필요한가 ? 하나의 트랜잭션으로 보아도 무방할까 !?

여담이지만, 도메인에다가 Validator를 별도로 구현해보고 싶은데 AuthMember 때문에 의존성 문제가 해결이 어려울 것 같네요 ㅠㅠ
이 또한, 도이가 추후에 해당 방식을 바꿔보자고 말씀해주셨으니까 지속적으로 고민해보아요 😄

🙋🏻 주의 사항

스크린샷

📎 관련 이슈

레퍼런스

@cpot5620 cpot5620 added BE 백엔드 관련 이슈 refactor 리팩토링 관련 이슈 labels Dec 3, 2023
Copy link
Collaborator Author

@cpot5620 cpot5620 left a comment

Choose a reason for hiding this comment

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

잘 부탁드려🐉

Comment on lines +66 to 74
// TODO: 2023/12/03 BookmarkCount의 정합성을 어떻게 맞출 것인가 ? 매번 topic 조회하는 것은 불필요한 행위같아보임
public void deleteTopicInBookmark(AuthMember authMember, Long topicId) {
validateBookmarkDeletingPermission(authMember, topicId);
BookmarkId bookmarkId = BookmarkId.of(topicId, authMember.getMemberId());

Bookmark bookmark = findBookmarkByMemberIdAndTopicId(authMember.getMemberId(), topicId);
bookmarkRepository.deleteById(bookmarkId);
Topic topic = getTopicById(topicId);

topic.removeBookmark(bookmark);
}

private Bookmark findBookmarkByMemberIdAndTopicId(Long memberId, Long topicId) {
return bookmarkRepository.findByMemberIdAndTopicId(memberId, topicId)
.orElseThrow(() -> new NoSuchElementException(
"findBookmarkByMemberIdAndTopicId; memberId=" + memberId + " topicId=" + topicId
));
topic.decreaseBookmarkCount();
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

BookmarkCount를 수정하기 위해, Topic을 조회하는 작업이 수행됩니다 ㅠㅠ
이 부분이 PR 본문 3번에 해당합니다 !

Comment on lines +25 to 30
@PostMapping("/topics/{topicId}")
public ResponseEntity<Void> addTopicInBookmark(AuthMember authMember, @PathVariable Long topicId) {
bookmarkCommandService.addTopicInBookmark(authMember, topicId);

return ResponseEntity.created(URI.create("/bookmarks/topics" + bookmarkId)).build();
return ResponseEntity.status(HttpStatus.CREATED).build();
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

실제로 BookmarkId를 사용하고 있지 않는 것 같아서, Resource URI는 안 보내는 걸로 결정했습니다. (본문 1번)

Comment on lines 81 to 84
private List<TopicResponse> getUserTopicResponses(final Map<Topic, Long> topicCounts, final AuthMember authMember) {
Member member = findMemberById(authMember.getMemberId());
List<Topic> bookmarkedTopics = findBookMarkedTopics(member);
List<Long> bookmarkedTopicIds = bookmarkRepository.findAllIdTopicIdByIdMemberId(member.getId());
List<Topic> topicsInAtlas = findTopicsInAtlas(member);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Bookmark가 되어있는지 여부를 판단하기 위해서 위와 같은 로직을 수행하는데요.
ID 값 만으로도 충분히 검증 가능해서, Topic 조회보다는 ID 값만 조회하도록 수정했습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

오 이렇게 하면 아무래도 다른 ID 이외의 다른 컬럼들은 불러오지 않아서 성능상 이점이 있으려나요 ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

아무래도 DB에서 조회해야하는 데이터 뿐만 아니라, 객체 생성에 필요한 리소스(메모리 자원 등) 측면에서 이점이 있을 것 같아요 ㅎㅎ..

Comment on lines 32 to 41
@Query(
value = "SELECT new com.mapbefine.mapbefine.topic.dto.TopicDto(" +
"t.id, t.topicInfo.name, t.topicInfo.image.imageUrl, " +
"m.memberInfo.nickName, t.pinCount, t.bookmarkCount, t.lastPinUpdatedAt" +
") " +
"FROM Topic t " +
"JOIN Bookmark b ON b.id.memberId = :memberId AND b.id.topicId = t.id " +
"JOIN Member m ON m.id = t.creator.id"
)
List<TopicDto> findTopicsInfoByBookmarksMemberId(@Param("memberId") Long memberId);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

본문 2번에 해당하는 부분입니다.

로직이 조금 지저분한데 ㅎㅎ.. 검증해주세요 여러분.. ㅠ_ㅠ
테스트는 통과하는뎅 ...

Copy link
Collaborator

Choose a reason for hiding this comment

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

직접 참조를 하지 않게 되면서 fetch join 대신 쿼리를 작성해주게 된거군요!

두 가지 질문이 있어용

  1. TopicDto 패키지 위치와 네이밍 기준 궁금해요!
  2. 이렇게 해주는 대신 Bookmark가 Topic을 참조하도록 하는 건 어떻게 생각하시나용? 또, 그렇게 되면 BookmarkRepository에서 entityGraph를 통해 이를 조회해오는게 더 자연스럽지 않을까요?

즐겨찾기 조회라는 기본적인 로직임에도 entityGraph 대신 이렇게 직접 쿼리를 작성해 조인을 해줘야 하는 상황 자체가, Bookmark는 Topic의 애그리거트에 속하는 엔티티라는 뜻은 아닐까 해서요!

이슈에서 이야기 나눈 것처럼, 쓰기 작업 시에는 조회할 필요가 없어서 간접 참조로 끊는다고 하셨는데
객체 참조의 지연로딩으로도 쓰기 작업 시 불필요한 조회를 방지하지 않나요? 혹시 제가 잘못 생각하는 부분이 있을까요?!

Copy link
Collaborator

Choose a reason for hiding this comment

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

즐겨찾기 조회라는 기본적인 로직임에도 entityGraph 대신 이렇게 직접 쿼리를 작성해 조인을 해줘야 하는 상황 자체가, Bookmark는 Topic의 애그리거트에 속하는 엔티티라는 뜻은 아닐까 해서요!

저도 도이의 이 의견에 대해서 동의합니다!

그리고 도이가 이후에 말씀해주신 사항에 대해서 찾아보다가 문득 든 생각이 있어요.

물론 추후 개선한다고 이야기를 나누었지만, 현재로서는 쓰기 작업시 무조건 Topic 을 조회할 수 밖에 없습니다.

그렇기 때문에, 사실상 Bookmark -> Topic 의존성을 간접참조로 끊어낸다고 하더라도 성능상(?) 이점은 얻을 수 없겠죠.

다만 쥬니가 말씀하신건 Bookmark -> member 의존성을 간접참조로 끊어내면서 Bookmark 를 저장하기 위해 추가적으로 Member 를 조회할 필요가 사라짐에 따라(Bookmark 를 생성할 때 Member Entity 를 넣을 필요가 없어서) 불필요한 조회를 방지한다고 하셨던 거 아닐까요?

제가 잘못 생각하고 있는 것이라면 꼭 피드백 부탁드립니다!

Copy link
Collaborator

Choose a reason for hiding this comment

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

음 근데 다시 생각해보니까 EntityGraph 대신에 직접 쿼리를 작성한 이유는 Topic 을 반환하는 것이 아닌 TopicDto 를 반환해서 그런건가...

갑자기 같은 애그리거트인지.. 혼란이...

아 그리고 로직은 맞는 것 같아요 호호

Copy link
Collaborator Author

@cpot5620 cpot5620 Dec 14, 2023

Choose a reason for hiding this comment

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

@yoondgu

  1. TopicDto 패키지 위치와 네이밍 기준 궁금해요!

하하.. 사실 네이밍은 그냥 아무생각 없이 쓴 것 같아요..
패키지 위치는 /topic/domain쪽에 둘까 싶었는데, 저희가 dto라는 공통된 패키지가 존재해서 이 곳에 두었습니다.

  1. 이렇게 해주는 대신 Bookmark가 Topic을 참조하도록 하는 건 어떻게 생각하시나용? 또, 그렇게 되면 BookmarkRepository에서 entityGraph를 통해 이를 조회해오는게 더 자연스럽지 않을까요?

우선, 좋은 의견 감사합니다 도이 👍
읽기 작업시에는 위 흐름이 자연스러운데, 쓰기 작업시에는 객체 참조를 할 필요가 없다고 생각했었거든요.
단순히, 올바른 topicId인지만 검증하면 된다는 생각을 가지고 있었던 것 같아요.
다만, 쓰기 작업보다 읽기 작업이 더 자주 발생한다는 점과 같은 애그리거트로 보는 것이 자연스럽다는 점에서 도이가 말씀해주신 흐름이 더 자연스러울 것 같네요 !

이슈에서 이야기 나눈 것처럼, 쓰기 작업 시에는 조회할 필요가 없어서 간접 참조로 끊는다고 하셨는데
객체 참조의 지연로딩으로도 쓰기 작업 시 불필요한 조회를 방지하지 않나요? 혹시 제가 잘못 생각하는 부분이 있을까요?!

제가 의사 전달을 똑바로 못한 것 같네요 😢
지금 Bookmark에 대한 쓰기 작업시에 authMember를 통해 검증을 하고 있지만, 이 부분이 수정 된다면 단순히 올바른 topicId / memberId인지 검증하기만 하면 된다고 생각했어요. 그럴 경우, select 쿼리보다는 exist 쿼리를 사용하는 것이 성능적으로 이점이 있을거라고 생각했었구요 !
그래서 불필요한 조회를 방지할 수 있다고 생각했던 것 같아요.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@kpeel5839

다만 쥬니가 말씀하신건 Bookmark -> member 의존성을 간접참조로 끊어내면서 Bookmark 를 저장하기 위해 추가적으로 Member 를 조회할 필요가 사라짐에 따라(Bookmark 를 생성할 때 Member Entity 를 넣을 필요가 없어서) 불필요한 조회를 방지한다고 하셨던 거 아닐까요?
제가 잘못 생각하고 있는 것이라면 꼭 피드백 부탁드립니다!

매튜가 말씀하신 Member도 맞구요 !! 위쪽에 도이한테 성능상 이점이 있을 수 있다라고 생각했던 부분을 그대로 복사해올게요 ㅎㅎ..

지금 Bookmark에 대한 쓰기 작업시에 authMember를 통해 검증을 하고 있지만, 이 부분이 수정 된다면 단순히 올바른 topicId / memberId인지 검증하기만 하면 된다고 생각했어요. 그럴 경우, select 쿼리보다는 exist 쿼리를 사용하는 것이 성능적으로 이점이 있을거라고 생각했었구요 !
그래서 불필요한 조회를 방지할 수 있다고 생각했던 것 같아요.

Copy link
Collaborator

Choose a reason for hiding this comment

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

쓰기 작업 시에는 객체 참조를 할 필요가 없다는 말에도 공감이 되는데, 의견 반영해주셔서 감사해요 !
성능 관련 내용에 대해서도 두 분 말씀 이해했습니당!! 설명 감사드려요~!

@jiwonh423
Copy link
Collaborator

COOL~

Copy link
Collaborator

@yoondgu yoondgu left a comment

Choose a reason for hiding this comment

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

쥬니 이번에도 좋은 고민 공유해주셔서 감사해요!
본문에서 공유해주신 내용 외 RC가 없어서 approve로 올리는데, 한가지 질문에 대해서 같이 얘기해보면 좋을 것 같아요!

1. Bookmark API 명세 수정
좋네용~

2. TopicRepository 조회용 JPQL 작성
궁금한 점이 있어서 코멘트로 질문 남겼습니다!

3. 간접 참조로의 변경에 따른, BookmarkCount 정합성(?)을 어떻게 맞추어야 할지 고민이 됩니다.
저도 남겨주신 내용에 공감합니다.

  1. 동시성 이슈, 성능 이슈 개선을 위해 JPQL로 처리하는 것 동의합니다.
  2. 정리해주신 이벤트 발행 방법 저도 나쁘지 않아보여요. 다만 이처럼 '역정규화'된 컬럼들은 모두 동일한 방법을 적용한다거나, 일관성만 가져가면 좋겠다는 의견입니다! 그리고 저도 하나의 트랜잭션이라고 생각해요~

즐겨찾기 개수 수정 로직은 원래는 별개의 로직이 아니라 '즐겨찾기 추가/삭제' 로직에 포함되어야 하지만, 테이블 역정규화로 인해 분리된 로직이라고 생각해요. (그래서 하나의 트랜잭션)
그치만 오히려 그래서, 쥬니가 말씀해주신 것처럼 서비스단에서 이 로직이 노출되는 게 데이터 의존적이고 어색하다는 것에 공감해요. 그런 이유라면 이벤트로 분리하는 것도 괜찮다고 생각해요!

Comment on lines 32 to 41
@Query(
value = "SELECT new com.mapbefine.mapbefine.topic.dto.TopicDto(" +
"t.id, t.topicInfo.name, t.topicInfo.image.imageUrl, " +
"m.memberInfo.nickName, t.pinCount, t.bookmarkCount, t.lastPinUpdatedAt" +
") " +
"FROM Topic t " +
"JOIN Bookmark b ON b.id.memberId = :memberId AND b.id.topicId = t.id " +
"JOIN Member m ON m.id = t.creator.id"
)
List<TopicDto> findTopicsInfoByBookmarksMemberId(@Param("memberId") Long memberId);
Copy link
Collaborator

Choose a reason for hiding this comment

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

직접 참조를 하지 않게 되면서 fetch join 대신 쿼리를 작성해주게 된거군요!

두 가지 질문이 있어용

  1. TopicDto 패키지 위치와 네이밍 기준 궁금해요!
  2. 이렇게 해주는 대신 Bookmark가 Topic을 참조하도록 하는 건 어떻게 생각하시나용? 또, 그렇게 되면 BookmarkRepository에서 entityGraph를 통해 이를 조회해오는게 더 자연스럽지 않을까요?

즐겨찾기 조회라는 기본적인 로직임에도 entityGraph 대신 이렇게 직접 쿼리를 작성해 조인을 해줘야 하는 상황 자체가, Bookmark는 Topic의 애그리거트에 속하는 엔티티라는 뜻은 아닐까 해서요!

이슈에서 이야기 나눈 것처럼, 쓰기 작업 시에는 조회할 필요가 없어서 간접 참조로 끊는다고 하셨는데
객체 참조의 지연로딩으로도 쓰기 작업 시 불필요한 조회를 방지하지 않나요? 혹시 제가 잘못 생각하는 부분이 있을까요?!

Copy link
Collaborator

@kpeel5839 kpeel5839 left a comment

Choose a reason for hiding this comment

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

쥬니~!

너무 고생많았어요

리뷰가 너무 늦었죠.

좋지 않은 일로 인해 많이 늦어졌네요.

알러뷰


flowchart BT
	a[Topic] --> b[Bookmark]
Loading

현재 쥬니가 하려는 것이 패키지 간의 의존성이 위와 같은 형태의 띄게 하려는 것이 맞나요 ?

그렇다면 의존성을 끊어내기 위해 이벤트를 발행하는 것이 좋아보여요!

그리고 이벤트 발행만을 가져가는 것이 아니라, JPQL 을 통해서 BookmarkCount 처리 로직도 같이 수행했으면 좋겠어요!

그러면 확실하게 의존성을 분리하고 성능, 동시성 이슈도 해결할 수 있을테니까요.

import java.time.LocalDateTime;

@Getter
public class TopicDto {
Copy link
Collaborator

Choose a reason for hiding this comment

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

해당 클래스도 record 로 만들어주면 깔끔할 것 같다는 생각을 했는데, 기본 생성자를 명시적으로 생성하지 않으면 발생하는 오류 때문에 record 로 하지 않은건가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

맞아요 !!
근데.. 도이와 매튜 의견에 따라 해당 dto 제거했숩니다..

this.memberId = memberId;
}

public static BookmarkId of(Long topicId, Long memberId) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

여기서도 이전처럼 topicIdmemberId 가 null 인지 검증하는 로직이 있으면 좋을 것 같아용!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

해당 검증 로직이 Permission에도 있어야 겠군요...

반영했어용 !

Comment on lines 32 to 41
@Query(
value = "SELECT new com.mapbefine.mapbefine.topic.dto.TopicDto(" +
"t.id, t.topicInfo.name, t.topicInfo.image.imageUrl, " +
"m.memberInfo.nickName, t.pinCount, t.bookmarkCount, t.lastPinUpdatedAt" +
") " +
"FROM Topic t " +
"JOIN Bookmark b ON b.id.memberId = :memberId AND b.id.topicId = t.id " +
"JOIN Member m ON m.id = t.creator.id"
)
List<TopicDto> findTopicsInfoByBookmarksMemberId(@Param("memberId") Long memberId);
Copy link
Collaborator

Choose a reason for hiding this comment

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

음 근데 다시 생각해보니까 EntityGraph 대신에 직접 쿼리를 작성한 이유는 Topic 을 반환하는 것이 아닌 TopicDto 를 반환해서 그런건가...

갑자기 같은 애그리거트인지.. 혼란이...

아 그리고 로직은 맞는 것 같아요 호호

Copy link
Collaborator Author

@cpot5620 cpot5620 left a comment

Choose a reason for hiding this comment

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

안녕하세요 여러분... 면목이 없습니다.

기나긴 방황을 끝마치고 제가 돌아왔어요..
오늘부터는 정말 정신차리고 살아가려구요 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
두 분 모두 approve 해주셨기에, 바로 머지할까도 고민해봤지만 반영사항 확인받고 진행하는 것이 좋을 것 같아서 기다리겠습니다 !
확인 부탁드려요 !

함께 논의했던 Bookmark Event 관련해서는 다음 PR에서 반영할게요 !

Comment on lines +22 to 25
@MapsId("topicId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "topic_id", nullable = false)
private Topic topic;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

궁금해하실 여러분들을 위해 간략하게 소개하자면 아래와 같은 이유에서 사용했어요.
해당 Bookmark 엔티티를 구현하면, 테이블상에는 복합키로 만든 BookmarkId에 있는 topicId, memberId, 그리고 해당 Topic 객체에 대한 topicId가 생기게 되요.
그러면, 중복되는 컬럼이 발생하겠죠 ?
그래서, 해당 엔티티에서 외래키이자 복합키를 사용하고자 할때, @MapsId를 통해 @EmbeddedId의 어떤 값을 사용할 것인지 알려주면 됩니다 !!

Copy link
Collaborator

Choose a reason for hiding this comment

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

오 이런 게 있군요!! 배워가영

Copy link
Collaborator

Choose a reason for hiding this comment

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

킹가 막히는군요

@cpot5620 cpot5620 merged commit 04f3a0f into refactor/dependency Dec 15, 2023
@semnil5202 semnil5202 deleted the refactor/dependency-bookmark branch February 9, 2024 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
BE 백엔드 관련 이슈 refactor 리팩토링 관련 이슈
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants