Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
application-local.properties

### STS ###
.apt_generated
Expand Down
9 changes: 7 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.mapstruct:mapstruct:1.6.3'

implementation 'org.springframework.boot:spring-boot-starter-aop'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'

Expand Down Expand Up @@ -139,7 +139,12 @@ tasks.named('test', Test) {
// REST Docs snippets 출력 디렉터리
outputs.dir(snippetsDir)
environment envProps
systemProperty 'spring.profiles.active', 'test'
def active =
System.getProperty("spring.profiles.active")
?: System.getenv("SPRING_PROFILES_ACTIVE")
?: "test"

systemProperty "spring.profiles.active", active

testLogging {
events "passed", "skipped", "failed"
Expand Down
Empty file modified gradlew
100755 → 100644
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.studypals.domain.groupManage.dao;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import com.studypals.domain.groupManage.entity.GroupHashTag;

/**
* {@link GroupHashTag} 에 대한 dao 클래스입니다.
*
* @author jack8
* @see GroupHashTag
* @since 2025-12-23
*/
@Repository
public interface GroupHashTagRepository extends JpaRepository<GroupHashTag, Long> {

/**
* 일반적인 {@code findAllByGroupId} 와 결과가 동일하나, hashTag 에 대한 fetch join 을 통한
* N+1 문제를 방지하였습니다.
* @param groupId 검색하고자 하는 그룹의 아이디
* @return hash tag 가 fetch join 된 groupHashTag 리스트
*/
@Query(
"""
SELECT gt
FROM GroupHashTag gt
JOIN FETCH gt.hashTag
WHERE gt.group.id = :groupId
""")
List<GroupHashTag> findAllByGroupIdWithTag(@Param("groupId") Long groupId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.studypals.domain.groupManage.dao;

import java.util.Collection;
import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import com.studypals.domain.groupManage.entity.HashTag;

/**
* {@link HashTag} 에 대한 dao 클래스입니다.
*
* @author jack8
* @see HashTag
* @since 2025-12-23
*/
@Repository
public interface HashTagRepository extends JpaRepository<HashTag, Long> {

/**
* tag 에 대해 객체를 반환합니다.
* @param tag 검색할 태그(정확히 일치)
* @return Optional Hash tag
*/
Optional<HashTag> findByTag(String tag);

/**
* tag 자동완성 시 사용할 메서드. 특정 검색어에 대해 cnt 로 정렬한 데이터를 반환합니다.<br>
*
* @param value 검색할 인자(접두사 / 순서대로)
* @param pageable 반환 개수 지정
* @return cnt 개수 만큼의, 사용 빈도가 높은 데이터
*/
@Query(
"""
SELECT t.tag
FROM HashTag t
WHERE t.tag LIKE CONCAT('%', :value, '%')
ORDER BY t.usedCount DESC
""")
List<String> search(@Param("value") String value, Pageable pageable);

/**
* usedCount 값을 원자적으로 증가시키는 메서드입니다. 해당 메서드가 실행 되면
* 이미 해당 태그를 사용했다는 의미이므로 deletedAt 을 초기화합니다.
* @param tag 증가시킬 태그
* @return 변경된 row 수
*/
@Modifying
@Query(
"""
UPDATE HashTag t
SET t.usedCount = t.usedCount + 1,
t.deletedAt = null
WHERE t.tag = :tag
""")
Integer increaseUsedCount(String tag);

/**
* usedCount 값을 원자적으로 증가시키는 메서드입니다. 단, 여러 tags 들에 대해 연산을 수행합니다.
* @param tags 증가시킬 태그들
* @return 변경된 row 수
*/
@Modifying
@Query(
"""
UPDATE HashTag t
SET t.usedCount = t.usedCount + 1,
t.deletedAt = null
WHERE t.tag in (:tags)
""")
void increaseUsedCountBulk(@Param("tags") Collection<String> tags);

/**
* usedCount 값을 원자적으로 감소시키는 메서드입니다. 만약 0이 되면,
* 그때부터 deletedAt 을 현재 시간으로 설정합니다. n 일 이후 자동 삭제됩니다(최적화, 배치 서버 분리)
* @param tag 감소시킬 태그
* @return 변경된 row 수
*/
@Modifying
@Query(
"""
UPDATE HashTag t
SET t.usedCount = t.usedCount - 1,
t.deletedAt = CASE
WHEN (t.usedCount - 1) = 0 THEN CURRENT_TIMESTAMP
ELSE t.deletedAt
END
WHERE t.tag = :tag
AND t.usedCount > 0
""")
Long decreaseUsedCount(String tag);

/**
* tag 리스트에 대한 전체 조회 반환 메서드입니다.
* @param tags 문자열 리스트
* @return 파라미터에 대해 정확히 일치하는 hash tag 엔티티 리스트
*/
List<HashTag> findAllByTagIn(Collection<String> tags);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package com.studypals.domain.groupManage.dto;

import java.util.List;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.annotation.Nulls;

/**
* 그룹 생성 시 사용되는 DTO 입니다.
Expand All @@ -24,4 +30,5 @@ public record CreateGroupReq(
Boolean isOpen,
Boolean isApprovalRequired,
// since 12-05 sanghyeok
String imageUrl) {}
String imageUrl,
@JsonSetter(nulls = Nulls.AS_EMPTY) @Size(max = 10) List<@NotBlank @Size(max = 20) String> hashTags) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.studypals.domain.groupManage.entity;

import jakarta.persistence.*;

import lombok.*;

/**
* group 과 hashTag 간의 매핑 테이블입니다. hashTag 조회 시 N + 1 문제를 조심해야 할 필요가 있습니다.
*
* @author jack8
* @see HashTag
* @see Group
* @since 2025-12-23
*/
@Entity
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
name = "group_hash_tag",
uniqueConstraints = {@UniqueConstraint(columnNames = {"group_id", "hash_tag_id"})})
public class GroupHashTag {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false, unique = true)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id")
private Group group;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "hash_tag_id")
private HashTag hashTag;

@Column(name = "display_tag", nullable = false)
private String displayTag;
}
40 changes: 40 additions & 0 deletions src/main/java/com/studypals/domain/groupManage/entity/HashTag.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.studypals.domain.groupManage.entity;

import java.time.LocalDate;

import jakarta.persistence.*;

import lombok.*;

/**
* group 에 대한 hashtag 엔티티입니다. group 과 N:M 관계이며 중간 매핑 테이블이 존재합니다.
*
* @author jack8
* @see Group
* @since 2025-12-23
*/
@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@Getter
@Table(
name = "hash_tag",
uniqueConstraints = {@UniqueConstraint(columnNames = "tag")})
public class HashTag {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false, unique = true)
private Long id;

@Column(name = "tag", nullable = false, unique = true)
private String tag;

@Builder.Default
@Column(name = "used_count", nullable = false)
private Long usedCount = 1L;

@Column(name = "deleted_at", nullable = true)
private LocalDate deletedAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.Map;
import java.util.stream.Collectors;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -20,6 +21,7 @@
import com.studypals.domain.groupManage.worker.*;
import com.studypals.domain.memberManage.entity.Member;
import com.studypals.domain.memberManage.worker.MemberReader;
import com.studypals.global.retry.RetryTx;
import com.studypals.domain.studyManage.dto.GroupCategoryDto;
import com.studypals.domain.studyManage.entity.StudyType;
import com.studypals.domain.studyManage.worker.StudyCategoryReader;
Expand Down Expand Up @@ -50,6 +52,10 @@ public class GroupServiceImpl implements GroupService {
private final GroupAuthorityValidator validator;
private final GroupMapper groupMapper;
private final GroupGoalCalculator groupGoalCalculator;

private final GroupHashTagWorker groupHashTagWorker;

// chat room worker class
private final ChatRoomWriter chatRoomWriter;
private final StudyCategoryReader studyCategoryReader;

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

@Override
@Transactional
@RetryTx(
maxAttempts = 2,
retryFor = {DataIntegrityViolationException.class})
public Long createGroup(Long userId, CreateGroupReq dto) {
// 그룹 생성
Group group = groupWriter.create(dto);
Member member = memberReader.getRef(userId);
groupMemberWriter.createLeader(member, group);

groupHashTagWorker.saveTags(group, dto.hashTags());

// 채팅방 생성
CreateChatRoomDto createChatRoomDto = new CreateChatRoomDto(dto.name(), dto.imageUrl());
ChatRoom chatRoom = chatRoomWriter.create(createChatRoomDto);
Expand Down
Loading