Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
eddeea0
feat: UpcomingScheduleNotificationRepository를 JPA 기반으로 분리
GoGradually Jan 17, 2026
2ca92ec
feat: PushSubscription 및 UpcomingScheduleNotification에서 UUID 생성기 사용
GoGradually Jan 17, 2026
fb3ac8c
feat: PushSubscription에서 UUID를 사용하도록 변경
GoGradually Jan 17, 2026
1102c12
feat: UpcomingScheduleNotification에서 UUID를 사용하도록 변경
GoGradually Jan 17, 2026
2c10dc9
feat: PushSubscriptionRepository를 JPA 기반으로 분리
GoGradually Jan 17, 2026
a2e589a
feat: IdGenerator 인터페이스 추가
GoGradually Jan 17, 2026
970c785
feat: IdV7GeneratorAdapter 및 UuidV7Generator 추가
GoGradually Jan 17, 2026
5c9ce45
feat: PushSubscription 및 UpcomingScheduleNotification에 대한 JPA 어댑터 추가
GoGradually Jan 17, 2026
8dca41d
feat: UpcomingScheduleNotificationRepository에서 JPA 기반 메서드로 변경
GoGradually Jan 17, 2026
4a51797
feat: PushSubscriptionEntity 및 UpcomingScheduleNotificationEntity 클래스 추가
GoGradually Jan 17, 2026
bd45db8
test: PushSubscriptionJpaRepository 및 UpcomingScheduleNotificationJpa…
GoGradually Jan 17, 2026
1f071e2
test: PushSubscriptionJpaRepository 및 UpcomingScheduleNotificationJpa…
GoGradually Jan 17, 2026
eb8b0b0
feat: PushSubscriptionEntity의 테이블 이름을 'push_subscription'으로 변경
GoGradually Jan 17, 2026
45ad1f2
feat: PushSubscriptionEntity 및 UpcomingScheduleNotificationEntity에 pu…
GoGradually Jan 17, 2026
176d3fc
feat: public_id 백필을 위한 스크립트 및 실행기 추가
GoGradually Jan 17, 2026
1c3291f
feat: FcmService 및 ScheduleNotificationService에 IdGenerator 추가
GoGradually Jan 18, 2026
c811948
test: UpcomingScheduleNotification 관련 테스트에서 불필요한 ID 검증 제거
GoGradually Jan 18, 2026
e59de82
test: PushSubscription 관련 테스트에서 불필요한 ID 검증 제거
GoGradually Jan 18, 2026
7041d03
feat: PushSubscriptionEntity 및 UpcomingScheduleNotificationEntity에서 p…
GoGradually Jan 18, 2026
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
6 changes: 6 additions & 0 deletions scripts/backfill-public-id.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail

PROFILES="${SPRING_PROFILES_ACTIVE:-backfill-public-id}"

./gradlew bootRun --args="--spring.profiles.active=${PROFILES} --spring.main.web-application-type=none"
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import me.pinitnotification.application.notification.query.ScheduleQueryPort;
import me.pinitnotification.domain.notification.UpcomingScheduleNotification;
import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository;
import me.pinitnotification.domain.shared.IdGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
Expand All @@ -18,10 +19,14 @@ public class ScheduleNotificationService {

private final UpcomingScheduleNotificationRepository notificationRepository;
private final ScheduleQueryPort scheduleQueryPort;
private final IdGenerator idGenerator;

public ScheduleNotificationService(UpcomingScheduleNotificationRepository notificationRepository, ScheduleQueryPort scheduleQueryPort) {
public ScheduleNotificationService(UpcomingScheduleNotificationRepository notificationRepository,
ScheduleQueryPort scheduleQueryPort,
IdGenerator idGenerator) {
this.notificationRepository = notificationRepository;
this.scheduleQueryPort = scheduleQueryPort;
this.idGenerator = idGenerator;
}

@Transactional
Expand Down Expand Up @@ -74,6 +79,7 @@ private UpcomingScheduleNotification buildNotification(Long ownerId, Long schedu
String scheduleStartTime = scheduleStartTimeOverride != null ? scheduleStartTimeOverride : basics.designatedStartTime();

return new UpcomingScheduleNotification(
idGenerator.generate(),
basics.ownerId(),
basics.scheduleId(),
basics.scheduleTitle(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,43 @@
package me.pinitnotification.domain.notification;


import jakarta.persistence.*;
import lombok.Getter;

import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.Map;
import java.util.UUID;

@Getter
@Entity
@Table(
name = "upcoming_schedule_notification",
uniqueConstraints = {
@UniqueConstraint(name = "uk_schedule_owner", columnNames = {"schedule_id", "owner_id"}),
@UniqueConstraint(name = "uk_idempotent_key", columnNames = {"idempotent_key"})
}
)
public class UpcomingScheduleNotification implements Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "owner_id", nullable = false)
private Long legacyId;
private UUID id;
private Long ownerId;
@Column(name = "schedule_id", nullable = false)
private Long scheduleId;
@Column(name = "schedule_title", nullable = false)
private String scheduleTitle;
@Column(name = "schedule_start_time", nullable = false)
private String scheduleStartTime;
@Column(name = "idempotent_key", nullable = false)
private String idempotentKey;

protected UpcomingScheduleNotification() {}

public UpcomingScheduleNotification(Long legacyId, UUID id, Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) {
this.legacyId = legacyId;
this.id = id;
this.ownerId = ownerId;
this.scheduleId = scheduleId;
this.scheduleTitle = scheduleTitle;
this.scheduleStartTime = scheduleStartTime;
this.idempotentKey = idempotentKey;
}

public UpcomingScheduleNotification(UUID id, Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) {
this.id = id;
this.ownerId = ownerId;
this.scheduleId = scheduleId;
this.scheduleTitle = scheduleTitle;
this.scheduleStartTime = scheduleStartTime;
this.idempotentKey = idempotentKey;
}

public UpcomingScheduleNotification(Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) {
this.ownerId = ownerId;
this.scheduleId = scheduleId;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package me.pinitnotification.domain.notification;

import org.springframework.data.jpa.repository.JpaRepository;

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

public interface UpcomingScheduleNotificationRepository extends JpaRepository<UpcomingScheduleNotification, Long> {
public interface UpcomingScheduleNotificationRepository {
List<UpcomingScheduleNotification> findAll();
Optional<UpcomingScheduleNotification> findByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
boolean existsByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
UpcomingScheduleNotification save(UpcomingScheduleNotification notification);
void deleteByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
void deleteAllInBatch(List<UpcomingScheduleNotification> notifications);
}
Original file line number Diff line number Diff line change
@@ -1,47 +1,44 @@
package me.pinitnotification.domain.push;

import jakarta.persistence.*;
import lombok.Getter;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.Instant;
import java.util.UUID;

@Entity
@Table(
uniqueConstraints = {
@UniqueConstraint(
name = "uk_deviceId_memberId",
columnNames = {"member_id", "device_id"}
)
}
)
@EntityListeners(AuditingEntityListener.class)
@Getter
public class PushSubscription {
@Id
@GeneratedValue
private Long id;

@Column(name = "member_id", nullable = false)
private Long legacyId;
private UUID id;
private Long memberId;
@Column(name = "device_id", nullable = false)
private String deviceId;
@Column(name = "token", nullable = false)
private String token;
@LastModifiedDate
@Column(name = "modified_at", nullable = false)
private Instant modifiedAt;
protected PushSubscription() {}

public PushSubscription(Long legacyId, UUID id, Long memberId, String deviceId, String token, Instant modifiedAt) {
this.legacyId = legacyId;
this.id = id;
this.memberId = memberId;
this.deviceId = deviceId;
this.token = token;
this.modifiedAt = modifiedAt;
}

public PushSubscription(UUID id, Long memberId, String deviceId, String token) {
this.id = id;
this.memberId = memberId;
this.deviceId = deviceId;
this.token = token;
}

public PushSubscription(Long memberId, String deviceId, String token) {
this.memberId = memberId;
this.deviceId = deviceId;
this.token = token;
}

public void updateToken(String token) {
if (this.modifiedAt.isAfter(Instant.now())) {
if (this.modifiedAt != null && this.modifiedAt.isAfter(Instant.now())) {
return;
}
this.token = token;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package me.pinitnotification.domain.push;

import org.springframework.data.jpa.repository.JpaRepository;

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

public interface PushSubscriptionRepository extends JpaRepository<PushSubscription, Long> {
public interface PushSubscriptionRepository {
Optional<PushSubscription> findByMemberIdAndDeviceId(Long memberId, String deviceId);

List<PushSubscription> findAllByMemberId(Long memberId);

PushSubscription save(PushSubscription subscription);

void deleteByToken(String token);

void deleteByMemberIdAndDeviceId(Long memberId, String deviceId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package me.pinitnotification.domain.shared;

import java.util.UUID;

public interface IdGenerator {
UUID generate();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package me.pinitnotification.infrastructure.batch;

import me.pinitnotification.domain.shared.IdGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import java.util.List;

@Profile("backfill-public-id")
@Component
public class PublicIdBackfillRunner implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(PublicIdBackfillRunner.class);

private final JdbcTemplate jdbcTemplate;
private final IdGenerator idGenerator;
private final int batchSize;

public PublicIdBackfillRunner(JdbcTemplate jdbcTemplate,
IdGenerator idGenerator,
@Value("${backfill.public-id.batch-size:500}") int batchSize) {
this.jdbcTemplate = jdbcTemplate;
this.idGenerator = idGenerator;
this.batchSize = batchSize;
}

@Override
public void run(String... args) {
backfillTable("upcoming_schedule_notification");
backfillTable("push_subscription");
}

private void backfillTable(String table) {
int total = 0;
while (true) {
List<Long> ids = jdbcTemplate.query(
"select id from " + table + " where public_id is null limit ?",
Comment on lines +40 to +41
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

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

(1) 문제점: SQL 인젝션 취약점이 있습니다. 테이블 이름이 직접 문자열 연결로 SQL 쿼리에 삽입되고 있습니다.

(2) 영향: 악의적인 테이블 이름이 전달될 경우 임의의 SQL이 실행될 수 있습니다.

(3) 수정 제안: 테이블 이름을 화이트리스트로 검증하거나, PreparedStatement의 테이블 이름 파라미터화를 사용할 수 없으므로 허용된 테이블 목록과 비교하여 검증해야 합니다.

Copilot uses AI. Check for mistakes.
ps -> ps.setInt(1, batchSize),
(rs, rowNum) -> rs.getLong(1)
);

if (ids.isEmpty()) {
break;
}

for (Long id : ids) {
int updated = jdbcTemplate.update(
"update " + table + " set public_id = ? where id = ? and public_id is null",
Comment on lines +51 to +52
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

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

(1) 문제점: SQL 인젝션 취약점이 있습니다. 테이블 이름이 직접 문자열 연결로 UPDATE 쿼리에 삽입되고 있습니다.

(2) 영향: 악의적인 테이블 이름이 전달될 경우 임의의 SQL이 실행될 수 있습니다.

(3) 수정 제안: 테이블 이름을 화이트리스트로 검증하거나, 허용된 테이블 목록과 비교하여 검증해야 합니다.

Copilot uses AI. Check for mistakes.
idGenerator.generate().toString(),
id
);
total += updated;
Comment on lines +50 to +56
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

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

(1) 문제점: 배치 업데이트가 개별 UPDATE 문으로 실행되어 성능이 매우 비효율적입니다. 각 레코드마다 별도의 데이터베이스 왕복이 발생합니다.

(2) 영향: 대량의 레코드를 백필해야 할 경우 실행 시간이 매우 오래 걸리고 데이터베이스에 과도한 부하가 발생할 수 있습니다.

(3) 수정 제안: jdbcTemplate.batchUpdate()를 사용하여 배치로 업데이트를 수행하거나, CASE 문을 사용한 단일 UPDATE 쿼리로 여러 레코드를 한번에 업데이트해야 합니다.

Suggested change
for (Long id : ids) {
int updated = jdbcTemplate.update(
"update " + table + " set public_id = ? where id = ? and public_id is null",
idGenerator.generate().toString(),
id
);
total += updated;
int[] updatedCounts = jdbcTemplate.batchUpdate(
"update " + table + " set public_id = ? where id = ? and public_id is null",
ids,
batchSize,
(ps, id) -> {
ps.setString(1, idGenerator.generate().toString());
ps.setLong(2, id);
}
);
for (int count : updatedCounts) {
total += count;

Copilot uses AI. Check for mistakes.
}
}

log.info("Backfill completed for table={}, updatedRows={}", table, total);
}
}
Comment on lines +16 to +62
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

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

(1) 문제점: PublicIdBackfillRunner에 대한 테스트가 없습니다. 배치 작업의 정확성, 멱등성, 에러 처리 등을 검증하는 테스트가 필요합니다.

(2) 영향: 백필 로직에 버그가 있어도 프로덕션 환경에서만 발견될 수 있으며, 데이터 정합성 문제를 초래할 수 있습니다.

(3) 수정 제안: PublicIdBackfillRunnerTest를 추가하여 배치 처리 로직, 빈 테이블 처리, 부분 업데이트 등의 시나리오를 테스트해야 합니다.

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import me.pinitnotification.domain.notification.Notification;
import me.pinitnotification.domain.push.PushSubscription;
import me.pinitnotification.domain.push.PushSubscriptionRepository;
import me.pinitnotification.domain.shared.IdGenerator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -22,10 +23,14 @@ public class FcmService implements PushService {
private String vapidPublicKey;
private final FirebaseMessaging firebaseMessaging;
private final PushSubscriptionRepository pushSubscriptionRepository;
private final IdGenerator idGenerator;

public FcmService(FirebaseMessaging firebaseMessaging, PushSubscriptionRepository pushSubscriptionRepository) {
public FcmService(FirebaseMessaging firebaseMessaging,
PushSubscriptionRepository pushSubscriptionRepository,
IdGenerator idGenerator) {
this.firebaseMessaging = firebaseMessaging;
this.pushSubscriptionRepository = pushSubscriptionRepository;
this.idGenerator = idGenerator;
}

@Override
Expand Down Expand Up @@ -64,7 +69,7 @@ public void subscribe(Long memberId, String deviceId, String token) {
PushSubscription existingSubscription = byMemberIdAndDeviceId.get();
existingSubscription.updateToken(token);
} else {
pushSubscriptionRepository.save(new PushSubscription(memberId, deviceId, token));
pushSubscriptionRepository.save(new PushSubscription(idGenerator.generate(), memberId, deviceId, token));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package me.pinitnotification.infrastructure.persistence;

import me.pinitnotification.domain.shared.IdGenerator;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
public class IdV7GeneratorAdapter implements IdGenerator {
@Override
public UUID generate() {
return UuidV7Generator.generate();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package me.pinitnotification.infrastructure.persistence;

import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;

public final class UuidV7Generator {
private UuidV7Generator() {
}

public static UUID generate() {
byte[] bytes = new byte[16];
long millis = System.currentTimeMillis();

bytes[0] = (byte) (millis >>> 40);
bytes[1] = (byte) (millis >>> 32);
bytes[2] = (byte) (millis >>> 24);
bytes[3] = (byte) (millis >>> 16);
bytes[4] = (byte) (millis >>> 8);
bytes[5] = (byte) millis;

int randA = ThreadLocalRandom.current().nextInt(1 << 12);
bytes[6] = (byte) (0x70 | ((randA >>> 8) & 0x0F));
bytes[7] = (byte) randA;

long randB = ThreadLocalRandom.current().nextLong();
bytes[8] = (byte) (randB >>> 56);
bytes[9] = (byte) (randB >>> 48);
bytes[10] = (byte) (randB >>> 40);
bytes[11] = (byte) (randB >>> 32);
bytes[12] = (byte) (randB >>> 24);
bytes[13] = (byte) (randB >>> 16);
bytes[14] = (byte) (randB >>> 8);
bytes[15] = (byte) randB;

bytes[8] = (byte) ((bytes[8] & 0x3F) | 0x80);

long msb = 0;
long lsb = 0;
for (int i = 0; i < 8; i++) {
msb = (msb << 8) | (bytes[i] & 0xFF);
}
for (int i = 8; i < 16; i++) {
lsb = (lsb << 8) | (bytes[i] & 0xFF);
}
return new UUID(msb, lsb);
}
}
Comment on lines +10 to +47
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

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

(1) 문제점: UuidV7Generator의 구현이 테스트되지 않았습니다. UUID v7 스펙을 정확히 구현했는지, 시간 기반 정렬이 보장되는지 등을 검증하는 테스트가 없습니다.

(2) 영향: UUID 생성 로직에 버그가 있어도 감지할 수 없으며, 향후 정렬 순서나 충돌 문제가 발생할 수 있습니다.

(3) 수정 제안: UuidV7GeneratorTest를 추가하여 생성된 UUID의 형식, 시간순 정렬, 고유성 등을 검증해야 합니다.

Copilot uses AI. Check for mistakes.
Loading
Loading