사용자는 면접 예상질문을 등록할 수 있는데, 각 질문에는 면접 유형, 기업 및 직무의 종류등과 같은 태그가 달리고, 이 태그를 통해 질문을 필터링하여 조회할 수 있다. 또한 사용자가 관심기업이나 관심직무등을 설정할 때에도 태그가 사용된다.
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은 해당 태그가 가장 상위 태그임을 의미한다.
태그는 사용자가 생성할 수 없고 데이터베이스에 저장된 값만 사용하기 때문에 RequestDTO없이 ResponseDTO만 만들었다.
@Data
@AllArgsConstructor
public class TagDTO {
private Long tagId;
private String tagName;
}
태그 하나의 정보를 표현하는 DTO로 태그의 id와 태그 이름으로 구성되어있다.
@Data
public class TagListDTO {
private List<TagDTO> tags;
}
여러개의 태그를 전달할 때 사용하는 DTO로 TagDTO를 리스트로 묶어 전달한다.
public interface TagRepository extends JpaRepository<Tag,Long> {
public List<Tag> findAllByCategoryIn(TagType... categories);
public List<Tag> findAllByParentId(Long parentId);
}
비즈니스 로직상으로 태그를 조회할 때 첫 단계에서는 최상위 타입의 태그만 보여주고, 태그를 선택하면 그 태그의 하위태그들을 보여주기 때문에 카테고리로 태그를 조회하는 메소드와 상위태그의 id로 태그를 조회하는 메소드를 정의하였다. 또한 카테고리로 조회하는 경우 여러 카테고리값을 한번에 조회하기 위해 where ~ in쿼리를 사용할 수 있도록 메소드의 이름을 지정하였다.
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class TagService {
private final TagRepository tagRepository;
...
TagService는 조건에 맞게 태그를 조회하는 역할만 담당하기 때문에 비교적 복잡하지 않아 tagRepository만을 주입받아 사용한다.
/**
* 대분류 태그 전체 조회
* @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로 변환하여 컨트롤러로 전달한다.
/**
* 상위 태그로 조회
* @param parentTagId
* @return
*/
public List<TagDTO> findByParentTag(Long parentTagId) {
List<Tag> tags = tagRepository.findAllByParentId(parentTagId);
return convertToTagDTOList(tags);
}
최상위 태그를 조회한 후 부모태그의 아이디로 하위태그를 조회한다. 이 때에는 단순히 레포지토리에서 값을 찾아 DTO로 변환하여 전달한다.
@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가 들어오면 해당 태그의 하위태그를 조회하여 제공한다.
@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를 반환해주기 때문이다.