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

카페 검색 기능 개발 #443

Merged
merged 19 commits into from
Sep 26, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ dependencies {
//Flyway
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'

//QueryDsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

def generatedQueryDsl = 'build/generated/querydsl'

sourceSets {
main.java.srcDirs += [generatedQueryDsl]
}

tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(generatedQueryDsl))
}

clean.doLast {
file(generatedQueryDsl).deleteDir()
}

ext {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.project.yozmcafe.config;

import com.querydsl.jpa.impl.JPAQueryFactory;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QueryDslConfig {

@PersistenceContext
private EntityManager entityManager;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.project.yozmcafe.controller.dto.cafe.CafeRankResponse;
import com.project.yozmcafe.controller.dto.cafe.CafeResponse;
import com.project.yozmcafe.controller.dto.cafe.CafeSearchRequest;
import com.project.yozmcafe.controller.dto.cafe.CafeSearchResponse;
import com.project.yozmcafe.domain.member.Member;
import com.project.yozmcafe.service.CafeService;
import org.springframework.data.domain.Pageable;
Expand Down Expand Up @@ -29,13 +31,13 @@ public CafeController(final CafeService cafeService) {

@GetMapping
public ResponseEntity<List<CafeResponse>> getCafesForLoggedInMember(final Member member) {
List<CafeResponse> cafeResponses = cafeService.getCafesForLoginMember(member, PAGE_SIZE);
final List<CafeResponse> cafeResponses = cafeService.getCafesForLoginMember(member, PAGE_SIZE);
return ResponseEntity.ok(cafeResponses);
}

@GetMapping("/guest")
public ResponseEntity<List<CafeResponse>> getCafesForUnLoggedInMember(@PageableDefault(size = PAGE_SIZE) final Pageable pageable) {
List<CafeResponse> cafeResponses = cafeService.getCafesForUnLoginMember(pageable);
final List<CafeResponse> cafeResponses = cafeService.getCafesForUnLoginMember(pageable);
return ResponseEntity.ok(cafeResponses);
}

Expand All @@ -47,9 +49,15 @@ public ResponseEntity<List<CafeRankResponse>> getCafesOrderByLikeCount(@Pageable

@GetMapping("/{cafeId}")
public ResponseEntity<CafeResponse> getCafeById(@PathVariable("cafeId") final long cafeId) {
CafeResponse cafeResponse = cafeService.getCafeById(cafeId);
final CafeResponse cafeResponse = cafeService.getCafeById(cafeId);
return ResponseEntity.ok(cafeResponse);
}

@GetMapping("/search")
public ResponseEntity<List<CafeSearchResponse>> getCafeBySearch(final CafeSearchRequest cafeSearchRequest) {
final List<CafeSearchResponse> cafeSearchResponses = cafeService.getCafesBySearch(cafeSearchRequest);
return ResponseEntity.ok(cafeSearchResponses);
}
}


Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.project.yozmcafe.controller;

import com.project.yozmcafe.controller.dto.cafe.LikedCafeResponse;
import com.project.yozmcafe.controller.dto.cafe.LikedCafeThumbnailResponse;
import com.project.yozmcafe.controller.dto.cafe.CafeThumbnailResponse;
import com.project.yozmcafe.domain.member.Member;
import com.project.yozmcafe.service.LikedCafeService;
import org.springframework.data.domain.Pageable;
Expand All @@ -27,9 +27,9 @@ public LikedCafeController(final LikedCafeService likedCafeService) {
}

@GetMapping("/members/{memberId}/liked-cafes")
public ResponseEntity<List<LikedCafeThumbnailResponse>> getLikedCafeThumbnails(@PathVariable("memberId") final String memberId,
@PageableDefault(size = PAGE_SIZE) final Pageable pageable) {
final List<LikedCafeThumbnailResponse> likedCafes = likedCafeService.findLikedCafeThumbnailsByMemberId(memberId, pageable);
public ResponseEntity<List<CafeThumbnailResponse>> getLikedCafeThumbnails(@PathVariable("memberId") final String memberId,
@PageableDefault(size = PAGE_SIZE) final Pageable pageable) {
final List<CafeThumbnailResponse> likedCafes = likedCafeService.findLikedCafeThumbnailsByMemberId(memberId, pageable);

return ResponseEntity.ok(likedCafes);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.project.yozmcafe.controller.dto.cafe;

public record CafeSearchRequest(String cafeName, String menu, String address) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.project.yozmcafe.controller.dto.cafe;

import com.project.yozmcafe.domain.cafe.Cafe;

public record CafeSearchResponse(Long id, String name, String address, String image, int likeCount) {
public static CafeSearchResponse from(final Cafe cafe) {
return new CafeSearchResponse(
cafe.getId(),
cafe.getName(),
cafe.getAddress(),
cafe.getRepresentativeImage(),
cafe.getLikeCount()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.project.yozmcafe.controller.dto.cafe;

import com.project.yozmcafe.domain.cafe.Cafe;

public record CafeThumbnailResponse(Long cafeId, String imageUrl) {

public static CafeThumbnailResponse from(final Cafe cafe) {
return new CafeThumbnailResponse(cafe.getId(), cafe.getRepresentativeImage());
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.project.yozmcafe.domain.cafe;

import java.util.List;

public interface CafeCustomRepository {

List<Cafe> findAllBy(final String cafeName, final String menu, final String address);

List<Cafe> findAllBy(final String cafeName, final String address);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.project.yozmcafe.domain.cafe;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.StringPath;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.springframework.stereotype.Repository;

import java.util.List;

import static com.project.yozmcafe.domain.cafe.QCafe.cafe;
import static com.project.yozmcafe.domain.menu.QMenu.menu;
import static com.querydsl.core.types.dsl.Expressions.numberTemplate;
import static io.micrometer.common.util.StringUtils.isBlank;

@Repository
public class CafeCustomRepositoryImpl extends QuerydslRepositorySupport implements CafeCustomRepository {

private static final double MATCH_THRESHOLD = 0.0;
private static final String MATCH_LITERALLY_OPERATOR = "\"";
Copy link
Collaborator

Choose a reason for hiding this comment

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

DOUBLE_QUOTES로 하거나 상수화를 안하는 것도 오히려 좋을 것 같은데 어떤가요??
MySQL Match 문법용 연산자인가 생각했는데 단순 문자열인 것 같아서요!

Copy link
Collaborator Author

@hum02 hum02 Sep 25, 2023

Choose a reason for hiding this comment

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

boolean모드에서 " operator를 통해 literal하게 문자열 그대로 포함하는 것을 검색하는 것
에 대해 의미를 드러내고자 했는데 쉽지 않네요....
상수화를 하지 않겠습니다.

그렇다고 functionContributor쪽에서 " 를 끼워 넣는것은 operator가 추가되었다는 인지를 힘들게 할 것 같아 하고싶지 않네요


public CafeCustomRepositoryImpl() {
super(Cafe.class);
}

public List<Cafe> findAllBy(final String cafeNameWord, final String menuWord, final String addressWord) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

아직 쿼리 dsl을 학습하지 않아서 잘은 모르겠지만 제가 이번에 근로 학습을 하면서 봤던 것은 지금처럼 cafeNameWord, menuWord, addressWord에 따라 메소드 오버로딩을 할 필요 없이 한 메소드를 통해서 진행할 수 있었는데 지금은 불가한 것일까요 ?

왜 오버로딩했는지가 궁금합니다! 단순하게 leftJoin, innerJoin 때문에 분리한건지도요 !

Copy link
Collaborator Author

@hum02 hum02 Sep 19, 2023

Choose a reason for hiding this comment

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

오버로딩 했던 이유가 menu검색을 하지 않을 경우에는 cafe엔티티만으로 검색이 가능해서에요!
그래서 분기문으로 menu검색이 없을 때는 불필요한 조인을 하지 않도록 하려 했습니다.

그런데 불필요한 join이라면 db측의 옵티마이저가 알아서 최적화해주지 않을까? 하는 생각도 들어서 50000개 정도의 카페에 메뉴2개씩 저장해서 테스트 해봤어요
image
image

알아서 최적화 해주지는 않네요.. join시 메뉴 개수만큼 곱해져서 조회되기에 그만큼의 성능 차이가 납니다.
결론은 leftjoin,inner join이냐의 문제보다는 join이 필요없을 때는 안하는게 게 성능 상 좋아서 분리하려해요!

return from(cafe)
.innerJoin(menu).on(menu.cafe.eq(cafe))
.where(
contains(cafe.name, cafeNameWord),
contains(menu.name, menuWord),
contains(cafe.address, addressWord))
.fetch();
}

public List<Cafe> findAllBy(final String cafeNameWord, final String addressWord) {
return from(cafe)
.where(
contains(cafe.name, cafeNameWord),
contains(cafe.address, addressWord))
.fetch();
}

private BooleanExpression contains(final StringPath target, final String searchWord) {
if (isBlank(searchWord)) {
return null;
}

final String literalSearchWord = MATCH_LITERALLY_OPERATOR + searchWord + MATCH_LITERALLY_OPERATOR;
Copy link
Collaborator

Choose a reason for hiding this comment

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

이 부분이 무슨 뜻인지 잘 모르겠어요ㅠㅠ

searchWordliteralSearchWord가 둘다 그냥 문자 같은데 무슨 차이인지...? 네이밍에서 잘 드러나지 않는다고 생각해요...!

Copy link
Collaborator Author

@hum02 hum02 Sep 25, 2023

Choose a reason for hiding this comment

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

습 저는 상수화를 통해 가독성을 개선했다 생각했는데 오히려 이해를 어렵게 했군요..
MATCH_LITERALLY_OPERATOR 는 상수화 하지 않고
literalSearchWord 라는 변수명은 formattedSearchWord로 수정하려합니다!


return numberTemplate(Double.class, "function('match_against', {0}, {1})",
target, literalSearchWord).gt(MATCH_THRESHOLD);
Copy link
Collaborator

Choose a reason for hiding this comment

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

gt() 같은 메서드 체이닝은 개행하면 더 좋을 것 같아요...!

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import java.util.List;

public interface CafeRepository extends JpaRepository<Cafe, Long> {
public interface CafeRepository extends JpaRepository<Cafe, Long>, CafeCustomRepository {
Copy link
Collaborator

Choose a reason for hiding this comment

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

저도 이런 구조를 생각했었는데 반영 👍


Slice<Cafe> findSliceBy(Pageable pageable);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.project.yozmcafe.domain.cafe;

import org.hibernate.boot.model.FunctionContributions;
import org.hibernate.boot.model.FunctionContributor;

import static org.hibernate.type.StandardBasicTypes.DOUBLE;

public class CustomFunctionContributor implements FunctionContributor {

private static final String FUNCTION_NAME = "match_against";
private static final String FUNCTION_PATTERN = "match (?1) against (?2 in boolean mode)";

Comment on lines +8 to +12
Copy link
Collaborator

Choose a reason for hiding this comment

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

RepositoryImpl에서 match_against라는 것을 봤을 때 이게 뭐지?? 싶었는데 패턴 매칭으로 여기서 함수를 정의하고 있었군요.

match against 문을 바로 쓰지 않고 함수화하는 이유가 궁금합니다.
어떤 이점이 있나요??

Copy link
Collaborator Author

@hum02 hum02 Sep 25, 2023

Choose a reason for hiding this comment

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

저도 가능하면 특화구문을 최소한의 의존성으로 사용하고 싶어요!

 private BooleanExpression contains(final StringPath target, final String searchWord) {
        if (isBlank(searchWord)) {
            return null;
        }
        return booleanTemplate("match({0}) against ({1} in boolean mode) > 0", target, searchWord);
    }

이런 식으로 match against문을 바로 쓰는 것을 생각하신 게 맞나요?
이렇게 사용할 시 hql이 이런 특화 구문을 지원해주지 않아 hibernate에서 parsing할 때 exception이 발생합니다 ㅜ.
image

그렇다고 native query사용을 위해 entityManager를 직접 사용하면 querydsl을 통한 동적쿼리의 의미가 없어지는 것 같고....
때문에 함수 등록을 하는 방법을 택했습니다! 다른 방법이 있을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

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

query dsl은 말 그대로 DSL로 편하게 동적 쿼리를 작성하기 위함이고 native query를 사용하는 것은 피할 수 없다고 생각해요.
필요하면 JDBC Template을 쓰거나, Entity Manager로 날리거나 하는 것이 당연하다고 생각합니다!

근데 만약 여기서 Entity Manager를 사용하면 좀 더 추상객체(Entity Manager)의 의존만으로 처리할 수 있지 않나 해서 남겼어요!
어떤 장점 때문에 했는지 궁금해서요.

native query가 필요할 때마다 native query가 아닌 Query DSL로 하실 것인가요??

Copy link
Collaborator

Choose a reason for hiding this comment

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

아 Entity Manager로 변경하라는 뜻은 아닙니다!
지금 그대로도 좋아요!! 단순 궁금증입니다.

Copy link
Collaborator Author

@hum02 hum02 Sep 26, 2023

Choose a reason for hiding this comment

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

querydsl의 where에 native쿼리가 들어가야 처음 querydsl을 쓸 때 의도했던 대로
하나의 쿼리 template으로 동적인 쿼리가 가능하다 생각해요!

Entity Manager를 쓰면서 이를 querydsl에 적용하려면 EntityManager의 쿼리 형식을 booleanExpression으로 바꿔야 하는 데 그게 가능한지 모르겠네요...

@Override
public void contributeFunctions(final FunctionContributions functionContributions) {
functionContributions.getFunctionRegistry()
.registerPattern(FUNCTION_NAME, FUNCTION_PATTERN,
functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(DOUBLE));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import jakarta.persistence.OrderBy;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static com.project.yozmcafe.exception.ErrorCode.NOT_EXISTED_LIKED_CAFE;
Expand Down Expand Up @@ -100,14 +99,13 @@ public List<UnViewedCafe> getNextUnViewedCafes(final int size) {
return result;
}

public List<LikedCafe> getLikedCafesSection(final int startIndex, final int endIndex) {
if (startIndex >= likedCafes.size()) {
return Collections.emptyList();
}

final List<LikedCafe> reverseLikedCafes = new ArrayList<>(likedCafes);
public List<Cafe> getLikedCafes(final int startIndex, final int endIndex) {
final List<LikedCafe> likedCafes = new ArrayList<>(this.likedCafes);
final List<Cafe> cafes = likedCafes.stream()
.map(LikedCafe::getCafe)
.toList();

return reverseLikedCafes.subList(startIndex, min(endIndex, likedCafes.size()));
return cafes.subList(startIndex, min(endIndex, this.likedCafes.size()));
}

public String getId() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.project.yozmcafe.controller.dto.cafe.CafeRankResponse;
import com.project.yozmcafe.controller.dto.cafe.CafeResponse;
import com.project.yozmcafe.controller.dto.cafe.CafeSearchRequest;
import com.project.yozmcafe.controller.dto.cafe.CafeSearchResponse;
import com.project.yozmcafe.domain.CafeRankGenerator;
import com.project.yozmcafe.domain.cafe.Cafe;
import com.project.yozmcafe.domain.cafe.CafeRepository;
Expand All @@ -15,6 +17,7 @@
import java.util.List;

import static com.project.yozmcafe.exception.ErrorCode.NOT_EXISTED_CAFE;
import static io.micrometer.common.util.StringUtils.isBlank;

@Service
@Transactional(readOnly = true)
Expand All @@ -24,7 +27,8 @@ public class CafeService {
private final UnViewedCafeService unViewedCafeService;
private final CafeRankGenerator cafeRankGenerator;

public CafeService(final CafeRepository cafeRepository, final UnViewedCafeService unViewedCafeService, final CafeRankGenerator cafeRankGenerator) {
public CafeService(final CafeRepository cafeRepository, final UnViewedCafeService unViewedCafeService,
final CafeRankGenerator cafeRankGenerator) {
this.cafeRepository = cafeRepository;
this.unViewedCafeService = unViewedCafeService;
this.cafeRankGenerator = cafeRankGenerator;
Expand Down Expand Up @@ -66,4 +70,19 @@ public CafeResponse getCafeById(final long cafeId) {

return CafeResponse.fromUnLoggedInUser(foundCafe);
}

public List<CafeSearchResponse> getCafesBySearch(final CafeSearchRequest searchRequest) {
final List<Cafe> cafes = searchWith(searchRequest);

return cafes.stream()
.map(CafeSearchResponse::from)
.toList();
}

private List<Cafe> searchWith(final CafeSearchRequest cafeSearchRequest) {
if (isBlank(cafeSearchRequest.menu())) {
return cafeRepository.findAllBy(cafeSearchRequest.cafeName(), cafeSearchRequest.address());
}
return cafeRepository.findAllBy(cafeSearchRequest.cafeName(), cafeSearchRequest.menu(), cafeSearchRequest.address());
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.project.yozmcafe.service;

import com.project.yozmcafe.controller.dto.cafe.CafeThumbnailResponse;
import com.project.yozmcafe.controller.dto.cafe.LikedCafeResponse;
import com.project.yozmcafe.controller.dto.cafe.LikedCafeThumbnailResponse;
import com.project.yozmcafe.domain.cafe.Cafe;
import com.project.yozmcafe.domain.cafe.CafeRepository;
import com.project.yozmcafe.domain.cafe.LikedCafe;
Expand All @@ -25,23 +25,16 @@ public LikedCafeService(final CafeRepository cafeRepository,
this.memberService = memberService;
}

public List<LikedCafeThumbnailResponse> findLikedCafeThumbnailsByMemberId(final String memberId, final Pageable pageable) {
public List<CafeThumbnailResponse> findLikedCafeThumbnailsByMemberId(final String memberId, final Pageable pageable) {
final Member member = memberService.findMemberByIdOrElseThrow(memberId);

final List<LikedCafe> likedCafes = getLikedCafes(pageable, member);
final List<Cafe> likedCafes = member.getLikedCafes((int) pageable.getOffset(), pageable.getPageSize());

return likedCafes.stream()
.map(LikedCafeThumbnailResponse::from)
.map(CafeThumbnailResponse::from)
.toList();
}

private List<LikedCafe> getLikedCafes(final Pageable pageable, final Member member) {
final int startIndex = pageable.getPageNumber() * pageable.getPageSize();
final int endIndex = startIndex + pageable.getPageSize();

return member.getLikedCafesSection(startIndex, endIndex);
}

public List<LikedCafeResponse> findLikedCafesByMemberId(final String memberId) {
final Member member = memberService.findMemberByIdOrElseThrow(memberId);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.project.yozmcafe.domain.cafe.CustomFunctionContributor
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 Author

@hum02 hum02 Sep 26, 2023

Choose a reason for hiding this comment

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

image

여기서 fuctionContirbutor를 사용하려면 sericeLoader에서 스캔하도록 META-INF경로에 파일을 만들거나
코드를 통해 등록하려면 applyFunctions등을 이용하라 안내하는데요.
첫번째 방법을 위해 serviceLoader가 스캔하는 경로에 파일을 등록했습니다.

두번 째 방법인
image
이 방식으로 해보니 여전히 직접 설정을 집어넣어야 하는 게 존재해서 이러느니 META-INF에 파일을 만드는 지금 방식이 더 낫다고 생각합니다

2 changes: 1 addition & 1 deletion server/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ spring.datasource.url=jdbc:mysql://localhost:20000/yozm-cafe?useSSL=false&allowP
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto=validate
spring.auth.key=testtesttesttesttesttesttesttesttesttesttesttesttesttesttest
spring.auth.accessTokenExpired=360000
Expand All @@ -28,3 +27,4 @@ spring.flyway.enabled=true
spring.flyway.baseline-version=20230901153300
spring.flyway.baseline-on-migrate=true
spring.flyway.out-of-order=true

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
alter table cafe add fulltext index cafe_name_idx (name) with parser ngram;

alter table cafe add fulltext index address_idx (address) with parser ngram;

alter table menu add fulltext index menu_name_idx (name) with parser ngram;
Copy link
Collaborator

Choose a reason for hiding this comment

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

이건 사실 반영 안해도 되는데,
ALTER TABLE menu 이런 식으로 다른 SQL문들처럼 대문자를 쓰면 좋을 것 같아요!

Copy link
Collaborator

Choose a reason for hiding this comment

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

음 반영해주시면 좋을것 같은 내용이라 생각해요!

Loading