From 37d25ab1e357529d1eb026c803771034d9f98390 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Sun, 14 Dec 2025 22:02:59 +0900 Subject: [PATCH 01/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20gender,?= =?UTF-8?q?=20profileImageUrl,=20bith=20=ED=95=84=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20-=20#250?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/adapter/in/dto/MemberReqDto.java | 6 ---- .../application/MemberCommandService.java | 14 +-------- .../member/converter/MemberConverter.java | 3 -- .../talkPick/domain/member/domain/Member.java | 30 ------------------- 4 files changed, 1 insertion(+), 52 deletions(-) diff --git a/src/main/java/talkPick/domain/member/adapter/in/dto/MemberReqDto.java b/src/main/java/talkPick/domain/member/adapter/in/dto/MemberReqDto.java index 92237cf..6344198 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/dto/MemberReqDto.java +++ b/src/main/java/talkPick/domain/member/adapter/in/dto/MemberReqDto.java @@ -36,12 +36,6 @@ public static class MemberSignupRequest { @NotEmpty(message = "닉네임은 필수입니다.") @Size(max = 25, message = "닉네임은 최대 25자입니다.") private String nickname; - @NotNull(message = "성별은 필수입니다.") - private Gender gender; - @NotNull(message = "생년월일은 필수입니다.") - private LocalDate birth; - @NotNull(message = "프로필 이미지는 필수입니다.") - private String profileImgUrl; @NotNull(message = "mbti는 필수입니다.") private MBTI mbti; } diff --git a/src/main/java/talkPick/domain/member/application/MemberCommandService.java b/src/main/java/talkPick/domain/member/application/MemberCommandService.java index 7c14e14..927955b 100644 --- a/src/main/java/talkPick/domain/member/application/MemberCommandService.java +++ b/src/main/java/talkPick/domain/member/application/MemberCommandService.java @@ -33,7 +33,6 @@ @Transactional @RequiredArgsConstructor public class MemberCommandService implements MemberCommandUseCase { - private static final String DEFAULT_PROFILE_IMG_URL = "https://example.com/images/default-profile.png"; private final MemberCommandRepositoryPort memberCommandRepositoryPort; private final TermQueryRepositoryPort termQueryRepositoryPort; @@ -88,18 +87,9 @@ public MemberResDto.MemberSignupResponse memberSignup(String authorization, Memb } // 추가 정보 입력 - findMember.updateBirth(request.getBirth()); - findMember.updateGender(request.getGender()); findMember.updateMbti(request.getMbti()); findMember.updateNickname(request.getNickname()); - String profileImgUrl = request.getProfileImgUrl(); - if (profileImgUrl == null || profileImgUrl.trim().isEmpty()) { - findMember.updateProfileImgUrl(DEFAULT_PROFILE_IMG_URL); - } else { - findMember.updateProfileImgUrl(profileImgUrl); - } - // 회원 ACTIVE 상태 변경 findMember.updateStatus(TalkPickStatus.ACTIVE); memberCommandRepositoryPort.save(findMember); @@ -218,9 +208,7 @@ public void TopicResultCommentChange(String authorization, MemberReqDto.TopicRes // 회원 가입 시 필수 정보 검증 private boolean validateAdditionalInfo(MemberReqDto.MemberSignupRequest request) { return request.getNickname() != null && - request.getMbti() != null && - request.getGender() != null && - request.getBirth() != null; + request.getMbti() != null; } // 필수 약관 동의 여부 검증 diff --git a/src/main/java/talkPick/domain/member/converter/MemberConverter.java b/src/main/java/talkPick/domain/member/converter/MemberConverter.java index 66d0163..7fab94e 100644 --- a/src/main/java/talkPick/domain/member/converter/MemberConverter.java +++ b/src/main/java/talkPick/domain/member/converter/MemberConverter.java @@ -15,7 +15,6 @@ import java.time.LocalDateTime; public class MemberConverter { - private static final String DEFAULT_PROFILE_IMG_URL = "https://example.com/images/default-profile.png"; private static final String DEFAULT_NICKNAME = "토픽"; public static MemberDataDto.MemberData toKakaoMemberData(io.jsonwebtoken.Claims claims) { @@ -30,10 +29,8 @@ public static Member toMember(MemberDataDto.MemberData MemberData, LoginType log .email(MemberData.getEmail()) .memberRole(Role.MEMBER) .nickname(DEFAULT_NICKNAME) - .gender(Gender.NONE) .loginType(loginType) .status(TalkPickStatus.PENDING) - .profileImageUrl(DEFAULT_PROFILE_IMG_URL) .providerId(MemberData.getSub()) .build(); diff --git a/src/main/java/talkPick/domain/member/domain/Member.java b/src/main/java/talkPick/domain/member/domain/Member.java index 9c84ff1..bbfae73 100644 --- a/src/main/java/talkPick/domain/member/domain/Member.java +++ b/src/main/java/talkPick/domain/member/domain/Member.java @@ -54,17 +54,6 @@ public class Member extends BaseTime { ) private String nickname; - @Column( - columnDefinition = "DATE COMMENT '생년월일'" - ) - private LocalDate birth; - - @Enumerated(EnumType.STRING) - @Column( - columnDefinition = "VARCHAR(10) COMMENT '성별(남/여 등)'" - ) - private Gender gender; - @Enumerated(EnumType.STRING) @Column( length = 6, @@ -87,13 +76,6 @@ public class Member extends BaseTime { ) private MBTI mbti; - @Column( - length = 255, - nullable = false, - columnDefinition = "VARCHAR(255) COMMENT '프로필 이미지 URL'" - ) - private String profileImageUrl; - @Column( length = 255, columnDefinition = "VARCHAR(255) COMMENT 'OAuth Provider 식별값(구글, 카카오 등 연결용)'" @@ -104,18 +86,6 @@ public void updateNickname(String nickname) { this.nickname = nickname; } - public void updateBirth(LocalDate birth) { - this.birth = birth; - } - - public void updateGender(Gender gender) { - this.gender = gender; - } - - public void updateProfileImgUrl(String profileImgUrl) { - this.profileImageUrl = profileImageUrl; - } - public void updateMbti(MBTI mbti) {this.mbti = mbti;} public void updateStatus(TalkPickStatus talkPickStatus) {this.status = talkPickStatus;} From ea8133ae5e87c7c6886d403e1e8a43ce1f1e145a Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Sun, 14 Dec 2025 22:06:44 +0900 Subject: [PATCH 02/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=82=AD=EC=A0=9C=EB=A1=9C=20TopicStat=20?= =?UTF-8?q?=EB=98=90=ED=95=9C=20=EB=B3=80=EA=B2=BD=20-=20#250?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/random/dto/MemberDataDTO.java | 15 +------- .../domain/topic/domain/TopicStat.java | 37 ------------------- 2 files changed, 2 insertions(+), 50 deletions(-) diff --git a/src/main/java/talkPick/domain/random/dto/MemberDataDTO.java b/src/main/java/talkPick/domain/random/dto/MemberDataDTO.java index 3f2fa24..a6c8b8e 100644 --- a/src/main/java/talkPick/domain/random/dto/MemberDataDTO.java +++ b/src/main/java/talkPick/domain/random/dto/MemberDataDTO.java @@ -1,23 +1,12 @@ package talkPick.domain.random.dto; import talkPick.domain.member.domain.Member; -import talkPick.domain.member.domain.type.Gender; import talkPick.domain.member.domain.type.MBTI; -import java.time.LocalDate; -import java.time.Period; - public record MemberDataDTO( - MBTI mbti, - Gender gender, - Integer age + MBTI mbti ) { public static MemberDataDTO from(Member member) { - return new MemberDataDTO(member.getMbti(), member.getGender(), calculateAge(member.getBirth())); - } - - private static int calculateAge(LocalDate birth) { - if (birth == null) return 0; - return Period.between(birth, LocalDate.now()).getYears(); + return new MemberDataDTO(member.getMbti()); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/domain/TopicStat.java b/src/main/java/talkPick/domain/topic/domain/TopicStat.java index f04201f..5f35fd7 100644 --- a/src/main/java/talkPick/domain/topic/domain/TopicStat.java +++ b/src/main/java/talkPick/domain/topic/domain/TopicStat.java @@ -95,13 +95,6 @@ public static TopicStat of(Long topicId) { .averageTalkTime(0) .selectCount(0) .likeCount(0) - .teenCount(0) - .twentiesCount(0) - .thirtiesCount(0) - .fortiesCount(0) - .fiftiesCount(0) - .maleCount(0) - .femaleCount(0) .build(); } @@ -113,8 +106,6 @@ public void addLike() { public void update(Member member, long talkTime) { MBTI mbti = MBTI.INFP; updateMBTI(mbti); - updateAge(member.getBirth()); - updateGender(member.getGender()); updateAverageTalkTime(talkTime); this.selectCount++; } @@ -145,34 +136,6 @@ private void updateMBTI(MBTI mbti) { } } - private void updateAge(LocalDate birth) { - if (birth != null) { - int age = LocalDate.now().getYear() - birth.getYear(); - - if (age < 20) { - this.teenCount++; - } else if (age < 30) { - this.twentiesCount++; - } else if (age < 40) { - this.thirtiesCount++; - } else if (age < 50) { - this.fortiesCount++; - } else { - this.fiftiesCount++; - } - } - } - - private void updateGender(Gender gender) { - if (gender != null) { - if (gender == Gender.MALE) { - this.maleCount++; - } else if (gender == Gender.FEMALE) { - this.femaleCount++; - } - } - } - private void updateAverageTalkTime(long talkTime) { if (this.selectCount == 1) { this.averageTalkTime = talkTime; From fc046a040800f8ef703f7203b086e85ac577051e Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Wed, 24 Dec 2025 21:55:29 +0900 Subject: [PATCH 03/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EC=97=B0=EA=B4=80=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=82=AD=EC=A0=9C=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20JPA=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20#253?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/repository/InquiryJpaRepository.java | 1 + .../adapter/out/repository/MemberTermJpaRepository.java | 2 ++ .../out/repository/MemberTopicHistoryJpaRepository.java | 8 ++++++++ .../out/repository/MemberTopicResultJpaRepository.java | 2 ++ .../adapter/out/repository/RandomJpaRepository.java | 2 ++ .../out/repository/RandomTopicHistoryJpaRepository.java | 2 ++ .../adapter/out/repository/TodayTopicJpaRepository.java | 1 + .../out/repository/TopicLikeHistoryJpaRepository.java | 1 + 8 files changed, 19 insertions(+) create mode 100644 src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java diff --git a/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java b/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java index bed05ca..25fbc50 100644 --- a/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java +++ b/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java @@ -4,4 +4,5 @@ import talkPick.domain.inquiry.domain.Inquiry; public interface InquiryJpaRepository extends JpaRepository { + void deleteByMemberId(Long memberId); } diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java index 4e83d35..937d537 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java @@ -9,4 +9,6 @@ public interface MemberTermJpaRepository extends JpaRepository { // 특정 약관 및 유저의 동의 상태 조회 Optional findByMemberIdAndTermId(Long memberId, Long termId); + + void deleteByMemberId(Long memberId); } diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java new file mode 100644 index 0000000..ed15898 --- /dev/null +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java @@ -0,0 +1,8 @@ +package talkPick.domain.member.adapter.out.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import talkPick.domain.topic.domain.member.MemberTopicHistory; + +public interface MemberTopicHistoryJpaRepository extends JpaRepository { + void deleteByMemberId(Long memberId); +} diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java index 93980ac..7d1c45c 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java @@ -7,4 +7,6 @@ public interface MemberTopicResultJpaRepository extends JpaRepository { Optional findByMemberTopicHistoryId(Long memberTopicHistoryId); + + void deleteByMemberId(Long memberId); } diff --git a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java index 18478b4..b730698 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java +++ b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java @@ -6,4 +6,6 @@ public interface RandomJpaRepository extends JpaRepository { Optional findRandomByMemberIdAndId(Long memberId, Long randomId); + + void deleteByMemberId(Long memberId); } diff --git a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java index 3f1dde2..d9cf5dc 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java +++ b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java @@ -7,4 +7,6 @@ public interface RandomTopicHistoryJpaRepository extends JpaRepository { Optional findByMemberIdAndRandomIdAndOrder(Long memberId, Long randomId, Integer order); + + void deleteByMemberId(Long memberId); } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java b/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java index eda474e..676821a 100644 --- a/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java +++ b/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java @@ -4,4 +4,5 @@ import talkPick.domain.today.domain.TodayTopic; public interface TodayTopicJpaRepository extends JpaRepository { + void deleteByMemberId(Long memberId); } diff --git a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java index 9303a8d..4fc2690 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java @@ -4,4 +4,5 @@ import talkPick.domain.topic.domain.TopicLikeHistory; public interface TopicLikeHistoryJpaRepository extends JpaRepository { + void deleteByMemberId(Long memberId); } From 19f8bf9eda945f942d7e475c0b008da2b05442cd Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Wed, 24 Dec 2025 21:55:47 +0900 Subject: [PATCH 04/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=ED=95=98=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20#253?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberTermCommandRepositoryAdapter.java | 5 ++ .../application/MemberCommandService.java | 36 ++++++-- .../out/MemberTermCommandRepositoryPort.java | 1 + .../MemberDeleteVerificationTest.java | 87 +++++++++++++++++++ 4 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 src/test/java/talkPick/domain/member/application/MemberDeleteVerificationTest.java diff --git a/src/main/java/talkPick/domain/member/adapter/out/MemberTermCommandRepositoryAdapter.java b/src/main/java/talkPick/domain/member/adapter/out/MemberTermCommandRepositoryAdapter.java index f03cbb9..b5cbb91 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/MemberTermCommandRepositoryAdapter.java +++ b/src/main/java/talkPick/domain/member/adapter/out/MemberTermCommandRepositoryAdapter.java @@ -22,6 +22,11 @@ public Optional findByMemberIdAndTermId(Long memberId, Long termId) public MemberTerm save(MemberTerm memberTerm) { return repository.save(memberTerm); } + + @Override + public void deleteByMemberId(Long memberId) { + repository.deleteByMemberId(memberId); + } } diff --git a/src/main/java/talkPick/domain/member/application/MemberCommandService.java b/src/main/java/talkPick/domain/member/application/MemberCommandService.java index 927955b..2a0a83b 100644 --- a/src/main/java/talkPick/domain/member/application/MemberCommandService.java +++ b/src/main/java/talkPick/domain/member/application/MemberCommandService.java @@ -1,9 +1,9 @@ package talkPick.domain.member.application; import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import talkPick.domain.member.adapter.out.repository.MemberJpaRepository; import talkPick.domain.member.port.out.MemberCommandRepositoryPort; import talkPick.domain.member.port.out.MemberTermCommandRepositoryPort; import talkPick.domain.member.adapter.out.repository.MemberTopicResultJpaRepository; @@ -26,6 +26,12 @@ import talkPick.global.exception.handler.TermExceptionHandler; import talkPick.global.model.TalkPickStatus; import talkPick.global.security.jwt.util.JwtProvider; +import talkPick.domain.inquiry.adapter.out.repository.InquiryJpaRepository; +import talkPick.domain.random.adapter.out.repository.RandomJpaRepository; +import talkPick.domain.random.adapter.out.repository.RandomTopicHistoryJpaRepository; +import talkPick.domain.today.adapter.out.repository.TodayTopicJpaRepository; +import talkPick.domain.topic.adapter.out.repository.TopicLikeHistoryJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTopicHistoryJpaRepository; import java.util.List; @@ -42,6 +48,14 @@ public class MemberCommandService implements MemberCommandUseCase { private final MemberQueryRepositoryPort memberQueryRepositoryPort; private final JwtProvider jwtProvider; private final MemberTopicResultJpaRepository memberTopicResultJpaRepository; + private final MemberJpaRepository memberJpaRepository; + private final InquiryJpaRepository inquiryJpaRepository; + private final RandomJpaRepository randomJpaRepository; + private final RandomTopicHistoryJpaRepository randomTopicHistoryJpaRepository; + private final TodayTopicJpaRepository todayTopicJpaRepository; + private final TopicLikeHistoryJpaRepository topicLikeHistoryJpaRepository; + private final MemberTopicHistoryJpaRepository memberTopicHistoryJpaRepository; + /** * 회원 프로필 수정 @@ -94,11 +108,6 @@ public MemberResDto.MemberSignupResponse memberSignup(String authorization, Memb findMember.updateStatus(TalkPickStatus.ACTIVE); memberCommandRepositoryPort.save(findMember); - // 이메일 회원은 임시 토큰 삭제 처리 -// if (findMember.getLoginType() == LoginType.EMAIL) { -// refreshTokenRepository.findByMember(findMember).ifPresent(refreshTokenRepository::delete); -// } - // 소셜 로그인 회원 가입 완료 시 로그인 기록 저장 if (findMember.getLoginType() == LoginType.KAKAO || findMember.getLoginType() == LoginType.APPLE) { MemberLoginHistory loginHistory = MemberConverter.toLoginHistory(findMember); @@ -179,8 +188,23 @@ public void delete(String authorization) { refreshTokenRepository.findByMember(findMember).ifPresent(refreshTokenRepository::delete); + // 연관 데이터 삭제 + deleteAllRelatedData(findMember.getId()); + // 로그인 기록 삭제 memberLoginHistoryRepository.deleteByMemberId(findMember.getId()); + memberJpaRepository.deleteById(findMember.getId()); + } + + private void deleteAllRelatedData(Long memberId) { + inquiryJpaRepository.deleteByMemberId(memberId); + memberTermJpaRepository.deleteByMemberId(memberId); + memberTopicResultJpaRepository.deleteByMemberId(memberId); + randomTopicHistoryJpaRepository.deleteByMemberId(memberId); + randomJpaRepository.deleteByMemberId(memberId); + todayTopicJpaRepository.deleteByMemberId(memberId); + topicLikeHistoryJpaRepository.deleteByMemberId(memberId); + memberTopicHistoryJpaRepository.deleteByMemberId(memberId); } // 토픽 캘린더 조회 코멘트 수정 diff --git a/src/main/java/talkPick/domain/member/port/out/MemberTermCommandRepositoryPort.java b/src/main/java/talkPick/domain/member/port/out/MemberTermCommandRepositoryPort.java index 0986741..0d1382f 100644 --- a/src/main/java/talkPick/domain/member/port/out/MemberTermCommandRepositoryPort.java +++ b/src/main/java/talkPick/domain/member/port/out/MemberTermCommandRepositoryPort.java @@ -7,6 +7,7 @@ public interface MemberTermCommandRepositoryPort { Optional findByMemberIdAndTermId(Long memberId, Long termId); MemberTerm save(MemberTerm memberTerm); + void deleteByMemberId(Long memberId); } diff --git a/src/test/java/talkPick/domain/member/application/MemberDeleteVerificationTest.java b/src/test/java/talkPick/domain/member/application/MemberDeleteVerificationTest.java new file mode 100644 index 0000000..fd55957 --- /dev/null +++ b/src/test/java/talkPick/domain/member/application/MemberDeleteVerificationTest.java @@ -0,0 +1,87 @@ +package talkPick.domain.member.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import talkPick.domain.inquiry.adapter.out.repository.InquiryJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTopicResultJpaRepository; +import talkPick.domain.member.domain.Member; +import talkPick.domain.member.port.out.MemberCommandRepositoryPort; +import talkPick.domain.member.port.out.MemberLoginHistoryCommandRepositoryPort; +import talkPick.domain.member.port.out.MemberQueryRepositoryPort; +import talkPick.domain.member.port.out.MemberTermCommandRepositoryPort; +import talkPick.domain.random.adapter.out.repository.RandomJpaRepository; +import talkPick.domain.random.adapter.out.repository.RandomTopicHistoryJpaRepository; +import talkPick.domain.term.port.out.TermQueryRepositoryPort; +import talkPick.domain.today.adapter.out.repository.TodayTopicJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTopicHistoryJpaRepository; +import talkPick.domain.topic.adapter.out.repository.TopicLikeHistoryJpaRepository; +import talkPick.global.security.jwt.repository.RefreshTokenRepository; +import talkPick.global.security.jwt.util.JwtProvider; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class MemberDeleteVerificationTest { + + @InjectMocks + private MemberCommandService memberCommandService; + + @Mock private MemberCommandRepositoryPort memberCommandRepositoryPort; + @Mock private TermQueryRepositoryPort termQueryRepositoryPort; + @Mock private MemberTermCommandRepositoryPort memberTermJpaRepository; + @Mock private RefreshTokenRepository refreshTokenRepository; + @Mock private MemberLoginHistoryCommandRepositoryPort memberLoginHistoryRepository; + @Mock private MemberQueryRepositoryPort memberQueryRepositoryPort; + @Mock private JwtProvider jwtProvider; + @Mock private MemberTopicResultJpaRepository memberTopicResultJpaRepository; + @Mock private MemberJpaRepository memberJpaRepository; + @Mock private InquiryJpaRepository inquiryJpaRepository; + @Mock private RandomJpaRepository randomJpaRepository; + @Mock private RandomTopicHistoryJpaRepository randomTopicHistoryJpaRepository; + @Mock private TodayTopicJpaRepository todayTopicJpaRepository; + @Mock private TopicLikeHistoryJpaRepository topicLikeHistoryJpaRepository; + @Mock private MemberTopicHistoryJpaRepository memberTopicHistoryJpaRepository; + + @Test + @DisplayName("회원 탈퇴 시 모든 연관 데이터가 삭제되어야 한다") + void delete_shouldDeleteAllRelatedData() { + // given + String token = "Bearer token"; + Long memberId = 1L; + Member mockMember = mock(Member.class); + given(mockMember.getId()).willReturn(memberId); + + given(jwtProvider.getMemberId(token)).willReturn(memberId); + given(memberQueryRepositoryPort.findMemberById(memberId)).willReturn(mockMember); + given(refreshTokenRepository.findByMember(mockMember)).willReturn(Optional.empty()); + + // when + memberCommandService.delete(token); + + // then + // 기존 삭제 로직 검증 + verify(memberLoginHistoryRepository).deleteByMemberId(memberId); + verify(memberJpaRepository).deleteById(memberId); + + // 누락되었던 삭제 로직 검증 + verify(inquiryJpaRepository).deleteByMemberId(memberId); + verify(memberTermJpaRepository).deleteByMemberId(memberId); + verify(memberTopicResultJpaRepository).deleteByMemberId(memberId); + verify(randomJpaRepository).deleteByMemberId(memberId); + verify(randomTopicHistoryJpaRepository).deleteByMemberId(memberId); + verify(todayTopicJpaRepository).deleteByMemberId(memberId); + verify(topicLikeHistoryJpaRepository).deleteByMemberId(memberId); + verify(memberTopicHistoryJpaRepository).deleteByMemberId(memberId); + } +} From e7bd19e04d7c4bc8869115db0f952d7308db52a4 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Fri, 26 Dec 2025 19:25:46 +0900 Subject: [PATCH 05/49] =?UTF-8?q?refactor:=20=EB=B2=8C=ED=81=AC=20?= =?UTF-8?q?=EC=97=B0=EC=82=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=20-=20#257?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/repository/InquiryJpaRepository.java | 7 +++++++ .../repository/MemberLoginHistoryJpaRepository.java | 9 ++++++++- .../out/repository/MemberTermJpaRepository.java | 10 ++++++++-- .../repository/MemberTopicHistoryJpaRepository.java | 7 +++++++ .../out/repository/MemberTopicResultJpaRepository.java | 7 +++++++ .../adapter/out/repository/RandomJpaRepository.java | 9 ++++++++- .../repository/RandomTopicHistoryJpaRepository.java | 9 ++++++++- .../out/repository/TodayTopicJpaRepository.java | 9 ++++++++- .../out/repository/TopicLikeHistoryJpaRepository.java | 9 ++++++++- .../jwt/repository/RefreshTokenRepository.java | 9 ++++++++- 10 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java b/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java index 25fbc50..f8898bf 100644 --- a/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java +++ b/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java @@ -1,8 +1,15 @@ package talkPick.domain.inquiry.adapter.out.repository; 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 talkPick.domain.inquiry.domain.Inquiry; public interface InquiryJpaRepository extends JpaRepository { void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Inquiry i WHERE i.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); } diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLoginHistoryJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLoginHistoryJpaRepository.java index acb08f9..9e5ef8d 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLoginHistoryJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLoginHistoryJpaRepository.java @@ -1,8 +1,15 @@ package talkPick.domain.member.adapter.out.repository; 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 talkPick.domain.member.domain.MemberLoginHistory; public interface MemberLoginHistoryJpaRepository extends JpaRepository { void deleteByMemberId(Long memberId); -} + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MemberLoginHistory m WHERE m.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java index 937d537..224f815 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java @@ -1,8 +1,10 @@ package talkPick.domain.member.adapter.out.repository; 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 talkPick.domain.member.domain.mapping.MemberTerm; -import talkPick.domain.term.domain.Term; import java.util.Optional; @@ -11,4 +13,8 @@ public interface MemberTermJpaRepository extends JpaRepository Optional findByMemberIdAndTermId(Long memberId, Long termId); void deleteByMemberId(Long memberId); -} + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MemberTerm m WHERE m.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java index ed15898..7bbb325 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java @@ -1,8 +1,15 @@ package talkPick.domain.member.adapter.out.repository; 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 talkPick.domain.topic.domain.member.MemberTopicHistory; public interface MemberTopicHistoryJpaRepository extends JpaRepository { void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MemberTopicHistory m WHERE m.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); } diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java index 7d1c45c..4ed246f 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java @@ -1,6 +1,9 @@ package talkPick.domain.member.adapter.out.repository; 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 talkPick.domain.topic.domain.member.MemberTopicResult; import java.util.Optional; @@ -9,4 +12,8 @@ public interface MemberTopicResultJpaRepository extends JpaRepository findByMemberTopicHistoryId(Long memberTopicHistoryId); void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MemberTopicResult m WHERE m.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); } diff --git a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java index b730698..6cbc4bb 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java +++ b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java @@ -1,6 +1,9 @@ package talkPick.domain.random.adapter.out.repository; 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 talkPick.domain.random.domain.Random; import java.util.Optional; @@ -8,4 +11,8 @@ public interface RandomJpaRepository extends JpaRepository { Optional findRandomByMemberIdAndId(Long memberId, Long randomId); void deleteByMemberId(Long memberId); -} + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Random r WHERE r.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java index d9cf5dc..179bf22 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java +++ b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java @@ -1,6 +1,9 @@ package talkPick.domain.random.adapter.out.repository; 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 talkPick.domain.random.domain.RandomTopicHistory; import java.util.Optional; @@ -9,4 +12,8 @@ public interface RandomTopicHistoryJpaRepository extends JpaRepository findByMemberIdAndRandomIdAndOrder(Long memberId, Long randomId, Integer order); void deleteByMemberId(Long memberId); -} \ No newline at end of file + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM RandomTopicHistory r WHERE r.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} diff --git a/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java b/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java index 676821a..44186f3 100644 --- a/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java +++ b/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java @@ -1,8 +1,15 @@ package talkPick.domain.today.adapter.out.repository; 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 talkPick.domain.today.domain.TodayTopic; public interface TodayTopicJpaRepository extends JpaRepository { void deleteByMemberId(Long memberId); -} + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM TodayTopic t WHERE t.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java index 4fc2690..7609c6c 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java @@ -1,8 +1,15 @@ package talkPick.domain.topic.adapter.out.repository; 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 talkPick.domain.topic.domain.TopicLikeHistory; public interface TopicLikeHistoryJpaRepository extends JpaRepository { void deleteByMemberId(Long memberId); -} + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM TopicLikeHistory t WHERE t.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/global/security/jwt/repository/RefreshTokenRepository.java b/src/main/java/talkPick/global/security/jwt/repository/RefreshTokenRepository.java index 95b0c40..ab4cdcd 100644 --- a/src/main/java/talkPick/global/security/jwt/repository/RefreshTokenRepository.java +++ b/src/main/java/talkPick/global/security/jwt/repository/RefreshTokenRepository.java @@ -1,6 +1,9 @@ package talkPick.global.security.jwt.repository; 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 talkPick.domain.member.domain.Member; import talkPick.global.security.jwt.RefreshToken; @@ -13,4 +16,8 @@ public interface RefreshTokenRepository extends JpaRepository findByMember(Member member); Optional findByMemberId(Long memberId); void deleteByMember(Member member); -} \ No newline at end of file + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM RefreshToken r WHERE r.member.id = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} From bf9ba01184b748a0d86e4e7c54f29e78601822e6 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Fri, 26 Dec 2025 19:26:11 +0900 Subject: [PATCH 06/49] =?UTF-8?q?refactor:=20=ED=8D=BC=EC=82=AC=EB=93=9C?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20#257?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/MemberWithdrawalService.java | 61 +++++++++++++++++++ .../port/in/MemberWithdrawalUseCase.java | 5 ++ 2 files changed, 66 insertions(+) create mode 100644 src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java create mode 100644 src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java diff --git a/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java b/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java new file mode 100644 index 0000000..a6aeb23 --- /dev/null +++ b/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java @@ -0,0 +1,61 @@ +package talkPick.domain.member.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import talkPick.domain.inquiry.adapter.out.repository.InquiryJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberLoginHistoryJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTermJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTopicHistoryJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTopicResultJpaRepository; +import talkPick.domain.member.port.in.MemberWithdrawalUseCase; +import talkPick.domain.random.adapter.out.repository.RandomJpaRepository; +import talkPick.domain.random.adapter.out.repository.RandomTopicHistoryJpaRepository; +import talkPick.domain.today.adapter.out.repository.TodayTopicJpaRepository; +import talkPick.domain.topic.adapter.out.repository.TopicLikeHistoryJpaRepository; +import talkPick.global.security.jwt.repository.RefreshTokenRepository; +import talkPick.global.security.jwt.util.JwtProvider; + +@Service +@RequiredArgsConstructor +public class MemberWithdrawalService implements MemberWithdrawalUseCase { + + private final JwtProvider jwtProvider; + private final MemberJpaRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + + // 연관 데이터 리포지토리들 + private final InquiryJpaRepository inquiryRepository; + private final MemberTermJpaRepository memberTermRepository; + private final MemberLoginHistoryJpaRepository memberLoginHistoryRepository; + private final MemberTopicHistoryJpaRepository memberTopicHistoryRepository; + private final MemberTopicResultJpaRepository memberTopicResultRepository; + private final RandomJpaRepository randomRepository; + private final RandomTopicHistoryJpaRepository randomTopicHistoryRepository; + private final TodayTopicJpaRepository todayTopicRepository; + private final TopicLikeHistoryJpaRepository topicLikeHistoryRepository; + + @Override + @Transactional + public void withdraw(String authorization) { + Long memberId = jwtProvider.getMemberId(authorization); + + // 1. Refresh Token 삭제 + refreshTokenRepository.deleteAllByMemberIdInBulk(memberId); + + // 2. 연관 데이터 일괄 삭제 + inquiryRepository.deleteAllByMemberIdInBulk(memberId); + memberTermRepository.deleteAllByMemberIdInBulk(memberId); + memberLoginHistoryRepository.deleteAllByMemberIdInBulk(memberId); + memberTopicHistoryRepository.deleteAllByMemberIdInBulk(memberId); + memberTopicResultRepository.deleteAllByMemberIdInBulk(memberId); + randomRepository.deleteAllByMemberIdInBulk(memberId); + randomTopicHistoryRepository.deleteAllByMemberIdInBulk(memberId); + todayTopicRepository.deleteAllByMemberIdInBulk(memberId); + topicLikeHistoryRepository.deleteAllByMemberIdInBulk(memberId); + + // 3. 회원 삭제 + memberRepository.deleteById(memberId); + } +} diff --git a/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java b/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java new file mode 100644 index 0000000..e4497c8 --- /dev/null +++ b/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java @@ -0,0 +1,5 @@ +package talkPick.domain.member.port.in; + +public interface MemberWithdrawalUseCase { + void withdraw(String authorization); +} From 895cfcf8ff1b3670803e5ba5d72e554619a2e74d Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Fri, 26 Dec 2025 19:26:34 +0900 Subject: [PATCH 07/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=8B=9C=20=ED=8D=BC=EC=82=AC=EB=93=9C=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EB=B0=8F=20=EB=B2=8C=ED=81=AC=20=EC=97=B0?= =?UTF-8?q?=EC=82=B0=20=EC=A0=81=EC=9A=A9=20-=20#257?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/MemberCommandController.java | 9 +- .../application/MemberCommandService.java | 46 +-------- .../member/port/in/MemberCommandUseCase.java | 6 +- .../performance/ConnectionPoolTest.java | 82 ++++++++++++++++ .../MemberDeletePerformanceTest.java | 97 +++++++++++++++++++ .../performance/PerformanceTestService.java | 39 ++++++++ 6 files changed, 230 insertions(+), 49 deletions(-) create mode 100644 src/test/java/talkPick/performance/ConnectionPoolTest.java create mode 100644 src/test/java/talkPick/performance/MemberDeletePerformanceTest.java create mode 100644 src/test/java/talkPick/performance/PerformanceTestService.java diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java index efae082..ccf1a0c 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java @@ -15,6 +15,7 @@ import talkPick.domain.member.domain.Member; import talkPick.domain.member.dto.*; import talkPick.domain.member.port.in.MemberCommandUseCase; +import talkPick.domain.member.port.in.MemberWithdrawalUseCase; import talkPick.global.security.jwt.port.in.JwtTokenCommandUseCase; /** @@ -28,6 +29,7 @@ public class MemberCommandController implements MemberCommandApi { private final KakaoOidcUsecase kakaoOidcService; private final AppleOidcUsecase appleOidcService; private final MemberCommandUseCase memberCommandUseCase; + private final MemberWithdrawalUseCase memberWithdrawalUseCase; // 의존성 추가 private final JwtTokenCommandUseCase jwtTokenCommandUseCase; @@ -116,7 +118,8 @@ public void logout( public void deleteMember( @RequestHeader(value = "Authorization", required = false) String authorization ) { - memberCommandUseCase.delete(authorization); + // Facade 서비스 호출로 변경 + memberWithdrawalUseCase.withdraw(authorization); } @Override @@ -126,6 +129,4 @@ public void changeComment( memberCommandUseCase.TopicResultCommentChange(authorization, request); } - - -} \ No newline at end of file +} diff --git a/src/main/java/talkPick/domain/member/application/MemberCommandService.java b/src/main/java/talkPick/domain/member/application/MemberCommandService.java index 2a0a83b..b04e2fb 100644 --- a/src/main/java/talkPick/domain/member/application/MemberCommandService.java +++ b/src/main/java/talkPick/domain/member/application/MemberCommandService.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import talkPick.domain.member.adapter.out.repository.MemberJpaRepository; import talkPick.domain.member.port.out.MemberCommandRepositoryPort; import talkPick.domain.member.port.out.MemberTermCommandRepositoryPort; import talkPick.domain.member.adapter.out.repository.MemberTopicResultJpaRepository; @@ -15,6 +14,7 @@ import talkPick.domain.member.adapter.in.dto.MemberReqDto; import talkPick.domain.member.adapter.out.dto.MemberResDto; import talkPick.domain.member.port.in.MemberCommandUseCase; +import talkPick.domain.member.port.in.MemberWithdrawalUseCase; import talkPick.domain.member.port.out.MemberLoginHistoryCommandRepositoryPort; import talkPick.domain.member.port.out.MemberQueryRepositoryPort; import talkPick.domain.term.port.out.TermQueryRepositoryPort; @@ -26,12 +26,7 @@ import talkPick.global.exception.handler.TermExceptionHandler; import talkPick.global.model.TalkPickStatus; import talkPick.global.security.jwt.util.JwtProvider; -import talkPick.domain.inquiry.adapter.out.repository.InquiryJpaRepository; -import talkPick.domain.random.adapter.out.repository.RandomJpaRepository; -import talkPick.domain.random.adapter.out.repository.RandomTopicHistoryJpaRepository; -import talkPick.domain.today.adapter.out.repository.TodayTopicJpaRepository; -import talkPick.domain.topic.adapter.out.repository.TopicLikeHistoryJpaRepository; -import talkPick.domain.member.adapter.out.repository.MemberTopicHistoryJpaRepository; + import java.util.List; @@ -46,15 +41,9 @@ public class MemberCommandService implements MemberCommandUseCase { private final RefreshTokenRepository refreshTokenRepository; private final MemberLoginHistoryCommandRepositoryPort memberLoginHistoryRepository; private final MemberQueryRepositoryPort memberQueryRepositoryPort; + private final MemberWithdrawalUseCase memberWithdrawalUseCase; private final JwtProvider jwtProvider; private final MemberTopicResultJpaRepository memberTopicResultJpaRepository; - private final MemberJpaRepository memberJpaRepository; - private final InquiryJpaRepository inquiryJpaRepository; - private final RandomJpaRepository randomJpaRepository; - private final RandomTopicHistoryJpaRepository randomTopicHistoryJpaRepository; - private final TodayTopicJpaRepository todayTopicJpaRepository; - private final TopicLikeHistoryJpaRepository topicLikeHistoryJpaRepository; - private final MemberTopicHistoryJpaRepository memberTopicHistoryJpaRepository; /** @@ -176,35 +165,10 @@ public void logout(String authorization) { memberLoginHistoryRepository.deleteByMemberId(findMember.getId()); } - // 회원 탈퇴 처리 (상태 비활성화, 토큰 및 로그인 기록 삭제) + // 회원 탈퇴 처리 @Override public void delete(String authorization) { - Long memberId = jwtProvider.getMemberId(authorization); - - Member findMember = memberQueryRepositoryPort.findMemberById(memberId); - - findMember.updateStatus(TalkPickStatus.DIS_ACTIVE); - memberCommandRepositoryPort.save(findMember); - - refreshTokenRepository.findByMember(findMember).ifPresent(refreshTokenRepository::delete); - - // 연관 데이터 삭제 - deleteAllRelatedData(findMember.getId()); - - // 로그인 기록 삭제 - memberLoginHistoryRepository.deleteByMemberId(findMember.getId()); - memberJpaRepository.deleteById(findMember.getId()); - } - - private void deleteAllRelatedData(Long memberId) { - inquiryJpaRepository.deleteByMemberId(memberId); - memberTermJpaRepository.deleteByMemberId(memberId); - memberTopicResultJpaRepository.deleteByMemberId(memberId); - randomTopicHistoryJpaRepository.deleteByMemberId(memberId); - randomJpaRepository.deleteByMemberId(memberId); - todayTopicJpaRepository.deleteByMemberId(memberId); - topicLikeHistoryJpaRepository.deleteByMemberId(memberId); - memberTopicHistoryJpaRepository.deleteByMemberId(memberId); + memberWithdrawalUseCase.withdraw(authorization); } // 토픽 캘린더 조회 코멘트 수정 diff --git a/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java b/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java index 9ff8e94..8d8dd58 100644 --- a/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java +++ b/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java @@ -7,13 +7,11 @@ import talkPick.domain.member.adapter.out.dto.MemberResDto; public interface MemberCommandUseCase { -// Member findOrCreateEmailMember(MemberReqDto.MemberEmailRequest emailReqDto); -// Member loginEmailMember(MemberReqDto.MemberEmailRequest emailReqDto); MemberResDto.MemberProfileResponse updateProfile(String authorization, MemberReqDto.ProfileUpdateRequest request); Member findOrCreateMember(MemberDataDto.MemberData kakaoMemberData, LoginType loginType); MemberResDto.MemberSignupResponse memberSignup(String authorization, MemberReqDto.MemberSignupRequest request); MemberResDto.TermAgreementResponse termAgreement(String authorization, MemberReqDto.TermAgreementRequest request); void logout(String authorization); - void delete(String authorization); void TopicResultCommentChange(String authorization, MemberReqDto.TopicResultCommentChangeRequest request); -} + void delete(String authorization); +} \ No newline at end of file diff --git a/src/test/java/talkPick/performance/ConnectionPoolTest.java b/src/test/java/talkPick/performance/ConnectionPoolTest.java new file mode 100644 index 0000000..c72933c --- /dev/null +++ b/src/test/java/talkPick/performance/ConnectionPoolTest.java @@ -0,0 +1,82 @@ +package talkPick.performance; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.util.StopWatch; + +import javax.sql.DataSource; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@SpringBootTest +@Import(PerformanceTestService.class) +public class ConnectionPoolTest { + + private static final Logger log = LoggerFactory.getLogger(ConnectionPoolTest.class); + + @Autowired + private PerformanceTestService performanceTestService; + + @Autowired + private DataSource dataSource; + + @Test + @DisplayName("커넥션 풀 사이즈와 컨텍스트 스위칭 성능 테스트") + void testConnectionPoolPerformance() throws InterruptedException { + // 1. 현재 HikariCP 설정 확인 + HikariDataSource hikariDataSource = (HikariDataSource) dataSource; + int currentPoolSize = hikariDataSource.getMaximumPoolSize(); + log.info("=================================================="); + log.info("현재 HikariCP Maximum Pool Size: {}", currentPoolSize); + log.info("=================================================="); + + // 테스트 설정 + int threadCount = 3000; // 동시 요청 스레드 수 + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + StopWatch stopWatch = new StopWatch(); + + log.info("테스트 시작: {}개의 동시 요청 실행 중...", threadCount); + stopWatch.start(); + + for (int i = 0; i < threadCount; i++) { + executorService.execute(() -> { + try { + performanceTestService.heavyWork(); + } catch (Exception e) { + log.error("테스트 중 예외 발생", e); + } finally { + latch.countDown(); + } + }); + } + + // 모든 스레드가 끝날 때까지 대기 + latch.await(); + stopWatch.stop(); + + log.info("=================================================="); + log.info("테스트 완료"); + log.info("총 소요 시간: {} ms", stopWatch.getTotalTimeMillis()); + log.info("초당 처리량(TPS): {}", threadCount / stopWatch.getTotalTimeSeconds()); + log.info("=================================================="); + + /* + * [테스트 가이드] + * 1. application.yml 또는 환경변수에서 'maximum-pool-size'를 10으로 설정 후 실행해 보세요. + * 2. 그 다음, 200으로 설정 후 실행해 보세요. + * + * 예상 결과: + * - Pool Size 10 (적절): 스레드들이 줄을 서서(Queueing) 기다리지만, CPU는 해시 연산에 집중하므로 전체 처리 속도는 빠릅니다. + * - Pool Size 200 (과다): 200개의 스레드가 동시에 커넥션을 잡고 CPU 쟁탈전을 벌입니다. + * 컨텍스트 스위칭 비용으로 인해 총 소요 시간이 오히려 더 늘어날 수 있습니다. + */ + } +} diff --git a/src/test/java/talkPick/performance/MemberDeletePerformanceTest.java b/src/test/java/talkPick/performance/MemberDeletePerformanceTest.java new file mode 100644 index 0000000..752e809 --- /dev/null +++ b/src/test/java/talkPick/performance/MemberDeletePerformanceTest.java @@ -0,0 +1,97 @@ +package talkPick.performance; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import talkPick.domain.inquiry.adapter.out.repository.InquiryJpaRepository; +import talkPick.domain.inquiry.domain.Inquiry; +import talkPick.domain.inquiry.domain.type.InquiryType; +import talkPick.domain.member.adapter.out.repository.MemberTopicHistoryJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTopicResultJpaRepository; +import talkPick.domain.topic.domain.member.MemberTopicHistory; +import talkPick.domain.topic.domain.member.MemberTopicResult; +import talkPick.domain.topic.domain.type.TopicType; + +import java.util.ArrayList; +import java.util.List; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class MemberDeletePerformanceTest { + + @Autowired InquiryJpaRepository inquiryRepository; + @Autowired MemberTopicHistoryJpaRepository historyRepository; + @Autowired MemberTopicResultJpaRepository resultRepository; + @Autowired EntityManager em; + + @Test + @DisplayName("회원 삭제 시 쿼리 발생 횟수 비교 (기존 vs 벌크)") + void compareQueryCounts() { + Long memberId = 99999L; // 테스트용 가상 ID + + // Case 1: 기존 방식 (JPA Delete) + setupData(memberId); + em.flush(); + em.clear(); + + inquiryRepository.deleteByMemberId(memberId); + historyRepository.deleteByMemberId(memberId); + resultRepository.deleteByMemberId(memberId); + em.flush(); + + // Case 2: 벌크 방식 (@Modifying) + setupData(memberId); + em.flush(); + em.clear(); + + inquiryRepository.deleteAllByMemberIdInBulk(memberId); + historyRepository.deleteAllByMemberIdInBulk(memberId); + resultRepository.deleteAllByMemberIdInBulk(memberId); + } + + private void setupData(Long memberId) { + int count = 100; // 각 엔티티 당 100개씩 생성 + + // 1. Inquiry 생성 + List inquiries = new ArrayList<>(); + for (int i = 0; i < count; i++) { + inquiries.add(Inquiry.builder() + .memberId(memberId) + .title("테스트 문의 " + i) + .content("내용입니다.") + .email("test" + i + "@example.com") + .type(InquiryType.GENERAL) + .isAnswered(false) + .build()); + } + inquiryRepository.saveAll(inquiries); + + // 2. MemberTopicHistory 생성 + List histories = new ArrayList<>(); + for (int i = 0; i < count; i++) { + histories.add(MemberTopicHistory.builder() + .memberId(memberId) + .topicId(100L + i) + .talkTime(60000L) + .checkLiked(false) + .sequence(i) + .topicType(TopicType.SELECTED) + .build()); + } + historyRepository.saveAll(histories); + + // 3. MemberTopicResult 생성 + List results = new ArrayList<>(); + for (int i = 0; i < count; i++) { + results.add(MemberTopicResult.builder() + .memberId(memberId) + .memberTopicHistoryId(200L + i) + .comment("결과 코멘트 " + i) + .build()); + } + resultRepository.saveAll(results); + } +} diff --git a/src/test/java/talkPick/performance/PerformanceTestService.java b/src/test/java/talkPick/performance/PerformanceTestService.java new file mode 100644 index 0000000..a761163 --- /dev/null +++ b/src/test/java/talkPick/performance/PerformanceTestService.java @@ -0,0 +1,39 @@ +package talkPick.performance; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Service +public class PerformanceTestService { + + /** + * DB 커넥션을 점유한 상태(@Transactional)에서 + * CPU 연산을 수행하여 컨텍스트 스위칭 부하를 시뮬레이션합니다. + */ + @Transactional + public void heavyWork() { + try { + // CPU 부하를 주기 위한 해시 계산 (50,000번 반복) + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + String data = "dummy-data-for-simulation-" + System.nanoTime(); + final Object SHARED_LOCK = new Object(); + + for (int i = 0; i < 50000; i++) { + digest.update(data.getBytes()); + digest.digest(); + + synchronized (SHARED_LOCK) { + try { + // 아주 짧은 락 점유 + Thread.sleep(1); + } catch (InterruptedException e) {} + } + } + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 알고리즘을 찾을 수 없습니다.", e); + } + } +} From 6c71081fed47048c7d0d93a7c8fc581b003e1c65 Mon Sep 17 00:00:00 2001 From: Zetty Date: Fri, 26 Dec 2025 21:31:55 +0900 Subject: [PATCH 08/49] feat: Add event handler for TopicLike - #260 --- .../out/event/TopicLikedEventHandler.java | 28 ++++++++++ .../application/TopicCommandService.java | 9 +-- .../domain/topic/domain/TopicStat.java | 55 ------------------- .../topic/domain/event/TopicLikedEvent.java | 20 +++++++ 4 files changed, 53 insertions(+), 59 deletions(-) create mode 100644 src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java create mode 100644 src/main/java/talkPick/domain/topic/domain/event/TopicLikedEvent.java diff --git a/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java b/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java new file mode 100644 index 0000000..ad8e7d9 --- /dev/null +++ b/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java @@ -0,0 +1,28 @@ +package talkPick.domain.topic.adapter.out.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import talkPick.domain.topic.domain.event.TopicLikedEvent; +import talkPick.domain.topic.port.out.TopicStatCommandRepositoryPort; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TopicLikedEventHandler { + private final TopicStatCommandRepositoryPort topicStatCommandRepositoryPort; + + @Async + @EventListener + @Transactional + public void handle(TopicLikedEvent event) { + try { + topicStatCommandRepositoryPort.incrementLikeCount(event.getTopicId()); + } catch (Exception e) { + log.error("토픽 좋아요 수 증가 실패 - topicId: {}", event.getTopicId(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/application/TopicCommandService.java b/src/main/java/talkPick/domain/topic/application/TopicCommandService.java index 62b2e80..c60110f 100644 --- a/src/main/java/talkPick/domain/topic/application/TopicCommandService.java +++ b/src/main/java/talkPick/domain/topic/application/TopicCommandService.java @@ -1,23 +1,24 @@ package talkPick.domain.topic.application; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import talkPick.domain.topic.port.out.TopicLikeHistoryCommandRepositoryPort; -import talkPick.domain.topic.port.out.TopicStatCommandRepositoryPort; +import talkPick.domain.topic.domain.event.TopicLikedEvent; import talkPick.domain.topic.port.in.TopicCommandUseCase; +import talkPick.domain.topic.port.out.TopicLikeHistoryCommandRepositoryPort; import talkPick.global.security.annotation.MemberId; @Service @Transactional @RequiredArgsConstructor public class TopicCommandService implements TopicCommandUseCase { - private final TopicStatCommandRepositoryPort topicStatCommandRepositoryPort; private final TopicLikeHistoryCommandRepositoryPort topicLikeHistoryCommandRepositoryPort; + private final ApplicationEventPublisher eventPublisher; @Override public void addLike(@MemberId Long memberId, Long topicId) { - topicStatCommandRepositoryPort.incrementLikeCount(topicId); topicLikeHistoryCommandRepositoryPort.save(memberId, topicId); + eventPublisher.publishEvent(TopicLikedEvent.of(this, memberId, topicId)); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/domain/TopicStat.java b/src/main/java/talkPick/domain/topic/domain/TopicStat.java index 5f35fd7..e7be1b5 100644 --- a/src/main/java/talkPick/domain/topic/domain/TopicStat.java +++ b/src/main/java/talkPick/domain/topic/domain/TopicStat.java @@ -2,12 +2,7 @@ import jakarta.persistence.*; import lombok.*; -import talkPick.domain.member.domain.Member; -import talkPick.domain.member.domain.type.Gender; -import talkPick.domain.member.domain.type.MBTI; -import java.time.LocalDate; -//TODO 동시성 고려해야 함. @Getter @Entity @Builder @@ -77,10 +72,6 @@ public class TopicStat { @Column(name = "average_talk_time", nullable = false, columnDefinition = "BIGINT COMMENT '평균 토크 시간(ms)'") private long averageTalkTime; - @Version - @Column(name = "version", nullable = false, columnDefinition = "BIGINT COMMENT '버전'") - private Long version; - public static TopicStat of(Long topicId) { return TopicStat.builder() .topicId(topicId) @@ -97,50 +88,4 @@ public static TopicStat of(Long topicId) { .likeCount(0) .build(); } - - public void addLike() { - this.likeCount++; - } - - //TODO 이 메서드 호출할 때 락 체크 + 리트라이 필요 - public void update(Member member, long talkTime) { - MBTI mbti = MBTI.INFP; - updateMBTI(mbti); - updateAverageTalkTime(talkTime); - this.selectCount++; - } - - private void updateMBTI(MBTI mbti) { - if (mbti != null) { - String mbtiString = mbti.name(); - if (mbtiString.startsWith("E")) { - this.eCount++; - } else if (mbtiString.startsWith("I")) { - this.iCount++; - } - if (mbtiString.charAt(1) == 'S') { - this.sCount++; - } else if (mbtiString.charAt(1) == 'N') { - this.nCount++; - } - if (mbtiString.charAt(2) == 'F') { - this.fCount++; - } else if (mbtiString.charAt(2) == 'T') { - this.tCount++; - } - if (mbtiString.charAt(3) == 'J') { - this.jCount++; - } else if (mbtiString.charAt(3) == 'P') { - this.pCount++; - } - } - } - - private void updateAverageTalkTime(long talkTime) { - if (this.selectCount == 1) { - this.averageTalkTime = talkTime; - } else { - this.averageTalkTime = ((this.averageTalkTime * (this.selectCount - 1)) + talkTime) / this.selectCount; - } - } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/domain/event/TopicLikedEvent.java b/src/main/java/talkPick/domain/topic/domain/event/TopicLikedEvent.java new file mode 100644 index 0000000..332f127 --- /dev/null +++ b/src/main/java/talkPick/domain/topic/domain/event/TopicLikedEvent.java @@ -0,0 +1,20 @@ +package talkPick.domain.topic.domain.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class TopicLikedEvent extends ApplicationEvent { + private final Long memberId; + private final Long topicId; + + private TopicLikedEvent(Object source, Long memberId, Long topicId) { + super(source); + this.memberId = memberId; + this.topicId = topicId; + } + + public static TopicLikedEvent of(Object source, Long memberId, Long topicId) { + return new TopicLikedEvent(source, memberId, topicId); + } +} From 4e769c95bfd85533d7d3ec5b08d59d87294ac21e Mon Sep 17 00:00:00 2001 From: Zetty Date: Fri, 26 Dec 2025 22:27:49 +0900 Subject: [PATCH 09/49] refactor: Apply event-driven architecture with transactional safety - #260 --- .../adapter/out/event/NoticeReadEventHandler.java | 5 +++-- .../notice/application/NoticeQueryService.java | 2 ++ .../out/repository/RandomQuerydslRepository.java | 13 ++++++++++--- .../out/event/TodayTopicSavedEventHandler.java | 5 +++-- .../today/application/TodayTopicQueryService.java | 11 ++--------- .../adapter/out/event/TopicLikedEventHandler.java | 5 +++-- .../out/repository/TopicStatJpaRepository.java | 2 +- .../talkPick/domain/topic/domain/TopicStat.java | 11 +++++++++-- 8 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java b/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java index cf6e483..8afd52e 100644 --- a/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java +++ b/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java @@ -2,10 +2,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import talkPick.domain.notice.adapter.out.repository.NoticeJpaRepository; import talkPick.domain.notice.domain.Notice; import talkPick.domain.notice.domain.event.NoticeReadEvent; @@ -17,7 +18,7 @@ public class NoticeReadEventHandler { private final NoticeJpaRepository noticeJpaRepository; @Async - @EventListener + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional public void handle(NoticeReadEvent event) { try { diff --git a/src/main/java/talkPick/domain/notice/application/NoticeQueryService.java b/src/main/java/talkPick/domain/notice/application/NoticeQueryService.java index 6d3b0e8..a1416f5 100644 --- a/src/main/java/talkPick/domain/notice/application/NoticeQueryService.java +++ b/src/main/java/talkPick/domain/notice/application/NoticeQueryService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import talkPick.domain.notice.adapter.in.dto.NoticeReqDTO; import talkPick.domain.notice.adapter.out.dto.NoticeResDTO; import talkPick.domain.notice.domain.event.NoticeReadEvent; @@ -12,6 +13,7 @@ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class NoticeQueryService implements NoticeQueryUseCase { private final NoticeQueryRepositoryPort noticeQueryRepositoryPort; private final ApplicationEventPublisher eventPublisher; diff --git a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java index 1ea7c32..8e0e129 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java +++ b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java @@ -8,6 +8,8 @@ import talkPick.domain.random.adapter.out.dto.RandomResDTO; import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.global.model.TalkPickStatus; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import static talkPick.domain.random.domain.QRandomTopicHistory.randomTopicHistory; import static talkPick.domain.topic.domain.QCategory.category; @@ -42,7 +44,7 @@ public List findRandomTopics(Long memberId, Long builder.and(category.title.eq(categoryType)); } - return queryFactory + List topics = queryFactory .select(Projections.constructor(RandomResDTO.RandomTopicDetail.class, topic.id, topic.title, @@ -57,8 +59,13 @@ public List findRandomTopics(Long memberId, Long .leftJoin(category).on(topic.categoryId.eq(category.id)) .leftJoin(keyword).on(topic.keywordId.eq(keyword.id)) .where(builder) - .orderBy(com.querydsl.core.types.dsl.Expressions.numberTemplate(Double.class, "rand()").asc()) - .limit(4) + .limit(20) .fetch(); + + List shuffledTopics = new ArrayList<>(topics); + Collections.shuffle(shuffledTopics); + return shuffledTopics.stream() + .limit(4) + .toList(); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java b/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java index 18bb225..12884d3 100644 --- a/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java +++ b/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java @@ -2,10 +2,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import talkPick.domain.today.adapter.out.repository.TodayTopicJpaRepository; import talkPick.domain.today.domain.event.TodayTopicSavedEvent; @@ -16,8 +17,8 @@ public class TodayTopicSavedEventHandler { private final TodayTopicJpaRepository todayTopicJpaRepository; @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional - @EventListener public void handle(TodayTopicSavedEvent event) { try { if (event.getTodayTopics() != null && !event.getTodayTopics().isEmpty()) { diff --git a/src/main/java/talkPick/domain/today/application/TodayTopicQueryService.java b/src/main/java/talkPick/domain/today/application/TodayTopicQueryService.java index fe15403..1811519 100644 --- a/src/main/java/talkPick/domain/today/application/TodayTopicQueryService.java +++ b/src/main/java/talkPick/domain/today/application/TodayTopicQueryService.java @@ -4,9 +4,7 @@ import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import talkPick.domain.today.adapter.out.dto.TodayTopicResDTO; import talkPick.domain.today.domain.TodayTopic; @@ -39,16 +37,11 @@ public List getTodayTopics(Long memberId) { cache.put(memberId, todayTopics); } - publishSavedEvent(memberId, todayTopics); - return todayTopics; - } - - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void publishSavedEvent(Long memberId, List todayTopics) { var entities = todayTopics.stream() .map(t -> TodayTopic.of(memberId, t.topicId())) .toList(); eventPublisher.publishEvent(TodayTopicSavedEvent.of(this, entities)); + + return todayTopics; } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java b/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java index ad8e7d9..ff0292c 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java @@ -2,10 +2,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import talkPick.domain.topic.domain.event.TopicLikedEvent; import talkPick.domain.topic.port.out.TopicStatCommandRepositoryPort; @@ -16,7 +17,7 @@ public class TopicLikedEventHandler { private final TopicStatCommandRepositoryPort topicStatCommandRepositoryPort; @Async - @EventListener + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional public void handle(TopicLikedEvent event) { try { diff --git a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicStatJpaRepository.java b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicStatJpaRepository.java index c697742..cbbf7a3 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicStatJpaRepository.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicStatJpaRepository.java @@ -11,7 +11,7 @@ public interface TopicStatJpaRepository extends JpaRepository { Optional findByTopicId(Long topicId); - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE TopicStat SET likeCount = likeCount + 1 WHERE topicId = :topicId") void incrementLikeCount(@Param("topicId") Long topicId); } diff --git a/src/main/java/talkPick/domain/topic/domain/TopicStat.java b/src/main/java/talkPick/domain/topic/domain/TopicStat.java index e7be1b5..d9b232d 100644 --- a/src/main/java/talkPick/domain/topic/domain/TopicStat.java +++ b/src/main/java/talkPick/domain/topic/domain/TopicStat.java @@ -83,9 +83,16 @@ public static TopicStat of(Long topicId) { .tCount(0) .jCount(0) .pCount(0) - .averageTalkTime(0) - .selectCount(0) .likeCount(0) + .teenCount(0) + .twentiesCount(0) + .thirtiesCount(0) + .fortiesCount(0) + .fiftiesCount(0) + .maleCount(0) + .femaleCount(0) + .selectCount(0) + .averageTalkTime(0) .build(); } } \ No newline at end of file From efc1885408f000377e96105cd319a4a8c8cc76b7 Mon Sep 17 00:00:00 2001 From: Zetty Date: Sat, 27 Dec 2025 00:09:31 +0900 Subject: [PATCH 10/49] test: Add Notice test code - #260 --- .../application/NoticeQueryServiceTest.java | 198 ++++++++++++++++++ .../domain/notice/domain/NoticeImageTest.java | 65 ++++++ .../domain/notice/domain/NoticeTest.java | 79 +++++++ .../domain/event/NoticeReadEventTest.java | 43 ++++ .../adapter/RateLimitControllerTest.java | 50 ----- .../application/TopicCommandServiceTest.java | 58 ----- 6 files changed, 385 insertions(+), 108 deletions(-) create mode 100644 src/test/java/talkPick/domain/notice/application/NoticeQueryServiceTest.java create mode 100644 src/test/java/talkPick/domain/notice/domain/NoticeImageTest.java create mode 100644 src/test/java/talkPick/domain/notice/domain/NoticeTest.java create mode 100644 src/test/java/talkPick/domain/notice/domain/event/NoticeReadEventTest.java delete mode 100644 src/test/java/talkPick/rateLimiter/adapter/RateLimitControllerTest.java delete mode 100644 src/test/java/talkPick/topic/application/TopicCommandServiceTest.java diff --git a/src/test/java/talkPick/domain/notice/application/NoticeQueryServiceTest.java b/src/test/java/talkPick/domain/notice/application/NoticeQueryServiceTest.java new file mode 100644 index 0000000..483465d --- /dev/null +++ b/src/test/java/talkPick/domain/notice/application/NoticeQueryServiceTest.java @@ -0,0 +1,198 @@ +package talkPick.domain.notice.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import talkPick.domain.notice.adapter.in.dto.NoticeReqDTO; +import talkPick.domain.notice.adapter.out.dto.NoticeResDTO; +import talkPick.domain.notice.domain.event.NoticeReadEvent; +import talkPick.domain.notice.port.out.NoticeQueryRepositoryPort; +import talkPick.global.response.CursorPageResponse; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NoticeQueryService 테스트") +class NoticeQueryServiceTest { + + @InjectMocks + private NoticeQueryService noticeQueryService; + + @Mock + private NoticeQueryRepositoryPort noticeQueryRepositoryPort; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Test + @DisplayName("커서 기반 페이징으로 공지사항 목록 조회 테스트") + void 커서_기반_페이징으로_공지사항_목록_조회_테스트() { + // given + NoticeReqDTO.Cursor cursor = new NoticeReqDTO.Cursor( + LocalDateTime.now(), + 100L, + 20 + ); + + List noticeList = List.of( + new NoticeResDTO.NoticeSummary(1L, "제목1", "내용1", LocalDateTime.now(), LocalDateTime.now()), + new NoticeResDTO.NoticeSummary(2L, "제목2", "내용2", LocalDateTime.now(), LocalDateTime.now()) + ); + + CursorPageResponse expectedResponse = CursorPageResponse.builder() + .items(noticeList) + .hasNext(true) + .build(); + + given(noticeQueryRepositoryPort.findNoticesWithCursor(cursor)) + .willReturn(expectedResponse); + + // when + CursorPageResponse response = noticeQueryService.getNotices(cursor); + + // then + assertAll( + () -> assertThat(response).isNotNull(), + () -> assertThat(response.getItems()).hasSize(2), + () -> assertThat(response.isHasNext()).isTrue(), + () -> verify(noticeQueryRepositoryPort, times(1)).findNoticesWithCursor(cursor) + ); + } + + @Test + @DisplayName("공지사항 목록 조회 시 빈 결과 반환 테스트") + void 공지사항_목록_조회시_빈_결과_반환_테스트() { + // given + NoticeReqDTO.Cursor cursor = new NoticeReqDTO.Cursor( + LocalDateTime.now(), + 1L, + 20 + ); + + CursorPageResponse emptyResponse = CursorPageResponse.builder() + .items(Collections.emptyList()) + .hasNext(false) + .build(); + + given(noticeQueryRepositoryPort.findNoticesWithCursor(cursor)) + .willReturn(emptyResponse); + + // when + CursorPageResponse response = noticeQueryService.getNotices(cursor); + + // then + assertAll( + () -> assertThat(response).isNotNull(), + () -> assertThat(response.getItems()).isEmpty(), + () -> assertThat(response.isHasNext()).isFalse(), + () -> verify(noticeQueryRepositoryPort, times(1)).findNoticesWithCursor(cursor) + ); + } + + @Test + @DisplayName("공지사항 상세 조회 및 조회 이벤트 발행 테스트") + void 공지사항_상세_조회_및_조회_이벤트_발행_테스트() { + // given + Long noticeId = 100L; + NoticeResDTO.NoticeDetail expectedDetail = new NoticeResDTO.NoticeDetail( + noticeId, + "공지사항 제목", + "공지사항 내용입니다.", + 50, + LocalDateTime.now(), + LocalDateTime.now() + ); + expectedDetail.addImageUrls(List.of( + "https://example.com/image1.png", + "https://example.com/image2.png" + )); + + given(noticeQueryRepositoryPort.findNoticeDetailById(noticeId)) + .willReturn(expectedDetail); + + // when + NoticeResDTO.NoticeDetail result = noticeQueryService.getNoticeDetail(noticeId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getNoticeId()).isEqualTo(noticeId), + () -> assertThat(result.getTitle()).isEqualTo("공지사항 제목"), + () -> assertThat(result.getContent()).isEqualTo("공지사항 내용입니다."), + () -> assertThat(result.getReadCount()).isEqualTo(50), + () -> assertThat(result.getImageUrls()).hasSize(2), + () -> verify(noticeQueryRepositoryPort, times(1)).findNoticeDetailById(noticeId), + () -> verify(eventPublisher, times(1)).publishEvent(any(NoticeReadEvent.class)) + ); + } + + @Test + @DisplayName("이미지가 없는 공지사항 상세 조회 테스트") + void 이미지가_없는_공지사항_상세_조회_테스트() { + // given + Long noticeId = 200L; + NoticeResDTO.NoticeDetail expectedDetail = new NoticeResDTO.NoticeDetail( + noticeId, + "이미지 없는 공지", + "내용", + 10, + LocalDateTime.now(), + LocalDateTime.now() + ); + expectedDetail.addImageUrls(Collections.emptyList()); + + given(noticeQueryRepositoryPort.findNoticeDetailById(noticeId)) + .willReturn(expectedDetail); + + // when + NoticeResDTO.NoticeDetail result = noticeQueryService.getNoticeDetail(noticeId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getImageUrls()).isEmpty(), + () -> verify(eventPublisher, times(1)).publishEvent(any(NoticeReadEvent.class)) + ); + } + + @Test + @DisplayName("공지사항 상세 조회 시 Repository 조회 후 이벤트 발행 순서 확인 테스트") + void 공지사항_상세_조회시_Repository_조회_후_이벤트_발행_순서_확인_테스트() { + // given + Long noticeId = 300L; + NoticeResDTO.NoticeDetail mockDetail = new NoticeResDTO.NoticeDetail( + noticeId, + "제목", + "내용", + 0, + LocalDateTime.now(), + LocalDateTime.now() + ); + + given(noticeQueryRepositoryPort.findNoticeDetailById(noticeId)) + .willReturn(mockDetail); + + // when + noticeQueryService.getNoticeDetail(noticeId); + + // then + // Repository 조회가 먼저 실행되고, 그 다음 이벤트가 발행되어야 함 + var inOrder = org.mockito.Mockito.inOrder(noticeQueryRepositoryPort, eventPublisher); + inOrder.verify(noticeQueryRepositoryPort).findNoticeDetailById(noticeId); + inOrder.verify(eventPublisher).publishEvent(any(NoticeReadEvent.class)); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/notice/domain/NoticeImageTest.java b/src/test/java/talkPick/domain/notice/domain/NoticeImageTest.java new file mode 100644 index 0000000..3f11e77 --- /dev/null +++ b/src/test/java/talkPick/domain/notice/domain/NoticeImageTest.java @@ -0,0 +1,65 @@ +package talkPick.domain.notice.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.global.model.TalkPickStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("NoticeImage 도메인 테스트") +class NoticeImageTest { + + @Test + @DisplayName("of 메서드로 NoticeImage 생성 테스트") + void of_메서드로_NoticeImage_생성_테스트() { + // given + Long noticeId = 100L; + String imageUrl = "https://example.com/notice-image.png"; + TalkPickStatus status = TalkPickStatus.ACTIVE; + + // when + NoticeImage noticeImage = NoticeImage.of(noticeId, imageUrl, status); + + // then + assertAll( + () -> assertThat(noticeImage).isNotNull(), + () -> assertThat(noticeImage.getNoticeId()).isEqualTo(noticeId), + () -> assertThat(noticeImage.getImageUrl()).isEqualTo(imageUrl), + () -> assertThat(noticeImage.getStatus()).isEqualTo(status) + ); + } + + @Test + @DisplayName("DIS_ACTIVE 상태로 NoticeImage 생성 테스트") + void DIS_ACTIVE_상태로_NoticeImage_생성_테스트() { + // given + Long noticeId = 100L; + String imageUrl = "https://example.com/deleted-image.png"; + TalkPickStatus status = TalkPickStatus.DIS_ACTIVE; + + // when + NoticeImage noticeImage = NoticeImage.of(noticeId, imageUrl, status); + + // then + assertThat(noticeImage.getStatus()).isEqualTo(TalkPickStatus.DIS_ACTIVE); + } + + @Test + @DisplayName("다양한 이미지 URL 형식으로 NoticeImage 생성 테스트") + void 다양한_이미지_URL_형식으로_NoticeImage_생성_테스트() { + // given + Long noticeId = 100L; + String[] imageUrls = { + "https://cdn.example.com/images/notice/12345.jpg", + "https://s3.amazonaws.com/bucket/notice-img.png", + "https://example.com/path/to/image.webp" + }; + + // when & then + for (String url : imageUrls) { + NoticeImage noticeImage = NoticeImage.of(noticeId, url, TalkPickStatus.ACTIVE); + assertThat(noticeImage.getImageUrl()).isEqualTo(url); + } + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/notice/domain/NoticeTest.java b/src/test/java/talkPick/domain/notice/domain/NoticeTest.java new file mode 100644 index 0000000..d9ecf8c --- /dev/null +++ b/src/test/java/talkPick/domain/notice/domain/NoticeTest.java @@ -0,0 +1,79 @@ +package talkPick.domain.notice.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.global.model.TalkPickStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("Notice 도메인 테스트") +class NoticeTest { + + @Test + @DisplayName("of 메서드로 Notice 생성 테스트") + void of_메서드로_Notice_생성_테스트() { + // given + Long adminId = 1L; + String title = "공지사항 제목"; + String content = "공지사항 내용입니다."; + Integer readCount = 0; + TalkPickStatus status = TalkPickStatus.ACTIVE; + + // when + Notice notice = Notice.of(adminId, title, content, readCount, status); + + // then + assertAll( + () -> assertThat(notice).isNotNull(), + () -> assertThat(notice.getAdminId()).isEqualTo(adminId), + () -> assertThat(notice.getTitle()).isEqualTo(title), + () -> assertThat(notice.getContent()).isEqualTo(content), + () -> assertThat(notice.getReadCount()).isEqualTo(readCount), + () -> assertThat(notice.getStatus()).isEqualTo(status) + ); + } + + @Test + @DisplayName("plusReadCount 호출 시 조회수 1 증가 테스트") + void plusReadCount_호출시_조회수_1_증가_테스트() { + // given + Notice notice = Notice.of(1L, "제목", "내용", 0, TalkPickStatus.ACTIVE); + Integer initialReadCount = notice.getReadCount(); + + // when + notice.plusReadCount(); + + // then + assertThat(notice.getReadCount()).isEqualTo(initialReadCount + 1); + } + + @Test + @DisplayName("plusReadCount 여러 번 호출 시 조회수 누적 증가 테스트") + void plusReadCount_여러번_호출시_조회수_누적_증가_테스트() { + // given + Notice notice = Notice.of(1L, "제목", "내용", 10, TalkPickStatus.ACTIVE); + int incrementCount = 5; + + // when + for (int i = 0; i < incrementCount; i++) { + notice.plusReadCount(); + } + + // then + assertThat(notice.getReadCount()).isEqualTo(15); + } + + @Test + @DisplayName("DIS_ACTIVE 상태로 Notice 생성 테스트") + void DIS_ACTIVE_상태로_Notice_생성_테스트() { + // given + TalkPickStatus status = TalkPickStatus.DIS_ACTIVE; + + // when + Notice notice = Notice.of(1L, "비활성 공지", "내용", 0, status); + + // then + assertThat(notice.getStatus()).isEqualTo(TalkPickStatus.DIS_ACTIVE); + } +} diff --git a/src/test/java/talkPick/domain/notice/domain/event/NoticeReadEventTest.java b/src/test/java/talkPick/domain/notice/domain/event/NoticeReadEventTest.java new file mode 100644 index 0000000..57050c8 --- /dev/null +++ b/src/test/java/talkPick/domain/notice/domain/event/NoticeReadEventTest.java @@ -0,0 +1,43 @@ +package talkPick.domain.notice.domain.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("NoticeReadEvent 도메인 이벤트 테스트") +class NoticeReadEventTest { + + @Test + @DisplayName("of 메서드로 NoticeReadEvent 생성 테스트") + void of_메서드로_NoticeReadEvent_생성_테스트() { + // given + Object source = this; + Long noticeId = 100L; + + // when + NoticeReadEvent event = NoticeReadEvent.of(source, noticeId); + + // then + assertAll( + () -> assertThat(event).isNotNull(), + () -> assertThat(event.getNoticeId()).isEqualTo(noticeId), + () -> assertThat(event.getSource()).isEqualTo(source) + ); + } + + @Test + @DisplayName("다양한 noticeId로 NoticeReadEvent 생성 테스트") + void 다양한_noticeId로_NoticeReadEvent_생성_테스트() { + // given + Object source = this; + Long[] noticeIds = {1L, 999L, 123456L}; + + // when & then + for (Long noticeId : noticeIds) { + NoticeReadEvent event = NoticeReadEvent.of(source, noticeId); + assertThat(event.getNoticeId()).isEqualTo(noticeId); + } + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/rateLimiter/adapter/RateLimitControllerTest.java b/src/test/java/talkPick/rateLimiter/adapter/RateLimitControllerTest.java deleted file mode 100644 index 29db1e1..0000000 --- a/src/test/java/talkPick/rateLimiter/adapter/RateLimitControllerTest.java +++ /dev/null @@ -1,50 +0,0 @@ -//package talkPick.rateLimiter.adapter; -// -//import org.junit.jupiter.api.DisplayName; -//import org.junit.jupiter.api.Test; -//import org.springframework.beans.factory.annotation.Autowired; -//import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -//import org.springframework.boot.test.context.SpringBootTest; -//import org.springframework.boot.test.mock.mockito.MockBean; -//import org.springframework.test.web.servlet.MockMvc; -//import talkPick.batch.topic.port.TopicCacheManager; -//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -// -//@SpringBootTest -//@AutoConfigureMockMvc -//class RateLimitControllerTest { -// -// @Autowired -// private MockMvc mockMvc; -// @MockBean -// private TopicCacheManager topicCacheManager; -// -// @Test -// @DisplayName("✅ 실제 Controller, 실제 RateLimiterManager 테스트") -// void 실제_Controller_테스트() throws Exception { -// String uri = "/test"; -// String ip1 = "127.0.0.1"; -// String ip2 = "127.0.0.2"; -// -// for (int i = 1; i <= 10; i++) { -// mockMvc.perform(get(uri).with(request -> { -// request.setRemoteAddr(ip1); -// return request; -// })) -// .andExpect(status().isOk()); -// } -// -// mockMvc.perform(get(uri).with(request -> { -// request.setRemoteAddr(ip1); -// return request; -// })) -// .andExpect(status().isTooManyRequests()); -// -// mockMvc.perform(get(uri).with(request -> { -// request.setRemoteAddr(ip2); -// return request; -// })) -// .andExpect(status().isOk()); -// } -//} \ No newline at end of file diff --git a/src/test/java/talkPick/topic/application/TopicCommandServiceTest.java b/src/test/java/talkPick/topic/application/TopicCommandServiceTest.java deleted file mode 100644 index b23a1e4..0000000 --- a/src/test/java/talkPick/topic/application/TopicCommandServiceTest.java +++ /dev/null @@ -1,58 +0,0 @@ -//package talkPick.topic.application; -// -//import org.junit.jupiter.api.DisplayName; -//import org.junit.jupiter.api.Test; -//import org.junit.jupiter.api.extension.ExtendWith; -//import org.mockito.InjectMocks; -//import org.mockito.Mock; -//import org.mockito.junit.jupiter.MockitoExtension; -//import talkPick.domain.topic.application.TopicCommandService; -//import talkPick.domain.topic.domain.TopicStat; -//import talkPick.domain.topic.port.out.TopicLikeHistoryCommandRepositoryPort; -//import talkPick.domain.topic.port.out.TopicStatQueryRepositoryPort; -//import talkPick.global.exception.ErrorCode; -//import talkPick.global.exception.handler.TopicExceptionHandler; -//import static org.junit.jupiter.api.Assertions.*; -//import static org.mockito.BDDMockito.given; -//import static org.mockito.Mockito.*; -// -//@ExtendWith(MockitoExtension.class) -//class TopicCommandServiceTest { -// @InjectMocks -// private TopicCommandService topicCommandService; -// @Mock -// private TopicStatQueryRepositoryPort topicStatQueryRepositoryPort; -// @Mock -// private TopicLikeHistoryCommandRepositoryPort topicLikeHistoryCommandRepositoryPort; -// -// @Test -// @DisplayName("✅ 좋아요 성공 테스트") -// void 좋아요_성공_테스트() { -// // given -// Long memberId = 1L; -// Long topicId = 100L; -// TopicStat mockTopicStat = mock(TopicStat.class); -// -// given(topicStatQueryRepositoryPort.findTopicStatByTopicId(topicId)).willReturn(mockTopicStat); -// -// // when -// topicCommandService.addLike(memberId, topicId); -// -// // then -// verify(mockTopicStat).addLike(); -// verify(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); -// } -// -//// @Test -//// @DisplayName("🚨 없는 TopicId로 좋아요 실패 테스트") -//// void 없는_TopicId로_좋아요_실패_테스트() { -//// // given -//// Long memberId = 1L; -//// Long topicId = 100L; -//// -//// given(topicStatQueryRepositoryPort.findTopicStatByTopicId(topicId)).willThrow(new TopicExceptionHandler(ErrorCode.TOPIC_NOT_FOUND)); -//// -//// // when && then -//// assertThrows(TopicExceptionHandler.class, () -> topicCommandService.addLike(memberId, topicId)); -//// } -//} \ No newline at end of file From 2bd801f523dfe2ec8f25e7de329edfb3591b73dc Mon Sep 17 00:00:00 2001 From: Zetty Date: Sat, 27 Dec 2025 00:19:02 +0900 Subject: [PATCH 11/49] test: Add test code about Random/Topic/Today - #260 --- .../application/RandomQueryServiceTest.java | 96 ++++++++++++++ .../domain/random/domain/RandomTest.java | 106 +++++++++++++++ .../random/domain/RandomTopicHistoryTest.java | 95 ++++++++++++++ .../TodayTopicQueryServiceTest.java | 124 ++++++++++++++++++ .../domain/today/domain/TodayTopicTest.java | 48 +++++++ .../event/TodayTopicSavedEventTest.java | 74 +++++++++++ .../application/TopicCommandServiceTest.java | 67 ++++++++++ .../domain/topic/domain/TopicStatTest.java | 75 +++++++++++ .../domain/event/TopicLikedEventTest.java | 51 +++++++ 9 files changed, 736 insertions(+) create mode 100644 src/test/java/talkPick/domain/random/application/RandomQueryServiceTest.java create mode 100644 src/test/java/talkPick/domain/random/domain/RandomTest.java create mode 100644 src/test/java/talkPick/domain/random/domain/RandomTopicHistoryTest.java create mode 100644 src/test/java/talkPick/domain/today/application/TodayTopicQueryServiceTest.java create mode 100644 src/test/java/talkPick/domain/today/domain/TodayTopicTest.java create mode 100644 src/test/java/talkPick/domain/today/domain/event/TodayTopicSavedEventTest.java create mode 100644 src/test/java/talkPick/domain/topic/application/TopicCommandServiceTest.java create mode 100644 src/test/java/talkPick/domain/topic/domain/TopicStatTest.java create mode 100644 src/test/java/talkPick/domain/topic/domain/event/TopicLikedEventTest.java diff --git a/src/test/java/talkPick/domain/random/application/RandomQueryServiceTest.java b/src/test/java/talkPick/domain/random/application/RandomQueryServiceTest.java new file mode 100644 index 0000000..7aca0ef --- /dev/null +++ b/src/test/java/talkPick/domain/random/application/RandomQueryServiceTest.java @@ -0,0 +1,96 @@ +package talkPick.domain.random.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import talkPick.domain.random.adapter.out.dto.RandomResDTO; +import talkPick.domain.random.port.out.RandomQueryRepositoryPort; +import talkPick.domain.topic.domain.type.CategoryGroup; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RandomQueryService 테스트") +class RandomQueryServiceTest { + + @InjectMocks + private RandomQueryService randomQueryService; + + @Mock + private RandomQueryRepositoryPort randomQueryRepositoryPort; + + @Test + @DisplayName("랜덤 토픽 목록 조회 테스트") + void 랜덤_토픽_목록_조회_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + Integer order = 1; + CategoryGroup categoryGroup = CategoryGroup.STRANGER; + String category = "일상"; + + List mockTopics = List.of( + new RandomResDTO.RandomTopic(1, List.of( + new RandomResDTO.RandomTopicDetail(1L, "토픽1", "설명1", "STRANGER", "일상", "키워드1", "img1.png", "icon1.png") + )), + new RandomResDTO.RandomTopic(2, List.of( + new RandomResDTO.RandomTopicDetail(2L, "토픽2", "설명2", "CLOSE", "대화", "키워드2", "img2.png", "icon2.png") + )) + ); + + given(randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, categoryGroup, category)) + .willReturn(mockTopics); + + // when + List result = randomQueryService.getRandomTopics( + memberId, randomId, order, categoryGroup, category + ); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(0).getOrder()).isEqualTo(1), + () -> assertThat(result.get(1).getOrder()).isEqualTo(2), + () -> verify(randomQueryRepositoryPort, times(1)) + .findRandomTopics(memberId, randomId, order, categoryGroup, category) + ); + } + + @Test + @DisplayName("랜덤 토픽 조회 시 빈 결과 반환 테스트") + void 랜덤_토픽_조회시_빈_결과_반환_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + Integer order = 1; + CategoryGroup categoryGroup = CategoryGroup.STRANGER; + String category = "일상"; + + given(randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, categoryGroup, category)) + .willReturn(Collections.emptyList()); + + // when + List result = randomQueryService.getRandomTopics( + memberId, randomId, order, categoryGroup, category + ); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).isEmpty(), + () -> verify(randomQueryRepositoryPort, times(1)) + .findRandomTopics(memberId, randomId, order, categoryGroup, category) + ); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/random/domain/RandomTest.java b/src/test/java/talkPick/domain/random/domain/RandomTest.java new file mode 100644 index 0000000..815b08a --- /dev/null +++ b/src/test/java/talkPick/domain/random/domain/RandomTest.java @@ -0,0 +1,106 @@ +package talkPick.domain.random.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.random.adapter.in.dto.RandomReqDTO; +import talkPick.domain.random.domain.type.RandomType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("Random 도메인 테스트") +class RandomTest { + + @Test + @DisplayName("from 메서드로 Random 생성 테스트") + void from_메서드로_Random_생성_테스트() { + // given + Long memberId = 1L; + + // when + Random random = Random.from(memberId); + + // then + assertAll( + () -> assertThat(random).isNotNull(), + () -> assertThat(random.getMemberId()).isEqualTo(memberId), + () -> assertThat(random.getType()).isEqualTo(RandomType.START), + () -> assertThat(random.getOneLine()).isNull(), + () -> assertThat(random.getRating()).isNull() + ); + } + + @Test + @DisplayName("quit 호출 시 RandomType이 QUIT으로 변경 테스트") + void quit_호출시_RandomType_QUIT으로_변경_테스트() { + // given + Random random = Random.from(1L); + + // when + random.quit(); + + // then + assertThat(random.getType()).isEqualTo(RandomType.QUIT); + } + + @Test + @DisplayName("end 호출 시 RandomType이 COMPLETED로 변경 테스트") + void end_호출시_RandomType_COMPLETED로_변경_테스트() { + // given + Random random = Random.from(1L); + + // when + random.end(); + + // then + assertThat(random.getType()).isEqualTo(RandomType.COMPLETED); + } + + @Test + @DisplayName("rate 호출 시 평점 설정 테스트") + void rate_호출시_평점_설정_테스트() { + // given + Random random = Random.from(1L); + RandomReqDTO.Rate rateDTO = new RandomReqDTO.Rate(5); + + // when + random.rate(rateDTO); + + // then + assertThat(random.getRating()).isEqualTo(5); + } + + @Test + @DisplayName("comment 호출 시 한 줄 평 설정 테스트") + void comment_호출시_한줄평_설정_테스트() { + // given + Random random = Random.from(1L); + String oneLine = "정말 재밌었어요!"; + RandomReqDTO.Comment commentDTO = new RandomReqDTO.Comment(oneLine); + + // when + random.comment(commentDTO); + + // then + assertThat(random.getOneLine()).isEqualTo(oneLine); + } + + @Test + @DisplayName("rate와 comment 호출 시 모두 설정 테스트") + void rate와_comment_호출시_모두_설정_테스트() { + // given + Random random = Random.from(1L); + RandomReqDTO.Rate rateDTO = new RandomReqDTO.Rate(4); + RandomReqDTO.Comment commentDTO = new RandomReqDTO.Comment("좋아요"); + + // when + random.rate(rateDTO); + random.comment(commentDTO); + + // then + assertAll( + () -> assertThat(random.getRating()).isEqualTo(4), + () -> assertThat(random.getOneLine()).isEqualTo("좋아요") + ); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/random/domain/RandomTopicHistoryTest.java b/src/test/java/talkPick/domain/random/domain/RandomTopicHistoryTest.java new file mode 100644 index 0000000..3d02808 --- /dev/null +++ b/src/test/java/talkPick/domain/random/domain/RandomTopicHistoryTest.java @@ -0,0 +1,95 @@ +package talkPick.domain.random.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.random.adapter.in.dto.RandomReqDTO; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("RandomTopicHistory 도메인 테스트") +class RandomTopicHistoryTest { + + @Test + @DisplayName("of 메서드로 RandomTopicHistory 생성 테스트") + void of_메서드로_RandomTopicHistory_생성_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, 1); + + // when + RandomTopicHistory history = RandomTopicHistory.of(memberId, randomId, recordDTO); + + // then + assertAll( + () -> assertThat(history).isNotNull(), + () -> assertThat(history.getMemberId()).isEqualTo(memberId), + () -> assertThat(history.getRandomId()).isEqualTo(randomId), + () -> assertThat(history.getTopicId()).isEqualTo(200L), + () -> assertThat(history.getOrder()).isEqualTo(1), + () -> assertThat(history.getStartAt()).isNotNull(), + () -> assertThat(history.getEndAt()).isNull() + ); + } + + @Test + @DisplayName("ofRecord 메서드로 RandomTopicHistory 생성 테스트") + void ofRecord_메서드로_RandomTopicHistory_생성_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + LocalDateTime startAt = LocalDateTime.now().minusMinutes(10); + LocalDateTime endAt = LocalDateTime.now(); + RandomReqDTO.TotalRecord totalRecordDTO = new RandomReqDTO.TotalRecord( + 200L, 1, startAt, endAt + ); + + // when + RandomTopicHistory history = RandomTopicHistory.ofRecord(memberId, randomId, totalRecordDTO); + + // then + assertAll( + () -> assertThat(history).isNotNull(), + () -> assertThat(history.getMemberId()).isEqualTo(memberId), + () -> assertThat(history.getRandomId()).isEqualTo(randomId), + () -> assertThat(history.getTopicId()).isEqualTo(200L), + () -> assertThat(history.getOrder()).isEqualTo(1), + () -> assertThat(history.getStartAt()).isEqualTo(startAt), + () -> assertThat(history.getEndAt()).isEqualTo(endAt) + ); + } + + @Test + @DisplayName("next 호출 시 endAt 설정 테스트") + void next_호출시_endAt_설정_테스트() { + // given + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, 1); + RandomTopicHistory history = RandomTopicHistory.of(1L, 100L, recordDTO); + assertThat(history.getEndAt()).isNull(); + + // when + history.next(); + + // then + assertThat(history.getEndAt()).isNotNull(); + } + + @Test + @DisplayName("다양한 order로 RandomTopicHistory 생성 테스트") + void 다양한_order로_RandomTopicHistory_생성_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + Integer[] orders = {1, 2, 3, 5, 10}; + + // when & then + for (Integer order : orders) { + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, order); + RandomTopicHistory history = RandomTopicHistory.of(memberId, randomId, recordDTO); + assertThat(history.getOrder()).isEqualTo(order); + } + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/today/application/TodayTopicQueryServiceTest.java b/src/test/java/talkPick/domain/today/application/TodayTopicQueryServiceTest.java new file mode 100644 index 0000000..4c161dd --- /dev/null +++ b/src/test/java/talkPick/domain/today/application/TodayTopicQueryServiceTest.java @@ -0,0 +1,124 @@ +package talkPick.domain.today.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationEventPublisher; +import talkPick.domain.today.adapter.out.dto.TodayTopicResDTO; +import talkPick.domain.today.domain.event.TodayTopicSavedEvent; +import talkPick.domain.today.port.out.TodayTopicQueryRepositoryPort; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TodayTopicQueryService 테스트") +class TodayTopicQueryServiceTest { + + @InjectMocks + private TodayTopicQueryService todayTopicQueryService; + + @Mock + private TodayTopicQueryRepositoryPort todayTopicQueryRepositoryPort; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private CacheManager cacheManager; + + @Mock + private Cache cache; + + @Test + @DisplayName("오늘의 토픽 조회 시 캐시 미존재로 DB 조회 및 이벤트 발행 테스트") + void 오늘의_토픽_조회시_캐시_미존재로_DB_조회_및_이벤트_발행_테스트() { + // given + Long memberId = 1L; + List mockTopics = List.of( + new TodayTopicResDTO.TodayTopic(100L, "토픽1", "카테고리1", "키워드1", "icon1.png"), + new TodayTopicResDTO.TodayTopic(200L, "토픽2", "카테고리2", "키워드2", "icon2.png") + ); + + given(cacheManager.getCache("todayTopics")).willReturn(cache); + given(cache.get(memberId)).willReturn(null); + given(todayTopicQueryRepositoryPort.findTodayTopics(memberId)).willReturn(mockTopics); + willDoNothing().given(cache).put(eq(memberId), any()); + willDoNothing().given(eventPublisher).publishEvent(any(TodayTopicSavedEvent.class)); + + // when + List result = todayTopicQueryService.getTodayTopics(memberId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).hasSize(2), + () -> verify(todayTopicQueryRepositoryPort, times(1)).findTodayTopics(memberId), + () -> verify(cache, times(1)).put(eq(memberId), any()), + () -> verify(eventPublisher, times(1)).publishEvent(any(TodayTopicSavedEvent.class)) + ); + } + + @Test + @DisplayName("오늘의 토픽 조회 시 빈 결과 반환 테스트") + void 오늘의_토픽_조회시_빈_결과_반환_테스트() { + // given + Long memberId = 1L; + List emptyList = Collections.emptyList(); + + given(cacheManager.getCache("todayTopics")).willReturn(cache); + given(cache.get(memberId)).willReturn(null); + given(todayTopicQueryRepositoryPort.findTodayTopics(memberId)).willReturn(emptyList); + willDoNothing().given(cache).put(eq(memberId), any()); + willDoNothing().given(eventPublisher).publishEvent(any(TodayTopicSavedEvent.class)); + + // when + List result = todayTopicQueryService.getTodayTopics(memberId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).isEmpty(), + () -> verify(todayTopicQueryRepositoryPort, times(1)).findTodayTopics(memberId), + () -> verify(eventPublisher, times(1)).publishEvent(any(TodayTopicSavedEvent.class)) + ); + } + + @Test + @DisplayName("오늘의 토픽 조회 시 캐시 매니저 null인 경우 정상 조회 테스트") + void 오늘의_토픽_조회시_캐시_매니저_null인_경우_정상_조회_테스트() { + // given + Long memberId = 1L; + List mockTopics = List.of( + new TodayTopicResDTO.TodayTopic(100L, "토픽1", "카테고리1", "키워드1", "icon1.png") + ); + + given(cacheManager.getCache("todayTopics")).willReturn(null); + given(todayTopicQueryRepositoryPort.findTodayTopics(memberId)).willReturn(mockTopics); + willDoNothing().given(eventPublisher).publishEvent(any(TodayTopicSavedEvent.class)); + + // when + List result = todayTopicQueryService.getTodayTopics(memberId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).hasSize(1), + () -> verify(todayTopicQueryRepositoryPort, times(1)).findTodayTopics(memberId), + () -> verify(eventPublisher, times(1)).publishEvent(any(TodayTopicSavedEvent.class)) + ); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/today/domain/TodayTopicTest.java b/src/test/java/talkPick/domain/today/domain/TodayTopicTest.java new file mode 100644 index 0000000..cec459a --- /dev/null +++ b/src/test/java/talkPick/domain/today/domain/TodayTopicTest.java @@ -0,0 +1,48 @@ +package talkPick.domain.today.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("TodayTopic 도메인 테스트") +class TodayTopicTest { + + @Test + @DisplayName("of 메서드로 TodayTopic 생성 테스트") + void of_메서드로_TodayTopic_생성_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + + // when + TodayTopic todayTopic = TodayTopic.of(memberId, topicId); + + // then + assertAll( + () -> assertThat(todayTopic).isNotNull(), + () -> assertThat(todayTopic.getMemberId()).isEqualTo(memberId), + () -> assertThat(todayTopic.getTopicId()).isEqualTo(topicId) + ); + } + + @Test + @DisplayName("다양한 memberId와 topicId로 TodayTopic 생성 테스트") + void 다양한_memberId와_topicId로_TodayTopic_생성_테스트() { + // given + Long[] memberIds = {1L, 100L, 999L}; + Long[] topicIds = {10L, 200L, 3000L}; + + // when & then + for (int i = 0; i < memberIds.length; i++) { + Long memberId = memberIds[i]; + Long topicId = topicIds[i]; + TodayTopic todayTopic = TodayTopic.of(memberId, topicId); + assertAll( + () -> assertThat(todayTopic.getMemberId()).isEqualTo(memberId), + () -> assertThat(todayTopic.getTopicId()).isEqualTo(topicId) + ); + } + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/today/domain/event/TodayTopicSavedEventTest.java b/src/test/java/talkPick/domain/today/domain/event/TodayTopicSavedEventTest.java new file mode 100644 index 0000000..ae2d002 --- /dev/null +++ b/src/test/java/talkPick/domain/today/domain/event/TodayTopicSavedEventTest.java @@ -0,0 +1,74 @@ +package talkPick.domain.today.domain.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.today.domain.TodayTopic; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("TodayTopicSavedEvent 도메인 이벤트 테스트") +class TodayTopicSavedEventTest { + + @Test + @DisplayName("of 메서드로 TodayTopicSavedEvent 생성 테스트") + void of_메서드로_TodayTopicSavedEvent_생성_테스트() { + // given + Object source = this; + List todayTopics = List.of( + TodayTopic.of(1L, 100L), + TodayTopic.of(1L, 200L) + ); + + // when + TodayTopicSavedEvent event = TodayTopicSavedEvent.of(source, todayTopics); + + // then + assertAll( + () -> assertThat(event).isNotNull(), + () -> assertThat(event.getTodayTopics()).hasSize(2), + () -> assertThat(event.getTodayTopics()).isEqualTo(todayTopics), + () -> assertThat(event.getSource()).isEqualTo(source) + ); + } + + @Test + @DisplayName("빈 리스트로 TodayTopicSavedEvent 생성 테스트") + void 빈_리스트로_TodayTopicSavedEvent_생성_테스트() { + // given + Object source = this; + List emptyList = Collections.emptyList(); + + // when + TodayTopicSavedEvent event = TodayTopicSavedEvent.of(source, emptyList); + + // then + assertAll( + () -> assertThat(event).isNotNull(), + () -> assertThat(event.getTodayTopics()).isEmpty() + ); + } + + @Test + @DisplayName("여러 개의 TodayTopic으로 이벤트 생성 테스트") + void 여러_개의_TodayTopic으로_이벤트_생성_테스트() { + // given + Object source = this; + List todayTopics = List.of( + TodayTopic.of(1L, 100L), + TodayTopic.of(1L, 200L), + TodayTopic.of(1L, 300L), + TodayTopic.of(1L, 400L), + TodayTopic.of(1L, 500L) + ); + + // when + TodayTopicSavedEvent event = TodayTopicSavedEvent.of(source, todayTopics); + + // then + assertThat(event.getTodayTopics()).hasSize(5); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/topic/application/TopicCommandServiceTest.java b/src/test/java/talkPick/domain/topic/application/TopicCommandServiceTest.java new file mode 100644 index 0000000..df8fb22 --- /dev/null +++ b/src/test/java/talkPick/domain/topic/application/TopicCommandServiceTest.java @@ -0,0 +1,67 @@ +package talkPick.domain.topic.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import talkPick.domain.topic.domain.event.TopicLikedEvent; +import talkPick.domain.topic.port.out.TopicLikeHistoryCommandRepositoryPort; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TopicCommandService 테스트") +class TopicCommandServiceTest { + + @InjectMocks + private TopicCommandService topicCommandService; + + @Mock + private TopicLikeHistoryCommandRepositoryPort topicLikeHistoryCommandRepositoryPort; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Test + @DisplayName("토픽 좋아요 추가 및 이벤트 발행 테스트") + void 토픽_좋아요_추가_및_이벤트_발행_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + + willDoNothing().given(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + willDoNothing().given(eventPublisher).publishEvent(any(TopicLikedEvent.class)); + + // when + topicCommandService.addLike(memberId, topicId); + + // then + verify(topicLikeHistoryCommandRepositoryPort, times(1)).save(memberId, topicId); + verify(eventPublisher, times(1)).publishEvent(any(TopicLikedEvent.class)); + } + + @Test + @DisplayName("토픽 좋아요 추가 시 Repository 저장 후 이벤트 발행 순서 확인 테스트") + void 토픽_좋아요_추가시_Repository_저장_후_이벤트_발행_순서_확인_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + + willDoNothing().given(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + willDoNothing().given(eventPublisher).publishEvent(any(TopicLikedEvent.class)); + + // when + topicCommandService.addLike(memberId, topicId); + + // then + var inOrder = org.mockito.Mockito.inOrder(topicLikeHistoryCommandRepositoryPort, eventPublisher); + inOrder.verify(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + inOrder.verify(eventPublisher).publishEvent(any(TopicLikedEvent.class)); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/topic/domain/TopicStatTest.java b/src/test/java/talkPick/domain/topic/domain/TopicStatTest.java new file mode 100644 index 0000000..783395c --- /dev/null +++ b/src/test/java/talkPick/domain/topic/domain/TopicStatTest.java @@ -0,0 +1,75 @@ +package talkPick.domain.topic.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("TopicStat 도메인 테스트") +class TopicStatTest { + + @Test + @DisplayName("of 메서드로 TopicStat 생성 테스트") + void of_메서드로_TopicStat_생성_테스트() { + // given + Long topicId = 100L; + + // when + TopicStat topicStat = TopicStat.of(topicId); + + // then + assertAll( + () -> assertThat(topicStat).isNotNull(), + () -> assertThat(topicStat.getTopicId()).isEqualTo(topicId), + () -> assertThat(topicStat.getECount()).isEqualTo(0), + () -> assertThat(topicStat.getICount()).isEqualTo(0), + () -> assertThat(topicStat.getSCount()).isEqualTo(0), + () -> assertThat(topicStat.getNCount()).isEqualTo(0), + () -> assertThat(topicStat.getFCount()).isEqualTo(0), + () -> assertThat(topicStat.getTCount()).isEqualTo(0), + () -> assertThat(topicStat.getJCount()).isEqualTo(0), + () -> assertThat(topicStat.getPCount()).isEqualTo(0), + () -> assertThat(topicStat.getLikeCount()).isEqualTo(0), + () -> assertThat(topicStat.getTeenCount()).isEqualTo(0), + () -> assertThat(topicStat.getTwentiesCount()).isEqualTo(0), + () -> assertThat(topicStat.getThirtiesCount()).isEqualTo(0), + () -> assertThat(topicStat.getFortiesCount()).isEqualTo(0), + () -> assertThat(topicStat.getFiftiesCount()).isEqualTo(0), + () -> assertThat(topicStat.getMaleCount()).isEqualTo(0), + () -> assertThat(topicStat.getFemaleCount()).isEqualTo(0), + () -> assertThat(topicStat.getSelectCount()).isEqualTo(0), + () -> assertThat(topicStat.getAverageTalkTime()).isEqualTo(0L) + ); + } + + @Test + @DisplayName("다양한 topicId로 TopicStat 생성 테스트") + void 다양한_topicId로_TopicStat_생성_테스트() { + // given + Long[] topicIds = {1L, 100L, 999L, 12345L}; + + // when & then + for (Long topicId : topicIds) { + TopicStat topicStat = TopicStat.of(topicId); + assertThat(topicStat.getTopicId()).isEqualTo(topicId); + } + } + + @Test + @DisplayName("TopicStat 생성 시 모든 카운트 0 초기화 테스트") + void TopicStat_생성시_모든_카운트_0_초기화_테스트() { + // given + Long topicId = 100L; + + // when + TopicStat topicStat = TopicStat.of(topicId); + + // then + assertAll( + () -> assertThat(topicStat.getLikeCount()).isZero(), + () -> assertThat(topicStat.getSelectCount()).isZero(), + () -> assertThat(topicStat.getAverageTalkTime()).isZero() + ); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/domain/topic/domain/event/TopicLikedEventTest.java b/src/test/java/talkPick/domain/topic/domain/event/TopicLikedEventTest.java new file mode 100644 index 0000000..9071d0b --- /dev/null +++ b/src/test/java/talkPick/domain/topic/domain/event/TopicLikedEventTest.java @@ -0,0 +1,51 @@ +package talkPick.domain.topic.domain.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("TopicLikedEvent 도메인 이벤트 테스트") +class TopicLikedEventTest { + + @Test + @DisplayName("of 메서드로 TopicLikedEvent 생성 테스트") + void of_메서드로_TopicLikedEvent_생성_테스트() { + // given + Object source = this; + Long memberId = 1L; + Long topicId = 100L; + + // when + TopicLikedEvent event = TopicLikedEvent.of(source, memberId, topicId); + + // then + assertAll( + () -> assertThat(event).isNotNull(), + () -> assertThat(event.getMemberId()).isEqualTo(memberId), + () -> assertThat(event.getTopicId()).isEqualTo(topicId), + () -> assertThat(event.getSource()).isEqualTo(source) + ); + } + + @Test + @DisplayName("다양한 memberId와 topicId로 TopicLikedEvent 생성 테스트") + void 다양한_memberId와_topicId로_TopicLikedEvent_생성_테스트() { + // given + Object source = this; + Long[] memberIds = {1L, 100L, 999L}; + Long[] topicIds = {10L, 200L, 3000L}; + + // when & then + for (int i = 0; i < memberIds.length; i++) { + Long memberId = memberIds[i]; + Long topicId = topicIds[i]; + TopicLikedEvent event = TopicLikedEvent.of(source, memberId, topicId); + assertAll( + () -> assertThat(event.getMemberId()).isEqualTo(memberId), + () -> assertThat(event.getTopicId()).isEqualTo(topicId) + ); + } + } +} \ No newline at end of file From 4a2e7e8d777a4108d0f4ff207321e047a9c11fee Mon Sep 17 00:00:00 2001 From: Hszoo Date: Sun, 28 Dec 2025 14:58:10 +0900 Subject: [PATCH 12/49] deploy: update docker-compose.yml (remove adminer container and add nginx volumes) --- docker-compose.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 554016f..ed29090 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,16 +15,6 @@ services: env_file: - ./env/.env - ## adminer - adminer: - image: adminer - container_name: talkpick-db-adminer - networks: - - t4y - restart: always - ports: - - "8081:8080" - ## nginx nginx: build: ./nginx @@ -43,6 +33,6 @@ services: - adminer volumes: - ./nginx/conf:/etc/nginx/conf.d - + - ./nginx/html:/usr/share/nginx/html networks: t4y: \ No newline at end of file From f12affa821c837f7b68f8f2ae9530b7c7f47bdd2 Mon Sep 17 00:00:00 2001 From: Hszoo Date: Sun, 28 Dec 2025 15:07:15 +0900 Subject: [PATCH 13/49] deploy: change server properties (local -> prod) --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef46c2a..90385b2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,3 @@ spring: profiles: - active: local \ No newline at end of file + active: prod \ No newline at end of file From bb45c8a6825f33a4589bfc07b327ea7cd23c65ff Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Sun, 28 Dec 2025 23:40:39 +0900 Subject: [PATCH 14/49] =?UTF-8?q?refactor:=20=ED=95=98=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberDeleteVerificationTest.java | 87 ----------------- .../performance/ConnectionPoolTest.java | 82 ---------------- .../MemberDeletePerformanceTest.java | 97 ------------------- .../performance/PerformanceTestService.java | 39 -------- 4 files changed, 305 deletions(-) delete mode 100644 src/test/java/talkPick/domain/member/application/MemberDeleteVerificationTest.java delete mode 100644 src/test/java/talkPick/performance/ConnectionPoolTest.java delete mode 100644 src/test/java/talkPick/performance/MemberDeletePerformanceTest.java delete mode 100644 src/test/java/talkPick/performance/PerformanceTestService.java diff --git a/src/test/java/talkPick/domain/member/application/MemberDeleteVerificationTest.java b/src/test/java/talkPick/domain/member/application/MemberDeleteVerificationTest.java deleted file mode 100644 index fd55957..0000000 --- a/src/test/java/talkPick/domain/member/application/MemberDeleteVerificationTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package talkPick.domain.member.application; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import talkPick.domain.inquiry.adapter.out.repository.InquiryJpaRepository; -import talkPick.domain.member.adapter.out.repository.MemberJpaRepository; -import talkPick.domain.member.adapter.out.repository.MemberTopicResultJpaRepository; -import talkPick.domain.member.domain.Member; -import talkPick.domain.member.port.out.MemberCommandRepositoryPort; -import talkPick.domain.member.port.out.MemberLoginHistoryCommandRepositoryPort; -import talkPick.domain.member.port.out.MemberQueryRepositoryPort; -import talkPick.domain.member.port.out.MemberTermCommandRepositoryPort; -import talkPick.domain.random.adapter.out.repository.RandomJpaRepository; -import talkPick.domain.random.adapter.out.repository.RandomTopicHistoryJpaRepository; -import talkPick.domain.term.port.out.TermQueryRepositoryPort; -import talkPick.domain.today.adapter.out.repository.TodayTopicJpaRepository; -import talkPick.domain.member.adapter.out.repository.MemberTopicHistoryJpaRepository; -import talkPick.domain.topic.adapter.out.repository.TopicLikeHistoryJpaRepository; -import talkPick.global.security.jwt.repository.RefreshTokenRepository; -import talkPick.global.security.jwt.util.JwtProvider; - -import java.util.Optional; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class MemberDeleteVerificationTest { - - @InjectMocks - private MemberCommandService memberCommandService; - - @Mock private MemberCommandRepositoryPort memberCommandRepositoryPort; - @Mock private TermQueryRepositoryPort termQueryRepositoryPort; - @Mock private MemberTermCommandRepositoryPort memberTermJpaRepository; - @Mock private RefreshTokenRepository refreshTokenRepository; - @Mock private MemberLoginHistoryCommandRepositoryPort memberLoginHistoryRepository; - @Mock private MemberQueryRepositoryPort memberQueryRepositoryPort; - @Mock private JwtProvider jwtProvider; - @Mock private MemberTopicResultJpaRepository memberTopicResultJpaRepository; - @Mock private MemberJpaRepository memberJpaRepository; - @Mock private InquiryJpaRepository inquiryJpaRepository; - @Mock private RandomJpaRepository randomJpaRepository; - @Mock private RandomTopicHistoryJpaRepository randomTopicHistoryJpaRepository; - @Mock private TodayTopicJpaRepository todayTopicJpaRepository; - @Mock private TopicLikeHistoryJpaRepository topicLikeHistoryJpaRepository; - @Mock private MemberTopicHistoryJpaRepository memberTopicHistoryJpaRepository; - - @Test - @DisplayName("회원 탈퇴 시 모든 연관 데이터가 삭제되어야 한다") - void delete_shouldDeleteAllRelatedData() { - // given - String token = "Bearer token"; - Long memberId = 1L; - Member mockMember = mock(Member.class); - given(mockMember.getId()).willReturn(memberId); - - given(jwtProvider.getMemberId(token)).willReturn(memberId); - given(memberQueryRepositoryPort.findMemberById(memberId)).willReturn(mockMember); - given(refreshTokenRepository.findByMember(mockMember)).willReturn(Optional.empty()); - - // when - memberCommandService.delete(token); - - // then - // 기존 삭제 로직 검증 - verify(memberLoginHistoryRepository).deleteByMemberId(memberId); - verify(memberJpaRepository).deleteById(memberId); - - // 누락되었던 삭제 로직 검증 - verify(inquiryJpaRepository).deleteByMemberId(memberId); - verify(memberTermJpaRepository).deleteByMemberId(memberId); - verify(memberTopicResultJpaRepository).deleteByMemberId(memberId); - verify(randomJpaRepository).deleteByMemberId(memberId); - verify(randomTopicHistoryJpaRepository).deleteByMemberId(memberId); - verify(todayTopicJpaRepository).deleteByMemberId(memberId); - verify(topicLikeHistoryJpaRepository).deleteByMemberId(memberId); - verify(memberTopicHistoryJpaRepository).deleteByMemberId(memberId); - } -} diff --git a/src/test/java/talkPick/performance/ConnectionPoolTest.java b/src/test/java/talkPick/performance/ConnectionPoolTest.java deleted file mode 100644 index c72933c..0000000 --- a/src/test/java/talkPick/performance/ConnectionPoolTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package talkPick.performance; - -import com.zaxxer.hikari.HikariDataSource; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; -import org.springframework.util.StopWatch; - -import javax.sql.DataSource; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -@SpringBootTest -@Import(PerformanceTestService.class) -public class ConnectionPoolTest { - - private static final Logger log = LoggerFactory.getLogger(ConnectionPoolTest.class); - - @Autowired - private PerformanceTestService performanceTestService; - - @Autowired - private DataSource dataSource; - - @Test - @DisplayName("커넥션 풀 사이즈와 컨텍스트 스위칭 성능 테스트") - void testConnectionPoolPerformance() throws InterruptedException { - // 1. 현재 HikariCP 설정 확인 - HikariDataSource hikariDataSource = (HikariDataSource) dataSource; - int currentPoolSize = hikariDataSource.getMaximumPoolSize(); - log.info("=================================================="); - log.info("현재 HikariCP Maximum Pool Size: {}", currentPoolSize); - log.info("=================================================="); - - // 테스트 설정 - int threadCount = 3000; // 동시 요청 스레드 수 - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - CountDownLatch latch = new CountDownLatch(threadCount); - StopWatch stopWatch = new StopWatch(); - - log.info("테스트 시작: {}개의 동시 요청 실행 중...", threadCount); - stopWatch.start(); - - for (int i = 0; i < threadCount; i++) { - executorService.execute(() -> { - try { - performanceTestService.heavyWork(); - } catch (Exception e) { - log.error("테스트 중 예외 발생", e); - } finally { - latch.countDown(); - } - }); - } - - // 모든 스레드가 끝날 때까지 대기 - latch.await(); - stopWatch.stop(); - - log.info("=================================================="); - log.info("테스트 완료"); - log.info("총 소요 시간: {} ms", stopWatch.getTotalTimeMillis()); - log.info("초당 처리량(TPS): {}", threadCount / stopWatch.getTotalTimeSeconds()); - log.info("=================================================="); - - /* - * [테스트 가이드] - * 1. application.yml 또는 환경변수에서 'maximum-pool-size'를 10으로 설정 후 실행해 보세요. - * 2. 그 다음, 200으로 설정 후 실행해 보세요. - * - * 예상 결과: - * - Pool Size 10 (적절): 스레드들이 줄을 서서(Queueing) 기다리지만, CPU는 해시 연산에 집중하므로 전체 처리 속도는 빠릅니다. - * - Pool Size 200 (과다): 200개의 스레드가 동시에 커넥션을 잡고 CPU 쟁탈전을 벌입니다. - * 컨텍스트 스위칭 비용으로 인해 총 소요 시간이 오히려 더 늘어날 수 있습니다. - */ - } -} diff --git a/src/test/java/talkPick/performance/MemberDeletePerformanceTest.java b/src/test/java/talkPick/performance/MemberDeletePerformanceTest.java deleted file mode 100644 index 752e809..0000000 --- a/src/test/java/talkPick/performance/MemberDeletePerformanceTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package talkPick.performance; - -import jakarta.persistence.EntityManager; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import talkPick.domain.inquiry.adapter.out.repository.InquiryJpaRepository; -import talkPick.domain.inquiry.domain.Inquiry; -import talkPick.domain.inquiry.domain.type.InquiryType; -import talkPick.domain.member.adapter.out.repository.MemberTopicHistoryJpaRepository; -import talkPick.domain.member.adapter.out.repository.MemberTopicResultJpaRepository; -import talkPick.domain.topic.domain.member.MemberTopicHistory; -import talkPick.domain.topic.domain.member.MemberTopicResult; -import talkPick.domain.topic.domain.type.TopicType; - -import java.util.ArrayList; -import java.util.List; - -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -public class MemberDeletePerformanceTest { - - @Autowired InquiryJpaRepository inquiryRepository; - @Autowired MemberTopicHistoryJpaRepository historyRepository; - @Autowired MemberTopicResultJpaRepository resultRepository; - @Autowired EntityManager em; - - @Test - @DisplayName("회원 삭제 시 쿼리 발생 횟수 비교 (기존 vs 벌크)") - void compareQueryCounts() { - Long memberId = 99999L; // 테스트용 가상 ID - - // Case 1: 기존 방식 (JPA Delete) - setupData(memberId); - em.flush(); - em.clear(); - - inquiryRepository.deleteByMemberId(memberId); - historyRepository.deleteByMemberId(memberId); - resultRepository.deleteByMemberId(memberId); - em.flush(); - - // Case 2: 벌크 방식 (@Modifying) - setupData(memberId); - em.flush(); - em.clear(); - - inquiryRepository.deleteAllByMemberIdInBulk(memberId); - historyRepository.deleteAllByMemberIdInBulk(memberId); - resultRepository.deleteAllByMemberIdInBulk(memberId); - } - - private void setupData(Long memberId) { - int count = 100; // 각 엔티티 당 100개씩 생성 - - // 1. Inquiry 생성 - List inquiries = new ArrayList<>(); - for (int i = 0; i < count; i++) { - inquiries.add(Inquiry.builder() - .memberId(memberId) - .title("테스트 문의 " + i) - .content("내용입니다.") - .email("test" + i + "@example.com") - .type(InquiryType.GENERAL) - .isAnswered(false) - .build()); - } - inquiryRepository.saveAll(inquiries); - - // 2. MemberTopicHistory 생성 - List histories = new ArrayList<>(); - for (int i = 0; i < count; i++) { - histories.add(MemberTopicHistory.builder() - .memberId(memberId) - .topicId(100L + i) - .talkTime(60000L) - .checkLiked(false) - .sequence(i) - .topicType(TopicType.SELECTED) - .build()); - } - historyRepository.saveAll(histories); - - // 3. MemberTopicResult 생성 - List results = new ArrayList<>(); - for (int i = 0; i < count; i++) { - results.add(MemberTopicResult.builder() - .memberId(memberId) - .memberTopicHistoryId(200L + i) - .comment("결과 코멘트 " + i) - .build()); - } - resultRepository.saveAll(results); - } -} diff --git a/src/test/java/talkPick/performance/PerformanceTestService.java b/src/test/java/talkPick/performance/PerformanceTestService.java deleted file mode 100644 index a761163..0000000 --- a/src/test/java/talkPick/performance/PerformanceTestService.java +++ /dev/null @@ -1,39 +0,0 @@ -package talkPick.performance; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -@Service -public class PerformanceTestService { - - /** - * DB 커넥션을 점유한 상태(@Transactional)에서 - * CPU 연산을 수행하여 컨텍스트 스위칭 부하를 시뮬레이션합니다. - */ - @Transactional - public void heavyWork() { - try { - // CPU 부하를 주기 위한 해시 계산 (50,000번 반복) - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - String data = "dummy-data-for-simulation-" + System.nanoTime(); - final Object SHARED_LOCK = new Object(); - - for (int i = 0; i < 50000; i++) { - digest.update(data.getBytes()); - digest.digest(); - - synchronized (SHARED_LOCK) { - try { - // 아주 짧은 락 점유 - Thread.sleep(1); - } catch (InterruptedException e) {} - } - } - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("SHA-256 알고리즘을 찾을 수 없습니다.", e); - } - } -} From 36c864884988e9ac9918296fe1c5a49d91cebe99 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Mon, 29 Dec 2025 17:59:42 +0900 Subject: [PATCH 15/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20API=20=EB=AA=85=EC=84=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20#267?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/adapter/in/MemberCommandApi.java | 6 ++++ .../adapter/in/MemberCommandController.java | 31 +++++++++++++++++++ .../out/repository/MemberJpaRepository.java | 5 +++ 3 files changed, 42 insertions(+) diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java index b246d65..d9b83d2 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java @@ -21,6 +21,12 @@ JwtResDTO.Login kakaoOAuth2Login( @Operation(summary = "APPLE Oauth2 로그인 API", description = "APPLE OAuth2 로그인 API 입니다.") JwtResDTO.Login appleOauth2Login (@Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); + @PostMapping("/{provider}/reactivate") + @Operation(summary = "계정 복구 API", description = "탈퇴한 계정(kakao/apple)을 복구하고 로그인합니다. provider: 'kakao' 또는 'apple'") + JwtResDTO.Login reactivateMember( + @Parameter(description = "kakao 또는 apple", example = "kakao") @PathVariable("provider") String provider, + @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); + @PostMapping("/token/refresh") @Operation(summary = "액세스 토큰 재발급", description = "쿠키에 담긴 리프레시 토큰으로 액세스 토큰을 재발급합니다.") JwtResDTO.AccessToken refreshAccessToken( diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java index ccf1a0c..339d556 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java @@ -66,6 +66,37 @@ public JwtResDTO.Login appleOauth2Login (@Valid @RequestBody MemberReqDto.OAuth2 ); } + @Override + public JwtResDTO.Login reactivateMember( + @PathVariable("provider") String provider, + @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response + ) { + MemberDataDto.MemberData memberData; + LoginType loginType; + + if ("kakao".equalsIgnoreCase(provider)) { + memberData = kakaoOidcService.verifyAndParseIdToken(request); + loginType = LoginType.KAKAO; + } else if ("apple".equalsIgnoreCase(provider)) { + memberData = appleOidcService.verifyAndParseIdToken(request); + loginType = LoginType.APPLE; + } else { + throw new IllegalArgumentException("지원하지 않는 Provider입니다: " + provider); + } + + Member member = memberCommandUseCase.reactivateMember(memberData, loginType); + JwtResDTO.GeneratedTokens generatedTokens = jwtTokenCommandUseCase.generateToken(member); + + setRefreshTokenCookie(response, generatedTokens.refreshToken(), generatedTokens.refreshExpiredTime()); + + return JwtResDTO.Login.of( + member.getId(), + member.getMemberRole().toString(), + generatedTokens.accessToken(), + generatedTokens.accessExpiredTime() + ); + } + // refresh token을 HttpOnly 쿠키로 설정하는 헬퍼 메서드 private void setRefreshTokenCookie(HttpServletResponse response, String refreshToken, Long refreshExpiredTime) { Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberJpaRepository.java index c5948d4..791c276 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberJpaRepository.java @@ -2,10 +2,15 @@ import org.springframework.data.jpa.repository.JpaRepository; import talkPick.domain.member.domain.Member; +import talkPick.global.model.TalkPickStatus; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface MemberJpaRepository extends JpaRepository { Optional findByProviderId(String sub); Optional findByEmail(String email); + Optional findTop1ByStatusAndDeletedAtBefore(TalkPickStatus status, LocalDateTime dateTime); + List findByStatusAndDeletedAtBefore(TalkPickStatus status, LocalDateTime dateTime); } From 46a69540fa50a35e9555fbb73f0cc7afd0e71ea8 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Mon, 29 Dec 2025 18:01:25 +0900 Subject: [PATCH 16/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=86=8C=ED=94=84=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20=ED=9B=84=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=20=EC=82=AD=EC=A0=9C=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20-=20#267?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/MemberCleanupScheduler.java | 50 +++++++++++++++++++ .../talkPick/global/exception/ErrorCode.java | 1 + 2 files changed, 51 insertions(+) create mode 100644 src/main/java/talkPick/batch/member/scheduler/MemberCleanupScheduler.java diff --git a/src/main/java/talkPick/batch/member/scheduler/MemberCleanupScheduler.java b/src/main/java/talkPick/batch/member/scheduler/MemberCleanupScheduler.java new file mode 100644 index 0000000..352a859 --- /dev/null +++ b/src/main/java/talkPick/batch/member/scheduler/MemberCleanupScheduler.java @@ -0,0 +1,50 @@ +package talkPick.batch.member.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import talkPick.domain.member.adapter.out.repository.MemberJpaRepository; +import talkPick.domain.member.domain.Member; +import talkPick.domain.member.port.in.MemberWithdrawalUseCase; +import talkPick.global.model.TalkPickStatus; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + + +@Slf4j +@Component +@RequiredArgsConstructor +public class MemberCleanupScheduler { + + private final MemberJpaRepository memberRepository; + private final MemberWithdrawalUseCase memberWithdrawalUseCase; + + /** + * 매일 새벽 3시에 탈퇴한 지 15일이 지난 회원을 영구 삭제합니다. + */ + @Scheduled(cron = "0 0 3 * * *") + public void cleanupExpiredMembers() { + log.info("회원 탈퇴 데이터 정리 스케줄러 시작"); + + LocalDateTime thresholdDate = LocalDateTime.now().minusDays(15); + Optional expiredMember = memberRepository.findTop1ByStatusAndDeletedAtBefore(TalkPickStatus.DIS_ACTIVE, thresholdDate); + + if (expiredMember.isPresent()) { + Member member = expiredMember.get(); + try { + log.info("삭제 대상 회원 ID: {}", member.getId()); + memberWithdrawalUseCase.hardDelete(member.getId()); + log.info("회원 ID {} 영구 삭제 완료", member.getId()); + } catch (Exception e) { + log.error("회원 ID {} 영구 삭제 중 오류 발생", member.getId(), e); + } + } else { + log.info("삭제 대상 회원이 없습니다."); + } + + log.info("회원 탈퇴 데이터 정리 스케줄러 종료"); + } +} diff --git a/src/main/java/talkPick/global/exception/ErrorCode.java b/src/main/java/talkPick/global/exception/ErrorCode.java index c76ee30..97de495 100644 --- a/src/main/java/talkPick/global/exception/ErrorCode.java +++ b/src/main/java/talkPick/global/exception/ErrorCode.java @@ -70,6 +70,7 @@ public enum ErrorCode { // Member MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."), + MEMBER_IS_WITHDRAWN(HttpStatus.FORBIDDEN, "탈퇴한 회원입니다."), MEMBER_EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 가입된 이메일입니다."), INVALID_MEMBER_INFO(HttpStatus.BAD_REQUEST, "회원 필수 정보가 누락되었습니다."), PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "비밀번호는 필수입니다."), From d716fdfb0bd90a67a57291e5c5a63247b74edfe3 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Mon, 29 Dec 2025 18:01:44 +0900 Subject: [PATCH 17/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20API=20=ED=99=94=EC=9D=B4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20-=20#267?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/talkPick/global/security/model/WhiteList.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/talkPick/global/security/model/WhiteList.java b/src/main/java/talkPick/global/security/model/WhiteList.java index 2b473a1..251bbcc 100644 --- a/src/main/java/talkPick/global/security/model/WhiteList.java +++ b/src/main/java/talkPick/global/security/model/WhiteList.java @@ -8,6 +8,8 @@ private WhiteList() {} // 인스턴스화 방지 "/api/v1/admin/login", "/api/v1/members/kakao/login", "/api/v1/members/apple/login", + "/api/v1/members/kakao/reactivate", + "/api/v1/members/apple/reactivate", "/api/v1/members/token/refresh", "/api/v1/inquiry", "/swagger-ui/**", From c39b6b219e9a087b40ec1a5071d1d0bfecbba1bb Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Mon, 29 Dec 2025 18:02:23 +0900 Subject: [PATCH 18/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EC=86=8C=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20-=20#267?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/MemberCommandService.java | 21 +++++++++++++++++++ .../application/MemberWithdrawalService.java | 18 +++++++++++++--- .../talkPick/domain/member/domain/Member.java | 16 ++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/main/java/talkPick/domain/member/application/MemberCommandService.java b/src/main/java/talkPick/domain/member/application/MemberCommandService.java index b04e2fb..b6bb3a9 100644 --- a/src/main/java/talkPick/domain/member/application/MemberCommandService.java +++ b/src/main/java/talkPick/domain/member/application/MemberCommandService.java @@ -72,9 +72,30 @@ public MemberResDto.MemberProfileResponse updateProfile(String authorization, Me public Member findOrCreateMember(MemberDataDto.MemberData MemberData, LoginType loginType) { Member findOrNewMember = memberCommandRepositoryPort.findByProviderId(MemberData.getSub()) .orElseGet(() -> MemberConverter.toMember(MemberData, loginType)); + + if (findOrNewMember.getStatus() == TalkPickStatus.DIS_ACTIVE) { + throw new MemberExceptionHandler(ErrorCode.MEMBER_IS_WITHDRAWN); + } + return memberCommandRepositoryPort.save(findOrNewMember); } + /** + * 탈퇴한 회원 복구 + */ + @Override + public Member reactivateMember(MemberDataDto.MemberData memberData, LoginType loginType) { + Member member = memberCommandRepositoryPort.findByProviderId(memberData.getSub()) + .orElseThrow(() -> new MemberExceptionHandler(ErrorCode.MEMBER_NOT_FOUND)); + + if (member.getStatus() == TalkPickStatus.DIS_ACTIVE) { + member.reactivate(); + return memberCommandRepositoryPort.save(member); + } + + return member; + } + /** * 회원 가입(추가 정보 입력 및 상태 변경 처리) */ diff --git a/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java b/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java index a6aeb23..35017a6 100644 --- a/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java +++ b/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java @@ -5,6 +5,7 @@ import org.springframework.transaction.annotation.Transactional; import talkPick.domain.inquiry.adapter.out.repository.InquiryJpaRepository; import talkPick.domain.member.adapter.out.repository.MemberJpaRepository; +import talkPick.domain.member.domain.Member; import talkPick.domain.member.adapter.out.repository.MemberLoginHistoryJpaRepository; import talkPick.domain.member.adapter.out.repository.MemberTermJpaRepository; import talkPick.domain.member.adapter.out.repository.MemberTopicHistoryJpaRepository; @@ -41,10 +42,21 @@ public class MemberWithdrawalService implements MemberWithdrawalUseCase { public void withdraw(String authorization) { Long memberId = jwtProvider.getMemberId(authorization); - // 1. Refresh Token 삭제 + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + // 1. Refresh Token 삭제 (즉시 로그아웃 효과) refreshTokenRepository.deleteAllByMemberIdInBulk(memberId); - // 2. 연관 데이터 일괄 삭제 + // 2. 소프트 삭제 처리 + member.withdraw(); + memberRepository.save(member); + } + + @Override + @Transactional + public void hardDelete(Long memberId) { + // 1. 연관 데이터 일괄 삭제 inquiryRepository.deleteAllByMemberIdInBulk(memberId); memberTermRepository.deleteAllByMemberIdInBulk(memberId); memberLoginHistoryRepository.deleteAllByMemberIdInBulk(memberId); @@ -55,7 +67,7 @@ public void withdraw(String authorization) { todayTopicRepository.deleteAllByMemberIdInBulk(memberId); topicLikeHistoryRepository.deleteAllByMemberIdInBulk(memberId); - // 3. 회원 삭제 + // 2. 회원 영구 삭제 memberRepository.deleteById(memberId); } } diff --git a/src/main/java/talkPick/domain/member/domain/Member.java b/src/main/java/talkPick/domain/member/domain/Member.java index bbfae73..9d9ff44 100644 --- a/src/main/java/talkPick/domain/member/domain/Member.java +++ b/src/main/java/talkPick/domain/member/domain/Member.java @@ -10,6 +10,7 @@ import talkPick.global.model.TalkPickStatus; import java.time.LocalDate; +import java.time.LocalDateTime; @Getter @Setter @@ -82,6 +83,11 @@ public class Member extends BaseTime { ) private String providerId; + @Column( + columnDefinition = "DATETIME COMMENT '회원 탈퇴 일시'" + ) + private LocalDateTime deletedAt; + public void updateNickname(String nickname) { this.nickname = nickname; } @@ -94,4 +100,14 @@ public void updatePassword(String password) { this.password = password; } + public void withdraw() { + this.status = TalkPickStatus.DIS_ACTIVE; + this.deletedAt = LocalDateTime.now(); + } + + public void reactivate() { + this.status = TalkPickStatus.ACTIVE; + this.deletedAt = null; + } + } From ba841d48d63ff8e66a9320cd15772cd79fb3cd74 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Mon, 29 Dec 2025 18:02:52 +0900 Subject: [PATCH 19/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EC=8B=9C=20=EC=8A=A4=EB=A0=88=EB=93=9C=20?= =?UTF-8?q?=ED=92=80=20=EC=84=A4=EC=A0=95=20-=20#267?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/port/in/MemberCommandUseCase.java | 1 + .../port/in/MemberWithdrawalUseCase.java | 1 + .../global/config/SchedulingConfig.java | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+) create mode 100644 src/main/java/talkPick/global/config/SchedulingConfig.java diff --git a/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java b/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java index 8d8dd58..ad71eea 100644 --- a/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java +++ b/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java @@ -9,6 +9,7 @@ public interface MemberCommandUseCase { MemberResDto.MemberProfileResponse updateProfile(String authorization, MemberReqDto.ProfileUpdateRequest request); Member findOrCreateMember(MemberDataDto.MemberData kakaoMemberData, LoginType loginType); + Member reactivateMember(MemberDataDto.MemberData memberData, LoginType loginType); MemberResDto.MemberSignupResponse memberSignup(String authorization, MemberReqDto.MemberSignupRequest request); MemberResDto.TermAgreementResponse termAgreement(String authorization, MemberReqDto.TermAgreementRequest request); void logout(String authorization); diff --git a/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java b/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java index e4497c8..5953f93 100644 --- a/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java +++ b/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java @@ -2,4 +2,5 @@ public interface MemberWithdrawalUseCase { void withdraw(String authorization); + void hardDelete(Long memberId); } diff --git a/src/main/java/talkPick/global/config/SchedulingConfig.java b/src/main/java/talkPick/global/config/SchedulingConfig.java new file mode 100644 index 0000000..5fbe541 --- /dev/null +++ b/src/main/java/talkPick/global/config/SchedulingConfig.java @@ -0,0 +1,20 @@ +package talkPick.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class SchedulingConfig implements SchedulingConfigurer { + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + taskScheduler.setPoolSize(3); + taskScheduler.setThreadNamePrefix("scheduled-task-"); + taskScheduler.initialize(); + + taskRegistrar.setTaskScheduler(taskScheduler); + } +} From 2e756191b2a93c06ab5c36c6677b7dd284f8059c Mon Sep 17 00:00:00 2001 From: Seong Ju Hong <97530721+Hszoo@users.noreply.github.com> Date: Tue, 30 Dec 2025 09:59:50 +0900 Subject: [PATCH 20/49] =?UTF-8?q?[Merge]=20CICD=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=88=98=EC=A0=95=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B0=98=EC=98=81=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor: deploy workflow Updated the deployment workflow to include new branch and streamlined steps for Docker Hub integration and EC2 deployment. * Fix: Docker Hub login env var * Fix: talkpick-server image and env file path Updated the image for talkpick-server and modified environment configuration. * Refactor CI flow Updated deployment script to use Docker Compose for managing containers and added environment variable handling. * Remove: adminer from nginx service dependencies Removed adminer service dependency from nginx. * Change active profile from 'prod' to 'local' * Remove CONFIG step for application-prod.properties Removed step to create application-prod.properties file. * Update Docker build command to use latest tag * Add production configuration for application * Remove .env properties import from application-prod.yml Removed the optional import of .env properties. * Test: Ensure no prod properties exist before build Add step to clean up production properties before build * Update docker-compose.yml * Change deployment branch from 'deploy/265' to 'staging' --- .github/workflows/deploy.yml | 73 ++++++++++---------- docker-compose.yml | 18 ++--- src/main/resources/application-prod.yml | 89 +++++++++++++++++++++++++ src/main/resources/application.yml | 2 +- 4 files changed, 135 insertions(+), 47 deletions(-) create mode 100644 src/main/resources/application-prod.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 76cf897..a3390a0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,65 +9,64 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - name: ✔️ GIT - Checkout Repository ✔️ + - name: GIT - Checkout Repository uses: actions/checkout@v4 - - name: 🔻 SETUP - Install JDK 21 🔻 + - name: SETUP - Install JDK 21 uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - - name: 🔐 CONFIG - Create application-prod.properties 🔐 + - name: CLEAN - Ensure no prod properties exist run: | - mkdir -p ./src/main/resources - echo "${{ secrets.ENV }}" > ./src/main/resources/application-prod.properties + sudo rm -f src/main/resources/application-prod.properties + find src/main/resources -name "application-prod.properties" || true - - name: ⏳ BUILD - Run Test & Build JAR ⏳ + - name: BUILD - Run Test & Build JAR run: | ./gradlew clean build - - name: ☁️ CONFIG - Configure AWS Credentials ☁️ + - name: CONFIG - Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-region: ap-northeast-2 aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - - name: ☁️ AWS - Login to Amazon ECR ☁️ - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 - - - name: 🐳 DOCKER - Build Docker Image 🐳 - run: docker build -t talkpick-server . - - - name: 🐳 DOCKER - Tag Docker Image 🐳 - run: docker tag talkpick-server ${{ steps.login-ecr.outputs.registry }}/talkpick-server:latest + - name: DOCKER - Build Docker Image + run: docker build --no-cache -t talkpick-server:latest . + + - name: DOCKER_HUB - Login to Docker Hub + run: | + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login \ + -u "${{ secrets.DOCKERHUB_USERNAME }}" \ + --password-stdin - - name: ☁️ AWS - Push Docker Image to ECR ☁️ - run: docker push ${{ steps.login-ecr.outputs.registry }}/talkpick-server:latest + - name: DOCKER_HUB - Push Docker image + run: | + docker tag talkpick-server:latest ${{ secrets.DOCKERHUB_USERNAME }}/talkpick-server:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/talkpick-server:latest - - name: 🛠️ PERMISSIONS - Fix file ownership and permission before tar + - name: PERMISSIONS - Fix file ownership and permission before tar run: | sudo chown -R $(whoami):$(whoami) . chmod -R 755 scripts chmod 644 docker-compose.yml - - name: ☁️ AWS - Compress for CodeDeploy ☁️ - run: | - mkdir -p deploy-files - cp appspec.yml docker-compose.yml -t deploy-files - cp -r scripts deploy-files - cp src/main/resources/application.yml deploy-files - tar -czvf $GITHUB_SHA.tar.gz -C deploy-files . - - - name: ☁️ AWS - Upload Archive to S3 ☁️ - run: aws s3 cp --region ap-northeast-2 ./$GITHUB_SHA.tar.gz s3://talkpick-server/$GITHUB_SHA.tar.gz - - - name: ☁️ AWS - Create Deployment to EC2 ☁️ - run: | - aws deploy create-deployment \ - --application-name talkpick-server \ - --deployment-config-name CodeDeployDefault.AllAtOnce \ - --deployment-group-name Production \ - --s3-location "bucket=talkpick-server,bundleType=tgz,key=$GITHUB_SHA.tar.gz" \ No newline at end of file + - name: AWS - Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + mkdir -p /home/${{ secrets.EC2_user }}/env + cat << 'EOF' > /home/${{ secrets.EC2_user }}/env/.env + ${{ secrets.ENV }} + EOF + + cd /home/${{ secrets.EC2_user }}/TalkPick_Server + docker compose pull + docker compose down + docker compose up -d diff --git a/docker-compose.yml b/docker-compose.yml index ed29090..59e0e19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,21 @@ services: ## talkpick talkpick-server: - image: 718513646976.dkr.ecr.ap-northeast-2.amazonaws.com/talkpick-server:latest + image: hszoo/talkpick-server:latest container_name: talkpick-server ports: - "8080:8080" networks: - t4y restart: always - volumes: - - ./env/.env.properties:/app/config/.env.properties - environment: - - SPRING_CONFIG_IMPORT=optional:file:/app/config/.env.properties env_file: - - ./env/.env - + - /home/ec2-user/env/.env + environment: + SPRING_PROFILES_ACTIVE: prod + SPRING_CONFIG_ADDITIONAL_LOCATION: file:/config/ + volumes: + - /home/ec2-user/config:/config + ## nginx nginx: build: ./nginx @@ -30,9 +31,8 @@ services: PRIVATE_CERTIFICATE_KEY: "${PRIVATE_CERTIFICATE_KEY}" depends_on: - talkpick-server - - adminer volumes: - ./nginx/conf:/etc/nginx/conf.d - ./nginx/html:/usr/share/nginx/html networks: - t4y: \ No newline at end of file + t4y: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..8997e32 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,89 @@ +jwt: + secret: ${JWT_SECRET} + accessTokenExpireTime: ${JWT_ACCESS_EXPIRE_TIME} + refreshTokenExpireTime: ${JWT_REFRESH_EXPIRE_TIME} + +server: + port: ${SERVER_PORT} + tomcat: + threads: + max: ${THREADS_MAX} + accept-count: ${ACCEPT_COUNT} + +spring: + autoconfigure: + exclude: org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} + username: ${DB_USER} + password: ${DB_PASSWORD} + hikari: + connection-timeout: ${CONNECTION_TIMEOUT} + maximum-pool-size: ${MAXIMUM_POOL_SIZE} + idle-timeout: ${IDLE_TIMEOUT} + max-lifetime: ${MAX_LIFETIME} + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + database-platform: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: update + servlet: + multipart: + max-file-size: ${MAX_FILE_SIZE} + max-request-size: ${MAX_REQUEST_SIZE} + messages: + basename: messages,errors + mvc: + throw-exception-if-no-handler-found: true + web: + resources: + add-mappings: false + +logging: + level: + root: info + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + org.springframework.web.cors: debug +# org.apache.coyote.http11: trace + +management: + endpoint: + health: + show-details: always + endpoints: + web: + exposure: + include: health, prometheus + +kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URI} + response-type: ${KAKAO_RESPONSE_TYPE} + +apple: + bundle-id: ${APPLE_BUNDLE_ID} + +resilience4j: + circuitbreaker: + instances: + llm: + registerHealthIndicator: true + slidingWindowSize: 10 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + +healthcheck: + url: ${HEALTH_CHECK_URL} + +jasypt: + admin: + secret-key: ${JASYPT_ADMIN_SECRET_KEY} + member: + secret-key: ${JASYPT_MEMBER_SECRET_KEY} + algorithm: ${JASYPT_ALGORITHM:PBEWITHHMACSHA512ANDAES_256} + pool-size: ${JASYPT_POOL_SIZE:1} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 90385b2..d74c444 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,3 @@ spring: profiles: - active: prod \ No newline at end of file + active: local From 57564d5c06955615cc88d89364e59b1dca276a16 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Tue, 30 Dec 2025 13:27:21 +0900 Subject: [PATCH 21/49] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=97=90=20GOOGLE=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20#272?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/talkPick/domain/member/domain/type/LoginType.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/talkPick/domain/member/domain/type/LoginType.java b/src/main/java/talkPick/domain/member/domain/type/LoginType.java index 7297543..5178b91 100644 --- a/src/main/java/talkPick/domain/member/domain/type/LoginType.java +++ b/src/main/java/talkPick/domain/member/domain/type/LoginType.java @@ -1,5 +1,5 @@ package talkPick.domain.member.domain.type; public enum LoginType { - KAKAO, EMAIL, APPLE + KAKAO, APPLE, GOOGLE } From ea2e2ee05c73606b3f02b75dc6ae5a142f378a8f Mon Sep 17 00:00:00 2001 From: Zetty Date: Tue, 30 Dec 2025 15:21:30 +0900 Subject: [PATCH 22/49] test: Add exception and boundary value test cases - #260 --- .../application/NoticeQueryServiceTest.java | 36 ++++++++++- .../notice/domain/NoticeImageTest.java | 3 +- .../notice/domain/NoticeTest.java | 29 ++++++++- .../domain/event/NoticeReadEventTest.java | 3 +- .../application/RandomQueryServiceTest.java | 3 +- .../random/domain/RandomTest.java | 59 ++++++++++++++++++- .../random/domain/RandomTopicHistoryTest.java | 38 +++++++++++- .../RateLimiterManagerAdapterTest.java | 2 +- .../TodayTopicQueryServiceTest.java | 19 +++++- .../today/domain/TodayTopicTest.java | 20 ++++++- .../event/TodayTopicSavedEventTest.java | 3 +- .../application/TopicCommandServiceTest.java | 54 +++++++++++++++-- .../topic/domain/TopicStatTest.java | 3 +- .../domain/event/TopicLikedEventTest.java | 3 +- 14 files changed, 257 insertions(+), 18 deletions(-) rename src/test/java/talkPick/{domain => }/notice/application/NoticeQueryServiceTest.java (85%) rename src/test/java/talkPick/{domain => }/notice/domain/NoticeImageTest.java (96%) rename src/test/java/talkPick/{domain => }/notice/domain/NoticeTest.java (72%) rename src/test/java/talkPick/{domain => }/notice/domain/event/NoticeReadEventTest.java (93%) rename src/test/java/talkPick/{domain => }/random/application/RandomQueryServiceTest.java (97%) rename src/test/java/talkPick/{domain => }/random/domain/RandomTest.java (63%) rename src/test/java/talkPick/{domain => }/random/domain/RandomTopicHistoryTest.java (73%) rename src/test/java/talkPick/rateLimiter/{adapter => }/RateLimiterManagerAdapterTest.java (97%) rename src/test/java/talkPick/{domain => }/today/application/TodayTopicQueryServiceTest.java (87%) rename src/test/java/talkPick/{domain => }/today/domain/TodayTopicTest.java (70%) rename src/test/java/talkPick/{domain => }/today/domain/event/TodayTopicSavedEventTest.java (95%) rename src/test/java/talkPick/{domain => }/topic/application/TopicCommandServiceTest.java (53%) rename src/test/java/talkPick/{domain => }/topic/domain/TopicStatTest.java (97%) rename src/test/java/talkPick/{domain => }/topic/domain/event/TopicLikedEventTest.java (94%) diff --git a/src/test/java/talkPick/domain/notice/application/NoticeQueryServiceTest.java b/src/test/java/talkPick/notice/application/NoticeQueryServiceTest.java similarity index 85% rename from src/test/java/talkPick/domain/notice/application/NoticeQueryServiceTest.java rename to src/test/java/talkPick/notice/application/NoticeQueryServiceTest.java index 483465d..dbc440f 100644 --- a/src/test/java/talkPick/domain/notice/application/NoticeQueryServiceTest.java +++ b/src/test/java/talkPick/notice/application/NoticeQueryServiceTest.java @@ -1,4 +1,4 @@ -package talkPick.domain.notice.application; +package talkPick.notice.application; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,6 +9,7 @@ import org.springframework.context.ApplicationEventPublisher; import talkPick.domain.notice.adapter.in.dto.NoticeReqDTO; import talkPick.domain.notice.adapter.out.dto.NoticeResDTO; +import talkPick.domain.notice.application.NoticeQueryService; import talkPick.domain.notice.domain.event.NoticeReadEvent; import talkPick.domain.notice.port.out.NoticeQueryRepositoryPort; import talkPick.global.response.CursorPageResponse; @@ -190,9 +191,40 @@ class NoticeQueryServiceTest { noticeQueryService.getNoticeDetail(noticeId); // then - // Repository 조회가 먼저 실행되고, 그 다음 이벤트가 발행되어야 함 var inOrder = org.mockito.Mockito.inOrder(noticeQueryRepositoryPort, eventPublisher); inOrder.verify(noticeQueryRepositoryPort).findNoticeDetailById(noticeId); inOrder.verify(eventPublisher).publishEvent(any(NoticeReadEvent.class)); } + + @Test + @DisplayName("공지사항 목록 조회 시 Repository 예외 발생 테스트") + void 공지사항_목록_조회시_Repository_예외_발생_테스트() { + // given + NoticeReqDTO.Cursor cursor = new NoticeReqDTO.Cursor( + LocalDateTime.now(), + 1L, + 20 + ); + + given(noticeQueryRepositoryPort.findNoticesWithCursor(cursor)) + .willThrow(new RuntimeException("DB Connection failed")); + + // when & then + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> noticeQueryService.getNotices(cursor)); + } + + @Test + @DisplayName("공지사항 상세 조회 시 Repository 예외 발생 테스트") + void 공지사항_상세_조회시_Repository_예외_발생_테스트() { + // given + Long noticeId = -1L; + + given(noticeQueryRepositoryPort.findNoticeDetailById(noticeId)) + .willThrow(new IllegalArgumentException("Notice not found")); + + // when & then + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> noticeQueryService.getNoticeDetail(noticeId)); + } } \ No newline at end of file diff --git a/src/test/java/talkPick/domain/notice/domain/NoticeImageTest.java b/src/test/java/talkPick/notice/domain/NoticeImageTest.java similarity index 96% rename from src/test/java/talkPick/domain/notice/domain/NoticeImageTest.java rename to src/test/java/talkPick/notice/domain/NoticeImageTest.java index 3f11e77..834dd74 100644 --- a/src/test/java/talkPick/domain/notice/domain/NoticeImageTest.java +++ b/src/test/java/talkPick/notice/domain/NoticeImageTest.java @@ -1,7 +1,8 @@ -package talkPick.domain.notice.domain; +package talkPick.notice.domain; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import talkPick.domain.notice.domain.NoticeImage; import talkPick.global.model.TalkPickStatus; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/talkPick/domain/notice/domain/NoticeTest.java b/src/test/java/talkPick/notice/domain/NoticeTest.java similarity index 72% rename from src/test/java/talkPick/domain/notice/domain/NoticeTest.java rename to src/test/java/talkPick/notice/domain/NoticeTest.java index d9ecf8c..e99d60f 100644 --- a/src/test/java/talkPick/domain/notice/domain/NoticeTest.java +++ b/src/test/java/talkPick/notice/domain/NoticeTest.java @@ -1,7 +1,8 @@ -package talkPick.domain.notice.domain; +package talkPick.notice.domain; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import talkPick.domain.notice.domain.Notice; import talkPick.global.model.TalkPickStatus; import static org.assertj.core.api.Assertions.assertThat; @@ -76,4 +77,30 @@ class NoticeTest { // then assertThat(notice.getStatus()).isEqualTo(TalkPickStatus.DIS_ACTIVE); } + + @Test + @DisplayName("조회수가 큰 값일 때 plusReadCount 호출 테스트") + void 조회수가_큰_값일때_plusReadCount_호출_테스트() { + // given + Notice notice = Notice.of(1L, "제목", "내용", Integer.MAX_VALUE - 10, TalkPickStatus.ACTIVE); + + // when + notice.plusReadCount(); + + // then + assertThat(notice.getReadCount()).isEqualTo(Integer.MAX_VALUE - 9); + } + + @Test + @DisplayName("다양한 초기 조회수로 Notice 생성 테스트") + void 다양한_초기_조회수로_Notice_생성_테스트() { + // given + Integer[] readCounts = {0, 10, 100, 1000, 10000}; + + // when & then + for (Integer readCount : readCounts) { + Notice notice = Notice.of(1L, "제목", "내용", readCount, TalkPickStatus.ACTIVE); + assertThat(notice.getReadCount()).isEqualTo(readCount); + } + } } diff --git a/src/test/java/talkPick/domain/notice/domain/event/NoticeReadEventTest.java b/src/test/java/talkPick/notice/domain/event/NoticeReadEventTest.java similarity index 93% rename from src/test/java/talkPick/domain/notice/domain/event/NoticeReadEventTest.java rename to src/test/java/talkPick/notice/domain/event/NoticeReadEventTest.java index 57050c8..a73e457 100644 --- a/src/test/java/talkPick/domain/notice/domain/event/NoticeReadEventTest.java +++ b/src/test/java/talkPick/notice/domain/event/NoticeReadEventTest.java @@ -1,7 +1,8 @@ -package talkPick.domain.notice.domain.event; +package talkPick.notice.domain.event; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import talkPick.domain.notice.domain.event.NoticeReadEvent; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; diff --git a/src/test/java/talkPick/domain/random/application/RandomQueryServiceTest.java b/src/test/java/talkPick/random/application/RandomQueryServiceTest.java similarity index 97% rename from src/test/java/talkPick/domain/random/application/RandomQueryServiceTest.java rename to src/test/java/talkPick/random/application/RandomQueryServiceTest.java index 7aca0ef..4dfc4ab 100644 --- a/src/test/java/talkPick/domain/random/application/RandomQueryServiceTest.java +++ b/src/test/java/talkPick/random/application/RandomQueryServiceTest.java @@ -1,4 +1,4 @@ -package talkPick.domain.random.application; +package talkPick.random.application; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -7,6 +7,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import talkPick.domain.random.adapter.out.dto.RandomResDTO; +import talkPick.domain.random.application.RandomQueryService; import talkPick.domain.random.port.out.RandomQueryRepositoryPort; import talkPick.domain.topic.domain.type.CategoryGroup; diff --git a/src/test/java/talkPick/domain/random/domain/RandomTest.java b/src/test/java/talkPick/random/domain/RandomTest.java similarity index 63% rename from src/test/java/talkPick/domain/random/domain/RandomTest.java rename to src/test/java/talkPick/random/domain/RandomTest.java index 815b08a..ed5d8dc 100644 --- a/src/test/java/talkPick/domain/random/domain/RandomTest.java +++ b/src/test/java/talkPick/random/domain/RandomTest.java @@ -1,8 +1,9 @@ -package talkPick.domain.random.domain; +package talkPick.random.domain; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import talkPick.domain.random.adapter.in.dto.RandomReqDTO; +import talkPick.domain.random.domain.Random; import talkPick.domain.random.domain.type.RandomType; import static org.assertj.core.api.Assertions.assertThat; @@ -103,4 +104,60 @@ class RandomTest { () -> assertThat(random.getOneLine()).isEqualTo("좋아요") ); } + + @Test + @DisplayName("START 상태에서 QUIT으로 상태 전이 테스트") + void START_상태에서_QUIT으로_상태_전이_테스트() { + // given + Random random = Random.from(1L); + assertThat(random.getType()).isEqualTo(RandomType.START); + + // when + random.quit(); + + // then + assertThat(random.getType()).isEqualTo(RandomType.QUIT); + } + + @Test + @DisplayName("START 상태에서 COMPLETED로 상태 전이 테스트") + void START_상태에서_COMPLETED로_상태_전이_테스트() { + // given + Random random = Random.from(1L); + assertThat(random.getType()).isEqualTo(RandomType.START); + + // when + random.end(); + + // then + assertThat(random.getType()).isEqualTo(RandomType.COMPLETED); + } + + @Test + @DisplayName("다양한 평점 값으로 rate 호출 테스트") + void 다양한_평점_값으로_rate_호출_테스트() { + // given + Integer[] ratings = {1, 2, 3, 4, 5}; + + // when & then + for (Integer rating : ratings) { + Random random = Random.from(1L); + RandomReqDTO.Rate rateDTO = new RandomReqDTO.Rate(rating); + random.rate(rateDTO); + assertThat(random.getRating()).isEqualTo(rating); + } + } + + @Test + @DisplayName("다양한 memberId로 Random 생성 테스트") + void 다양한_memberId로_Random_생성_테스트() { + // given + Long[] memberIds = {1L, 100L, 999L, 12345L}; + + // when & then + for (Long memberId : memberIds) { + Random random = Random.from(memberId); + assertThat(random.getMemberId()).isEqualTo(memberId); + } + } } \ No newline at end of file diff --git a/src/test/java/talkPick/domain/random/domain/RandomTopicHistoryTest.java b/src/test/java/talkPick/random/domain/RandomTopicHistoryTest.java similarity index 73% rename from src/test/java/talkPick/domain/random/domain/RandomTopicHistoryTest.java rename to src/test/java/talkPick/random/domain/RandomTopicHistoryTest.java index 3d02808..e33a4bc 100644 --- a/src/test/java/talkPick/domain/random/domain/RandomTopicHistoryTest.java +++ b/src/test/java/talkPick/random/domain/RandomTopicHistoryTest.java @@ -1,8 +1,9 @@ -package talkPick.domain.random.domain; +package talkPick.random.domain; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import talkPick.domain.random.adapter.in.dto.RandomReqDTO; +import talkPick.domain.random.domain.RandomTopicHistory; import java.time.LocalDateTime; @@ -92,4 +93,39 @@ class RandomTopicHistoryTest { assertThat(history.getOrder()).isEqualTo(order); } } + + @Test + @DisplayName("next 호출 전후로 endAt 값 변경 확인 테스트") + void next_호출_전후로_endAt_값_변경_확인_테스트() { + // given + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, 1); + RandomTopicHistory history = RandomTopicHistory.of(1L, 100L, recordDTO); + + // when + LocalDateTime beforeNext = history.getEndAt(); + history.next(); + LocalDateTime afterNext = history.getEndAt(); + + // then + assertAll( + () -> assertThat(beforeNext).isNull(), + () -> assertThat(afterNext).isNotNull() + ); + } + + @Test + @DisplayName("큰 값의 order로 RandomTopicHistory 생성 테스트") + void 큰_값의_order로_RandomTopicHistory_생성_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + Integer largeOrder = Integer.MAX_VALUE; + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, largeOrder); + + // when + RandomTopicHistory history = RandomTopicHistory.of(memberId, randomId, recordDTO); + + // then + assertThat(history.getOrder()).isEqualTo(largeOrder); + } } \ No newline at end of file diff --git a/src/test/java/talkPick/rateLimiter/adapter/RateLimiterManagerAdapterTest.java b/src/test/java/talkPick/rateLimiter/RateLimiterManagerAdapterTest.java similarity index 97% rename from src/test/java/talkPick/rateLimiter/adapter/RateLimiterManagerAdapterTest.java rename to src/test/java/talkPick/rateLimiter/RateLimiterManagerAdapterTest.java index 065d38b..810ac63 100644 --- a/src/test/java/talkPick/rateLimiter/adapter/RateLimiterManagerAdapterTest.java +++ b/src/test/java/talkPick/rateLimiter/RateLimiterManagerAdapterTest.java @@ -1,4 +1,4 @@ -package talkPick.rateLimiter.adapter; +package talkPick.rateLimiter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/talkPick/domain/today/application/TodayTopicQueryServiceTest.java b/src/test/java/talkPick/today/application/TodayTopicQueryServiceTest.java similarity index 87% rename from src/test/java/talkPick/domain/today/application/TodayTopicQueryServiceTest.java rename to src/test/java/talkPick/today/application/TodayTopicQueryServiceTest.java index 4c161dd..f69ff71 100644 --- a/src/test/java/talkPick/domain/today/application/TodayTopicQueryServiceTest.java +++ b/src/test/java/talkPick/today/application/TodayTopicQueryServiceTest.java @@ -1,4 +1,4 @@ -package talkPick.domain.today.application; +package talkPick.today.application; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -10,6 +10,7 @@ import org.springframework.cache.CacheManager; import org.springframework.context.ApplicationEventPublisher; import talkPick.domain.today.adapter.out.dto.TodayTopicResDTO; +import talkPick.domain.today.application.TodayTopicQueryService; import talkPick.domain.today.domain.event.TodayTopicSavedEvent; import talkPick.domain.today.port.out.TodayTopicQueryRepositoryPort; @@ -121,4 +122,20 @@ class TodayTopicQueryServiceTest { () -> verify(eventPublisher, times(1)).publishEvent(any(TodayTopicSavedEvent.class)) ); } + + @Test + @DisplayName("오늘의 토픽 조회 시 Repository 예외 발생 테스트") + void 오늘의_토픽_조회시_Repository_예외_발생_테스트() { + // given + Long memberId = 1L; + + given(cacheManager.getCache("todayTopics")).willReturn(cache); + given(cache.get(memberId)).willReturn(null); + given(todayTopicQueryRepositoryPort.findTodayTopics(memberId)) + .willThrow(new RuntimeException("DB error")); + + // when & then + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> todayTopicQueryService.getTodayTopics(memberId)); + } } \ No newline at end of file diff --git a/src/test/java/talkPick/domain/today/domain/TodayTopicTest.java b/src/test/java/talkPick/today/domain/TodayTopicTest.java similarity index 70% rename from src/test/java/talkPick/domain/today/domain/TodayTopicTest.java rename to src/test/java/talkPick/today/domain/TodayTopicTest.java index cec459a..3817396 100644 --- a/src/test/java/talkPick/domain/today/domain/TodayTopicTest.java +++ b/src/test/java/talkPick/today/domain/TodayTopicTest.java @@ -1,7 +1,8 @@ -package talkPick.domain.today.domain; +package talkPick.today.domain; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import talkPick.domain.today.domain.TodayTopic; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -45,4 +46,21 @@ class TodayTopicTest { ); } } + + @Test + @DisplayName("큰 값의 memberId와 topicId로 TodayTopic 생성 테스트") + void 큰_값의_memberId와_topicId로_TodayTopic_생성_테스트() { + // given + Long memberId = Long.MAX_VALUE; + Long topicId = Long.MAX_VALUE - 1; + + // when + TodayTopic todayTopic = TodayTopic.of(memberId, topicId); + + // then + assertAll( + () -> assertThat(todayTopic.getMemberId()).isEqualTo(memberId), + () -> assertThat(todayTopic.getTopicId()).isEqualTo(topicId) + ); + } } \ No newline at end of file diff --git a/src/test/java/talkPick/domain/today/domain/event/TodayTopicSavedEventTest.java b/src/test/java/talkPick/today/domain/event/TodayTopicSavedEventTest.java similarity index 95% rename from src/test/java/talkPick/domain/today/domain/event/TodayTopicSavedEventTest.java rename to src/test/java/talkPick/today/domain/event/TodayTopicSavedEventTest.java index ae2d002..d584720 100644 --- a/src/test/java/talkPick/domain/today/domain/event/TodayTopicSavedEventTest.java +++ b/src/test/java/talkPick/today/domain/event/TodayTopicSavedEventTest.java @@ -1,8 +1,9 @@ -package talkPick.domain.today.domain.event; +package talkPick.today.domain.event; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import talkPick.domain.today.domain.TodayTopic; +import talkPick.domain.today.domain.event.TodayTopicSavedEvent; import java.util.Collections; import java.util.List; diff --git a/src/test/java/talkPick/domain/topic/application/TopicCommandServiceTest.java b/src/test/java/talkPick/topic/application/TopicCommandServiceTest.java similarity index 53% rename from src/test/java/talkPick/domain/topic/application/TopicCommandServiceTest.java rename to src/test/java/talkPick/topic/application/TopicCommandServiceTest.java index df8fb22..f197262 100644 --- a/src/test/java/talkPick/domain/topic/application/TopicCommandServiceTest.java +++ b/src/test/java/talkPick/topic/application/TopicCommandServiceTest.java @@ -1,4 +1,4 @@ -package talkPick.domain.topic.application; +package talkPick.topic.application; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -7,13 +7,16 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import talkPick.domain.topic.application.TopicCommandService; import talkPick.domain.topic.domain.event.TopicLikedEvent; import talkPick.domain.topic.port.out.TopicLikeHistoryCommandRepositoryPort; - +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.willDoNothing; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.*; +import org.mockito.ArgumentCaptor; @ExtendWith(MockitoExtension.class) @DisplayName("TopicCommandService 테스트") @@ -64,4 +67,47 @@ class TopicCommandServiceTest { inOrder.verify(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); inOrder.verify(eventPublisher).publishEvent(any(TopicLikedEvent.class)); } + + @Test + @DisplayName("토픽 좋아요 추가 시 ArgumentCaptor로 이벤트 내용 검증 테스트") + void 토픽_좋아요_추가시_ArgumentCaptor로_이벤트_내용_검증_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(TopicLikedEvent.class); + + willDoNothing().given(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + willDoNothing().given(eventPublisher).publishEvent(any(TopicLikedEvent.class)); + + // when + topicCommandService.addLike(memberId, topicId); + + // then + verify(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + TopicLikedEvent capturedEvent = eventCaptor.getValue(); + assertAll( + () -> assertThat(capturedEvent).isNotNull(), + () -> assertThat(capturedEvent.getMemberId()).isEqualTo(memberId), + () -> assertThat(capturedEvent.getTopicId()).isEqualTo(topicId) + ); + } + + @Test + @DisplayName("토픽 좋아요 추가 시 Repository 예외 발생 테스트") + void 토픽_좋아요_추가시_Repository_예외_발생_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + + willThrow(new IllegalStateException("Already liked")) + .given(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + + // when & then + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> topicCommandService.addLike(memberId, topicId)); + + verify(eventPublisher, never()).publishEvent(any(TopicLikedEvent.class)); + } } \ No newline at end of file diff --git a/src/test/java/talkPick/domain/topic/domain/TopicStatTest.java b/src/test/java/talkPick/topic/domain/TopicStatTest.java similarity index 97% rename from src/test/java/talkPick/domain/topic/domain/TopicStatTest.java rename to src/test/java/talkPick/topic/domain/TopicStatTest.java index 783395c..79af8ad 100644 --- a/src/test/java/talkPick/domain/topic/domain/TopicStatTest.java +++ b/src/test/java/talkPick/topic/domain/TopicStatTest.java @@ -1,7 +1,8 @@ -package talkPick.domain.topic.domain; +package talkPick.topic.domain; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import talkPick.domain.topic.domain.TopicStat; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; diff --git a/src/test/java/talkPick/domain/topic/domain/event/TopicLikedEventTest.java b/src/test/java/talkPick/topic/domain/event/TopicLikedEventTest.java similarity index 94% rename from src/test/java/talkPick/domain/topic/domain/event/TopicLikedEventTest.java rename to src/test/java/talkPick/topic/domain/event/TopicLikedEventTest.java index 9071d0b..8d701de 100644 --- a/src/test/java/talkPick/domain/topic/domain/event/TopicLikedEventTest.java +++ b/src/test/java/talkPick/topic/domain/event/TopicLikedEventTest.java @@ -1,7 +1,8 @@ -package talkPick.domain.topic.domain.event; +package talkPick.topic.domain.event; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import talkPick.domain.topic.domain.event.TopicLikedEvent; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; From f8b085a05b7fe5a1a7a2a6eba5e865d335544481 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Wed, 31 Dec 2025 15:02:52 +0900 Subject: [PATCH 23/49] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?-=20#272?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/adapter/in/MemberCommandApi.java | 9 +++++-- .../adapter/in/MemberCommandController.java | 24 ++++++++++++++++++ .../member/converter/MemberConverter.java | 25 +++++++++++-------- .../global/security/model/WhiteList.java | 2 ++ src/main/resources/application-local.yml | 3 +++ 5 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java index d9b83d2..4854d05 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java @@ -21,10 +21,15 @@ JwtResDTO.Login kakaoOAuth2Login( @Operation(summary = "APPLE Oauth2 로그인 API", description = "APPLE OAuth2 로그인 API 입니다.") JwtResDTO.Login appleOauth2Login (@Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); + @PostMapping("/google/login") + @Operation(summary = "GOOGLE OAuth2 로그인 API", description = "GOOGLE OAuth2 로그인 API 입니다.") + JwtResDTO.Login googleOauth2Login( + @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); + @PostMapping("/{provider}/reactivate") - @Operation(summary = "계정 복구 API", description = "탈퇴한 계정(kakao/apple)을 복구하고 로그인합니다. provider: 'kakao' 또는 'apple'") + @Operation(summary = "계정 복구 API", description = "탈퇴한 계정(kakao/apple/google)을 복구하고 로그인합니다. provider: 'kakao', 'apple' 또는 'google'") JwtResDTO.Login reactivateMember( - @Parameter(description = "kakao 또는 apple", example = "kakao") @PathVariable("provider") String provider, + @Parameter(description = "kakao, apple 또는 google", example = "kakao") @PathVariable("provider") String provider, @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); @PostMapping("/token/refresh") diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java index 339d556..3e8ee48 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java @@ -10,6 +10,7 @@ import talkPick.domain.member.adapter.out.dto.MemberResDto; import talkPick.domain.member.domain.type.LoginType; import talkPick.external.apple.port.in.AppleOidcUsecase; +import talkPick.external.google.port.in.GoogleOidcUsecase; import talkPick.external.kakao.port.in.KakaoOidcUsecase; import talkPick.global.security.jwt.dto.JwtResDTO; import talkPick.domain.member.domain.Member; @@ -28,6 +29,7 @@ public class MemberCommandController implements MemberCommandApi { private final KakaoOidcUsecase kakaoOidcService; private final AppleOidcUsecase appleOidcService; + private final GoogleOidcUsecase googleOidcService; private final MemberCommandUseCase memberCommandUseCase; private final MemberWithdrawalUseCase memberWithdrawalUseCase; // 의존성 추가 private final JwtTokenCommandUseCase jwtTokenCommandUseCase; @@ -51,6 +53,7 @@ public JwtResDTO.Login kakaoOAuth2Login( ); } + @Override public JwtResDTO.Login appleOauth2Login (@Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response) { MemberDataDto.MemberData appleMemberData = appleOidcService.verifyAndParseIdToken(request); Member member = memberCommandUseCase.findOrCreateMember(appleMemberData, LoginType.APPLE); @@ -66,6 +69,24 @@ public JwtResDTO.Login appleOauth2Login (@Valid @RequestBody MemberReqDto.OAuth2 ); } + @Override + public JwtResDTO.Login googleOauth2Login( + @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response + ) { + MemberDataDto.MemberData googleMemberData = googleOidcService.verifyAndParseIdToken(request); + Member member = memberCommandUseCase.findOrCreateMember(googleMemberData, LoginType.GOOGLE); + JwtResDTO.GeneratedTokens generatedTokens = jwtTokenCommandUseCase.generateToken(member); + + setRefreshTokenCookie(response, generatedTokens.refreshToken(), generatedTokens.refreshExpiredTime()); + + return JwtResDTO.Login.of( + member.getId(), + member.getMemberRole().toString(), + generatedTokens.accessToken(), + generatedTokens.accessExpiredTime() + ); + } + @Override public JwtResDTO.Login reactivateMember( @PathVariable("provider") String provider, @@ -80,6 +101,9 @@ public JwtResDTO.Login reactivateMember( } else if ("apple".equalsIgnoreCase(provider)) { memberData = appleOidcService.verifyAndParseIdToken(request); loginType = LoginType.APPLE; + } else if ("google".equalsIgnoreCase(provider)) { + memberData = googleOidcService.verifyAndParseIdToken(request); + loginType = LoginType.GOOGLE; } else { throw new IllegalArgumentException("지원하지 않는 Provider입니다: " + provider); } diff --git a/src/main/java/talkPick/domain/member/converter/MemberConverter.java b/src/main/java/talkPick/domain/member/converter/MemberConverter.java index 7fab94e..dc8bdc9 100644 --- a/src/main/java/talkPick/domain/member/converter/MemberConverter.java +++ b/src/main/java/talkPick/domain/member/converter/MemberConverter.java @@ -5,10 +5,8 @@ import talkPick.domain.member.domain.Member; import talkPick.domain.member.domain.MemberLoginHistory; import talkPick.domain.member.domain.mapping.MemberTerm; -import talkPick.domain.member.domain.type.Gender; import talkPick.domain.member.domain.type.LoginType; import talkPick.domain.member.dto.MemberDataDto; -import talkPick.domain.member.adapter.in.dto.MemberReqDto; import talkPick.domain.member.adapter.out.dto.MemberResDto; import talkPick.domain.term.domain.Term; import talkPick.global.model.TalkPickStatus; @@ -24,6 +22,20 @@ public static MemberDataDto.MemberData toKakaoMemberData(io.jsonwebtoken.Claims .build(); } + public static MemberDataDto.MemberData toAppleMemberData(Claims claims) { + return MemberDataDto.MemberData.builder() + .sub(claims.getSubject()) + .email(claims.get("email", String.class) != null ? claims.get("email", String.class) : "NONE") + .build(); + } + + public static MemberDataDto.MemberData toGoogleMemberData(io.jsonwebtoken.Claims claims) { + return MemberDataDto.MemberData.builder() + .sub(claims.getSubject()) + .email(claims.get("email", String.class)) + .build(); + } + public static Member toMember(MemberDataDto.MemberData MemberData, LoginType loginType) { return Member.builder() .email(MemberData.getEmail()) @@ -72,11 +84,4 @@ public static MemberLoginHistory toLoginHistory(Member member) { .loginTime(LocalDateTime.now()) .build(); } - - public static MemberDataDto.MemberData toAppleMemberData(Claims claims) { - return MemberDataDto.MemberData.builder() - .sub(claims.getSubject()) - .email(claims.get("email", String.class) != null ? claims.get("email", String.class) : "NONE") - .build(); - } -} +} \ No newline at end of file diff --git a/src/main/java/talkPick/global/security/model/WhiteList.java b/src/main/java/talkPick/global/security/model/WhiteList.java index 251bbcc..e4c98c0 100644 --- a/src/main/java/talkPick/global/security/model/WhiteList.java +++ b/src/main/java/talkPick/global/security/model/WhiteList.java @@ -8,7 +8,9 @@ private WhiteList() {} // 인스턴스화 방지 "/api/v1/admin/login", "/api/v1/members/kakao/login", "/api/v1/members/apple/login", + "/api/v1/members/google/login", "/api/v1/members/kakao/reactivate", + "/api/v1/members/google/reactivate", "/api/v1/members/apple/reactivate", "/api/v1/members/token/refresh", "/api/v1/inquiry", diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 6372f7a..a8adccf 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -70,6 +70,9 @@ kakao: apple: bundle-id: ${APPLE_BUNDLE_ID} +google: + client-id: ${GOOGLE_CLIENT_ID} + resilience4j: circuitbreaker: instances: From 25cfd6ae93904ebcb35c6e15c5adaf6e3fd65aee Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Wed, 31 Dec 2025 15:03:07 +0900 Subject: [PATCH 24/49] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EA=B0=9C=EB=B0=9C=20-=20#272?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/event/NoticeReadEventHandler.java | 3 +- .../event/TodayTopicSavedEventHandler.java | 3 +- .../out/event/TopicLikedEventHandler.java | 3 +- .../google/application/GoogleOidcService.java | 99 +++++++++++++++++++ .../google/port/in/GoogleOidcUsecase.java | 11 +++ .../exception/handler/GoogleHandler.java | 10 ++ 6 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/main/java/talkPick/external/google/application/GoogleOidcService.java create mode 100644 src/main/java/talkPick/external/google/port/in/GoogleOidcUsecase.java create mode 100644 src/main/java/talkPick/global/exception/handler/GoogleHandler.java diff --git a/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java b/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java index 8afd52e..88bd750 100644 --- a/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java +++ b/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -19,7 +20,7 @@ public class NoticeReadEventHandler { @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handle(NoticeReadEvent event) { try { noticeJpaRepository.findById(event.getNoticeId()) diff --git a/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java b/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java index 12884d3..91871c2 100644 --- a/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java +++ b/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -18,7 +19,7 @@ public class TodayTopicSavedEventHandler { @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handle(TodayTopicSavedEvent event) { try { if (event.getTodayTopics() != null && !event.getTodayTopics().isEmpty()) { diff --git a/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java b/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java index ff0292c..60a0e19 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -18,7 +19,7 @@ public class TopicLikedEventHandler { @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handle(TopicLikedEvent event) { try { topicStatCommandRepositoryPort.incrementLikeCount(event.getTopicId()); diff --git a/src/main/java/talkPick/external/google/application/GoogleOidcService.java b/src/main/java/talkPick/external/google/application/GoogleOidcService.java new file mode 100644 index 0000000..fe41860 --- /dev/null +++ b/src/main/java/talkPick/external/google/application/GoogleOidcService.java @@ -0,0 +1,99 @@ +package talkPick.external.google.application; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import talkPick.domain.member.adapter.in.dto.MemberReqDto; +import talkPick.domain.member.converter.MemberConverter; +import talkPick.domain.member.dto.MemberDataDto; +import talkPick.external.google.port.in.GoogleOidcUsecase; +import talkPick.global.exception.ErrorCode; +import talkPick.global.exception.handler.GoogleHandler; + +import java.math.BigInteger; +import java.net.URL; +import java.security.KeyFactory; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleOidcService implements GoogleOidcUsecase { + + // Google JWKS URL + private static final String JWK_URL = "https://www.googleapis.com/oauth2/v3/certs"; + + @Value("${google.client-id}") + private String CLIENT_ID; + + @Override + public MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRequest request) { + try { + String idToken = request.getIdToken(); + + // 1. 헤더 파싱해서 kid 찾기 + String[] parts = idToken.split("\\."); + if (parts.length != 3) throw new GoogleHandler(ErrorCode.INVALID_JWT_TOKEN); + + String headerJson = new String(Base64.getUrlDecoder().decode(parts[0])); + ObjectMapper mapper = new ObjectMapper(); + JsonNode header = mapper.readTree(headerJson); + String kid = header.get("kid").asText(); + + // 2. 구글 공개키 목록(JWKS) 가져와서 kid 일치하는 키 찾기 + JsonNode keys = mapper.readTree(new URL(JWK_URL)).get("keys"); + JsonNode matchedKey = null; + for (JsonNode key : keys) { + if (key.get("kid").asText().equals(kid)) { + matchedKey = key; + break; + } + } + if (matchedKey == null) throw new GoogleHandler(ErrorCode.ERROR_ON_VERIFYING); + + // 3. RSA Public Key 생성 + String n = matchedKey.get("n").asText(); + String e = matchedKey.get("e").asText(); + BigInteger modulus = new BigInteger(1, Base64.getUrlDecoder().decode(n)); + BigInteger exponent = new BigInteger(1, Base64.getUrlDecoder().decode(e)); + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec); + + // 4. 토큰 검증 + JwtParser parser = Jwts.parserBuilder() + .setSigningKey(publicKey) + .setAllowedClockSkewSeconds(300) + .build(); + + Claims claims = parser.parseClaimsJws(idToken).getBody(); + + // 5. aud (Client ID) 검증 및 iss 검증 + String aud = claims.getAudience(); + if (aud == null || !aud.equals(CLIENT_ID)) { + throw new GoogleHandler(ErrorCode.INVALID_JWT_TOKEN); + } + + String iss = claims.getIssuer(); + if (!"https://accounts.google.com".equals(iss) && !"accounts.google.com".equals(iss)) { + throw new GoogleHandler(ErrorCode.INVALID_JWT_TOKEN); + } + + return MemberConverter.toGoogleMemberData(claims); + + } catch (ExpiredJwtException e) { + throw new GoogleHandler(ErrorCode.EXPIRED_JWT_TOKEN); + } catch (Exception e) { + log.error("Google OAuth Error", e); + throw new GoogleHandler(ErrorCode.ERROR_ON_VERIFYING); + } + } +} diff --git a/src/main/java/talkPick/external/google/port/in/GoogleOidcUsecase.java b/src/main/java/talkPick/external/google/port/in/GoogleOidcUsecase.java new file mode 100644 index 0000000..a30b361 --- /dev/null +++ b/src/main/java/talkPick/external/google/port/in/GoogleOidcUsecase.java @@ -0,0 +1,11 @@ +package talkPick.external.google.port.in; + +import talkPick.domain.member.adapter.in.dto.MemberReqDto; +import talkPick.domain.member.dto.MemberDataDto; + +public interface GoogleOidcUsecase { + /** + * Google ID Token 검증 및 회원 정보 추출 + */ + MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRequest request); +} diff --git a/src/main/java/talkPick/global/exception/handler/GoogleHandler.java b/src/main/java/talkPick/global/exception/handler/GoogleHandler.java new file mode 100644 index 0000000..828c0ee --- /dev/null +++ b/src/main/java/talkPick/global/exception/handler/GoogleHandler.java @@ -0,0 +1,10 @@ +package talkPick.global.exception.handler; + +import talkPick.global.exception.ErrorCode; +import talkPick.global.exception.TalkPickException; + +public class GoogleHandler extends TalkPickException { + public GoogleHandler(ErrorCode errorCode) { + super(errorCode); + } +} From 765c3b4fa4b901c10d8aa983c0324ae7ed3fa61e Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 13:47:47 +0900 Subject: [PATCH 25/49] =?UTF-8?q?refactor:=20prod=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=97=90=20google=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8997e32..11e6214 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -68,6 +68,9 @@ kakao: apple: bundle-id: ${APPLE_BUNDLE_ID} +google: + client-id: ${GOOGLE_CLIENT_ID} + resilience4j: circuitbreaker: instances: From b8e201acf8a70017f33662e6ea459d0d4f85a88d Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 14:31:02 +0900 Subject: [PATCH 26/49] =?UTF-8?q?refactor:=20MemberCommandController?= =?UTF-8?q?=EC=97=90=20=EA=B2=BD=EB=A1=9C=20=EB=A7=A4=ED=95=91=20=EC=95=A0?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/adapter/in/MemberCommandController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java index 3e8ee48..55a743e 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java @@ -26,6 +26,7 @@ @RestController @RequiredArgsConstructor @Slf4j +@RequestMapping("/api/v1/members") public class MemberCommandController implements MemberCommandApi { private final KakaoOidcUsecase kakaoOidcService; private final AppleOidcUsecase appleOidcService; From 7adc28b0cdc6402ec0fb3c96f35b8a2129c9efba Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 14:40:53 +0900 Subject: [PATCH 27/49] =?UTF-8?q?refactor:=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EC=95=A0=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/adapter/in/MemberQueryController.java | 1 + .../domain/member/application/MemberCommandService.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberQueryController.java b/src/main/java/talkPick/domain/member/adapter/in/MemberQueryController.java index d53bb85..4634aca 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberQueryController.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberQueryController.java @@ -14,6 +14,7 @@ @RestController @RequiredArgsConstructor @Slf4j +@RequestMapping("/api/v1/members") public class MemberQueryController implements MemberQueryApi { private final MemberQueryUseCase memberQueryUseCase; diff --git a/src/main/java/talkPick/domain/member/application/MemberCommandService.java b/src/main/java/talkPick/domain/member/application/MemberCommandService.java index b6bb3a9..3b18ef6 100644 --- a/src/main/java/talkPick/domain/member/application/MemberCommandService.java +++ b/src/main/java/talkPick/domain/member/application/MemberCommandService.java @@ -119,7 +119,7 @@ public MemberResDto.MemberSignupResponse memberSignup(String authorization, Memb memberCommandRepositoryPort.save(findMember); // 소셜 로그인 회원 가입 완료 시 로그인 기록 저장 - if (findMember.getLoginType() == LoginType.KAKAO || findMember.getLoginType() == LoginType.APPLE) { + if (findMember.getLoginType() == LoginType.KAKAO || findMember.getLoginType() == LoginType.APPLE || findMember.getLoginType() == LoginType.GOOGLE) { MemberLoginHistory loginHistory = MemberConverter.toLoginHistory(findMember); memberLoginHistoryRepository.save(loginHistory); } From f23d60865ed56638ad367d6ad0dee42a90eb528b Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 15:02:20 +0900 Subject: [PATCH 28/49] =?UTF-8?q?build=20:=20deploy.yml=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a3390a0..35666f8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -67,6 +67,7 @@ jobs: EOF cd /home/${{ secrets.EC2_user }}/TalkPick_Server + docker compose down + docker rmi ${{ secrets.DOCKERHUB_USERNAME }}/talkpick-server:latest || true docker compose pull - docker compose down docker compose up -d From 12bf9b09f866305e2513b29f6f081d6ded2401fc Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 15:14:30 +0900 Subject: [PATCH 29/49] =?UTF-8?q?refactor:=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20permitAll=EC=97=90=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EA=B2=BD=EB=A1=9C=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/talkPick/global/security/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/talkPick/global/security/config/SecurityConfig.java b/src/main/java/talkPick/global/security/config/SecurityConfig.java index 3440907..0e72b4f 100644 --- a/src/main/java/talkPick/global/security/config/SecurityConfig.java +++ b/src/main/java/talkPick/global/security/config/SecurityConfig.java @@ -53,6 +53,7 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti .authorizeHttpRequests( authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry + .requestMatchers("/api/v1/members/google/login").permitAll() // 구글 로그인 경로 명시적 허용 (디버깅용) .requestMatchers(PATHS).permitAll() // whiteList는 인증 없이 접근 가능 .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() From 7444cd670c9f6d025f62fe6dc69aeb7fd26118ab Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 17:31:02 +0900 Subject: [PATCH 30/49] =?UTF-8?q?build:=20=EB=B9=8C=EB=93=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 27 +++++++++++++++---- .../security/config/SecurityConfig.java | 2 +- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 35666f8..3a56be3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,6 +54,15 @@ jobs: chmod -R 755 scripts chmod 644 docker-compose.yml + - name: SCP - Copy files to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "docker-compose.yml,nginx/" + target: "/home/${{ secrets.EC2_user }}/TalkPick_Server" + - name: AWS - Deploy to EC2 uses: appleboy/ssh-action@v1.0.3 with: @@ -61,13 +70,21 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - mkdir -p /home/${{ secrets.EC2_user }}/env - cat << 'EOF' > /home/${{ secrets.EC2_user }}/env/.env + TARGET_DIR="/home/${{ secrets.EC2_user }}/TalkPick_Server" + mkdir -p $TARGET_DIR/env + cat << 'EOF' > $TARGET_DIR/env/.env ${{ secrets.ENV }} EOF - cd /home/${{ secrets.EC2_user }}/TalkPick_Server + cd $TARGET_DIR + + # Stop current services docker compose down + + # Remove the specific app image and any unused data to ensure a clean state docker rmi ${{ secrets.DOCKERHUB_USERNAME }}/talkpick-server:latest || true - docker compose pull - docker compose up -d + docker system prune -f + + # Pull latest images and rebuild local ones (like nginx) + docker compose pull + docker compose up -d --build diff --git a/src/main/java/talkPick/global/security/config/SecurityConfig.java b/src/main/java/talkPick/global/security/config/SecurityConfig.java index 0e72b4f..6a47e29 100644 --- a/src/main/java/talkPick/global/security/config/SecurityConfig.java +++ b/src/main/java/talkPick/global/security/config/SecurityConfig.java @@ -53,7 +53,7 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti .authorizeHttpRequests( authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry - .requestMatchers("/api/v1/members/google/login").permitAll() // 구글 로그인 경로 명시적 허용 (디버깅용) + .requestMatchers("/api/v1/members/google/login").permitAll() .requestMatchers(PATHS).permitAll() // whiteList는 인증 없이 접근 가능 .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() From 4c5e55471a55dcd76f4f251291b0bc17f0060e77 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 17:33:52 +0900 Subject: [PATCH 31/49] =?UTF-8?q?build=20:=20deploy.yml=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a56be3..b197791 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,6 +54,17 @@ jobs: chmod -R 755 scripts chmod 644 docker-compose.yml + - name: AWS - Fix Permissions on EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + target_dir="/home/${{ secrets.EC2_user }}/TalkPick_Server" + sudo mkdir -p $target_dir + sudo chown -R $(whoami):$(whoami) $target_dir + - name: SCP - Copy files to EC2 uses: appleboy/scp-action@v0.1.7 with: From 84ec4f18c283d68eaea2a0815016fc9099eb55c5 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 17:45:57 +0900 Subject: [PATCH 32/49] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/adapter/in/MemberCommandApi.java | 2 +- .../apple/application/AppleOidcService.java | 21 +++++++++++++------ .../exception/handler/AppleHandler.java | 4 ++++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java index 4854d05..3e6ad66 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java @@ -17,7 +17,7 @@ public interface MemberCommandApi { JwtResDTO.Login kakaoOAuth2Login( @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); - @PatchMapping("/apple/login") + @PostMapping("/apple/login") @Operation(summary = "APPLE Oauth2 로그인 API", description = "APPLE OAuth2 로그인 API 입니다.") JwtResDTO.Login appleOauth2Login (@Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); diff --git a/src/main/java/talkPick/external/apple/application/AppleOidcService.java b/src/main/java/talkPick/external/apple/application/AppleOidcService.java index d23f7de..ac6bcad 100644 --- a/src/main/java/talkPick/external/apple/application/AppleOidcService.java +++ b/src/main/java/talkPick/external/apple/application/AppleOidcService.java @@ -58,8 +58,10 @@ public MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRe break; } } - if (matchedKey == null) throw new AppleHandler(ErrorCode.ERROR_ON_VERIFYING); - + if (matchedKey == null) { + log.error("Apple Matching Key not found for kid: {}", kid); + throw new AppleHandler(ErrorCode.ERROR_ON_VERIFYING, "애플 공개키 목록에서 kid가 일치하는 키를 찾을 수 없습니다. kid: " + kid); + } String n = matchedKey.get("n").asText(); String e = matchedKey.get("e").asText(); @@ -79,20 +81,27 @@ public MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRe Object audObj = claims.get("aud"); boolean audOk = false; + log.info("Apple BUNDLE_ID: {}, Token aud: {}", BUNDLE_ID, audObj); + if (audObj instanceof String audStr) { audOk = BUNDLE_ID.equals(audStr); } else if (audObj instanceof List audList) { audOk = audList.stream().anyMatch(a -> BUNDLE_ID.equals(String.valueOf(a))); } - if (!audOk) throw new AppleHandler(ErrorCode.INVALID_JWT_TOKEN); + + if (!audOk) { + log.error("Apple Audience mismatch. Expected: {}, Received: {}", BUNDLE_ID, audObj); + throw new AppleHandler(ErrorCode.INVALID_JWT_TOKEN, "애플 토큰의 aud(Audience)가 일치하지 않습니다. 기대값: " + BUNDLE_ID + ", 실제값: " + audObj); + } return MemberConverter.toAppleMemberData(claims); } catch (ExpiredJwtException e) { - throw new AppleHandler(ErrorCode.EXPIRED_JWT_TOKEN); + log.error("Apple Token Expired", e); + throw new AppleHandler(ErrorCode.EXPIRED_JWT_TOKEN, "애플 토큰이 만료되었습니다: " + e.getMessage()); } catch (Exception e) { - log.error("Apple OAuth Error", e); - throw new AppleHandler(ErrorCode.ERROR_ON_VERIFYING); + log.error("Apple OAuth Error: {}", e.getMessage(), e); + throw new AppleHandler(ErrorCode.ERROR_ON_VERIFYING, "애플 토큰 검증 중 오류가 발생했습니다: " + e.getMessage()); } } } \ No newline at end of file diff --git a/src/main/java/talkPick/global/exception/handler/AppleHandler.java b/src/main/java/talkPick/global/exception/handler/AppleHandler.java index 1da65cf..ae80bdc 100644 --- a/src/main/java/talkPick/global/exception/handler/AppleHandler.java +++ b/src/main/java/talkPick/global/exception/handler/AppleHandler.java @@ -7,4 +7,8 @@ public class AppleHandler extends TalkPickException { public AppleHandler(ErrorCode errorCode) { super(errorCode); } + + public AppleHandler(ErrorCode errorCode, String message) { + super(errorCode, message); + } } \ No newline at end of file From c5647f7b27f57aaea1bf93a0c3443a23db9781ab Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 17:49:57 +0900 Subject: [PATCH 33/49] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/GlobalExceptionHandler.java | 6 +++++- src/main/java/talkPick/global/response/ApiResponse.java | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/talkPick/global/exception/GlobalExceptionHandler.java b/src/main/java/talkPick/global/exception/GlobalExceptionHandler.java index 13f09a6..ce42dd9 100644 --- a/src/main/java/talkPick/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/talkPick/global/exception/GlobalExceptionHandler.java @@ -27,10 +27,14 @@ public class GlobalExceptionHandler { // 커스텀 예외 처리 @ExceptionHandler(TalkPickException.class) public ResponseEntity> talkPickExceptionHandler(final TalkPickException e) { + String message = e.getMessage(); + if (message == null || message.isBlank()) { + message = e.getErrorCode().getMessage(); + } return ResponseEntity .status(e.getErrorCode().getStatus()) .headers(jsonHeaders) - .body(ApiResponse.ofErrorCode(e.getErrorCode())); + .body(ApiResponse.ofErrorCode(e.getErrorCode(), message)); } // 유효성 검사 실패 예외 처리 diff --git a/src/main/java/talkPick/global/response/ApiResponse.java b/src/main/java/talkPick/global/response/ApiResponse.java index f3cbd12..f96cbf5 100644 --- a/src/main/java/talkPick/global/response/ApiResponse.java +++ b/src/main/java/talkPick/global/response/ApiResponse.java @@ -71,6 +71,15 @@ public static ApiResponse ofErrorCode(ErrorCode errorCode) { .build(); } + public static ApiResponse ofErrorCode(ErrorCode errorCode, String message) { + return ApiResponse.builder() + .status("FAIL") + .message(message) + .timestamp(LocalDateTime.now()) + .httpStatus(errorCode.getStatus().value()) + .build(); + } + public static ApiResponse ofErrorCode(ErrorCode errorCode, T data) { return ApiResponse.builder() .status("FAIL") From 763665aa244f7ba422da51a04a0f80cbb6f8fb3b Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 18:00:54 +0900 Subject: [PATCH 34/49] =?UTF-8?q?build:=20=EB=B9=8C=EB=93=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b197791..72680da 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -82,7 +82,12 @@ jobs: key: ${{ secrets.EC2_SSH_KEY }} script: | TARGET_DIR="/home/${{ secrets.EC2_user }}/TalkPick_Server" + + # Ensure env directory exists and clear old .env mkdir -p $TARGET_DIR/env + rm -f $TARGET_DIR/env/.env + + # Write new .env file cat << 'EOF' > $TARGET_DIR/env/.env ${{ secrets.ENV }} EOF From 0204f9f6d57d151e5c9fa9fe02b58347afd0f299 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 18:15:06 +0900 Subject: [PATCH 35/49] =?UTF-8?q?build:=20=EB=B9=8C=EB=93=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/talkPick/global/security/model/WhiteList.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/talkPick/global/security/model/WhiteList.java b/src/main/java/talkPick/global/security/model/WhiteList.java index e4c98c0..ef38569 100644 --- a/src/main/java/talkPick/global/security/model/WhiteList.java +++ b/src/main/java/talkPick/global/security/model/WhiteList.java @@ -23,3 +23,4 @@ private WhiteList() {} // 인스턴스화 방지 "/actuator/health/**" }; } + From a6df43e90adfe48069783b597ee27d2bd965f044 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 18:38:17 +0900 Subject: [PATCH 36/49] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=88=84=EB=A5=B8=20=ED=86=A0?= =?UTF-8?q?=ED=94=BD=20id=20=EC=88=98=EC=A0=95=20-=20#278?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../talkPick/domain/member/adapter/out/dto/MemberResDto.java | 2 +- .../out/repository/MemberLikedTopicsQuerydslRepository.java | 2 +- .../talkPick/domain/member/application/MemberQueryService.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/talkPick/domain/member/adapter/out/dto/MemberResDto.java b/src/main/java/talkPick/domain/member/adapter/out/dto/MemberResDto.java index 816305c..8205739 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/dto/MemberResDto.java +++ b/src/main/java/talkPick/domain/member/adapter/out/dto/MemberResDto.java @@ -56,7 +56,7 @@ public static class MemberTopicResultResDto { @AllArgsConstructor @NoArgsConstructor public static class MemberLikedTopicResDto { - private Long id; // 좋아요 누른 토픽 id (TopicLikeHistory 테이블) + private Long topicId; // 좋아요 누른 토픽 id (Topic 테이블) private String title; //토픽 주제 (Topic 테이블) private String keyword; //키워드 (Topickeyword 테이블) private Category category; //카테고리 (TopicCategory 테이블) diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLikedTopicsQuerydslRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLikedTopicsQuerydslRepository.java index 8de96e8..957224f 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLikedTopicsQuerydslRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLikedTopicsQuerydslRepository.java @@ -41,7 +41,7 @@ public List findMemberLikedTopics(Member me // size + 1개를 조회하여 다음 페이지 존재 여부 확인 return queryFactory .select(Projections.constructor(MemberResDto.MemberLikedTopicResDto.class, - tlh.id, + t.id, // 토픽 ID t.title, // 토픽 주제 (String) k.name, // 키워드 (Keyword) c, // 카테고리 (Category) diff --git a/src/main/java/talkPick/domain/member/application/MemberQueryService.java b/src/main/java/talkPick/domain/member/application/MemberQueryService.java index 3f25603..50c3985 100644 --- a/src/main/java/talkPick/domain/member/application/MemberQueryService.java +++ b/src/main/java/talkPick/domain/member/application/MemberQueryService.java @@ -62,7 +62,7 @@ public CursorPageResponse getMemberLikedTop CursorPageResponse.Cursor nextCursor = null; if (hasNext && !memberLikedTopics.isEmpty()) { MemberResDto.MemberLikedTopicResDto last = memberLikedTopics.get(memberLikedTopics.size() - 1); - nextCursor = new CursorPageResponse.Cursor(last.getCreatedDate(), last.getId()); + nextCursor = new CursorPageResponse.Cursor(last.getCreatedDate(), last.getTopicId()); } // 커서 기반 페이징 응답 반환 From 74e557e97fc93f91c9258d4b284c7d07211f62e3 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Thu, 1 Jan 2026 18:52:59 +0900 Subject: [PATCH 37/49] =?UTF-8?q?refactor:=20appleOidcService=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../talkPick/external/apple/application/AppleOidcService.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/talkPick/external/apple/application/AppleOidcService.java b/src/main/java/talkPick/external/apple/application/AppleOidcService.java index ac6bcad..80b9468 100644 --- a/src/main/java/talkPick/external/apple/application/AppleOidcService.java +++ b/src/main/java/talkPick/external/apple/application/AppleOidcService.java @@ -8,7 +8,6 @@ import io.jsonwebtoken.Jwts; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import talkPick.domain.member.adapter.in.dto.MemberReqDto; import talkPick.domain.member.converter.MemberConverter; @@ -34,8 +33,7 @@ public class AppleOidcService implements AppleOidcUsecase { private static final String JWK_URL = "https://appleid.apple.com/auth/keys"; // iss 검증 값 private static final String ISSUER = "https://appleid.apple.com"; - @Value("${apple.bundle-id}") - private String BUNDLE_ID; + private static final String BUNDLE_ID = "io.tuist.TalkPick"; @Override public MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRequest request) { From 690dec7d4f5987cb617e5d2e60fb5fcf221129c8 Mon Sep 17 00:00:00 2001 From: Hszoo Date: Fri, 2 Jan 2026 14:05:15 +0900 Subject: [PATCH 38/49] =?UTF-8?q?Deploy:=20github=20actions=20deploy.yml?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 41 ++--------- appspec.yml | 26 ------- docker-compose.yml | 11 +-- prometheus.yml | 24 ------ scripts/start-server.sh | 21 ------ scripts/stop-server.sh | 7 -- src/main/resources/application-local.yml | 94 ------------------------ src/main/resources/application-prod.yml | 92 ----------------------- src/main/resources/application.yml | 93 ++++++++++++++++++++++- 9 files changed, 101 insertions(+), 308 deletions(-) delete mode 100644 appspec.yml delete mode 100644 prometheus.yml delete mode 100644 scripts/start-server.sh delete mode 100644 scripts/stop-server.sh delete mode 100644 src/main/resources/application-local.yml delete mode 100644 src/main/resources/application-prod.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72680da..742fe14 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,11 +18,6 @@ jobs: distribution: temurin java-version: 21 - - name: CLEAN - Ensure no prod properties exist - run: | - sudo rm -f src/main/resources/application-prod.properties - find src/main/resources -name "application-prod.properties" || true - - name: BUILD - Run Test & Build JAR run: | ./gradlew clean build @@ -61,19 +56,10 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - target_dir="/home/${{ secrets.EC2_user }}/TalkPick_Server" + target_dir="/home/${{ secrets.EC2_USER }}/TalkPick_Server" sudo mkdir -p $target_dir sudo chown -R $(whoami):$(whoami) $target_dir - - name: SCP - Copy files to EC2 - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - source: "docker-compose.yml,nginx/" - target: "/home/${{ secrets.EC2_user }}/TalkPick_Server" - - name: AWS - Deploy to EC2 uses: appleboy/ssh-action@v1.0.3 with: @@ -81,26 +67,11 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - TARGET_DIR="/home/${{ secrets.EC2_user }}/TalkPick_Server" - - # Ensure env directory exists and clear old .env - mkdir -p $TARGET_DIR/env - rm -f $TARGET_DIR/env/.env - - # Write new .env file - cat << 'EOF' > $TARGET_DIR/env/.env + mkdir -p /home/${{ secrets.EC2_user }}/TalkPick_Server/env + cat << 'EOF' > /home/${{ secrets.EC2_user }}/TalkPick_Server/env/.env ${{ secrets.ENV }} EOF - cd $TARGET_DIR - - # Stop current services - docker compose down - - # Remove the specific app image and any unused data to ensure a clean state - docker rmi ${{ secrets.DOCKERHUB_USERNAME }}/talkpick-server:latest || true - docker system prune -f - - # Pull latest images and rebuild local ones (like nginx) - docker compose pull - docker compose up -d --build + cd /home/${{ secrets.EC2_user }}/TalkPick_Server + docker compose pull + docker compose up -d diff --git a/appspec.yml b/appspec.yml deleted file mode 100644 index 2b2f399..0000000 --- a/appspec.yml +++ /dev/null @@ -1,26 +0,0 @@ ---- -version: 0.0 -os: linux - -files: - - source: / - destination: /home/ec2-user/TalkPick_Server - overwrite: yes - -permissions: - - object: /home/ec2-user/TalkPick_Server - pattern: "**" - owner: ec2-user - group: ec2-user - mode: "755" - -hooks: - ApplicationStop: - - location: scripts/stop-server.sh - timeout: 60 - runas: ec2-user - - ApplicationStart: - - location: scripts/start-server.sh - timeout: 60 - runas: ec2-user \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 59e0e19..e1fad02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,15 @@ services: - ## talkpick + ## talkpick server talkpick-server: image: hszoo/talkpick-server:latest container_name: talkpick-server - ports: - - "8080:8080" networks: - t4y + ports: + - "8080:8080" restart: always env_file: - - /home/ec2-user/env/.env - environment: - SPRING_PROFILES_ACTIVE: prod - SPRING_CONFIG_ADDITIONAL_LOCATION: file:/config/ + - /home/ec2-user/TalkPick_Server/env/.env volumes: - /home/ec2-user/config:/config diff --git a/prometheus.yml b/prometheus.yml deleted file mode 100644 index ecd28a3..0000000 --- a/prometheus.yml +++ /dev/null @@ -1,24 +0,0 @@ -global: - scrape_interval: 15s - scrape_timeout: 15s - evaluation_interval: 2m - - external_labels: - monitor: 'codelab-monitor' - query_log_file: query_log_file.log - -scrape_configs: - - job_name: 'monitoring-item' - scrape_interval: 10s - scrape_timeout: 10s - metrics_path: '/metrics' - scheme: 'http' - - static_configs: - - targets: [ - 'prometheus:9090', - 'node_exporter:9100', - 'localhost:8080' - ] - labels: - service: 'monitor' \ No newline at end of file diff --git a/scripts/start-server.sh b/scripts/start-server.sh deleted file mode 100644 index c7b0d82..0000000 --- a/scripts/start-server.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -set -e - -echo "--------------- START : Talkpick Server Deploy -----------------" -cd /home/ec2-user/TalkPick_Server - -echo "📂 현재 디렉토리: $(pwd)" -echo "📄 파일 목록:" -ls -al - -echo "🔐 ECR 로그인" -aws ecr get-login-password --region ap-northeast-2 \ - | docker login --username AWS --password-stdin 718513646976.dkr.ecr.ap-northeast-2.amazonaws.com - -echo "⏸️ 실행 중인 컨테이너 중지" -docker compose down || true - -echo "▶️ 컨테이너 실행" -docker compose up -d - -echo "✅ 서버 배포 완료" \ No newline at end of file diff --git a/scripts/stop-server.sh b/scripts/stop-server.sh deleted file mode 100644 index e761b80..0000000 --- a/scripts/stop-server.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -# 컨테이너 stop -docker stop talkpick-server || true -docker rm talkpick-server || true - -docker pull 718513646976.dkr.ecr.ap-northeast-2.amazonaws.com/talkpick-server:latest \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml deleted file mode 100644 index a8adccf..0000000 --- a/src/main/resources/application-local.yml +++ /dev/null @@ -1,94 +0,0 @@ -jwt: - secret: ${JWT_SECRET} - accessTokenExpireTime: ${JWT_ACCESS_EXPIRE_TIME} - refreshTokenExpireTime: ${JWT_REFRESH_EXPIRE_TIME} - -server: - port: ${SERVER_PORT} - tomcat: - threads: - max: ${THREADS_MAX} - accept-count: ${ACCEPT_COUNT} - -spring: - autoconfigure: - exclude: org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration - config: - import: optional:file:.env[.properties] - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} - username: ${DB_USER} - password: ${DB_PASSWORD} - hikari: - connection-timeout: ${CONNECTION_TIMEOUT} - maximum-pool-size: ${MAXIMUM_POOL_SIZE} - idle-timeout: ${IDLE_TIMEOUT} - max-lifetime: ${MAX_LIFETIME} - jpa: - show-sql: true - properties: - hibernate: - format_sql: true - database-platform: org.hibernate.dialect.MySQL8Dialect - hibernate: - ddl-auto: update - servlet: - multipart: - max-file-size: ${MAX_FILE_SIZE} - max-request-size: ${MAX_REQUEST_SIZE} - messages: - basename: messages,errors - mvc: - throw-exception-if-no-handler-found: true - web: - resources: - add-mappings: false - -logging: - level: - root: info - org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace - org.springframework.web.cors: debug -# org.apache.coyote.http11: trace - -management: - endpoint: - health: - show-details: always - endpoints: - web: - exposure: - include: health, prometheus - -kakao: - client-id: ${KAKAO_CLIENT_ID} - redirect-uri: ${KAKAO_REDIRECT_URI} - response-type: ${KAKAO_RESPONSE_TYPE} - -apple: - bundle-id: ${APPLE_BUNDLE_ID} - -google: - client-id: ${GOOGLE_CLIENT_ID} - -resilience4j: - circuitbreaker: - instances: - llm: - registerHealthIndicator: true - slidingWindowSize: 10 - failureRateThreshold: 50 - waitDurationInOpenState: 10s - -healthcheck: - url: ${HEALTH_CHECK_URL} - -jasypt: - admin: - secret-key: ${JASYPT_ADMIN_SECRET_KEY} - member: - secret-key: ${JASYPT_MEMBER_SECRET_KEY} - algorithm: ${JASYPT_ALGORITHM:PBEWITHHMACSHA512ANDAES_256} - pool-size: ${JASYPT_POOL_SIZE:1} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml deleted file mode 100644 index 11e6214..0000000 --- a/src/main/resources/application-prod.yml +++ /dev/null @@ -1,92 +0,0 @@ -jwt: - secret: ${JWT_SECRET} - accessTokenExpireTime: ${JWT_ACCESS_EXPIRE_TIME} - refreshTokenExpireTime: ${JWT_REFRESH_EXPIRE_TIME} - -server: - port: ${SERVER_PORT} - tomcat: - threads: - max: ${THREADS_MAX} - accept-count: ${ACCEPT_COUNT} - -spring: - autoconfigure: - exclude: org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} - username: ${DB_USER} - password: ${DB_PASSWORD} - hikari: - connection-timeout: ${CONNECTION_TIMEOUT} - maximum-pool-size: ${MAXIMUM_POOL_SIZE} - idle-timeout: ${IDLE_TIMEOUT} - max-lifetime: ${MAX_LIFETIME} - jpa: - show-sql: true - properties: - hibernate: - format_sql: true - database-platform: org.hibernate.dialect.MySQL8Dialect - hibernate: - ddl-auto: update - servlet: - multipart: - max-file-size: ${MAX_FILE_SIZE} - max-request-size: ${MAX_REQUEST_SIZE} - messages: - basename: messages,errors - mvc: - throw-exception-if-no-handler-found: true - web: - resources: - add-mappings: false - -logging: - level: - root: info - org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace - org.springframework.web.cors: debug -# org.apache.coyote.http11: trace - -management: - endpoint: - health: - show-details: always - endpoints: - web: - exposure: - include: health, prometheus - -kakao: - client-id: ${KAKAO_CLIENT_ID} - redirect-uri: ${KAKAO_REDIRECT_URI} - response-type: ${KAKAO_RESPONSE_TYPE} - -apple: - bundle-id: ${APPLE_BUNDLE_ID} - -google: - client-id: ${GOOGLE_CLIENT_ID} - -resilience4j: - circuitbreaker: - instances: - llm: - registerHealthIndicator: true - slidingWindowSize: 10 - failureRateThreshold: 50 - waitDurationInOpenState: 10s - -healthcheck: - url: ${HEALTH_CHECK_URL} - -jasypt: - admin: - secret-key: ${JASYPT_ADMIN_SECRET_KEY} - member: - secret-key: ${JASYPT_MEMBER_SECRET_KEY} - algorithm: ${JASYPT_ALGORITHM:PBEWITHHMACSHA512ANDAES_256} - pool-size: ${JASYPT_POOL_SIZE:1} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d74c444..11e6214 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,92 @@ +jwt: + secret: ${JWT_SECRET} + accessTokenExpireTime: ${JWT_ACCESS_EXPIRE_TIME} + refreshTokenExpireTime: ${JWT_REFRESH_EXPIRE_TIME} + +server: + port: ${SERVER_PORT} + tomcat: + threads: + max: ${THREADS_MAX} + accept-count: ${ACCEPT_COUNT} + spring: - profiles: - active: local + autoconfigure: + exclude: org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} + username: ${DB_USER} + password: ${DB_PASSWORD} + hikari: + connection-timeout: ${CONNECTION_TIMEOUT} + maximum-pool-size: ${MAXIMUM_POOL_SIZE} + idle-timeout: ${IDLE_TIMEOUT} + max-lifetime: ${MAX_LIFETIME} + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + database-platform: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: update + servlet: + multipart: + max-file-size: ${MAX_FILE_SIZE} + max-request-size: ${MAX_REQUEST_SIZE} + messages: + basename: messages,errors + mvc: + throw-exception-if-no-handler-found: true + web: + resources: + add-mappings: false + +logging: + level: + root: info + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + org.springframework.web.cors: debug +# org.apache.coyote.http11: trace + +management: + endpoint: + health: + show-details: always + endpoints: + web: + exposure: + include: health, prometheus + +kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URI} + response-type: ${KAKAO_RESPONSE_TYPE} + +apple: + bundle-id: ${APPLE_BUNDLE_ID} + +google: + client-id: ${GOOGLE_CLIENT_ID} + +resilience4j: + circuitbreaker: + instances: + llm: + registerHealthIndicator: true + slidingWindowSize: 10 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + +healthcheck: + url: ${HEALTH_CHECK_URL} + +jasypt: + admin: + secret-key: ${JASYPT_ADMIN_SECRET_KEY} + member: + secret-key: ${JASYPT_MEMBER_SECRET_KEY} + algorithm: ${JASYPT_ALGORITHM:PBEWITHHMACSHA512ANDAES_256} + pool-size: ${JASYPT_POOL_SIZE:1} From 00d63f0ea48aa3e5523fdebcff7f47c28c5f5f1b Mon Sep 17 00:00:00 2001 From: Hszoo Date: Fri, 2 Jan 2026 14:16:01 +0900 Subject: [PATCH 39/49] Deploy: fix the env file path --- .github/workflows/deploy.yml | 12 +++++++----- docker-compose.yml | 4 +--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 742fe14..84a9465 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,7 +30,7 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: DOCKER - Build Docker Image - run: docker build --no-cache -t talkpick-server:latest . + run: docker build -t talkpick-server:latest . - name: DOCKER_HUB - Login to Docker Hub run: | @@ -67,11 +67,13 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - mkdir -p /home/${{ secrets.EC2_user }}/TalkPick_Server/env - cat << 'EOF' > /home/${{ secrets.EC2_user }}/TalkPick_Server/env/.env + mkdir -p /home/${{ secrets.EC2_USER }}/TalkPick_Server/env + cd /home/${{ secrets.EC2_USER }}/TalkPick_Server + + cat << 'EOF' > .env ${{ secrets.ENV }} EOF - cd /home/${{ secrets.EC2_user }}/TalkPick_Server - docker compose pull + docker compose pull talkpick-server + docker compose build nginx docker compose up -d diff --git a/docker-compose.yml b/docker-compose.yml index e1fad02..44ddbda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,7 @@ services: - "8080:8080" restart: always env_file: - - /home/ec2-user/TalkPick_Server/env/.env - volumes: - - /home/ec2-user/config:/config + - ./.env ## nginx nginx: From 82bf3c0c0f10c0daf4667841aef90bba484fc16b Mon Sep 17 00:00:00 2001 From: Hszoo Date: Fri, 2 Jan 2026 14:22:45 +0900 Subject: [PATCH 40/49] Deploy: fix the env file path --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 84a9465..beb98ba 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -70,7 +70,7 @@ jobs: mkdir -p /home/${{ secrets.EC2_USER }}/TalkPick_Server/env cd /home/${{ secrets.EC2_USER }}/TalkPick_Server - cat << 'EOF' > .env + cat << 'EOF' > /env/.env ${{ secrets.ENV }} EOF From 2ebe37f8757186ea986396e4a95f68502f57e0b8 Mon Sep 17 00:00:00 2001 From: Hszoo Date: Fri, 2 Jan 2026 14:26:26 +0900 Subject: [PATCH 41/49] Deploy: fix the env file path --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index beb98ba..0484017 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -70,7 +70,7 @@ jobs: mkdir -p /home/${{ secrets.EC2_USER }}/TalkPick_Server/env cd /home/${{ secrets.EC2_USER }}/TalkPick_Server - cat << 'EOF' > /env/.env + cat << 'EOF' > ./env/.env ${{ secrets.ENV }} EOF From 226920db207cc1c3c4f9d9019fa689e43e5b0d56 Mon Sep 17 00:00:00 2001 From: Seong Ju Hong <97530721+Hszoo@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:27:02 +0900 Subject: [PATCH 42/49] [Merge] update deploy configurations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor: deploy workflow Updated the deployment workflow to include new branch and streamlined steps for Docker Hub integration and EC2 deployment. * Fix: Docker Hub login env var * Fix: talkpick-server image and env file path Updated the image for talkpick-server and modified environment configuration. * Refactor CI flow Updated deployment script to use Docker Compose for managing containers and added environment variable handling. * Remove: adminer from nginx service dependencies Removed adminer service dependency from nginx. * Change active profile from 'prod' to 'local' * Remove CONFIG step for application-prod.properties Removed step to create application-prod.properties file. * Update Docker build command to use latest tag * Add production configuration for application * Remove .env properties import from application-prod.yml Removed the optional import of .env properties. * Test: Ensure no prod properties exist before build Add step to clean up production properties before build * Update docker-compose.yml * Change deployment branch from 'deploy/265' to 'staging' * refactor: 회원 복구 API 명세 추가 - #267 * refactor: 회원 소프트 삭제 후 하드 삭제를 위한 스케줄러 설정 - #267 * refactor: 회원 복구 API 화이트리스트 추가 - #267 * refactor: 회원 삭제 시 소프트 삭제 방식으로 변경 - #267 * refactor: 회원 삭제 스케줄러 동작 시 스레드 풀 설정 - #267 * feat: Add event handler for TopicLike - #260 * refactor: Apply event-driven architecture with transactional safety - #260 * test: Add Notice test code - #260 * test: Add test code about Random/Topic/Today - #260 * test: Add exception and boundary value test cases - #260 * feat: 로그인 타입에 GOOGLE 추가 - #272 * feat: 구글 소셜 로그인 개발 - #272 * feat: 구글 소셜 로그인 에러 핸들러 개발 - #272 * refactor: prod 설정에 google 설정 추가 * build : deploy.yml 임시 수정 * refactor: MemberCommandController에 경로 매핑 애노테이션 추가 * refactor: 멤버 컨트롤러 경로 매핑 애노테이션 추가 * refactor: 구글 로그인 permitAll에 직접 경로 명시 * build: 빌드 파일 수정 * build : deploy.yml 임시 수정 * refactor: 에러메시지 추가 * refactor: 에러메시지 추가 * build: 빌드 파일 수정 * build: 빌드 파일 수정 * refactor: appleOidcService 수정 * refactor: 회원 좋아요 누른 토픽 id 수정 - #278 * Deploy: github actions deploy.yml 수정 * Deploy: fix the env file path * Deploy: fix the env file path * Deploy: fix the env file path * update deploy.yml (Deploy to EC2 configuration) * update docker-compose.yml * update deploy.yml * update docker-compose.yml * update nginx config * update entrypoint.sh * update entrypoint.sh * update nginx configurations * update application.yml * update nginx configurations * update nginx configurations * update nginx configurations * update nginx conf * update nginx static index.html file path * remove the nginx folder * change the push branch --------- Co-authored-by: simhyunmin Co-authored-by: Zetty --- .github/workflows/deploy.yml | 14 +++++++++++--- docker-compose.yml | 8 +++----- nginx/Dockerfile | 9 --------- nginx/conf/default.conf | 23 ----------------------- nginx/entrypoint.sh | 11 ----------- src/main/resources/application.yml | 1 + 6 files changed, 15 insertions(+), 51 deletions(-) delete mode 100644 nginx/Dockerfile delete mode 100644 nginx/conf/default.conf delete mode 100644 nginx/entrypoint.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0484017..baf3907 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -60,6 +60,15 @@ jobs: sudo mkdir -p $target_dir sudo chown -R $(whoami):$(whoami) $target_dir + - name: SCP - Copy docker-compose.yml to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "docker-compose.yml" + target: "/home/${{ secrets.EC2_USER }}/TalkPick_Server" + - name: AWS - Deploy to EC2 uses: appleboy/ssh-action@v1.0.3 with: @@ -68,12 +77,11 @@ jobs: key: ${{ secrets.EC2_SSH_KEY }} script: | mkdir -p /home/${{ secrets.EC2_USER }}/TalkPick_Server/env - cd /home/${{ secrets.EC2_USER }}/TalkPick_Server - - cat << 'EOF' > ./env/.env + cat << 'EOF' > /home/${{ secrets.EC2_USER }}/TalkPick_Server/env/.env ${{ secrets.ENV }} EOF + cd /home/${{ secrets.EC2_USER }}/TalkPick_Server docker compose pull talkpick-server docker compose build nginx docker compose up -d diff --git a/docker-compose.yml b/docker-compose.yml index 44ddbda..d9b3b31 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: - "8080:8080" restart: always env_file: - - ./.env + - /home/ec2-user/TalkPick_Server/env/.env ## nginx nginx: @@ -21,13 +21,11 @@ services: ports: - "443:443" - "80:80" - environment: - ORIGIN_CERTIFICATE: "${ORIGIN_CERTIFICATE}" - PRIVATE_CERTIFICATE_KEY: "${PRIVATE_CERTIFICATE_KEY}" depends_on: - talkpick-server volumes: - ./nginx/conf:/etc/nginx/conf.d - - ./nginx/html:/usr/share/nginx/html + - ./nginx/html/terms/privacy-policy:/usr/share/nginx/html/docs:ro + - /etc/ssl/cloudflare:/etc/ssl/cloudflare:ro networks: t4y: diff --git a/nginx/Dockerfile b/nginx/Dockerfile deleted file mode 100644 index 1472fa2..0000000 --- a/nginx/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM nginx:stable-alpine - -# Nginx 설정 복사 -COPY conf /etc/nginx/conf.d -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -# 컨테이너 시작 시 entrypoint.sh 실행 -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/nginx/conf/default.conf b/nginx/conf/default.conf deleted file mode 100644 index d48c3a0..0000000 --- a/nginx/conf/default.conf +++ /dev/null @@ -1,23 +0,0 @@ -server { - listen 443 ssl; - server_name talkpick.co.kr *.talkpick.co.kr; - - ssl_certificate /etc/ssl/cloudflare/origin.crt; - ssl_certificate_key /etc/ssl/cloudflare/origin.key; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers on; - - location / { - proxy_pass http://talkpick-server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} - -server { - listen 80; - server_name talkpick.co.kr *.talkpick.co.kr; - return 301 https://$host$request_uri; -} \ No newline at end of file diff --git a/nginx/entrypoint.sh b/nginx/entrypoint.sh deleted file mode 100644 index 3b80227..0000000 --- a/nginx/entrypoint.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -set -e - -# Secret 환경변수를 파일로 변환 -mkdir -p /etc/ssl/cloudflare -echo "$ORIGIN_CERTIFICATE" > /etc/ssl/cloudflare/origin.crt -echo "$PRIVATE_CERTIFICATE_KEY" > /etc/ssl/cloudflare/origin.key -chmod 600 /etc/ssl/cloudflare/origin.key - -# Nginx 실행 -nginx -g 'daemon off;' \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 11e6214..b51678d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,6 +4,7 @@ jwt: refreshTokenExpireTime: ${JWT_REFRESH_EXPIRE_TIME} server: + forward-headers-strategy: framework port: ${SERVER_PORT} tomcat: threads: From 35010f28e000760426cd97a49c871fd92178c564 Mon Sep 17 00:00:00 2001 From: Zetty Date: Sun, 4 Jan 2026 11:02:22 +0900 Subject: [PATCH 43/49] docs: Update release.md - #283 --- .github/PULL_REQUEST_TEMPLATE/release.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md index 9bd2d82..c074881 100644 --- a/.github/PULL_REQUEST_TEMPLATE/release.md +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -4,16 +4,5 @@ 이번 PR은 **[브랜치A] → [브랜치B]** 병합을 위한 릴리즈 PR입니다. -### 📄 변경 요약 -- 스프린트 기능 묶음 반영 -- 주요 변경사항 하이라이트 (선택) - -### 🛠 스키마 / 환경 변수 변경 -- 없음 - -### 🧪 테스트 / QA -- smoke test 완료 -- staging QA 완료 (해당 시) - ### ⚠️ 참고 사항 \ No newline at end of file From cc30195ba5999f12b0067b4e3d42137b81f26d08 Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Sun, 4 Jan 2026 14:31:40 +0900 Subject: [PATCH 44/49] =?UTF-8?q?docs:=20=ED=86=A0=ED=94=BD=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B5=AC=EC=A1=B0=20=EB=AC=B8=EC=84=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20-=20#285?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- STRUCTURE.md | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 STRUCTURE.md diff --git a/STRUCTURE.md b/STRUCTURE.md new file mode 100644 index 0000000..edb6bd7 --- /dev/null +++ b/STRUCTURE.md @@ -0,0 +1,128 @@ +# TalkPick 서버 구조 + +## 개요 +TalkPick 서버는 스몰토크 주제 추천 및 사용자 성향(MBTI 등) 기반 통계 서비스를 제공하는 서비스 +헥사고날 아키텍처(Ports & Adapters)를 기반으로 도메인 중심 설계를 따름 + +## 1. Global (공통 인프라 및 설정) + +### `talkPick.global.config` +* **`AsyncConfig`**: 비동기 작업 처리 설정 (`@EnableAsync`) +* **`CacheConfig`**: 로컬 캐싱 설정 (Caffeine 등) +* **`CorsFilter`**: CORS 정책 설정 (허용 출처, 헤더 등) +* **`JacksonConfig`**: JSON 직렬화/역직렬화 설정 +* **`JasyptConfig`**: 설정 파일 암호화 지원 +* **`JpaAuditingConfig`**: JPA 엔티티 자동 감시 (`BaseTime` 등) 활성화 +* **`QuerydslConfig`**: QueryDSL `JPAQueryFactory` 빈 등록 +* **`SchedulingConfig`**: 스케줄링 작업 활성화 (`@EnableScheduling`) +* **`SpringDocOpenApiConfig`**: Swagger/OpenAPI 문서 설정 +* **`WebConfig`**: 웹 MVC 설정 (ArgumentResolver 등 등록) + +### `talkPick.global.security` +* **Config** + * `SecurityConfig`: Spring Security 체인 설정. CSRF/FormLogin 비활성화, JWT 필터 추가, WhiteList 기반 경로 허용 +* **Filter** + * `JwtAuthenticationFilter`: JWT 토큰 파싱 및 유효성 검증, `SecurityContext`에 인증 정보 설정 + * `ExceptionHandlerFilter`: 필터 체인 내 예외 포착 및 핸들링 +* **JWT** + * `JwtProvider`: 토큰 생성, 검증, 정보 추출 (MemberId, Role) + * `JwtTokenCommandService`: 액세스/리프레시 토큰 발급 및 재발급 + * `RefreshTokenRepository`: 리프레시 토큰 저장소 +* **Resolver** + * `MemberIdArgumentResolver`: 컨트롤러 파라미터 `@MemberId`를 통해 인증된 사용자 ID 주입 + +### `talkPick.global.exception` +* **Handler**: `GlobalExceptionHandler` 및 도메인별 핸들러 (`MemberExceptionHandler`, `TopicExceptionHandler` 등) +* **Exception**: `TalkPickException` (루트 예외), `ErrorCode` (에러 코드 정의) + +### `talkPick.global.rateLimiter` +* **Adapter**: `RateLimiterManagerAdapter` (Caffeine + Bucket4j 기반 토큰 버킷 알고리즘 구현) +* **Aspect**: `RateLimiterAspect` (`@RateLimited` 어노테이션이 붙은 메서드 트래픽 제어) +* **Annotation**: `@RateLimited` + +### `talkPick.global.log` +* **Aspect**: `LoggerAspect` (현재 주석 처리됨, AOP 기반 로깅) + +### `talkPick.global.healthCheck` +* **API**: `DBHealthIndicator`, `UrlHealthIndicator` (시스템 상태 점검) + +--- + +## 2. Domain (핵심 비즈니스 로직) + +### **Member (회원)** +* **Entity**: `Member` (핵심 정보), `MemberLoginHistory` (로그인 기록), `MemberTerm` (약관 동의 내역) +* **Port (In/Out)** + * In: `MemberCommandUseCase`, `MemberQueryUseCase`, `MemberWithdrawalUseCase` + * Out: `MemberCommandRepositoryPort`, `MemberQueryRepositoryPort` 등 +* **Service (Application)** + * `MemberCommandService`: 회원가입, 프로필 수정(MBTI), 약관 동의, 로그아웃 + * `MemberQueryService`: 프로필 조회, 좋아요한 토픽 조회, 캘린더 결과 조회 + * `MemberWithdrawalService`: 회원 탈퇴(Soft Delete) 및 영구 삭제(Hard Delete) +* **Adapter (Out)** + * `MemberJpaRepository`: 기본 CRUD + * `MemberLikedTopicsQuerydslRepository`: 좋아요한 토픽 커서 페이징 조회 (복잡한 조인) + * `MemberTopicResultQuerydslRepository`: 일자별 토픽 결과 조회 + +### **Topic (토픽)** +* **Entity**: `Topic` (주제), `TopicStat` (통계), `Category`, `Keyword`, `TopicLikeHistory` +* **Port (In/Out)** + * In: `TopicCommandUseCase`, `TopicQueryUseCase` + * Out: `TopicQueryRepositoryPort`, `TopicLikeHistoryCommandRepositoryPort` 등 +* **Service (Application)** + * `TopicCommandService`: 좋아요 기능 (이벤트 발행) + * `TopicQueryService`: 카테고리 목록, 토픽 상세 조회 +* **Adapter (Out)** + * `TopicQuerydslRepository`: 토픽 검색 및 조회 + * `TopicStatJpaRepository`: 통계 데이터 관리 + +### **Random (랜덤 토픽)** +* **Entity**: `Random` (세션), `RandomTopicHistory` (진행 이력) +* **Port (In/Out)** + * In: `RandomCommandUseCase`, `RandomQueryUseCase` +* **Service (Application)** + * `RandomCommandService`: 세션 시작/종료, 다음 토픽 진행, 평점/한줄평 등록 + * `RandomQueryService`: 조건별 랜덤 토픽 추천 목록 조회 +* **Adapter (Out)** + * `RandomQuerydslRepository`, `RandomTopicHistoryQuerydslRepository`: 동적 쿼리 처리 + +### **Today (오늘의 토픽)** +* **Entity**: `TodayTopic` (사용자-토픽 매핑) +* **Service (Application)** + * `TodayTopicQueryService`: 사용자별 오늘의 토픽 조회 (캐싱 적용 `CacheManager`) +* **Adapter (Out)** + * `TodayTopicQuerydslRepository`: 오늘의 토픽 조회 최적화 + +### **Notice (공지사항)** +* **Entity**: `Notice`, `NoticeImage` +* **Service (Application)** + * `NoticeQueryService`: 공지사항 목록(커서 페이징) 및 상세 조회 +* **Adapter (Out)** + * `NoticeQuerydslRepository`: 페이징 쿼리 + +### **Inquiry (문의)** +* **Entity**: `Inquiry` +* **Service (Application)** + * `InquiryCommandService`: 문의 등록 + * `InquiryQueryService`: 내 문의 내역 조회 +* **Adapter (Out)** + * `InquiryQuerydslRepository`: 문의 내역 페이징 + +### **Term (약관)** +* **Entity**: `Term` +* **Adapter (Out)**: `TermJpaRepository` + +--- + +## 3. Batch (배치 및 스케줄러) +* **`MemberCleanupScheduler`**: 탈퇴 상태인 회원과 연관 데이터를 주기적으로 영구 삭제 (Hard Delete) +* **`TodayTopicCacheRefreshScheduler`**: 매일 자정 사용자별 새로운 '오늘의 토픽' 생성 및 캐시 갱신 +* **`MasterTokenGenerator`**: 개발/테스트용 마스터 토큰 생성 + +--- + +## 4. External (외부 연동) +* **Kakao**: `KakaoOidcService` (ID Token 검증, 공개키 조회, 사용자 정보 파싱) +* **Apple**: `AppleOidcService` (애플 로그인 지원) +* **Google**: `GoogleOidcService` (구글 로그인 지원) +* **Port**: `KakaoOidcUsecase` 등 인터페이스 정의로 결합도 낮춤 \ No newline at end of file From fae57f9676b1a4b184b75de62057341abe32386c Mon Sep 17 00:00:00 2001 From: simhyunmin Date: Sun, 4 Jan 2026 16:59:10 +0900 Subject: [PATCH 45/49] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=20=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20config=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coderabbit.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..33d20b9 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,11 @@ +# CodeRabbit 설정 파일 +language: "ko-KR" # 리뷰 언어 한국어로 설정 +early_access: false +reviews: + profile: "chill" + request_changes_workflow: false # AI가 승인 거부를 못 하게 + high_level_summary: true # 전체적인 3줄 요약 제공 + auto_review: + enabled: true +chat: + auto_reply: true # 대댓글로 질문하면 답변 \ No newline at end of file From 3b9e434979a18be20ffd07c2266b721744f98e9d Mon Sep 17 00:00:00 2001 From: Hszoo Date: Mon, 5 Jan 2026 16:23:10 +0900 Subject: [PATCH 46/49] Deploy: set up promtail-based log monitoring to grafana cloud --- .github/workflows/deploy.yml | 2 ++ docker-compose.yml | 13 +++++++++++++ promtail.yaml | 25 +++++++++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 promtail.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index baf3907..23253fd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -79,6 +79,8 @@ jobs: mkdir -p /home/${{ secrets.EC2_USER }}/TalkPick_Server/env cat << 'EOF' > /home/${{ secrets.EC2_USER }}/TalkPick_Server/env/.env ${{ secrets.ENV }} + GRAFANA_CLOUD_USER=${{ secrets.GRAFANA_CLOUD_USER }} + GRAFANA_CLOUD_TOKEN=${{ secrets.GRAFANA_CLOUD_TOKEN }} EOF cd /home/${{ secrets.EC2_USER }}/TalkPick_Server diff --git a/docker-compose.yml b/docker-compose.yml index d9b3b31..9f4c378 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,5 +27,18 @@ services: - ./nginx/conf:/etc/nginx/conf.d - ./nginx/html/terms/privacy-policy:/usr/share/nginx/html/docs:ro - /etc/ssl/cloudflare:/etc/ssl/cloudflare:ro + + ## promtail + promtail: + image: grafana/promtail:2.9.4 + container_name: promtail + networks: + - t4y + volumes: + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - ./promtail.yaml:/etc/promtail/config.yml:ro + command: -config.file=/etc/promtail/config.yml + restart: unless-stopped + networks: t4y: diff --git a/promtail.yaml b/promtail.yaml new file mode 100644 index 0000000..b807954 --- /dev/null +++ b/promtail.yaml @@ -0,0 +1,25 @@ +server: + http_listen_port: 0 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: https://logs-prod-030.grafana.net/loki/api/v1/push + basic_auth: + username: ${GRAFANA_CLOUD_USER} + password: ${GRAFANA_CLOUD_TOKEN} + +scrape_configs: + - job_name: docker + static_configs: + - targets: + - localhost + labels: + job: docker + __path__: /var/lib/docker/containers/*/*.log + + pipeline_stages: + - docker: {} + - labels: \ No newline at end of file From bc995d041b7e859e3eb0be536d7c49dcc29ad410 Mon Sep 17 00:00:00 2001 From: Zetty Date: Sat, 10 Jan 2026 14:46:49 +0900 Subject: [PATCH 47/49] =?UTF-8?q?refactor:=20CategoryGroup=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0=20=ED=9B=84?= =?UTF-8?q?=20=EB=8D=94=EB=AF=B8=EB=8D=B0=EC=9D=B4=ED=84=B0=20sql=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20#289?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/dummy_data.sql | 18 +++++++++--------- .../random/adapter/in/RandomQueryApi.java | 5 +---- .../adapter/in/RandomQueryController.java | 6 +++--- .../out/RandomQueryRepositoryAdapter.java | 5 ++--- .../repository/RandomQuerydslRepository.java | 8 +------- .../random/application/RandomQueryService.java | 6 +++--- .../random/port/in/RandomQueryUseCase.java | 4 ++-- .../port/out/RandomQueryRepositoryPort.java | 4 ++-- .../domain/topic/adapter/in/TopicQueryApi.java | 6 ++---- .../topic/adapter/in/TopicQueryController.java | 5 ++--- .../out/TopicQueryRepositoryAdapter.java | 5 ++--- .../topic/adapter/out/dto/TopicResDTO.java | 7 +------ .../repository/TopicQuerydslRepository.java | 8 ++------ .../topic/application/TopicQueryService.java | 5 ++--- .../talkPick/domain/topic/domain/Category.java | 8 +------- .../topic/domain/type/CategoryGroup.java | 6 ------ .../topic/port/in/TopicQueryUseCase.java | 3 +-- .../port/out/TopicQueryRepositoryPort.java | 3 +-- .../application/RandomQueryServiceTest.java | 15 ++++++--------- 19 files changed, 43 insertions(+), 84 deletions(-) delete mode 100644 src/main/java/talkPick/domain/topic/domain/type/CategoryGroup.java diff --git a/scripts/dummy_data.sql b/scripts/dummy_data.sql index df9c641..16115d9 100644 --- a/scripts/dummy_data.sql +++ b/scripts/dummy_data.sql @@ -14,15 +14,15 @@ VALUES ( NOW() ); -INSERT INTO category (title, image_url, category_group) VALUES - ('소개팅/과팅', 'https://dummyimage.com/600x400/000/fff&text=소개팅', 'STRANGER'), - ('그룹 첫 모임', 'https://dummyimage.com/600x400/111/fff&text=그룹모임', 'STRANGER'), - ('룸메 첫 만남', 'https://dummyimage.com/600x400/222/fff&text=룸메', 'STRANGER'), - ('기타/아이스브레이킹', 'https://dummyimage.com/600x400/333/fff&text=기타', 'STRANGER'), - ('가족', 'https://dummyimage.com/600x400/444/fff&text=가족', 'CLOSE'), - ('친구', 'https://dummyimage.com/600x400/555/fff&text=친구', 'CLOSE'), - ('연인', 'https://dummyimage.com/600x400/666/fff&text=연인', 'CLOSE'), - ('동료', 'https://dummyimage.com/600x400/777/fff&text=동료', 'CLOSE'); +INSERT INTO category (title, image_url) VALUES + ('소개팅/과팅', 'https://dummyimage.com/600x400/000/fff&text=소개팅'), + ('그룹 첫 모임', 'https://dummyimage.com/600x400/111/fff&text=그룹모임'), + ('룸메 첫 만남', 'https://dummyimage.com/600x400/222/fff&text=룸메'), + ('기타/아이스브레이킹', 'https://dummyimage.com/600x400/333/fff&text=기타'), + ('가족', 'https://dummyimage.com/600x400/444/fff&text=가족'), + ('친구', 'https://dummyimage.com/600x400/555/fff&text=친구'), + ('연인', 'https://dummyimage.com/600x400/666/fff&text=연인'), + ('동료', 'https://dummyimage.com/600x400/777/fff&text=동료'); INSERT INTO keyword (name, image_url, icon_url) VALUES ('만약에', 'https://dummyimage.com/600x400/f44/fff&text=만약에', 'https://dummyimage.com/100x100/f44/fff&text=만약에'), diff --git a/src/main/java/talkPick/domain/random/adapter/in/RandomQueryApi.java b/src/main/java/talkPick/domain/random/adapter/in/RandomQueryApi.java index df42edc..94211b7 100644 --- a/src/main/java/talkPick/domain/random/adapter/in/RandomQueryApi.java +++ b/src/main/java/talkPick/domain/random/adapter/in/RandomQueryApi.java @@ -9,7 +9,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import talkPick.domain.random.adapter.out.dto.RandomResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.global.security.annotation.MemberId; import java.util.List; @@ -24,7 +23,7 @@ public interface RandomQueryApi { order는 현재 순서 기준으로 (1, 2, 3, 4) 넣어주세요. 랜덤 대화 주제 코스에서 톡픽들을 조회할 때, 해당 API를 한 번 요청해 주세요. - 랜덤 대화 주제 코스 첫 시도 시, 사용자가 선택한 카테고리 그룹+카테고리를 + 랜덤 대화 주제 코스 첫 시도 시, 사용자가 선택한 카테고리를 파라미터로 넣어서 요청 주세요. """ ) @@ -35,8 +34,6 @@ List getRandomTopics( @NotNull(message = "[ERROR] randomId 값이 존재하지 않습니다.") Long randomId, @RequestParam(name = "order", required = true) @NotNull(message = "[ERROR] order 값이 존재하지 않습니다.") Integer order, - @RequestParam(name = "categoryGroup", required = false) - CategoryGroup categoryGroup, @RequestParam(name = "category", required = false) String category diff --git a/src/main/java/talkPick/domain/random/adapter/in/RandomQueryController.java b/src/main/java/talkPick/domain/random/adapter/in/RandomQueryController.java index de8b6a5..5cbe800 100644 --- a/src/main/java/talkPick/domain/random/adapter/in/RandomQueryController.java +++ b/src/main/java/talkPick/domain/random/adapter/in/RandomQueryController.java @@ -4,7 +4,7 @@ import org.springframework.web.bind.annotation.RestController; import talkPick.domain.random.adapter.out.dto.RandomResDTO; import talkPick.domain.random.port.in.RandomQueryUseCase; -import talkPick.domain.topic.domain.type.CategoryGroup; + import java.util.List; @RestController @@ -13,7 +13,7 @@ public class RandomQueryController implements RandomQueryApi { private final RandomQueryUseCase randomQueryUseCase; @Override - public List getRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category) { - return randomQueryUseCase.getRandomTopics(memberId, randomId, order, categoryGroup, category); + public List getRandomTopics(Long memberId, Long randomId, Integer order, String category) { + return randomQueryUseCase.getRandomTopics(memberId, randomId, order, category); } } diff --git a/src/main/java/talkPick/domain/random/adapter/out/RandomQueryRepositoryAdapter.java b/src/main/java/talkPick/domain/random/adapter/out/RandomQueryRepositoryAdapter.java index 29a6281..fea9f46 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/RandomQueryRepositoryAdapter.java +++ b/src/main/java/talkPick/domain/random/adapter/out/RandomQueryRepositoryAdapter.java @@ -7,7 +7,6 @@ import talkPick.domain.random.adapter.out.repository.RandomQuerydslRepository; import talkPick.domain.random.domain.Random; import talkPick.domain.random.port.out.RandomQueryRepositoryPort; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.global.exception.handler.RandomExceptionHandler; import java.util.List; import static talkPick.global.exception.ErrorCode.RANDOM_NOT_FOUND; @@ -19,8 +18,8 @@ public class RandomQueryRepositoryAdapter implements RandomQueryRepositoryPort { private final RandomQuerydslRepository randomQuerydslRepository; @Override - public List findRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category) { - var topics = randomQuerydslRepository.findRandomTopics(memberId, randomId, categoryGroup, category); + public List findRandomTopics(Long memberId, Long randomId, Integer order, String category) { + var topics = randomQuerydslRepository.findRandomTopics(memberId, randomId, category); return List.of(new RandomResDTO.RandomTopic(order, topics)); } diff --git a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java index 8e0e129..107fee3 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java +++ b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java @@ -6,7 +6,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import talkPick.domain.random.adapter.out.dto.RandomResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.global.model.TalkPickStatus; import java.util.ArrayList; import java.util.Collections; @@ -21,7 +20,7 @@ public class RandomQuerydslRepository { private final JPAQueryFactory queryFactory; - public List findRandomTopics(Long memberId, Long randomId, CategoryGroup categoryGroup, String categoryType){ + public List findRandomTopics(Long memberId, Long randomId, String categoryType){ List alreadyUsedTopicIds = queryFactory .select(randomTopicHistory.topicId) .from(randomTopicHistory) @@ -36,10 +35,6 @@ public List findRandomTopics(Long memberId, Long builder.and(topic.id.notIn(alreadyUsedTopicIds)); } - if (categoryGroup != null) { - builder.and(category.categoryGroup.eq(categoryGroup)); - } - if (categoryType != null) { builder.and(category.title.eq(categoryType)); } @@ -49,7 +44,6 @@ public List findRandomTopics(Long memberId, Long topic.id, topic.title, topic.detail, - category.categoryGroup.stringValue(), category.title, keyword.name, keyword.imageUrl, diff --git a/src/main/java/talkPick/domain/random/application/RandomQueryService.java b/src/main/java/talkPick/domain/random/application/RandomQueryService.java index 451edbe..51a1da4 100644 --- a/src/main/java/talkPick/domain/random/application/RandomQueryService.java +++ b/src/main/java/talkPick/domain/random/application/RandomQueryService.java @@ -6,7 +6,7 @@ import talkPick.domain.random.adapter.out.dto.RandomResDTO; import talkPick.domain.random.port.in.RandomQueryUseCase; import talkPick.domain.random.port.out.RandomQueryRepositoryPort; -import talkPick.domain.topic.domain.type.CategoryGroup; + import java.util.List; @Service @@ -16,7 +16,7 @@ public class RandomQueryService implements RandomQueryUseCase { private final RandomQueryRepositoryPort randomQueryRepositoryPort; @Override - public List getRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category) { - return randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, categoryGroup, category); + public List getRandomTopics(Long memberId, Long randomId, Integer order, String category) { + return randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, category); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/random/port/in/RandomQueryUseCase.java b/src/main/java/talkPick/domain/random/port/in/RandomQueryUseCase.java index d75f86f..b6995b4 100644 --- a/src/main/java/talkPick/domain/random/port/in/RandomQueryUseCase.java +++ b/src/main/java/talkPick/domain/random/port/in/RandomQueryUseCase.java @@ -1,9 +1,9 @@ package talkPick.domain.random.port.in; import talkPick.domain.random.adapter.out.dto.RandomResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; + import java.util.List; public interface RandomQueryUseCase { - List getRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category); + List getRandomTopics(Long memberId, Long randomId, Integer order, String category); } diff --git a/src/main/java/talkPick/domain/random/port/out/RandomQueryRepositoryPort.java b/src/main/java/talkPick/domain/random/port/out/RandomQueryRepositoryPort.java index 1d62a28..99f9a05 100644 --- a/src/main/java/talkPick/domain/random/port/out/RandomQueryRepositoryPort.java +++ b/src/main/java/talkPick/domain/random/port/out/RandomQueryRepositoryPort.java @@ -2,10 +2,10 @@ import talkPick.domain.random.adapter.out.dto.RandomResDTO; import talkPick.domain.random.domain.Random; -import talkPick.domain.topic.domain.type.CategoryGroup; + import java.util.List; public interface RandomQueryRepositoryPort { - List findRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category); + List findRandomTopics(Long memberId, Long randomId, Integer order, String category); Random findRandomByMemberIdAndId(Long memberId, Long randomId); } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryApi.java b/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryApi.java index ac3a34b..9ded97f 100644 --- a/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryApi.java +++ b/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryApi.java @@ -8,9 +8,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import java.util.List; @Validated @@ -18,8 +16,8 @@ @Tag(name = "톡픽 API", description = "톡픽 관련 API 입니다.") public interface TopicQueryApi { @GetMapping("/categories") - @Operation(summary = "카테고리 전체 조회 API", description = "카테고리 전체 조회 API 입니다. 조회를 원하는 Category의 CategoryGroup(STRANGER : 첫 만남, CLOSE : 가까운 사이)를 파라미터에 넣어서 보내주세요.") - List getCategories(@RequestParam(name = "categoryGroup") CategoryGroup categoryGroup); + @Operation(summary = "카테고리 전체 조회 API", description = "카테고리 전체 조회 API 입니다. 기존에 CategoryGroup을 파라미터로 넣어서 보냈는데, 이제 안 넣으시고 요청하셔도 됩니다.") + List getCategories(); @GetMapping("/{topicId}") @Operation(summary = "토픽 상세 조회 API", description = "토픽 상세 조회 API 입니다.") diff --git a/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryController.java b/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryController.java index 931faa2..145eaaf 100644 --- a/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryController.java +++ b/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryController.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.RestController; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.domain.topic.port.in.TopicQueryUseCase; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; import java.util.List; @@ -13,8 +12,8 @@ public class TopicQueryController implements TopicQueryApi { private final TopicQueryUseCase topicQueryUseCase; @Override - public List getCategories(CategoryGroup categoryGroup) { - return topicQueryUseCase.getCategories(categoryGroup); + public List getCategories() { + return topicQueryUseCase.getCategories(); } @Override diff --git a/src/main/java/talkPick/domain/topic/adapter/out/TopicQueryRepositoryAdapter.java b/src/main/java/talkPick/domain/topic/adapter/out/TopicQueryRepositoryAdapter.java index 8c25c9b..b5b40c3 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/TopicQueryRepositoryAdapter.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/TopicQueryRepositoryAdapter.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.domain.topic.domain.Topic; import talkPick.domain.topic.port.out.TopicQueryRepositoryPort; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; @@ -25,8 +24,8 @@ public Topic findTopicById(final Long topicId) { } @Override - public List findCategoriesByCategoryGroup(CategoryGroup categoryGroup) { - return Optional.ofNullable(topicQuerydslRepository.findCategoriesByCategoryGroup(categoryGroup)) + public List findCategories() { + return Optional.ofNullable(topicQuerydslRepository.findCategories()) .orElse(Collections.emptyList()); } diff --git a/src/main/java/talkPick/domain/topic/adapter/out/dto/TopicResDTO.java b/src/main/java/talkPick/domain/topic/adapter/out/dto/TopicResDTO.java index 44b87ff..ce0c009 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/dto/TopicResDTO.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/dto/TopicResDTO.java @@ -1,8 +1,5 @@ package talkPick.domain.topic.adapter.out.dto; -import talkPick.domain.topic.domain.Keyword; -import talkPick.domain.topic.domain.type.CategoryGroup; - public class TopicResDTO { public record Topic( Long id, @@ -12,8 +9,7 @@ public record Topic( public record Categories( Long categoryId, String title, - String imageUrl, - CategoryGroup categoryGroup + String imageUrl ) {} public record TopicDetail( @@ -21,7 +17,6 @@ public record TopicDetail( String title, String detail, String category, - CategoryGroup categoryGroup, String keywordName, String keywordImageUrl, String topicImageUrl diff --git a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicQuerydslRepository.java b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicQuerydslRepository.java index 486929a..b3eb05d 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicQuerydslRepository.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicQuerydslRepository.java @@ -5,7 +5,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import java.util.List; import static talkPick.domain.topic.domain.QCategory.category; import static talkPick.domain.topic.domain.QKeyword.keyword; @@ -17,15 +16,13 @@ public class TopicQuerydslRepository { private final JPAQueryFactory queryFactory; - public List findCategoriesByCategoryGroup(CategoryGroup categoryGroup) { + public List findCategories() { return queryFactory.select(Projections.constructor(TopicResDTO.Categories.class, category.id, category.title, - category.imageUrl, - category.categoryGroup + category.imageUrl )) .from(category) - .where(category.categoryGroup.eq(categoryGroup)) .fetch(); } @@ -35,7 +32,6 @@ public TopicResDTO.TopicDetail findTopicDetailById(Long topicId) { topic.title, topic.detail, category.title, - category.categoryGroup, keyword.name, keyword.imageUrl, topic.imageUrl diff --git a/src/main/java/talkPick/domain/topic/application/TopicQueryService.java b/src/main/java/talkPick/domain/topic/application/TopicQueryService.java index ea1e9e0..22e97c2 100644 --- a/src/main/java/talkPick/domain/topic/application/TopicQueryService.java +++ b/src/main/java/talkPick/domain/topic/application/TopicQueryService.java @@ -4,7 +4,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.domain.topic.port.in.TopicQueryUseCase; import talkPick.domain.topic.port.out.TopicQueryRepositoryPort; import java.util.List; @@ -16,8 +15,8 @@ public class TopicQueryService implements TopicQueryUseCase { private final TopicQueryRepositoryPort topicQueryRepositoryPort; @Override - public List getCategories(CategoryGroup categoryGroup) { - return topicQueryRepositoryPort.findCategoriesByCategoryGroup(categoryGroup); + public List getCategories() { + return topicQueryRepositoryPort.findCategories(); } @Override diff --git a/src/main/java/talkPick/domain/topic/domain/Category.java b/src/main/java/talkPick/domain/topic/domain/Category.java index f22a123..cc81068 100644 --- a/src/main/java/talkPick/domain/topic/domain/Category.java +++ b/src/main/java/talkPick/domain/topic/domain/Category.java @@ -2,7 +2,6 @@ import jakarta.persistence.*; import lombok.*; -import talkPick.domain.topic.domain.type.CategoryGroup; @Getter @Entity @@ -22,15 +21,10 @@ public class Category { @Column(name = "image_url", nullable = true, length = 500, columnDefinition = "VARCHAR(500) COMMENT '카테고리 이미지 URL'") private String imageUrl; - @Enumerated(EnumType.STRING) - @Column(name = "category_group", nullable = false, columnDefinition = "VARCHAR(20) COMMENT '카테고리 그룹'") - private CategoryGroup categoryGroup; - - public static Category of(String title, String imageUrl, CategoryGroup categoryGroup) { + public static Category of(String title, String imageUrl) { return Category.builder() .title(title) .imageUrl(imageUrl) - .categoryGroup(categoryGroup) .build(); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/domain/type/CategoryGroup.java b/src/main/java/talkPick/domain/topic/domain/type/CategoryGroup.java deleted file mode 100644 index 1410b61..0000000 --- a/src/main/java/talkPick/domain/topic/domain/type/CategoryGroup.java +++ /dev/null @@ -1,6 +0,0 @@ -package talkPick.domain.topic.domain.type; - -public enum CategoryGroup { - STRANGER, // 첫 만남 - CLOSE // 가까운 사이 -} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/port/in/TopicQueryUseCase.java b/src/main/java/talkPick/domain/topic/port/in/TopicQueryUseCase.java index bacd39f..5bb3376 100644 --- a/src/main/java/talkPick/domain/topic/port/in/TopicQueryUseCase.java +++ b/src/main/java/talkPick/domain/topic/port/in/TopicQueryUseCase.java @@ -1,10 +1,9 @@ package talkPick.domain.topic.port.in; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import java.util.List; public interface TopicQueryUseCase { - List getCategories(CategoryGroup categoryGroup); + List getCategories(); TopicResDTO.TopicDetail getTopicDetail(Long topicId); } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/port/out/TopicQueryRepositoryPort.java b/src/main/java/talkPick/domain/topic/port/out/TopicQueryRepositoryPort.java index 9ae772a..1d2786d 100644 --- a/src/main/java/talkPick/domain/topic/port/out/TopicQueryRepositoryPort.java +++ b/src/main/java/talkPick/domain/topic/port/out/TopicQueryRepositoryPort.java @@ -1,12 +1,11 @@ package talkPick.domain.topic.port.out; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; import talkPick.domain.topic.domain.Topic; import java.util.List; public interface TopicQueryRepositoryPort { Topic findTopicById(final Long topicId); - List findCategoriesByCategoryGroup(CategoryGroup categoryGroup); + List findCategories(); TopicResDTO.TopicDetail findTopicDetail(Long topicId); } \ No newline at end of file diff --git a/src/test/java/talkPick/random/application/RandomQueryServiceTest.java b/src/test/java/talkPick/random/application/RandomQueryServiceTest.java index 4dfc4ab..e6f8da8 100644 --- a/src/test/java/talkPick/random/application/RandomQueryServiceTest.java +++ b/src/test/java/talkPick/random/application/RandomQueryServiceTest.java @@ -9,7 +9,6 @@ import talkPick.domain.random.adapter.out.dto.RandomResDTO; import talkPick.domain.random.application.RandomQueryService; import talkPick.domain.random.port.out.RandomQueryRepositoryPort; -import talkPick.domain.topic.domain.type.CategoryGroup; import java.util.Collections; import java.util.List; @@ -37,7 +36,6 @@ class RandomQueryServiceTest { Long memberId = 1L; Long randomId = 100L; Integer order = 1; - CategoryGroup categoryGroup = CategoryGroup.STRANGER; String category = "일상"; List mockTopics = List.of( @@ -49,12 +47,12 @@ class RandomQueryServiceTest { )) ); - given(randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, categoryGroup, category)) + given(randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, category)) .willReturn(mockTopics); // when List result = randomQueryService.getRandomTopics( - memberId, randomId, order, categoryGroup, category + memberId, randomId, order, category ); // then @@ -64,7 +62,7 @@ class RandomQueryServiceTest { () -> assertThat(result.get(0).getOrder()).isEqualTo(1), () -> assertThat(result.get(1).getOrder()).isEqualTo(2), () -> verify(randomQueryRepositoryPort, times(1)) - .findRandomTopics(memberId, randomId, order, categoryGroup, category) + .findRandomTopics(memberId, randomId, order, category) ); } @@ -75,15 +73,14 @@ class RandomQueryServiceTest { Long memberId = 1L; Long randomId = 100L; Integer order = 1; - CategoryGroup categoryGroup = CategoryGroup.STRANGER; String category = "일상"; - given(randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, categoryGroup, category)) + given(randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, category)) .willReturn(Collections.emptyList()); // when List result = randomQueryService.getRandomTopics( - memberId, randomId, order, categoryGroup, category + memberId, randomId, order, category ); // then @@ -91,7 +88,7 @@ class RandomQueryServiceTest { () -> assertThat(result).isNotNull(), () -> assertThat(result).isEmpty(), () -> verify(randomQueryRepositoryPort, times(1)) - .findRandomTopics(memberId, randomId, order, categoryGroup, category) + .findRandomTopics(memberId, randomId, order, category) ); } } \ No newline at end of file From bab45bdddd52212ebd5e607a44405938a42eeefb Mon Sep 17 00:00:00 2001 From: Zetty Date: Sat, 10 Jan 2026 20:26:54 +0900 Subject: [PATCH 48/49] =?UTF-8?q?fix:=20RandomTopicDetail=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=95=88=EC=97=90=20CategoryGroup=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AD=EC=A0=9C=20-=20#293?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../talkPick/domain/random/adapter/out/dto/RandomResDTO.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/talkPick/domain/random/adapter/out/dto/RandomResDTO.java b/src/main/java/talkPick/domain/random/adapter/out/dto/RandomResDTO.java index d11e0d5..014286f 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/dto/RandomResDTO.java +++ b/src/main/java/talkPick/domain/random/adapter/out/dto/RandomResDTO.java @@ -32,7 +32,6 @@ public static class RandomTopicDetail { private Long topicId; private String title; private String detail; - private String categoryGroup; private String category; private String keywordName; private String keywordImageUrl; From 61a3d7a93e16bc9e788409b22c619526dc960da7 Mon Sep 17 00:00:00 2001 From: Zetty Date: Sat, 10 Jan 2026 21:23:19 +0900 Subject: [PATCH 49/49] =?UTF-8?q?fix:=20CategoryGroup=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=82=AD=EC=A0=9C=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=EB=8F=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20#293?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../talkPick/random/application/RandomQueryServiceTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/talkPick/random/application/RandomQueryServiceTest.java b/src/test/java/talkPick/random/application/RandomQueryServiceTest.java index e6f8da8..04fceed 100644 --- a/src/test/java/talkPick/random/application/RandomQueryServiceTest.java +++ b/src/test/java/talkPick/random/application/RandomQueryServiceTest.java @@ -40,10 +40,10 @@ class RandomQueryServiceTest { List mockTopics = List.of( new RandomResDTO.RandomTopic(1, List.of( - new RandomResDTO.RandomTopicDetail(1L, "토픽1", "설명1", "STRANGER", "일상", "키워드1", "img1.png", "icon1.png") + new RandomResDTO.RandomTopicDetail(1L, "토픽1", "설명1", "일상", "키워드1", "img1.png", "icon1.png") )), new RandomResDTO.RandomTopic(2, List.of( - new RandomResDTO.RandomTopicDetail(2L, "토픽2", "설명2", "CLOSE", "대화", "키워드2", "img2.png", "icon2.png") + new RandomResDTO.RandomTopicDetail(2L, "토픽2", "설명2", "대화", "키워드2", "img2.png", "icon2.png") )) );