Skip to content

Commit de335ef

Browse files
authored
Feat: 태그 테이블 추가 및 검색 (#151)
* Feat: Tag, MentoringTag 엔티티 추가 * Feat: tag 검색 추가 * Refactor: tag 검색 exists 서브 쿼리로 변경 * Refactor: 테스트 컨트롤러 제거 * Fix: 키워드 검색 시 n+1 문제 제거
1 parent 9c22e6d commit de335ef

13 files changed

Lines changed: 270 additions & 40 deletions

File tree

back/src/main/java/com/back/domain/mentoring/mentoring/dto/MentoringDetailDto.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public static MentoringDetailDto from(Mentoring mentoring) {
2626
return new MentoringDetailDto(
2727
mentoring.getId(),
2828
mentoring.getTitle(),
29-
mentoring.getTags(),
29+
mentoring.getTagNames(),
3030
mentoring.getBio(),
3131
mentoring.getThumb(),
3232
mentoring.getCreateDate(),

back/src/main/java/com/back/domain/mentoring/mentoring/dto/MentoringWithTagsDto.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public static MentoringWithTagsDto from(Mentoring mentoring) {
2121
return new MentoringWithTagsDto(
2222
mentoring.getId(),
2323
mentoring.getTitle(),
24-
mentoring.getTags(),
24+
mentoring.getTagNames(),
2525
mentoring.getMentor().getId(),
2626
mentoring.getMentor().getMember().getNickname()
2727
);

back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.back.domain.mentoring.mentoring.entity;
22

33
import com.back.domain.member.mentor.entity.Mentor;
4-
import com.back.global.converter.StringListConverter;
54
import com.back.global.jpa.BaseEntity;
65
import jakarta.persistence.*;
76
import lombok.Builder;
@@ -25,30 +24,45 @@ public class Mentoring extends BaseEntity {
2524
@Column(columnDefinition = "TEXT")
2625
private String bio;
2726

28-
@Convert(converter = StringListConverter.class)
29-
@Column(columnDefinition = "JSON")
30-
private List<String> tags;
27+
@OneToMany(mappedBy = "mentoring", cascade = CascadeType.ALL, orphanRemoval = true)
28+
private List<MentoringTag> mentoringTags = new ArrayList<>();
3129

3230
@Column(length = 255)
3331
private String thumb;
3432

3533
@Builder
36-
public Mentoring(Mentor mentor, String title, String bio, List<String> tags, String thumb) {
34+
public Mentoring(Mentor mentor, String title, String bio, String thumb) {
3735
this.mentor = mentor;
3836
this.title = title;
3937
this.bio = bio;
40-
this.tags = tags != null ? tags : new ArrayList<>();
4138
this.thumb = thumb;
4239
}
4340

44-
public void update(String title, String bio, List<String> tags, String thumb) {
41+
public void update(String title, String bio, List<Tag> tags, String thumb) {
4542
this.title = title;
4643
this.bio = bio;
47-
this.tags = tags != null ? tags : new ArrayList<>();
4844
this.thumb = thumb;
45+
46+
updateTags(tags);
47+
}
48+
49+
public void updateTags(List<Tag> tags) {
50+
this.mentoringTags.clear();
51+
52+
if (tags != null) {
53+
tags.forEach(tag ->
54+
this.mentoringTags.add(new MentoringTag(this, tag))
55+
);
56+
}
4957
}
5058

5159
public boolean isOwner(Mentor mentor) {
5260
return this.mentor.equals(mentor);
5361
}
62+
63+
public List<String> getTagNames() {
64+
return mentoringTags.stream()
65+
.map(mentoringTag -> mentoringTag.getTag().getName())
66+
.toList();
67+
}
5468
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.back.domain.mentoring.mentoring.entity;
2+
3+
import com.back.global.jpa.BaseEntity;
4+
import jakarta.persistence.*;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
@Entity
10+
@Getter
11+
@NoArgsConstructor
12+
@Table(name = "mentoring_tag")
13+
public class MentoringTag extends BaseEntity {
14+
@ManyToOne(fetch = FetchType.LAZY)
15+
@JoinColumn(name = "mentoring_id")
16+
private Mentoring mentoring;
17+
18+
@ManyToOne(fetch = FetchType.LAZY)
19+
@JoinColumn(name = "tag_id")
20+
private Tag tag;
21+
22+
@Builder
23+
public MentoringTag(Mentoring mentoring, Tag tag) {
24+
this.mentoring = mentoring;
25+
this.tag = tag;
26+
}
27+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.domain.mentoring.mentoring.entity;
2+
3+
import com.back.global.jpa.BaseEntity;
4+
import jakarta.persistence.Column;
5+
import jakarta.persistence.Entity;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
@Entity
11+
@Getter
12+
@NoArgsConstructor
13+
public class Tag extends BaseEntity {
14+
@Column(length = 50, nullable = false, unique = true)
15+
private String name;
16+
17+
@Builder
18+
public Tag(String name) {
19+
this.name = name;
20+
}
21+
}

back/src/main/java/com/back/domain/mentoring/mentoring/repository/MentoringRepositoryImpl.java

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
import com.back.domain.member.mentor.entity.QMentor;
55
import com.back.domain.mentoring.mentoring.entity.Mentoring;
66
import com.back.domain.mentoring.mentoring.entity.QMentoring;
7+
import com.back.domain.mentoring.mentoring.entity.QMentoringTag;
8+
import com.back.domain.mentoring.mentoring.entity.QTag;
79
import com.querydsl.core.BooleanBuilder;
10+
import com.querydsl.core.types.dsl.BooleanExpression;
11+
import com.querydsl.jpa.JPAExpressions;
812
import com.querydsl.jpa.impl.JPAQueryFactory;
913
import lombok.RequiredArgsConstructor;
1014
import org.springframework.data.domain.Page;
@@ -23,40 +27,54 @@ public Page<Mentoring> searchMentorings(String keyword, Pageable pageable) {
2327
QMentoring mentoring = QMentoring.mentoring;
2428
QMentor mentor = QMentor.mentor;
2529
QMember member = QMember.member;
30+
QMentoringTag mentoringTag = QMentoringTag.mentoringTag;
31+
QTag tag = QTag.tag;
2632

2733
BooleanBuilder builder = new BooleanBuilder();
2834

29-
// 제목, 멘토 닉네임 검색 조건
35+
// 제목, 멘토 닉네임, 태그 검색 조건
3036
if (keyword != null && !keyword.isBlank()) {
31-
builder.and(
32-
mentoring.title.containsIgnoreCase(keyword)
33-
.or(mentor.member.nickname.containsIgnoreCase(keyword))
34-
);
37+
// 제목, 멘토 닉네임 검색 조건
38+
BooleanExpression titleOrNickName = mentoring.title.containsIgnoreCase(keyword)
39+
.or(mentor.member.nickname.containsIgnoreCase(keyword));
40+
41+
// 태그 검색 조건 (EXISTS 서브쿼리)
42+
BooleanExpression tagSearch = JPAExpressions
43+
.selectOne()
44+
.from(mentoringTag)
45+
.join(mentoringTag.tag, tag)
46+
.where(
47+
mentoringTag.mentoring.eq(mentoring)
48+
.and(tag.name.containsIgnoreCase(keyword))
49+
)
50+
.exists();
51+
52+
builder.and(titleOrNickName).or(tagSearch);
3553
}
3654

37-
// 1. 조건에 맞는 모든 데이터 조회 (태그 제외)
55+
// 조건에 맞는 모든 데이터 조회
3856
List<Mentoring> content = queryFactory
3957
.selectFrom(mentoring)
4058
.leftJoin(mentoring.mentor, mentor).fetchJoin()
4159
.leftJoin(mentor.member, member).fetchJoin()
60+
.leftJoin(mentoring.mentoringTags, mentoringTag).fetchJoin()
61+
.leftJoin(mentoringTag.tag, tag).fetchJoin()
4262
.where(builder)
4363
.orderBy(mentoring.id.desc())
4464
.offset(pageable.getOffset())
4565
.limit(pageable.getPageSize())
4666
.fetch();
4767

48-
// 2. 태그 검색
49-
// TODO: 태그 테이블 분리 후 추가 예정
50-
51-
long total = getTotal(mentoring, builder);
68+
long total = getTotal(mentoring, mentor, builder);
5269

5370
return new PageImpl<>(content, pageable, total);
5471
}
5572

56-
private long getTotal(QMentoring mentoring, BooleanBuilder builder) {
73+
private long getTotal(QMentoring mentoring, QMentor mentor, BooleanBuilder builder) {
5774
Long totalCount = queryFactory
5875
.select(mentoring.count())
5976
.from(mentoring)
77+
.leftJoin(mentoring.mentor, mentor)
6078
.where(builder)
6179
.fetchOne();
6280
return totalCount != null ? totalCount : 0L;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.back.domain.mentoring.mentoring.repository;
2+
3+
import com.back.domain.mentoring.mentoring.entity.Tag;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
import java.util.List;
7+
import java.util.Optional;
8+
9+
public interface TagRepository extends JpaRepository<Tag, Long> {
10+
Optional<Tag> findByName(String name);
11+
List<Tag> findByNameIn(List<String> names);
12+
}

back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
import com.back.domain.mentoring.mentoring.dto.request.MentoringRequest;
88
import com.back.domain.mentoring.mentoring.dto.response.MentoringResponse;
99
import com.back.domain.mentoring.mentoring.entity.Mentoring;
10+
import com.back.domain.mentoring.mentoring.entity.Tag;
1011
import com.back.domain.mentoring.mentoring.error.MentoringErrorCode;
1112
import com.back.domain.mentoring.mentoring.repository.MentoringRepository;
13+
import com.back.domain.mentoring.mentoring.repository.TagRepository;
1214
import com.back.global.exception.ServiceException;
1315
import lombok.RequiredArgsConstructor;
1416
import org.springframework.data.domain.Page;
@@ -17,11 +19,17 @@
1719
import org.springframework.stereotype.Service;
1820
import org.springframework.transaction.annotation.Transactional;
1921

22+
import java.util.ArrayList;
23+
import java.util.List;
24+
import java.util.Set;
25+
import java.util.stream.Collectors;
26+
2027
@Service
2128
@RequiredArgsConstructor
2229
public class MentoringService {
2330
private final MentoringRepository mentoringRepository;
2431
private final MentoringStorage mentoringStorage;
32+
private final TagRepository tagRepository;
2533

2634
@Transactional(readOnly = true)
2735
public Page<MentoringWithTagsDto> getMentorings(String keyword, int page, int size) {
@@ -52,10 +60,12 @@ public MentoringResponse createMentoring(MentoringRequest reqDto, Mentor mentor)
5260
.mentor(mentor)
5361
.title(reqDto.title())
5462
.bio(reqDto.bio())
55-
.tags(reqDto.tags())
5663
.thumb(reqDto.thumb())
5764
.build();
5865

66+
List<Tag> tags = getOrCreateTags(reqDto.tags());
67+
mentoring.updateTags(tags);
68+
5969
mentoringRepository.save(mentoring);
6070

6171
return new MentoringResponse(
@@ -70,7 +80,9 @@ public MentoringResponse updateMentoring(Long mentoringId, MentoringRequest reqD
7080

7181
validateOwner(mentoring, mentor);
7282

73-
mentoring.update(reqDto.title(), reqDto.bio(), reqDto.tags(), reqDto.thumb());
83+
List<Tag> tags = getOrCreateTags(reqDto.tags());
84+
85+
mentoring.update(reqDto.title(), reqDto.bio(), tags, reqDto.thumb());
7486

7587
return new MentoringResponse(
7688
MentoringDetailDto.from(mentoring),
@@ -97,6 +109,43 @@ public void deleteMentoring(Long mentoringId, Mentor mentor) {
97109
}
98110

99111

112+
// ==== Tag 관리 =====
113+
114+
private List<Tag> getOrCreateTags(List<String> tagNames) {
115+
if (tagNames == null || tagNames.isEmpty()) {
116+
return new ArrayList<>();
117+
}
118+
119+
// 기존 태그 조회
120+
List<Tag> existingTags = tagRepository.findByNameIn(tagNames);
121+
122+
Set<String> existingNames = existingTags.stream()
123+
.map(Tag::getName)
124+
.collect(Collectors.toSet());
125+
126+
// 신규 태그 생성
127+
List<Tag> newTags = createNewTags(tagNames, existingNames);
128+
129+
// 기존 태그 + 신규 태그
130+
List<Tag> allTags = new ArrayList<>(existingTags);
131+
allTags.addAll(newTags);
132+
133+
return allTags;
134+
}
135+
136+
private List<Tag> createNewTags(List<String> tagNames, Set<String> existingNames) {
137+
List<Tag> newTags = tagNames.stream()
138+
.filter(name -> !existingNames.contains(name))
139+
.map(name -> Tag.builder().name(name).build())
140+
.toList();
141+
142+
if (!newTags.isEmpty()) {
143+
tagRepository.saveAll(newTags);
144+
}
145+
return newTags;
146+
}
147+
148+
100149
// ===== 유효성 검사 =====
101150

102151
private void validateOwner(Mentoring mentoring, Mentor mentor) {

back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,11 @@ void createMentoringSuccess() throws Exception {
192192
// Mentoring 정보 검증
193193
.andExpect(jsonPath("$.data.mentoring.mentoringId").value(mentoring.getId()))
194194
.andExpect(jsonPath("$.data.mentoring.title").value(mentoring.getTitle()))
195-
.andExpect(jsonPath("$.data.mentoring.tags").value(mentoring.getTags()))
196195
.andExpect(jsonPath("$.data.mentoring.bio").value(mentoring.getBio()))
197196
.andExpect(jsonPath("$.data.mentoring.thumb").value(mentoring.getThumb()))
197+
.andExpect(jsonPath("$.data.mentoring.tags").isArray())
198+
.andExpect(jsonPath("$.data.mentoring.tags[0]").value("Spring"))
199+
.andExpect(jsonPath("$.data.mentoring.tags[1]").value("Java"))
198200

199201
// Mentor 정보 검증
200202
.andExpect(jsonPath("$.data.mentor.mentorId").value(mentorOfMentoring.getId()))

back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
import com.back.domain.mentoring.mentoring.dto.request.MentoringRequest;
77
import com.back.domain.mentoring.mentoring.dto.response.MentoringResponse;
88
import com.back.domain.mentoring.mentoring.entity.Mentoring;
9+
import com.back.domain.mentoring.mentoring.entity.Tag;
910
import com.back.domain.mentoring.mentoring.error.MentoringErrorCode;
1011
import com.back.domain.mentoring.mentoring.repository.MentoringRepository;
12+
import com.back.domain.mentoring.mentoring.repository.TagRepository;
1113
import com.back.fixture.MemberFixture;
1214
import com.back.fixture.MentorFixture;
1315
import com.back.fixture.mentoring.MentoringFixture;
16+
import com.back.fixture.mentoring.TagFixture;
1417
import com.back.global.exception.ServiceException;
1518
import org.junit.jupiter.api.BeforeEach;
1619
import org.junit.jupiter.api.DisplayName;
@@ -41,6 +44,9 @@ class MentoringServiceTest {
4144
@Mock
4245
private MentoringRepository mentoringRepository;
4346

47+
@Mock
48+
private TagRepository tagRepository;
49+
4450
@Mock
4551
private MentoringStorage mentoringStorage;
4652

@@ -166,6 +172,10 @@ class Describe_createMentoring {
166172
@Test
167173
@DisplayName("생성 성공")
168174
void createMentoring() {
175+
List<Tag> tags = TagFixture.createDefaultTags();
176+
177+
when(tagRepository.findByNameIn(request.tags()))
178+
.thenReturn(tags);
169179
when(mentoringRepository.existsByMentorId(mentor1.getId()))
170180
.thenReturn(false);
171181

@@ -179,6 +189,7 @@ void createMentoring() {
179189
assertThat(result.mentoring().tags()).isEqualTo(request.tags());
180190
assertThat(result.mentoring().thumb()).isEqualTo(request.thumb());
181191
verify(mentoringRepository).existsByMentorId(mentor1.getId());
192+
verify(tagRepository).findByNameIn(request.tags());
182193
verify(mentoringRepository).save(any(Mentoring.class));
183194
}
184195

@@ -207,7 +218,10 @@ class Describe_updateMentoring {
207218
void updateMentoring() {
208219
// given
209220
Long mentoringId = 1L;
221+
List<Tag> tags = TagFixture.createDefaultTags();
210222

223+
when(tagRepository.findByNameIn(request.tags()))
224+
.thenReturn(tags);
211225
when(mentoringStorage.findMentoring(mentoringId))
212226
.thenReturn(mentoring1);
213227

@@ -220,7 +234,9 @@ void updateMentoring() {
220234
assertThat(result.mentoring().bio()).isEqualTo(request.bio());
221235
assertThat(result.mentoring().tags()).isEqualTo(request.tags());
222236
assertThat(result.mentoring().thumb()).isEqualTo(request.thumb());
237+
223238
verify(mentoringStorage).findMentoring(mentoringId);
239+
verify(tagRepository).findByNameIn(request.tags());
224240
}
225241

226242
@Test

0 commit comments

Comments
 (0)