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/build.gradle b/build.gradle index f1b0c097..2f8ccad9 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' @@ -139,7 +139,12 @@ tasks.named('test', Test) { // REST Docs snippets 출력 디렉터리 outputs.dir(snippetsDir) environment envProps - systemProperty 'spring.profiles.active', 'test' + def active = + System.getProperty("spring.profiles.active") + ?: System.getenv("SPRING_PROFILES_ACTIVE") + ?: "test" + + systemProperty "spring.profiles.active", active testLogging { events "passed", "skipped", "failed" diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 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..19b37bd2 --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/dao/HashTagRepository.java @@ -0,0 +1,106 @@ +package com.studypals.domain.groupManage.dao; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.studypals.domain.groupManage.entity.HashTag; + +/** + * {@link HashTag} 에 대한 dao 클래스입니다. + * + * @author jack8 + * @see HashTag + * @since 2025-12-23 + */ +@Repository +public interface HashTagRepository extends JpaRepository { + + /** + * tag 에 대해 객체를 반환합니다. + * @param tag 검색할 태그(정확히 일치) + * @return Optional Hash tag + */ + Optional findByTag(String tag); + + /** + * tag 자동완성 시 사용할 메서드. 특정 검색어에 대해 cnt 로 정렬한 데이터를 반환합니다.
+ * + * @param value 검색할 인자(접두사 / 순서대로) + * @param pageable 반환 개수 지정 + * @return cnt 개수 만큼의, 사용 빈도가 높은 데이터 + */ + @Query( + """ + SELECT t.tag + FROM HashTag t + WHERE t.tag LIKE CONCAT('%', :value, '%') + ORDER BY t.usedCount DESC +""") + List search(@Param("value") String value, Pageable pageable); + + /** + * usedCount 값을 원자적으로 증가시키는 메서드입니다. 해당 메서드가 실행 되면 + * 이미 해당 태그를 사용했다는 의미이므로 deletedAt 을 초기화합니다. + * @param tag 증가시킬 태그 + * @return 변경된 row 수 + */ + @Modifying + @Query( + """ + UPDATE HashTag t + SET t.usedCount = t.usedCount + 1, + t.deletedAt = null + WHERE t.tag = :tag + """) + Integer increaseUsedCount(String tag); + + /** + * usedCount 값을 원자적으로 증가시키는 메서드입니다. 단, 여러 tags 들에 대해 연산을 수행합니다. + * @param tags 증가시킬 태그들 + * @return 변경된 row 수 + */ + @Modifying + @Query( + """ + UPDATE HashTag t + SET t.usedCount = t.usedCount + 1, + t.deletedAt = null + WHERE t.tag in (:tags) + """) + void increaseUsedCountBulk(@Param("tags") Collection tags); + + /** + * usedCount 값을 원자적으로 감소시키는 메서드입니다. 만약 0이 되면, + * 그때부터 deletedAt 을 현재 시간으로 설정합니다. n 일 이후 자동 삭제됩니다(최적화, 배치 서버 분리) + * @param tag 감소시킬 태그 + * @return 변경된 row 수 + */ + @Modifying + @Query( + """ + UPDATE HashTag t + SET t.usedCount = t.usedCount - 1, + t.deletedAt = CASE + WHEN (t.usedCount - 1) = 0 THEN CURRENT_TIMESTAMP + ELSE t.deletedAt + END + WHERE t.tag = :tag + AND t.usedCount > 0 +""") + Long decreaseUsedCount(String tag); + + /** + * tag 리스트에 대한 전체 조회 반환 메서드입니다. + * @param tags 문자열 리스트 + * @return 파라미터에 대해 정확히 일치하는 hash tag 엔티티 리스트 + */ + List 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..bcfb3673 100644 --- a/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java +++ b/src/main/java/com/studypals/domain/groupManage/dto/CreateGroupReq.java @@ -1,8 +1,14 @@ package com.studypals.domain.groupManage.dto; +import java.util.List; + import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; /** * 그룹 생성 시 사용되는 DTO 입니다. @@ -24,4 +30,5 @@ public record CreateGroupReq( Boolean isOpen, Boolean isApprovalRequired, // since 12-05 sanghyeok - String imageUrl) {} + String imageUrl, + @JsonSetter(nulls = Nulls.AS_EMPTY) @Size(max = 10) List<@NotBlank @Size(max = 20) String> hashTags) {} 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..35497c96 --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/entity/GroupHashTag.java @@ -0,0 +1,40 @@ +package com.studypals.domain.groupManage.entity; + +import jakarta.persistence.*; + +import lombok.*; + +/** + * group 과 hashTag 간의 매핑 테이블입니다. hashTag 조회 시 N + 1 문제를 조심해야 할 필요가 있습니다. + * + * @author jack8 + * @see HashTag + * @see Group + * @since 2025-12-23 + */ +@Entity +@Builder +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "group_hash_tag", + uniqueConstraints = {@UniqueConstraint(columnNames = {"group_id", "hash_tag_id"})}) +public class GroupHashTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false, unique = true) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id") + private Group group; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "hash_tag_id") + private HashTag hashTag; + + @Column(name = "display_tag", nullable = false) + private String displayTag; +} 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..cbd97536 --- /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) + private Long usedCount = 1L; + + @Column(name = "deleted_at", nullable = true) + private LocalDate deletedAt; +} 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 fd38377a..288fb6a8 100644 --- a/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java +++ b/src/main/java/com/studypals/domain/groupManage/service/GroupServiceImpl.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.stream.Collectors; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +21,7 @@ import com.studypals.domain.groupManage.worker.*; import com.studypals.domain.memberManage.entity.Member; import com.studypals.domain.memberManage.worker.MemberReader; +import com.studypals.global.retry.RetryTx; import com.studypals.domain.studyManage.dto.GroupCategoryDto; import com.studypals.domain.studyManage.entity.StudyType; import com.studypals.domain.studyManage.worker.StudyCategoryReader; @@ -50,6 +52,10 @@ public class GroupServiceImpl implements GroupService { private final GroupAuthorityValidator validator; private final GroupMapper groupMapper; private final GroupGoalCalculator groupGoalCalculator; + + private final GroupHashTagWorker groupHashTagWorker; + + // chat room worker class private final ChatRoomWriter chatRoomWriter; private final StudyCategoryReader studyCategoryReader; @@ -59,13 +65,17 @@ public List getGroupTags() { } @Override - @Transactional + @RetryTx( + maxAttempts = 2, + retryFor = {DataIntegrityViolationException.class}) public Long createGroup(Long userId, CreateGroupReq dto) { // 그룹 생성 Group group = groupWriter.create(dto); Member member = memberReader.getRef(userId); groupMemberWriter.createLeader(member, group); + groupHashTagWorker.saveTags(group, dto.hashTags()); + // 채팅방 생성 CreateChatRoomDto createChatRoomDto = new CreateChatRoomDto(dto.name(), dto.imageUrl()); ChatRoom chatRoom = chatRoomWriter.create(createChatRoomDto); 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..0c724682 --- /dev/null +++ b/src/main/java/com/studypals/domain/groupManage/worker/GroupHashTagWorker.java @@ -0,0 +1,125 @@ +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.utils.StringUtils; + +/** + * 그룹 해시태그 관리를 담당하는 워커 클래스입니다. + *

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

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

주요 기능:
+ * - 입력 태그 문자열을 저장용 태그와 표시용 태그로 분리 및 정규화
+ * - 존재하는 해시태그의 사용 횟수(usedCount) 증가
+ * - 새 해시태그 생성 시 동시성 충돌 처리 및 재시도
+ * - 그룹과 해시태그 간의 매핑({@link GroupHashTag}) 생성 및 저장
+ * + * @author jack8 + * @see com.studypals.domain.groupManage.dao.GroupHashTagRepository + * @see com.studypals.domain.groupManage.dao.HashTagRepository + * @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()) { + exists.addAll(hashTagRepository.saveAll(notExists)); + } + + // 저장된 hashtag 를 기반으로 groupHashTag 저장 + List groupHashTags = new ArrayList<>(); + for (HashTag tag : exists) { + String val = normalized.getOrDefault(tag.getTag(), tag.getTag()); + + groupHashTags.add(GroupHashTag.builder() + .hashTag(tag) + .displayTag(val) + .group(group) + .build()); + } + groupHashTagRepository.saveAll(groupHashTags); + } + + /** + * 각 태그에 대한 정규화 진행. 띄어쓰기는 _ 로 대체, 중복된 띄어쓰기 제거/특수문제 제거, trim 제거, lowercase + * @param tags 정규화 대상 리스트 + * @return 정규화된 문자열 / raw 문자열로 이루어진 map + */ + 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); + 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/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/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/retry/RetryTx.java b/src/main/java/com/studypals/global/retry/RetryTx.java new file mode 100644 index 00000000..08ffb60e --- /dev/null +++ b/src/main/java/com/studypals/global/retry/RetryTx.java @@ -0,0 +1,104 @@ +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; + +/** + * 트랜잭션 메서드에 재시도(retry) 동작을 적용하기 위해 사용하는 선언적 어노테이션입니다. + * + *

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

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

사용 전제:
+ * 구현 클래스의 실제 메서드에 부착하는 사용을 전제로 합니다. + * + * @see RetryTxAspect + * @author jack8 + * @since 2026-01-03 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@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 new file mode 100644 index 00000000..c526ff30 --- /dev/null +++ b/src/main/java/com/studypals/global/retry/RetryTxAspect.java @@ -0,0 +1,172 @@ +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.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** + * {@link RetryTx} 어노테이션이 적용된 메서드에 대해 재시도 정책을 수행하는 AOP Aspect 입니다. + * + *

+ * 대상 메서드 실행 중 지정한 예외({@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 + * @since 2026-01-03 + */ +@Slf4j +@Aspect +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RetryTxAspect { + + /** + * {@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()); + long maxBackoff = Math.max(backoff, retryTx.maxBackoffMs()); + + Throwable last = null; + + // attempt는 1부터 시작 (1회차가 최초 실행) + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + // 다음 advice(예: @Transactional) 및 실제 타깃 메서드 실행 + return pjp.proceed(); + } catch (Throwable raw) { + // 프록시/리플렉션 래핑 예외를 풀고 실제 예외를 기준으로 판단 + Throwable ex = unwrap(raw); + last = ex; + + // 재시도 불가 예외면 즉시 종료 + if (!isRetryable(ex, retryTx)) { + throw ex; + } + + // 최대 횟수 초과 시 종료 + if (attempt == maxAttempts) { + throw ex; + } + + // 대기 시간 계산 후 sleep + long sleepMs = calcBackoff(backoff, multiplier, maxBackoff, attempt); + + log.warn( + "[RetryTx] attempt {}/{} failed: {}: {} (sleep {}ms) - {}", + attempt, + maxAttempts, + ex.getClass().getSimpleName(), + ex.getMessage(), + sleepMs, + pjp.getSignature().toShortString()); + + sleep(sleepMs); + } + } + + // 논리상 도달하지 않지만, 컴파일러/흐름 안정성을 위해 유지 + throw last; + } + + /** + * 재시도 대상 예외인지 판단합니다. + * + *

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

+ * attempt=1 실패 후 대기: base
+ * attempt=2 실패 후 대기: base * multiplier
+ * ... + */ + private long calcBackoff(long base, double multiplier, long max, int attempt) { + 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) { + Thread.currentThread().interrupt(); + } + } + + /** + * 프록시 계층에서 감싸진 예외를 실제 예외로 단순화합니다. + * (예: {@link UndeclaredThrowableException}) + */ + private Throwable unwrap(Throwable t) { + if (t instanceof UndeclaredThrowableException ute && ute.getUndeclaredThrowable() != null) { + return ute.getUndeclaredThrowable(); + } + return t; + } +} 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..f270247e --- /dev/null +++ b/src/main/java/com/studypals/global/utils/StringUtils.java @@ -0,0 +1,80 @@ +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("_+"); + + /** + * 주어진 문자열을 정규화합니다. + *

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

    + *
  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; + + 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("^_+|_+$", ""); + + 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 deleted file mode 100644 index ee68df24..00000000 --- a/src/main/resources/application-local.properties +++ /dev/null @@ -1,39 +0,0 @@ -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 -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 - -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..5e81d277 --- /dev/null +++ b/src/test/java/com/studypals/domain/groupManage/dao/HashTagRepositoryTest.java @@ -0,0 +1,167 @@ +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.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; +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()); + } + + 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( + "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 { + 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/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java b/src/test/java/com/studypals/domain/groupManage/restDocsTest/GroupControllerRestDocsTest.java index 21a1d766..d690411a 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); @@ -100,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 82d08450..6b5d15f6 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; @@ -85,6 +84,9 @@ public class GroupServiceTest { @Mock private ChatRoom mockChatRoom; + @Mock + private GroupHashTagWorker groupHashTagWorker; + @InjectMocks private GroupServiceImpl groupService; @@ -107,12 +109,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"); + 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); @@ -126,7 +130,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)); @@ -142,7 +147,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/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/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/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java b/src/test/java/com/studypals/domain/memberManage/restDocsTest/MemberControllerRestDocsTest.java index f6c07f4a..8863af87 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; given(memberService.duplicateCheck(any())) .willThrow(new AuthException( @@ -199,7 +202,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/retry/RetryTxAspectTest.java b/src/test/java/com/studypals/global/retry/RetryTxAspectTest.java new file mode 100644 index 00000000..805643b1 --- /dev/null +++ b/src/test/java/com/studypals/global/retry/RetryTxAspectTest.java @@ -0,0 +1,113 @@ +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; +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; + + @BeforeEach + void setUp() { + hashTagRepository.saveAndFlush( + HashTag.builder().tag("spring").usedCount(1L).build()); + } + + @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 { + @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); + } + } +} 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 16db800d..7fd6eb8a 100644 --- a/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java +++ b/src/test/java/com/studypals/integrationTest/GroupIntegrationTest.java @@ -6,11 +6,12 @@ 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; 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; @@ -26,7 +27,6 @@ * @see AbstractGroupIntegrationTest * @since 2025-04-12 */ -@ActiveProfiles("test") @DisplayName("API TEST / 그룹 관리 통합 테스트") public class GroupIntegrationTest extends AbstractGroupIntegrationTest { @Autowired @@ -56,7 +56,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") 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/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/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 f6d497f0..31a34e75 100644 --- a/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java +++ b/src/test/java/com/studypals/testModules/testSupport/TestEnvironment.java @@ -14,7 +14,6 @@ /** * * @author jack8 - * @see * @since 2025-11-25 */ @SuppressWarnings("all") @@ -27,25 +26,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..d3cfcd50 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_HOST} +spring.data.mongodb.port=${MONGO_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