diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/user/UserRepository.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/user/UserRepository.java index 946740172..2a2585b6c 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/user/UserRepository.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/user/UserRepository.java @@ -86,4 +86,9 @@ Optional findByPhoneNumber( Optional findByEmailAndName(String email, String name); boolean existsBy(); + + Optional findTopBy(); + + @Query("SELECT u.id FROM User u") + List findAllIds(); } \ No newline at end of file diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/ActionType.java b/app-main/src/main/java/net/causw/app/main/shared/seed/ActionType.java new file mode 100644 index 000000000..6ae8c0bc5 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/ActionType.java @@ -0,0 +1,27 @@ +package net.causw.app.main.shared.seed; + +public enum ActionType { + // Whale(70%), Dolphin(25%), Minnow(5%) + POST(0.70, 0.70 + 0.25, 3.0), + + // Whale(40%), Dolphin(40%), Minnow(20%) + COMMENT(0.40, 0.40 + 0.40, 2.0), + + // Whale(20%), Dolphin(30%), Minnow(50%) + LIKE(0.20, 0.20 + 0.30, 1.0); + + // 누적 임계값 (0.0 ~ 1.0) + public final double whaleThreshold; // 이 값보다 작으면 Whale + public final double dolphinThreshold; // 이 값보다 작으면 Dolphin, 아니면 Minnow + + // 그룹 내 편중도 (Zipfian exponent) + // 행동이 어려울수록(Post) 상위 유저 중에서도 더 상위에게 몰리는 경향(3.0)이 심함 + // 행동이 쉬울수록(Like) 그룹 내에서는 평등하게 퍼짐(1.0) + public final double skewFactor; + + ActionType(double whaleProb, double cumulativeDolphinProb, double skewFactor) { + this.whaleThreshold = whaleProb; + this.dolphinThreshold = cumulativeDolphinProb; + this.skewFactor = skewFactor; + } +} diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/BasePostSeeder.java b/app-main/src/main/java/net/causw/app/main/shared/seed/BasePostSeeder.java new file mode 100644 index 000000000..d76016e4d --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/BasePostSeeder.java @@ -0,0 +1,75 @@ +package net.causw.app.main.shared.seed; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +import net.causw.app.main.shared.util.DistributionUtils; +import net.causw.app.main.shared.util.UserSegmenter; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public abstract class BasePostSeeder { + protected final JdbcTemplate jdbcTemplate; + protected final UserSegmenter userSegmenter; // 유저 유틸 상속 + protected final int totalCount; + protected final int batchSize; + + // [Template Method] 실행 흐름을 제어 (final로 오버라이드 방지를 하려고 했으나, AOP 프록시 객체와의 충돌로 제거함) + @Transactional + public void seed() { + log.info("[Seeder] Start seeding {} items for {}", totalCount, this.getClass().getSimpleName()); + beforeSeeding(); + long startTime = System.currentTimeMillis(); + + List buffer = new ArrayList<>(); + + for (int i = 0; i < totalCount; i++) { + // 1. 데이터 생성 위임 (Hook Method) + T item = createItem(i); + buffer.add(item); + + // 2. 배치가 차면 DB 투입 + if (buffer.size() >= batchSize) { + batchInsert(buffer); + buffer.clear(); + } + } + + // 3. 남은 데이터 처리 + if (!buffer.isEmpty()) { + batchInsert(buffer); + } + + long endTime = System.currentTimeMillis(); + log.info("[Seeder] Finished. Time taken: {}ms", endTime - startTime); + } + + protected String pickUser() { + ActionType type = getActionType(); + int groupType = DistributionUtils.selectUserGroupType(type); + + // UserSegmenter 내부도 List으로 관리되고 있다고 가정 + List targetGroup = switch (groupType) { + case 0 -> userSegmenter.getWhales(); + case 1 -> userSegmenter.getDolphins(); + default -> userSegmenter.getActiveMinnows(); + }; + + return DistributionUtils.pickWeightedRandom(targetGroup, type.skewFactor); + } + + // [Abstract Methods] 자식들이 반드시 구현해야 할 부분 + protected abstract T createItem(int index); // 데이터 생성 전략 + + protected abstract void batchInsert(List items); // DB 저장 전략 + + protected abstract ActionType getActionType(); // 어떤 확률 분포를 쓸지 + + protected void beforeSeeding() {} +} \ No newline at end of file diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/BoardSeeder.java b/app-main/src/main/java/net/causw/app/main/shared/seed/BoardSeeder.java new file mode 100644 index 000000000..6490d00da --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/BoardSeeder.java @@ -0,0 +1,85 @@ +package net.causw.app.main.shared.seed; + +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import net.causw.app.main.domain.community.board.entity.Board; +import net.causw.app.main.domain.community.board.entity.BoardApply; +import net.causw.app.main.domain.community.board.entity.BoardApplyStatus; +import net.causw.app.main.domain.community.board.repository.BoardApplyRepository; +import net.causw.app.main.domain.community.board.repository.BoardRepository; +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.domain.user.account.repository.user.UserRepository; +import net.causw.global.constant.StaticValue; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Profile("seed") +@RequiredArgsConstructor +@Slf4j +public class BoardSeeder { + private final EntityManager em; + private final BoardApplyRepository boardApplyRepository; + private final BoardRepository boardRepository; + private final UserRepository userRepository; + + @Transactional + public void seed() { + if (boardRepository.count() > 0) { + log.warn("🚫 Seed skipped: boards already exist"); + return; + } + + User user = userRepository.findTopBy() + .orElseThrow(() -> new IllegalStateException("🚫 Seed skipped: User not found for seeding boards")); + + process(user); + } + + private void process(User user) { + // 게시판 카테고리 이름 예시 (20개) + List boardNames = List.of( + "자유게시판", "익명게시판", "질문게시판", "정보공유", "취업게시판", + "동아리홍보", "스터디모집", "중고장터", "분실물센터", "학교생활", + "유머게시판", "건의사항", "컴공게시판", "학사공지", "장학공지", + "학생회 공지 게시판", "크자회 공지 게시판", "서비스 공지", "졸업생게시판", "새내기게시판"); + + for (String name : boardNames) { + createActiveBoard(user, name); + } + + em.flush(); + em.clear(); + + log.info("✅ Seeded {} boards completed.", boardNames.size()); + } + + private void createActiveBoard(User user, String name) { + BoardApply boardApply = BoardApply.of( // 일반 게시판 신청 + user, + name, + null, + StaticValue.BOARD_NAME_APP_FREE, + false, + null); + boardApply.updateAcceptStatus(BoardApplyStatus.ACCEPTED); + this.boardApplyRepository.save(boardApply); + em.persist(boardApply); + + Board newBoard = Board.of( // 일반 게시판 생성 + boardApply.getBoardName(), + boardApply.getDescription(), + boardApply.getCategory(), + boardApply.getIsAnonymousAllowed(), + null); + + this.boardRepository.save(newBoard); + em.persist(newBoard); + } +} diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/ChildCommentSeeder.java b/app-main/src/main/java/net/causw/app/main/shared/seed/ChildCommentSeeder.java new file mode 100644 index 000000000..1142d3773 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/ChildCommentSeeder.java @@ -0,0 +1,133 @@ +package net.causw.app.main.shared.seed; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import net.causw.app.main.shared.util.UserSegmenter; + +@Component +@Profile("seed") +public class ChildCommentSeeder extends BasePostSeeder { + private static final Random random = new Random(); + private final List parentComments = new ArrayList<>(); + + public ChildCommentSeeder(JdbcTemplate jdbcTemplate, UserSegmenter userSegmenter) { + super(jdbcTemplate, userSegmenter, 50_000, 1_000); + } + + @Override + protected ActionType getActionType() { + return ActionType.COMMENT; + } + + @Override + protected void beforeSeeding() { + loadCommentIds(); + } + + @Override + protected ChildCommentItem createItem(int index) { + // 1. 부모 메서드를 통해 확률에 맞는 작성자 선정 + String writerId = pickUser(); + // 2. 낙수 효과에 따라 부모 댓글 선정 + ParentCommentInfo parentInfo = parentComments.get(random.nextInt(parentComments.size())); + LocalDateTime createdAt = LocalDateTime.now().minusMinutes(totalCount - index); + + return new ChildCommentItem( + UUID.randomUUID().toString(), + "Child Comment " + index, + parentInfo.postId(), + parentInfo.id, + writerId, + createdAt); + } + + @Override + protected void batchInsert(List items) { + // 1. 대댓글 삽입 + String sqlChildComment = "INSERT INTO tb_child_comment " + + "(id, content, parent_comment_id, user_id, is_anonymous, is_deleted, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, false, false, ?, ?)"; + + jdbcTemplate.batchUpdate(sqlChildComment, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ChildCommentItem item = items.get(i); + + ps.setString(1, item.id()); + ps.setString(2, item.content()); + ps.setString(3, item.parentId()); + ps.setString(4, item.writerId()); + ps.setTimestamp(5, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(6, Timestamp.valueOf(item.createdAt())); + } + + @Override + public int getBatchSize() { + return items.size(); + } + }); + + // 2. 알림 삽입 + String sqlNotification = "INSERT INTO tb_notification " + + "(id, user_id, content, notice_type, target_id, is_global, created_at, updated_at) " + + "VALUES (?, ?, ?, 'COMMENT', ?, false, ?, ?)"; + + jdbcTemplate.batchUpdate(sqlNotification, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ChildCommentItem item = items.get(i); + + ps.setString(1, UUID.randomUUID().toString()); + ps.setString(2, item.writerId()); + ps.setString(3, item.content()); + ps.setString(4, item.postId()); + ps.setTimestamp(5, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(6, Timestamp.valueOf(item.createdAt())); + } + + @Override + public int getBatchSize() { + return items.size(); + } + }); + } + + private void loadCommentIds() { + String sql = "SELECT id, post_id FROM tb_comment"; + jdbcTemplate.query(sql, rs -> { + String id = rs.getString("id"); + String postId = rs.getString("post_id"); + parentComments.add(new ParentCommentInfo(id, postId)); + }); + + if (parentComments.isEmpty()) { + throw new RuntimeException("❌ 부모 댓글이 없습니다! CommentSeeder를 먼저 실행해주세요."); + } + } + + private record ParentCommentInfo( + String id, + String postId) { + } + + public record ChildCommentItem( + String id, + String content, + String postId, + String parentId, + String writerId, + LocalDateTime createdAt) { + } +} diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/CommentSeeder.java b/app-main/src/main/java/net/causw/app/main/shared/seed/CommentSeeder.java new file mode 100644 index 000000000..5c3de1a52 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/CommentSeeder.java @@ -0,0 +1,136 @@ +package net.causw.app.main.shared.seed; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import net.causw.app.main.shared.util.HotPostSampler; +import net.causw.app.main.shared.util.UserSegmenter; + +@Component +@Profile("seed") +public class CommentSeeder extends BasePostSeeder { + + private final HotPostSampler hotPostSampler; + + public CommentSeeder(JdbcTemplate jdbcTemplate, UserSegmenter userSegmenter, HotPostSampler hotPostSampler) { + super(jdbcTemplate, userSegmenter, 150_000, 1_000); + this.hotPostSampler = hotPostSampler; + } + + @Override + protected ActionType getActionType() { + return ActionType.COMMENT; + } + + @Override + protected void beforeSeeding() { + hotPostSampler.init(); + } + + @Override + protected CommentItem createItem(int index) { + // 1. 부모 메서드를 통해 확률에 맞는 작성자 선정 + String writerId = pickUser(); + // 2. 메트칼프의 법칙에 따라 대상 게시글 선정 + PostMetaData targetPost = hotPostSampler.pickHotPost(); + LocalDateTime createdAt = LocalDateTime.now().minusMinutes(totalCount - index); + + return new CommentItem( + UUID.randomUUID().toString(), + "Comment " + index, + targetPost.id(), + writerId, + createdAt); + } + + @Override + protected void batchInsert(List items) { + // 1. 댓글 삽입 + String sqlComment = "INSERT INTO tb_comment (id, content, post_id, user_id, is_anonymous, is_deleted, created_at, updated_at) " + + + "VALUES (?, ?, ?, ?, false, false, ?, ?)"; + + jdbcTemplate.batchUpdate(sqlComment, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + CommentItem item = items.get(i); + + ps.setString(1, item.id()); + ps.setString(2, item.content()); + ps.setString(3, item.postId()); + ps.setString(4, item.writerId()); + ps.setTimestamp(5, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(6, Timestamp.valueOf(item.createdAt())); + } + + @Override + public int getBatchSize() { + return items.size(); + } + }); + + // 2. 구독자 연결 + String sqlSubscribe = "INSERT INTO tb_user_comment_subscribe " + + "(id, is_subscribed, comment_id, user_id, created_at, updated_at) " + + "VALUES (?, true, ?, ?, ?, ?)"; + + jdbcTemplate.batchUpdate(sqlSubscribe, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + CommentItem item = items.get(i); + + ps.setString(1, UUID.randomUUID().toString()); + ps.setString(2, item.id); + ps.setString(3, item.writerId()); + ps.setTimestamp(4, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(5, Timestamp.valueOf(item.createdAt())); + } + + @Override + public int getBatchSize() { + return items.size(); + } + }); + + // 3. 알림 삽입 + String sqlNotification = "INSERT INTO tb_notification " + + "(id, user_id, content, notice_type, target_id, is_global, created_at, updated_at) " + + "VALUES (?, ?, ?, 'POST', ?, false, ?, ?)"; + + jdbcTemplate.batchUpdate(sqlNotification, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + CommentItem item = items.get(i); + + ps.setString(1, UUID.randomUUID().toString()); + ps.setString(2, item.writerId()); + ps.setString(3, item.content()); + ps.setString(4, item.postId()); + ps.setTimestamp(5, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(6, Timestamp.valueOf(item.createdAt())); + } + + @Override + public int getBatchSize() { + return items.size(); + } + }); + } + + public record CommentItem( + String id, + String content, + String postId, + String writerId, + LocalDateTime createdAt) { + } +} diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/InteractionSeeder.java b/app-main/src/main/java/net/causw/app/main/shared/seed/InteractionSeeder.java new file mode 100644 index 000000000..f5a89663e --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/InteractionSeeder.java @@ -0,0 +1,86 @@ +package net.causw.app.main.shared.seed; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import net.causw.app.main.shared.util.HotPostSampler; +import net.causw.app.main.shared.util.UserSegmenter; + +@Component +@Profile("seed") +public class InteractionSeeder extends BasePostSeeder { + + private final HotPostSampler hotPostSampler; + + public InteractionSeeder(JdbcTemplate jdbcTemplate, UserSegmenter userSegmenter, HotPostSampler hotPostSampler) { + super(jdbcTemplate, userSegmenter, 500_000, 1_000); + this.hotPostSampler = hotPostSampler; + } + + @Override + protected ActionType getActionType() { + return ActionType.LIKE; + } + + @Override + protected void beforeSeeding() { + hotPostSampler.init(); + } + + @Override + protected LikeItem createItem(int index) { + // 1. 부모 메서드를 통해 확률에 맞는 작성자 선정 + String userId = pickUser(); + // 2. 메트칼프의 법칙에 따라 대상 게시글 선정 + PostMetaData targetPost = hotPostSampler.pickHotPost(); + LocalDateTime createdAt = LocalDateTime.now().minusMinutes(totalCount - index); + + return new LikeItem( + UUID.randomUUID().toString(), + targetPost.id(), + userId, + createdAt); + } + + @Override + protected void batchInsert(List items) { + // NOTE: 중복이더라도 업데이트가 지속되도록 DB 단에서 방어, 따라서 예상보다 시딩이 덜 될 수는 있음. + String sqlLike = "INSERT IGNORE INTO tb_like_post (id, post_id, user_id, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?)"; + + jdbcTemplate.batchUpdate(sqlLike, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + LikeItem item = items.get(i); + Timestamp timestamp = Timestamp.valueOf(item.createdAt()); + + ps.setString(1, item.id()); + ps.setString(2, item.postId()); + ps.setString(3, item.userId()); + ps.setTimestamp(4, timestamp); + ps.setTimestamp(5, timestamp); + } + + @Override + public int getBatchSize() { + return items.size(); + } + }); + } + + public record LikeItem( + String id, + String postId, + String userId, + LocalDateTime createdAt) { + } +} diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/PostMetaData.java b/app-main/src/main/java/net/causw/app/main/shared/seed/PostMetaData.java new file mode 100644 index 000000000..c2a49d28e --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/PostMetaData.java @@ -0,0 +1,7 @@ +package net.causw.app.main.shared.seed; + +public record PostMetaData( + String id, + String boardId, + double viralScore) { +} \ No newline at end of file diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/PostSeedRunner.java b/app-main/src/main/java/net/causw/app/main/shared/seed/PostSeedRunner.java new file mode 100644 index 000000000..d86d66255 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/PostSeedRunner.java @@ -0,0 +1,34 @@ +package net.causw.app.main.shared.seed; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Profile("seed") +@RequiredArgsConstructor +@Slf4j +@Order(2) +public class PostSeedRunner implements CommandLineRunner { + + private final BoardSeeder boardSeeder; + private final PostSeeder postSeeder; + private final CommentSeeder commentSeeder; + private final ChildCommentSeeder childCommentSeeder; + private final InteractionSeeder interactionSeeder; + + @Override + public void run(String... args) { + log.info("🌱 Seeding data initialized..."); + boardSeeder.seed(); + postSeeder.seed(); + commentSeeder.seed(); + childCommentSeeder.seed(); + interactionSeeder.seed(); + log.info("🌳 Seeding data finished."); + } +} diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/PostSeeder.java b/app-main/src/main/java/net/causw/app/main/shared/seed/PostSeeder.java new file mode 100644 index 000000000..92e8dada9 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/PostSeeder.java @@ -0,0 +1,249 @@ +package net.causw.app.main.shared.seed; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import net.causw.app.main.shared.util.DistributionUtils; +import net.causw.app.main.shared.util.UserSegmenter; + +@Component +@Profile("seed") +public class PostSeeder extends BasePostSeeder { + + private String freeBoardId; + private String humorBoardId; + private List otherBoardIds; + + public PostSeeder(JdbcTemplate jdbcTemplate, UserSegmenter userSegmenter) { + super(jdbcTemplate, userSegmenter, 100_000, 1_000); + } + + @Override + protected ActionType getActionType() { + return ActionType.POST; + } + + @Override + protected void beforeSeeding() { + loadBoardIds(); + } + + @Override + protected PostItem createItem(int index) { + // 1. 부모 메서드를 통해 확률에 맞는 작성자 선정 + String writerId = pickUser(); + // 2. 파레토 법칙으로 게시판 선정 + String boardId = DistributionUtils.selectBoardId(freeBoardId, humorBoardId, otherBoardIds); + String postId = UUID.randomUUID().toString(); + LocalDateTime createdAt = LocalDateTime.now().minusMinutes(totalCount - index); + + // 3. 이미지 시딩: 50% 확률로 0장, 나머지 50% 확률로 랜덤하게 1~3장 + List images = new ArrayList<>(); + if (Math.random() < 0.5) { // 50% 확률로 진입 + int imageCount = (int)(Math.random() * 3) + 1; // 1~3장 + + for (int i = 0; i < imageCount; i++) { + String fileUuid = UUID.randomUUID().toString(); + String fileKey = "seed/post/" + fileUuid + ".png"; + String fileUrl = "https://cdn.seed.test/post/" + fileUuid + ".png"; + + images.add(new PostImageItem( + fileUuid, + postId, + fileKey, + fileUrl, // 확장자 png로 고정 + createdAt.plusSeconds(i))); + } + } + + return new PostItem( + postId, + writerId, + boardId, + "Seeding Title " + index, + "This is seeding content for post " + index, + createdAt, + images); + } + + @Override + protected void batchInsert(List items) { + // 1. 게시글 삽입 + String sqlPost = "INSERT INTO tb_post (id, user_id, board_id, title, content, created_at, updated_at, is_deleted) " + + + "VALUES (?, ?, ?, ?, ?, ?, ?, false)"; + + jdbcTemplate.batchUpdate(sqlPost, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + PostItem item = items.get(i); + ps.setString(1, item.id); + ps.setString(2, item.writerId()); + ps.setString(3, item.boardId()); + ps.setString(4, item.title()); + ps.setString(5, item.content()); + ps.setTimestamp(6, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(7, Timestamp.valueOf(item.createdAt())); // updated_at도 동일하게 + } + + @Override + public int getBatchSize() { + return items.size(); + } + }); + + // 2. 이미지 삽입 및 연결 + List images = items.stream() + .flatMap(item -> item.images().stream()) + .toList(); + + if (!images.isEmpty()) { + String sqlUuid = "INSERT INTO tb_uuid_file " + + "(id, uuid, file_key, raw_file_name, file_path, file_url, extension, created_at, updated_at) " + + "VALUES (?, ?, ?, 'post.png', 'POST', ?, 'png', ?, ?)"; + + jdbcTemplate.batchUpdate(sqlUuid, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + PostImageItem item = images.get(i); + ps.setString(1, item.id); + ps.setString(2, item.id); + ps.setString(3, item.fileKey()); + ps.setString(4, item.fileUrl()); + ps.setTimestamp(5, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(6, Timestamp.valueOf(item.createdAt())); // updated_at도 동일하게 + } + + @Override + public int getBatchSize() { + return images.size(); + } + }); + String sqlMapping = "INSERT INTO tb_post_attach_image_uuid_file " + + "(id, post_id, uuid_file_id, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?)"; + + jdbcTemplate.batchUpdate(sqlMapping, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + PostImageItem item = images.get(i); + ps.setString(1, UUID.randomUUID().toString()); + ps.setString(2, item.postId()); + ps.setString(3, item.id()); + ps.setTimestamp(4, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(5, Timestamp.valueOf(item.createdAt())); + } + + @Override + public int getBatchSize() { + return images.size(); + } + }); + } + + // 3. 구독자 연결 + String sqlSub = "INSERT INTO tb_user_post_subscribe " + + "(id, post_id, user_id, is_subscribed, created_at, updated_at) " + + "VALUES (?, ?, ?, true, ?, ?)"; + + jdbcTemplate.batchUpdate(sqlSub, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + PostItem item = items.get(i); + + ps.setString(1, UUID.randomUUID().toString()); + ps.setString(2, item.id); + ps.setString(3, item.writerId()); + ps.setTimestamp(4, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(5, Timestamp.valueOf(item.createdAt())); + } + + @Override + public int getBatchSize() { + return items.size(); + } + }); + + // 4. 알림 삽입 + String sqlNotification = "INSERT INTO tb_notification " + + "(id, user_id, content, notice_type, target_id, is_global, created_at, updated_at) " + + "VALUES (?, ?, ?, 'BOARD', ?, false, ?, ?)"; + + jdbcTemplate.batchUpdate(sqlNotification, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + PostItem item = items.get(i); + String notificationId = UUID.randomUUID().toString(); + + ps.setString(1, notificationId); + ps.setString(2, item.writerId()); + ps.setString(3, item.title()); + ps.setString(4, item.id); + ps.setTimestamp(5, Timestamp.valueOf(item.createdAt())); + ps.setTimestamp(6, Timestamp.valueOf(item.createdAt())); + } + + @Override + public int getBatchSize() { + return items.size(); + } + }); + } + + private void loadBoardIds() { + String query = "SELECT id, name FROM tb_board"; + + List> boards = jdbcTemplate.queryForList(query); + + List others = new ArrayList<>(); + + for (Map row : boards) { + String id = (String)row.get("id"); // UUID String + String name = (String)row.get("name"); + + if ("자유게시판".equals(name)) { + this.freeBoardId = id; + } else if ("유머게시판".equals(name)) { + this.humorBoardId = id; + } else { + others.add(id); + } + } + this.otherBoardIds = others; + + // 예외 처리 + if (this.freeBoardId == null) + throw new RuntimeException("자유게시판이 없습니다. BoardSeeder를 먼저 실행하세요."); + if (this.humorBoardId == null) + throw new RuntimeException("유머게시판이 없습니다. BoardSeeder를 먼저 실행하세요."); + } + + public record PostItem( + String id, + String writerId, + String boardId, + String title, + String content, + LocalDateTime createdAt, + List images) { + } + + public record PostImageItem( + String id, + String postId, + String fileKey, + String fileUrl, + LocalDateTime createdAt) { + } +} \ No newline at end of file diff --git a/app-main/src/main/java/net/causw/app/main/shared/seed/UserSeedRunner.java b/app-main/src/main/java/net/causw/app/main/shared/seed/UserSeedRunner.java index 5dd60bf10..b7618fd46 100644 --- a/app-main/src/main/java/net/causw/app/main/shared/seed/UserSeedRunner.java +++ b/app-main/src/main/java/net/causw/app/main/shared/seed/UserSeedRunner.java @@ -2,10 +2,12 @@ import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @Component @Profile("seed") +@Order(1) public class UserSeedRunner implements CommandLineRunner { private final UserSeeder userSeeder; diff --git a/app-main/src/main/java/net/causw/app/main/shared/util/DistributionUtils.java b/app-main/src/main/java/net/causw/app/main/shared/util/DistributionUtils.java new file mode 100644 index 000000000..d97174a67 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/util/DistributionUtils.java @@ -0,0 +1,41 @@ +package net.causw.app.main.shared.util; + +import java.util.List; +import java.util.Random; + +import net.causw.app.main.shared.seed.ActionType; + +public class DistributionUtils { + private static final Random random = new Random(); + + // 게시판 ID 선택 로직 (파레토: 1번 55%, 2번 30%, 나머지 15%) + public static String selectBoardId(String freeBoardId, String humorBoardId, List otherBoardIds) { + double p = random.nextDouble(); + if (p < 0.55) + return freeBoardId; // 실제 자유게시판 ID + if (p < 0.85) + return humorBoardId; // 실제 유머게시판 ID + + return otherBoardIds.get(random.nextInt(otherBoardIds.size())); + } + + // 반환값: 0=Whale, 1=Dolphin, 2=ActiveMinnow + public static int selectUserGroupType(ActionType actionType) { + double p = random.nextDouble(); // 0.0 ~ 1.0 난수 생성 + + if (p < actionType.whaleThreshold) { + return 0; // Whale 당첨 + } else if (p < actionType.dolphinThreshold) { + return 1; // Dolphin 당첨 + } else { + return 2; // Minnow 당첨 + } + } + + // 그룹 내에서 누가 활동할지 결정 + public static T pickWeightedRandom(List list, double n) { + int index = (int)(list.size() * Math.pow(random.nextDouble(), n)); + index = Math.max(0, Math.min(index, list.size() - 1)); + return list.get(index); + } +} \ No newline at end of file diff --git a/app-main/src/main/java/net/causw/app/main/shared/util/HotPostSampler.java b/app-main/src/main/java/net/causw/app/main/shared/util/HotPostSampler.java new file mode 100644 index 000000000..2dd732a0f --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/util/HotPostSampler.java @@ -0,0 +1,80 @@ +package net.causw.app.main.shared.util; + +import java.util.TreeMap; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import net.causw.app.main.shared.seed.PostMetaData; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class HotPostSampler { + + private final JdbcTemplate jdbcTemplate; + + // 누적 확률 분포를 저장할 맵 (빠른 검색용) + private final TreeMap cumulativeDistribution = new TreeMap<>(); + private double totalWeight = 0.0; + private boolean isInitialized = false; + + // 시딩 시작 전(beforeSeeding)에 딱 한 번 호출 + public void init() { + if (isInitialized) + return; + + // 1. 모든 게시글 ID와 보드 정보 조회 + String sql = """ + SELECT p.id, p.board_id, p.user_id, b.name as board_name + FROM tb_board b + JOIN tb_post p ON p.board_id = b.id + """; + + jdbcTemplate.query(sql, rs -> { + String id = rs.getString("id"); + String boardName = rs.getString("board_name"); + String userId = rs.getString("user_id"); + + // 2. 바이럴 점수 계산 + double score = calculateViralScore(boardName); + + PostMetaData meta = new PostMetaData(id, rs.getString("board_id"), score); + + // 3. 가중치 누적 + totalWeight += score; + cumulativeDistribution.put(totalWeight, meta); + }); + + isInitialized = true; + } + + // 게시글의 바이럴 정도 결정 로직 + private double calculateViralScore(String boardName) { + double baseScore = 1.0; + + // 사람이 많은 곳에 쓴 글이 주목받을 가능성이 높다. + if ("자유게시판".equals(boardName)) + baseScore *= 5.0; + else if ("유머게시판".equals(boardName)) + baseScore *= 3.0; + else + baseScore *= 0.1; + + // Math.random()은 0~1 사이. 역수 등을 취해 롱테일 분포 생성 + // 값이 작을수록 빈도가 높고, 클수록 빈도가 낮지만 엄청 큼 + double luck = Math.pow(Math.random(), -0.5); // 파레토 분포와 유사한 효과 + + return baseScore * luck; + } + + // 가중치 랜덤 뽑기 (점수 높은 글이 더 자주 리턴됨) + public PostMetaData pickHotPost() { + if (!isInitialized) + init(); + + double randomValue = Math.random() * totalWeight; + return cumulativeDistribution.higherEntry(randomValue).getValue(); + } +} \ No newline at end of file diff --git a/app-main/src/main/java/net/causw/app/main/shared/util/UserSegmenter.java b/app-main/src/main/java/net/causw/app/main/shared/util/UserSegmenter.java new file mode 100644 index 000000000..1bc4b2157 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/util/UserSegmenter.java @@ -0,0 +1,60 @@ +package net.causw.app.main.shared.util; + +import java.util.Collections; +import java.util.List; + +import org.springframework.stereotype.Component; + +import net.causw.app.main.domain.user.account.repository.user.UserRepository; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class UserSegmenter { + private final UserRepository userRepository; + + // 캐싱된 ID 그룹들 + private List whaleIds; // 1% + private List dolphinIds; // 9% + private List activeMinnowIds;// 45% (90% 중 50%) + // 나머지 45퍼센트는 데이터 생성에 참여하지 않음 + + @PostConstruct + public void init() { + // 1. 전체 ID 로드 (성능을 위해 ID만 조회) + List allIds = userRepository.findAllIds(); + Collections.shuffle(allIds); // 무작위성을 위해 섞기 + + int total = allIds.size(); + int whaleCount = (int)(total * 0.01); + int dolphinCount = (int)(total * 0.09); + int activeMinnowCount = (int)(total * 0.9 * 0.5); // 90% 중 절반 + + int current = 0; + this.whaleIds = allIds.subList(current, current + whaleCount); + current += whaleCount; + + this.dolphinIds = allIds.subList(current, current + dolphinCount); + current += dolphinCount; + + this.activeMinnowIds = allIds.subList(current, current + activeMinnowCount); + + log.info("User Segmentation Complete: Whales=" + whaleIds.size()); + } + + public List getWhales() { + return whaleIds; + } + + public List getDolphins() { + return dolphinIds; + } + + public List getActiveMinnows() { + return activeMinnowIds; + } +}