[빙터뷰] 질문 기능 구현

impala·2023년 4월 14일
0
post-thumbnail

Question

사용자가 태그를 선택하고 면접 예상질문을 등록하여 면접 연습영상의 태그로 달 수 있는 기능이다. 사용자가 등록하는 면접 질문은 다른 사용자에게 공개되어 그 질문에 대한 연습영상을 올릴 수 있기 때문에 question은 한번 등록하면 수정이나 삭제가 불가능하도록 설계하였다. 또한 사용자는 원하는 질문을 스크랩해서 확인할 수 있다.

Domain

Question

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Question extends EntityDate {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "question_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
    private String content;

    @OneToMany(mappedBy = "question")
    private List<TagQuestion> tags = new ArrayList<>();

    @OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
    private List<QuestionMemberScrap> scraps = new ArrayList<>();


    @Builder
    public Question(Member member, String content) {
        this.member = member;
        this.content = content;
    }

    public void setMemberNull() {
        member = null;
    }

}

질문 엔티티는 Member엔티티와 일대다 관계와 다대다 관계를 동시에 가진다. 각 질문에는 질문을 등록한 작성자의 정보가 일대다 관계로 매핑되면서, 각 사용자들은 원하는 질문을 스크랩할 수 있기 때문에 스크랩 정보를 관리하는 중간 엔티티를 통해 회원 엔티티와 다대다 관계를 맺는다.

또한 각 질문에는 범주에 맞는 태그를 달 수 있기 때문에 질문과 태그 사이에 중간 엔티티를 두어 태그 엔티티와 다대다 관계를 맺는다.

마지막으로 등록한 질문을 삭제하는 기능은 제공하지 않지만, 논리적으로 질문이 사라지면 그 질문을 스크랩한 정보가 같이 삭제되는게 맞다고 생각하여 cascade = CascadeType.REMOVE 옵션을 통해 질문이 삭제되면 스크랩 엔티티가 같이 삭제되도록 설정하였다.

QuestionMemberScrap

@Entity
@Getter
@NoArgsConstructor
public class QuestionMemberScrap {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "question_member_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "question_id")
    private Question question;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @Builder
    public QuestionMemberScrap(Question question, Member member) {
        this.question = question;
        this.member = member;
    }

    // 연관관계 편의 메소드
    public void setQuestion(Question question) {
        this.question = question;
        question.getScraps().add(this);
    }
}

Question과 Member사이에서 스크랩 정보를 다루는 도메인이다. 질문 엔티티와 양방향 연관관계를 편리하게 매핑하기 위해 setQuestion메소드를 통해 엔티티를 등록한다.

TagQuestion

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TagQuestion {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "tag_question_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "tag_id")
    private Tag tag;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "question_id")
    private Question question;

    @Builder
    public TagQuestion(Tag tag, Question question) {
        this.tag = tag;
        this.question = question;
    }

    // 연관관계 편의 메소드
    public void setQuestion(Question question) {
        this.question = question;
        question.getTags().add(this);
    }
}

Tag와 Question사이에서 질문에 등록된 태그 정보를 다루는 엔티티이다. 질문 엔티티와 양방향 연관관계를 편리하게 매핑하기 위해 setQuestion메소드를 통해 엔티티를 등록한다.

DTO

RequestDTO

질문은 등록 이후 수정이 불가능하기 때문에 Update없이 CreateDTO만 사용할 수 있도록 설계하였다.

QuestionCreateDTO

@Data
public class QuestionCreateDTO {

    private List<Long> tags;
    private Long memberId;
    private String questionContent;
}

질문을 등록하는 사용자의 id와 질문의 내용, 질문에 달리는 태그 id들의 리스트로 구성된다.

ResponseDTO

QuestionDTO

@Data
@NoArgsConstructor
public class QuestionDTO {

    private Long questionId;
    private String questionContent;
    private LocalDateTime createTime;
    private int scrapCount;
    private int boardCount;
    private List<TagDTO> tags;

    @Builder
    public QuestionDTO(Long questionId, String questionContent, 
    					LocalDateTime createTime, int scrapCount, 
                        int boardCount, List<TagDTO> tags) {
        this.questionId = questionId;
        this.questionContent = questionContent;
        this.createTime = createTime;
        this.scrapCount = scrapCount;
        this.boardCount = boardCount;
        this.tags = tags;
    }
}

단일 질문에 대한 정보로 질문의 id값과 내용, 등록시간과 태그정보가 전달되고, 추가로 질문을 스크랩한 사용자의 수와, 이 질문에 대한 연습영상 게시글의 수를 제공한다.

QuestionListDTO

@Data
@AllArgsConstructor
public class QuestionListDTO {

    private List<QuestionDTO> questions;
}

여러 건의 질문을 전달할 때 사용하는 DTO로 리스트로 한번 감싸서 질문들의 정보를 제공한다.

Repository

QuestionRepository

public interface QuestionRepository extends JpaRepository<Question,Long> {

    List<Question> findAllByMemberId(Long memberId);

}

사용자의 활동 기록을 열람할 때 사용자가 등록한 질문의 종류를 확인할 수 있도록 설계했기 때문에 질문을 등록한 작성자의 정보로 질문을 찾는 메소드를 추가하였다.

QuestionMemberScrapRepository

public interface QuestionMemberScrapRepository extends JpaRepository<QuestionMemberScrap,Long> {

    int countByQuestion(Question question);

    @Query("select s from QuestionMemberScrap s " +
            "join fetch s.question q " +
            "where s.member.id = :memberId")
    List<QuestionMemberScrap> findAllQuestionByMemberId(@Param("memberId") Long scrapMemberId);

    Optional<QuestionMemberScrap> findByQuestionIdAndMemberId(Long questionId, Long memberId);
}

질문 정보를 사용자에게 제공할 때 질문의 스크랩 수를 함께 제공하기 때문에 countByQuestion메소드를 통해 해당 질문을 스크랩한 사용자의 수를 불러온다.

또한 비즈니스 요구사항에서 각 사용자는 본인이 스크랩한 질문을 모아서 확인할 수 있다고 정의했기 때문에 findAllQuestionByMemberId 메소드를 통해 스크랩 테이블에서 회원의 id로 해당 회원이 스크랩한 질문을 찾는다. 이때 질문의 정보는 Question엔티티에서 관리하고 있기 때문에 fetch join을 사용하여 질문에 대한 정보를 같이 불러온다.

마지막으로 사용자가 질문을 스크랩 할 때, 이미 스크랩 된 질문이라면 스크랩을 취소하도록 설계했기 때문에 findByQuestionIdAndMemberId를 통해 (질문id, 회원id)쌍으로 스크랩 정보를 기존에 스크랩한 이력이 있는지 찾는다. 이때 처음으로 스크랩을 하는 경우라면 값이 없기 때문에 Optional로 결과를 감싸 반환한다.

TagQuestionRepository

public interface TagQuestionRepository extends JpaRepository<TagQuestion,Long> {

    @Query("select tq from TagQuestion tq " +
            "join fetch tq.question q " +
            "where tq.tag.id in (:tagIds)")
    List<TagQuestion> findAllQuestionByTagId(@Param("tagIds") List<Long> tagIds);

}

질문을 검색할 때 태그id를 리스트로 받아 필터링할 수 있기 때문에 태그와 질문의 매핑정보를 담은 중간 테이블에서 태그id를 where ~ in 쿼리를 통해 한번에 넘겨 해당 태그를 달고있는 질문들을 찯는다. 실제 질문의 내용은 Question 엔티티에서 관리하기 때문에 fetch join을 사용하여 질문의 내용까지 한번에 불러온다.

Service

member fields

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class QuestionService {

    private final QuestionRepository questionRepository;
    private final BoardRepository boardRepository;
    private final MemberRepository memberRepository;
    private final TagRepository tagRepository;
    
    private final QuestionMemberScrapRepository scrapRepository;
    private final TagQuestionRepository tagQuestionRepository;
...

QuestionService는 질문을 등록하고 조회, 스크랩하는 기능이 전부이지만, 질문 엔티티와 연관된 엔티티가 많기 때문에 거의 모든 레포지토리를 주입받아 사용한다.

create

	/**
     * 질문 생성
     * @param questionCreateDTO
     * @return
     */
    public Long create(QuestionCreateDTO questionCreateDTO) {
        Member member = memberRepository.findById(questionCreateDTO.getMemberId())
                .orElseThrow(() -> new NoSuchElementException("회원 정보 없음"));

        Question question = questionRepository.save(
                Question.builder()
                        .member(member)
                        .content(questionCreateDTO.getQuestionContent())
                        .build());

        questionCreateDTO.getTags().stream()
                .forEach(tagId -> {
                    Tag tag = tagRepository.findById(tagId)
                            .orElseThrow(() -> new NoSuchElementException("태그 없음"));
                    TagQuestion tagQuestion = TagQuestion.builder()
                            .question(question)
                            .tag(tag)
                            .build();
                    tagQuestion.setQuestion(question);
                    tagQuestionRepository.save(tagQuestion);
                });

        return question.getId();
    }

사용자가 질문을 등록하는 기능으로 memberRepository에서 회원을 찾아 Question 엔티티를 저장한다. 이후 태그정보를 매핑하기 위해 DTO로 들어온 태그id 각각에 대해 태그를 찾고, 찾은 태그를 질문과 연결하여 중간테이블에 저장한다.

findAll

	/**
     * 전체 질문 목록 조회
     * @return
     */
    public List<QuestionDTO> findAll() {
        List<Question> questions = questionRepository.findAll();

        return questions.stream()
                .map(this::convertToQuestionDTO)
                .toList();
    }

전체 질문을 조회한 뒤 DTO로 변환하여 컨트롤러로 넘겨준다.

findByMember

	/**
     * 작성자로 질문 조회
     * @param memberId
     * @return
     */
    public List<QuestionDTO> findByMember(Long memberId) {
        List<Question> questions = questionRepository.findAllByMemberId(memberId);
        return questions.stream()
                .map(this::convertToQuestionDTO)
                .toList();
    }

질문을 등록한 작성자의 회원id로 질문 목록을 조회한 뒤 DTO로 변환한다.

findByTags

	/**
     * 태그로 질문 조회
     * @param tagId
     * @return
     */
    public List<QuestionDTO> findByTags(List<Long> tagId) {
        List<TagQuestion> result = tagQuestionRepository.findAllQuestionByTagId(tagId);
        return result.stream()
                .map(TagQuestion::getQuestion)
                .map(this::convertToQuestionDTO)
                .toList();
    }

질문을 태그로 필터링하기 위해 태그id를 리스트로 받아 레포지토리에서 해당 태그가 달린 질문들을 조회한다. 이때 여러 태그가 들어오는 경우, 전체 태그 중 하나라도 해당되는 질문이 있다면 모두 조회한다.

findByScrap

	/**
     * 스크랩한 질문 조회
     * @param scrapMemberId
     * @return
     */
    public List<QuestionDTO> findByScrap(Long scrapMemberId) {
        List<QuestionMemberScrap> result = scrapRepository.findAllQuestionByMemberId(scrapMemberId);
        return result.stream()
                .map(QuestionMemberScrap::getQuestion)
                .map(this::convertToQuestionDTO)
                .toList();
    }

사용자가 자신이 스크랩한 질문을 조회할 때 호출되는 메소드로, 스크랩 중간테이블에서 해당하는 질문들을 찾아 반환한다.

scrap

	/**
     * 질문 스크랩
     * @param id
     */
    public void scrap(Long id) {
        Long member_id = 1L; // 임시 회원
        Optional<QuestionMemberScrap> scrap = scrapRepository.findByQuestionIdAndMemberId(id, member_id);

        if (scrap.isEmpty()) {
            Question question = questionRepository.findById(id).orElseThrow(() -> new NoSuchElementException("질문 없음"));
            Member member = memberRepository.findById(member_id).orElseThrow(() -> new NoSuchElementException("회원 정보 없음"));
            QuestionMemberScrap questionMemberScrap = new QuestionMemberScrap(question, member);
            questionMemberScrap.setQuestion(question);
            scrapRepository.save(questionMemberScrap);
        } else {
            scrapRepository.delete(scrap.get());
        }
    }

질문을 스크랩하고 스크랩을 취소하는 기능으로, 스크랩 중간테이블에서 스크랩 정보를 조회한 뒤 값이 없으면 질문과 회원을 연결하여 스크랩 엔티티를 만들고 테이블에 저장한다. 반대로 이미 스크랩했다는 정보가 있으면 스크랩 정보를 삭제한다.

Utility method : convertToQuestionDTO

	/**
     * DTO로 변환
     * @param question
     * @return
     */
    private QuestionDTO convertToQuestionDTO(Question question) {

        List<TagDTO> tagDTOList = question.getTags().stream()
                .map(tagQuestion -> {
                    Tag tag = tagQuestion.getTag();
                    return new TagDTO(tag.getId(), tag.getName());
                })
                .toList();

        // 건당 count 쿼리가 나감 <- 개선 필요
        return QuestionDTO.builder()
                .questionId(question.getId())
                .questionContent(question.getContent())
                .tags(tagDTOList)
                .boardCount(boardRepository.countByQuestion(question))
                .scrapCount(scrapRepository.countByQuestion(question))
                .build();
    }

Question 엔티티를 받아 DTO로 변환하는 편의 메소드로, question 엔티티에서 태그 리스트를 추출하여 DTO로 만들고, QuestionDTO에 필요한 정보를 세팅한다. 이때 question 엔티티에 없는 정보들은 직접 레포지토리를 통해 값을 불러와 사용한다(countByQuestion).

Controller

@RestController
@RequestMapping(value = "/questions", produces = "application/json;charset=utf8")
@RequiredArgsConstructor
@Slf4j
public class QuestionController {

    private final QuestionService questionService;


    @GetMapping("")
    public ResponseEntity<QuestionListDTO> list() {
        return ResponseEntity.ok(new QuestionListDTO(questionService.findAll()));
    }

    @GetMapping(params = "tag_id")
    public ResponseEntity<QuestionListDTO> findByTag(@RequestParam(name = "tag_id") List<Long> tagId) {
        return ResponseEntity.ok(new QuestionListDTO(questionService.findByTags(tagId)));
    }


    @GetMapping(params = "member_id")
    public ResponseEntity<QuestionListDTO> findByMember(@RequestParam(name = "member_id") Long memberId) {
        return ResponseEntity.ok(new QuestionListDTO(questionService.findByMember(memberId)));
    }

    @GetMapping(params = "scrap_member_id")
    public ResponseEntity<QuestionListDTO> findScraps(@RequestParam(name = "scrap_member_id") Long scrapMemberId) {
        return ResponseEntity.ok(new QuestionListDTO(questionService.findByScrap(scrapMemberId)));
    }

    @PostMapping("")
    public ResponseEntity<QuestionResponseDTO> create(@RequestBody QuestionCreateDTO questionCreateDTO) {
        return new ResponseEntity<>(new QuestionResponseDTO(questionService.create(questionCreateDTO)), HttpStatus.CREATED);
    }

    @GetMapping("/{id}/scrap")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void scrap(@PathVariable Long id) {
        questionService.scrap(id);

    }
}

QuestionController는 클라이언트로부터 질문등록, 조회, 스크랩 요청을 받아 QuestionService에 위임한다. 태그id로 질문을 필터링하는 기능은 태그id를 리스트로 받아 조회하기 때문에 /questions?tag_id=1&tag_id=2와 같이 요청할 수 있다.

Test

test data

@SpringBootTest
@Transactional
class QuestionServiceTest {

    @Autowired
    QuestionService questionService;
    @Autowired
    EntityManager em;

    List<Member> members = new ArrayList<>();
    List<Question> questions = new ArrayList<>();
    List<Tag> tags = new ArrayList<>();

    List<QuestionMemberScrap> scraps = new ArrayList<>();

    @BeforeEach
    void init() {

        for (int i = 0; i < 3; i++) {
            Member member = Member.builder()
                    .name("testMember" + i)
                    .build();
            em.persist(member);
            members.add(member);
        }

        for (int i = 0; i < 10; i++) {
            Question question = Question.builder()
                    .content("testQuestion" + i)
                    .member(members.get(i % 3))
                    .build();
            em.persist(question);
            questions.add(question);
        }

        for (int i = 0; i < 5; i++) {
            Tag tag = Tag.builder()
                    .name("testTag" + i)
                    .build();
            em.persist(tag);
            tags.add(tag);
        }

        for (int i = 0; i < 10; i++) {
            Question question = questions.get(i);
            TagQuestion tagQuestion = TagQuestion.builder().question(question).tag(tags.get(i % 5)).build();
            tagQuestion.setQuestion(question);
            em.persist(tagQuestion);
        }

        for (int i = 0; i < 5; i++) {
            Question question = questions.get(i);
            QuestionMemberScrap scrap = QuestionMemberScrap.builder().question(question).member(members.get(i % 3)).build();
            scrap.setQuestion(question);
            scraps.add(scrap);
            em.persist(scrap);
        }
    }

    @AfterEach
    void clear() {
        members.clear();
        questions.clear();
        tags.clear();
    }

QuestionService를 테스트하기 위해서는 Member, Board, Tag등 여러 데이터가 미리 존재해야하기 때문에 JPA의 EntityManager와 @BeforeEach 어노테이션을 사용하여 매 테스트가 시작하기 전에 기본 테스트 데이터를 초기화하고 리스트에 보관한다. 또한 각 테스트 사이의 독립성을 보장하기 위해 테스트가 끝나면 리스트를 비워주어야 하는데, @AfterEach 어노테이션을 사용하여 모든 리스트를 비워준다.

create

	@Test
    void create() {
        List<Long> tags = new ArrayList<>();
        tags.add(1L);
        tags.add(5L);
        tags.add(8L);
        tags.add(12L);

        QuestionCreateDTO dto = new QuestionCreateDTO();
        dto.setMemberId(1L);
        dto.setQuestionContent("testQuestion1");
        dto.setTags(tags);

        Long questionId = questionService.create(dto);

        QuestionDTO foundDTO = questionService.findOne(questionId);

        assertThat(foundDTO.getQuestionId()).isEqualTo(questionId);
        assertThat(foundDTO.getQuestionContent()).isEqualTo(dto.getQuestionContent());
        assertThat(foundDTO.getTags()).extracting("tagId").isEqualTo(dto.getTags());
    }
    
    // 회원이 없는 경우
    @Test
    void createWithWrongMemberId() {
        List<Long> tags = new ArrayList<>();
        tags.add(1L);
        tags.add(5L);
        tags.add(8L);

        QuestionCreateDTO dto = new QuestionCreateDTO();
        dto.setMemberId(100L);
        dto.setQuestionContent("testQuestion1");
        dto.setTags(tags);

        assertThatThrownBy(() -> questionService.create(dto)).isInstanceOf(NoSuchElementException.class);
    }

    // 태그가 없는 경우
    @Test
    void createWithWrongTagId() {
        List<Long> tags = new ArrayList<>();
        tags.add(100L);

        QuestionCreateDTO dto = new QuestionCreateDTO();
        dto.setMemberId(1L);
        dto.setQuestionContent("testQuestion1");
        dto.setTags(tags);

        assertThatThrownBy(() -> questionService.create(dto)).isInstanceOf(NoSuchElementException.class);
    }

질문에 연결될 태그의 id를 준비하고 DTO를 만들어 질문을 등록한다. 이후 조회한 데이터와 등록시 입력한 정보를 비교한다. 만약 회원이나 태그에 잘못된 정보가 들어오면 NoSuchElementException을 던진다.

find

findAll

	// 질문 전체 조회
    @Test
    void findAll() {
        List<QuestionDTO> questionDTOList = questionService.findAll();
        List<Long> ids = questionDTOList.stream().map(QuestionDTO::getQuestionId).toList();
        List<String> contents = questionDTOList.stream().map(QuestionDTO::getQuestionContent).toList();

        assertThat(ids).containsAll(questions.stream().map(Question::getId).toList());
        assertThat(contents).containsAll(questions.stream().map(Question::getContent).toList());
    }

@BeforeEach 를 통해 저장해 둔 질문 전체를 조회하고 id와 content를 추출하여 리스트에 저장된 값이 포함되는지 확인한다. 이때 contains를 사용한 이유는 테스트용 DB가 따로 없기 때문에 개발의 편의성을 위해 데이터베이스에 미리 저장해 둔 데이터가 같이 조회되기 때문에 포함관계만을 비교하였다.

findByMember

	// 작성자로 필터링
    @Test
    void findByMember() {
        Member member = members.get(0);
        List<QuestionDTO> questionDTOList = questionService.findByMember(member.getId());

        List<Long> ids = questionDTOList.stream().map(QuestionDTO::getQuestionId).toList();

        assertThat(ids).containsExactlyInAnyOrderElementsOf(questions.stream()
                .filter(question -> question.getMember().equals(member))
                .map(Question::getId).toList());
    }

    // 없는 회원으로 필터링
    @Test
    void findByWrongMember() {
        List<QuestionDTO> questionDTOList = questionService.findByMember(100L);
        assertThat(questionDTOList.size()).isEqualTo(0);
    }

members에서 회원을 하나 선택하여 이 회원이 작성한 질문들을 조회하고, 조회결과에 해당 회원이 작성한 질문만 있는지 검사한다. members에 있는 회원은 테스트를 위해 만든 회원이기 때문에 기존 DB에 들어있는 데이터과 전혀 관련이 없으므로 리스트에 있는 데이터만 조회되는 것이 맞다. 따라서 이를 위해 containsExactlyInAnyOrderElementsOf 메소드를 사용하여 순서에 상관없이 원하는 데이터만 포함되었는지 검증한다.

또한 만약 조회하는 회원이 없는 경우라면 빈 리스트를 반환하기 때문에 조회결과로 나온 리스트의 크기가 0인지 확인한다.

findByTags

	// 태그로 필터링
    @Test
    void findByTags() {
        List<Tag> tagList = new ArrayList<>(Arrays.asList(tags.get(0), tags.get(tags.size()-1)));

        List<Long> tagIds = tagList.stream().map(Tag::getId).toList();

        List<QuestionDTO> questionDTOList = questionService.findByTags(tagIds);
        questionDTOList.stream().forEach(questionDTO -> {
            List<Long> ids = questionDTO.getTags().stream().map(TagDTO::getTagId).toList();
            assertThat(ids).containsAnyElementsOf(tagIds);
        });
    }

    // 없는 태그로 필터링
    @Test
    void findByWrongTag() {
        List<Long> tagIds = new ArrayList<>(Arrays.asList(100L));

        List<QuestionDTO> questionDTOList = questionService.findByTags(tagIds);

        assertThat(questionDTOList.size()).isEqualTo(0);
    }

여러 태그로 조회가 가능한지 확인하기 위해 tags의 첫번째 태그와 마지막 태그를 뽑아 DTO에 넣고 조회한다. 이후 결과로 나온 DTOList의 각각의 QuestionDTO에 대해서 선택한 태그중 하나 이상 포함하는지 확인한다. 만약 존재하지 않는 태그번호로 필터링을 하게되면 빈 리스트를 반환하므로 결과 리스트의 크기가 0인지 검사한다.

findByScrap

	// 스크랩한 회원으로 필터링
    @Test
    void findByScrap() {
        Member scrapMember = members.get(0);
        List<QuestionDTO> questionDTOList = questionService.findByScrap(scrapMember.getId());
        List<Long> ids = questionDTOList.stream().map(QuestionDTO::getQuestionId).toList();
        assertThat(ids).containsExactlyInAnyOrderElementsOf(
                scraps.stream()
                        .filter(scrap -> scrap.getMember().equals(scrapMember))
                        .map(QuestionMemberScrap::getQuestion)
                        .map(Question::getId)
                        .toList()
        );
    }

    // 없는 회원으로 필터링
    @Test
    void findByWrongScrapMember() {
        List<QuestionDTO> questionDTOList = questionService.findByScrap(100L);
        assertThat(questionDTOList.size()).isEqualTo(0);
    }

findByMember와 같은 방법으로 members에서 회원을 하나 선택하고, 해당 회원이 스크랩한 질문을 조회하여 나온 결과에 예상 값들만 포함되는지 검사한다. 또한 회원정보가 없는 경우 빈 리스트를 반환하므로 크기가 0인지 검사한다.

scrap

	// 스크랩, 스크랩 취소
    @Test
    void scrap() {
        Question question = Question.builder()
                .content("testQuestion")
                .member(members.get(0))
                .build();
        em.persist(question);

        questionService.scrap(question.getId());

        QuestionDTO scrap = questionService.findOne(question.getId());
        assertThat(scrap.getScrapCount()).isEqualTo(1);

        em.flush();
        em.clear();

        questionService.scrap(question.getId());
        QuestionDTO unScrap = questionService.findOne(question.getId());
        assertThat(unScrap.getScrapCount()).isEqualTo(0);
    }

새로운 질문을 만들어 저장하고 스크랩한 뒤 해당 질문의 scrapCount가 1인지 검사한다. 이후 영속성 컨텍스트를 비우고 다시 scrap을 호출했을 때 정상적으로 스크랩이 취소되었는지 확인한다.

문제점

  • Question의 대부분은 필터링 여부에 차이가 있을 뿐 질문 목록을 조회하는 기능이다. 그런데 question 엔티티를 DTO로 변환하는 과정에서 게시글의 수와 스크랩 수 등을 레포지토리에서 얻어야 하는데, 결과로 제공하는 모든 질문에 대해 convertToQuestionDTO 메소드가 실행된다. 그런데 DTO를 만들기 위해서 조회된 질문 한 건당 Board테이블과 Scrap테이블에 대해서 각각 count쿼리가 나간다. 쉽게 말해서 결과로 조회한 질문 엔티티가 10개면 count쿼리만 20번이 나가는 현상이 나타났다. 이는 분명 불필요한 쿼리이고 좀 더 효율적인 방법이 있을 것 같기 때문에 방법을 찾아 레포지토리를 수정해야 할 것 같다.
  • 질문을 등록하는 create과정에서도 쿼리가 지나치게 많이 나가는 것 같다. 질문과 태그를 연결해주기 위해 DTO의 태그id 리스트를 돌면서 태그를 조회하고 질문과 연결하여 중간테이블에 저장하는 과정을 반복하는데, 태그 각각에 대해 select쿼리와 insert쿼리가 나가는 것을 확인했다. 이것도 가능하면 한번에 태그를 조회하고 중간테이블에 한번에 값을 저장하도록 수정이 필요해 보인다.
  • 테스트 코드가 많이 지저분하고, 테스트 데이터를 넣는 과정이 하드코딩 되어있어 각 테스트 코드가 왜 이렇게 작성되었는지 한눈에 파악하기 어려운 것 같다. 추후 테스트용 DB를 만들거나 다른 방법을 찾아 좀더 간결하게 바꿔야할 것 같다.

0개의 댓글