관리 메뉴

제뉴어리의 모든것

태그 (Tag) 를 사용한 게시물 검색과 네이티브 쿼리 페이지네이션 본문

Spring Boot/JPA

태그 (Tag) 를 사용한 게시물 검색과 네이티브 쿼리 페이지네이션

제뉴어리맨 2022. 11. 7. 01:13

전체 항목

  • 구현하려는 기능의 설명
  • 사전 규칙
  • 구현 
  • 결론

구현하려는 기능의 설명

아래의 사진처럼 게시물에 작성자가 검색을 위한 태그를 설정하였고,

검색창에서 태그를 입력하면 해당 태그가 설정된 게시물들을 최신 등록순으로 게시물을 페이지네이션 해주는 기능이다. 

 


사전 규칙

  • 클라이언트측
    tag를 쿼리파라미터 (쿼리스트링) 의 형태로 서버로 전송한다.

    ex ) 서버로 전송하는 api
    http://localhost:8080/api/questions/search?page=1&size=15&tag=안뇽&tag=얼씨구

  • 서버측

1.

MySQL DB에 Question 테이블 정의 내용
중요한것은 tags 이다

아래와 같이 태그는 |1번태그||2번태그| 와 같은 형태로 넣을 것이다. (물론 앞서 말했듯이, 태그는 사전 정렬이 된다)

|1번태그||2번태그|

와 같이 저장을 하는것인가 궁금할것이다.

그것은, 태그를 조건으로 달아서 select 해오는 쿼리문 때문이다.

 

만약 태그로 java, javascript가 들어왔다고 해보자.

각 태그마다의 구분자없이 javajavascript 이런식으로 필드에 들어갔다고 해보자.

이때의 문제점은

 

사용자가 질문 리스트를 불어올때, 태그 키워드를 구분할 수 없다.

사용자는 java, javascript 이렇게 2개의 키워드를 입력했지만,

저렇게 이어서 필드에 저장한다면, javaj, avascript 로 키워드를 저장하였는지..

ja, vajavascript 로 키워드를 저장하였는지... select 해올때 알 수가없다..

그러므로 최소한 태그 키워드를 구분되어 저장되어야 한다..

그러므로 || 를 사용...

 

서버에서는 || 를 기준으로 잘 파싱하여 다시 배열 형태로 클라이언트에게 전달을 하면 된다.

 

 

 

 

2.
태그는 클라이언트에서 글 작성시 순서와 상관없이,
서버에서 사전순으로 정렬하여 DB에 저장한다.

ex )
클라이언트에서 태그를 zzzz, aaaa 순으로 등록하였다 하더라고
서버는 정렬하여 aaaa, zzzz 순으로 DB에 저장한다.

 

여기서 의문..

왜 정렬을 하여 저장하나 의문이 들것이다..

왜냐하면.. 다음과 같은 이유때문이다..

 

만약 zzzz , aaaa이렇게 2개의 태그가 입력된 질문 게시물이 있다고 해보자...

사용자 입력 그대로 저장을 한다면,,

DB의 tags 필드에는 |zzzz||aaaa|  이런식으로 저장이 될것이다.

그런데, 사용자가 태그로 질문 검색시

[aaaa][zzzz] 이렇게 두개의 키워드를 입력 하였다고 해보자.

사용자는 aaaa, zzzz 두개의 태그 키워드가 포함된 게실물을 찾는것이다.

그런데 서버에서 어떤 게시물은 태그가 |aaaa||zzzz| 로 저장되어 있고,

또다른 게시물은 태그가 |zzzz||aaaa| 로 순서만 다르게 저장되어 있다고 생각해보자.

이럴경우 태그의 조합의 모든 경우의 수인 |aaaa||zzzz| 조건과 |zzzz||aaaa| 조건으로 두번의 검색을 해줘야한다.

그리고 만약 현재는 태그가 2개인 상황이지만,,, 10개를 입력했다면 검색해야하는 경우의 수는 더욱 증가된다...

불필요한 상황이다.

그러므로, 서버에서는 저장할때도 정렬하여 저장하고,

사용자가 검색시에 입력한 태그도 서버에서는 정렬하여 조건에 달아 한개의 select 문만을 날린다.

그래서 사용자가 

만약 태그 검색시 [zzzz][aaaa] 로 검색하였다 하더라도 서버에서는 

아래와 같이 정렬한 내용으로 LIKE 키워드에 붙여서 select 해온다.

SELECT * FROM question AS q WHERE q.tags LIKE "%|aaaa|%|zzzz|%";

 


구현 

Question 엔티티

@NoArgsConstructor
@Getter
@Setter
@Entity
@Table(name = "QUESTION")
public class Question extends Auditable {

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

    @Column(nullable = false)
    private String questionTitle;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String questionContent;

    @Column(nullable = false)
    private int questionViewed;

    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    public void addMember(Member member) {
        this.member = member;
    }

    @OneToMany(mappedBy = "question", cascade = CascadeType.ALL)
    private List<Answer> answer = new ArrayList<>();

    @Column(columnDefinition = "TEXT") //서버에서는 편의를 위해 길이 제한을 최대로 풀었다. 
    private String tags;  // 중요 부분
}

 


QuestionPostDto

@Getter
@Setter
public class QuestionPostDto {

    @NotBlank
    @Length(min = 5, max = 100)
    private String questionTitle;

    @NotBlank
    @Length(min = 15)
    private String questionContent;

	//클라이언트가 쿼리파라미터에서 같은 Key로 여러 Value를 할당하기 때문에
    //클라이언트에게서 받을때는 배열로 받는다.
    private String[] tags; 
}

 

 

 

QuestionResponseDto

@Getter
@Setter
public class QuestionResponseDto {

    private Long questionId;
    private String questionTitle;
    private String questionContent;
    private int questionViewed;
    private Long memberId;
    private String memberName;
    private LocalDateTime createdAt;
    private LocalDateTime modifiedAt;
    private int answerCount;
    private String[] tags; //중요 부분
}

 

 

QuestionController (질문 등록, 검색에 의한 질문 리스트 요청 을 받는 컨트롤러)

@Validated
@RequestMapping("/api/questions")
@RestController
public class QuestionController {

    private final QuestionMapper mapper;
    private final QuestionService service;
    private final MemberMapper memberMapper;
    private final AnswerMapper answerMapper;

    public QuestionController(QuestionMapper mapper, QuestionService service, MemberMapper memberMapper, AnswerMapper answerMapper) {
        this.mapper = mapper;
        this.service = service;
        this.memberMapper = memberMapper;
        this.answerMapper = answerMapper;
    }

    @NeedMemberId //파라미터로 서버에서 인증된 MemberId가 필요한 경우 붙이면 되는 사용자 정의 어노테이션
    @PostMapping
    public ResponseEntity postQuestion(@Valid @RequestBody QuestionPostDto questionPostDto, Long authMemberId) {

        Question question = mapper.questionPostDtoToQuestion(questionPostDto, authMemberId);

        Question createdQuestion = service.createQuestion(question);

        SingleResponseDto<QuestionResponseDto> singleResponseDto =
                new SingleResponseDto<>(mapper.questionToQuestionResponseDto(createdQuestion, memberMapper));

        return new ResponseEntity<>(singleResponseDto, HttpStatus.CREATED);
    }

    @GetMapping // 전체 질문 조회
    public ResponseEntity getQuestions(@Positive @RequestParam(defaultValue = "1") int page,
                                       @Positive @RequestParam(defaultValue = "15") int size) {
        Page<Question> pageQuestions = service.findQuestions(page - 1, size);
        List<Question> questions = pageQuestions.getContent();

        MultiResponseDto<QuestionResponseDto> multiResponseDto =
                new MultiResponseDto<>(mapper.questionsToQuestionResponseDtoList(questions, memberMapper), pageQuestions);


        return new ResponseEntity(multiResponseDto, HttpStatus.OK);
    }

    @GetMapping("/search") // 검색에 의한 리스트 요청, 중요 부분
    public ResponseEntity getQuestions(@Positive @RequestParam(defaultValue = "1") int page,
                                       @Positive @RequestParam(defaultValue = "15") int size,
                                       @RequestParam("tag") String[] tag) {
        Page<Question> resultSearchTags = service.findTageQuestion(page, size, tag);
        List<QuestionResponseDto> questionResponseDtoList = mapper.questionsToQuestionResponseDtoList(resultSearchTags.getContent(), memberMapper);

        return new ResponseEntity(new MultiResponseDto<>(questionResponseDtoList, resultSearchTags),HttpStatus.OK);
    }
}

 

QuestionService

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

@Service
public class QuestionService {

    private final QuestionRepository questionRepository;

    public QuestionService(QuestionRepository questionRepository) {
        this.questionRepository = questionRepository;
    }

    public Question createQuestion(Question question){

        Question savedQuestion = questionRepository.save(question);

        return savedQuestion;
    }

	:
    :

    public Page<Question> findTageQuestion(int page, int size, String[] tags) { //중요 부분, 검색 처리

        Arrays.sort(tags);

        StringBuilder likeQueryBuilder = new StringBuilder("");

        for (int i = 0; i < tags.length; i++) {
            String temp = "%|"+ tags[i] + "|%";
            likeQueryBuilder.append(temp);
        }

//		  PageRequest 객체를 사용하지 않고, 완전히 native query로 페이지네이션을 할 수도 있다.
//        List<Question> searchQuestionList = questionRepository.getSearchQuestionList(likeQueryBuilder.toString(), page-1, size);
        PageRequest pageRequest = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "created_at"));
        Page<Question> searchQuestionList = questionRepository.getSearchQuestionList(likeQueryBuilder.toString(), pageRequest);

        System.out.println();
        return searchQuestionList;
    }
}

 

QuestionRepository

핵심 내용이다.

public interface QuestionRepository extends JpaRepository<Question, Long> {

    //count 쿼리를 넣어주지 않으면 특정 부분에서 에러가 발생한다...
    @Query(
            value = "SELECT * FROM question AS q WHERE q.tags LIKE :likeQuery",
            countQuery = "SELECT COUNT(*) FROM question AS q WHERE q.tags LIKE :likeQuery",
            nativeQuery = true)
    Page<Question> getSearchQuestionList(String likeQuery, Pageable pageable);
}

추가적으로 말하자면,

As q 와 같이 Alias 를 넣은 상황에서

countQuery를 넣지 않으면,, 아래와 같은 이상한 쿼리를 날리면서

   select
        count(q) 
    FROM
        question as q 
    where
        q.tags like ?

 

아래와 같은 에러가 발생한다...

Unknown column 'q' in 'field list'

 

그래서 alias 아예 안쓰는 방법으로도 해결이 가능하지만,

깔끔하게 countQuery를 넣어주는것이 좋은것 같다...

레퍼런스에도 countQuery를 넣도록 나와있다고 들은것 같다...

 

 

 

+ 추가내용

native query와 Pageable 을 혼용으로 사용하여 페이지네이션을 구현하였다.

참고로, 아래와 전부 native query로 해서 페이지네이션을 처리 할 수도 있을것같다..

물론 추가적으로 페이지처리에 필요한 데이터를 구해오는 처리가 필요하다..

    @Query(value = "SELECT * FROM question as q where q.tags like :likeQuery order by q.created_at DESC LIMIT :start, :size",nativeQuery = true)
    List<Question> getSearchQuestionList(String likeQuery, int start, int size);

 


결론

중요한 아이디어는,

1. 클라이언트에게서 태그를 배열 형태로 전달 받음

2. DB에 저장하기 전에 넘어 온 태그들을 정렬함

3. DB에 tag를 한 필드에 구분자를 이용하여 저장

4. tag를 검색 기준으로 질문 리스트 뽑아 올때, jpa에서 화끈하게 native query를 사용함

5. countQuery를 넣어서 에러 해결

6. 검색할때 LIKE %태그1%태그2% 방식을 사용하여 다수개의 태그를 검색

 

 

 

 


참조