From 0b6aa3c685a891f407dcec5a6fd851016d1f032f Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 23 Dec 2025 09:36:08 +0900 Subject: [PATCH 01/17] =?UTF-8?q?Feat:=20=ED=95=B4=EC=8B=9C=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hashTag 및 group 과의 매핑 테이블 groupHashTag 정의 - mapping table 상에서 group/hashtag 에 대한 lazy loading - tag 에 대한 유니크(+인덱싱) - groupHashTag 에서 group 에 대한 인덱싱 Ref: #132 --- .../groupManage/entity/GroupHashTag.java | 51 +++++++++++++++++++ .../domain/groupManage/entity/HashTag.java | 40 +++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java create mode 100644 src/main/java/com/studypals/domain/groupManage/entity/HashTag.java diff --git a/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java b/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java new file mode 100644 index 00000000..c05bc2f1 --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java @@ -0,0 +1,51 @@ +package com.studypals.domain.groupManage.entity; + +import jakarta.persistence.*; + +import lombok.*; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2025-12-23 + */ +@Entity +@Builder +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "group_hash_tag", + indexes = {@Index(name = "idx_grouphashtag_group_id", columnList = "group_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; +} diff --git a/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java b/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java new file mode 100644 index 00000000..30bc4989 --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java @@ -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, columnDefinition = "DEFAULT 0") + private Long usedCount = 0L; + + @Column(name = "last_used_date", nullable = true) + private LocalDate lastUsedDate; +} From 0d358e3436b4a23585296437416cd799763d1ccb Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 23 Dec 2025 10:21:44 +0900 Subject: [PATCH 02/17] =?UTF-8?q?Feat:=20=ED=95=B4=EC=8B=9C=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20dao=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usedCount 필드 원자적 증감/soft delete 추가 - n+1 방지 fetch join 메서드 추가 - 자동완성 용 prefix 용 조회 메서드 추가 Ref: #132 --- .../dao/GroupHashTagRepository.java | 36 ++++++++ .../groupManage/dao/HashTagRepository.java | 82 +++++++++++++++++++ .../groupManage/entity/GroupHashTag.java | 20 +---- .../domain/groupManage/entity/HashTag.java | 8 +- 4 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java create mode 100644 src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java diff --git a/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java new file mode 100644 index 00000000..0e92cf8a --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/dao/GroupHashTagRepository.java @@ -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 { + + /** + * 일반적인 {@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 findAllByGroupIdWithTag(@Param("groupId") Long groupId); +} diff --git a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java new file mode 100644 index 00000000..fc9b5497 --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java @@ -0,0 +1,82 @@ +package com.studypals.domain.groupManage.dao; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +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 { + + /** + * tag 에 대해 객체를 반환합니다. + * @param tag 검색할 태그(정확히 일치) + * @return Optional Hash tag + */ + Optional findByTag(String tag); + + /** + * tag 자동완성 시 사용할 메서드. prefix 에 대해 cnt 값 만큼의 자주 사용되는 데이터를 반환합니다. + * + * @param prefix 검색할 인자(접두사 / 순서대로) + * @param cnt 반환 데이터 최대 개수 + * @return cnt 개수 만큼의, 사용 빈도가 높은 데이터 + */ + @Query( + """ + SELECT t.tag + FROM HashTag t + WHERE t.tag LIKE CONCAT(:prefix, '%') + ORDER BY t.usedCount DESC + LIMIT :cnt + """) + List findNamesByPrefix(String prefix, int cnt); + + /** + * usedCount 값을 원자적으로 증가시키는 메서드입니다. 해당 메서드가 실행 되면 + * 이미 해당 태그를 사용했다는 의미이므로 deletedAt 을 초기화합니다. + * @param tag 증가시킬 태그 + * @return 변경된 row 수 + */ + @Modifying + @Query( + """ + UPDATE HashTag t + SET t.usedCount = t.usedCount + 1, + t.deletedAt = null + WHERE t.tag = :tag + """) + Long increaseUsedCount(String tag); + + /** + * usedCount 값을 원자적으로 감소시키는 메서드입니다. 만약 0이 되면, + * 그때부터 deletedAt 을 현재 시간으로 설정합니다. n 일 이후 자동 삭제됩니다(최적화, 배치 서버 분리) + * @param tag 감소시킬 태그 + * @return 변경된 row 수 + */ + @Modifying(clearAutomatically = true) + @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); +} diff --git a/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java b/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java index c05bc2f1..08552df2 100644 --- a/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java +++ b/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java @@ -5,25 +5,11 @@ import lombok.*; /** - * 코드에 대한 전체적인 역할을 적습니다. - *

- * 코드에 대한 작동 원리 등을 적습니다. - * - *

상속 정보:
- * 상속 정보를 적습니다. - * - *

주요 생성자:
- * {@code ExampleClass(String example)}
- * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
- * - *

빈 관리:
- * 필요 시 빈 관리에 대한 내용을 적습니다. - * - *

외부 모듈:
- * 필요 시 외부 모듈에 대한 내용을 적습니다. + * group 과 hashTag 간의 매핑 테이블입니다. hashTag 조회 시 N + 1 문제를 조심해야 할 필요가 있습니다. * * @author jack8 - * @see + * @see HashTag + * @see Group * @since 2025-12-23 */ @Entity diff --git a/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java b/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java index 30bc4989..9eff3965 100644 --- a/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java +++ b/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java @@ -32,9 +32,9 @@ public class HashTag { private String tag; @Builder.Default - @Column(name = "used_count", nullable = false, columnDefinition = "DEFAULT 0") - private Long usedCount = 0L; + @Column(name = "used_count", nullable = false, columnDefinition = "DEFAULT 1") + private Long usedCount = 1L; - @Column(name = "last_used_date", nullable = true) - private LocalDate lastUsedDate; + @Column(name = "deleted_at", nullable = true) + private LocalDate deletedAt; } From 0668238ad1149393132d4b6e52aba6dffe461600 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 23 Dec 2025 11:01:09 +0900 Subject: [PATCH 03/17] =?UTF-8?q?Feat:=20displayTag=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실제 저장되는 값과, 보여지는 데이터를 분리하여 보여지는 데이터를 groupHashTag 에 추가 Ref: #132 --- .../com/studypals/domain/groupManage/entity/GroupHashTag.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java b/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java index 08552df2..da5e54fa 100644 --- a/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java +++ b/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java @@ -34,4 +34,7 @@ public class GroupHashTag { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "hash_tag_id") private HashTag hashTag; + + @Column(name = "display_tag", nullable = false) + private String displayTag; } From 74b966df0d76dbeb3d990fea886de4fe28ca77eb Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 23 Dec 2025 12:19:19 +0900 Subject: [PATCH 04/17] =?UTF-8?q?Feat:=20=EA=B7=B8=EB=A3=B9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20hash=20tag=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80(service~)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 해시태그 정규화 및 ui 상 보여지는 이름 분리(실제 저장과 외부 노출 값 분리) - 동시성 문제로 인한 unique 예외에 대비한 1회 재시도 전략 - 이미 존재하는 태그는 1 추가/ 아닌 경우는 새롭게 생성 Ref: #132 --- .../groupManage/dao/HashTagRepository.java | 23 +++ .../groupManage/dto/CreateGroupReq.java | 6 +- .../groupManage/service/GroupServiceImpl.java | 5 + .../worker/GroupHashTagWorker.java | 159 ++++++++++++++++++ .../studypals/global/utils/StringUtils.java | 58 +++++++ 5 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java create mode 100644 src/main/java/com/studypals/global/utils/StringUtils.java diff --git a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java index fc9b5497..653e20f2 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java @@ -1,5 +1,6 @@ package com.studypals.domain.groupManage.dao; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -60,6 +61,21 @@ WHERE t.tag LIKE CONCAT(:prefix, '%') """) Long 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(Collection tags); + /** * usedCount 값을 원자적으로 감소시키는 메서드입니다. 만약 0이 되면, * 그때부터 deletedAt 을 현재 시간으로 설정합니다. n 일 이후 자동 삭제됩니다(최적화, 배치 서버 분리) @@ -79,4 +95,11 @@ WHERE t.tag LIKE CONCAT(:prefix, '%') AND t.usedCount > 0 """) Long decreaseUsedCount(String tag); + + /** + * tag 리스트에 대한 전체 조회 반환 메서드입니다. + * @param tags 문자열 리스트 + * @return 파라미터에 대해 정확히 일치하는 hash tag 엔티티 리스트 + */ + List findAllByTagIn(Collection tags); } diff --git a/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java b/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java index bf65b49e..94db95c1 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java @@ -1,5 +1,7 @@ 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; @@ -24,4 +26,6 @@ public record CreateGroupReq( Boolean isOpen, Boolean isApprovalRequired, // since 12-05 sanghyeok - String imageUrl) {} + String imageUrl, + // since 12-23 sanghyeok(#132) + List hashTags) {} diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java index e40267db..ae1ed449 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -44,6 +44,8 @@ public class GroupServiceImpl implements GroupService { private final GroupMapper groupMapper; private final GroupGoalCalculator groupGoalCalculator; + private final GroupHashTagWorker groupHashTagWorker; + // chat room worker class private final ChatRoomWriter chatRoomWriter; @@ -60,6 +62,9 @@ public Long createGroup(Long userId, CreateGroupReq dto) { Member member = memberReader.getRef(userId); groupMemberWriter.createLeader(member, group); + // 해시태그 삽입 (12-23 #132 sang) + groupHashTagWorker.saveTags(group, dto.hashTags()); + // 채팅방 생성 CreateChatRoomDto createChatRoomDto = new CreateChatRoomDto(dto.name(), dto.imageUrl()); ChatRoom chatRoom = chatRoomWriter.create(createChatRoomDto); diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java new file mode 100644 index 00000000..1c84d56e --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java @@ -0,0 +1,159 @@ +package com.studypals.domain.groupManage.worker; + +import java.util.*; + +import org.springframework.dao.DataIntegrityViolationException; + +import lombok.RequiredArgsConstructor; + +import com.studypals.domain.groupManage.dao.GroupHashTagRepository; +import com.studypals.domain.groupManage.dao.HashTagRepository; +import com.studypals.domain.groupManage.entity.Group; +import com.studypals.domain.groupManage.entity.GroupHashTag; +import com.studypals.domain.groupManage.entity.HashTag; +import com.studypals.global.annotations.Worker; +import com.studypals.global.exceptions.errorCode.GroupErrorCode; +import com.studypals.global.exceptions.exception.GroupException; +import com.studypals.global.utils.StringUtils; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2025-12-23 + */ +@Worker +@RequiredArgsConstructor +public class GroupHashTagWorker { + + private final GroupHashTagRepository groupHashTagRepository; + private final HashTagRepository hashTagRepository; + private final StringUtils stringUtils; + + /** + * 그룹이 생성될 때, 설정한 tag 를 같이 저장하는 메서드입니다. 데이터 정규화 / 저장 on duplicate update 구현 + * 등이 되어 있습니다. + * @param group 저장대상 그룹 + * @param inputTags 그룹에 포함할 해시태그 문자열 + */ + public void saveTags(Group group, List inputTags) { + // hashTag 에 저장되는 정규화된 문자열과 사용자에게 보여질 입력 그대로 문자열을 분리 + Map normalized = toNormalizedAndRowMap(inputTags); + if (normalized.isEmpty()) return; + + Set normalizedTags = new HashSet<>(normalized.keySet()); + + // 기존 hash tag 조회 + List exists = hashTagRepository.findAllByTagIn(normalizedTags); + List notExists = notExistTagToCreate(normalizedTags, exists); + + // 이미 존재하는 경우 usedCount 1 증가 + if (!exists.isEmpty()) { + hashTagRepository.increaseUsedCountBulk( + exists.stream().map(HashTag::getTag).toList()); + } + + if (!notExists.isEmpty()) { + try { + hashTagRepository.saveAll(notExists); + hashTagRepository.flush(); // unique 제약 조건 발생용 flush + } catch (DataIntegrityViolationException e) { // 저장 실패 시 동시성 문제라 생각하고 1회 재시도 + notExists = retrySave(notExists.stream().map(HashTag::getTag).toList()); + } + } + // 모두 저장이 되었으니 exists 로 상태 변경 + exists.addAll(notExists); + + // 저장된 hashtag 를 기반으로 groupHashTag 저장 + List groupHashTags = new ArrayList<>(); + for (HashTag tag : exists) { + String val = normalized.get(tag.getTag()) == null ? tag.getTag() : normalized.get(tag.getTag()); + + groupHashTags.add(GroupHashTag.builder() + .hashTag(tag) + .displayTag(val) + .group(group) + .build()); + } + groupHashTagRepository.saveAll(groupHashTags); + } + + /** + * 저장 재시도 메서드입니다. 새로운 hashtag 를 사용하여 그룹을 생성하는 와중, 조회할 당시에는 없는 + * 해시태그였으나 그 사이에 다른 세션에서 해시태그를 추가할 경우 unique 제약 조건이 생길 수 있습니다. + * 이를 고려하여 단 1회 저장을 재시도하는 메서드입니다. + * @param failToSave 저장에 실패한 엔티티 + * @return 저장 및 조회 성공 후 영속화되어 있는 엔티티. 즉, 현재 DB 에 모두 존재하는 hashTag + */ + private List retrySave(List failToSave) { + List reExists = hashTagRepository.findAllByTagIn(failToSave); + List reNotExists = notExistTagToCreate(new HashSet<>(failToSave), reExists); + if (!reExists.isEmpty()) { + hashTagRepository.increaseUsedCountBulk( + reExists.stream().map(HashTag::getTag).toList()); + } + if (!reNotExists.isEmpty()) { + try { + hashTagRepository.saveAll(reNotExists); + hashTagRepository.flush(); // unique 제약 조건 발생용 flush + } catch (DataIntegrityViolationException e) { + throw new GroupException( + GroupErrorCode.GROUP_CREATE_FAIL, "[GroupHashTagWorker#retrySave] cannot save hash tag"); + } + } + reExists.addAll(reNotExists); + + return reExists; + } + + /** + * 각 태그에 대한 정규화 진행. 띄어쓰기는 _ 로 대체, 중복된 띄어쓰기 제거/특수문제 제거, trim 제거, lowercase + * @param tags 정규화 대상 리스트 + * @return 정규화된 문자열 / raw 문자열로 이루어진 map + */ + private Map toNormalizedAndRowMap(List tags) { + Map result = new HashMap<>(); + for (String tag : tags) { + if (tag.isEmpty()) continue; + String norm = stringUtils.normalize(tag); + if (norm == null || norm.isBlank()) continue; + result.putIfAbsent(norm, tag); + } + + return result; + } + + /** + * 기존에 DB에 존재하지 않는 hash tag 를 찾아 새로운 엔티티를 생성하는 메서드 + * @param reqData 해시태그 생성을 요청하는 데이터, DB 에 이미 존재하거나 없어서 새로 만들어야 되는 값이 합쳐져 있음 + * @param existTags DB 에 이미 존재하는 데이터 + * @return 새롭게 만들어지는 영속화 전 데이터 + */ + private List notExistTagToCreate(Set reqData, List existTags) { + List result = new ArrayList<>(); + for (HashTag tag : existTags) { + reqData.remove(tag.getTag()); + } + for (String str : reqData) { + result.add(HashTag.builder().tag(str).usedCount(1L).build()); + } + + return result; + } +} diff --git a/src/main/java/com/studypals/global/utils/StringUtils.java b/src/main/java/com/studypals/global/utils/StringUtils.java new file mode 100644 index 00000000..55ccd8a3 --- /dev/null +++ b/src/main/java/com/studypals/global/utils/StringUtils.java @@ -0,0 +1,58 @@ +package com.studypals.global.utils; + +import java.util.regex.Pattern; + +import org.springframework.stereotype.Component; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2025-12-23 + */ +@Component +public class StringUtils { + + private static final Pattern DISALLOWED = Pattern.compile("[^a-zA-Z0-9_\\s]"); + private static final Pattern SPACES = Pattern.compile("\\s+"); + private static final Pattern MULTI_UNDERSCORE = Pattern.compile("_+"); + + public String normalize(String raw) { + if (raw == null) return null; + + String s = raw.trim(); + if (s.isEmpty()) return null; + + s = s.replace("#", ""); + + // 특수문자 제거(문자/숫자/공백/_ 만 남김) + s = DISALLOWED.matcher(s).replaceAll(""); + + // 공백 -> _ + s = SPACES.matcher(s).replaceAll("_"); + + // 연속 _ -> 하나로 + s = MULTI_UNDERSCORE.matcher(s).replaceAll("_"); + + // 앞뒤 _ 제거(앞뒤 공백 제거의 효과) + s = s.replaceAll("^_+|_+$", ""); + + return s.isBlank() ? null : s; + } +} From 69891f1a01a472d97ea2b99c5f7838b6443f43df Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 23 Dec 2025 12:20:57 +0900 Subject: [PATCH 05/17] =?UTF-8?q?Feat:=20groupHashTag=20=EC=97=90=20group-?= =?UTF-8?q?hashtag=20unique=20=EC=A0=9C=EC=95=BD=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #132 --- .../com/studypals/domain/groupManage/entity/GroupHashTag.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java b/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java index da5e54fa..35497c96 100644 --- a/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java +++ b/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java @@ -19,7 +19,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table( name = "group_hash_tag", - indexes = {@Index(name = "idx_grouphashtag_group_id", columnList = "group_id")}) + uniqueConstraints = {@UniqueConstraint(columnNames = {"group_id", "hash_tag_id"})}) public class GroupHashTag { @Id From be4864de5e01196a25e58d538238dce84de069c0 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Thu, 25 Dec 2025 18:23:32 +0900 Subject: [PATCH 06/17] =?UTF-8?q?Fix=20:=20copilot=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - naming, null check, 일부 잠재적인 동시성 오류 부분 수정 Ref: #142 --- .../groupManage/dao/HashTagRepository.java | 19 ++++---- .../groupManage/dto/CreateGroupReq.java | 4 +- .../domain/groupManage/entity/HashTag.java | 2 +- .../groupManage/service/GroupServiceImpl.java | 1 - .../worker/GroupHashTagWorker.java | 44 +++++++++---------- .../exceptions/errorCode/GroupErrorCode.java | 4 +- .../global/responses/ResponseCode.java | 1 + .../studypals/global/utils/StringUtils.java | 21 ++++++++- 8 files changed, 58 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java index 653e20f2..00ae8d7c 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java @@ -4,9 +4,11 @@ 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; @@ -32,18 +34,17 @@ public interface HashTagRepository extends JpaRepository { * tag 자동완성 시 사용할 메서드. prefix 에 대해 cnt 값 만큼의 자주 사용되는 데이터를 반환합니다. * * @param prefix 검색할 인자(접두사 / 순서대로) - * @param cnt 반환 데이터 최대 개수 + * @param pageable 반환 개수 지정 * @return cnt 개수 만큼의, 사용 빈도가 높은 데이터 */ @Query( """ - SELECT t.tag - FROM HashTag t - WHERE t.tag LIKE CONCAT(:prefix, '%') - ORDER BY t.usedCount DESC - LIMIT :cnt - """) - List findNamesByPrefix(String prefix, int cnt); + SELECT t.tag + FROM HashTag t + WHERE t.tag LIKE CONCAT(:prefix, '%') + ORDER BY t.usedCount DESC +""") + List findNamesByPrefix(@Param("prefix") String prefix, Pageable pageable); /** * usedCount 값을 원자적으로 증가시키는 메서드입니다. 해당 메서드가 실행 되면 @@ -88,7 +89,7 @@ WHERE t.tag LIKE CONCAT(:prefix, '%') UPDATE HashTag t SET t.usedCount = t.usedCount - 1, t.deletedAt = CASE - WHEN (t.usedCount - 1) = 0 THEN current_timestamp + WHEN (t.usedCount - 1) = 0 THEN CURRENT_TIMESTAMP ELSE t.deletedAt END WHERE t.tag = :tag diff --git a/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java b/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java index 94db95c1..a4563595 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; /** * 그룹 생성 시 사용되는 DTO 입니다. @@ -27,5 +28,4 @@ public record CreateGroupReq( Boolean isApprovalRequired, // since 12-05 sanghyeok String imageUrl, - // since 12-23 sanghyeok(#132) - List hashTags) {} + @Size(max = 10) List<@NotBlank @Size(max = 8) String> hashTags) {} diff --git a/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java b/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java index 9eff3965..cbd97536 100644 --- a/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java +++ b/src/main/java/com/studypals/domain/groupManage/entity/HashTag.java @@ -32,7 +32,7 @@ public class HashTag { private String tag; @Builder.Default - @Column(name = "used_count", nullable = false, columnDefinition = "DEFAULT 1") + @Column(name = "used_count", nullable = false) private Long usedCount = 1L; @Column(name = "deleted_at", nullable = true) diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java index ae1ed449..b65b61ca 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -62,7 +62,6 @@ public Long createGroup(Long userId, CreateGroupReq dto) { Member member = memberReader.getRef(userId); groupMemberWriter.createLeader(member, group); - // 해시태그 삽입 (12-23 #132 sang) groupHashTagWorker.saveTags(group, dto.hashTags()); // 채팅방 생성 diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java index 1c84d56e..af1bc942 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java @@ -17,25 +17,25 @@ import com.studypals.global.utils.StringUtils; /** - * 코드에 대한 전체적인 역할을 적습니다. + * 그룹 해시태그 관리를 담당하는 워커 클래스입니다. *

- * 코드에 대한 작동 원리 등을 적습니다. - * - *

상속 정보:
- * 상속 정보를 적습니다. - * - *

주요 생성자:
- * {@code ExampleClass(String example)}
- * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
- * - *

빈 관리:
- * 필요 시 빈 관리에 대한 내용을 적습니다. + * 사용자가 입력한 해시태그 목록을 정규화하여 {@link HashTag} 엔티티로 저장하고, + * 이미 존재하는 해시태그의 사용 횟수를 증가시키며, + * {@link Group} 과 {@link HashTag} 사이의 관계를 {@link GroupHashTag} 엔티티로 관리합니다. + *

+ * 해시태그 저장 시 UNIQUE 제약 조건으로 인한 {@link DataIntegrityViolationException} + * (동시성 충돌)을 감지하면, 해당 태그들에 대해 재조회 및 재저장을 수행하여 + * 동시성 문제를 완화합니다. * - *

외부 모듈:
- * 필요 시 외부 모듈에 대한 내용을 적습니다. + *

주요 기능:
+ * - 입력 태그 문자열을 저장용 태그와 표시용 태그로 분리 및 정규화
+ * - 존재하는 해시태그의 사용 횟수(usedCount) 증가
+ * - 새 해시태그 생성 시 동시성 충돌 처리 및 재시도
+ * - 그룹과 해시태그 간의 매핑({@link GroupHashTag}) 생성 및 저장
* * @author jack8 - * @see + * @see com.studypals.domain.groupManage.dao.GroupHashTagRepository + * @see com.studypals.domain.groupManage.dao.HashTagRepository * @since 2025-12-23 */ @Worker @@ -74,16 +74,16 @@ public void saveTags(Group group, List inputTags) { hashTagRepository.saveAll(notExists); hashTagRepository.flush(); // unique 제약 조건 발생용 flush } catch (DataIntegrityViolationException e) { // 저장 실패 시 동시성 문제라 생각하고 1회 재시도 - notExists = retrySave(notExists.stream().map(HashTag::getTag).toList()); + retrySave(notExists.stream().map(HashTag::getTag).toList()); } } - // 모두 저장이 되었으니 exists 로 상태 변경 - exists.addAll(notExists); + // 최종 결과물 재조회 + exists = hashTagRepository.findAllByTagIn(normalizedTags); // 저장된 hashtag 를 기반으로 groupHashTag 저장 List groupHashTags = new ArrayList<>(); for (HashTag tag : exists) { - String val = normalized.get(tag.getTag()) == null ? tag.getTag() : normalized.get(tag.getTag()); + String val = normalized.getOrDefault(tag.getTag(), tag.getTag()); groupHashTags.add(GroupHashTag.builder() .hashTag(tag) @@ -101,7 +101,7 @@ public void saveTags(Group group, List inputTags) { * @param failToSave 저장에 실패한 엔티티 * @return 저장 및 조회 성공 후 영속화되어 있는 엔티티. 즉, 현재 DB 에 모두 존재하는 hashTag */ - private List retrySave(List failToSave) { + private void retrySave(List failToSave) { List reExists = hashTagRepository.findAllByTagIn(failToSave); List reNotExists = notExistTagToCreate(new HashSet<>(failToSave), reExists); if (!reExists.isEmpty()) { @@ -114,12 +114,10 @@ private List retrySave(List failToSave) { hashTagRepository.flush(); // unique 제약 조건 발생용 flush } catch (DataIntegrityViolationException e) { throw new GroupException( - GroupErrorCode.GROUP_CREATE_FAIL, "[GroupHashTagWorker#retrySave] cannot save hash tag"); + GroupErrorCode.GROUP_HASHTAG_FAIL, "[GroupHashTagWorker#retrySave] cannot save hash tag"); } } reExists.addAll(reNotExists); - - return reExists; } /** diff --git a/src/main/java/com/studypals/global/exceptions/errorCode/GroupErrorCode.java b/src/main/java/com/studypals/global/exceptions/errorCode/GroupErrorCode.java index 1cf53bdd..84487283 100644 --- a/src/main/java/com/studypals/global/exceptions/errorCode/GroupErrorCode.java +++ b/src/main/java/com/studypals/global/exceptions/errorCode/GroupErrorCode.java @@ -44,7 +44,9 @@ public enum GroupErrorCode implements ErrorCode { GROUP_CATEGORY_NOT_FOUND(ResponseCode.GROUP_CATEGORY, HttpStatus.BAD_REQUEST, "can't findAndDelete group category"), GROUP_ENTRY_REQUEST_NOT_FOUND( - ResponseCode.GROUP_ENTRY_REQUEST, HttpStatus.NOT_FOUND, "can't findAndDelete group entry request"); + ResponseCode.GROUP_ENTRY_REQUEST, HttpStatus.NOT_FOUND, "can't findAndDelete group entry request"), + + GROUP_HASHTAG_FAIL(ResponseCode.GROUP_HASHTAG, HttpStatus.BAD_REQUEST, "fail to save group hash tag"); private final ResponseCode responseCode; private final HttpStatus status; diff --git a/src/main/java/com/studypals/global/responses/ResponseCode.java b/src/main/java/com/studypals/global/responses/ResponseCode.java index 14eb089a..1258ba6d 100644 --- a/src/main/java/com/studypals/global/responses/ResponseCode.java +++ b/src/main/java/com/studypals/global/responses/ResponseCode.java @@ -42,6 +42,7 @@ public enum ResponseCode { GROUP_ENTRY_REQUEST_LIST("U02-16"), GROUP_LIST("U02-17"), GROUP_DETAIL("U02-18"), + GROUP_HASHTAG("U02-19"), // U03 - User Study & Time STUDY_TIME_ALL("U03-00"), diff --git a/src/main/java/com/studypals/global/utils/StringUtils.java b/src/main/java/com/studypals/global/utils/StringUtils.java index 55ccd8a3..78463862 100644 --- a/src/main/java/com/studypals/global/utils/StringUtils.java +++ b/src/main/java/com/studypals/global/utils/StringUtils.java @@ -33,6 +33,25 @@ public class StringUtils { private static final Pattern SPACES = Pattern.compile("\\s+"); private static final Pattern MULTI_UNDERSCORE = Pattern.compile("_+"); + /** + * 주어진 문자열을 정규화합니다. + *

+ * 동작 순서는 다음과 같습니다. + *

    + *
  1. {@code raw}가 {@code null}이면 {@code null}을 반환합니다.
  2. + *
  3. 앞뒤 공백을 제거합니다.
  4. + *
  5. 공백 제거 후 비어 있으면 {@code null}을 반환합니다.
  6. + *
  7. {@code #} 문자를 모두 제거합니다.
  8. + *
  9. 영문자, 숫자, 공백, 밑줄({@code _})을 제외한 모든 문자를 제거합니다.
  10. + *
  11. 모든 공백 문자를 밑줄({@code _})로 변환합니다.
  12. + *
  13. 연속된 밑줄을 하나의 밑줄로 축소합니다.
  14. + *
  15. 앞뒤의 밑줄을 제거합니다.
  16. + *
+ * + * @param raw 정규화할 원본 문자열. {@code null}일 수 있습니다. + * @return 정규화된 문자열. 입력이 {@code null}이거나, 정규화 결과가 비어 있거나 + * 공백뿐인 경우에는 {@code null}을 반환합니다. + */ public String normalize(String raw) { if (raw == null) return null; @@ -53,6 +72,6 @@ public String normalize(String raw) { // 앞뒤 _ 제거(앞뒤 공백 제거의 효과) s = s.replaceAll("^_+|_+$", ""); - return s.isBlank() ? null : s; + return s; } } From abdd478e2e620e1e6e7752ca98fc6547727dd037 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Sun, 4 Jan 2026 21:08:19 +0900 Subject: [PATCH 07/17] =?UTF-8?q?Feat:=20=EC=9E=AC=EC=8B=9C=EB=8F=84?= =?UTF-8?q?=EC=9A=A9=20retry=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transactional 을 덮는 RetryTx 어노테이션 추가 Ref: #142 --- build.gradle | 2 +- .../com/studypals/global/retry/RetryTx.java | 71 ++++++++++ .../studypals/global/retry/RetryTxAspect.java | 131 ++++++++++++++++++ .../global/retry/RetryTxAspectTest.java | 104 ++++++++++++++ 4 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/studypals/global/retry/RetryTx.java create mode 100644 src/main/java/com/studypals/global/retry/RetryTxAspect.java create mode 100644 src/test/java/com/studypals/global/retry/RetryTxAspectTest.java diff --git a/build.gradle b/build.gradle index f1b0c097..ec2b4fda 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/com/studypals/global/retry/RetryTx.java b/src/main/java/com/studypals/global/retry/RetryTx.java new file mode 100644 index 00000000..42f12e48 --- /dev/null +++ b/src/main/java/com/studypals/global/retry/RetryTx.java @@ -0,0 +1,71 @@ +package com.studypals.global.retry; + +import java.lang.annotation.*; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.dao.PessimisticLockingFailureException; +import org.springframework.dao.TransientDataAccessException; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2026-01-03 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Transactional +public @interface RetryTx { + + int maxAttempts() default 3; + + long backoffMs() default 200; + + double multiplier() default 1.0; + + long maxBackoffMs() default 2_000; + + Class[] retryFor() default { + PessimisticLockingFailureException.class, TransientDataAccessException.class + }; + + Class[] noRetryFor() default {}; + + @AliasFor(annotation = Transactional.class, attribute = "propagation") + Propagation propagation() default Propagation.REQUIRED; + + @AliasFor(annotation = Transactional.class, attribute = "isolation") + Isolation isolation() default Isolation.DEFAULT; + + @AliasFor(annotation = Transactional.class, attribute = "timeout") + int timeout() default -1; + + @AliasFor(annotation = Transactional.class, attribute = "readOnly") + boolean readOnly() default false; + + @AliasFor(annotation = Transactional.class, attribute = "rollbackFor") + Class[] rollbackFor() default {}; + + @AliasFor(annotation = Transactional.class, attribute = "noRollbackFor") + Class[] noRollbackFor() default {}; +} diff --git a/src/main/java/com/studypals/global/retry/RetryTxAspect.java b/src/main/java/com/studypals/global/retry/RetryTxAspect.java new file mode 100644 index 00000000..c77af4ea --- /dev/null +++ b/src/main/java/com/studypals/global/retry/RetryTxAspect.java @@ -0,0 +1,131 @@ +package com.studypals.global.retry; + +import java.lang.reflect.UndeclaredThrowableException; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2026-01-03 + */ +@Slf4j +@Aspect +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RetryTxAspect { + + @Around("@annotation(retryTx)") + public Object around(ProceedingJoinPoint pjp, RetryTx retryTx) throws Throwable { + + int maxAttempts = Math.max(1, retryTx.maxAttempts()); + long backoff = Math.max(0, retryTx.backoffMs()); + double multiplier = Math.max(1.0, retryTx.multiplier()); + long maxBackoff = Math.max(backoff, retryTx.maxBackoffMs()); + + Throwable last = null; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return pjp.proceed(); // 다음 advice(@Transactional) 실행 + } catch (Throwable raw) { // raw throwable 받음 + Throwable ex = unwrap(raw); + last = ex; + + if (!isRetryable(ex, retryTx)) { // 어노테이션에서 정의한 exception 필터링 + throw ex; + } + + if (attempt == maxAttempts) { // 재시도 최대 횟수 초과 시 종료 + throw ex; + } + + long sleepMs = calcBackoff(backoff, multiplier, maxBackoff, attempt); // 재시도 시 일정 기간 waiting + + log.warn( + "[RetryTx] attempt {}/{} failed: {}: {} (sleep {}ms) - {}", + attempt, + maxAttempts, + ex.getClass().getSimpleName(), + ex.getMessage(), + sleepMs, + pjp.getSignature().toShortString()); + + sleep(sleepMs); + } + } + + // 실제 여기까지 도달하지 않음 + throw last; + } + + private boolean isRetryable(Throwable ex, RetryTx cfg) { + // Error는 retry 대상 아님 + if (ex instanceof Error) return false; + + // noRetryFor 우선 + for (Class c : cfg.noRetryFor()) { + if (c.isInstance(ex)) return false; + } + + Class[] retryFor = cfg.retryFor(); + if (retryFor == null || retryFor.length == 0) { + // 비워뒀다면 보수적으로 RuntimeException만 + return ex instanceof RuntimeException; + } + + for (Class c : retryFor) { + if (c.isInstance(ex)) return true; + } + return false; + } + + private long calcBackoff(long base, double multiplier, long max, int attempt) { + // attempt=1 실패 후 대기 => base + // attempt=2 실패 후 대기 => base * multiplier + double pow = Math.pow(multiplier, Math.max(0, attempt - 1)); + long v = (long) Math.round(base * pow); + if (v < 0) v = max; + return Math.min(v, max); + } + + private void sleep(long ms) { + if (ms <= 0) return; + try { + Thread.sleep(ms); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + + private Throwable unwrap(Throwable t) { + if (t instanceof UndeclaredThrowableException ute && ute.getUndeclaredThrowable() != null) { + return ute.getUndeclaredThrowable(); + } + return t; + } +} diff --git a/src/test/java/com/studypals/global/retry/RetryTxAspectTest.java b/src/test/java/com/studypals/global/retry/RetryTxAspectTest.java new file mode 100644 index 00000000..8ea7921a --- /dev/null +++ b/src/test/java/com/studypals/global/retry/RetryTxAspectTest.java @@ -0,0 +1,104 @@ +package com.studypals.global.retry; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.annotation.DirtiesContext; + +import lombok.RequiredArgsConstructor; + +import com.studypals.domain.groupManage.dao.HashTagRepository; +import com.studypals.domain.groupManage.entity.HashTag; +import com.studypals.testModules.testSupport.TestEnvironment; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2026-01-03 + */ +@SpringBootTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class RetryTxAspectTest extends TestEnvironment { + + @Autowired + HashTagRepository hashTagRepository; + @Autowired + RaceConditionHashTagService raceConditionHashTagService; +Data + + @BeforeEach + void setUp() { + hashTagRepository.saveAndFlush(HashTag.builder() + .tag("spring") + .usedCount(1L) + .build()); + } + + @TestConfiguration + + + @TestConfiguration + static class Config { + @Bean + RaceConditionHashTagService raceConditionHashTagService(HashTagRepository hashTagRepository) { + return new RaceConditionHashTagService(hashTagRepository); + } + + } + + + @RequiredArgsConstructor + public static class RaceConditionHashTagService { + private final HashTagRepository hashTagRepository; + private final AtomicInteger attempts = new AtomicInteger(0); + + public int getAttempts() { + return attempts.get(); + } + + @RetryTx( + maxAttempts = 2, + backoffMs = 0, + retryFor = {DataIntegrityViolationException.class} + ) + public void create(String tag) { + int attempt = attempts.incrementAndGet(); + + if (attempt == 1) { + // 1회차: insert + flush로 unique 위반 강제 + hashTagRepository.save(HashTag.builder() + .tag(tag) + .usedCount(1L) + .build()); + hashTagRepository.flush(); // 여기서 DataIntegrityViolationException + return; + } + + // 2회차: 복구 동작 (usedCount 증가) + hashTagRepository.increaseUsedCount(tag); + } + + } +} From 72e7c91fc3fbede862b4fe01661c001a17adc94d Mon Sep 17 00:00:00 2001 From: unikal1 Date: Mon, 5 Jan 2026 14:06:09 +0900 Subject: [PATCH 08/17] =?UTF-8?q?Test:=20retryTx=20aspect=20=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retry 정상 작동 여부 테스트 - hashTag 관련한 변경으로 인해 실패하는 테스트 수정 - aspect 시 annotation 이 제대로 들어가지 않는 문제 해결 - 주석 추가 Ref: #142 --- .../groupManage/dao/HashTagRepository.java | 2 +- .../com/studypals/global/retry/RetryTx.java | 61 +++++++++--- .../studypals/global/retry/RetryTxAspect.java | 95 +++++++++++++------ .../GroupControllerRestDocsTest.java | 3 +- .../groupManage/service/GroupServiceTest.java | 9 +- .../groupManage/worker/GroupWriterTest.java | 11 ++- .../global/retry/RetryTxAspectTest.java | 39 +++++--- .../integrationTest/GroupIntegrationTest.java | 5 +- 8 files changed, 160 insertions(+), 65 deletions(-) diff --git a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java index 00ae8d7c..f61b2872 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java @@ -60,7 +60,7 @@ WHERE t.tag LIKE CONCAT(:prefix, '%') t.deletedAt = null WHERE t.tag = :tag """) - Long increaseUsedCount(String tag); + Integer increaseUsedCount(String tag); /** * usedCount 값을 원자적으로 증가시키는 메서드입니다. 단, 여러 tags 들에 대해 연산을 수행합니다. diff --git a/src/main/java/com/studypals/global/retry/RetryTx.java b/src/main/java/com/studypals/global/retry/RetryTx.java index 42f12e48..08ffb60e 100644 --- a/src/main/java/com/studypals/global/retry/RetryTx.java +++ b/src/main/java/com/studypals/global/retry/RetryTx.java @@ -10,25 +10,22 @@ import org.springframework.transaction.annotation.Transactional; /** - * 코드에 대한 전체적인 역할을 적습니다. - *

- * 코드에 대한 작동 원리 등을 적습니다. - * - *

상속 정보:
- * 상속 정보를 적습니다. + * 트랜잭션 메서드에 재시도(retry) 동작을 적용하기 위해 사용하는 선언적 어노테이션입니다. * - *

주요 생성자:
- * {@code ExampleClass(String example)}
- * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ *

+ * 이 어노테이션이 부착된 메서드는 {@link RetryTxAspect}에 의해 감지되며, + * 지정된 조건에 따라 동일 메서드가 여러 번 재호출될 수 있습니다. + * 재시도 로직의 상세한 동작 방식은 Aspect 구현에 위임됩니다. * - *

빈 관리:
- * 필요 시 빈 관리에 대한 내용을 적습니다. + *

+ * {@link Transactional}을 메타 어노테이션으로 포함하고 있으며, + * 트랜잭션 전파 수준, 격리 수준 등은 이 어노테이션의 속성을 통해 함께 설정할 수 있습니다. * - *

외부 모듈:
- * 필요 시 외부 모듈에 대한 내용을 적습니다. + *

사용 전제:
+ * 구현 클래스의 실제 메서드에 부착하는 사용을 전제로 합니다. * + * @see RetryTxAspect * @author jack8 - * @see * @since 2026-01-03 */ @Target(ElementType.METHOD) @@ -37,35 +34,71 @@ @Transactional public @interface RetryTx { + /** + * 최대 재시도 횟수 (최초 실행 포함). + */ int maxAttempts() default 3; + /** + * 재시도 간 기본 대기 시간(ms). + */ long backoffMs() default 200; + /** + * 재시도 간 대기 시간 증가 배수. + */ double multiplier() default 1.0; + /** + * 재시도 대기 시간의 최대 상한(ms). + */ long maxBackoffMs() default 2_000; + /** + * 재시도를 수행할 예외 타입 목록. + */ Class[] retryFor() default { PessimisticLockingFailureException.class, TransientDataAccessException.class }; + /** + * 재시도를 수행하지 않을 예외 타입 목록. + */ Class[] noRetryFor() default {}; + /** + * 트랜잭션 전파 수준. + */ @AliasFor(annotation = Transactional.class, attribute = "propagation") Propagation propagation() default Propagation.REQUIRED; + /** + * 트랜잭션 격리 수준. + */ @AliasFor(annotation = Transactional.class, attribute = "isolation") Isolation isolation() default Isolation.DEFAULT; + /** + * 트랜잭션 타임아웃(초). + */ @AliasFor(annotation = Transactional.class, attribute = "timeout") int timeout() default -1; + /** + * 읽기 전용 트랜잭션 여부. + */ @AliasFor(annotation = Transactional.class, attribute = "readOnly") boolean readOnly() default false; + /** + * 롤백 대상 예외 타입 목록. + */ @AliasFor(annotation = Transactional.class, attribute = "rollbackFor") Class[] rollbackFor() default {}; + /** + * 롤백하지 않을 예외 타입 목록. + */ @AliasFor(annotation = Transactional.class, attribute = "noRollbackFor") Class[] noRollbackFor() default {}; } diff --git a/src/main/java/com/studypals/global/retry/RetryTxAspect.java b/src/main/java/com/studypals/global/retry/RetryTxAspect.java index c77af4ea..c526ff30 100644 --- a/src/main/java/com/studypals/global/retry/RetryTxAspect.java +++ b/src/main/java/com/studypals/global/retry/RetryTxAspect.java @@ -5,6 +5,7 @@ import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @@ -12,25 +13,23 @@ import lombok.extern.slf4j.Slf4j; /** - * 코드에 대한 전체적인 역할을 적습니다. - *

- * 코드에 대한 작동 원리 등을 적습니다. - * - *

상속 정보:
- * 상속 정보를 적습니다. + * {@link RetryTx} 어노테이션이 적용된 메서드에 대해 재시도 정책을 수행하는 AOP Aspect 입니다. * - *

주요 생성자:
- * {@code ExampleClass(String example)}
- * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ *

+ * 대상 메서드 실행 중 지정한 예외({@link RetryTx#retryFor()})가 발생하면, + * 최대 시도 횟수({@link RetryTx#maxAttempts()})까지 재호출을 수행합니다. + * 재시도 간 대기(backoff)는 base 값({@link RetryTx#backoffMs()})과 배수({@link RetryTx#multiplier()})를 기반으로 증가하며, + * 최대 대기 시간({@link RetryTx#maxBackoffMs()})을 초과하지 않습니다. * *

빈 관리:
- * 필요 시 빈 관리에 대한 내용을 적습니다. + * {@code @Component} 로 스프링 빈으로 등록되며, {@code @Aspect} 를 통해 Advisor(포인트컷 + 어드바이스)로 구성됩니다.
+ * 스프링 AOP 프록시 체인에 포함되어, {@link RetryTx} 대상 메서드 호출을 가로채 재시도 로직을 적용합니다.
+ * {@code @Order(Ordered.HIGHEST_PRECEDENCE)} 로 우선순위를 높게 설정하여, 다른 Advisor(예: 트랜잭션)보다 먼저 실행되도록 합니다. * *

외부 모듈:
- * 필요 시 외부 모듈에 대한 내용을 적습니다. - * + * Spring AOP / AspectJ 표현식 기반 포인트컷을 사용합니다.
+ * - {@code org.springframework.boot:spring-boot-starter-aop}
* @author jack8 - * @see * @since 2026-01-03 */ @Slf4j @@ -39,9 +38,21 @@ @Order(Ordered.HIGHEST_PRECEDENCE) public class RetryTxAspect { - @Around("@annotation(retryTx)") - public Object around(ProceedingJoinPoint pjp, RetryTx retryTx) throws Throwable { - + /** + * {@link RetryTx}가 붙은 메서드를 가로채 재시도를 수행합니다. + * + *

+ * 주의: 현재 구현은 {@code MethodSignature#getMethod()}로 어노테이션을 조회합니다.
+ * 어노테이션 조회가 {@code null}이 될 수 있습니다.
+ * 따라서, 구현 클래스의 메서드에만 사용이 가능합니다. + */ + @Around("@annotation(com.studypals.global.retry.RetryTx)") + public Object around(ProceedingJoinPoint pjp) throws Throwable { + // 어노테이션 설정값 조회 + MethodSignature methodSignature = (MethodSignature) pjp.getSignature(); + RetryTx retryTx = methodSignature.getMethod().getAnnotation(RetryTx.class); + + // 방어적 보정 (이상값을 넣어도 최소 1회는 실행되도록) int maxAttempts = Math.max(1, retryTx.maxAttempts()); long backoff = Math.max(0, retryTx.backoffMs()); double multiplier = Math.max(1.0, retryTx.multiplier()); @@ -49,22 +60,28 @@ public Object around(ProceedingJoinPoint pjp, RetryTx retryTx) throws Throwable Throwable last = null; + // attempt는 1부터 시작 (1회차가 최초 실행) for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { - return pjp.proceed(); // 다음 advice(@Transactional) 실행 - } catch (Throwable raw) { // raw throwable 받음 + // 다음 advice(예: @Transactional) 및 실제 타깃 메서드 실행 + return pjp.proceed(); + } catch (Throwable raw) { + // 프록시/리플렉션 래핑 예외를 풀고 실제 예외를 기준으로 판단 Throwable ex = unwrap(raw); last = ex; - if (!isRetryable(ex, retryTx)) { // 어노테이션에서 정의한 exception 필터링 + // 재시도 불가 예외면 즉시 종료 + if (!isRetryable(ex, retryTx)) { throw ex; } - if (attempt == maxAttempts) { // 재시도 최대 횟수 초과 시 종료 + // 최대 횟수 초과 시 종료 + if (attempt == maxAttempts) { throw ex; } - long sleepMs = calcBackoff(backoff, multiplier, maxBackoff, attempt); // 재시도 시 일정 기간 waiting + // 대기 시간 계산 후 sleep + long sleepMs = calcBackoff(backoff, multiplier, maxBackoff, attempt); log.warn( "[RetryTx] attempt {}/{} failed: {}: {} (sleep {}ms) - {}", @@ -79,22 +96,28 @@ public Object around(ProceedingJoinPoint pjp, RetryTx retryTx) throws Throwable } } - // 실제 여기까지 도달하지 않음 + // 논리상 도달하지 않지만, 컴파일러/흐름 안정성을 위해 유지 throw last; } + /** + * 재시도 대상 예외인지 판단합니다. + * + *

+ * 우선순위: {@code noRetryFor}가 가장 우선이며, 그 다음 {@code retryFor} 기준으로 판정합니다. + */ private boolean isRetryable(Throwable ex, RetryTx cfg) { - // Error는 retry 대상 아님 + // JVM Error 계열은 재시도 대상으로 보지 않음 if (ex instanceof Error) return false; - // noRetryFor 우선 + // noRetryFor가 우선 for (Class c : cfg.noRetryFor()) { if (c.isInstance(ex)) return false; } + // retryFor 미지정 시 보수적으로 RuntimeException만 재시도 Class[] retryFor = cfg.retryFor(); if (retryFor == null || retryFor.length == 0) { - // 비워뒀다면 보수적으로 RuntimeException만 return ex instanceof RuntimeException; } @@ -104,17 +127,31 @@ private boolean isRetryable(Throwable ex, RetryTx cfg) { return false; } + /** + * 재시도 간 backoff 시간을 계산합니다. + * + *

+ * attempt=1 실패 후 대기: base
+ * attempt=2 실패 후 대기: base * multiplier
+ * ... + */ private long calcBackoff(long base, double multiplier, long max, int attempt) { - // attempt=1 실패 후 대기 => base - // attempt=2 실패 후 대기 => base * multiplier double pow = Math.pow(multiplier, Math.max(0, attempt - 1)); long v = (long) Math.round(base * pow); + + // 오버플로 등 비정상 값 방어 if (v < 0) v = max; + return Math.min(v, max); } + /** + * 지정한 시간(ms)만큼 대기합니다. + * 인터럽트 발생 시 인터럽트 플래그를 복구합니다. + */ private void sleep(long ms) { if (ms <= 0) return; + try { Thread.sleep(ms); } catch (InterruptedException ie) { @@ -122,6 +159,10 @@ private void sleep(long ms) { } } + /** + * 프록시 계층에서 감싸진 예외를 실제 예외로 단순화합니다. + * (예: {@link UndeclaredThrowableException}) + */ private Throwable unwrap(Throwable t) { if (t instanceof UndeclaredThrowableException ute && ute.getUndeclaredThrowable() != null) { return ute.getUndeclaredThrowable(); diff --git a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java index c0fadadd..41e9bdc6 100644 --- a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java @@ -77,7 +77,8 @@ void getGroupTags_success() throws Exception { void createGroup_success() throws Exception { // given - CreateGroupReq req = new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com"); + CreateGroupReq req = + new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); given(groupService.createGroup(any(), any())).willReturn(1L); diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java index ca317d1b..e88acc02 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java @@ -101,7 +101,8 @@ void getGroupTags_success() { void createGroup_success() { // given Long userId = 1L; - CreateGroupReq req = new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com"); + CreateGroupReq req = + new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); given(memberReader.getRef(userId)).willReturn(mockMember); given(groupWriter.create(req)).willReturn(mockGroup); @@ -120,7 +121,8 @@ void createGroup_fail_whileGroupCreating() { // given Long userId = 1L; GroupErrorCode errorCode = GroupErrorCode.GROUP_CREATE_FAIL; - CreateGroupReq req = new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com"); + CreateGroupReq req = + new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); given(groupWriter.create(req)).willThrow(new GroupException(errorCode)); @@ -136,7 +138,8 @@ void createGroup_fail_whileGroupMemberCreating() { // given Long userId = 1L; GroupErrorCode errorCode = GroupErrorCode.GROUP_MEMBER_CREATE_FAIL; - CreateGroupReq req = new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com"); + CreateGroupReq req = + new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); given(memberReader.getRef(userId)).willReturn(mockMember); given(groupWriter.create(req)).willReturn(mockGroup); diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupWriterTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupWriterTest.java index 30e666dd..e78a7ed0 100644 --- a/src/test/java/com/studypals/domain/groupManage/worker/GroupWriterTest.java +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupWriterTest.java @@ -4,6 +4,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import java.util.List; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -48,7 +50,8 @@ public class GroupWriterTest { @Test void create_success() { // given - CreateGroupReq req = new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com"); + CreateGroupReq req = + new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); given(groupMapper.toEntity(req)).willReturn(mockGroup); given(groupTagRepository.existsById(req.tag())).willReturn(true); @@ -64,7 +67,8 @@ void create_success() { void create_fail_tagNotFound() { // given GroupErrorCode errorCode = GroupErrorCode.GROUP_CREATE_FAIL; - CreateGroupReq req = new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com"); + CreateGroupReq req = + new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); given(groupMapper.toEntity(req)).willReturn(mockGroup); given(groupTagRepository.existsById(req.tag())).willReturn(false); @@ -80,7 +84,8 @@ void create_fail_tagNotFound() { void create_fail_whileSave() { // given GroupErrorCode errorCode = GroupErrorCode.GROUP_CREATE_FAIL; - CreateGroupReq req = new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com"); + CreateGroupReq req = + new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); given(groupMapper.toEntity(req)).willReturn(mockGroup); given(groupTagRepository.existsById(req.tag())).willReturn(true); diff --git a/src/test/java/com/studypals/global/retry/RetryTxAspectTest.java b/src/test/java/com/studypals/global/retry/RetryTxAspectTest.java index 8ea7921a..805643b1 100644 --- a/src/test/java/com/studypals/global/retry/RetryTxAspectTest.java +++ b/src/test/java/com/studypals/global/retry/RetryTxAspectTest.java @@ -1,8 +1,12 @@ package com.studypals.global.retry; +import static org.assertj.core.api.Assertions.assertThat; + import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; @@ -44,20 +48,32 @@ class RetryTxAspectTest extends TestEnvironment { @Autowired HashTagRepository hashTagRepository; + @Autowired RaceConditionHashTagService raceConditionHashTagService; -Data @BeforeEach void setUp() { - hashTagRepository.saveAndFlush(HashTag.builder() - .tag("spring") - .usedCount(1L) - .build()); + hashTagRepository.saveAndFlush( + HashTag.builder().tag("spring").usedCount(1L).build()); } - @TestConfiguration + @Test + void unique_violation_then_retry_success_update_usedCount() { + assertThat(AopUtils.isAopProxy(raceConditionHashTagService)).isTrue(); + + raceConditionHashTagService.create("spring"); + + // retry가 2회 돌았는지 + assertThat(raceConditionHashTagService.getAttempts()).isEqualTo(2); + // row는 1개만 존재 + assertThat(hashTagRepository.count()).isEqualTo(1); + + // usedCount가 증가했는지 + HashTag tag = hashTagRepository.findByTag("spring").orElseThrow(); + assertThat(tag.getUsedCount()).isEqualTo(2L); + } @TestConfiguration static class Config { @@ -65,10 +81,8 @@ static class Config { RaceConditionHashTagService raceConditionHashTagService(HashTagRepository hashTagRepository) { return new RaceConditionHashTagService(hashTagRepository); } - } - @RequiredArgsConstructor public static class RaceConditionHashTagService { private final HashTagRepository hashTagRepository; @@ -81,17 +95,13 @@ public int getAttempts() { @RetryTx( maxAttempts = 2, backoffMs = 0, - retryFor = {DataIntegrityViolationException.class} - ) + retryFor = {DataIntegrityViolationException.class}) public void create(String tag) { int attempt = attempts.incrementAndGet(); if (attempt == 1) { // 1회차: insert + flush로 unique 위반 강제 - hashTagRepository.save(HashTag.builder() - .tag(tag) - .usedCount(1L) - .build()); + hashTagRepository.save(HashTag.builder().tag(tag).usedCount(1L).build()); hashTagRepository.flush(); // 여기서 DataIntegrityViolationException return; } @@ -99,6 +109,5 @@ public void create(String tag) { // 2회차: 복구 동작 (usedCount 증가) hashTagRepository.increaseUsedCount(tag); } - } } diff --git a/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java b/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java index 16db800d..b88d0805 100644 --- a/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java @@ -6,6 +6,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.util.List; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -56,7 +58,8 @@ void createGroup_success() throws Exception { // given CreateUserVar user = createUser(); createGroupTag("group tag"); - CreateGroupReq req = new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com"); + CreateGroupReq req = + new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); // when ResultActions result = mockMvc.perform(post("/groups") From 7dfbc981ddfc3db5f71ea9afe83ba130dd90d2f8 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Mon, 5 Jan 2026 14:31:52 +0900 Subject: [PATCH 09/17] =?UTF-8?q?Refactor:=20RetryTx=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20=ED=9B=84,=20=EC=9E=90=EC=B2=B4=EC=A0=81=EC=9D=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=A0=84=EB=9E=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - try-catch 제거 - retryTx 적용 Ref: #142 --- .../groupManage/service/GroupServiceImpl.java | 6 ++- .../worker/GroupHashTagWorker.java | 37 +------------------ 2 files changed, 6 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java index b65b61ca..c7a6f658 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +17,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; /** * group service 의 구현 클래스입니다. @@ -55,7 +57,9 @@ public List getGroupTags() { } @Override - @Transactional + @RetryTx( + maxAttempts = 2, + retryFor = {DataIntegrityViolationException.class}) public Long createGroup(Long userId, CreateGroupReq dto) { // 그룹 생성 Group group = groupWriter.create(dto); diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java index af1bc942..301f8699 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java @@ -12,8 +12,6 @@ import com.studypals.domain.groupManage.entity.GroupHashTag; import com.studypals.domain.groupManage.entity.HashTag; import com.studypals.global.annotations.Worker; -import com.studypals.global.exceptions.errorCode.GroupErrorCode; -import com.studypals.global.exceptions.exception.GroupException; import com.studypals.global.utils.StringUtils; /** @@ -70,15 +68,8 @@ public void saveTags(Group group, List inputTags) { } if (!notExists.isEmpty()) { - try { - hashTagRepository.saveAll(notExists); - hashTagRepository.flush(); // unique 제약 조건 발생용 flush - } catch (DataIntegrityViolationException e) { // 저장 실패 시 동시성 문제라 생각하고 1회 재시도 - retrySave(notExists.stream().map(HashTag::getTag).toList()); - } + exists.addAll(hashTagRepository.saveAll(notExists)); } - // 최종 결과물 재조회 - exists = hashTagRepository.findAllByTagIn(normalizedTags); // 저장된 hashtag 를 기반으로 groupHashTag 저장 List groupHashTags = new ArrayList<>(); @@ -94,32 +85,6 @@ public void saveTags(Group group, List inputTags) { groupHashTagRepository.saveAll(groupHashTags); } - /** - * 저장 재시도 메서드입니다. 새로운 hashtag 를 사용하여 그룹을 생성하는 와중, 조회할 당시에는 없는 - * 해시태그였으나 그 사이에 다른 세션에서 해시태그를 추가할 경우 unique 제약 조건이 생길 수 있습니다. - * 이를 고려하여 단 1회 저장을 재시도하는 메서드입니다. - * @param failToSave 저장에 실패한 엔티티 - * @return 저장 및 조회 성공 후 영속화되어 있는 엔티티. 즉, 현재 DB 에 모두 존재하는 hashTag - */ - private void retrySave(List failToSave) { - List reExists = hashTagRepository.findAllByTagIn(failToSave); - List reNotExists = notExistTagToCreate(new HashSet<>(failToSave), reExists); - if (!reExists.isEmpty()) { - hashTagRepository.increaseUsedCountBulk( - reExists.stream().map(HashTag::getTag).toList()); - } - if (!reNotExists.isEmpty()) { - try { - hashTagRepository.saveAll(reNotExists); - hashTagRepository.flush(); // unique 제약 조건 발생용 flush - } catch (DataIntegrityViolationException e) { - throw new GroupException( - GroupErrorCode.GROUP_HASHTAG_FAIL, "[GroupHashTagWorker#retrySave] cannot save hash tag"); - } - } - reExists.addAll(reNotExists); - } - /** * 각 태그에 대한 정규화 진행. 띄어쓰기는 _ 로 대체, 중복된 띄어쓰기 제거/특수문제 제거, trim 제거, lowercase * @param tags 정규화 대상 리스트 From cd3882ea832fcb100cbb79ae67e964a2f0c5fb60 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Mon, 5 Jan 2026 14:33:36 +0900 Subject: [PATCH 10/17] =?UTF-8?q?Fix=20:=20clearAutomatically=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hashtag 삭제를 batch 서버로 이전함에 따라, 1차 캐시 초기화 불필요로 인한 삭제 Ref: #142 --- .../com/studypals/domain/groupManage/dao/HashTagRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java index f61b2872..0df043dc 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java @@ -83,7 +83,7 @@ WHERE t.tag LIKE CONCAT(:prefix, '%') * @param tag 감소시킬 태그 * @return 변경된 row 수 */ - @Modifying(clearAutomatically = true) + @Modifying @Query( """ UPDATE HashTag t From f0d8269fc448327e493c290b8ba2c83b9033907f Mon Sep 17 00:00:00 2001 From: unikal1 Date: Mon, 5 Jan 2026 14:50:45 +0900 Subject: [PATCH 11/17] =?UTF-8?q?Fix=20:=20findByPrefix=20->=20=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=EC=A0=81=EC=9D=B8=20=EA=B2=80=EC=83=89=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=96=B4=20search=20=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 prefix 를 토대로 검색을 대신하여 중간에 값이 있어도 검색 가능하도록 변경 Ref: #142 --- .../studypals/domain/groupManage/dao/HashTagRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java index 0df043dc..bc525839 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java @@ -33,7 +33,7 @@ public interface HashTagRepository extends JpaRepository { /** * tag 자동완성 시 사용할 메서드. prefix 에 대해 cnt 값 만큼의 자주 사용되는 데이터를 반환합니다. * - * @param prefix 검색할 인자(접두사 / 순서대로) + * @param value 검색할 인자(접두사 / 순서대로) * @param pageable 반환 개수 지정 * @return cnt 개수 만큼의, 사용 빈도가 높은 데이터 */ @@ -41,10 +41,10 @@ public interface HashTagRepository extends JpaRepository { """ SELECT t.tag FROM HashTag t - WHERE t.tag LIKE CONCAT(:prefix, '%') + WHERE t.tag LIKE CONCAT('%', :value, '%') ORDER BY t.usedCount DESC """) - List findNamesByPrefix(@Param("prefix") String prefix, Pageable pageable); + List search(@Param("value") String value, Pageable pageable); /** * usedCount 값을 원자적으로 증가시키는 메서드입니다. 해당 메서드가 실행 되면 From 9bb405d2abaa259bc2c46778ff961f3f2039157f Mon Sep 17 00:00:00 2001 From: unikal1 Date: Mon, 5 Jan 2026 17:00:20 +0900 Subject: [PATCH 12/17] =?UTF-8?q?Build:=20application.properties=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local/ test / prod 분리 및 운용 DB 분리 - 자세한 내용은 위키 참조 Ref: #142 --- .../groupManage/dao/HashTagRepository.java | 4 +- .../resources/application-local.properties | 59 +++---- src/main/resources/application.properties | 46 ++++-- .../dao/HashTagRepositoryTest.java | 151 ++++++++++++++++++ .../testSupport/DataJpaSupport.java | 2 +- .../testSupport/TestEnvironment.java | 60 +++++-- .../resources/application-test.properties | 49 +++--- 7 files changed, 274 insertions(+), 97 deletions(-) create mode 100644 src/test/java/com/studypals/domain/groupManage/dao/HashTagRepositoryTest.java diff --git a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java index bc525839..5c49634a 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java @@ -73,9 +73,9 @@ WHERE t.tag LIKE CONCAT('%', :value, '%') UPDATE HashTag t SET t.usedCount = t.usedCount + 1, t.deletedAt = null - WHERE t.tag in :tags + WHERE t.tag in (:tags) """) - void increaseUsedCountBulk(Collection tags); + void increaseUsedCountBulk(@Param("tags") Collection tags); /** * usedCount 값을 원자적으로 감소시키는 메서드입니다. 만약 0이 되면, diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index ee68df24..d0c76c07 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -1,39 +1,26 @@ -spring.application.name=studyPals - -#mysql access settings -spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/study_pal?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&serverTimezone=Asia/Seoul -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.datasource.username=${MYSQL_USER} -spring.datasource.password=${MYSQL_PWD} -#mysql settings - -spring.jpa.show-sql=false +# =============================== +# MySQL (local) +# =============================== +spring.datasource.url=jdbc:mysql://127.0.0.1:3306/study_pal?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&serverTimezone=Asia/Seoul +spring.datasource.username=root +spring.datasource.password=root + +# JPA (local) spring.jpa.hibernate.ddl-auto=update -spring.jpa.properties.hibernate.format_sql=false - -#logging.level.org.hibernate.SQL=DEBUG -#logging.level.org.hibernate.studyType.descriptor.sql.BasicBinder=TRACE - -spring.data.redis.host=${REDIS_HOST} -spring.data.redis.port=${REDIS_PORT} -spring.data.redis.database=0 -#redis connection pool setting -spring.data.redis.jedis.pool.max-active=8 -spring.data.redis.jedis.pool.max-idle=8 -spring.data.redis.jedis.pool.min-idle=0 -spring.data.redis.jedis.pool.max-wait=-1ms -#redis template setting -spring.data.redis.timeout=2000ms - -debug.message.print = true - -jwt.secret=${JWT_SECRET} -jwt.expireDate.accessToken=7200000 -jwt.expireDate.refreshToken=2592000000 -minio.endpoint=${MINIO_ENDPOINT} -minio.access_key=${MINIO_ACCESS_KEY} -minio.secret_key=${MINIO_SECRET_KEY} -minio.bucket=study-pal +# =============================== +# Redis (local) +# =============================== +spring.data.redis.host=localhost +spring.data.redis.port=6379 + +# =============================== +# Mongo (prod) +# =============================== +spring.data.mongodb.host=${MONGO_HOST} +spring.data.mongodb.port=${MONGO_PORT} +spring.data.mongodb.database=${MONGO_DB} +spring.data.mongodb.username=${MONGO_USER} +spring.data.mongodb.password=${MONGO_PWD} +spring.data.mongodb.authentication-database=${MONGO_AUTH_DB} -logging.level.org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver=WARN diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 94b12281..40e3f8fd 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,38 +1,49 @@ spring.application.name=studyPals -#mysql access settings +# =============================== +# MySQL (prod) +# =============================== spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/study_pal?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&serverTimezone=Asia/Seoul spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.username=${MYSQL_USER} spring.datasource.password=${MYSQL_PWD} -#mysql settings +# JPA spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.format_sql=false +# =============================== +# Redis (prod) +# =============================== spring.data.redis.host=${REDIS_HOST} spring.data.redis.port=${REDIS_PORT} spring.data.redis.database=0 -#redis connection pool setting +spring.data.redis.timeout=2000ms + spring.data.redis.jedis.pool.max-active=8 spring.data.redis.jedis.pool.max-idle=8 spring.data.redis.jedis.pool.min-idle=0 spring.data.redis.jedis.pool.max-wait=-1ms -#redis template setting -spring.data.redis.timeout=2000ms - -debug.message.print = true +# =============================== +# JWT (prod) +# =============================== jwt.secret=${JWT_SECRET} jwt.expireDate.accessToken=7200000 jwt.expireDate.refreshToken=2592000000 +# =============================== +# MinIO (prod) +# =============================== minio.endpoint=${MINIO_ENDPOINT} minio.access_key=${MINIO_ACCESS_KEY} minio.secret_key=${MINIO_SECRET_KEY} minio.bucket=study-pal +# =============================== +# Mongo (prod) +# =============================== spring.data.mongodb.host=${MONGO_HOST} spring.data.mongodb.port=${MONGO_PORT} spring.data.mongodb.database=${MONGO_DB} @@ -40,16 +51,21 @@ spring.data.mongodb.username=${MONGO_USER} spring.data.mongodb.password=${MONGO_PWD} spring.data.mongodb.authentication-database=${MONGO_AUTH_DB} -logging.level.org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver=WARN - +# =============================== +# Server (prod) +# =============================== server.info.instanceId=1 -testcontainer.dev.use=${USE_CONTAINER} - server.tomcat.threads.max=500 server.tomcat.accept-count=1000 server.tomcat.max-connections=10000 -#logging.level.org.springframework.data.redis.core.RedisTemplate=DEBUG -#logging.level.org.springframework.data.redis.core=DEBUG -#logging.level.io.lettuce.core.protocol=DEBUG -chat.subscribe.address.default=/sub/chat/room/ \ No newline at end of file +# =============================== +# Logging (prod) +# =============================== +logging.level.org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver=WARN + +# =============================== +# Feature flags / misc +# =============================== +debug.message.print=true +chat.subscribe.address.default=/sub/chat/room/ diff --git a/src/test/java/com/studypals/domain/groupManage/dao/HashTagRepositoryTest.java b/src/test/java/com/studypals/domain/groupManage/dao/HashTagRepositoryTest.java new file mode 100644 index 00000000..370420e5 --- /dev/null +++ b/src/test/java/com/studypals/domain/groupManage/dao/HashTagRepositoryTest.java @@ -0,0 +1,151 @@ +package com.studypals.domain.groupManage.dao; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.transaction.TestTransaction; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import com.studypals.domain.groupManage.entity.HashTag; +import com.studypals.testModules.testSupport.DataJpaSupport; + +/** + * 코드에 대한 전체적인 역할을 적습니다. + *

+ * 코드에 대한 작동 원리 등을 적습니다. + * + *

상속 정보:
+ * 상속 정보를 적습니다. + * + *

주요 생성자:
+ * {@code ExampleClass(String example)}
+ * 주요 생성자와 그 매개변수에 대한 설명을 적습니다.
+ * + *

빈 관리:
+ * 필요 시 빈 관리에 대한 내용을 적습니다. + * + *

외부 모듈:
+ * 필요 시 외부 모듈에 대한 내용을 적습니다. + * + * @author jack8 + * @see + * @since 2026-01-05 + */ +class HashTagRepositoryTest extends DataJpaSupport { + + @Autowired + private HashTagRepository hashTagRepository; + + @Autowired + PlatformTransactionManager txManager; + + private HashTag insertHashTag(String tag) { + return em.persist(HashTag.builder().tag(tag).build()); + } + + @Test + void search_success() { + List tags = List.of( + "apple_pie", + "apple_pan", + "apple_jam", + "apple_latte", + "pan_apple_pan", + "pineapple_pan", + "appple_pan", + "appleappleapple", + "APPLE_BIG", + "aple", + "apple", + "app_pie", + "banana_pan"); + + tags.forEach(this::insertHashTag); + + List result = hashTagRepository.search("apple", Pageable.ofSize(20)); + + assertThat(result).hasSize(9); + } + + @Test + void increaseUsedCountBulk_success() throws Exception { + TransactionTemplate tt = new TransactionTemplate(txManager); + List tags = List.of( + "apple_pie", + "apple_pan", + "apple_jam", + "apple_latte", + "pan_apple_pan", + "pineapple_pan", + "appple_pan", + "appleappleapple", + "APPLE_BIG", + "aple", + "apple", + "app_pie", + "banana_pan"); + + tags.forEach( + tag -> hashTagRepository.saveAndFlush(HashTag.builder().tag(tag).build())); + em.clear(); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + int thread = 20; + int callsPerThread = 50; + int totalCall = thread * callsPerThread; + + ExecutorService pool = Executors.newFixedThreadPool(thread); + CountDownLatch ready = new CountDownLatch(thread); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(thread); + + List> futures = new ArrayList<>(); + + for (int i = 0; i < thread; i++) { + futures.add(pool.submit(() -> { + ready.countDown(); + try { + start.await(); + + for (int j = 0; j < callsPerThread; j++) { + tt.execute(status -> { + hashTagRepository.increaseUsedCountBulk(tags); + return null; + }); + } + return null; + } finally { + done.countDown(); // 예외가 나도 done은 줄어야 함 + } + })); + } + + ready.await(); + start.countDown(); + done.await(); + + // 스레드 내부 예외를 테스트 실패로 끌어올림 + for (Future f : futures) { + f.get(); + } + + pool.shutdown(); + + List finded = hashTagRepository.findAllByTagIn(tags); + + assertThat(finded).hasSize(tags.size()); + assertThat(finded).allSatisfy(t -> assertThat(t.getUsedCount()).isEqualTo((long) totalCall + 1)); + } +} diff --git a/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java b/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java index d051c891..85898b9e 100644 --- a/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java +++ b/src/test/java/com/studypals/testModules/testSupport/DataJpaSupport.java @@ -12,7 +12,7 @@ @DataJpaTest @Import(QueryDslTestConfig.class) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -public abstract class DataJpaSupport { +public abstract class DataJpaSupport extends TestEnvironment { @Autowired protected TestEntityManager em; diff --git a/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java b/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java index f6d497f0..a2f1f521 100644 --- a/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java +++ b/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java @@ -4,6 +4,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.GenericContainer; @@ -18,6 +19,7 @@ * @since 2025-11-25 */ @SuppressWarnings("all") +@ActiveProfiles("test") public abstract class TestEnvironment { static final MySQLContainer MYSQL; @@ -27,25 +29,57 @@ public abstract class TestEnvironment { private static final Logger log = LoggerFactory.getLogger(TestEnvironment.class); static { - MYSQL = new MySQLContainer<>("mysql:8.0") - .withDatabaseName("study_pal") - .withUsername("testuser") - .withPassword("testpassword") - .withCommand("--innodb_redo_log_capacity=512M", "--skip-log-bin") - .withTmpFs(Collections.singletonMap("/var/lib/mysql", "rw")) - .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("MYSQL")) - .withReuse(true); - MYSQL.start(); + if (!shouldStartContainers()) { + MYSQL = null; + REDIS = null; + MONGO = null; + } else { + MYSQL = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("study_pal") + .withUsername("testuser") + .withPassword("testpassword") + .withCommand("--innodb_redo_log_capacity=512M", "--skip-log-bin") + .withTmpFs(Collections.singletonMap("/var/lib/mysql", "rw")) + .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("MYSQL")) + .withReuse(true); + MYSQL.start(); - REDIS = new GenericContainer<>("redis:7.2").withExposedPorts(6379).withReuse(true); - REDIS.start(); + REDIS = new GenericContainer<>("redis:7.2").withExposedPorts(6379).withReuse(true); + REDIS.start(); - MONGO = new MongoDBContainer("mongo:7.0").withReuse(true); - MONGO.start(); + MONGO = new MongoDBContainer("mongo:7.0").withReuse(true); + MONGO.start(); + } + } + + private static boolean shouldStartContainers() { + if ("true".equalsIgnoreCase(System.getProperty("tc.disable"))) return false; + String env = System.getenv("TC_DISABLE"); + if ("true".equalsIgnoreCase(env)) return false; + + return isRunningUnderJUnit(); + } + + private static boolean isRunningUnderJUnit() { + // JUnit이 로드된 상태면 테스트 JVM로 판단 + try { + Class.forName("org.junit.jupiter.api.Test"); + for (StackTraceElement e : Thread.currentThread().getStackTrace()) { + String c = e.getClassName(); + if (c.startsWith("org.junit.") || c.startsWith("org.junit.platform.")) return true; + } + for (StackTraceElement e : Thread.currentThread().getStackTrace()) { + if (e.getClassName().startsWith("org.gradle.api.internal.tasks.testing")) return true; + } + return true; + } catch (ClassNotFoundException ex) { + return false; + } } @DynamicPropertySource static void overrideProps(DynamicPropertyRegistry registry) { + if (!shouldStartContainers()) return; // === MySQL === registry.add("spring.datasource.url", MYSQL::getJdbcUrl); registry.add("spring.datasource.username", MYSQL::getUsername); diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index caff7403..8e65d886 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,47 +1,36 @@ -spring.application.name=studyPals - spring.config.import=optional:env[.env] -#mysql access settings + +# =============================== +# MySQL (test) +# =============================== spring.datasource.url=jdbc:mysql://${MYSQL_TEST_HOST}:${MYSQL_TEST_PORT}/study_pal?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&serverTimezone=Asia/Seoul -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.username=${MYSQL_TEST_USER} spring.datasource.password=${MYSQL_TEST_PWD} spring.datasource.hikari.connection-timeout=300000 -#mysql settings -spring.jpa.show-sql=false +# JPA (test) spring.jpa.hibernate.ddl-auto=create -spring.jpa.properties.hibernate.format_sql=false +# =============================== +# Redis (test) +# =============================== spring.data.redis.host=${REDIS_TEST_HOST} spring.data.redis.port=${REDIS_TEST_PORT} -spring.data.redis.database=0 -#redis connection pool setting -spring.data.redis.jedis.pool.max-active=8 -spring.data.redis.jedis.pool.max-idle=8 -spring.data.redis.jedis.pool.min-idle=0 -spring.data.redis.jedis.pool.max-wait=-1ms -#redis template setting -spring.data.redis.timeout=2000ms - -debug.message.print = true - -jwt.secret=${JWT_SECRET} -jwt.expireDate.accessToken=7200000 -jwt.expireDate.refreshToken=2592000000 +# =============================== +# Mongo (test) +# =============================== +spring.data.mongodb.host=${MONGO_TEST_HOST} +spring.data.mongodb.port=${MONGO_TEST_PORT} +spring.data.mongodb.database=${MONGO_DB} +spring.data.mongodb.authentication-database=${MONGO_AUTH_DB} + +# =============================== +# Logging (test) +# =============================== logging.level.root=WARN logging.level.org.springframework=WARN logging.level.org.hibernate=ERROR logging.level.org.testcontainers=INFO - logging.level.org.testcontainers.utility=INFO - - logging.level.org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver=DEBUG - -#logging.level.org.springframework.web.socket=TRACE -#logging.level.org.springframework.web.socket.client=TRACE -#logging.level.org.springframework.web.socket.messaging=TRACE -#logging.level.org.apache.tomcat.websocket=TRACE -#logging.level.org.springframework.web.socket.messaging.StompSubProtocolErrorHandler=TRACE \ No newline at end of file From 8a6f6cc70c35bf9f0d67860cb92d682f60e89a81 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 6 Jan 2026 10:31:45 +0900 Subject: [PATCH 13/17] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test 프로파일 적용 제거 - hash tag 테스트 추가 - retryTx 성능 테스트 시 db 초기화 로직 추가 - 문자열 정규화 로직 추가 - 그 외 테스트 유틸 수정 Ref: #142 --- build.gradle | 1 - .../groupManage/dto/CreateGroupReq.java | 2 +- .../service/MemberServiceImpl.java | 9 +- .../studypals/global/utils/StringUtils.java | 3 + .../resources/application-local.properties | 2 +- .../dao/HashTagRepositoryTest.java | 18 +- .../GroupControllerRestDocsTest.java | 3 + .../groupManage/service/GroupServiceTest.java | 11 +- .../worker/GroupHashTagWorkerTest.java | 80 +++++++++ .../MemberControllerRestDocsTest.java | 5 +- .../RedisHashPerformanceTest.java | 2 - .../RedisHashRepositoryTest.java | 2 - .../global/utils/StringUtilsTest.java | 159 ++++++++++++++++++ .../integrationTest/AuthIntegrationTest.java | 2 - .../CategoryIntegrationTest.java | 2 - .../integrationTest/GroupIntegrationTest.java | 2 - .../StudySessionIntegrationTest.java | 2 - .../StudyTimeIntegrationTest.java | 2 - .../testSupport/IntegrationSupport.java | 2 - .../testSupport/TestEnvironment.java | 3 - .../resources/application-test.properties | 2 +- 21 files changed, 284 insertions(+), 30 deletions(-) create mode 100644 src/test/java/com/studypals/domain/groupManage/worker/GroupHashTagWorkerTest.java create mode 100644 src/test/java/com/studypals/global/utils/StringUtilsTest.java diff --git a/build.gradle b/build.gradle index ec2b4fda..4dc25681 100644 --- a/build.gradle +++ b/build.gradle @@ -139,7 +139,6 @@ tasks.named('test', Test) { // REST Docs snippets 출력 디렉터리 outputs.dir(snippetsDir) environment envProps - systemProperty 'spring.profiles.active', 'test' testLogging { events "passed", "skipped", "failed" diff --git a/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java b/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java index a4563595..7351c0b2 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java @@ -28,4 +28,4 @@ public record CreateGroupReq( Boolean isApprovalRequired, // since 12-05 sanghyeok String imageUrl, - @Size(max = 10) List<@NotBlank @Size(max = 8) String> hashTags) {} + @Size(max = 10) List<@NotBlank @Size(max = 20) String> hashTags) {} diff --git a/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java b/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java index 49a49a40..b53ee8c6 100644 --- a/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java +++ b/src/main/java/com/studypals/domain/memberManage/service/MemberServiceImpl.java @@ -84,13 +84,20 @@ public boolean duplicateCheck(CheckDuplicateDto dto) { boolean hasUsername = dto.username() != null && !dto.username().isBlank(); boolean hasNickname = dto.nickname() != null && !dto.nickname().isBlank(); - if (hasUsername == hasNickname) { + if (hasUsername == hasNickname && hasUsername) { throw new AuthException( AuthErrorCode.SIGNUP_FAIL, "username 혹은 nickname 중 하나는 필수입니다.", "[MemberController#checkAvailability] username & nickname both blank"); } + if (hasUsername == hasNickname) { + throw new AuthException( + AuthErrorCode.SIGNUP_FAIL, + "username 혹은 nickname 중 하나만 존재해야 합니다.", + "[MemberController#checkAvailability] username & nickname both exists"); + } + return hasUsername ? memberReader.existsByUsername(dto.username()) : memberReader.existsByNickname(dto.nickname()); diff --git a/src/main/java/com/studypals/global/utils/StringUtils.java b/src/main/java/com/studypals/global/utils/StringUtils.java index 78463862..f270247e 100644 --- a/src/main/java/com/studypals/global/utils/StringUtils.java +++ b/src/main/java/com/studypals/global/utils/StringUtils.java @@ -72,6 +72,9 @@ public String normalize(String raw) { // 앞뒤 _ 제거(앞뒤 공백 제거의 효과) s = s.replaceAll("^_+|_+$", ""); + s = s.toLowerCase(); + if (s.isBlank()) return null; + return s; } } diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index d0c76c07..ffd2b3d3 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -15,7 +15,7 @@ spring.data.redis.host=localhost spring.data.redis.port=6379 # =============================== -# Mongo (prod) +# Mongo (local) # =============================== spring.data.mongodb.host=${MONGO_HOST} spring.data.mongodb.port=${MONGO_PORT} diff --git a/src/test/java/com/studypals/domain/groupManage/dao/HashTagRepositoryTest.java b/src/test/java/com/studypals/domain/groupManage/dao/HashTagRepositoryTest.java index 370420e5..5e81d277 100644 --- a/src/test/java/com/studypals/domain/groupManage/dao/HashTagRepositoryTest.java +++ b/src/test/java/com/studypals/domain/groupManage/dao/HashTagRepositoryTest.java @@ -10,6 +10,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Pageable; @@ -54,6 +56,21 @@ private HashTag insertHashTag(String tag) { return em.persist(HashTag.builder().tag(tag).build()); } + private TransactionTemplate tt; + + @BeforeEach + void setUp() { + tt = new TransactionTemplate(txManager); + } + + @AfterEach + void tearDown() { + tt.execute(status -> { + hashTagRepository.deleteAllInBatch(); + return null; + }); + } + @Test void search_success() { List tags = List.of( @@ -80,7 +97,6 @@ void search_success() { @Test void increaseUsedCountBulk_success() throws Exception { - TransactionTemplate tt = new TransactionTemplate(txManager); List tags = List.of( "apple_pie", "apple_pan", diff --git a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java index 41e9bdc6..f107a342 100644 --- a/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java @@ -101,6 +101,9 @@ void createGroup_success() throws Exception { fieldWithPath("maxMember") .description("그룹 최대 인원수 / Default 100") .attributes(constraints("not null")), + fieldWithPath("hashTags") + .description("해당 그룹을 나타내는 해시태그") + .attributes(constraints("개수 0 ~ 10, 각 문자 길이 ~ 20")), fieldWithPath("isOpen") .description("그룹 공개 여부 / Default FALSE") .attributes(constraints("not null")), diff --git a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java index e88acc02..f75d92bb 100644 --- a/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java +++ b/src/test/java/com/studypals/domain/groupManage/service/GroupServiceTest.java @@ -3,8 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.*; import java.time.LocalDate; import java.util.List; @@ -79,6 +78,9 @@ public class GroupServiceTest { @Mock private ChatRoom mockChatRoom; + @Mock + private GroupHashTagWorker groupHashTagWorker; + @InjectMocks private GroupServiceImpl groupService; @@ -101,13 +103,14 @@ void getGroupTags_success() { void createGroup_success() { // given Long userId = 1L; - CreateGroupReq req = - new CreateGroupReq("group name", "group tag", 10, false, false, "image.example.com", List.of()); + CreateGroupReq req = new CreateGroupReq( + "group name", "group tag", 10, false, false, "image.example.com", List.of("hashtag1", "hashtag2")); given(memberReader.getRef(userId)).willReturn(mockMember); given(groupWriter.create(req)).willReturn(mockGroup); given(chatRoomWriter.create(any())).willReturn(mockChatRoom); willDoNothing().given(chatRoomWriter).joinAsAdmin(mockChatRoom, mockMember); + willDoNothing().given(groupHashTagWorker).saveTags(mockGroup, req.hashTags()); // when Long actual = groupService.createGroup(userId, req); diff --git a/src/test/java/com/studypals/domain/groupManage/worker/GroupHashTagWorkerTest.java b/src/test/java/com/studypals/domain/groupManage/worker/GroupHashTagWorkerTest.java new file mode 100644 index 00000000..aec9dae4 --- /dev/null +++ b/src/test/java/com/studypals/domain/groupManage/worker/GroupHashTagWorkerTest.java @@ -0,0 +1,80 @@ +package com.studypals.domain.groupManage.worker; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.when; + +import java.util.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.studypals.domain.groupManage.dao.GroupHashTagRepository; +import com.studypals.domain.groupManage.dao.HashTagRepository; +import com.studypals.domain.groupManage.entity.Group; +import com.studypals.domain.groupManage.entity.GroupHashTag; +import com.studypals.domain.groupManage.entity.HashTag; +import com.studypals.global.utils.StringUtils; + +/** + * {@link GroupHashTagWorker} 에 대한 테스트코드 + * + * @author jack8 + * @since 2026-01-05 + */ +@ExtendWith(MockitoExtension.class) +class GroupHashTagWorkerTest { + + @Mock + GroupHashTagRepository groupHashTagRepository; + + @Mock + HashTagRepository hashTagRepository; + + @Spy + StringUtils stringUtils = new StringUtils(); + + @InjectMocks + GroupHashTagWorker worker; + + @Test + void saveTags_success() { + // given + Group group = Group.builder().id(1L).build(); + + List inputTags = List.of("#Spring Boot", "#JPA"); + + // normalize 결과 + // "Spring Boot" -> "Spring_Boot" + // "JPA" -> "JPA" + + HashTag existing = HashTag.builder().id(10L).tag("jpa").usedCount(3L).build(); + + when(hashTagRepository.findAllByTagIn(Set.of("spring_boot", "jpa"))) + .thenReturn(new ArrayList<>(List.of(existing))); + + when(hashTagRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + worker.saveTags(group, inputTags); + + // then + verify(hashTagRepository).findAllByTagIn(any()); + verify(hashTagRepository).increaseUsedCountBulk(List.of("jpa")); + verify(hashTagRepository).saveAll(argThat(iterable -> { + Collection tags = (Collection) iterable; + return tags.size() == 1 + && tags.iterator().next().getTag().equals("spring_boot") + && tags.iterator().next().getUsedCount() == 1L; + })); + + verify(groupHashTagRepository).saveAll(argThat(iterable -> { + Collection relations = (Collection) iterable; + return relations.size() == 2 && relations.stream().allMatch(r -> r.getGroup() == group); + })); + } +} diff --git a/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java b/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java index 7a14d7ec..181bc7e4 100644 --- a/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java +++ b/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java @@ -184,6 +184,9 @@ void updateProfile_success() throws Exception { @WithMockUser void checkAvailability_fail_when_both_username_and_nickname_present() throws Exception { // given + given(memberService.duplicateCheck(new CheckDuplicateDto("username@example.com", "nickname"))) + .willThrow(new AuthException(AuthErrorCode.SIGNUP_FAIL, "username 혹은 nickname 중 하나만 존재해야 합니다.", "log")); + AuthErrorCode errorCode = AuthErrorCode.SIGNUP_FAIL; // when @@ -194,7 +197,7 @@ void checkAvailability_fail_when_both_username_and_nickname_present() throws Exc // then result.andExpect(hasStatus(errorCode)) - .andExpect(hasKey(errorCode, "username 혹은 nickname 중 하나는 필수입니다.")) + .andExpect(hasKey(errorCode, "username 혹은 nickname 중 하나만 존재해야 합니다.")) .andExpect(status().is4xxClientError()) .andDo(restDocs.document( httpRequest(), diff --git a/src/test/java/com/studypals/global/redis/redisHashRepository/RedisHashPerformanceTest.java b/src/test/java/com/studypals/global/redis/redisHashRepository/RedisHashPerformanceTest.java index 922e72b1..a9a8b279 100644 --- a/src/test/java/com/studypals/global/redis/redisHashRepository/RedisHashPerformanceTest.java +++ b/src/test/java/com/studypals/global/redis/redisHashRepository/RedisHashPerformanceTest.java @@ -9,7 +9,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.test.context.ActiveProfiles; import com.fasterxml.jackson.databind.ObjectMapper; import com.studypals.global.redis.redisHashRepository.annotations.EnableRedisHashRepositories; @@ -23,7 +22,6 @@ * @since 2025-07-19 */ @SpringBootTest -@ActiveProfiles("test") @EnableRedisHashRepositories(basePackageClasses = TestRedisHashRepository.class) public class RedisHashPerformanceTest { diff --git a/src/test/java/com/studypals/global/redis/redisHashRepository/RedisHashRepositoryTest.java b/src/test/java/com/studypals/global/redis/redisHashRepository/RedisHashRepositoryTest.java index 0325fd1e..09d7224f 100644 --- a/src/test/java/com/studypals/global/redis/redisHashRepository/RedisHashRepositoryTest.java +++ b/src/test/java/com/studypals/global/redis/redisHashRepository/RedisHashRepositoryTest.java @@ -12,7 +12,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; import com.studypals.global.redis.redisHashRepository.annotations.EnableRedisHashRepositories; import com.studypals.testModules.testComponent.TestRedisHashEntity; @@ -25,7 +24,6 @@ * @since 2025-05-27 */ @SpringBootTest -@ActiveProfiles("test") @EnableRedisHashRepositories(basePackageClasses = TestRedisHashRepository.class) public class RedisHashRepositoryTest { @Autowired diff --git a/src/test/java/com/studypals/global/utils/StringUtilsTest.java b/src/test/java/com/studypals/global/utils/StringUtilsTest.java new file mode 100644 index 00000000..fb55f2e5 --- /dev/null +++ b/src/test/java/com/studypals/global/utils/StringUtilsTest.java @@ -0,0 +1,159 @@ +package com.studypals.global.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * + * + * @author jack8 + * @see + * @since 2026-01-06 + */ +class StringUtilsTest { + private final StringUtils stringUtils = new StringUtils(); + + @DisplayName("null/blank 입력은 null 반환") + @ParameterizedTest + @MethodSource("nullOrBlankCases") + void normalize_null_or_blank_returns_null(String raw) { + assertNull(stringUtils.normalize(raw)); + } + + static Stream nullOrBlankCases() { + return Stream.of(Arguments.of((String) null), Arguments.of(""), Arguments.of(" "), Arguments.of("\n\t ")); + } + + @DisplayName("앞뒤 공백 trim + 최종 소문자 변환") + @ParameterizedTest + @MethodSource("trimAndLowerCases") + void normalize_trim_and_lowercase(String raw, String expected) { + assertEquals(expected, stringUtils.normalize(raw)); + } + + static Stream trimAndLowerCases() { + return Stream.of( + Arguments.of(" Hello ", "hello"), + Arguments.of("\nHeLLo\t", "hello"), + Arguments.of("JAVA", "java"), + Arguments.of("jAvA", "java")); + } + + @DisplayName("공백은 '_'로 치환, 연속 공백은 '_' 1개로 압축") + @ParameterizedTest + @MethodSource("spacesToUnderscoreCases") + void normalize_spaces_to_underscore(String raw, String expected) { + assertEquals(expected, stringUtils.normalize(raw)); + } + + static Stream spacesToUnderscoreCases() { + return Stream.of( + Arguments.of("hello world", "hello_world"), + Arguments.of("hello world", "hello_world"), + Arguments.of("hello\t\tworld", "hello_world"), + Arguments.of("hello \n world", "hello_world"), + Arguments.of(" hello world ", "hello_world"), + Arguments.of("a b c", "a_b_c")); + } + + @DisplayName("앞뒤 '_' 제거(앞뒤 공백이 '_'로 바뀐 경우 포함)") + @ParameterizedTest + @MethodSource("trimUnderscoreCases") + void normalize_trim_underscores(String raw, String expected) { + assertEquals(expected, stringUtils.normalize(raw)); + } + + static Stream trimUnderscoreCases() { + return Stream.of( + Arguments.of(" hello ", "hello"), + Arguments.of(" hello ", "hello"), + Arguments.of(" hello world ", "hello_world"), + // 공백 -> _ 된 뒤 앞뒤 _ 제거 확인 + Arguments.of(" a ", "a"), + Arguments.of(" a b ", "a_b")); + } + + @DisplayName("해시(#) 제거") + @ParameterizedTest + @MethodSource("hashRemovalCases") + void normalize_remove_hash(String raw, String expected) { + assertEquals(expected, stringUtils.normalize(raw)); + } + + static Stream hashRemovalCases() { + return Stream.of( + Arguments.of("#Hello", "hello"), + Arguments.of("##Hello", "hello"), + Arguments.of("###Hello###", "hello"), + Arguments.of("#hello #world", "hello_world"), + Arguments.of("### Hello World ###", "hello_world")); + } + + @DisplayName("특수문자 제거(문자/숫자/공백/_ 만 남김) + 소문자") + @ParameterizedTest + @MethodSource("specialCharRemovalCases") + void normalize_remove_special_chars(String raw, String expected) { + assertEquals(expected, stringUtils.normalize(raw)); + } + + static Stream specialCharRemovalCases() { + return Stream.of( + Arguments.of("hello!@#$%^&*()", "hello"), + Arguments.of("he-llo", "hello"), + Arguments.of("he+llo", "hello"), + Arguments.of("he.llo", "hello"), + Arguments.of("he/llo", "hello"), + Arguments.of("hello(world)", "helloworld"), + Arguments.of("hello[]{}<>", "hello"), + Arguments.of("hello:world", "helloworld"), + Arguments.of("hello, world", "hello_world"), // 콤마 제거 후 공백은 _ + Arguments.of("a_b", "a_b"), // '_'는 유지 + Arguments.of("A_B", "a_b") // '_' 유지 + 소문자 + ); + } + + @DisplayName("숫자는 유지되고, 공백 치환/특수문자 제거 규칙이 함께 적용") + @ParameterizedTest + @MethodSource("numberCases") + void normalize_numbers(String raw, String expected) { + assertEquals(expected, stringUtils.normalize(raw)); + } + + static Stream numberCases() { + return Stream.of( + Arguments.of("ver 2", "ver_2"), + Arguments.of("Ver 2.0", "ver_20"), // '.' 제거 + Arguments.of("v2.0.1", "v201"), // '.' 제거 + Arguments.of(" 123 ", "123"), + Arguments.of("a 1 b 2", "a_1_b_2")); + } + + @DisplayName("연속 '_'는 하나로 압축") + @Test + void normalize_multi_underscore_to_single() { + assertEquals("a_b", stringUtils.normalize("a___b")); + assertEquals("a_b", stringUtils.normalize("a__ __b")); // 공백 -> _까지 포함해도 최종 압축 + } + + @DisplayName("특수문자만 있는 경우: 제거 후 blank면 null") + @Test + void normalize_only_specials_returns_null() { + // "!!!" -> "" -> blank => null + assertNull(stringUtils.normalize("!!!")); + assertNull(stringUtils.normalize("###")); // '#' 제거 후 빈 문자열 + assertNull(stringUtils.normalize(" ### ")); // trim -> ### -> 제거 -> blank + } + + @DisplayName("결합 시나리오: # + 특수문자 + 다중 공백 + 대소문자") + @Test + void normalize_combined_case() { + assertEquals("spring_boot_jpa", stringUtils.normalize(" ###Spring Boot!! JPA### ")); + } +} diff --git a/src/test/java/com/studypals/integrationTest/AuthIntegrationTest.java b/src/test/java/com/studypals/integrationTest/AuthIntegrationTest.java index f70b3eca..ee401f5c 100644 --- a/src/test/java/com/studypals/integrationTest/AuthIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/AuthIntegrationTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.ResultActions; import com.studypals.domain.memberManage.dao.RefreshTokenRedisRepository; @@ -28,7 +27,6 @@ * @see IntegrationSupport * @since 2025-04-08 */ -@ActiveProfiles("test") @DisplayName("API TEST / 인증 통합 테스트") public class AuthIntegrationTest extends IntegrationSupport { @Autowired diff --git a/src/test/java/com/studypals/integrationTest/CategoryIntegrationTest.java b/src/test/java/com/studypals/integrationTest/CategoryIntegrationTest.java index c79bd74a..ee0bfadd 100644 --- a/src/test/java/com/studypals/integrationTest/CategoryIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/CategoryIntegrationTest.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.ResultActions; import com.studypals.domain.studyManage.dto.CreateCategoryReq; @@ -24,7 +23,6 @@ * @see IntegrationSupport * @since 2025-04-12 */ -@ActiveProfiles("test") @DisplayName("API TEST / 카테고리 통합 테스트") public class CategoryIntegrationTest extends IntegrationSupport { diff --git a/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java b/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java index b88d0805..7fd6eb8a 100644 --- a/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java @@ -12,7 +12,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.ResultActions; import com.studypals.domain.groupManage.dao.GroupEntryCodeRedisRepository; @@ -28,7 +27,6 @@ * @see AbstractGroupIntegrationTest * @since 2025-04-12 */ -@ActiveProfiles("test") @DisplayName("API TEST / 그룹 관리 통합 테스트") public class GroupIntegrationTest extends AbstractGroupIntegrationTest { @Autowired diff --git a/src/test/java/com/studypals/integrationTest/StudySessionIntegrationTest.java b/src/test/java/com/studypals/integrationTest/StudySessionIntegrationTest.java index bfdc88fd..7021804e 100644 --- a/src/test/java/com/studypals/integrationTest/StudySessionIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/StudySessionIntegrationTest.java @@ -10,7 +10,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.ResultActions; import com.studypals.domain.studyManage.dto.EndStudyReq; @@ -25,7 +24,6 @@ * @author jack8 * @since 2025-04-14 */ -@ActiveProfiles("test") @DisplayName("API TEST / 공부 세션 통합 테스트") public class StudySessionIntegrationTest extends IntegrationSupport { diff --git a/src/test/java/com/studypals/integrationTest/StudyTimeIntegrationTest.java b/src/test/java/com/studypals/integrationTest/StudyTimeIntegrationTest.java index 28781efc..030de374 100644 --- a/src/test/java/com/studypals/integrationTest/StudyTimeIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/StudyTimeIntegrationTest.java @@ -16,7 +16,6 @@ import org.springframework.http.MediaType; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.ResultActions; import com.studypals.global.responses.ResponseCode; @@ -29,7 +28,6 @@ * @author jack8 * @since 2025-04-14 */ -@ActiveProfiles("test") @DisplayName("API TEST / 공부 시간 통합 테스트") public class StudyTimeIntegrationTest extends IntegrationSupport { diff --git a/src/test/java/com/studypals/testModules/testSupport/IntegrationSupport.java b/src/test/java/com/studypals/testModules/testSupport/IntegrationSupport.java index 46e8de4c..39c474be 100644 --- a/src/test/java/com/studypals/testModules/testSupport/IntegrationSupport.java +++ b/src/test/java/com/studypals/testModules/testSupport/IntegrationSupport.java @@ -14,7 +14,6 @@ import org.springframework.jdbc.support.KeyHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.result.MockMvcResultHandlers; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -48,7 +47,6 @@ @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @AutoConfigureMockMvc @Import({SecurityConfig.class}) -@ActiveProfiles("test") public class IntegrationSupport extends TestEnvironment { @Autowired diff --git a/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java b/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java index a2f1f521..31a34e75 100644 --- a/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java +++ b/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java @@ -4,7 +4,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.GenericContainer; @@ -15,11 +14,9 @@ /** * * @author jack8 - * @see * @since 2025-11-25 */ @SuppressWarnings("all") -@ActiveProfiles("test") public abstract class TestEnvironment { static final MySQLContainer MYSQL; diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 8e65d886..b8cb0587 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -20,7 +20,7 @@ spring.data.redis.port=${REDIS_TEST_PORT} # =============================== # Mongo (test) # =============================== -spring.data.mongodb.host=${MONGO_TEST_HOST} +spring.data.mongodb.host=${MONGO_HOST} spring.data.mongodb.port=${MONGO_TEST_PORT} spring.data.mongodb.database=${MONGO_DB} spring.data.mongodb.authentication-database=${MONGO_AUTH_DB} From 33ef14f89fe4bed260d547a7cf5aa35710b5508b Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 6 Jan 2026 10:39:11 +0900 Subject: [PATCH 14/17] =?UTF-8?q?Build:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=9C=20=ED=99=9C=EC=84=B1=ED=99=94=EB=90=98=EB=8A=94=20pro?= =?UTF-8?q?file=20override=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존에 -Dspring.profiles.active 환경변수가 있으면 이를 사용, 없으면 test 프로필 적용 Ref: #142 --- build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build.gradle b/build.gradle index 4dc25681..2f8ccad9 100644 --- a/build.gradle +++ b/build.gradle @@ -139,6 +139,12 @@ tasks.named('test', Test) { // REST Docs snippets 출력 디렉터리 outputs.dir(snippetsDir) environment envProps + def active = + System.getProperty("spring.profiles.active") + ?: System.getenv("SPRING_PROFILES_ACTIVE") + ?: "test" + + systemProperty "spring.profiles.active", active testLogging { events "passed", "skipped", "failed" From b76984db6d8e7eacb747118ffacccb0470257b1e Mon Sep 17 00:00:00 2001 From: unikal1 Date: Tue, 6 Jan 2026 10:42:11 +0900 Subject: [PATCH 15/17] =?UTF-8?q?Build:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=9D=B8=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잘못 작성된 이름 수정 Ref: #142 --- src/test/resources/application-test.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index b8cb0587..d3cfcd50 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -21,7 +21,7 @@ spring.data.redis.port=${REDIS_TEST_PORT} # Mongo (test) # =============================== spring.data.mongodb.host=${MONGO_HOST} -spring.data.mongodb.port=${MONGO_TEST_PORT} +spring.data.mongodb.port=${MONGO_PORT} spring.data.mongodb.database=${MONGO_DB} spring.data.mongodb.authentication-database=${MONGO_AUTH_DB} From 6a9dc00efbda8439c223d0a56445c375d0efa847 Mon Sep 17 00:00:00 2001 From: unikal1 Date: Thu, 8 Jan 2026 09:36:26 +0900 Subject: [PATCH 16/17] =?UTF-8?q?Fix:=20PR=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search 관련 주석 변경 - application-local.properties 가 더 이 상 git 에서 추적되지 않음. Ref: #142 --- .gitignore | 1 + gradlew | 0 .../groupManage/dao/HashTagRepository.java | 2 +- .../resources/application-local.properties | 26 ------------------- 4 files changed, 2 insertions(+), 27 deletions(-) mode change 100755 => 100644 gradlew delete mode 100644 src/main/resources/application-local.properties diff --git a/.gitignore b/.gitignore index aa6272ce..e291b509 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +application-local.properties ### STS ### .apt_generated diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java index 5c49634a..19b37bd2 100644 --- a/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java +++ b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java @@ -31,7 +31,7 @@ public interface HashTagRepository extends JpaRepository { Optional findByTag(String tag); /** - * tag 자동완성 시 사용할 메서드. prefix 에 대해 cnt 값 만큼의 자주 사용되는 데이터를 반환합니다. + * tag 자동완성 시 사용할 메서드. 특정 검색어에 대해 cnt 로 정렬한 데이터를 반환합니다.
* * @param value 검색할 인자(접두사 / 순서대로) * @param pageable 반환 개수 지정 diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties deleted file mode 100644 index ffd2b3d3..00000000 --- a/src/main/resources/application-local.properties +++ /dev/null @@ -1,26 +0,0 @@ -# =============================== -# MySQL (local) -# =============================== -spring.datasource.url=jdbc:mysql://127.0.0.1:3306/study_pal?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&serverTimezone=Asia/Seoul -spring.datasource.username=root -spring.datasource.password=root - -# JPA (local) -spring.jpa.hibernate.ddl-auto=update - -# =============================== -# Redis (local) -# =============================== -spring.data.redis.host=localhost -spring.data.redis.port=6379 - -# =============================== -# Mongo (local) -# =============================== -spring.data.mongodb.host=${MONGO_HOST} -spring.data.mongodb.port=${MONGO_PORT} -spring.data.mongodb.database=${MONGO_DB} -spring.data.mongodb.username=${MONGO_USER} -spring.data.mongodb.password=${MONGO_PWD} -spring.data.mongodb.authentication-database=${MONGO_AUTH_DB} - From a30f15fe0d5759a093a38357e363ab731c3dd85f Mon Sep 17 00:00:00 2001 From: unikal1 Date: Fri, 9 Jan 2026 11:16:16 +0900 Subject: [PATCH 17/17] =?UTF-8?q?Fix:=20=EA=B8=B0=EB=B3=B8=20=ED=95=B4?= =?UTF-8?q?=EC=8B=9C=ED=83=9C=EA=B7=B8=20null=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=9D=84=20=EB=B9=88=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: #158 --- .../com/studypals/domain/groupManage/dto/CreateGroupReq.java | 5 ++++- .../domain/groupManage/worker/GroupHashTagWorker.java | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java b/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java index 7351c0b2..bcfb3673 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java @@ -7,6 +7,9 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + /** * 그룹 생성 시 사용되는 DTO 입니다. * @@ -28,4 +31,4 @@ public record CreateGroupReq( Boolean isApprovalRequired, // since 12-05 sanghyeok String imageUrl, - @Size(max = 10) List<@NotBlank @Size(max = 20) String> hashTags) {} + @JsonSetter(nulls = Nulls.AS_EMPTY) @Size(max = 10) List<@NotBlank @Size(max = 20) String> hashTags) {} diff --git a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java index 301f8699..0c724682 100644 --- a/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java @@ -92,6 +92,9 @@ public void saveTags(Group group, List inputTags) { */ private Map toNormalizedAndRowMap(List tags) { Map result = new HashMap<>(); + if (tags == null || tags.isEmpty()) { + return result; + } for (String tag : tags) { if (tag.isEmpty()) continue; String norm = stringUtils.normalize(tag);