QueryDSL 사용한 동적 쿼리 - Page,Slice 사용법

이진우·2024년 5월 3일
0

스프링 학습

목록 보기
32/41

개요

QueryDsl 에 대해 학습을 하였으므로 상황을 가정함으로써 앞으로 잘 쓸 수 있도록 준비를 해보겠다.

상황 가정

  • 소개팅 앱 개발
  • Member 와 Team 이 존재, 다대일 관계
  • Member 는 age , name , id 가 존재하고 team 은 quality, Name, id 가 존재
  • 사용자는 Member 에 대해 검색할 때 사용자의 나이, 이름, 그 사용자가 속한 team 의 quality , name 으로 검색 이 가능 하다.
  • 단 모두 조건으로 주는 것이 아니라 사용자가 원하는 것만 조건으로 준다. (동적으로 준다)
  • Slice 방식 과 Page 방식 모두 사용해야 한다.
  • 사용자가 검색 했을 때 정렬을 위해서 age, teamName, memberName 에 대해서 정렬이 가능해야 한다.
  • 모든 사용자는 Team 에 속해 있다고 가정한다.
  • 프론트에게 반환하는 정보는 memberId,teamId, memberName, teamName,teamQuality,memberAge 이다.
  • Slice 방식에는 다음 페이지가 있는지, Page 방식에는 total 의 개수와 , 전체 page 의 개수를 함께 반환한다.

코드 작성

build.gradle 에 세팅은 모두 되어있다고 가정한다.

설정 파일

QueryDslConfig

@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {
    private final EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em){
        return new JPAQueryFactory(em);
    }
}

쿼리 Dsl 에 필요한 JPAQueryFactor 를 빈으로 등록하기 위해 설정해준다.

initMemberAndTeam

@Component
@RequiredArgsConstructor
public class initMemberAndTeam {

    private final InitMemberAndTeamService initMemberService;

    @PostConstruct
    public void init(){
        initMemberService.init();
    }

    @Component
    static class InitMemberAndTeamService{
        @PersistenceContext
        EntityManager em;

        @Transactional
        public void init(){
            Team teamA = Team.builder().quality(3).name("teamA").build();
            Team teamB = Team.builder().quality(1).name("teamB").build();
            Team teamC = Team.builder().quality(4).name("teamC").build();
            Team teamD = Team.builder().quality(2).name("teamD").build();
            Team teamE = Team.builder().quality(6).name("teamE").build();
            Team teamF = Team.builder().quality(5).name("teamF").build();

            em.persist(teamA);
            em.persist(teamB);
            em.persist(teamC);
            em.persist(teamD);
            em.persist(teamE);
            em.persist(teamF);

            for(int i=0;i<100;i++){
                Team team = null;
                switch (i%6){
                    case 0:
                        team=teamA;
                        break;
                    case 1:
                        team=teamB;
                        break;
                    case 2:
                        team=teamC;
                        break;
                    case 3:
                        team=teamD;
                        break;
                    case 4:
                        team=teamE;
                        break;
                    case 5:
                        team=teamF;
                        break;
                }

                Member member = Member.builder().name("member"+i).age(100-i).team(team).build();
                em.persist(member);

            }
        }

    }

}

실제로 정렬이나 동적 쿼리가 잘 작동되는지 확인을 위한 클래스이다.
Member 와 Team 에 대한 데이터를 넣어준다.

도메인

Member

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member extends BaseEntity {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @Column(nullable = false)
    private String name;



    @Column(nullable = false)
    private int age;



    @Builder
    public Member(String name,int age,Team team){
        this.name=name;
        this.team=team;
        this.age =age;
    }


    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}
  • Member 와 팀은 다대일 관계이므로 @JoinColumn 으로 다대일 세팅을 해준다.

Team

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Team {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;

   private String name;

   private int quality;


   @Builder
   public Team(String name, int quality){
       this.name=name;
       this.quality = quality;
   }

   @OneToMany(mappedBy = "team",cascade = CascadeType.ALL,orphanRemoval = true)
   private List<Member> members=new ArrayList<>();

}
  • 양방향 관계를 위해 세팅을 해줬지만 안해줘도 상관없다.

DTO

MemberResposneWithTeamDto

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class MemberResponseWithTeamDto {
    private Long memberId;
    private Long teamId;
    private String memberName;

    private String teamName;

    private int teamQuality;

    private int memberAge;

    public static MemberResponseWithTeamDto of(Member member){
        return new MemberResponseWithTeamDto(member.getId(),member.getTeam().getId(),member.getName(),member.getTeam().getName(),
                member.getTeam().getQuality(),member.getAge());
    }

}

공통적으로 프론트에게 반환하는 클래스 이다 .

MemberSearch

@Data
public class MemberSearch {
    private Integer teamQuality;
    private Integer minAge;
    private Integer maxAge;
    private String memberName;
    private String teamName;

}

사용자가 동적으로 검색할 때 활용하는 class 이다. 일단 request dto 와 비슷한 역할을 한다고 생각한다.

원시타입이 아닌 Integer 이런 식으로 준 이유는

밑에서 Null 체크를 하기 때문이다.

MemberSearchResponseWithPageDto

@Getter
@AllArgsConstructor(access = AccessLevel.PUBLIC)
public class MemberSearchResponseWithPageDto {

    private long totalCount;

    private List<MemberResponseWithTeamDto> memberResponseWithTeamDtoList = new ArrayList<>();

    private int totalPage;

}

Page 방식을 반환하기를 원할 때 실제 내용과 총 내용의 개수, 총 page 의 개수를 반환하였기에 위와 같이 클래스를 설정해준다.

MemberSearchResponseWithSliceDto

@Getter
@AllArgsConstructor(access = AccessLevel.PUBLIC)
public class MemberSearchResponseWithSliceDto {
    private boolean hasNextPage;

    private List<MemberResponseWithTeamDto> memberResponseWithTeamDtoList = new ArrayList<>();
}

마찬가지로 Slice 방식은 다음 페이지가 있는 지 여부와 실제 내용을 함께 반환하기에 위와 같이 클래스를 설정한다.

Repository

Interface-MemberCustomRepository

public interface MemberCustomRepository {

    public Slice<Member> showMemberWithSlice(MemberSearch memberSearch, Pageable pageable);

    public Page<Member> showMemberWithPage(MemberSearch memberSearch,Pageable pageable);

}

구현체 - MemberCustomRepositoryImpl

@RequiredArgsConstructor
public class MemberCustomRepositoryImpl implements MemberCustomRepository{

    private final JPAQueryFactory queryFactory;
    private static final String MEMBER_NAME="memberName";
    private static final String TEAM_NAME="teamName";

    private static final String AGE="age";
    private static final String QUALITY = "quality";


    @Override
    public Page<Member> showMemberWithPage(MemberSearch memberSearch, Pageable pageable) {
        List<Member> content = queryFactory
                .selectFrom(member)
                .join(member.team,team).fetchJoin()
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),
                        teamQualityEq(memberSearch.getTeamQuality()),
                        ageLoe(memberSearch.getMaxAge()),
                        ageGoe(memberSearch.getMinAge()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(memberSort(pageable))
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(member.count())
                .from(member)
            //    .join(member.team,team).fetchJoin()
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),
                        teamQualityEq(memberSearch.getTeamQuality()),
                        ageLoe(memberSearch.getMaxAge()),
                        ageGoe(memberSearch.getMinAge()));

        Long count = queryFactory
                .select(member.count())
                .from(member)
                //    .join(member.team,team).fetchJoin()
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),
                        teamQualityEq(memberSearch.getTeamQuality()),
                        ageLoe(memberSearch.getMaxAge()),
                        ageGoe(memberSearch.getMinAge()))
                .fetchOne();


        return new PageImpl<>(content,pageable,count);
       // return PageableExecutionUtils.getPage(content,pageable,countQuery::fetchOne);

    }

    @Override
    public Slice<Member> showMemberWithSlice(MemberSearch memberSearch, Pageable pageable) {
        List<Member> memberList = queryFactory
                .selectFrom(member)
                .join(member.team, team).fetchJoin()
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),
                        teamQualityEq(memberSearch.getTeamQuality()),
                        ageLoe(memberSearch.getMaxAge()),
                        ageGoe(memberSearch.getMinAge()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize()+1)
                .orderBy(memberSort(pageable))
                .fetch();

        return checkEndPage(memberList,pageable);

    }


    private Slice<Member> checkEndPage(List<Member> memberList,Pageable pageable){
        boolean hasNext = false;
        System.out.println("result 의 size"+memberList.size());
        System.out.println("pageable 의 getPageSize()"+pageable.getPageSize());
        if(memberList.size()> pageable.getPageSize()){
            hasNext = true;
            memberList.remove(pageable.getPageSize());
        }

        return new SliceImpl<>(memberList,pageable,hasNext);
    }


    private BooleanExpression usernameEq(String username){
        return username ==null?null:member.name.eq(username);
    }

    private BooleanExpression teamNameEq(String teamName){
        return teamName==null?null:team.name.eq(teamName);
    }
    private BooleanExpression teamQualityEq(Integer quality){
        return quality == null ? null: member.team.quality.eq(quality);
    }

    private BooleanExpression ageLoe(Integer ageLoe){
        return ageLoe == null ? null : member.age.loe(ageLoe);
    }

    private BooleanExpression ageGoe(Integer ageGoe){
        return ageGoe == null ? null : member.age.goe(ageGoe);
    }

    private OrderSpecifier<?> memberSort(Pageable pageable){
        if(!pageable.getSort().isEmpty()){

            for(Sort.Order order: pageable.getSort()){
                Order direction  = order.getDirection().isAscending()? Order.ASC:Order.DESC;

                switch (order.getProperty()){

                    case MEMBER_NAME:
                        return new OrderSpecifier(direction,member.name);

                    case TEAM_NAME:
                        return new OrderSpecifier(direction,member.team.name);

                    case AGE:
                        return new OrderSpecifier(direction,member.age);

                    case QUALITY:
                        return new OrderSpecifier(direction,team.quality);
                }
            }
        }

        return new OrderSpecifier(Order.ASC,member.id);
    }


}

메서드- OrderSpecifier<?> memberSort(Pageable pageable)

이 메서드는 Slice 방식과 Page 방식 모두 정렬 을 위해서 사용해야 하는 클래스이다.

private OrderSpecifier<?> memberSort(Pageable pageable){
        if(!pageable.getSort().isEmpty()){

            for(Sort.Order order: pageable.getSort()){
                Order direction  = order.getDirection().isAscending()? Order.ASC:Order.DESC;

                switch (order.getProperty()){

                    case MEMBER_NAME:
                        return new OrderSpecifier(direction,member.name);

                    case TEAM_NAME:
                        return new OrderSpecifier(direction,member.team.name);

                    case AGE:
                        return new OrderSpecifier(direction,member.age);

                    case QUALITY:
                        return new OrderSpecifier(direction,team.quality);
                }
            }
        }

        return new OrderSpecifier(Order.ASC,member.id);
    }

처음에 sort 에 대한 정보가 들어왔는지 판단한다. 만약 Sort 에 대한 정보가 들어오지 않았다면 기본적인 정렬 방식인 member의 id 에 대해 증가하는 방식으로 반환했다.

Null 을 반환하고 싶지만 그러면 에러가 나기 때문에 (위에서 사용되는 OrderBy(Null) 이 불가) 설정해 주었다.

나머지 if 문 안에 있는 코드는 정렬 방식을 받고 그 정렬 방식의 필드, direction 에 따라 OrderSpecifier 의 정렬 필드, dirction 을 설정해준다.

case 다음 문에는 클래스 상단에 static final 로 설정 해주었다.

메서드 - BooleanExpression 들

  private BooleanExpression usernameEq(String username){
        return username ==null?null:member.name.eq(username);
    }

    private BooleanExpression teamNameEq(String teamName){
        return teamName==null?null:team.name.eq(teamName);
    }
    private BooleanExpression teamQualityEq(Integer quality){
        return quality == null ? null: member.team.quality.eq(quality);
    }

    private BooleanExpression ageLoe(Integer ageLoe){
        return ageLoe == null ? null : member.age.loe(ageLoe);
    }

    private BooleanExpression ageGoe(Integer ageGoe){
        return ageGoe == null ? null : member.age.goe(ageGoe);
    }

BooleanExpressionWhere 절에 넣어 주고 , 로 연결 시킬 수 있다.

이 과정에서 BooleanExpression 의 반환값이 Null 이라면 Where 절에서 무시가 되기 때문에 동적으로 쿼리를 발생시킬 수 있는 원동력이 된다.

age=null & memberName= jinu 가 아니라
memberName = jinu 만 where 절에 넣을 수 있다는 말이다.

Slice - 메서드

@Override
    public Slice<Member> showMemberWithSlice(MemberSearch memberSearch, Pageable pageable) {
        List<Member> memberList = queryFactory
                .selectFrom(member)
                .join(member.team, team).fetchJoin()
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),
                        teamQualityEq(memberSearch.getTeamQuality()),
                        ageLoe(memberSearch.getMaxAge()),
                        ageGoe(memberSearch.getMinAge()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize()+1)
                .orderBy(memberSort(pageable))
                .fetch();

        return checkEndPage(memberList,pageable);

    }


    private Slice<Member> checkEndPage(List<Member> memberList,Pageable pageable){
        boolean hasNext = false;
        System.out.println("result 의 size"+memberList.size());
        System.out.println("pageable 의 getPageSize()"+pageable.getPageSize());
        if(memberList.size()> pageable.getPageSize()){
            hasNext = true;
            memberList.remove(pageable.getPageSize());
        }

        return new SliceImpl<>(memberList,pageable,hasNext);
    }

Slice 방식은 전체 count 쿼리가 필요없는 무한 스크롤 방식에 주로 사용된다.

원래 스프링 data jpa 에서 Slice 를 이용했을 떄 자동으로 hasNextPage 를 가져와서 사용하면 이게 다음 페이지가 있는지 확인이 가능했는데

쿼리 Dsl 에서는 직접 이 과정을 해줘야 한다고 한다.

.offset(pageable.getOffset())
                .limit(pageable.getPageSize()+1)
                .orderBy(memberSort(pageable))
                .fetch();
  • pageable.getOffset() 을 통해서 시작 위치를 가져올 수 있다.
    이는 프론트 에서 쿼리가 page=X 이렇게 온다면

pageable.getOffset() 의 값은 X 곱하기 pageSize 값을 반환한다.

디폴트가 size 가 20이고, page =3 이라면
60을 반환할 것이다. 아무튼 시작 지점을 반환한다.

  • limit(pageable.getPageSize()+1) 을 해주는 이유는 실제 요청한 것보다 limit 를 1증가시켜 다음 페이지가 있는지 확인하기 위한 작업이다.

  • order by 는 아까 그 만든 메서드를 넣어준다 .

 private Slice<Member> checkEndPage(List<Member> memberList,Pageable pageable){
        boolean hasNext = false;
        System.out.println("result 의 size"+memberList.size());
        System.out.println("pageable 의 getPageSize()"+pageable.getPageSize());
        if(memberList.size()> pageable.getPageSize()){
            hasNext = true;
            memberList.remove(pageable.getPageSize());
        }

        return new SliceImpl<>(memberList,pageable,hasNext);
    }
  • memberList의 size가 pageable.getPageSize() 보다 크다는 의미는 무엇일까 ?
    반대로 생각해보자.
    memberList 의 size가 pageable.getPageSize() 보다 같거나 작다는 의미는
    실제 조회하려고 한 개수 (getPageSize() ) 보다 실제 나온 개수(memberList.getSize() (아까 1 더해준 것) 가 같거나 작다는 의미이다.
    그 말은 다음 페이지가 없다는 뜻이다. 위 if 문은 다음 페이지가 있다는 뜻이므로 hasNext 를 true 로 설정하고 아까 limit 절에 하나씩 더 추가 했기에 마지막 하나를 제거한다.

Page - 메서드

   @Override
    public Page<Member> showMemberWithPage(MemberSearch memberSearch, Pageable pageable) {
        List<Member> content = queryFactory
                .selectFrom(member)
                .join(member.team,team).fetchJoin()
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),
                        teamQualityEq(memberSearch.getTeamQuality()),
                        ageLoe(memberSearch.getMaxAge()),
                        ageGoe(memberSearch.getMinAge()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(memberSort(pageable))
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(member.count())
                .from(member)
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),
                        teamQualityEq(memberSearch.getTeamQuality()),
                        ageLoe(memberSearch.getMaxAge()),
                        ageGoe(memberSearch.getMinAge()));

        Long count = queryFactory
                .select(member.count())
                .from(member)
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),
                        teamQualityEq(memberSearch.getTeamQuality()),
                        ageLoe(memberSearch.getMaxAge()),
                        ageGoe(memberSearch.getMinAge()))
                .fetchOne();


        return new PageImpl<>(content,pageable,count);
       // return PageableExecutionUtils.getPage(content,pageable,countQuery::fetchOne);

    }
    

Page는 Slice 와 다르게 total 개수 에 대한 count 쿼리가 날라간다.
이를 직접 구현해준다. (
QueryResults 를 반환형으로 받으면 (끝에 .fetchResults()를 붙이면 됨)
손쉽게 results.getTotal() 로 Count 값을 구할 수 있으나

1) 불필요한 조인이 발생할 수 있다는 점
2) fetchResults 는 deprecated 되었다는 점

에서 직접 total 을 구한다(count 쿼리로)

    Long count = queryFactory
                .select(member.count())
                .from(member)
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),
                        teamQualityEq(memberSearch.getTeamQuality()),
                        ageLoe(memberSearch.getMaxAge()),
                        ageGoe(memberSearch.getMinAge()))
                .fetchOne();

이를 PageImpl 에 넣어주면 우리가 평소에 쓰던 스프링 data jpa 를 이용해서 쓰던 그 방식을 사용할 수 있다.

하지만 PageableExecutionUtils.getPage 를 사용한다면

페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때 ,
마지막 페이지일때 (마지막 페이지이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때)

쿼리를 발생시키지 않으면서 조금이라도 더 count 쿼리를 줄여 효율적으로 사용이 가능하다.

interface - memberRepository

public interface MemberRepository extends JpaRepository<Member,Long>,MemberRepositoryCustom {
   List<Member> findMemberByUsername(String username);

}

위 처럼 extends 를 사용해서 우리가 만든 메서드를 MemberRepository 를 사용해서 쓸수 있게 한다.

아니면 너무 쿼리 DSL 에 종속되는 경우 걍 따로 QueryDsl 리포지토리일반 스프링 Data jpa 리포지토리 를 분리해도 상관 없다.

Service & Controller

MemberService

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class MemberService {
    private final MemberRepository memberRepository;


    public MemberSearchResponseWithSliceDto showMemberSearchWithSlice(MemberSearch memberSearch,
            Pageable pageable){
        Slice<MemberResponseWithTeamDto> memberSlice = memberRepository.showMemberWithSlice(memberSearch,pageable)
                .map(member -> MemberResponseWithTeamDto.of(member));


        return new MemberSearchResponseWithSliceDto(memberSlice.hasNext(),memberSlice.getContent());
    }

    public MemberSearchResponseWithPageDto showMemberSearchWithPage(MemberSearch memberSearch,
            Pageable pageable){
        Page<MemberResponseWithTeamDto> memberpage = memberRepository.showMemberWithPage(memberSearch,pageable)
                .map(member -> MemberResponseWithTeamDto.of(member));


        return new MemberSearchResponseWithPageDto(memberpage.getTotalElements(),memberpage.getContent(),memberpage.getTotalPages());
    }

}

MemberController

@RestController
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;


    @GetMapping("/api/v1/members/s")
    public MemberSearchResponseWithSliceDto memberSearchResponseWithSliceDto(MemberSearch memberSearch,
            Pageable pageable){
        return memberService.showMemberSearchWithSlice(memberSearch,pageable);
    }

    @GetMapping("/api/v1/members/p")
    public MemberSearchResponseWithPageDto memberSearchResponseWithPageDto(MemberSearch memberSearch,
            Pageable pageable){
        return memberService.showMemberSearchWithPage(memberSearch,pageable);
    }


}

그냥 반환해서 확인하면 된다 .

Postman 을 통한 테스트

15세 이상 30살 이하를 조회하고 이름은 상관없고, 사용자의 팀의 퀄리티가 3이면 좋겠어요. 나이 적은 순서대로 정렬 해주세요ㅎ

저는 조건 없이 모든 사람들을 조회하고 싶어요!

등등 모든 것이 가능하다.

참고

DTO 로 조회

List<Member> 로 리포지토리에서 데이터를 받으면
DTO 에 포함되지 않는 다른 것도 조회를 해야 한다.

이에 대해 성능이 나빠질 가능성이 있다 .

이런 문제를 해결하기 위해서
쿼리 Dsl 에서는 리포지토리에서 DTO 로 조회가 가능하다. (물론 spring data jpa 도)

필드 직접 접근

List<MemberDto> result = queryFactor
.select(Projections.field(MemberDto.class,member.username.as("name"), member.age))
from(member)
.fetch()

//별칭이 다를 때 as("name") 을 붙여야 함

생성자 사용


List<MemberDto> result = queryFactor
.select(Projections.constructor(MemberDto.class,member.username.as("name"), member.age))
from(member)
.fetch()

@QueryProjectino

혹은 DTO 안에 생성자에 @QueryProjection 을 붙이면 더 깔끔하게 생성이 가능하나 DTO 가 QueryDsl 에 의존하게 되므로 나는 안쓸 것 같다.

만약 Team 이 없는 Member 가 있다면??

이럴 때는 당연하게도 Team 과 연관되지 않은 Member 의 모습이 보이지 않는다 .

Init Service 의 마지막 에 아래 코드를 추가한다.

                break;
                    case 4:
                        team=teamE;
                        break;
                    case 5:
                        team=teamF;
                        break;
                }

                Member member = Member.builder().name("member"+i).age(100-i).team(team).build();
                em.persist(member);

            }
            Member member = Member.builder().name("member3000").age(3500).build();
            em.persist(member);
        }

마지막 member 는 team 에 대해 연관관계가 없어서 그냥 join 을 사용하면 아래와 같이 보이지 않는다.

이는 left join 으로 해결이 가능하다 .

 @Override
    public Page<Member> showMemberWithPage(MemberSearch memberSearch, Pageable pageable) {
        List<Member> content = queryFactory
                .selectFrom(member)
                .leftJoin(member.team,team).fetchJoin()
                .where(usernameEq(memberSearch.getMemberName()),
                        teamNameEq(memberSearch.getTeamName()),

profile
기록을 통해 실력을 쌓아가자

0개의 댓글