[빙터뷰] 태그 기능 구현

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

Tag

사용자는 면접 예상질문을 등록할 수 있는데, 각 질문에는 면접 유형, 기업 및 직무의 종류등과 같은 태그가 달리고, 이 태그를 통해 질문을 필터링하여 조회할 수 있다. 또한 사용자가 관심기업이나 관심직무등을 설정할 때에도 태그가 사용된다.

Domain

package ving.vingterview.domain.tag;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import ving.vingterview.dto.tag.TagDTO;

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

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Tag parent;
    private String name;

    @Enumerated(EnumType.STRING)
    private TagType category;

    @Builder
    public Tag(Tag parent, String name, TagType category) {
        this.parent = parent;
        this.name = name;
        this.category = category;
    }
}

태그 도메인은 태그 이름과 카테고리, 그리고 부모태그로 구성된다. @NoArgsConstructor(access = AccessLevel.PROTECTED) @ManyToOne(fetch = FetchType.LAZY)과 같은 부분은 Comment에서 설명한 이유와 같고, 여기에서 특징이 되는 부분은 category와 parent이다.

public enum TagType {
    JOB, INTERVIEW, ENTERPRISE, CLASSIFICATION
}

먼저 각 태그의 종류를 표현하기 위해 Enum타입으로 TagType을 정의하였다. 엔티티에 Enum타입의 필드를 선언하는 경우 반드시 @Enumerated(EnumType.STRING) 어노테이션을 통해 데이터베이스에 문자열의 형태로 저장될 수 있도록 설정해주어야 한다. 그렇지 않으면 기본값인 EnumType.ORDINAL로 설정되어 타입이 숫자로 저장되는데, 이 경우 Enum값에 변경이 생긴다면 모든 데이터가 부정확한 값을 가지게 될 수 있다.

태그의 종류에는 직무-기업-기업분류와 면접종류가 있고, 앞의 세 태그는 부모-자식관계를 가지도록 설계하였다. 즉, 기업분류 안에 구체적인 기업들이 포함되고, 각 기업은 직무로 세분화된다. 이를 구현하기 위해 Tag엔티티는 parent필드를 통해 자기자신과 다대일 연관관계를 맺고 있으며, parent = null은 해당 태그가 가장 상위 태그임을 의미한다.

DTO

ResponseDTO

태그는 사용자가 생성할 수 없고 데이터베이스에 저장된 값만 사용하기 때문에 RequestDTO없이 ResponseDTO만 만들었다.

TagDTO

@Data
@AllArgsConstructor
public class TagDTO {

    private Long tagId;
    private String tagName;
}

태그 하나의 정보를 표현하는 DTO로 태그의 id와 태그 이름으로 구성되어있다.

TagListDTO

@Data
public class TagListDTO {
    private List<TagDTO> tags;
}

여러개의 태그를 전달할 때 사용하는 DTO로 TagDTO를 리스트로 묶어 전달한다.

Repository

public interface TagRepository extends JpaRepository<Tag,Long> {

    public List<Tag> findAllByCategoryIn(TagType... categories);

    public List<Tag> findAllByParentId(Long parentId);

}

비즈니스 로직상으로 태그를 조회할 때 첫 단계에서는 최상위 타입의 태그만 보여주고, 태그를 선택하면 그 태그의 하위태그들을 보여주기 때문에 카테고리로 태그를 조회하는 메소드와 상위태그의 id로 태그를 조회하는 메소드를 정의하였다. 또한 카테고리로 조회하는 경우 여러 카테고리값을 한번에 조회하기 위해 where ~ in쿼리를 사용할 수 있도록 메소드의 이름을 지정하였다.

Service

member fields

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

    private final TagRepository tagRepository;
	...

TagService는 조건에 맞게 태그를 조회하는 역할만 담당하기 때문에 비교적 복잡하지 않아 tagRepository만을 주입받아 사용한다.

findAll

	/**
     * 대분류 태그 전체 조회
     * @return
     */
    public List<TagDTO> findAll() {
        List<Tag> tags = tagRepository.findAllByCategoryIn(TagType.CLASSIFICATION, TagType.INTERVIEW);
        List<Tag> results = tags.stream().filter(tag -> tag.getParent() == null).toList();
        return convertToTagDTOList(results);
    }

태그를 조회하는 첫 단계로 parent = null인 최상위 태그만을 조회한다. 현재 상태에서 parent가 없는 태그는 카테고리가 CLASSIFICATION와 INTERVIEW인 태그들이므로 이 두 카테고리를 통해 레포지토리에서 조회하고, 스트림의 filter를 통해 검증을 거친 뒤 DTO로 변환하여 컨트롤러로 전달한다.

findByParentTag

	/**
     * 상위 태그로 조회
     * @param parentTagId
     * @return
     */
    public List<TagDTO> findByParentTag(Long parentTagId) {
        List<Tag> tags = tagRepository.findAllByParentId(parentTagId);
        return convertToTagDTOList(tags);
    }

최상위 태그를 조회한 후 부모태그의 아이디로 하위태그를 조회한다. 이 때에는 단순히 레포지토리에서 값을 찾아 DTO로 변환하여 전달한다.

Controller

@RestController
@RequestMapping("/tags")
@RequiredArgsConstructor
public class TagController {

    private final TagService tagService;

    @GetMapping("")
    public ResponseEntity<TagListDTO> list(@RequestParam(name = "parent_tag_id", required = false) Long parentTagId) {

        TagListDTO tags = new TagListDTO();

        if (parentTagId == null) {
            tags.setTags(tagService.findAll());
        } else {
            tags.setTags(tagService.findByParentTag(parentTagId));
        }

        return new ResponseEntity<>(tags, HttpStatus.OK);
    }
}

TagController는 /tags로 들어오는 하나의 GET요청만을 처리한다. 이때, 쿼리 파라미터가 없으면 최상위 태그를 조회하여 제공하고, parent_tag_id라는 이름의 쿼리파라미터로 상위태그id가 들어오면 해당 태그의 하위태그를 조회하여 제공한다.

Test

find

@SpringBootTest
@Transactional
class TagServiceTest {

    @Autowired
    TagService tagService;
    @Autowired
    EntityManager em;

    List<Tag> tagList = new ArrayList<>();

    @BeforeEach
    void init() {
        Tag testTag1 = new Tag(null, "testTag1", TagType.CLASSIFICATION);
        Tag testTag2 = new Tag(null, "testTag2", TagType.INTERVIEW);
        Tag testTag3 = new Tag(testTag1, "testTag3", TagType.ENTERPRISE);
        em.persist(testTag1);
        em.persist(testTag2);
        em.persist(testTag3);
        tagList.addAll(Arrays.asList(testTag1, testTag2, testTag3));
    }

    @AfterEach
    void clear() {
        tagList.clear();
    }

    // tag type이 CLASSIFICATION이거나 INTERVIEW인 태그만 조회
    @Test
    void findAll() {
        Tag testTag1 = tagList.get(0);
        Tag testTag2 = tagList.get(1);
        Tag testTag3 = tagList.get(2);

        List<TagDTO> tags = tagService.findAll();

        assertThat(tags).extracting("tagId").contains(testTag1.getId(), testTag2.getId());
        assertThat(tags).extracting("tagName").contains(testTag1.getName(), testTag2.getName());

        assertThat(tags).extracting("tagId").doesNotContain(testTag3.getId());
        assertThat(tags).extracting("tagName").doesNotContain(testTag3.getName());
    }

    // 상위 태그로 검색한 경우
    @Test
    void findByParentTag() {
        Tag testTag1 = tagList.get(0);
        Tag testTag2 = tagList.get(1);
        Tag testTag3 = tagList.get(2);

        List<TagDTO> tags = tagService.findByParentTag(testTag1.getId());

        assertThat(tags).extracting("tagId").doesNotContain(testTag1.getId(), testTag2.getId());
        assertThat(tags).extracting("tagName").doesNotContain(testTag1.getName(), testTag2.getName());

        assertThat(tags).extracting("tagId").containsExactly(testTag3.getId());
        assertThat(tags).extracting("tagName").containsExactly(testTag3.getName());
    }

    // 조회하는 상위 태그의 id가 없는 경우
    @Test
    void findByWrongParentTag() {
        Tag testTag1 = tagList.get(0);

        List<TagDTO> tags = tagService.findByParentTag(testTag1.getId() + 100L);
        assertThat(tags.size()).isEqualTo(0);
    }


    // 하위 태그가 없는 경우
    @Test
    void findByParentTagWithNoChild() {
        Tag testTag3 = tagList.get(2);

        List<TagDTO> tags = tagService.findByParentTag(testTag3.getId());
        assertThat(tags.size()).isEqualTo(0);
    }
}

테스트를 위해 예시 태그 3개를 만들어 저장하고 세번째 태그는 부모태그를 지정해주었다. 각 테스트 메소드는 TagService의 기능을 검증하기 위해 contains와 doesNotContain메소드를 사용하였고, 추가로 조회하는 태그 id가 없는 경우나 하위태그가 없는 경우에 대해서도 테스트 코드를 작성하였다.

조회하는 데이터가 없는 경우 null이 아닌 빈 리스트가 반환되는 이유는 SpringDataJPA 내부적으로 findAll로 시작하는 메소드가 getResultList라는 메소드를 호출하는데, 이때 값이 없으면 Collections.EMPTY_LIST를 반환해주기 때문이다.

0개의 댓글