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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'

// TestContainer
testImplementation "org.testcontainers:testcontainers:1.19.3"
testImplementation "org.testcontainers:junit-jupiter:1.19.3"
testImplementation "org.testcontainers:mysql:1.19.3"

// Micrometer
implementation 'io.micrometer:micrometer-registry-prometheus' // Prometheus

Expand Down Expand Up @@ -66,6 +61,7 @@ dependencies {
implementation 'com.google.firebase:firebase-admin:9.2.0'

// test containers
testImplementation "org.testcontainers:testcontainers"
testImplementation 'org.testcontainers:mysql'
testImplementation 'org.testcontainers:jdbc'
testImplementation 'org.testcontainers:junit-jupiter'
Expand Down Expand Up @@ -112,11 +108,11 @@ tasks.register('copyDocument', Copy) { // 8
dependsOn asciidoctor

from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
into file("docs")
}

asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
delete file('docs')
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class TodaysoundServerApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,7 @@ public void createAlert(@RequestBody InternalAlertRequest request) {
// 알림이 활성화된 구독에 대해서만 푸시 전송
if (subscription.isAlarmEnabled()) {
User user = subscription.getUser();
String prefix;

if (request.keywordMatched == true) {
prefix = "[" + request.siteAlias + "]";
} else {
prefix = "[긴급/" + request.siteAlias + "]";
}
String prefix = "[" + request.siteAlias + "]";

fcmService.sendNotificationToUser(
user,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,13 @@

@Entity
@Getter
@Builder
@Table(name = "keywords")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Keyword extends BaseEntity {

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

@Builder.Default
@OneToMany(mappedBy = "keyword", cascade = CascadeType.ALL, orphanRemoval = true)
private List<SubscriptionKeyword> subscriptions = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import java.util.ArrayList;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
Expand All @@ -27,10 +26,8 @@

@Entity
@Getter
@Builder
@Table(name = "subscriptions")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Subscription extends BaseEntity {

@ManyToOne(fetch = FetchType.LAZY)
Expand All @@ -44,9 +41,8 @@ public class Subscription extends BaseEntity {
private boolean isAlarmEnabled;

// 마지막으로 처리한 게시물의 site_post_id
@Builder.Default
@Column(name = "last_seen_post_id", nullable = false)
private String lastSeenPostId = "";
private String lastSeenPostId;

@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
Expand All @@ -60,12 +56,10 @@ public class Subscription extends BaseEntity {
@JoinColumn(name = "url_id", nullable = false)
private Url url;

@Builder.Default
@OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL, orphanRemoval = true)
@OnDelete(action = OnDeleteAction.CASCADE)
private List<SubscriptionKeyword> subscriptionKeywords = new ArrayList<>();

@Builder.Default
@OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL, orphanRemoval = true)
@OnDelete(action = OnDeleteAction.CASCADE)
private List<Summary> summaries = new ArrayList<>();
Expand Down Expand Up @@ -100,4 +94,18 @@ public void updateKeywords(List<Keyword> keywords) {

keywords.forEach(keyword -> this.subscriptionKeywords.add(SubscriptionKeyword.of(this, keyword)));
}

@Builder
private Subscription(Url url, boolean isAlarmEnabled, String alias, User user, String lastSeenPostId) {
this.url = url;
this.isAlarmEnabled = isAlarmEnabled;
this.alias = alias;
this.user = user;
this.lastSeenPostId = lastSeenPostId;
}
Comment on lines +98 to +105

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

@Builder 애노테이션이 클래스 레벨(30라인)과 생성자 레벨(104라인)에 중복으로 사용되었습니다. Lombok은 한 클래스에 여러 개의 @Builder를 허용하지 않으므로 컴파일 오류가 발생합니다.

새로 추가된 create 정적 팩토리 메서드와 생성자 빌더 패턴을 사용하시려면, 클래스 레벨의 @Builder@AllArgsConstructor를 제거해야 합니다.

예시:

@Entity
@Getter
//@Builder // <- 제거
@Table(name = "subscriptions")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
//@AllArgsConstructor(access = AccessLevel.PRIVATE) // <- 제거
public class Subscription extends BaseEntity {
    //...
}

이렇게 수정하면 새로 추가한 빌더만 사용되어 문제가 해결됩니다.


public static Subscription create(Url url, boolean isAlarmEnabled, String alias, User user, String lastSeenPostId) {
return Subscription.builder().url(url).isAlarmEnabled(isAlarmEnabled).alias(alias).user(user)
.lastSeenPostId(lastSeenPostId).build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
import java.util.List;

public interface SubscriptionDynamicRepository {
List<Subscription> findByUserId(Long userId, Long cursor, Integer size);
List<Subscription> findByUserId(Long userId, Integer cursor, Integer size);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class SubscriptionDynamicRepositoryImpl implements SubscriptionDynamicRep
private final JPAQueryFactory queryFactory;

@Override
public List<Subscription> findByUserId(Long userId, Long page, Integer size) {
public List<Subscription> findByUserId(Long userId, Integer page, Integer size) {

return queryFactory
.selectFrom(subscription)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,4 @@ public static Summary create(String hash, String title, String content,
return summary;
}

// Summary를 읽음 처리
public void markAsRead() {
this.isKeywordMatched = true;
this.updatedAt = LocalDateTime.now();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.todaysound.todaysound_server.domain.summary.infra.scheduler;

import com.todaysound.todaysound_server.domain.summary.repository.SummaryRepository;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@RequiredArgsConstructor
public class SummaryCleanupScheduler {

private final SummaryRepository summaryRepository;

@Transactional
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시에 실행
public void deleteOldSummaries() {

LocalDateTime threshold = LocalDateTime.now().minusDays(7);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

LocalDateTime.now()를 직접 사용하면 서버의 기본 시간대에 의존하게 되어 예기치 않은 동작을 유발할 수 있고, 시간과 관련된 로직을 테스트하기 어렵습니다.

java.time.Clock을 빈으로 등록하고 주입받아 사용하면 시간대 문제를 해결하고 테스트 용이성을 높일 수 있습니다.

제안:

  1. Clock 빈을 설정 파일에 등록합니다.
@Configuration
public class AppConfig {
    @Bean
    public Clock clock() {
        return Clock.systemDefaultZone(); // 또는 Clock.systemUTC()
    }
}
  1. 스케줄러에서 Clock을 주입받아 사용합니다.
@Component
@RequiredArgsConstructor
public class SummaryCleanupScheduler {

    private final SummaryRepository summaryRepository;
    private final Clock clock; // Clock 주입

    @Transactional
    @Scheduled(cron = "0 0 3 * * *")
    public void deleteOldSummaries() {
        LocalDateTime threshold = LocalDateTime.now(clock).minusDays(7); // 주입받은 clock 사용
        summaryRepository.deleteByCreatedAtBefore(threshold);
    }
}

테스트에서는 Clock.fixed(...)를 사용하여 시간을 고정할 수 있어 보다 안정적인 테스트가 가능해집니다.

summaryRepository.deleteByCreatedAtBefore(threshold);
Comment on lines +20 to +21

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

deleteByCreatedAtBefore 메서드는 삭제할 데이터가 많을 경우, 단일 DELETE 쿼리로 인해 데이터베이스에 상당한 부하를 줄 수 있습니다. 이는 장시간의 테이블 락을 유발하여 다른 작업에 영향을 미칠 수 있습니다.
성능 및 안정성을 위해 대량 삭제는 배치(batch) 방식으로 처리하는 것을 고려해 보세요. 예를 들어, 삭제할 데이터를 일정 개수씩 끊어서 여러 번에 걸쳐 삭제하는 방식입니다.

이를 위해 Repository에 LIMIT을 사용하는 메서드를 추가할 수 있습니다.

// SummaryRepository.java
@Modifying
@Query("DELETE FROM Summary s WHERE s.createdAt < :threshold")
int deleteBatchByCreatedAtBefore(@Param("threshold") LocalDateTime threshold, Pageable pageable);

// SummaryCleanupScheduler.java
public void deleteOldSummaries() {
    LocalDateTime threshold = LocalDateTime.now().minusDays(7);
    int deletedCount;
    do {
        // 예를 들어 1000개씩 삭제
        deletedCount = summaryRepository.deleteBatchByCreatedAtBefore(threshold, PageRequest.of(0, 1000));
    } while (deletedCount > 0);
}

위 예시는 JPA의 Pageable을 활용한 배치 삭제 방법입니다. 실제 구현은 프로젝트 상황에 맞게 조정이 필요할 수 있습니다.


}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.todaysound.todaysound_server.domain.summary.repository;

import com.todaysound.todaysound_server.domain.summary.entity.Summary;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
Expand All @@ -13,6 +14,10 @@ public interface SummaryRepository extends JpaRepository<Summary, Long> {
*/
Optional<Summary> findById(Long id);

/**
* 생성일 기준으로 오래된 Summary 삭제
*/
void deleteByCreatedAtBefore(LocalDateTime dateTime);

}

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

Expand All @@ -20,4 +21,13 @@ public class Url extends BaseEntity {
@Column
String title;

@Builder
private Url(String link, String title) {
this.link = link;
this.title = title;
}

public static Url create(String link, String title) {
return Url.builder().link(link).title(title).build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,14 @@
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Builder
@Table(name = "fcm_tokens")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class FCM_Token extends BaseEntity {

@ManyToOne
Expand All @@ -33,4 +30,15 @@ public class FCM_Token extends BaseEntity {
public void update(String sFcmToken) {
this.fcmToken = sFcmToken;
}

@Builder
private FCM_Token(User user, String fcmToken, String model) {
this.user = user;
this.fcmToken = fcmToken;
this.model = model;
}

public static FCM_Token create(User user, String fcmToken, String model) {
return FCM_Token.builder().user(user).fcmToken(fcmToken).model(model).build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@
@Entity
@Getter
@Setter
@Builder
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class User extends BaseEntity {

// ********************************* static final 상수 필드 *********************************/
Expand Down Expand Up @@ -69,7 +67,6 @@ public class User extends BaseEntity {
/**
* 사용자 활성 상태 기본값 true, 탈퇴 시 false로 변경
*/
@Builder.Default
@Column(name = "is_active", nullable = false)
private boolean isActive = true;

Expand All @@ -92,12 +89,9 @@ public class User extends BaseEntity {
* cascade = CascadeType.ALL: User 삭제 시 관련 Subscription도 함께 삭제 orphanRemoval = true: 고아 객체(연관관계가 끊어진 객체) 자동 삭제 fetch
* = FetchType.LAZY: 지연 로딩으로 성능 최적화
*/
@Builder.Default
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true,
fetch = FetchType.LAZY)
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<Subscription> subscriptions = new ArrayList<>();

@Builder.Default
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<FCM_Token> fcmTokenList = new ArrayList<>();

Expand Down Expand Up @@ -162,4 +156,40 @@ public void addFcmToken(FCM_Token fcmToken) {
// FCM_Token의 user 필드는 builder에서 이미 설정되어야 함
this.fcmTokenList.add(fcmToken);
}

@Builder
private User(String userId, String hashedSecret, String secretFingerprint, UserType userType, boolean isActive,
String plainSecret, List<FCM_Token> fcmTokenList) {
this.userId = userId;
this.hashedSecret = hashedSecret;
this.secretFingerprint = secretFingerprint;
this.userType = userType;
this.isActive = isActive;
this.plainSecret = plainSecret;
this.fcmTokenList = fcmTokenList;
}

public static User createAnonymous(String userId, String hashedSecret, String secretFingerprint, UserType userType,
boolean isActive, String plainSecret, List<FCM_Token> fcmTokenList) {
return User.builder().userId(userId)
.hashedSecret(hashedSecret)
.secretFingerprint(secretFingerprint)
.userType(userType)
.isActive(isActive)
.plainSecret(plainSecret)
.fcmTokenList(fcmTokenList)
.build();
}
Comment on lines 160 to 182

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Subscription 엔티티와 동일한 문제가 있습니다. @Builder 애노테이션이 클래스 레벨(26라인)과 생성자 레벨(165라인)에 중복으로 선언되어 컴파일 오류를 유발합니다.

의도하신 대로 create 정적 팩토리 메서드를 사용하려면 클래스 레벨의 @Builder@AllArgsConstructor를 제거해 주세요.

예시:

@Entity
@Getter
@Setter
//@Builder // <- 제거
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
//@AllArgsConstructor(access = AccessLevel.PRIVATE) // <- 제거
public class User extends BaseEntity {
    //...
}


public static User create(String userId, String hashedSecret, String secretFingerprint, UserType userType,
boolean isActive, String plainSecret) {
return User.builder().userId(userId)
.hashedSecret(hashedSecret)
.secretFingerprint(secretFingerprint)
.userType(userType)
.isActive(isActive)
.plainSecret(plainSecret)
.fcmTokenList(new ArrayList<>())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ public class UserFactory {
public User createAnonymousUser(UserSecretRequest userSecretRequest) {

// 배포시 로깅은 제거
log.debug("익명 사용자 생성 시작: deviceSecret={}",
userSecretRequest.deviceSecret().substring(0, 8) + "...");
log.debug("익명 사용자 생성 시작: deviceSecret={}", userSecretRequest.deviceSecret().substring(0, 8) + "...");

// UUID 생성
String userId = UUID.randomUUID().toString();
Expand All @@ -35,12 +34,8 @@ public User createAnonymousUser(UserSecretRequest userSecretRequest) {
// 중복 검사용 fingerprint 생성 (SHA-256)
String secretFingerprint = CryptoUtils.sha256(userSecretRequest.deviceSecret());

// User 엔티티 생성
User user = User.builder().userId(userId).hashedSecret(hashedSecret)
.secretFingerprint(secretFingerprint).userType(UserType.ANONYMOUS).isActive(true)
.plainSecret(userSecretRequest.deviceSecret()) // 생성 시에만 설정
.fcmTokenList(new ArrayList<>()) // 빌더 사용 시 명시적으로 초기화
.build();
User user = User.createAnonymous(userId, hashedSecret, secretFingerprint, UserType.ANONYMOUS, true,
userSecretRequest.deviceSecret(), new ArrayList<>());

log.debug("익명 사용자 생성 완료: userId={}", userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ public class UserService {
public UserIdResponse anonymous(UserSecretRequest userSecretRequest) {

log.info("Anonymous user command received");
boolean secretExists =
userQueryService.existsBySecretFingerprint(userSecretRequest.deviceSecret());
boolean secretExists = userQueryService.existsBySecretFingerprint(userSecretRequest.deviceSecret());

User user;

Expand All @@ -38,8 +37,7 @@ public UserIdResponse anonymous(UserSecretRequest userSecretRequest) {

User newUser = userFactory.createAnonymousUser(userSecretRequest);

FCM_Token fcmToken = FCM_Token.builder().fcmToken(userSecretRequest.fcmToken())
.model(userSecretRequest.model()).user(newUser).build();
FCM_Token fcmToken = FCM_Token.create(newUser, userSecretRequest.fcmToken(), userSecretRequest.model());

newUser.addFcmToken(fcmToken);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.todaysound.todaysound_server.global.dto;

public record PageRequest(Long page, Integer size) {
public record PageRequest(Integer page, Integer size) {
private static final Integer DEFAULT_SIZE = 5;

@Override
Expand Down
Loading