Skip to content

Commit 2dfd591

Browse files
authored
Merge pull request #144 from study-pals/feat/group-hashtag
[#142][FEATURE] 그룹 해시태그 기능 추가
2 parents 0b0a30b + 7c9db3d commit 2dfd591

37 files changed

Lines changed: 1389 additions & 132 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ build/
44
!gradle/wrapper/gradle-wrapper.jar
55
!**/src/main/**/build/
66
!**/src/test/**/build/
7+
application-local.properties
78

89
### STS ###
910
.apt_generated

build.gradle

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ dependencies {
3434
implementation 'org.springframework.boot:spring-boot-starter-validation'
3535
implementation 'org.springframework.boot:spring-boot-starter-websocket'
3636
implementation 'org.mapstruct:mapstruct:1.6.3'
37-
37+
implementation 'org.springframework.boot:spring-boot-starter-aop'
3838
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
3939
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
4040

@@ -139,7 +139,12 @@ tasks.named('test', Test) {
139139
// REST Docs snippets 출력 디렉터리
140140
outputs.dir(snippetsDir)
141141
environment envProps
142-
systemProperty 'spring.profiles.active', 'test'
142+
def active =
143+
System.getProperty("spring.profiles.active")
144+
?: System.getenv("SPRING_PROFILES_ACTIVE")
145+
?: "test"
146+
147+
systemProperty "spring.profiles.active", active
143148

144149
testLogging {
145150
events "passed", "skipped", "failed"

gradlew

100755100644
File mode changed.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.studypals.domain.groupManage.dao;
2+
3+
import java.util.List;
4+
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
8+
import org.springframework.stereotype.Repository;
9+
10+
import com.studypals.domain.groupManage.entity.GroupHashTag;
11+
12+
/**
13+
* {@link GroupHashTag} 에 대한 dao 클래스입니다.
14+
*
15+
* @author jack8
16+
* @see GroupHashTag
17+
* @since 2025-12-23
18+
*/
19+
@Repository
20+
public interface GroupHashTagRepository extends JpaRepository<GroupHashTag, Long> {
21+
22+
/**
23+
* 일반적인 {@code findAllByGroupId} 와 결과가 동일하나, hashTag 에 대한 fetch join 을 통한
24+
* N+1 문제를 방지하였습니다.
25+
* @param groupId 검색하고자 하는 그룹의 아이디
26+
* @return hash tag 가 fetch join 된 groupHashTag 리스트
27+
*/
28+
@Query(
29+
"""
30+
SELECT gt
31+
FROM GroupHashTag gt
32+
JOIN FETCH gt.hashTag
33+
WHERE gt.group.id = :groupId
34+
""")
35+
List<GroupHashTag> findAllByGroupIdWithTag(@Param("groupId") Long groupId);
36+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.studypals.domain.groupManage.dao;
2+
3+
import java.util.Collection;
4+
import java.util.List;
5+
import java.util.Optional;
6+
7+
import org.springframework.data.domain.Pageable;
8+
import org.springframework.data.jpa.repository.JpaRepository;
9+
import org.springframework.data.jpa.repository.Modifying;
10+
import org.springframework.data.jpa.repository.Query;
11+
import org.springframework.data.repository.query.Param;
12+
import org.springframework.stereotype.Repository;
13+
14+
import com.studypals.domain.groupManage.entity.HashTag;
15+
16+
/**
17+
* {@link HashTag} 에 대한 dao 클래스입니다.
18+
*
19+
* @author jack8
20+
* @see HashTag
21+
* @since 2025-12-23
22+
*/
23+
@Repository
24+
public interface HashTagRepository extends JpaRepository<HashTag, Long> {
25+
26+
/**
27+
* tag 에 대해 객체를 반환합니다.
28+
* @param tag 검색할 태그(정확히 일치)
29+
* @return Optional Hash tag
30+
*/
31+
Optional<HashTag> findByTag(String tag);
32+
33+
/**
34+
* tag 자동완성 시 사용할 메서드. 특정 검색어에 대해 cnt 로 정렬한 데이터를 반환합니다.<br>
35+
*
36+
* @param value 검색할 인자(접두사 / 순서대로)
37+
* @param pageable 반환 개수 지정
38+
* @return cnt 개수 만큼의, 사용 빈도가 높은 데이터
39+
*/
40+
@Query(
41+
"""
42+
SELECT t.tag
43+
FROM HashTag t
44+
WHERE t.tag LIKE CONCAT('%', :value, '%')
45+
ORDER BY t.usedCount DESC
46+
""")
47+
List<String> search(@Param("value") String value, Pageable pageable);
48+
49+
/**
50+
* usedCount 값을 원자적으로 증가시키는 메서드입니다. 해당 메서드가 실행 되면
51+
* 이미 해당 태그를 사용했다는 의미이므로 deletedAt 을 초기화합니다.
52+
* @param tag 증가시킬 태그
53+
* @return 변경된 row 수
54+
*/
55+
@Modifying
56+
@Query(
57+
"""
58+
UPDATE HashTag t
59+
SET t.usedCount = t.usedCount + 1,
60+
t.deletedAt = null
61+
WHERE t.tag = :tag
62+
""")
63+
Integer increaseUsedCount(String tag);
64+
65+
/**
66+
* usedCount 값을 원자적으로 증가시키는 메서드입니다. 단, 여러 tags 들에 대해 연산을 수행합니다.
67+
* @param tags 증가시킬 태그들
68+
* @return 변경된 row 수
69+
*/
70+
@Modifying
71+
@Query(
72+
"""
73+
UPDATE HashTag t
74+
SET t.usedCount = t.usedCount + 1,
75+
t.deletedAt = null
76+
WHERE t.tag in (:tags)
77+
""")
78+
void increaseUsedCountBulk(@Param("tags") Collection<String> tags);
79+
80+
/**
81+
* usedCount 값을 원자적으로 감소시키는 메서드입니다. 만약 0이 되면,
82+
* 그때부터 deletedAt 을 현재 시간으로 설정합니다. n 일 이후 자동 삭제됩니다(최적화, 배치 서버 분리)
83+
* @param tag 감소시킬 태그
84+
* @return 변경된 row 수
85+
*/
86+
@Modifying
87+
@Query(
88+
"""
89+
UPDATE HashTag t
90+
SET t.usedCount = t.usedCount - 1,
91+
t.deletedAt = CASE
92+
WHEN (t.usedCount - 1) = 0 THEN CURRENT_TIMESTAMP
93+
ELSE t.deletedAt
94+
END
95+
WHERE t.tag = :tag
96+
AND t.usedCount > 0
97+
""")
98+
Long decreaseUsedCount(String tag);
99+
100+
/**
101+
* tag 리스트에 대한 전체 조회 반환 메서드입니다.
102+
* @param tags 문자열 리스트
103+
* @return 파라미터에 대해 정확히 일치하는 hash tag 엔티티 리스트
104+
*/
105+
List<HashTag> findAllByTagIn(Collection<String> tags);
106+
}

src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package com.studypals.domain.groupManage.dto;
22

3+
import java.util.List;
4+
35
import jakarta.validation.constraints.Max;
46
import jakarta.validation.constraints.Min;
57
import jakarta.validation.constraints.NotBlank;
8+
import jakarta.validation.constraints.Size;
9+
10+
import com.fasterxml.jackson.annotation.JsonSetter;
11+
import com.fasterxml.jackson.annotation.Nulls;
612

713
/**
814
* 그룹 생성 시 사용되는 DTO 입니다.
@@ -24,4 +30,5 @@ public record CreateGroupReq(
2430
Boolean isOpen,
2531
Boolean isApprovalRequired,
2632
// since 12-05 sanghyeok
27-
String imageUrl) {}
33+
String imageUrl,
34+
@JsonSetter(nulls = Nulls.AS_EMPTY) @Size(max = 10) List<@NotBlank @Size(max = 20) String> hashTags) {}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.studypals.domain.groupManage.entity;
2+
3+
import jakarta.persistence.*;
4+
5+
import lombok.*;
6+
7+
/**
8+
* group 과 hashTag 간의 매핑 테이블입니다. hashTag 조회 시 N + 1 문제를 조심해야 할 필요가 있습니다.
9+
*
10+
* @author jack8
11+
* @see HashTag
12+
* @see Group
13+
* @since 2025-12-23
14+
*/
15+
@Entity
16+
@Builder
17+
@Getter
18+
@AllArgsConstructor
19+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
20+
@Table(
21+
name = "group_hash_tag",
22+
uniqueConstraints = {@UniqueConstraint(columnNames = {"group_id", "hash_tag_id"})})
23+
public class GroupHashTag {
24+
25+
@Id
26+
@GeneratedValue(strategy = GenerationType.IDENTITY)
27+
@Column(name = "id", nullable = false, unique = true)
28+
private Long id;
29+
30+
@ManyToOne(fetch = FetchType.LAZY)
31+
@JoinColumn(name = "group_id")
32+
private Group group;
33+
34+
@ManyToOne(fetch = FetchType.LAZY)
35+
@JoinColumn(name = "hash_tag_id")
36+
private HashTag hashTag;
37+
38+
@Column(name = "display_tag", nullable = false)
39+
private String displayTag;
40+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.studypals.domain.groupManage.entity;
2+
3+
import java.time.LocalDate;
4+
5+
import jakarta.persistence.*;
6+
7+
import lombok.*;
8+
9+
/**
10+
* group 에 대한 hashtag 엔티티입니다. group 과 N:M 관계이며 중간 매핑 테이블이 존재합니다.
11+
*
12+
* @author jack8
13+
* @see Group
14+
* @since 2025-12-23
15+
*/
16+
@Entity
17+
@AllArgsConstructor
18+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
19+
@Builder
20+
@Getter
21+
@Table(
22+
name = "hash_tag",
23+
uniqueConstraints = {@UniqueConstraint(columnNames = "tag")})
24+
public class HashTag {
25+
26+
@Id
27+
@GeneratedValue(strategy = GenerationType.IDENTITY)
28+
@Column(name = "id", nullable = false, unique = true)
29+
private Long id;
30+
31+
@Column(name = "tag", nullable = false, unique = true)
32+
private String tag;
33+
34+
@Builder.Default
35+
@Column(name = "used_count", nullable = false)
36+
private Long usedCount = 1L;
37+
38+
@Column(name = "deleted_at", nullable = true)
39+
private LocalDate deletedAt;
40+
}

src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.util.Map;
66
import java.util.stream.Collectors;
77

8+
import org.springframework.dao.DataIntegrityViolationException;
89
import org.springframework.stereotype.Service;
910
import org.springframework.transaction.annotation.Transactional;
1011

@@ -20,6 +21,7 @@
2021
import com.studypals.domain.groupManage.worker.*;
2122
import com.studypals.domain.memberManage.entity.Member;
2223
import com.studypals.domain.memberManage.worker.MemberReader;
24+
import com.studypals.global.retry.RetryTx;
2325
import com.studypals.domain.studyManage.dto.GroupCategoryDto;
2426
import com.studypals.domain.studyManage.entity.StudyType;
2527
import com.studypals.domain.studyManage.worker.StudyCategoryReader;
@@ -50,6 +52,10 @@ public class GroupServiceImpl implements GroupService {
5052
private final GroupAuthorityValidator validator;
5153
private final GroupMapper groupMapper;
5254
private final GroupGoalCalculator groupGoalCalculator;
55+
56+
private final GroupHashTagWorker groupHashTagWorker;
57+
58+
// chat room worker class
5359
private final ChatRoomWriter chatRoomWriter;
5460
private final StudyCategoryReader studyCategoryReader;
5561

@@ -59,13 +65,17 @@ public List<GetGroupTagRes> getGroupTags() {
5965
}
6066

6167
@Override
62-
@Transactional
68+
@RetryTx(
69+
maxAttempts = 2,
70+
retryFor = {DataIntegrityViolationException.class})
6371
public Long createGroup(Long userId, CreateGroupReq dto) {
6472
// 그룹 생성
6573
Group group = groupWriter.create(dto);
6674
Member member = memberReader.getRef(userId);
6775
groupMemberWriter.createLeader(member, group);
6876

77+
groupHashTagWorker.saveTags(group, dto.hashTags());
78+
6979
// 채팅방 생성
7080
CreateChatRoomDto createChatRoomDto = new CreateChatRoomDto(dto.name(), dto.imageUrl());
7181
ChatRoom chatRoom = chatRoomWriter.create(createChatRoomDto);

0 commit comments

Comments
 (0)