From 46ba97048f71bf52a5b7938abaf06043b0f3130a Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Fri, 12 Dec 2025 12:23:20 +0900 Subject: [PATCH 01/22] =?UTF-8?q?fix:=20=ED=99=98=EA=B2=BD=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 0ecb28366..cb6841cf1 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -69,7 +69,7 @@ jobs: - name: Download current task definition run: | aws ecs describe-task-definition \ - --task-definition ${{ vars.ECS_SERVICE }} \ + --task-definition ${{ vars.ECS_TASK_DEFINITION }} \ --query 'taskDefinition' \ --output json | jq 'del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' \ > task-definition.json From f26b0097aaf6986e5e7400bdc2529136130fed7a Mon Sep 17 00:00:00 2001 From: hoyunjung Date: Tue, 16 Dec 2025 22:50:24 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20Flyway=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인터뷰 질문, 미션, 데이팅 시험 관련 기본 데이터 마이그레이션 추가 - Flyway 설정 로직에 비활성화 처리 조건 추가 - Hibernate DDL 설정 방식 변경 및 스키마 생성 스크립트 설정 추가 #377 --- .../deepple/common/config/FlywayConfig.java | 6 +- .../payment/command/domain/order/Order.java | 4 +- src/main/resources/application-local.yml | 2 +- .../db/migration/V1__create_tables.sql | 496 ++++++++++++++++++ ...2__insert_default_interview_questions.sql} | 0 ...s.sql => V3__instert_default_missions.sql} | 0 ...ql => V4__insert_default_dating_exams.sql} | 0 7 files changed, 504 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration/V1__create_tables.sql rename src/main/resources/db/migration/{V1__insert_default_interview_questions.sql => V2__insert_default_interview_questions.sql} (100%) rename src/main/resources/db/migration/{V2__instert_default_missions.sql => V3__instert_default_missions.sql} (100%) rename src/main/resources/db/migration/{V3__insert_default_dating_exams.sql => V4__insert_default_dating_exams.sql} (100%) diff --git a/src/main/java/deepple/deepple/common/config/FlywayConfig.java b/src/main/java/deepple/deepple/common/config/FlywayConfig.java index 31140f652..851199675 100644 --- a/src/main/java/deepple/deepple/common/config/FlywayConfig.java +++ b/src/main/java/deepple/deepple/common/config/FlywayConfig.java @@ -29,8 +29,12 @@ public Flyway flyway(DataSource dataSource, FlywayProperties props) { } @Bean - public ApplicationRunner migrateFlyway(Flyway flyway) { + public ApplicationRunner migrateFlyway(Flyway flyway, FlywayProperties props) { return args -> { + if (!props.isEnabled()) { + log.info("Flyway is disabled. Skipping migration."); + return; + } try { flyway.migrate(); } catch (Exception e) { diff --git a/src/main/java/deepple/deepple/payment/command/domain/order/Order.java b/src/main/java/deepple/deepple/payment/command/domain/order/Order.java index a8b1b7027..8cda785fd 100644 --- a/src/main/java/deepple/deepple/payment/command/domain/order/Order.java +++ b/src/main/java/deepple/deepple/payment/command/domain/order/Order.java @@ -28,11 +28,11 @@ public class Order extends BaseEntity { private String transactionId; @Getter - @Enumerated + @Enumerated(EnumType.STRING) @Column(columnDefinition = "varchar(50)") private PaymentMethod paymentMethod; - @Enumerated + @Enumerated(EnumType.STRING) @Column(columnDefinition = "varchar(50)") @Getter private OrderStatus status; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 781851447..e1bee5b84 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -7,7 +7,7 @@ spring: jpa: hibernate: - ddl-auto: ${JPA_DDL_AUTO:create} + ddl-auto: ${JPA_DDL_AUTO:update} show-sql: ${JPA_SHOW_SQL:true} properties: hibernate: diff --git a/src/main/resources/db/migration/V1__create_tables.sql b/src/main/resources/db/migration/V1__create_tables.sql new file mode 100644 index 000000000..3a0dbc461 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_tables.sql @@ -0,0 +1,496 @@ +create table admins +( + created_at datetime(6) not null, + deleted_at datetime(6), + id bigint not null auto_increment, + updated_at datetime(6), + comment varchar(255), + email varchar(255), + name varchar(255), + password varchar(255), + phone_number varchar(255), + approval_status varchar(50), + role varchar(50), + primary key (id) +) engine=InnoDB; + +create table blocks +( + blocked_id bigint not null, + blocker_id bigint not null, + created_at datetime(6) not null, + id bigint not null auto_increment, + updated_at datetime(6), + primary key (id) +) engine=InnoDB; + +create table dating_exam_answer +( + created_at datetime(6) not null, + id bigint not null auto_increment, + question_id bigint not null, + updated_at datetime(6), + content varchar(255) not null, + primary key (id) +) engine=InnoDB; + +create table dating_exam_question +( + created_at datetime(6) not null, + id bigint not null auto_increment, + subject_id bigint not null, + updated_at datetime(6), + content varchar(255) not null, + primary key (id) +) engine=InnoDB; + +create table dating_exam_subject +( + is_public bit not null, + created_at datetime(6) not null, + id bigint not null auto_increment, + updated_at datetime(6), + name varchar(255) not null, + type varchar(50) not null, + primary key (id) +) engine=InnoDB; + +create table dating_exam_submit +( + created_at datetime(6) not null, + id bigint not null auto_increment, + member_id bigint not null, + subject_id bigint not null, + updated_at datetime(6), + answers varchar(255) not null, + primary key (id) +) engine=InnoDB; + +create table device_registrations +( + is_active bit not null, + id bigint not null auto_increment, + member_id bigint, + device_id varchar(255), + registration_token varchar(255), + primary key (id) +) engine=InnoDB; + +create table heart_purchase_options +( + amount bigint, + created_at datetime(6) not null, + deleted_at datetime(6), + id bigint not null auto_increment, + price bigint, + updated_at datetime(6), + name varchar(255), + product_id varchar(255), + primary key (id) +) engine=InnoDB; + +create table heart_transactions +( + amount bigint, + created_at datetime(6) not null, + id bigint not null auto_increment, + member_id bigint, + mission_heart_balance bigint, + purchase_heart_balance bigint, + updated_at datetime(6), + content varchar(255), + transaction_type varchar(50), + primary key (id) +) engine=InnoDB; + +create table heart_usage_policies +( + created_at datetime(6) not null, + id bigint not null auto_increment, + price bigint, + updated_at datetime(6), + gender varchar(50), + transaction_type varchar(50), + primary key (id) +) engine=InnoDB; + +create table interview_answers +( + created_at datetime(6) not null, + id bigint not null auto_increment, + member_id bigint, + question_id bigint, + updated_at datetime(6), + content varchar(255), + primary key (id) +) engine=InnoDB; + +create table interview_questions +( + is_public bit not null, + created_at datetime(6) not null, + id bigint not null auto_increment, + updated_at datetime(6), + content varchar(255), + category varchar(50), + primary key (id) +) engine=InnoDB; + +create table likes +( + created_at datetime(6) not null, + id bigint not null auto_increment, + receiver_id bigint, + sender_id bigint, + updated_at datetime(6), + level varchar(50), + primary key (id) +) engine=InnoDB; + +create table matches +( + created_at datetime(6) not null, + id bigint not null auto_increment, + read_by_responder_at datetime(6), + requester_id bigint, + responder_id bigint, + updated_at datetime(6), + request_message varchar(255), + response_message varchar(255), + requester_contact_type varchar(50), + responder_contact_type varchar(50), + status varchar(50), + primary key (id) +) engine=InnoDB; + +create table member_hobbies +( + member_id bigint not null, + name varchar(50) +) engine=InnoDB; + +create table member_ideal_cities +( + member_ideal_id bigint not null, + cities varchar(50) +) engine=InnoDB; + +create table member_ideal_hobbies +( + member_ideal_id bigint not null, + hobbies varchar(50) +) engine=InnoDB; + +create table member_ideals +( + max_age integer, + min_age integer, + created_at datetime(6) not null, + id bigint not null auto_increment, + member_id bigint, + updated_at datetime(6), + drinking_status varchar(50), + religion varchar(50), + smoking_status varchar(50), + primary key (id) +) engine=InnoDB; + +create table member_introductions +( + created_at datetime(6) not null, + id bigint not null auto_increment, + introduced_member_id bigint, + member_id bigint, + updated_at datetime(6), + type varchar(50), + primary key (id) +) engine=InnoDB; + +create table member_notification_preferences +( + is_enabled bit, + member_id bigint not null, + notification_type varchar(50) not null, + primary key (member_id, notification_type) +) engine=InnoDB; + +create table member_mission +( + attempt_count integer not null, + is_completed bit not null, + success_count integer not null, + created_at datetime(6) not null, + id bigint not null auto_increment, + member_id bigint not null, + mission_id bigint not null, + updated_at datetime(6), + primary key (id) +) engine=InnoDB; + +create table members +( + height integer, + is_dating_exam_submitted bit not null, + is_profile_public bit not null, + is_vip bit not null, + year_of_birth integer, + created_at datetime(6) not null, + deleted_at datetime(6), + id bigint not null auto_increment, + mission_heart_balance bigint, + purchase_heart_balance bigint, + updated_at datetime(6), + kakao_id varchar(255), + nickname varchar(255), + phone_number varchar(255), + activity_status varchar(50), + city varchar(50), + district varchar(50), + drinking_status varchar(50), + gender varchar(50), + grade varchar(50), + highest_education varchar(50), + job varchar(50), + mbti varchar(50), + primary_contact_type varchar(50), + religion varchar(50), + smoking_status varchar(50), + primary key (id) +) engine=InnoDB; + +create table missions +( + is_public bit not null, + repeatable_count integer not null, + required_attempt integer not null, + rewarded_heart integer not null, + created_at datetime(6) not null, + id bigint not null auto_increment, + updated_at datetime(6), + action_type varchar(50), + frequency_type varchar(50), + target_gender varchar(50), + primary key (id) +) engine=InnoDB; + +create table notification_preferences +( + is_enabled_globally bit not null, + created_at datetime(6) not null, + deleted_at datetime(6), + id bigint not null auto_increment, + member_id bigint, + updated_at datetime(6), + primary key (id) +) engine=InnoDB; + +create table notification_templates +( + is_active bit not null, + id bigint not null auto_increment, + body_template varchar(255), + title_template varchar(255), + type varchar(50), + primary key (id) +) engine=InnoDB; + +create table notifications +( + created_at datetime(6) not null, + deleted_at datetime(6), + id bigint not null auto_increment, + read_at datetime(6), + receiver_id bigint, + sender_id bigint, + updated_at datetime(6), + body varchar(255), + title varchar(255), + sender_type varchar(50), + status varchar(50), + type varchar(50), + primary key (id) +) engine=InnoDB; + +create table orders +( + created_at datetime(6) not null, + id bigint not null auto_increment, + member_id bigint, + updated_at datetime(6), + transaction_id varchar(255), + payment_method varchar(50), + status varchar(50), + primary key (id) +) engine=InnoDB; + +create table profile_exchanges +( + created_at datetime(6) not null, + id bigint not null auto_increment, + requester_id bigint not null, + responder_id bigint not null, + updated_at datetime(6), + status varchar(50), + primary key (id) +) engine=InnoDB; + +create table profile_images +( + is_primary bit not null, + profile_order integer, + created_at datetime(6) not null, + id bigint not null auto_increment, + member_id bigint, + updated_at datetime(6), + url varchar(255), + primary key (id) +) engine=InnoDB; + +create table reports +( + admin_id bigint, + created_at datetime(6) not null, + id bigint not null auto_increment, + reportee_id bigint, + reporter_id bigint, + updated_at datetime(6), + version bigint, + content varchar(255), + reason varchar(50), + result varchar(50), + primary key (id) +) engine=InnoDB; + +create table screenings +( + admin_id bigint, + created_at datetime(6) not null, + id bigint not null auto_increment, + member_id bigint, + updated_at datetime(6), + version bigint, + rejection_reason varchar(50), + status varchar(50), + primary key (id) +) engine=InnoDB; + +create table self_introductions +( + is_opened bit not null, + created_at datetime(6) not null, + deleted_at datetime(6), + id bigint not null auto_increment, + member_id bigint, + updated_at datetime(6), + content varchar(255), + title varchar(255), + primary key (id) +) engine=InnoDB; + +create table suspensions +( + admin_id bigint, + created_at datetime(6) not null, + expire_at datetime(6), + id bigint not null auto_increment, + member_id bigint, + updated_at datetime(6), + status varchar(50), + primary key (id) +) engine=InnoDB; + +create table warning_reasons +( + warning_id bigint not null, + reason_type varchar(50) +) engine=InnoDB; + +create table warnings +( + is_critical bit not null, + admin_id bigint, + created_at datetime(6) not null, + id bigint not null auto_increment, + member_id bigint, + updated_at datetime(6), + primary key (id) +) engine=InnoDB; + +create index idx_block_blocked_id + on blocks (blocked_id); + +alter table blocks + add constraint unique_blocker_id_blocked_id unique (blocker_id, blocked_id); + +alter table dating_exam_submit + add constraint uk_dating_exam_submit_member_subject unique (member_id, subject_id); + +alter table device_registrations + add constraint uk_member_id unique (member_id); + +create index idx_receiver_id + on likes (receiver_id); + +alter table likes + add constraint UKr7tda4tud26t5ybryolk9105x unique (sender_id, receiver_id); + +create index idx_responder_id + on matches (responder_id); + +create index idx_requester_id_responder_id + on matches (requester_id, responder_id); + +alter table member_ideals + add constraint UK3e1lp5igxl6cxxdipej09phe6 unique (member_id); + +create index idx_deleted_at + on members (deleted_at); + +alter table members + add constraint UK99xbxdwmyun0ehfiwpbntlqs5 unique (phone_number); + +alter table orders + add constraint UKx21diseqfw2bofpm4opvb6go unique (transaction_id, payment_method); + +create index idx_responder_id + on profile_exchanges (responder_id); + +create index idx_requester_id_responder_id + on profile_exchanges (requester_id, responder_id); + +create index idx_member_id_is_primary + on profile_images (member_id, is_primary); + +create index idx_member_id + on self_introductions (member_id); + +alter table suspensions + add constraint UKbe4hjk4livshyt1rnk4el2f7p unique (member_id); + +create index idx_member_id + on warnings (member_id); + +alter table member_hobbies + add constraint FKs3rargk4m22bjg2tjhpc0ctt7 + foreign key (member_id) + references members (id); + +alter table member_ideal_cities + add constraint FKo4hxe41a3bnikkcgnidgvlnsv + foreign key (member_ideal_id) + references member_ideals (id); + +alter table member_ideal_hobbies + add constraint FKi1nsyie3isud83mj5j77grkgr + foreign key (member_ideal_id) + references member_ideals (id); + +alter table member_notification_preferences + add constraint FK7b63dmtnmrxxvjs21pwto5dt6 + foreign key (member_id) + references notification_preferences (id); + +alter table warning_reasons + add constraint FKouk9oiltck8uud2at58kqrjlx + foreign key (warning_id) + references warnings (id); diff --git a/src/main/resources/db/migration/V1__insert_default_interview_questions.sql b/src/main/resources/db/migration/V2__insert_default_interview_questions.sql similarity index 100% rename from src/main/resources/db/migration/V1__insert_default_interview_questions.sql rename to src/main/resources/db/migration/V2__insert_default_interview_questions.sql diff --git a/src/main/resources/db/migration/V2__instert_default_missions.sql b/src/main/resources/db/migration/V3__instert_default_missions.sql similarity index 100% rename from src/main/resources/db/migration/V2__instert_default_missions.sql rename to src/main/resources/db/migration/V3__instert_default_missions.sql diff --git a/src/main/resources/db/migration/V3__insert_default_dating_exams.sql b/src/main/resources/db/migration/V4__insert_default_dating_exams.sql similarity index 100% rename from src/main/resources/db/migration/V3__insert_default_dating_exams.sql rename to src/main/resources/db/migration/V4__insert_default_dating_exams.sql From 7b2ef70fe8aafbdfd60055411e69e0b4fd9289ac Mon Sep 17 00:00:00 2001 From: hainho Date: Tue, 16 Dec 2025 23:32:09 +0900 Subject: [PATCH 03/22] =?UTF-8?q?fix:=20flyway=20=EC=88=98=EB=8F=99=20conf?= =?UTF-8?q?ig=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../deepple/common/config/FlywayConfig.java | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 src/main/java/deepple/deepple/common/config/FlywayConfig.java diff --git a/src/main/java/deepple/deepple/common/config/FlywayConfig.java b/src/main/java/deepple/deepple/common/config/FlywayConfig.java deleted file mode 100644 index 851199675..000000000 --- a/src/main/java/deepple/deepple/common/config/FlywayConfig.java +++ /dev/null @@ -1,45 +0,0 @@ -package deepple.deepple.common.config; - -import lombok.extern.slf4j.Slf4j; -import org.flywaydb.core.Flyway; -import org.flywaydb.core.api.MigrationVersion; -import org.springframework.boot.ApplicationRunner; -import org.springframework.boot.autoconfigure.flyway.FlywayProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; - -import javax.sql.DataSource; - -@Slf4j -@Configuration -@Profile("!test") -@EnableConfigurationProperties(org.springframework.boot.autoconfigure.flyway.FlywayProperties.class) -public class FlywayConfig { - @Bean - public Flyway flyway(DataSource dataSource, FlywayProperties props) { - return Flyway.configure() - .dataSource(dataSource) - .baselineOnMigrate(props.isBaselineOnMigrate()) - .baselineVersion(MigrationVersion.fromVersion(props.getBaselineVersion())) - .locations(props.getLocations().toArray(new String[0])) - .table(props.getTable()) - .load(); - } - - @Bean - public ApplicationRunner migrateFlyway(Flyway flyway, FlywayProperties props) { - return args -> { - if (!props.isEnabled()) { - log.info("Flyway is disabled. Skipping migration."); - return; - } - try { - flyway.migrate(); - } catch (Exception e) { - log.error("Flyway migration failed", e); - } - }; - } -} \ No newline at end of file From 20bb6953dd8cfbd2410ff76800120a825ee2cfd3 Mon Sep 17 00:00:00 2001 From: hainho Date: Tue, 23 Dec 2025 19:17:11 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat:=20metric=20export=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ src/main/resources/application-dev.yml | 17 +++++++++++++++++ src/main/resources/application-local.yml | 17 +++++++++++++++++ src/main/resources/application-prod.yml | 11 ++++++++--- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 30bdf4010..5876d057d 100644 --- a/build.gradle +++ b/build.gradle @@ -88,6 +88,9 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Monitoring + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 91086a987..cdf49ce45 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -94,3 +94,20 @@ bizgo: scheduler: cron: "0 0 0 * * *" + +management: + server: + port: 8081 + endpoints: + web: + exposure: + include: health,info,prometheus + base-path: /actuator + endpoint: + health: + show-details: never + prometheus: + enabled: true + metrics: + tags: + application: ${APP_NAME:depple-spring-server-dev} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index e1bee5b84..ff9a31459 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -90,3 +90,20 @@ bizgo: scheduler: cron: "0 0 0 * * *" + +management: + server: + port: 8081 + endpoints: + web: + exposure: + include: health,info,prometheus + base-path: /actuator + endpoint: + health: + show-details: never + prometheus: + enabled: true + metrics: + tags: + application: ${APP_NAME:depple-spring-server-local} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index b57a908ce..a3cae4a6b 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -96,13 +96,18 @@ scheduler: cron: "0 0 0 * * *" management: + server: + port: 8081 endpoints: web: exposure: - include: health + include: health,info,prometheus base-path: /actuator endpoint: health: show-details: never - server: - port: 8080 + prometheus: + enabled: true + metrics: + tags: + application: ${APP_NAME:depple-spring-server-prod} From 4a691e3de74a9503c91b016d07f8ae901d774b9d Mon Sep 17 00:00:00 2001 From: hainho Date: Wed, 24 Dec 2025 03:41:04 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20metric=20export=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + src/main/resources/application-dev.yml | 18 +---------- src/main/resources/application-local.yml | 18 +---------- src/main/resources/application-prod.yml | 14 +-------- src/main/resources/application.yml | 39 ++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 47 deletions(-) diff --git a/build.gradle b/build.gradle index 5876d057d..9c3b74ae0 100644 --- a/build.gradle +++ b/build.gradle @@ -91,6 +91,7 @@ dependencies { // Monitoring runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + implementation 'org.hibernate.orm:hibernate-micrometer' } tasks.named('test') { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index cdf49ce45..395144bb2 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -11,6 +11,7 @@ spring: show-sql: ${JPA_SHOW_SQL:true} properties: hibernate: + generate_statistics: true format_sql: ${JPA_FORMAT_SQL:true} jdbc: timezone: Asia/Seoul @@ -94,20 +95,3 @@ bizgo: scheduler: cron: "0 0 0 * * *" - -management: - server: - port: 8081 - endpoints: - web: - exposure: - include: health,info,prometheus - base-path: /actuator - endpoint: - health: - show-details: never - prometheus: - enabled: true - metrics: - tags: - application: ${APP_NAME:depple-spring-server-dev} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ff9a31459..c22fb639e 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -12,6 +12,7 @@ spring: properties: hibernate: format_sql: ${JPA_FORMAT_SQL:true} + generate_statistics: true jdbc: timezone: Asia/Seoul @@ -90,20 +91,3 @@ bizgo: scheduler: cron: "0 0 0 * * *" - -management: - server: - port: 8081 - endpoints: - web: - exposure: - include: health,info,prometheus - base-path: /actuator - endpoint: - health: - show-details: never - prometheus: - enabled: true - metrics: - tags: - application: ${APP_NAME:depple-spring-server-local} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a3cae4a6b..bcd23b1b7 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -11,6 +11,7 @@ spring: show-sql: ${JPA_SHOW_SQL:false} properties: hibernate: + generate_statistics: true format_sql: ${JPA_FORMAT_SQL:false} jdbc: timezone: Asia/Seoul @@ -98,16 +99,3 @@ scheduler: management: server: port: 8081 - endpoints: - web: - exposure: - include: health,info,prometheus - base-path: /actuator - endpoint: - health: - show-details: never - prometheus: - enabled: true - metrics: - tags: - application: ${APP_NAME:depple-spring-server-prod} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5652858b8..b9d8178bb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,3 +11,42 @@ spring: multipart: max-file-size: 50MB max-request-size: 50MB + +management: + metrics: + distribution: + percentiles-histogram: + http.server.requests: true + tags: + application: + ${APP_NAME:deepple-api-service}-${SPRING_PROFILES_ACTIVE:local} + endpoints: + web: + exposure: + include: + - health + - prometheus + endpoint: + health: + probes: + enabled: true + group: + liveness: + show-components: always + include: + - livenessState + readiness: + show-components: always + prometheus: + access: read_only + health: + livenessState: + enabled: true + readinessState: + enabled: true + observations: + annotations: + enabled: true + key-values: + application: + ${APP_NAME:deepple-api-service}-${SPRING_PROFILES_ACTIVE:local} From b455bc7539b2024c646cfddadd2b8745c555a49e Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Tue, 30 Dec 2025 17:31:09 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20k6=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - k6 테스트 설정 파일 및 API 테스트 스크립트 추가 - 테스트 환경 변수 및 부하 프로파일 설정 - README에 k6 관련 문서 및 실행 방법 추가 #394 --- k6/README.md | 40 ++++++++++++++++++++ k6/api/member-profile.js | 82 ++++++++++++++++++++++++++++++++++++++++ k6/config.js | 65 +++++++++++++++++++++++++++++++ k6/lib/auth.js | 54 ++++++++++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 k6/README.md create mode 100644 k6/api/member-profile.js create mode 100644 k6/config.js create mode 100644 k6/lib/auth.js diff --git a/k6/README.md b/k6/README.md new file mode 100644 index 000000000..e7394a0fb --- /dev/null +++ b/k6/README.md @@ -0,0 +1,40 @@ +# 성능 테스트 (k6) + +## 설치 + +```bash +brew install k6 +``` + +## 구조 + +``` +k6/ +├── config.js # 설정 +├── lib/ # 헬퍼 +└── api/ # API 테스트 +``` + +## 환경 변수 + +| 변수 | 설명 | 기본값 | +|-------------|-------------------|-------------------------| +| `BASE_URL` | API 서버 URL | `http://localhost:8080` | +| `TEST_TYPE` | 부하 프로파일 (아래 표 참고) | `smoke` | + +### TEST_TYPE + +| 값 | VU | 시간 | 용도 | +|----------|-----------|-----|------------| +| `smoke` | 1 | 30s | 기능 확인 | +| `load` | 50 | 5m | 일반 부하 | +| `stress` | 100 → 200 | 16m | 한계 테스트 | +| `spike` | 10 → 500 | 5m | 급격한 트래픽 대응 | +| `soak` | 100 | 40m | 장시간 안정성 | + +## 실행 + +```bash +k6 run k6/api/member-profile.js +k6 run -e BASE_URL=https://dev-api.deepple.co.kr -e TEST_TYPE=load k6/api/member-profile.js +``` diff --git a/k6/api/member-profile.js b/k6/api/member-profile.js new file mode 100644 index 000000000..ba00705ce --- /dev/null +++ b/k6/api/member-profile.js @@ -0,0 +1,82 @@ +/** + * 회원 프로필 조회 API 테스트 + * + * 테스트 대상: + * - GET /member/{memberId} + * + * 실행: + * k6 run k6/api/member-profile.js + * k6 run -e BASE_URL=https://your-api.com k6/api/member-profile.js + */ + +import http from 'k6/http'; +import {check, sleep} from 'k6'; +import {config, loadProfiles, thresholds} from '../config.js'; +import {authHeaders, generateTestPhoneNumber, testLogin} from '../lib/auth.js'; + +const TEST_TYPE = __ENV.TEST_TYPE || 'smoke'; + +export const options = { + thresholds, + scenarios: { + member_profile: { + executor: 'ramping-vus', + startVUs: 0, + stages: loadProfiles[TEST_TYPE].stages || [ + {duration: loadProfiles[TEST_TYPE].duration, target: loadProfiles[TEST_TYPE].vus}, + ], + gracefulRampDown: '30s', + }, + }, +}; + +let vuTokens = {}; + +export function setup() { + console.log(`=== Member Profile API Test ===`); + console.log(`Target: ${config.baseUrl}`); + console.log(`Test Type: ${TEST_TYPE}`); + return {}; +} + +export default function () { + const vuId = __VU; + + // VU별 토큰 캐싱 (매 iteration마다 로그인 안 함) + if (!vuTokens[vuId]) { + const phoneNumber = generateTestPhoneNumber(vuId); + console.log(`VU ${vuId}: 로그인 시도 - ${phoneNumber}`); + const accessToken = testLogin(phoneNumber); + if (!accessToken) { + console.log(`VU ${vuId}: 로그인 실패`); + sleep(1); + return; + } + console.log(`VU ${vuId}: 로그인 성공`); + vuTokens[vuId] = accessToken; + } + + const headers = authHeaders(vuTokens[vuId]); + + // 타 회원 프로필 조회 (테스트 환경에 맞게 ID 수정) + const targetIds = [3, 4, 5]; + const targetId = targetIds[Math.floor(Math.random() * targetIds.length)]; + const res = http.get(`${config.baseUrl}/member/${targetId}`, { + headers, + tags: {name: 'memberProfile'}, + }); + + if (res.status !== 200 && res.status !== 404) { + console.log(`회원 ${targetId} 조회 실패: ${res.status} - ${res.body}`); + } + + check(res, { + 'status is 200 or 404': (r) => r.status === 200 || r.status === 404, + }); + + sleep(0.5); +} + +export function teardown() { + console.log('=== Member Profile API Test Completed ==='); +} diff --git a/k6/config.js b/k6/config.js new file mode 100644 index 000000000..937ec3659 --- /dev/null +++ b/k6/config.js @@ -0,0 +1,65 @@ +// k6 테스트 설정 파일 +// 환경변수로 테스트 환경 전환 가능 + +export const config = { + // 기본 URL 설정 (환경변수로 오버라이드 가능) + baseUrl: __ENV.BASE_URL || 'http://localhost:8080', + +}; + +// 성능 목표 (thresholds) +// p(95)<300 = 95% 요청이 300ms 이내 +// rate<0.01 = 에러율 1% 미만 +export const thresholds = { + http_req_duration: ['p(95)<300'], + http_req_failed: ['rate<0.01'], +}; + +// 시나리오별 부하 설정 +export const loadProfiles = { + smoke: { + stages: [ + {duration: '5s', target: 1}, // 5초만에 1명 도달 + {duration: '25s', target: 1}, // 25초간 유지 + ], + }, + load: { + stages: [ + {duration: '1m', target: 50}, + {duration: '3m', target: 50}, + {duration: '1m', target: 0}, + ], + }, + stress: { + stages: [ + {duration: '2m', target: 100}, + {duration: '5m', target: 100}, + {duration: '2m', target: 200}, + {duration: '5m', target: 200}, + {duration: '2m', target: 0}, + ], + }, + spike: { + stages: [ + {duration: '10s', target: 10}, + {duration: '1m', target: 500}, + {duration: '10s', target: 10}, + {duration: '3m', target: 10}, + {duration: '10s', target: 0}, + ], + }, + soak: { + stages: [ + {duration: '5m', target: 100}, + {duration: '30m', target: 100}, + {duration: '5m', target: 0}, + ], + }, +}; + +// HTTP 헤더 기본값 +export const defaultHeaders = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', +}; + diff --git a/k6/lib/auth.js b/k6/lib/auth.js new file mode 100644 index 000000000..b9306f407 --- /dev/null +++ b/k6/lib/auth.js @@ -0,0 +1,54 @@ +import http from 'k6/http'; +import {check} from 'k6'; +import {config, defaultHeaders} from '../config.js'; + +/** + * 테스트 로그인 수행 (인증번호 불필요) + */ +export function testLogin(phoneNumber) { + const url = `${config.baseUrl}/member/login/test`; + const payload = JSON.stringify({phoneNumber}); + + const res = http.post(url, payload, { + headers: defaultHeaders, + tags: {name: 'login'}, + }); + + const success = check(res, { + 'login status is 200': (r) => r.status === 200, + 'login has accessToken': (r) => { + try { + const body = JSON.parse(r.body); + return body.data && body.data.accessToken; + } catch { + return false; + } + }, + }); + + if (!success) { + console.error(`Login failed for ${phoneNumber}: ${res.status} ${res.body}`); + return null; + } + + const body = JSON.parse(res.body); + return body.data.accessToken; +} + +/** + * 인증 헤더 생성 + */ +export function authHeaders(accessToken) { + return { + ...defaultHeaders, + 'Authorization': `Bearer ${accessToken}`, + }; +} + +/** + * VU별 고유한 테스트 전화번호 생성 + */ +export function generateTestPhoneNumber(vuId) { + const suffix = String(vuId).padStart(4, '0'); + return `0109999${suffix}`; +} From bc6b6442fa4bb0999f969196873de547a6a17e6f Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Mon, 5 Jan 2026 17:53:50 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20k6=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=9D=EC=84=B1=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 성능 테스트용 SQL 데이터 생성 스크립트 추가 - 회원, 소개, 좋아요, 매칭, 알림 데이터 포함 - 데이터 검증 및 README 파일 추가 #394 --- k6/README.md | 40 --------- k6/api/member-profile.js | 82 ------------------ k6/config.js | 65 -------------- k6/data/01_members.sql | 158 +++++++++++++++++++++++++++++++++++ k6/data/02_introductions.sql | 99 ++++++++++++++++++++++ k6/data/03_likes.sql | 50 +++++++++++ k6/data/04_matches.sql | 47 +++++++++++ k6/data/05_notifications.sql | 100 ++++++++++++++++++++++ k6/data/06_finalize.sql | 30 +++++++ k6/data/README.md | 32 +++++++ k6/lib/auth.js | 54 ------------ 11 files changed, 516 insertions(+), 241 deletions(-) delete mode 100644 k6/README.md delete mode 100644 k6/api/member-profile.js delete mode 100644 k6/config.js create mode 100644 k6/data/01_members.sql create mode 100644 k6/data/02_introductions.sql create mode 100644 k6/data/03_likes.sql create mode 100644 k6/data/04_matches.sql create mode 100644 k6/data/05_notifications.sql create mode 100644 k6/data/06_finalize.sql create mode 100644 k6/data/README.md delete mode 100644 k6/lib/auth.js diff --git a/k6/README.md b/k6/README.md deleted file mode 100644 index e7394a0fb..000000000 --- a/k6/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# 성능 테스트 (k6) - -## 설치 - -```bash -brew install k6 -``` - -## 구조 - -``` -k6/ -├── config.js # 설정 -├── lib/ # 헬퍼 -└── api/ # API 테스트 -``` - -## 환경 변수 - -| 변수 | 설명 | 기본값 | -|-------------|-------------------|-------------------------| -| `BASE_URL` | API 서버 URL | `http://localhost:8080` | -| `TEST_TYPE` | 부하 프로파일 (아래 표 참고) | `smoke` | - -### TEST_TYPE - -| 값 | VU | 시간 | 용도 | -|----------|-----------|-----|------------| -| `smoke` | 1 | 30s | 기능 확인 | -| `load` | 50 | 5m | 일반 부하 | -| `stress` | 100 → 200 | 16m | 한계 테스트 | -| `spike` | 10 → 500 | 5m | 급격한 트래픽 대응 | -| `soak` | 100 | 40m | 장시간 안정성 | - -## 실행 - -```bash -k6 run k6/api/member-profile.js -k6 run -e BASE_URL=https://dev-api.deepple.co.kr -e TEST_TYPE=load k6/api/member-profile.js -``` diff --git a/k6/api/member-profile.js b/k6/api/member-profile.js deleted file mode 100644 index ba00705ce..000000000 --- a/k6/api/member-profile.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 회원 프로필 조회 API 테스트 - * - * 테스트 대상: - * - GET /member/{memberId} - * - * 실행: - * k6 run k6/api/member-profile.js - * k6 run -e BASE_URL=https://your-api.com k6/api/member-profile.js - */ - -import http from 'k6/http'; -import {check, sleep} from 'k6'; -import {config, loadProfiles, thresholds} from '../config.js'; -import {authHeaders, generateTestPhoneNumber, testLogin} from '../lib/auth.js'; - -const TEST_TYPE = __ENV.TEST_TYPE || 'smoke'; - -export const options = { - thresholds, - scenarios: { - member_profile: { - executor: 'ramping-vus', - startVUs: 0, - stages: loadProfiles[TEST_TYPE].stages || [ - {duration: loadProfiles[TEST_TYPE].duration, target: loadProfiles[TEST_TYPE].vus}, - ], - gracefulRampDown: '30s', - }, - }, -}; - -let vuTokens = {}; - -export function setup() { - console.log(`=== Member Profile API Test ===`); - console.log(`Target: ${config.baseUrl}`); - console.log(`Test Type: ${TEST_TYPE}`); - return {}; -} - -export default function () { - const vuId = __VU; - - // VU별 토큰 캐싱 (매 iteration마다 로그인 안 함) - if (!vuTokens[vuId]) { - const phoneNumber = generateTestPhoneNumber(vuId); - console.log(`VU ${vuId}: 로그인 시도 - ${phoneNumber}`); - const accessToken = testLogin(phoneNumber); - if (!accessToken) { - console.log(`VU ${vuId}: 로그인 실패`); - sleep(1); - return; - } - console.log(`VU ${vuId}: 로그인 성공`); - vuTokens[vuId] = accessToken; - } - - const headers = authHeaders(vuTokens[vuId]); - - // 타 회원 프로필 조회 (테스트 환경에 맞게 ID 수정) - const targetIds = [3, 4, 5]; - const targetId = targetIds[Math.floor(Math.random() * targetIds.length)]; - const res = http.get(`${config.baseUrl}/member/${targetId}`, { - headers, - tags: {name: 'memberProfile'}, - }); - - if (res.status !== 200 && res.status !== 404) { - console.log(`회원 ${targetId} 조회 실패: ${res.status} - ${res.body}`); - } - - check(res, { - 'status is 200 or 404': (r) => r.status === 200 || r.status === 404, - }); - - sleep(0.5); -} - -export function teardown() { - console.log('=== Member Profile API Test Completed ==='); -} diff --git a/k6/config.js b/k6/config.js deleted file mode 100644 index 937ec3659..000000000 --- a/k6/config.js +++ /dev/null @@ -1,65 +0,0 @@ -// k6 테스트 설정 파일 -// 환경변수로 테스트 환경 전환 가능 - -export const config = { - // 기본 URL 설정 (환경변수로 오버라이드 가능) - baseUrl: __ENV.BASE_URL || 'http://localhost:8080', - -}; - -// 성능 목표 (thresholds) -// p(95)<300 = 95% 요청이 300ms 이내 -// rate<0.01 = 에러율 1% 미만 -export const thresholds = { - http_req_duration: ['p(95)<300'], - http_req_failed: ['rate<0.01'], -}; - -// 시나리오별 부하 설정 -export const loadProfiles = { - smoke: { - stages: [ - {duration: '5s', target: 1}, // 5초만에 1명 도달 - {duration: '25s', target: 1}, // 25초간 유지 - ], - }, - load: { - stages: [ - {duration: '1m', target: 50}, - {duration: '3m', target: 50}, - {duration: '1m', target: 0}, - ], - }, - stress: { - stages: [ - {duration: '2m', target: 100}, - {duration: '5m', target: 100}, - {duration: '2m', target: 200}, - {duration: '5m', target: 200}, - {duration: '2m', target: 0}, - ], - }, - spike: { - stages: [ - {duration: '10s', target: 10}, - {duration: '1m', target: 500}, - {duration: '10s', target: 10}, - {duration: '3m', target: 10}, - {duration: '10s', target: 0}, - ], - }, - soak: { - stages: [ - {duration: '5m', target: 100}, - {duration: '30m', target: 100}, - {duration: '5m', target: 0}, - ], - }, -}; - -// HTTP 헤더 기본값 -export const defaultHeaders = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', -}; - diff --git a/k6/data/01_members.sql b/k6/data/01_members.sql new file mode 100644 index 000000000..efd128f10 --- /dev/null +++ b/k6/data/01_members.sql @@ -0,0 +1,158 @@ +-- ============================================================ +-- 회원 데이터 생성 +-- ============================================================ + +-- 생성할 회원 수 설정 +SET +@TARGET_COUNT = 1000000; + +-- 기존 데이터 삭제 +SET +FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE member_hobbies; +TRUNCATE TABLE member_ideals; +TRUNCATE TABLE profile_images; +TRUNCATE TABLE members; +SET +FOREIGN_KEY_CHECKS = 1; + +-- ============================================================ +-- 1. 숫자 시퀀스 테이블 생성 (1 ~ 1,000,000) +-- ============================================================ +DROP TABLE IF EXISTS tmp_numbers; +CREATE TABLE tmp_numbers +( + n INT PRIMARY KEY +); + +-- 1단계: 1-1000 생성 +DROP TABLE IF EXISTS tmp_seq; +CREATE TABLE tmp_seq +( + n INT PRIMARY KEY +); +INSERT INTO tmp_seq (n) +WITH RECURSIVE seq AS (SELECT 1 AS n + UNION ALL + SELECT n + 1 + FROM seq + WHERE n < 1000) +SELECT n +FROM seq; + +-- 2단계: CROSS JOIN으로 1-1000000 생성 (별도 테이블에서 읽기) +INSERT INTO tmp_numbers (n) +SELECT a.n + (b.n - 1) * 1000 +FROM tmp_seq a + CROSS JOIN tmp_seq b +WHERE a.n + (b.n - 1) * 1000 <= @TARGET_COUNT; + +DROP TABLE tmp_seq; + +-- ============================================================ +-- 2. 회원 데이터 생성 +-- ============================================================ +INSERT INTO members (created_at, updated_at, deleted_at, + phone_number, kakao_id, nickname, + gender, year_of_birth, height, + city, district, job, religion, + smoking_status, drinking_status, highest_education, mbti, + activity_status, grade, primary_contact_type, + is_profile_public, is_vip, is_dating_exam_submitted, + mission_heart_balance, purchase_heart_balance) +SELECT NOW(6), + NOW(6), + NULL, + CONCAT('010', LPAD(n.n, 8, '0')), + CONCAT('kakao_', n.n), + CONCAT('User', n.n), + IF(n.n % 2 = 1, 'MALE', 'FEMALE'), + 1980 + (n.n % 23), + IF(n.n % 2 = 1, 165 + (n.n % 21), 155 + (n.n % 16)), + ELT((n.n % 17) + 1, 'SEOUL', 'INCHEON', 'BUSAN', 'DAEJEON', 'DAEGU', 'GWANGJU', 'ULSAN', 'JEJU', 'SEJONG', + 'GANGWON', 'GYEONGGI', 'GYEONGSANGNAM', 'GYEONGSANGBUK', 'CHUNGCHEONGNAM', 'CHUNGCHEONGBUK', 'JEOLLANAM', + 'JEOLLABUK'), + 'GANGNAM_GU', + ELT((n.n % 20) + 1, 'RESEARCH_AND_ENGINEERING', 'SELF_EMPLOYMENT', 'SALES', 'MANAGEMENT_AND_PLANNING', + 'STUDYING_FOR_FUTURE', 'JOB_SEARCHING', 'EDUCATION', 'ARTS_AND_SPORTS', 'FOOD_SERVICE', 'MEDICAL_AND_HEALTH', + 'MECHANICAL_AND_CONSTRUCTION', 'DESIGN', 'MARKETING_AND_ADVERTISING', 'TRADE_AND_DISTRIBUTION', + 'MEDIA_AND_ENTERTAINMENT', 'LEGAL_AND_PUBLIC', 'PRODUCTION_AND_MANUFACTURING', 'CUSTOMER_SERVICE', + 'TRAVEL_AND_TRANSPORT', 'OTHERS'), + ELT((n.n % 5) + 1, 'NONE', 'CHRISTIAN', 'CATHOLIC', 'BUDDHIST', 'OTHER'), + ELT((n.n % 5) + 1, 'NONE', 'QUIT', 'OCCASIONAL', 'DAILY', 'VAPE'), + ELT((n.n % 5) + 1, 'NONE', 'QUIT', 'SOCIAL', 'OCCASIONAL', 'FREQUENT'), + ELT((n.n % 9) + 1, 'HIGH_SCHOOL', 'ASSOCIATE', 'BACHELORS_LOCAL', 'BACHELORS_SEOUL', 'BACHELORS_OVERSEAS', + 'LAW_SCHOOL', 'MASTERS', 'DOCTORATE', 'OTHER'), + ELT((n.n % 16) + 1, 'ESFP', 'ESFJ', 'ESTP', 'ESTJ', 'ENFP', 'ENFJ', 'ENTP', 'ENTJ', 'ISFP', 'ISFJ', 'ISTP', + 'ISTJ', 'INFP', 'INFJ', 'INTP', 'INTJ'), + 'ACTIVE', + CASE + WHEN n.n % 100 < 70 THEN 'SILVER' + WHEN n.n % 100 < 95 THEN 'GOLD' + ELSE 'DIAMOND' + END, + 'KAKAO', + TRUE, + FALSE, + n.n % 2, + (n.n % 500), + 100 + (n.n % 1000) +FROM tmp_numbers n +WHERE n.n <= @TARGET_COUNT; + +COMMIT; + +-- ============================================================ +-- 3. 회원 취미 생성 (회원당 3개) +-- ============================================================ +INSERT INTO member_hobbies (member_id, name) +SELECT m.id, + ELT((m.id % 29) + 1, 'TRAVEL', 'PERFORMANCE_AND_EXHIBITION', 'WEBTOON_AND_COMICS', 'DRAMA_AND_ENTERTAINMENT', + 'PC_AND_MOBILE_GAMES', 'ANIMATION', 'GOLF', 'THEATER_AND_MOVIES', 'WRITING', 'BOARD_GAMES', 'PHOTOGRAPHY', + 'SINGING', 'BADMINTON_AND_TENNIS', 'DANCE', 'DRIVING', 'HIKING_AND_CLIMBING', 'WALKING', 'FOOD_HUNT', + 'SHOPPING', 'SKI_AND_SNOWBOARD', 'PLAYING_INSTRUMENTS', 'WINE', 'WORKOUT', 'YOGA_AND_PILATES', 'COOKING', + 'INTERIOR_DESIGN', 'CYCLING', 'CAMPING', 'OTHERS') +FROM members m; + +INSERT INTO member_hobbies (member_id, name) +SELECT m.id, ELT(((m.id + 7) % 29) + 1, 'TRAVEL', 'PERFORMANCE_AND_EXHIBITION', 'WEBTOON_AND_COMICS', 'DRAMA_AND_ENTERTAINMENT', 'PC_AND_MOBILE_GAMES', 'ANIMATION', 'GOLF', 'THEATER_AND_MOVIES', 'WRITING', 'BOARD_GAMES', 'PHOTOGRAPHY', 'SINGING', 'BADMINTON_AND_TENNIS', 'DANCE', 'DRIVING', 'HIKING_AND_CLIMBING', 'WALKING', 'FOOD_HUNT', 'SHOPPING', 'SKI_AND_SNOWBOARD', 'PLAYING_INSTRUMENTS', 'WINE', 'WORKOUT', 'YOGA_AND_PILATES', 'COOKING', 'INTERIOR_DESIGN', 'CYCLING', 'CAMPING', 'OTHERS') +FROM members m; + +INSERT INTO member_hobbies (member_id, name) +SELECT m.id, ELT(((m.id + 13) % 29) + 1, 'TRAVEL', 'PERFORMANCE_AND_EXHIBITION', 'WEBTOON_AND_COMICS', 'DRAMA_AND_ENTERTAINMENT', 'PC_AND_MOBILE_GAMES', 'ANIMATION', 'GOLF', 'THEATER_AND_MOVIES', 'WRITING', 'BOARD_GAMES', 'PHOTOGRAPHY', 'SINGING', 'BADMINTON_AND_TENNIS', 'DANCE', 'DRIVING', 'HIKING_AND_CLIMBING', 'WALKING', 'FOOD_HUNT', 'SHOPPING', 'SKI_AND_SNOWBOARD', 'PLAYING_INSTRUMENTS', 'WINE', 'WORKOUT', 'YOGA_AND_PILATES', 'COOKING', 'INTERIOR_DESIGN', 'CYCLING', 'CAMPING', 'OTHERS') +FROM members m; + +COMMIT; + +-- ============================================================ +-- 4. 회원 이상형 생성 +-- ============================================================ +INSERT INTO member_ideals (created_at, updated_at, member_id, + min_age, max_age, religion, smoking_status, drinking_status) +SELECT NOW(6), + NOW(6), + m.id, + 20, + 40, + ELT((m.id % 5) + 1, 'NONE', 'CHRISTIAN', 'CATHOLIC', 'BUDDHIST', 'OTHER'), + ELT((m.id % 5) + 1, 'NONE', 'QUIT', 'OCCASIONAL', 'DAILY', 'VAPE'), + ELT((m.id % 5) + 1, 'NONE', 'QUIT', 'SOCIAL', 'OCCASIONAL', 'FREQUENT') +FROM members m; + +COMMIT; + +-- ============================================================ +-- 5. 프로필 이미지 생성 (회원당 2장) +-- ============================================================ +INSERT INTO profile_images (created_at, updated_at, member_id, url, is_primary, profile_order) +SELECT NOW(6), NOW(6), m.id, CONCAT('https://example.com/profile/', m.id, '_1.jpg'), TRUE, 1 +FROM members m; + +INSERT INTO profile_images (created_at, updated_at, member_id, url, is_primary, profile_order) +SELECT NOW(6), NOW(6), m.id, CONCAT('https://example.com/profile/', m.id, '_2.jpg'), FALSE, 2 +FROM members m; + +COMMIT; + +SELECT CONCAT('01_members 완료: ', COUNT(*), '명') AS status +FROM members; diff --git a/k6/data/02_introductions.sql b/k6/data/02_introductions.sql new file mode 100644 index 000000000..731f8561a --- /dev/null +++ b/k6/data/02_introductions.sql @@ -0,0 +1,99 @@ +-- ============================================================ +-- 소개 관계 데이터 생성 +-- 이성 회원간 소개만 가능하므로 남성-여성 간 데이터만 생성 +-- ============================================================ + +-- 회원당 소개받을 수 설정 +SET +@INTROS_PER_MEMBER = 30; + +-- 기존 데이터 삭제 +SET +FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE member_introductions; +SET +FOREIGN_KEY_CHECKS = 1; + +-- 소개 시퀀스 테이블 생성 (1~30) +DROP TABLE IF EXISTS tmp_intro_seq; +CREATE TABLE tmp_intro_seq +( + n INT PRIMARY KEY +); +INSERT INTO tmp_intro_seq (n) +VALUES (1), + (2), + (3), + (4), + (5), + (6), + (7), + (8), + (9), + (10), + (11), + (12), + (13), + (14), + (15), + (16), + (17), + (18), + (19), + (20), + (21), + (22), + (23), + (24), + (25), + (26), + (27), + (28), + (29), + (30); + +-- 회원 수 계산 +SET +@male_count = (SELECT COUNT(*) FROM members WHERE gender = 'MALE' AND activity_status = 'ACTIVE'); +SET +@female_count = (SELECT COUNT(*) FROM members WHERE gender = 'FEMALE' AND activity_status = 'ACTIVE'); + +-- 남성 회원이 여성을 소개받음 +-- 남성 id는 홀수(1,3,5,...), 여성 id는 짝수(2,4,6,...) +INSERT INTO member_introductions (created_at, updated_at, member_id, introduced_member_id, type) +SELECT NOW(6), + NOW(6), + m.id, + 2 * ((((m.id - 1) DIV 2) + s.n * 7) % @female_count + 1), + ELT(((m.id + s.n) % 9) + 1, 'DIAMOND_GRADE', 'SAME_HOBBY', 'SAME_RELIGION', 'SAME_CITY', 'RECENTLY_JOINED', 'TODAY_CARD', 'SOULMATE', 'SAME_ANSWER', 'IDEAL') +FROM members m + CROSS JOIN tmp_intro_seq s +WHERE m.gender = 'MALE' + AND m.activity_status = 'ACTIVE' + AND s.n <= @INTROS_PER_MEMBER +ON DUPLICATE KEY +UPDATE updated_at = NOW(6); + +COMMIT; + +-- 여성 회원이 남성을 소개받음 +INSERT INTO member_introductions (created_at, updated_at, member_id, introduced_member_id, type) +SELECT NOW(6), + NOW(6), + m.id, + 2 * ((((m.id DIV 2) - 1) + s.n * 7) % @male_count) + 1, + ELT(((m.id + s.n + 3) % 9) + 1, 'DIAMOND_GRADE', 'SAME_HOBBY', 'SAME_RELIGION', 'SAME_CITY', 'RECENTLY_JOINED', 'TODAY_CARD', 'SOULMATE', 'SAME_ANSWER', 'IDEAL') +FROM members m + CROSS JOIN tmp_intro_seq s +WHERE m.gender = 'FEMALE' + AND m.activity_status = 'ACTIVE' + AND s.n <= @INTROS_PER_MEMBER +ON DUPLICATE KEY +UPDATE updated_at = NOW(6); + +COMMIT; + +DROP TABLE tmp_intro_seq; + +SELECT CONCAT('02_introductions 완료: ', COUNT(*), '건') AS status +FROM member_introductions; diff --git a/k6/data/03_likes.sql b/k6/data/03_likes.sql new file mode 100644 index 000000000..42716f2ae --- /dev/null +++ b/k6/data/03_likes.sql @@ -0,0 +1,50 @@ +-- ============================================================ +-- 좋아요 데이터 생성 +-- 소개받은 회원에게만 좋아요 가능하므로 소개 관계 기반으로 생성 +-- ============================================================ + +-- 소개 관계 중 좋아요 비율 +SET +@LIKE_RATIO = 30; -- 30% + +-- 기존 데이터 삭제 +SET +FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE likes; +SET +FOREIGN_KEY_CHECKS = 1; + +-- 좋아요 생성 (소개받은 회원에게) +INSERT INTO likes (created_at, updated_at, sender_id, receiver_id, level) +SELECT NOW(6), + NOW(6), + mi.member_id, + mi.introduced_member_id, + IF((mi.member_id + mi.introduced_member_id) % 10 < 7, 'INTERESTED', 'HIGHLY_INTERESTED') +FROM member_introductions mi +WHERE (mi.member_id % 100) < @LIKE_RATIO ON DUPLICATE KEY +UPDATE updated_at = NOW(6); + +COMMIT; + +-- 상호 좋아요 생성 (20%) +SET +@MUTUAL_RATIO = 20; + +INSERT INTO likes (created_at, updated_at, sender_id, receiver_id, level) +SELECT NOW(6), + NOW(6), + l.receiver_id, + l.sender_id, + IF((l.sender_id + l.receiver_id) % 2 = 0, 'INTERESTED', 'HIGHLY_INTERESTED') +FROM likes l + JOIN member_introductions mi + ON mi.member_id = l.receiver_id + AND mi.introduced_member_id = l.sender_id +WHERE (l.id % 100) < @MUTUAL_RATIO ON DUPLICATE KEY +UPDATE updated_at = NOW(6); + +COMMIT; + +SELECT CONCAT('03_likes 완료: ', COUNT(*), '건') AS status +FROM likes; diff --git a/k6/data/04_matches.sql b/k6/data/04_matches.sql new file mode 100644 index 000000000..263725507 --- /dev/null +++ b/k6/data/04_matches.sql @@ -0,0 +1,47 @@ +-- ============================================================ +-- 매칭 데이터 생성 +-- 좋아요 보낸 회원에게만 매칭 요청 가능하므로 좋아요 기반으로 생성 +-- ============================================================ + +-- 좋아요 중 매칭 비율 +SET +@MATCH_RATIO = 30; -- 30% + +-- 기존 데이터 삭제 +SET +FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE matches; +SET +FOREIGN_KEY_CHECKS = 1; + +INSERT INTO matches (created_at, updated_at, + requester_id, responder_id, + request_message, response_message, + status, requester_contact_type, responder_contact_type, + read_by_responder_at) +SELECT NOW(6), + NOW(6), + l.sender_id, + l.receiver_id, + '안녕하세요! 프로필 보고 연락드려요 :)', + CASE + WHEN (l.id % 100) < 50 THEN '반갑습니다! 저도 관심있어요 :)' + ELSE NULL + END, + CASE + WHEN (l.id % 100) < 30 THEN 'WAITING' + WHEN (l.id % 100) < 80 THEN 'MATCHED' + WHEN (l.id % 100) < 90 THEN 'REJECTED' + WHEN (l.id % 100) < 95 THEN 'REJECT_CHECKED' + ELSE 'EXPIRED' + END, + IF(l.id % 3 = 0, 'PHONE_NUMBER', 'KAKAO'), + IF(l.id % 4 = 0, 'PHONE_NUMBER', 'KAKAO'), + IF((l.id % 100) < 60, NOW(6), NULL) +FROM likes l +WHERE (l.id % 100) < @MATCH_RATIO; + +COMMIT; + +SELECT CONCAT('04_matches 완료: ', COUNT(*), '건') AS status +FROM matches; diff --git a/k6/data/05_notifications.sql b/k6/data/05_notifications.sql new file mode 100644 index 000000000..5c3d93bd1 --- /dev/null +++ b/k6/data/05_notifications.sql @@ -0,0 +1,100 @@ +-- ============================================================ +-- 알림 데이터 생성 +-- ============================================================ + +-- 기존 데이터 삭제 +SET +FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE notifications; +TRUNCATE TABLE notification_preferences; +SET +FOREIGN_KEY_CHECKS = 1; + +-- notification_preferences 생성 +INSERT INTO notification_preferences (created_at, updated_at, deleted_at, member_id, is_enabled_globally) +SELECT NOW(6), NOW(6), NULL, id, TRUE +FROM members +WHERE activity_status = 'ACTIVE'; + +COMMIT; + +-- 좋아요 알림 +INSERT INTO notifications (created_at, updated_at, deleted_at, + sender_type, sender_id, receiver_id, + type, title, body, status, read_at) +SELECT DATE_SUB(NOW(6), INTERVAL (l.id % 30) DAY), + NOW(6), + NULL, + 'MEMBER', + l.sender_id, + l.receiver_id, + 'LIKE', + '새로운 좋아요', + '회원님을 좋아하는 사람이 있어요!', + 'SENT', + IF((l.id % 100) < 30, DATE_SUB(NOW(6), INTERVAL (l.id % 7) DAY), NULL) +FROM likes l; + +COMMIT; + +-- 매칭 요청 알림 +INSERT INTO notifications (created_at, updated_at, deleted_at, + sender_type, sender_id, receiver_id, + type, title, body, status, read_at) +SELECT DATE_SUB(NOW(6), INTERVAL (m.id % 30) DAY), + NOW(6), + NULL, + 'MEMBER', + m.requester_id, + m.responder_id, + 'MATCH_REQUEST', + '새로운 매칭 요청', + '회원님께 매칭 요청이 도착했습니다.', + 'SENT', + IF((m.id % 100) < 40, DATE_SUB(NOW(6), INTERVAL (m.id % 7) DAY), NULL) +FROM matches m; + +COMMIT; + +-- 매칭 수락/거절 알림 +INSERT INTO notifications (created_at, updated_at, deleted_at, + sender_type, sender_id, receiver_id, + type, title, body, status, read_at) +SELECT DATE_SUB(NOW(6), INTERVAL (m.id % 20) DAY), + NOW(6), + NULL, + 'MEMBER', + m.responder_id, + m.requester_id, + IF(m.status = 'MATCHED', 'MATCH_ACCEPT', 'MATCH_REJECT'), + IF(m.status = 'MATCHED', '매칭이 수락되었습니다', '매칭이 거절되었습니다'), + IF(m.status = 'MATCHED', '상대방이 매칭을 수락했습니다!', '아쉽지만 다음 기회에!'), + 'SENT', + IF((m.id % 100) < 50, DATE_SUB(NOW(6), INTERVAL (m.id % 5) DAY), NULL) +FROM matches m +WHERE m.status IN ('MATCHED', 'REJECTED', 'REJECT_CHECKED'); + +COMMIT; + +-- 심사 승인 알림 +INSERT INTO notifications (created_at, updated_at, deleted_at, + sender_type, sender_id, receiver_id, + type, title, body, status, read_at) +SELECT DATE_SUB(NOW(6), INTERVAL 30 DAY), + NOW(6), + NULL, + 'SYSTEM', + NULL, + m.id, + 'SCREENING_APPROVED', + '프로필 심사 승인', + '프로필이 승인되었습니다.', + 'SENT', + DATE_SUB(NOW(6), INTERVAL 29 DAY) +FROM members m +WHERE m.activity_status = 'ACTIVE'; + +COMMIT; + +SELECT CONCAT('05_notifications 완료: ', COUNT(*), '건') AS status +FROM notifications; diff --git a/k6/data/06_finalize.sql b/k6/data/06_finalize.sql new file mode 100644 index 000000000..08dcb5c8c --- /dev/null +++ b/k6/data/06_finalize.sql @@ -0,0 +1,30 @@ +-- ============================================================ +-- 최종 정리 및 데이터 검증 +-- ============================================================ + +-- 성능 설정 복원 +SET +FOREIGN_KEY_CHECKS = 1; +SET +UNIQUE_CHECKS = 1; +SET +autocommit = 1; + +-- 임시 테이블 삭제 +DROP TABLE IF EXISTS tmp_numbers; + +-- 데이터 요약 +SELECT 'members' AS `table`, COUNT(*) AS count +FROM members +UNION ALL +SELECT 'member_introductions', COUNT(*) +FROM member_introductions +UNION ALL +SELECT 'likes', COUNT(*) +FROM likes +UNION ALL +SELECT 'matches', COUNT(*) +FROM matches +UNION ALL +SELECT 'notifications', COUNT(*) +FROM notifications; diff --git a/k6/data/README.md b/k6/data/README.md new file mode 100644 index 000000000..262674042 --- /dev/null +++ b/k6/data/README.md @@ -0,0 +1,32 @@ +# 성능 테스트용 데이터 생성 + +## 실행 + +```bash +mysql -u [user] -p -h [host] [db] < ./01_members.sql +mysql -u [user] -p -h [host] [db] < ./02_introductions.sql +mysql -u [user] -p -h [host] [db] < ./03_likes.sql +mysql -u [user] -p -h [host] [db] < ./04_matches.sql +mysql -u [user] -p -h [host] [db] < ./05_notifications.sql +mysql -u [user] -p -h [host] [db] < ./06_finalize.sql +``` + +## 설정 + +| 파일 | 변수 | 기본값 | 설명 | +|------------------|--------------------|-----------|-------------| +| 01_members | @TARGET_COUNT | 1,000,000 | 회원 수 | +| 02_introductions | @INTROS_PER_MEMBER | 30 | 회원당 소개 수 | +| 03_likes | @LIKE_RATIO | 30% | 소개 중 좋아요 비율 | +| 04_matches | @MATCH_RATIO | 30% | 좋아요 중 매칭 비율 | + +## 생성 데이터 + +- **회원**: 100만명 (전화번호: 01000000001 ~ 01001000000) +- **소개**: 3,000만건 (회원당 30명) +- **좋아요**: 900만건 +- **매칭**: 270만건 + +## 주의 + +각 파일 실행 시 해당 테이블 데이터 자동 삭제 후 재생성 diff --git a/k6/lib/auth.js b/k6/lib/auth.js deleted file mode 100644 index b9306f407..000000000 --- a/k6/lib/auth.js +++ /dev/null @@ -1,54 +0,0 @@ -import http from 'k6/http'; -import {check} from 'k6'; -import {config, defaultHeaders} from '../config.js'; - -/** - * 테스트 로그인 수행 (인증번호 불필요) - */ -export function testLogin(phoneNumber) { - const url = `${config.baseUrl}/member/login/test`; - const payload = JSON.stringify({phoneNumber}); - - const res = http.post(url, payload, { - headers: defaultHeaders, - tags: {name: 'login'}, - }); - - const success = check(res, { - 'login status is 200': (r) => r.status === 200, - 'login has accessToken': (r) => { - try { - const body = JSON.parse(r.body); - return body.data && body.data.accessToken; - } catch { - return false; - } - }, - }); - - if (!success) { - console.error(`Login failed for ${phoneNumber}: ${res.status} ${res.body}`); - return null; - } - - const body = JSON.parse(res.body); - return body.data.accessToken; -} - -/** - * 인증 헤더 생성 - */ -export function authHeaders(accessToken) { - return { - ...defaultHeaders, - 'Authorization': `Bearer ${accessToken}`, - }; -} - -/** - * VU별 고유한 테스트 전화번호 생성 - */ -export function generateTestPhoneNumber(vuId) { - const suffix = String(vuId).padStart(4, '0'); - return `0109999${suffix}`; -} From 20af71a5df4944e6447d8157ea0cfd46c9d19738 Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Tue, 6 Jan 2026 09:43:46 +0900 Subject: [PATCH 08/22] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #394 --- k6/data/01_members.sql | 91 +++++++++++-------- k6/data/02_introductions.sql | 35 ++++--- k6/data/03_likes.sql | 25 ++--- k6/data/04_matches.sql | 16 ++-- k6/data/05_notifications.sql | 23 +++-- k6/data/06_finalize.sql | 30 ------ k6/data/README.md | 5 +- .../presentation/NotificationController.java | 24 +++++ 8 files changed, 144 insertions(+), 105 deletions(-) delete mode 100644 k6/data/06_finalize.sql diff --git a/k6/data/01_members.sql b/k6/data/01_members.sql index efd128f10..011ee2dce 100644 --- a/k6/data/01_members.sql +++ b/k6/data/01_members.sql @@ -1,10 +1,9 @@ -- ============================================================ --- 회원 데이터 생성 +-- 01. 회원 데이터 생성 -- ============================================================ --- 생성할 회원 수 설정 SET -@TARGET_COUNT = 1000000; +@TARGET_COUNT = 1000000; -- 생성할 회원 수 -- 기존 데이터 삭제 SET @@ -16,21 +15,21 @@ TRUNCATE TABLE members; SET FOREIGN_KEY_CHECKS = 1; --- ============================================================ +-- ------------------------------------------------------------ -- 1. 숫자 시퀀스 테이블 생성 (1 ~ 1,000,000) --- ============================================================ +-- ------------------------------------------------------------ DROP TABLE IF EXISTS tmp_numbers; CREATE TABLE tmp_numbers ( n INT PRIMARY KEY ); --- 1단계: 1-1000 생성 DROP TABLE IF EXISTS tmp_seq; CREATE TABLE tmp_seq ( n INT PRIMARY KEY ); + INSERT INTO tmp_seq (n) WITH RECURSIVE seq AS (SELECT 1 AS n UNION ALL @@ -40,7 +39,6 @@ WITH RECURSIVE seq AS (SELECT 1 AS n SELECT n FROM seq; --- 2단계: CROSS JOIN으로 1-1000000 생성 (별도 테이블에서 읽기) INSERT INTO tmp_numbers (n) SELECT a.n + (b.n - 1) * 1000 FROM tmp_seq a @@ -49,9 +47,9 @@ WHERE a.n + (b.n - 1) * 1000 <= @TARGET_COUNT; DROP TABLE tmp_seq; --- ============================================================ --- 2. 회원 데이터 생성 --- ============================================================ +-- ------------------------------------------------------------ +-- 2. 회원 생성 +-- ------------------------------------------------------------ INSERT INTO members (created_at, updated_at, deleted_at, phone_number, kakao_id, nickname, gender, year_of_birth, height, @@ -69,22 +67,26 @@ SELECT NOW(6), IF(n.n % 2 = 1, 'MALE', 'FEMALE'), 1980 + (n.n % 23), IF(n.n % 2 = 1, 165 + (n.n % 21), 155 + (n.n % 16)), - ELT((n.n % 17) + 1, 'SEOUL', 'INCHEON', 'BUSAN', 'DAEJEON', 'DAEGU', 'GWANGJU', 'ULSAN', 'JEJU', 'SEJONG', - 'GANGWON', 'GYEONGGI', 'GYEONGSANGNAM', 'GYEONGSANGBUK', 'CHUNGCHEONGNAM', 'CHUNGCHEONGBUK', 'JEOLLANAM', - 'JEOLLABUK'), + ELT((n.n % 17) + 1, + 'SEOUL', 'INCHEON', 'BUSAN', 'DAEJEON', 'DAEGU', 'GWANGJU', 'ULSAN', 'JEJU', 'SEJONG', + 'GANGWON', 'GYEONGGI', 'GYEONGSANGNAM', 'GYEONGSANGBUK', 'CHUNGCHEONGNAM', 'CHUNGCHEONGBUK', + 'JEOLLANAM', 'JEOLLABUK'), 'GANGNAM_GU', - ELT((n.n % 20) + 1, 'RESEARCH_AND_ENGINEERING', 'SELF_EMPLOYMENT', 'SALES', 'MANAGEMENT_AND_PLANNING', - 'STUDYING_FOR_FUTURE', 'JOB_SEARCHING', 'EDUCATION', 'ARTS_AND_SPORTS', 'FOOD_SERVICE', 'MEDICAL_AND_HEALTH', - 'MECHANICAL_AND_CONSTRUCTION', 'DESIGN', 'MARKETING_AND_ADVERTISING', 'TRADE_AND_DISTRIBUTION', - 'MEDIA_AND_ENTERTAINMENT', 'LEGAL_AND_PUBLIC', 'PRODUCTION_AND_MANUFACTURING', 'CUSTOMER_SERVICE', - 'TRAVEL_AND_TRANSPORT', 'OTHERS'), + ELT((n.n % 20) + 1, + 'RESEARCH_AND_ENGINEERING', 'SELF_EMPLOYMENT', 'SALES', 'MANAGEMENT_AND_PLANNING', + 'STUDYING_FOR_FUTURE', 'JOB_SEARCHING', 'EDUCATION', 'ARTS_AND_SPORTS', 'FOOD_SERVICE', + 'MEDICAL_AND_HEALTH', 'MECHANICAL_AND_CONSTRUCTION', 'DESIGN', 'MARKETING_AND_ADVERTISING', + 'TRADE_AND_DISTRIBUTION', 'MEDIA_AND_ENTERTAINMENT', 'LEGAL_AND_PUBLIC', + 'PRODUCTION_AND_MANUFACTURING', 'CUSTOMER_SERVICE', 'TRAVEL_AND_TRANSPORT', 'OTHERS'), ELT((n.n % 5) + 1, 'NONE', 'CHRISTIAN', 'CATHOLIC', 'BUDDHIST', 'OTHER'), ELT((n.n % 5) + 1, 'NONE', 'QUIT', 'OCCASIONAL', 'DAILY', 'VAPE'), ELT((n.n % 5) + 1, 'NONE', 'QUIT', 'SOCIAL', 'OCCASIONAL', 'FREQUENT'), - ELT((n.n % 9) + 1, 'HIGH_SCHOOL', 'ASSOCIATE', 'BACHELORS_LOCAL', 'BACHELORS_SEOUL', 'BACHELORS_OVERSEAS', + ELT((n.n % 9) + 1, + 'HIGH_SCHOOL', 'ASSOCIATE', 'BACHELORS_LOCAL', 'BACHELORS_SEOUL', 'BACHELORS_OVERSEAS', 'LAW_SCHOOL', 'MASTERS', 'DOCTORATE', 'OTHER'), - ELT((n.n % 16) + 1, 'ESFP', 'ESFJ', 'ESTP', 'ESTJ', 'ENFP', 'ENFJ', 'ENTP', 'ENTJ', 'ISFP', 'ISFJ', 'ISTP', - 'ISTJ', 'INFP', 'INFJ', 'INTP', 'INTJ'), + ELT((n.n % 16) + 1, + 'ESFP', 'ESFJ', 'ESTP', 'ESTJ', 'ENFP', 'ENFJ', 'ENTP', 'ENTJ', + 'ISFP', 'ISFJ', 'ISTP', 'ISTJ', 'INFP', 'INFJ', 'INTP', 'INTJ'), 'ACTIVE', CASE WHEN n.n % 100 < 70 THEN 'SILVER' @@ -102,33 +104,46 @@ WHERE n.n <= @TARGET_COUNT; COMMIT; --- ============================================================ +-- ------------------------------------------------------------ -- 3. 회원 취미 생성 (회원당 3개) --- ============================================================ +-- ------------------------------------------------------------ INSERT INTO member_hobbies (member_id, name) SELECT m.id, - ELT((m.id % 29) + 1, 'TRAVEL', 'PERFORMANCE_AND_EXHIBITION', 'WEBTOON_AND_COMICS', 'DRAMA_AND_ENTERTAINMENT', - 'PC_AND_MOBILE_GAMES', 'ANIMATION', 'GOLF', 'THEATER_AND_MOVIES', 'WRITING', 'BOARD_GAMES', 'PHOTOGRAPHY', - 'SINGING', 'BADMINTON_AND_TENNIS', 'DANCE', 'DRIVING', 'HIKING_AND_CLIMBING', 'WALKING', 'FOOD_HUNT', - 'SHOPPING', 'SKI_AND_SNOWBOARD', 'PLAYING_INSTRUMENTS', 'WINE', 'WORKOUT', 'YOGA_AND_PILATES', 'COOKING', - 'INTERIOR_DESIGN', 'CYCLING', 'CAMPING', 'OTHERS') + ELT((m.id % 29) + 1, + 'TRAVEL', 'PERFORMANCE_AND_EXHIBITION', 'WEBTOON_AND_COMICS', 'DRAMA_AND_ENTERTAINMENT', + 'PC_AND_MOBILE_GAMES', 'ANIMATION', 'GOLF', 'THEATER_AND_MOVIES', 'WRITING', 'BOARD_GAMES', + 'PHOTOGRAPHY', 'SINGING', 'BADMINTON_AND_TENNIS', 'DANCE', 'DRIVING', 'HIKING_AND_CLIMBING', + 'WALKING', 'FOOD_HUNT', 'SHOPPING', 'SKI_AND_SNOWBOARD', 'PLAYING_INSTRUMENTS', 'WINE', + 'WORKOUT', 'YOGA_AND_PILATES', 'COOKING', 'INTERIOR_DESIGN', 'CYCLING', 'CAMPING', 'OTHERS') FROM members m; INSERT INTO member_hobbies (member_id, name) -SELECT m.id, ELT(((m.id + 7) % 29) + 1, 'TRAVEL', 'PERFORMANCE_AND_EXHIBITION', 'WEBTOON_AND_COMICS', 'DRAMA_AND_ENTERTAINMENT', 'PC_AND_MOBILE_GAMES', 'ANIMATION', 'GOLF', 'THEATER_AND_MOVIES', 'WRITING', 'BOARD_GAMES', 'PHOTOGRAPHY', 'SINGING', 'BADMINTON_AND_TENNIS', 'DANCE', 'DRIVING', 'HIKING_AND_CLIMBING', 'WALKING', 'FOOD_HUNT', 'SHOPPING', 'SKI_AND_SNOWBOARD', 'PLAYING_INSTRUMENTS', 'WINE', 'WORKOUT', 'YOGA_AND_PILATES', 'COOKING', 'INTERIOR_DESIGN', 'CYCLING', 'CAMPING', 'OTHERS') +SELECT m.id, + ELT(((m.id + 7) % 29) + 1, + 'TRAVEL', 'PERFORMANCE_AND_EXHIBITION', 'WEBTOON_AND_COMICS', 'DRAMA_AND_ENTERTAINMENT', + 'PC_AND_MOBILE_GAMES', 'ANIMATION', 'GOLF', 'THEATER_AND_MOVIES', 'WRITING', 'BOARD_GAMES', + 'PHOTOGRAPHY', 'SINGING', 'BADMINTON_AND_TENNIS', 'DANCE', 'DRIVING', 'HIKING_AND_CLIMBING', + 'WALKING', 'FOOD_HUNT', 'SHOPPING', 'SKI_AND_SNOWBOARD', 'PLAYING_INSTRUMENTS', 'WINE', + 'WORKOUT', 'YOGA_AND_PILATES', 'COOKING', 'INTERIOR_DESIGN', 'CYCLING', 'CAMPING', 'OTHERS') FROM members m; INSERT INTO member_hobbies (member_id, name) -SELECT m.id, ELT(((m.id + 13) % 29) + 1, 'TRAVEL', 'PERFORMANCE_AND_EXHIBITION', 'WEBTOON_AND_COMICS', 'DRAMA_AND_ENTERTAINMENT', 'PC_AND_MOBILE_GAMES', 'ANIMATION', 'GOLF', 'THEATER_AND_MOVIES', 'WRITING', 'BOARD_GAMES', 'PHOTOGRAPHY', 'SINGING', 'BADMINTON_AND_TENNIS', 'DANCE', 'DRIVING', 'HIKING_AND_CLIMBING', 'WALKING', 'FOOD_HUNT', 'SHOPPING', 'SKI_AND_SNOWBOARD', 'PLAYING_INSTRUMENTS', 'WINE', 'WORKOUT', 'YOGA_AND_PILATES', 'COOKING', 'INTERIOR_DESIGN', 'CYCLING', 'CAMPING', 'OTHERS') +SELECT m.id, + ELT(((m.id + 13) % 29) + 1, + 'TRAVEL', 'PERFORMANCE_AND_EXHIBITION', 'WEBTOON_AND_COMICS', 'DRAMA_AND_ENTERTAINMENT', + 'PC_AND_MOBILE_GAMES', 'ANIMATION', 'GOLF', 'THEATER_AND_MOVIES', 'WRITING', 'BOARD_GAMES', + 'PHOTOGRAPHY', 'SINGING', 'BADMINTON_AND_TENNIS', 'DANCE', 'DRIVING', 'HIKING_AND_CLIMBING', + 'WALKING', 'FOOD_HUNT', 'SHOPPING', 'SKI_AND_SNOWBOARD', 'PLAYING_INSTRUMENTS', 'WINE', + 'WORKOUT', 'YOGA_AND_PILATES', 'COOKING', 'INTERIOR_DESIGN', 'CYCLING', 'CAMPING', 'OTHERS') FROM members m; COMMIT; --- ============================================================ +-- ------------------------------------------------------------ -- 4. 회원 이상형 생성 --- ============================================================ -INSERT INTO member_ideals (created_at, updated_at, member_id, - min_age, max_age, religion, smoking_status, drinking_status) +-- ------------------------------------------------------------ +INSERT INTO member_ideals (created_at, updated_at, member_id, min_age, max_age, religion, smoking_status, + drinking_status) SELECT NOW(6), NOW(6), m.id, @@ -141,9 +156,9 @@ FROM members m; COMMIT; --- ============================================================ +-- ------------------------------------------------------------ -- 5. 프로필 이미지 생성 (회원당 2장) --- ============================================================ +-- ------------------------------------------------------------ INSERT INTO profile_images (created_at, updated_at, member_id, url, is_primary, profile_order) SELECT NOW(6), NOW(6), m.id, CONCAT('https://example.com/profile/', m.id, '_1.jpg'), TRUE, 1 FROM members m; @@ -154,5 +169,9 @@ FROM members m; COMMIT; +-- 임시 테이블 정리 +DROP TABLE IF EXISTS tmp_numbers; + +-- 결과 확인 SELECT CONCAT('01_members 완료: ', COUNT(*), '명') AS status FROM members; diff --git a/k6/data/02_introductions.sql b/k6/data/02_introductions.sql index 731f8561a..ac7d186dd 100644 --- a/k6/data/02_introductions.sql +++ b/k6/data/02_introductions.sql @@ -1,11 +1,10 @@ -- ============================================================ --- 소개 관계 데이터 생성 --- 이성 회원간 소개만 가능하므로 남성-여성 간 데이터만 생성 +-- 02. 소개 데이터 생성 -- ============================================================ +-- 이성 회원간 소개만 가능 (남성↔여성) --- 회원당 소개받을 수 설정 SET -@INTROS_PER_MEMBER = 30; +@INTROS_PER_MEMBER = 30; -- 회원당 소개 수 -- 기존 데이터 삭제 SET @@ -14,12 +13,15 @@ TRUNCATE TABLE member_introductions; SET FOREIGN_KEY_CHECKS = 1; --- 소개 시퀀스 테이블 생성 (1~30) +-- ------------------------------------------------------------ +-- 1. 소개 시퀀스 테이블 생성 (1~30) +-- ------------------------------------------------------------ DROP TABLE IF EXISTS tmp_intro_seq; CREATE TABLE tmp_intro_seq ( n INT PRIMARY KEY ); + INSERT INTO tmp_intro_seq (n) VALUES (1), (2), @@ -52,20 +54,23 @@ VALUES (1), (29), (30); --- 회원 수 계산 SET @male_count = (SELECT COUNT(*) FROM members WHERE gender = 'MALE' AND activity_status = 'ACTIVE'); SET @female_count = (SELECT COUNT(*) FROM members WHERE gender = 'FEMALE' AND activity_status = 'ACTIVE'); --- 남성 회원이 여성을 소개받음 --- 남성 id는 홀수(1,3,5,...), 여성 id는 짝수(2,4,6,...) +-- ------------------------------------------------------------ +-- 2. 남성 → 여성 소개 생성 +-- ------------------------------------------------------------ +-- 남성 id: 홀수(1,3,5,...), 여성 id: 짝수(2,4,6,...) INSERT INTO member_introductions (created_at, updated_at, member_id, introduced_member_id, type) SELECT NOW(6), NOW(6), m.id, 2 * ((((m.id - 1) DIV 2) + s.n * 7) % @female_count + 1), - ELT(((m.id + s.n) % 9) + 1, 'DIAMOND_GRADE', 'SAME_HOBBY', 'SAME_RELIGION', 'SAME_CITY', 'RECENTLY_JOINED', 'TODAY_CARD', 'SOULMATE', 'SAME_ANSWER', 'IDEAL') + ELT(((m.id + s.n) % 9) + 1, + 'DIAMOND_GRADE', 'SAME_HOBBY', 'SAME_RELIGION', 'SAME_CITY', 'RECENTLY_JOINED', + 'TODAY_CARD', 'SOULMATE', 'SAME_ANSWER', 'IDEAL') FROM members m CROSS JOIN tmp_intro_seq s WHERE m.gender = 'MALE' @@ -76,13 +81,17 @@ UPDATE updated_at = NOW(6); COMMIT; --- 여성 회원이 남성을 소개받음 +-- ------------------------------------------------------------ +-- 3. 여성 → 남성 소개 생성 +-- ------------------------------------------------------------ INSERT INTO member_introductions (created_at, updated_at, member_id, introduced_member_id, type) SELECT NOW(6), NOW(6), m.id, 2 * ((((m.id DIV 2) - 1) + s.n * 7) % @male_count) + 1, - ELT(((m.id + s.n + 3) % 9) + 1, 'DIAMOND_GRADE', 'SAME_HOBBY', 'SAME_RELIGION', 'SAME_CITY', 'RECENTLY_JOINED', 'TODAY_CARD', 'SOULMATE', 'SAME_ANSWER', 'IDEAL') + ELT(((m.id + s.n + 3) % 9) + 1, + 'DIAMOND_GRADE', 'SAME_HOBBY', 'SAME_RELIGION', 'SAME_CITY', 'RECENTLY_JOINED', + 'TODAY_CARD', 'SOULMATE', 'SAME_ANSWER', 'IDEAL') FROM members m CROSS JOIN tmp_intro_seq s WHERE m.gender = 'FEMALE' @@ -93,7 +102,9 @@ UPDATE updated_at = NOW(6); COMMIT; -DROP TABLE tmp_intro_seq; +-- 임시 테이블 정리 +DROP TABLE IF EXISTS tmp_intro_seq; +-- 결과 확인 SELECT CONCAT('02_introductions 완료: ', COUNT(*), '건') AS status FROM member_introductions; diff --git a/k6/data/03_likes.sql b/k6/data/03_likes.sql index 42716f2ae..8e3db6f6d 100644 --- a/k6/data/03_likes.sql +++ b/k6/data/03_likes.sql @@ -1,11 +1,12 @@ -- ============================================================ --- 좋아요 데이터 생성 --- 소개받은 회원에게만 좋아요 가능하므로 소개 관계 기반으로 생성 +-- 03. 좋아요 데이터 생성 -- ============================================================ +-- 소개받은 회원에게만 좋아요 가능 --- 소개 관계 중 좋아요 비율 SET -@LIKE_RATIO = 30; -- 30% +@LIKE_RATIO = 30; -- 소개 중 좋아요 비율 (%) +SET +@MUTUAL_RATIO = 20; -- 상호 좋아요 비율 (%) -- 기존 데이터 삭제 SET @@ -14,7 +15,9 @@ TRUNCATE TABLE likes; SET FOREIGN_KEY_CHECKS = 1; --- 좋아요 생성 (소개받은 회원에게) +-- ------------------------------------------------------------ +-- 1. 좋아요 생성 +-- ------------------------------------------------------------ INSERT INTO likes (created_at, updated_at, sender_id, receiver_id, level) SELECT NOW(6), NOW(6), @@ -27,10 +30,9 @@ UPDATE updated_at = NOW(6); COMMIT; --- 상호 좋아요 생성 (20%) -SET -@MUTUAL_RATIO = 20; - +-- ------------------------------------------------------------ +-- 2. 상호 좋아요 생성 +-- ------------------------------------------------------------ INSERT INTO likes (created_at, updated_at, sender_id, receiver_id, level) SELECT NOW(6), NOW(6), @@ -38,13 +40,12 @@ SELECT NOW(6), l.sender_id, IF((l.sender_id + l.receiver_id) % 2 = 0, 'INTERESTED', 'HIGHLY_INTERESTED') FROM likes l - JOIN member_introductions mi - ON mi.member_id = l.receiver_id - AND mi.introduced_member_id = l.sender_id + JOIN member_introductions mi ON mi.member_id = l.receiver_id AND mi.introduced_member_id = l.sender_id WHERE (l.id % 100) < @MUTUAL_RATIO ON DUPLICATE KEY UPDATE updated_at = NOW(6); COMMIT; +-- 결과 확인 SELECT CONCAT('03_likes 완료: ', COUNT(*), '건') AS status FROM likes; diff --git a/k6/data/04_matches.sql b/k6/data/04_matches.sql index 263725507..f12b9bdad 100644 --- a/k6/data/04_matches.sql +++ b/k6/data/04_matches.sql @@ -1,11 +1,10 @@ -- ============================================================ --- 매칭 데이터 생성 --- 좋아요 보낸 회원에게만 매칭 요청 가능하므로 좋아요 기반으로 생성 +-- 04. 매칭 데이터 생성 -- ============================================================ +-- 좋아요 보낸 회원에게만 매칭 요청 가능 --- 좋아요 중 매칭 비율 SET -@MATCH_RATIO = 30; -- 30% +@MATCH_RATIO = 30; -- 좋아요 중 매칭 비율 (%) -- 기존 데이터 삭제 SET @@ -14,6 +13,9 @@ TRUNCATE TABLE matches; SET FOREIGN_KEY_CHECKS = 1; +-- ------------------------------------------------------------ +-- 1. 매칭 생성 +-- ------------------------------------------------------------ INSERT INTO matches (created_at, updated_at, requester_id, responder_id, request_message, response_message, @@ -24,10 +26,7 @@ SELECT NOW(6), l.sender_id, l.receiver_id, '안녕하세요! 프로필 보고 연락드려요 :)', - CASE - WHEN (l.id % 100) < 50 THEN '반갑습니다! 저도 관심있어요 :)' - ELSE NULL - END, + CASE WHEN (l.id % 100) < 50 THEN '반갑습니다! 저도 관심있어요 :)' ELSE NULL END, CASE WHEN (l.id % 100) < 30 THEN 'WAITING' WHEN (l.id % 100) < 80 THEN 'MATCHED' @@ -43,5 +42,6 @@ WHERE (l.id % 100) < @MATCH_RATIO; COMMIT; +-- 결과 확인 SELECT CONCAT('04_matches 완료: ', COUNT(*), '건') AS status FROM matches; diff --git a/k6/data/05_notifications.sql b/k6/data/05_notifications.sql index 5c3d93bd1..10ca4a4af 100644 --- a/k6/data/05_notifications.sql +++ b/k6/data/05_notifications.sql @@ -1,5 +1,5 @@ -- ============================================================ --- 알림 데이터 생성 +-- 05. 알림 데이터 생성 -- ============================================================ -- 기존 데이터 삭제 @@ -10,7 +10,9 @@ TRUNCATE TABLE notification_preferences; SET FOREIGN_KEY_CHECKS = 1; --- notification_preferences 생성 +-- ------------------------------------------------------------ +-- 1. 알림 설정 생성 +-- ------------------------------------------------------------ INSERT INTO notification_preferences (created_at, updated_at, deleted_at, member_id, is_enabled_globally) SELECT NOW(6), NOW(6), NULL, id, TRUE FROM members @@ -18,7 +20,9 @@ WHERE activity_status = 'ACTIVE'; COMMIT; --- 좋아요 알림 +-- ------------------------------------------------------------ +-- 2. 좋아요 알림 생성 +-- ------------------------------------------------------------ INSERT INTO notifications (created_at, updated_at, deleted_at, sender_type, sender_id, receiver_id, type, title, body, status, read_at) @@ -37,7 +41,9 @@ FROM likes l; COMMIT; --- 매칭 요청 알림 +-- ------------------------------------------------------------ +-- 3. 매칭 요청 알림 생성 +-- ------------------------------------------------------------ INSERT INTO notifications (created_at, updated_at, deleted_at, sender_type, sender_id, receiver_id, type, title, body, status, read_at) @@ -56,7 +62,9 @@ FROM matches m; COMMIT; --- 매칭 수락/거절 알림 +-- ------------------------------------------------------------ +-- 4. 매칭 수락/거절 알림 생성 +-- ------------------------------------------------------------ INSERT INTO notifications (created_at, updated_at, deleted_at, sender_type, sender_id, receiver_id, type, title, body, status, read_at) @@ -76,7 +84,9 @@ WHERE m.status IN ('MATCHED', 'REJECTED', 'REJECT_CHECKED'); COMMIT; --- 심사 승인 알림 +-- ------------------------------------------------------------ +-- 5. 심사 승인 알림 생성 +-- ------------------------------------------------------------ INSERT INTO notifications (created_at, updated_at, deleted_at, sender_type, sender_id, receiver_id, type, title, body, status, read_at) @@ -96,5 +106,6 @@ WHERE m.activity_status = 'ACTIVE'; COMMIT; +-- 결과 확인 SELECT CONCAT('05_notifications 완료: ', COUNT(*), '건') AS status FROM notifications; diff --git a/k6/data/06_finalize.sql b/k6/data/06_finalize.sql deleted file mode 100644 index 08dcb5c8c..000000000 --- a/k6/data/06_finalize.sql +++ /dev/null @@ -1,30 +0,0 @@ --- ============================================================ --- 최종 정리 및 데이터 검증 --- ============================================================ - --- 성능 설정 복원 -SET -FOREIGN_KEY_CHECKS = 1; -SET -UNIQUE_CHECKS = 1; -SET -autocommit = 1; - --- 임시 테이블 삭제 -DROP TABLE IF EXISTS tmp_numbers; - --- 데이터 요약 -SELECT 'members' AS `table`, COUNT(*) AS count -FROM members -UNION ALL -SELECT 'member_introductions', COUNT(*) -FROM member_introductions -UNION ALL -SELECT 'likes', COUNT(*) -FROM likes -UNION ALL -SELECT 'matches', COUNT(*) -FROM matches -UNION ALL -SELECT 'notifications', COUNT(*) -FROM notifications; diff --git a/k6/data/README.md b/k6/data/README.md index 262674042..f25dc11cd 100644 --- a/k6/data/README.md +++ b/k6/data/README.md @@ -8,7 +8,6 @@ mysql -u [user] -p -h [host] [db] < ./02_introductions.sql mysql -u [user] -p -h [host] [db] < ./03_likes.sql mysql -u [user] -p -h [host] [db] < ./04_matches.sql mysql -u [user] -p -h [host] [db] < ./05_notifications.sql -mysql -u [user] -p -h [host] [db] < ./06_finalize.sql ``` ## 설정 @@ -18,7 +17,9 @@ mysql -u [user] -p -h [host] [db] < ./06_finalize.sql | 01_members | @TARGET_COUNT | 1,000,000 | 회원 수 | | 02_introductions | @INTROS_PER_MEMBER | 30 | 회원당 소개 수 | | 03_likes | @LIKE_RATIO | 30% | 소개 중 좋아요 비율 | +| 03_likes | @MUTUAL_RATIO | 20% | 상호 좋아요 비율 | | 04_matches | @MATCH_RATIO | 30% | 좋아요 중 매칭 비율 | +| 05_notifications | - | - | 설정 변수 없음 | ## 생성 데이터 @@ -26,6 +27,8 @@ mysql -u [user] -p -h [host] [db] < ./06_finalize.sql - **소개**: 3,000만건 (회원당 30명) - **좋아요**: 900만건 - **매칭**: 270만건 +- **알림**: 좋아요/매칭요청/매칭수락·거절/심사승인 알림 +- **알림 설정**: 100만건 (활성 회원당 1건) ## 주의 diff --git a/src/main/java/deepple/deepple/notification/presentation/NotificationController.java b/src/main/java/deepple/deepple/notification/presentation/NotificationController.java index d24c6833b..e0d7bb5fb 100644 --- a/src/main/java/deepple/deepple/notification/presentation/NotificationController.java +++ b/src/main/java/deepple/deepple/notification/presentation/NotificationController.java @@ -4,6 +4,9 @@ import deepple.deepple.auth.presentation.AuthPrincipal; import deepple.deepple.common.response.BaseResponse; import deepple.deepple.notification.command.application.*; +import deepple.deepple.notification.command.domain.ChannelType; +import deepple.deepple.notification.command.domain.NotificationType; +import deepple.deepple.notification.command.domain.SenderType; import deepple.deepple.notification.query.NotificationQueryService; import deepple.deepple.notification.query.NotificationViews; import io.swagger.v3.oas.annotations.Operation; @@ -13,6 +16,8 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.Map; + import static deepple.deepple.common.enums.StatusType.OK; @Tag(name = "알림 API") @@ -68,4 +73,23 @@ public ResponseEntity> send( notificationSendService.send(request); return ResponseEntity.ok(BaseResponse.from(OK)); } + + // TODO: 삭제 필요(k6 부하 테스트 용도) + @Operation(summary = "(테스트) 알림 전송 - k6 부하 테스트용") + @PostMapping("/test") + public ResponseEntity> sendTestNotification( + @AuthPrincipal AuthContext authContext, + @RequestParam Long receiverId + ) { + var request = new NotificationSendRequest( + SenderType.MEMBER, + authContext.getId(), + receiverId, + NotificationType.LIKE, + Map.of("senderName", "테스트"), + ChannelType.PUSH + ); + notificationSendService.send(request); + return ResponseEntity.ok(BaseResponse.from(OK)); + } } From b5b2499fa0c4eff19bc3765857e54cc509c535f6 Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Tue, 6 Jan 2026 16:11:18 +0900 Subject: [PATCH 09/22] =?UTF-8?q?feat:=20k6=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #394 --- k6/README.md | 32 ++++++++++++++++ k6/scripts/lib/auth.js | 30 +++++++++++++++ k6/scripts/lib/config.js | 3 ++ k6/scripts/member-profile.js | 74 ++++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 k6/README.md create mode 100644 k6/scripts/lib/auth.js create mode 100644 k6/scripts/lib/config.js create mode 100644 k6/scripts/member-profile.js diff --git a/k6/README.md b/k6/README.md new file mode 100644 index 000000000..6c1ffd60d --- /dev/null +++ b/k6/README.md @@ -0,0 +1,32 @@ +# k6 성능 테스트 + +## 설치 + +```bash +# macOS +brew install k6 + +# Docker +docker pull grafana/k6 +``` + +## 실행 + +```bash +# 로컬 실행 +k6 run k6/scripts/*.js + +# 환경변수 설정 +k6 run -e BASE_URL=http://localhost:8080 k6/scripts/*.js + +# Docker 실행 +docker run --rm -i grafana/k6 run - < k6/scripts/*.js +``` + +## 디렉토리 구조 + +``` +k6/ +├── scripts/ # 테스트 스크립트 +└── data/ # 테스트 데이터 생성 SQL +``` \ No newline at end of file diff --git a/k6/scripts/lib/auth.js b/k6/scripts/lib/auth.js new file mode 100644 index 000000000..05baee049 --- /dev/null +++ b/k6/scripts/lib/auth.js @@ -0,0 +1,30 @@ +import http from 'k6/http'; +import {config} from './config.js'; + +export function login(memberId) { + const phoneNumber = `010${String(memberId).padStart(8, '0')}`; + + const res = http.post( + `${config.baseUrl}/member/login/test`, + JSON.stringify({phoneNumber}), + {headers: {'Content-Type': 'application/json'}} + ); + + if (res.status !== 200) { + console.warn(`Login failed for memberId=${memberId}: ${res.status}`); + return null; + } + + const body = JSON.parse(res.body); + return { + memberId, + accessToken: body.data.accessToken, + }; +} + +export function authHeaders(token) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; +} \ No newline at end of file diff --git a/k6/scripts/lib/config.js b/k6/scripts/lib/config.js new file mode 100644 index 000000000..1c3279e60 --- /dev/null +++ b/k6/scripts/lib/config.js @@ -0,0 +1,3 @@ +export const config = { + baseUrl: __ENV.BASE_URL || 'http://localhost:8080', +}; \ No newline at end of file diff --git a/k6/scripts/member-profile.js b/k6/scripts/member-profile.js new file mode 100644 index 000000000..c7ebb42e5 --- /dev/null +++ b/k6/scripts/member-profile.js @@ -0,0 +1,74 @@ +import http from 'k6/http'; +import {check, sleep} from 'k6'; +import {config} from './lib/config.js'; +import {authHeaders, login} from './lib/auth.js'; + +const TARGET_RPS = parseInt(__ENV.TARGET_RPS) || 100; +const MAX_VUS = TARGET_RPS * 2; +const MEMBER_OFFSET = 400000; +const FEMALE_COUNT = 500000; +const INTROS_PER_MEMBER = 30; + +export const options = { + scenarios: { + member_profile: { + executor: 'ramping-arrival-rate', + startRate: 0, + timeUnit: '1s', + preAllocatedVUs: MAX_VUS, + maxVUs: MAX_VUS, + stages: [ + {duration: '1m', target: Math.floor(TARGET_RPS * 0.1)}, + {duration: '1m', target: Math.floor(TARGET_RPS * 0.5)}, + {duration: '1m', target: TARGET_RPS}, + {duration: '1m', target: TARGET_RPS * 2}, + {duration: '1m', target: TARGET_RPS * 3}, + ], + }, + }, + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + http_req_failed: ['rate<0.01'], + }, +}; + +function getIntroducedFemaleId(maleId, introIndex) { + return 2 * ((Math.floor((maleId - 1) / 2) + introIndex * 7) % FEMALE_COUNT + 1); +} + +export function setup() { + console.log(`Setup: ${MAX_VUS}명 토큰 획득 시작`); + + const tokens = []; + for (let i = 0; i < MAX_VUS; i++) { + const memberId = MEMBER_OFFSET + (i * 2) + 1; + const result = login(memberId); + if (result) tokens.push(result); + } + + console.log(`Setup 완료: ${tokens.length}개 토큰 획득`); + return {tokens}; +} + +export default function (data) { + const {tokens} = data; + + if (!tokens.length || __VU > tokens.length) { + return; + } + + const requester = tokens[__VU - 1]; + const introIndex = (__ITER % INTROS_PER_MEMBER) + 1; + const targetMemberId = getIntroducedFemaleId(requester.memberId, introIndex); + + const res = http.get( + `${config.baseUrl}/member/${targetMemberId}`, + {headers: authHeaders(requester.accessToken)} + ); + + check(res, { + 'status is 200': (r) => r.status === 200, + }); + + sleep(Math.random() * 0.3 + 0.1); +} \ No newline at end of file From 4cdc8c4194c09ef0ab59e6fda608fdae97d83c65 Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Tue, 6 Jan 2026 16:11:37 +0900 Subject: [PATCH 10/22] =?UTF-8?q?feat:=20db=20connection=20pool=20?= =?UTF-8?q?=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 #394 --- src/main/resources/application-prod.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index bcd23b1b7..cf6b53e80 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -4,6 +4,10 @@ spring: username: ${MYSQL_USER:user} password: ${MYSQL_PASSWORD:1234} driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 20 + minimum-idle: 20 + connection-timeout: 3000 jpa: hibernate: From efda53c937142f0ae300108f575e0ae1875ece55 Mon Sep 17 00:00:00 2001 From: hainho Date: Tue, 6 Jan 2026 23:49:02 +0900 Subject: [PATCH 11/22] =?UTF-8?q?fix:=20annotationProcessor=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 9c3b74ae0..5ab81ecf9 100644 --- a/build.gradle +++ b/build.gradle @@ -46,8 +46,8 @@ dependencies { // Database - QueryDSL implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' - annotationProcessor 'jakarta.annotation:jakarta.annotation-api' - annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api:2.1.1' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0' // Database - Migration implementation 'org.flywaydb:flyway-core' From 1c0b566b07886a4aa1dcdbafa4cd22ee256a720d Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Thu, 8 Jan 2026 14:06:00 +0900 Subject: [PATCH 12/22] =?UTF-8?q?feat:=20rps=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #394 --- k6/scripts/member-profile.js | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/k6/scripts/member-profile.js b/k6/scripts/member-profile.js index c7ebb42e5..bf0d92f68 100644 --- a/k6/scripts/member-profile.js +++ b/k6/scripts/member-profile.js @@ -3,10 +3,8 @@ import {check, sleep} from 'k6'; import {config} from './lib/config.js'; import {authHeaders, login} from './lib/auth.js'; -const TARGET_RPS = parseInt(__ENV.TARGET_RPS) || 100; -const MAX_VUS = TARGET_RPS * 2; -const MEMBER_OFFSET = 400000; -const FEMALE_COUNT = 500000; +const MAX_VUS = 1000; +const MEMBER_COUNT = 1000000; const INTROS_PER_MEMBER = 30; export const options = { @@ -18,11 +16,11 @@ export const options = { preAllocatedVUs: MAX_VUS, maxVUs: MAX_VUS, stages: [ - {duration: '1m', target: Math.floor(TARGET_RPS * 0.1)}, - {duration: '1m', target: Math.floor(TARGET_RPS * 0.5)}, - {duration: '1m', target: TARGET_RPS}, - {duration: '1m', target: TARGET_RPS * 2}, - {duration: '1m', target: TARGET_RPS * 3}, + {duration: '1m', target: 50}, + {duration: '1m', target: 100}, + {duration: '1m', target: 150}, + {duration: '1m', target: 200}, + {duration: '1m', target: 250}, ], }, }, @@ -32,8 +30,12 @@ export const options = { }, }; -function getIntroducedFemaleId(maleId, introIndex) { - return 2 * ((Math.floor((maleId - 1) / 2) + introIndex * 7) % FEMALE_COUNT + 1); +function getIntroducedMemberId(memberId, introIndex) { + const isMale = memberId % 2 === 1; + const memberIndex = Math.floor((memberId - 1) / 2); + const targetIndex = (memberIndex + introIndex * 7) % (MEMBER_COUNT / 2); + // 남성(홀수)은 여성(짝수)을, 여성(짝수)은 남성(홀수)을 조회 + return isMale ? (targetIndex + 1) * 2 : targetIndex * 2 + 1; } export function setup() { @@ -41,7 +43,7 @@ export function setup() { const tokens = []; for (let i = 0; i < MAX_VUS; i++) { - const memberId = MEMBER_OFFSET + (i * 2) + 1; + const memberId = i + 1; // 1 ~ 1000 (홀수=남성, 짝수=여성) const result = login(memberId); if (result) tokens.push(result); } @@ -59,7 +61,7 @@ export default function (data) { const requester = tokens[__VU - 1]; const introIndex = (__ITER % INTROS_PER_MEMBER) + 1; - const targetMemberId = getIntroducedFemaleId(requester.memberId, introIndex); + const targetMemberId = getIntroducedMemberId(requester.memberId, introIndex); const res = http.get( `${config.baseUrl}/member/${targetMemberId}`, @@ -67,7 +69,7 @@ export default function (data) { ); check(res, { - 'status is 200': (r) => r.status === 200, + 'status 200': (r) => r.status === 200, }); sleep(Math.random() * 0.3 + 0.1); From 2f717e16c1595f055225d10b4ffa39b2be5a3827 Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Thu, 8 Jan 2026 14:06:00 +0900 Subject: [PATCH 13/22] =?UTF-8?q?feat:=20rps=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #394 --- k6/scripts/member-profile.js | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/k6/scripts/member-profile.js b/k6/scripts/member-profile.js index c7ebb42e5..bf0d92f68 100644 --- a/k6/scripts/member-profile.js +++ b/k6/scripts/member-profile.js @@ -3,10 +3,8 @@ import {check, sleep} from 'k6'; import {config} from './lib/config.js'; import {authHeaders, login} from './lib/auth.js'; -const TARGET_RPS = parseInt(__ENV.TARGET_RPS) || 100; -const MAX_VUS = TARGET_RPS * 2; -const MEMBER_OFFSET = 400000; -const FEMALE_COUNT = 500000; +const MAX_VUS = 1000; +const MEMBER_COUNT = 1000000; const INTROS_PER_MEMBER = 30; export const options = { @@ -18,11 +16,11 @@ export const options = { preAllocatedVUs: MAX_VUS, maxVUs: MAX_VUS, stages: [ - {duration: '1m', target: Math.floor(TARGET_RPS * 0.1)}, - {duration: '1m', target: Math.floor(TARGET_RPS * 0.5)}, - {duration: '1m', target: TARGET_RPS}, - {duration: '1m', target: TARGET_RPS * 2}, - {duration: '1m', target: TARGET_RPS * 3}, + {duration: '1m', target: 50}, + {duration: '1m', target: 100}, + {duration: '1m', target: 150}, + {duration: '1m', target: 200}, + {duration: '1m', target: 250}, ], }, }, @@ -32,8 +30,12 @@ export const options = { }, }; -function getIntroducedFemaleId(maleId, introIndex) { - return 2 * ((Math.floor((maleId - 1) / 2) + introIndex * 7) % FEMALE_COUNT + 1); +function getIntroducedMemberId(memberId, introIndex) { + const isMale = memberId % 2 === 1; + const memberIndex = Math.floor((memberId - 1) / 2); + const targetIndex = (memberIndex + introIndex * 7) % (MEMBER_COUNT / 2); + // 남성(홀수)은 여성(짝수)을, 여성(짝수)은 남성(홀수)을 조회 + return isMale ? (targetIndex + 1) * 2 : targetIndex * 2 + 1; } export function setup() { @@ -41,7 +43,7 @@ export function setup() { const tokens = []; for (let i = 0; i < MAX_VUS; i++) { - const memberId = MEMBER_OFFSET + (i * 2) + 1; + const memberId = i + 1; // 1 ~ 1000 (홀수=남성, 짝수=여성) const result = login(memberId); if (result) tokens.push(result); } @@ -59,7 +61,7 @@ export default function (data) { const requester = tokens[__VU - 1]; const introIndex = (__ITER % INTROS_PER_MEMBER) + 1; - const targetMemberId = getIntroducedFemaleId(requester.memberId, introIndex); + const targetMemberId = getIntroducedMemberId(requester.memberId, introIndex); const res = http.get( `${config.baseUrl}/member/${targetMemberId}`, @@ -67,7 +69,7 @@ export default function (data) { ); check(res, { - 'status is 200': (r) => r.status === 200, + 'status 200': (r) => r.status === 200, }); sleep(Math.random() * 0.3 + 0.1); From 1f854a50a9b446152fc404d51b15ea3efc3e1530 Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Thu, 8 Jan 2026 16:22:28 +0900 Subject: [PATCH 14/22] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20k6=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FCM 제외 테스트 로직 `sendWithoutPush` 추가 - k6 부하 테스트 스크립트 구현 - 테스트용 SQL 데이터 초기화/생성 로직 추가 #394 --- k6/data/05_notifications.sql | 223 +++++++++++++----- k6/scripts/notifications.js | 74 ++++++ .../application/NotificationSendService.java | 32 +++ .../presentation/NotificationController.java | 13 +- 4 files changed, 274 insertions(+), 68 deletions(-) create mode 100644 k6/scripts/notifications.js diff --git a/k6/data/05_notifications.sql b/k6/data/05_notifications.sql index 10ca4a4af..771013f9d 100644 --- a/k6/data/05_notifications.sql +++ b/k6/data/05_notifications.sql @@ -7,102 +7,199 @@ SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE notifications; TRUNCATE TABLE notification_preferences; +TRUNCATE TABLE member_notification_preferences; +TRUNCATE TABLE device_registrations; +TRUNCATE TABLE notification_templates; SET FOREIGN_KEY_CHECKS = 1; -- ------------------------------------------------------------ --- 1. 알림 설정 생성 +-- 1. 알림 템플릿 생성 -- ------------------------------------------------------------ -INSERT INTO notification_preferences (created_at, updated_at, deleted_at, member_id, is_enabled_globally) -SELECT NOW(6), NOW(6), NULL, id, TRUE -FROM members -WHERE activity_status = 'ACTIVE'; +INSERT INTO notification_templates (type, title_template, body_template, is_active) +VALUES +-- 매치 +('MATCH_REQUEST', '매치 요청', '{senderName}님에게 메시지가 도착하였습니다.', true), +('MATCH_ACCEPT', '매치 수락', '{senderName}님과 매칭되었습니다. 축하합니다!', true), +('MATCH_REJECT', '매치 거절', '{senderName}님이 데이트 신청을 거절하셨습니다.', true), +-- 프로필 교환 +('PROFILE_EXCHANGE_REQUEST', '프로필 교환 요청', '{senderName}님이 프로필 교환을 요청하였습니다.', true), +('PROFILE_EXCHANGE_ACCEPT', '프로필 교환 수락', '{senderName}님이 프로필 교환을 수락하셨습니다!', true), +('PROFILE_EXCHANGE_REJECT', '프로필 교환 거절', '{senderName}님이 프로필 교환을 거절하셨습니다.', true), +-- 좋아요 +('LIKE', '좋아요', '{senderName}님이 회원님의 프로필을 좋아합니다.', true), +-- 프로필 이미지 변경 요청 +('PROFILE_IMAGE_CHANGE_REQUEST', '프로필 이미지 변경 요청', '나를 더 잘 표현할 수 있는 프로필 이미지로 설정해보세요!', true), +-- 인터뷰 작성 요청 +('INTERVIEW_WRITE_REQUEST', '인터뷰 작성 요청', '아직 인터뷰를 작성하지 않으셨어요! 인터뷰 작성하고 무료 하트 받아 가세요.', true), +-- 경고 알림 +('INAPPROPRIATE_PROFILE', '부적절한 프로필 경고', '등록하신 프로필 정보를 변경해 주세요. 개인 정보 혹은 부적절한 내용 등록 시 서비스 이용이 제한될 수 있습니다.', true), +('INAPPROPRIATE_PROFILE_IMAGE', '부적절한 프로필 사진 경고', '등록하신 프로필 사진을 변경해 주세요. 부적절한 사진 등록 시 서비스 이용이 제한될 수 있습니다.', true), +('INAPPROPRIATE_INTERVIEW', '부적절한 인터뷰 경고', '등록하신 인터뷰를 변경해 주세요. 개인 정보 혹은 부적절한 내용 등록 시 서비스 이용이 제한될 수 있습니다.', true), +('INAPPROPRIATE_SELF_INTRODUCTION', '부적절한 셀프소개 게시글 경고', + '작성하신 게시글에 부적절한 내용이 포함되어 있습니다. 부적절한 내용 등록 시 서비스 이용이 제한될 수 있습니다.', true), +-- 장기 미로그인 +('INACTIVITY_REMINDER', '다시 방문해보세요', '장기간 접속이 없으시네요! 다시 연애를 시작해 볼까요?', true), +-- 심사 +('SCREENING_APPROVED', '심사 승인', '프로필 심사가 완료되었어요! 안전하고 진정성 있는 만남을 시작해 보세요.', true), +('SCREENING_REJECTED', '심사 반려', '심사가 반려되었습니다. 등록하신 프로필을 변경해주세요. (반려 사유: {rejectionReason})', true); COMMIT; -- ------------------------------------------------------------ --- 2. 좋아요 알림 생성 +-- 2. 알림 설정 생성 -- ------------------------------------------------------------ -INSERT INTO notifications (created_at, updated_at, deleted_at, - sender_type, sender_id, receiver_id, - type, title, body, status, read_at) -SELECT DATE_SUB(NOW(6), INTERVAL (l.id % 30) DAY), - NOW(6), - NULL, - 'MEMBER', - l.sender_id, - l.receiver_id, - 'LIKE', - '새로운 좋아요', - '회원님을 좋아하는 사람이 있어요!', - 'SENT', - IF((l.id % 100) < 30, DATE_SUB(NOW(6), INTERVAL (l.id % 7) DAY), NULL) -FROM likes l; +INSERT INTO notification_preferences (created_at, updated_at, deleted_at, member_id, is_enabled_globally) +SELECT NOW(6), NOW(6), NULL, id, TRUE +FROM members +WHERE activity_status = 'ACTIVE'; COMMIT; -- ------------------------------------------------------------ --- 3. 매칭 요청 알림 생성 +-- 3. 회원별 알림 타입 설정 (모든 타입 활성화) -- ------------------------------------------------------------ -INSERT INTO notifications (created_at, updated_at, deleted_at, - sender_type, sender_id, receiver_id, - type, title, body, status, read_at) -SELECT DATE_SUB(NOW(6), INTERVAL (m.id % 30) DAY), - NOW(6), - NULL, - 'MEMBER', - m.requester_id, - m.responder_id, - 'MATCH_REQUEST', - '새로운 매칭 요청', - '회원님께 매칭 요청이 도착했습니다.', - 'SENT', - IF((m.id % 100) < 40, DATE_SUB(NOW(6), INTERVAL (m.id % 7) DAY), NULL) -FROM matches m; +INSERT INTO member_notification_preferences (member_id, notification_type, is_enabled) +SELECT np.id, nt.type, TRUE +FROM notification_preferences np + CROSS JOIN (SELECT 'MATCH_REQUEST' AS type + UNION ALL + SELECT 'MATCH_ACCEPT' + UNION ALL + SELECT 'MATCH_REJECT' + UNION ALL + SELECT 'PROFILE_EXCHANGE_REQUEST' + UNION ALL + SELECT 'PROFILE_EXCHANGE_ACCEPT' + UNION ALL + SELECT 'PROFILE_EXCHANGE_REJECT' + UNION ALL + SELECT 'LIKE' + UNION ALL + SELECT 'PROFILE_IMAGE_CHANGE_REQUEST' + UNION ALL + SELECT 'INTERVIEW_WRITE_REQUEST' + UNION ALL + SELECT 'INAPPROPRIATE_PROFILE' + UNION ALL + SELECT 'INAPPROPRIATE_PROFILE_IMAGE' + UNION ALL + SELECT 'INAPPROPRIATE_INTERVIEW' + UNION ALL + SELECT 'INAPPROPRIATE_SELF_INTRODUCTION' + UNION ALL + SELECT 'INACTIVITY_REMINDER' + UNION ALL + SELECT 'SCREENING_APPROVED' + UNION ALL + SELECT 'SCREENING_REJECTED') nt; COMMIT; -- ------------------------------------------------------------ --- 4. 매칭 수락/거절 알림 생성 +-- 4. 디바이스 등록 (회원당 1개) -- ------------------------------------------------------------ -INSERT INTO notifications (created_at, updated_at, deleted_at, - sender_type, sender_id, receiver_id, - type, title, body, status, read_at) -SELECT DATE_SUB(NOW(6), INTERVAL (m.id % 20) DAY), - NOW(6), - NULL, - 'MEMBER', - m.responder_id, - m.requester_id, - IF(m.status = 'MATCHED', 'MATCH_ACCEPT', 'MATCH_REJECT'), - IF(m.status = 'MATCHED', '매칭이 수락되었습니다', '매칭이 거절되었습니다'), - IF(m.status = 'MATCHED', '상대방이 매칭을 수락했습니다!', '아쉽지만 다음 기회에!'), - 'SENT', - IF((m.id % 100) < 50, DATE_SUB(NOW(6), INTERVAL (m.id % 5) DAY), NULL) -FROM matches m -WHERE m.status IN ('MATCHED', 'REJECTED', 'REJECT_CHECKED'); +INSERT INTO device_registrations (member_id, device_id, registration_token, is_active) +SELECT id, + CONCAT('device_', id), + CONCAT('fcm_token_', id), + TRUE +FROM members +WHERE activity_status = 'ACTIVE'; COMMIT; -- ------------------------------------------------------------ --- 5. 심사 승인 알림 생성 +-- 5. 알림 생성 (회원당 30개, 총 3000만 건) -- ------------------------------------------------------------ INSERT INTO notifications (created_at, updated_at, deleted_at, sender_type, sender_id, receiver_id, type, title, body, status, read_at) -SELECT DATE_SUB(NOW(6), INTERVAL 30 DAY), +SELECT DATE_SUB(NOW(6), INTERVAL ((m.id + seq.n) % 30) DAY), NOW(6), NULL, - 'SYSTEM', - NULL, + 'MEMBER', m.id, - 'SCREENING_APPROVED', - '프로필 심사 승인', - '프로필이 승인되었습니다.', + IF(m.id % 2 = 1, m.id + 1, m.id - 1), + ELT(((m.id + seq.n) % 16) + 1, + 'MATCH_REQUEST', 'MATCH_ACCEPT', 'MATCH_REJECT', + 'PROFILE_EXCHANGE_REQUEST', 'PROFILE_EXCHANGE_ACCEPT', 'PROFILE_EXCHANGE_REJECT', + 'LIKE', + 'PROFILE_IMAGE_CHANGE_REQUEST', 'INTERVIEW_WRITE_REQUEST', + 'INAPPROPRIATE_PROFILE', 'INAPPROPRIATE_PROFILE_IMAGE', + 'INAPPROPRIATE_INTERVIEW', 'INAPPROPRIATE_SELF_INTRODUCTION', + 'INACTIVITY_REMINDER', 'SCREENING_APPROVED', 'SCREENING_REJECTED'), + ELT(((m.id + seq.n) % 16) + 1, + '매치 요청', '매치 수락', '매치 거절', + '프로필 교환 요청', '프로필 교환 수락', '프로필 교환 거절', + '좋아요', + '프로필 이미지 변경 요청', '인터뷰 작성 요청', + '부적절한 프로필 경고', '부적절한 프로필 사진 경고', + '부적절한 인터뷰 경고', '부적절한 셀프소개 게시글 경고', + '다시 방문해보세요', '심사 승인', '심사 반려'), + '테스트 알림 본문입니다.', 'SENT', - DATE_SUB(NOW(6), INTERVAL 29 DAY) + IF(((m.id + seq.n) % 100) < 30, DATE_SUB(NOW(6), INTERVAL ((m.id + seq.n) % 7) DAY), NULL) FROM members m -WHERE m.activity_status = 'ACTIVE'; + CROSS JOIN (SELECT 1 AS n + UNION ALL + SELECT 2 + UNION ALL + SELECT 3 + UNION ALL + SELECT 4 + UNION ALL + SELECT 5 + UNION ALL + SELECT 6 + UNION ALL + SELECT 7 + UNION ALL + SELECT 8 + UNION ALL + SELECT 9 + UNION ALL + SELECT 10 + UNION ALL + SELECT 11 + UNION ALL + SELECT 12 + UNION ALL + SELECT 13 + UNION ALL + SELECT 14 + UNION ALL + SELECT 15 + UNION ALL + SELECT 16 + UNION ALL + SELECT 17 + UNION ALL + SELECT 18 + UNION ALL + SELECT 19 + UNION ALL + SELECT 20 + UNION ALL + SELECT 21 + UNION ALL + SELECT 22 + UNION ALL + SELECT 23 + UNION ALL + SELECT 24 + UNION ALL + SELECT 25 + UNION ALL + SELECT 26 + UNION ALL + SELECT 27 + UNION ALL + SELECT 28 + UNION ALL + SELECT 29 + UNION ALL + SELECT 30) seq; COMMIT; diff --git a/k6/scripts/notifications.js b/k6/scripts/notifications.js new file mode 100644 index 000000000..dbeb43a2b --- /dev/null +++ b/k6/scripts/notifications.js @@ -0,0 +1,74 @@ +import http from 'k6/http'; +import {check, sleep} from 'k6'; +import {config} from './lib/config.js'; +import {authHeaders, login} from './lib/auth.js'; + +const MAX_VUS = 100; +const MEMBER_COUNT = 1000000; + +export const options = { + scenarios: { + notifications_test: { + executor: 'ramping-arrival-rate', + startRate: 0, + timeUnit: '1s', + preAllocatedVUs: MAX_VUS, + maxVUs: MAX_VUS, + stages: [ + {duration: '1m', target: 50}, + {duration: '1m', target: 100}, + {duration: '1m', target: 150}, + {duration: '1m', target: 200}, + {duration: '1m', target: 250}, + ], + }, + }, + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + http_req_failed: ['rate<0.01'], + }, +}; + +function getReceiverId(senderId) { + const isMale = senderId % 2 === 1; + const senderIndex = Math.floor((senderId - 1) / 2); + const targetIndex = (senderIndex + 1) % (MEMBER_COUNT / 2); + return isMale ? (targetIndex + 1) * 2 : targetIndex * 2 + 1; +} + +export function setup() { + console.log(`Setup: ${MAX_VUS}명 토큰 획득 시작`); + + const tokens = []; + for (let i = 0; i < MAX_VUS; i++) { + const memberId = i + 1; + const result = login(memberId); + if (result) tokens.push(result); + } + + console.log(`Setup 완료: ${tokens.length}개 토큰 획득`); + return {tokens}; +} + +export default function (data) { + const {tokens} = data; + + if (!tokens.length || __VU > tokens.length) { + return; + } + + const sender = tokens[__VU - 1]; + const receiverId = getReceiverId(sender.memberId); + + const res = http.post( + `${config.baseUrl}/notifications/test?receiverId=${receiverId}`, + null, + {headers: authHeaders(sender.accessToken)} + ); + + check(res, { + 'status 200': (r) => r.status === 200, + }); + + sleep(Math.random() * 0.3 + 0.1); +} \ No newline at end of file diff --git a/src/main/java/deepple/deepple/notification/command/application/NotificationSendService.java b/src/main/java/deepple/deepple/notification/command/application/NotificationSendService.java index b7fb9e1e3..a199085af 100644 --- a/src/main/java/deepple/deepple/notification/command/application/NotificationSendService.java +++ b/src/main/java/deepple/deepple/notification/command/application/NotificationSendService.java @@ -42,6 +42,38 @@ public void send(NotificationSendRequest request) { sendNotification(notification, device, request); } + // TODO: 삭제 필요(k6 부하 테스트 용도 - FCM 전송 제외) + @Transactional + public void sendWithoutPush(NotificationSendRequest request) { + // 1. 알림 생성 + var notification = createNotificationWithTemplate(request); + if (notification == null) { + return; + } + + // 2. 수신자 설정 확인 + if (!canSendByPreference(notification)) { + return; + } + + // 3. 수신자 기기 조회 + var device = findReceiversActiveDevice(notification); + if (device == null) { + return; + } + + // 4. FCM 전송 시간 시뮬레이션 (50ms) + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // 5. 저장 + notification.markAsSent(); + save(notification); + } + private Notification createNotificationWithTemplate(NotificationSendRequest request) { return notificationTemplateRepository.findByType(request.notificationType()) .map(template -> Notification.create( diff --git a/src/main/java/deepple/deepple/notification/presentation/NotificationController.java b/src/main/java/deepple/deepple/notification/presentation/NotificationController.java index e0d7bb5fb..5c4a3c4d8 100644 --- a/src/main/java/deepple/deepple/notification/presentation/NotificationController.java +++ b/src/main/java/deepple/deepple/notification/presentation/NotificationController.java @@ -74,22 +74,25 @@ public ResponseEntity> send( return ResponseEntity.ok(BaseResponse.from(OK)); } - // TODO: 삭제 필요(k6 부하 테스트 용도) - @Operation(summary = "(테스트) 알림 전송 - k6 부하 테스트용") + // TODO: 삭제 필요(k6 부하 테스트 용도 - FCM 전송 제외, 50ms 대기) + @Operation(summary = "(테스트) 알림 전송 - k6 부하 테스트용 (FCM 전송 제외)") @PostMapping("/test") public ResponseEntity> sendTestNotification( @AuthPrincipal AuthContext authContext, @RequestParam Long receiverId ) { + var types = NotificationType.values(); + var type = types[(int) (receiverId % types.length)]; + var request = new NotificationSendRequest( SenderType.MEMBER, authContext.getId(), receiverId, - NotificationType.LIKE, - Map.of("senderName", "테스트"), + type, + Map.of("senderName", "테스트", "rejectionReason", "테스트 사유"), ChannelType.PUSH ); - notificationSendService.send(request); + notificationSendService.sendWithoutPush(request); return ResponseEntity.ok(BaseResponse.from(OK)); } } From aff2e3385c08fc0e0db4d918f9ab2144ea11d934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B3=B5=ED=83=9C=ED=98=84?= Date: Sun, 11 Jan 2026 17:35:40 +0900 Subject: [PATCH 15/22] =?UTF-8?q?[Fix]=20Bizgo=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20ContactType=20Validation=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD.=20=20(#401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refac] : When Match Request or Response, Don't Check Primary Contatct Type * [fix] : Change BaseURL * [Fix] : Using Enum --- .../match/command/domain/match/Match.java | 8 +- .../match/event/MatchRequestedEvent.java | 5 +- .../match/event/MatchRespondedEvent.java | 6 +- .../member/MemberProfileService.java | 11 ++- ...=> ContactTypeSettingNeededException.java} | 4 +- .../member/MemberProfileEventHandler.java | 4 +- .../infra/member/sms/BizgoMessanger.java | 32 +++----- .../infra/member/sms/BizgoTokenHandler.java | 77 ------------------- .../member/MemberExceptionHandler.java | 4 +- src/main/resources/application-dev.yml | 1 + src/main/resources/application-local.yml | 1 + src/main/resources/application-prod.yml | 1 + .../member/MemberProfileServiceTest.java | 58 +++++++++++++- src/test/resources/application.yml | 1 + 14 files changed, 95 insertions(+), 118 deletions(-) rename src/main/java/deepple/deepple/member/command/application/member/exception/{PrimaryContactTypeSettingNeededException.java => ContactTypeSettingNeededException.java} (50%) delete mode 100644 src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoTokenHandler.java diff --git a/src/main/java/deepple/deepple/match/command/domain/match/Match.java b/src/main/java/deepple/deepple/match/command/domain/match/Match.java index 6f77c0bd9..65af175c3 100644 --- a/src/main/java/deepple/deepple/match/command/domain/match/Match.java +++ b/src/main/java/deepple/deepple/match/command/domain/match/Match.java @@ -64,7 +64,7 @@ public static Match request(long requesterId, long responderId, @NonNull Message .requesterContactType(contactType) .build(); - Events.raise(MatchRequestedEvent.of(requesterId, requesterName, responderId, type.name())); + Events.raise(MatchRequestedEvent.of(requesterId, requesterName, responderId, type.name(), contactType.name())); Events.raise(MatchRequestCompletedEvent.of(requesterId, responderId)); return match; @@ -75,21 +75,21 @@ public void approve(@NonNull Message message, String responderName, @NonNull Mat status = MatchStatus.MATCHED; responseMessage = message; responderContactType = contactType; - Events.raise(MatchRespondedEvent.of(requesterId, responderId, status)); + Events.raise(MatchRespondedEvent.of(requesterId, responderId, status, contactType.name())); Events.raise(MatchAcceptedEvent.of(requesterId, responderId, responderName)); } public void reject(String responderName) { validateChangeStatus(); status = MatchStatus.REJECTED; - Events.raise(MatchRespondedEvent.of(requesterId, responderId, status)); + Events.raise(MatchRespondedEvent.of(requesterId, responderId, status, null)); Events.raise(MatchRejectedEvent.of(requesterId, responderId, responderName)); } public void expire() { validateChangeStatus(); status = MatchStatus.EXPIRED; - Events.raise(MatchRespondedEvent.of(requesterId, responderId, status)); + Events.raise(MatchRespondedEvent.of(requesterId, responderId, status, null)); } public void checkRejected() { diff --git a/src/main/java/deepple/deepple/match/command/domain/match/event/MatchRequestedEvent.java b/src/main/java/deepple/deepple/match/command/domain/match/event/MatchRequestedEvent.java index 487b7afa7..f67e6dba7 100644 --- a/src/main/java/deepple/deepple/match/command/domain/match/event/MatchRequestedEvent.java +++ b/src/main/java/deepple/deepple/match/command/domain/match/event/MatchRequestedEvent.java @@ -13,9 +13,10 @@ public class MatchRequestedEvent extends Event { private final String requesterName; private final long responderId; private final String matchType; + private final String contactType; public static MatchRequestedEvent of(long requesterId, @NonNull String requesterName, long responderId, - String matchType) { - return new MatchRequestedEvent(requesterId, requesterName, responderId, matchType); + String matchType, String contactType) { + return new MatchRequestedEvent(requesterId, requesterName, responderId, matchType, contactType); } } diff --git a/src/main/java/deepple/deepple/match/command/domain/match/event/MatchRespondedEvent.java b/src/main/java/deepple/deepple/match/command/domain/match/event/MatchRespondedEvent.java index c993cb4d2..5ed795a4a 100644 --- a/src/main/java/deepple/deepple/match/command/domain/match/event/MatchRespondedEvent.java +++ b/src/main/java/deepple/deepple/match/command/domain/match/event/MatchRespondedEvent.java @@ -12,8 +12,10 @@ public class MatchRespondedEvent extends Event { private final long requesterId; private final long responderId; private final String matchStatus; + private final String contactType; - public static MatchRespondedEvent of(Long requesterId, Long responderId, MatchStatus matchStatus) { - return new MatchRespondedEvent(requesterId, responderId, matchStatus.toString()); + public static MatchRespondedEvent of(Long requesterId, Long responderId, MatchStatus matchStatus, + String contactType) { + return new MatchRespondedEvent(requesterId, responderId, matchStatus.toString(), contactType); } } diff --git a/src/main/java/deepple/deepple/member/command/application/member/MemberProfileService.java b/src/main/java/deepple/deepple/member/command/application/member/MemberProfileService.java index 9fd2ef059..f5e7ff451 100644 --- a/src/main/java/deepple/deepple/member/command/application/member/MemberProfileService.java +++ b/src/main/java/deepple/deepple/member/command/application/member/MemberProfileService.java @@ -3,7 +3,7 @@ import deepple.deepple.member.command.application.member.exception.MemberNotFoundException; import deepple.deepple.member.command.application.member.exception.PermanentlySuspendedMemberException; -import deepple.deepple.member.command.application.member.exception.PrimaryContactTypeSettingNeededException; +import deepple.deepple.member.command.application.member.exception.ContactTypeSettingNeededException; import deepple.deepple.member.command.domain.member.ActivityStatus; import deepple.deepple.member.command.domain.member.Member; import deepple.deepple.member.command.domain.member.MemberCommandRepository; @@ -45,9 +45,12 @@ private void validateMemberStatusForActive(final Member member) { } @Transactional - public void validatePrimaryContactTypeSetting(Long memberId) { - if (getMemberById(memberId).getPrimaryContactType() == PrimaryContactType.NONE) { - throw new PrimaryContactTypeSettingNeededException(); + public void validateContactTypeSetting(Long memberId, String contactType) { + Member member = getMemberById(memberId); + if (PrimaryContactType.PHONE_NUMBER.name().equals(contactType) && member.getPhoneNumber() == null) { + throw new ContactTypeSettingNeededException(); + } else if (PrimaryContactType.KAKAO.name().equals(contactType) && member.getKakaoId() == null) { + throw new ContactTypeSettingNeededException(); } } diff --git a/src/main/java/deepple/deepple/member/command/application/member/exception/PrimaryContactTypeSettingNeededException.java b/src/main/java/deepple/deepple/member/command/application/member/exception/ContactTypeSettingNeededException.java similarity index 50% rename from src/main/java/deepple/deepple/member/command/application/member/exception/PrimaryContactTypeSettingNeededException.java rename to src/main/java/deepple/deepple/member/command/application/member/exception/ContactTypeSettingNeededException.java index 299484d63..a93a60be3 100644 --- a/src/main/java/deepple/deepple/member/command/application/member/exception/PrimaryContactTypeSettingNeededException.java +++ b/src/main/java/deepple/deepple/member/command/application/member/exception/ContactTypeSettingNeededException.java @@ -1,7 +1,7 @@ package deepple.deepple.member.command.application.member.exception; -public class PrimaryContactTypeSettingNeededException extends RuntimeException { - public PrimaryContactTypeSettingNeededException() { +public class ContactTypeSettingNeededException extends RuntimeException { + public ContactTypeSettingNeededException() { super("연락 타입 설정이 필요합니다."); } } diff --git a/src/main/java/deepple/deepple/member/command/infra/member/MemberProfileEventHandler.java b/src/main/java/deepple/deepple/member/command/infra/member/MemberProfileEventHandler.java index 64c292ed6..07c98c0b5 100644 --- a/src/main/java/deepple/deepple/member/command/infra/member/MemberProfileEventHandler.java +++ b/src/main/java/deepple/deepple/member/command/infra/member/MemberProfileEventHandler.java @@ -15,11 +15,11 @@ public class MemberProfileEventHandler { @EventListener(value = MatchRequestedEvent.class) public void handle(MatchRequestedEvent event) { - memberProfileService.validatePrimaryContactTypeSetting(event.getRequesterId()); + memberProfileService.validateContactTypeSetting(event.getRequesterId(), event.getContactType()); } @EventListener(value = MatchRespondedEvent.class) public void handle(MatchRespondedEvent event) { - memberProfileService.validatePrimaryContactTypeSetting(event.getResponderId()); + memberProfileService.validateContactTypeSetting(event.getResponderId(), event.getContactType()); } } diff --git a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java b/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java index 058a5829f..e0433ec78 100644 --- a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java +++ b/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java @@ -14,40 +14,30 @@ public class BizgoMessanger { private final RestClient restClient; - private final BizgoTokenHandler bizgoTokenHandler; - @Value("${bizgo.from-phone-number}") private String fromPhoneNumber; + @Value("${bizgo.api-url}") private String apiUrl; - public void sendMessage(String message, String phoneNumber) { - trySendMessageWithRetry(message, phoneNumber); - } + @Value("${bizgo.api-key}") + private String apiKey; - private void trySendMessageWithRetry(String message, String phoneNumber) { - String authToken = bizgoTokenHandler.getAuthToken(); - - try { - sendRequest(message, phoneNumber, authToken); - } catch (BizgoMessageSendException e) { - if (e.getStatusCode() == ResponseCode.EXPIRED_TOKEN.getCode()) { - authToken = bizgoTokenHandler.getAuthToken(); - sendRequest(message, phoneNumber, authToken); - } else { - throw e; - } - } + public void sendMessage(String message, String phoneNumber) { + /** + * TODO : 타임아웃 에러에 대해서, Fallback 으로 재시도하는 로직 추가. + */ + sendRequest(message, phoneNumber); } - private void sendRequest(String message, String phoneNumber, String authToken) { - String requestURL = apiUrl + "/send/sms"; + private void sendRequest(String message, String phoneNumber) { + String requestURL = apiUrl + "/api/comm/v1/send/omni"; restClient.post() .uri(requestURL) .header("Content-Type", "application/json") .header("Accept", "application/json") - .header("Authorization", "Bearer " + authToken) + .header("Authorization", apiKey) .body(new BizgoMessageRequest(fromPhoneNumber, phoneNumber, message)) .retrieve() .onStatus(HttpStatusCode::isError, (request, httpResponse) -> { diff --git a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoTokenHandler.java b/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoTokenHandler.java deleted file mode 100644 index ce2695eb0..000000000 --- a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoTokenHandler.java +++ /dev/null @@ -1,77 +0,0 @@ -package deepple.deepple.member.command.infra.member.sms; - -import deepple.deepple.common.repository.RedissonLockRepository; -import deepple.deepple.member.command.infra.member.sms.dto.BizgoAuthResponse; -import deepple.deepple.member.command.infra.member.sms.exception.BizgoAuthenticationException; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.HttpStatusCode; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClient; - -import java.util.concurrent.TimeUnit; - - -@Service -@RequiredArgsConstructor -public class BizgoTokenHandler { - private static final long EXPIRE_TIME_SECOND = 23 * 60 * 60; - private static final String KEY = "BIZGO_AUTH_TOKEN"; - private static final int WAIT_TIME = 3; - private static final int LEASE_TIME = 5; - private final RedisTemplate redisTemplate; - private final RedissonLockRepository redissonLockRepository; - private final RestClient restClient; - @Value("${bizgo.client-id}") - private String clientId; - @Value("${bizgo.client-password}") - private String clientPassword; - @Value("${bizgo.api-url}") - private String apiUrl; - - public String getAuthToken() { - String token = redisTemplate.opsForValue().get(KEY); - if (token == null) { - redissonLockRepository.withLock(() -> { - if (redisTemplate.opsForValue().get(KEY) != null) { - return; - } - setAuthToken(); - }, KEY, WAIT_TIME, LEASE_TIME); - - return redisTemplate.opsForValue().get(KEY); - } else { - return token; - } - } - - private void setAuthToken() { - String requestURL = apiUrl + "/auth/token"; - - BizgoAuthResponse response = restClient.post() - .uri(requestURL) - .header("Accept", "application/json") - .header("X-IB-Client-Id", clientId) - .header("X-IB-Client-Passwd", clientPassword) - .retrieve() - .onStatus(HttpStatusCode::isError, (request, httpResponse) -> { - throw new BizgoAuthenticationException(); - } - ) - .toEntity(BizgoAuthResponse.class) - .getBody(); - - validateAuthResponse(response); - - String authToken = response.data().token(); - redisTemplate.opsForValue().set(KEY, authToken); - redisTemplate.expire(KEY, EXPIRE_TIME_SECOND, TimeUnit.SECONDS); - } - - private void validateAuthResponse(BizgoAuthResponse response) { - if (response == null || response.code() == null || response.data() == null || response.data().token() == null) { - throw new BizgoAuthenticationException(); - } - } -} diff --git a/src/main/java/deepple/deepple/member/presentation/member/MemberExceptionHandler.java b/src/main/java/deepple/deepple/member/presentation/member/MemberExceptionHandler.java index 889bd65be..f9514c6c4 100644 --- a/src/main/java/deepple/deepple/member/presentation/member/MemberExceptionHandler.java +++ b/src/main/java/deepple/deepple/member/presentation/member/MemberExceptionHandler.java @@ -77,9 +77,9 @@ public ResponseEntity> handleKakaoIdAlreadyExistsException(Ka .body(BaseResponse.of(StatusType.BAD_REQUEST, e.getMessage())); } - @ExceptionHandler(PrimaryContactTypeSettingNeededException.class) + @ExceptionHandler(ContactTypeSettingNeededException.class) public ResponseEntity> handlePrimaryContactTypeSettingNeededException( - PrimaryContactTypeSettingNeededException e) { + ContactTypeSettingNeededException e) { log.warn("매치 요청/응답에 실패하였습니다. {}", e.getMessage()); return ResponseEntity.badRequest() diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 395144bb2..7e5fddd04 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -89,6 +89,7 @@ payment: bizgo: api-url: ${BIZGO_API_URL:api-url} + api-key: ${BIZGO_API_KEY:api-key} client-id: ${BIZGO_CLIENT_ID:client-id} client-password: ${BIZGO_CLIENT_PASSWORD:client_password} from-phone-number: ${BIZGO_FROM_PHONE_NUMBER:from-phone-number} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c22fb639e..dc2298884 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -85,6 +85,7 @@ payment: bizgo: api-url: ${BIZGO_API_URL:api-url} + api-key: ${BIZGO_API_KEY:api-key} client-id: ${BIZGO_CLIENT_ID:client-id} client-password: ${BIZGO_CLIENT_PASSWORD:client_password} from-phone-number: ${BIZGO_FROM_PHONE_NUMBER:from-phone-number} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index cf6b53e80..9fa787a6e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -93,6 +93,7 @@ payment: bizgo: api-url: ${BIZGO_API_URL:api-url} + api-key: ${BIZGO_API_KEY:api-key} client-id: ${BIZGO_CLIENT_ID:client-id} client-password: ${BIZGO_CLIENT_PASSWORD:client_password} from-phone-number: ${BIZGO_FROM_PHONE_NUMBER:from-phone-number} diff --git a/src/test/java/deepple/deepple/member/command/application/member/MemberProfileServiceTest.java b/src/test/java/deepple/deepple/member/command/application/member/MemberProfileServiceTest.java index 9a064524d..b4196badb 100644 --- a/src/test/java/deepple/deepple/member/command/application/member/MemberProfileServiceTest.java +++ b/src/test/java/deepple/deepple/member/command/application/member/MemberProfileServiceTest.java @@ -1,6 +1,7 @@ package deepple.deepple.member.command.application.member; import deepple.deepple.common.MockEventsExtension; +import deepple.deepple.member.command.application.member.exception.ContactTypeSettingNeededException; import deepple.deepple.member.command.application.member.exception.MemberNotFoundException; import deepple.deepple.member.command.domain.member.*; import deepple.deepple.member.command.domain.member.exception.InvalidMemberEnumValueException; @@ -11,15 +12,16 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Calendar; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; import static org.springframework.test.util.ReflectionTestUtils.setField; @@ -199,4 +201,56 @@ void callsChangeActivityStatusAndNonPublishProfileWhenActivityStatusIsNotActive( verify(member, times(1)).nonPublishProfile(); } } + + @Nested + @DisplayName("validateContactTypeSetting 메소드 테스트.") + class ValidateContactTypeSettingTest { + @Test + @DisplayName("멤버가 존재하지 않으면, 예외를 던집니다.") + void throwExceptionWhenMemberNotFound() { + // Given + final Long memberId = 1L; + final String contactType = "PHONE_NUMBER"; + when(memberCommandRepository.findById(memberId)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> memberProfileService.validateContactTypeSetting(memberId, contactType)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + @DisplayName("ContactType이 Null 일 경우, 유효성을 검증하지 않는다.") + void shouldNotValidateWhenContactTypeIsNull() { + // Given + final Long memberId = 1L; + final String contactType = null; + Member member = Member.builder().build(); + when(memberCommandRepository.findById(memberId)).thenReturn(Optional.of(member)); + + // When + memberProfileService.validateContactTypeSetting(memberId, contactType); + + // Then + assertThatNoException(); + } + + @Test + @DisplayName("ContactType 이 PHONE_NUMBER or KAKAO 일 경우, 유효성을 검증한다.") + void shouldValidateWhenContactTypeIsInKakaoOrPhonNumber() { + // Given + final Long memberId = 1L; + final List contactTypes = List.of("PHONE_NUMBER","KAKAO"); + Member member = Mockito.mock(Member.class); + when(memberCommandRepository.findById(memberId)).thenReturn(Optional.of(member)); + when(member.getPhoneNumber()).thenReturn(null); + when(member.getKakaoId()).thenReturn(null); + + // When & Then + for (String contactType : contactTypes) { + assertThatThrownBy(() -> memberProfileService.validateContactTypeSetting(memberId, contactType)) + .isInstanceOf(ContactTypeSettingNeededException.class); + } + + } + } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index cadb3b9d7..f3a89d02b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -69,6 +69,7 @@ payment: bizgo: api-url: api-url + api-key: api-key client-id: client-id client-password: client_password from-phone-number: from-phone-number From 9d4021d3915707a2d12471a30cf6f82c97fd9650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B3=B5=ED=83=9C=ED=98=84?= Date: Sun, 11 Jan 2026 17:58:20 +0900 Subject: [PATCH 16/22] [Refac] Change Requestbody for Bizgo (#402) * [Refac] : When Match Request or Response, Don't Check Primary Contatct Type * [fix] : Change BaseURL * [Fix] : Using Enum * [Refac] Change BizgoRequestBody --- .../infra/member/sms/BizgoMessanger.java | 2 +- .../member/sms/dto/BizgoMessageRequest.java | 41 ++++++++++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java b/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java index e0433ec78..fcb06d064 100644 --- a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java +++ b/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java @@ -38,7 +38,7 @@ private void sendRequest(String message, String phoneNumber) { .header("Content-Type", "application/json") .header("Accept", "application/json") .header("Authorization", apiKey) - .body(new BizgoMessageRequest(fromPhoneNumber, phoneNumber, message)) + .body(new BizgoMessageRequest(message, fromPhoneNumber, phoneNumber)) .retrieve() .onStatus(HttpStatusCode::isError, (request, httpResponse) -> { throw new BizgoMessageSendException(httpResponse.getStatusCode().value()); diff --git a/src/main/java/deepple/deepple/member/command/infra/member/sms/dto/BizgoMessageRequest.java b/src/main/java/deepple/deepple/member/command/infra/member/sms/dto/BizgoMessageRequest.java index 11fc0dae4..d14d4d47e 100644 --- a/src/main/java/deepple/deepple/member/command/infra/member/sms/dto/BizgoMessageRequest.java +++ b/src/main/java/deepple/deepple/member/command/infra/member/sms/dto/BizgoMessageRequest.java @@ -1,8 +1,37 @@ package deepple.deepple.member.command.infra.member.sms.dto; -public record BizgoMessageRequest( - String from, - String to, - String text -) { -} +import lombok.AllArgsConstructor; + +import java.util.List; + +public class BizgoMessageRequest { + List messageFlow; + List destinations; + String ref; + + @AllArgsConstructor + public class MessageFlow { + Sms sms; + } + + @AllArgsConstructor + public class Sms { + String from; + String text; + } + + @AllArgsConstructor + public class Destination { + String to; + } + + public BizgoMessageRequest(String text, String from, String to) { + Destination destination = new Destination(to); + Sms sms = new Sms(from, text); + MessageFlow messageFlow = new MessageFlow(sms); + + this.messageFlow = List.of(messageFlow); + this.destinations = List.of(destination); + this.ref = null; + } +} \ No newline at end of file From b0ada0a4985f553433a094a57c8b5117488f44a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B3=B5=ED=83=9C=ED=98=84?= Date: Sun, 11 Jan 2026 21:18:01 +0900 Subject: [PATCH 17/22] [Fix] Bizgo & Match (#403) * [Refac] : When Match Request or Response, Don't Check Primary Contatct Type * [fix] : Change BaseURL * [Fix] : Using Enum * [Refac] Change BizgoRequestBody * [Fix] : Use Record for serialization --- .../member/sms/dto/BizgoMessageRequest.java | 41 ++++++------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/src/main/java/deepple/deepple/member/command/infra/member/sms/dto/BizgoMessageRequest.java b/src/main/java/deepple/deepple/member/command/infra/member/sms/dto/BizgoMessageRequest.java index d14d4d47e..32cceb47b 100644 --- a/src/main/java/deepple/deepple/member/command/infra/member/sms/dto/BizgoMessageRequest.java +++ b/src/main/java/deepple/deepple/member/command/infra/member/sms/dto/BizgoMessageRequest.java @@ -1,37 +1,22 @@ package deepple.deepple.member.command.infra.member.sms.dto; -import lombok.AllArgsConstructor; - import java.util.List; -public class BizgoMessageRequest { - List messageFlow; - List destinations; - String ref; - - @AllArgsConstructor - public class MessageFlow { - Sms sms; - } +public record BizgoMessageRequest( + List messageFlow, + List destinations, + String ref +) { - @AllArgsConstructor - public class Sms { - String from; - String text; - } - - @AllArgsConstructor - public class Destination { - String to; - } + public record MessageFlow(Sms sms) {} + public record Sms(String from, String text) {} + public record Destination(String to) {} public BizgoMessageRequest(String text, String from, String to) { - Destination destination = new Destination(to); - Sms sms = new Sms(from, text); - MessageFlow messageFlow = new MessageFlow(sms); - - this.messageFlow = List.of(messageFlow); - this.destinations = List.of(destination); - this.ref = null; + this( + List.of(new MessageFlow(new Sms(from, text))), + List.of(new Destination(to)), + null + ); } } \ No newline at end of file From a5e6f8f8ba568f5e08744136f03a0a100d4dad83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B3=B5=ED=83=9C=ED=98=84?= Date: Sun, 11 Jan 2026 21:45:52 +0900 Subject: [PATCH 18/22] [Refac] : Add Logging for Bizgo (#404) * [Refac] : When Match Request or Response, Don't Check Primary Contatct Type * [fix] : Change BaseURL * [Fix] : Using Enum * [Refac] Change BizgoRequestBody * [Fix] : Use Record for serialization * [Refac] : Add Logging for checking. --- .../command/infra/member/sms/BizgoMessanger.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java b/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java index fcb06d064..b6138f0e6 100644 --- a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java +++ b/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java @@ -3,14 +3,17 @@ import deepple.deepple.member.command.infra.member.sms.dto.BizgoMessageRequest; import deepple.deepple.member.command.infra.member.sms.exception.BizgoMessageSendException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; @Service @RequiredArgsConstructor +@Slf4j public class BizgoMessanger { private final RestClient restClient; @@ -33,7 +36,7 @@ public void sendMessage(String message, String phoneNumber) { private void sendRequest(String message, String phoneNumber) { String requestURL = apiUrl + "/api/comm/v1/send/omni"; - restClient.post() + ResponseEntity response = restClient.post() .uri(requestURL) .header("Content-Type", "application/json") .header("Accept", "application/json") @@ -43,6 +46,10 @@ private void sendRequest(String message, String phoneNumber) { .onStatus(HttpStatusCode::isError, (request, httpResponse) -> { throw new BizgoMessageSendException(httpResponse.getStatusCode().value()); } - ); + ).toEntity(String.class); + + log.info("status = {}", response.getStatusCode()); + log.info("headers = {}", response.getHeaders()); + log.info("body = {}", response.getBody()); } } From 35b4539cbd366191693d4f14f51e505c21ca83f5 Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Tue, 13 Jan 2026 18:12:42 +0900 Subject: [PATCH 19/22] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 접근 레이어 분리 (Reader/Writer) 및 저장 로직 개선 - FCM 예외 처리 로깅 추가 - 알림 템플릿 정보 전송 방식 변경 - k6 부하 테스트 시 최대 VU 증가 - notification_preferences 테이블에 인덱스 추가 - 테스트 코드 리팩토링 및 명확한 실패 상태 로깅 적용 #394 --- k6/scripts/notifications.js | 2 +- .../application/NotificationDataReader.java | 35 ++++++ .../application/NotificationDataWriter.java | 40 ++++++ .../application/NotificationSendService.java | 63 ++-------- .../application/NotificationTemplateInfo.java | 50 ++++++++ .../domain/NotificationPreference.java | 5 +- .../infra/FcmNotificationSender.java | 14 +++ .../presentation/NotificationController.java | 7 +- ..._add_index_to_notification_preferences.sql | 2 + .../NotificationSendServiceTest.java | 118 +++++++++--------- 10 files changed, 220 insertions(+), 116 deletions(-) create mode 100644 src/main/java/deepple/deepple/notification/command/application/NotificationDataReader.java create mode 100644 src/main/java/deepple/deepple/notification/command/application/NotificationDataWriter.java create mode 100644 src/main/java/deepple/deepple/notification/command/application/NotificationTemplateInfo.java create mode 100644 src/main/resources/db/migration/V6__add_index_to_notification_preferences.sql diff --git a/k6/scripts/notifications.js b/k6/scripts/notifications.js index dbeb43a2b..af5f44d6a 100644 --- a/k6/scripts/notifications.js +++ b/k6/scripts/notifications.js @@ -3,7 +3,7 @@ import {check, sleep} from 'k6'; import {config} from './lib/config.js'; import {authHeaders, login} from './lib/auth.js'; -const MAX_VUS = 100; +const MAX_VUS = 500; const MEMBER_COUNT = 1000000; export const options = { diff --git a/src/main/java/deepple/deepple/notification/command/application/NotificationDataReader.java b/src/main/java/deepple/deepple/notification/command/application/NotificationDataReader.java new file mode 100644 index 000000000..1c51b9fbe --- /dev/null +++ b/src/main/java/deepple/deepple/notification/command/application/NotificationDataReader.java @@ -0,0 +1,35 @@ +package deepple.deepple.notification.command.application; + +import deepple.deepple.notification.command.domain.*; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class NotificationDataReader { + + private final NotificationTemplateCommandRepository notificationTemplateRepository; + private final NotificationPreferenceCommandRepository notificationPreferenceRepository; + private final DeviceRegistrationCommandRepository deviceRegistrationCommandRepository; + + @Cacheable(value = "notificationTemplate", key = "#type") + @Transactional(readOnly = true) + public Optional findTemplate(NotificationType type) { + return notificationTemplateRepository.findByType(type) + .map(NotificationTemplateInfo::from); + } + + @Transactional(readOnly = true) + public Optional findPreference(Long memberId) { + return notificationPreferenceRepository.findByMemberId(memberId); + } + + @Transactional(readOnly = true) + public Optional findActiveDevice(Long memberId) { + return deviceRegistrationCommandRepository.findByMemberIdAndIsActiveTrue(memberId); + } +} \ No newline at end of file diff --git a/src/main/java/deepple/deepple/notification/command/application/NotificationDataWriter.java b/src/main/java/deepple/deepple/notification/command/application/NotificationDataWriter.java new file mode 100644 index 000000000..74704bb3f --- /dev/null +++ b/src/main/java/deepple/deepple/notification/command/application/NotificationDataWriter.java @@ -0,0 +1,40 @@ +package deepple.deepple.notification.command.application; + +import deepple.deepple.notification.command.domain.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class NotificationDataWriter { + + private final NotificationCommandRepository notificationCommandRepository; + + @Transactional + public void save(Notification notification) { + notificationCommandRepository.save(notification); + } + + @Transactional + public void saveFailedNotification( + SenderType senderType, + Long senderId, + Long receiverId, + NotificationType type, + String title, + String body, + NotificationStatus status + ) { + var failedNotification = Notification.createFailed( + senderType, + senderId, + receiverId, + type, + title, + body, + status + ); + notificationCommandRepository.save(failedNotification); + } +} \ No newline at end of file diff --git a/src/main/java/deepple/deepple/notification/command/application/NotificationSendService.java b/src/main/java/deepple/deepple/notification/command/application/NotificationSendService.java index a199085af..750b69f7b 100644 --- a/src/main/java/deepple/deepple/notification/command/application/NotificationSendService.java +++ b/src/main/java/deepple/deepple/notification/command/application/NotificationSendService.java @@ -1,10 +1,12 @@ package deepple.deepple.notification.command.application; -import deepple.deepple.notification.command.domain.*; +import deepple.deepple.notification.command.domain.DeviceRegistration; +import deepple.deepple.notification.command.domain.Notification; +import deepple.deepple.notification.command.domain.NotificationSender; +import deepple.deepple.notification.command.domain.NotificationStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import static deepple.deepple.notification.command.domain.NotificationStatus.*; @@ -13,13 +15,10 @@ @RequiredArgsConstructor public class NotificationSendService { - private final NotificationCommandRepository notificationCommandRepository; - private final NotificationPreferenceCommandRepository notificationPreferenceRepository; - private final NotificationTemplateCommandRepository notificationTemplateRepository; - private final DeviceRegistrationCommandRepository deviceRegistrationCommandRepository; + private final NotificationDataReader reader; + private final NotificationDataWriter writer; private final NotificationSenderResolver notificationSenderResolver; - @Transactional public void send(NotificationSendRequest request) { // 1. 알림 생성 var notification = createNotificationWithTemplate(request); @@ -42,40 +41,8 @@ public void send(NotificationSendRequest request) { sendNotification(notification, device, request); } - // TODO: 삭제 필요(k6 부하 테스트 용도 - FCM 전송 제외) - @Transactional - public void sendWithoutPush(NotificationSendRequest request) { - // 1. 알림 생성 - var notification = createNotificationWithTemplate(request); - if (notification == null) { - return; - } - - // 2. 수신자 설정 확인 - if (!canSendByPreference(notification)) { - return; - } - - // 3. 수신자 기기 조회 - var device = findReceiversActiveDevice(notification); - if (device == null) { - return; - } - - // 4. FCM 전송 시간 시뮬레이션 (50ms) - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - - // 5. 저장 - notification.markAsSent(); - save(notification); - } - private Notification createNotificationWithTemplate(NotificationSendRequest request) { - return notificationTemplateRepository.findByType(request.notificationType()) + return reader.findTemplate(request.notificationType()) .map(template -> Notification.create( request.senderType(), request.senderId(), @@ -92,7 +59,7 @@ private Notification createNotificationWithTemplate(NotificationSendRequest requ } private boolean canSendByPreference(Notification notification) { - return notificationPreferenceRepository.findByMemberId(notification.getReceiverId()) + return reader.findPreference(notification.getReceiverId()) .map(pref -> { boolean canSend = pref.canReceive(notification.getType()); if (!canSend) { @@ -108,7 +75,7 @@ private boolean canSendByPreference(Notification notification) { } private DeviceRegistration findReceiversActiveDevice(Notification notification) { - return deviceRegistrationCommandRepository.findByMemberIdAndIsActiveTrue(notification.getReceiverId()) + return reader.findActiveDevice(notification.getReceiverId()) .orElseGet(() -> { log.warn("[디바이스 정보 조회 실패] receiverId={}", notification.getReceiverId()); saveFailedNotification(notification, FAILED_DEVICE_NOT_FOUND); @@ -132,7 +99,7 @@ private void dispatch(NotificationSender sender, Notification notification, Devi try { sender.send(notification, deviceRegistration); notification.markAsSent(); - save(notification); + writer.save(notification); } catch (Exception e) { log.warn("[알림 전송 실패] receiverId={}, type={}", notification.getReceiverId(), notification.getType(), e); saveFailedNotification(notification, FAILED_EXCEPTION); @@ -145,7 +112,7 @@ private void handleUnsupportedChannel(Notification notification, NotificationSen } private void saveFailedNotification(NotificationSendRequest request, NotificationStatus status) { - var failedNotification = Notification.createFailed( + writer.saveFailedNotification( request.senderType(), request.senderId(), request.receiverId(), @@ -154,11 +121,10 @@ private void saveFailedNotification(NotificationSendRequest request, Notificatio "알림 전송에 실패했습니다.", status ); - save(failedNotification); } private void saveFailedNotification(Notification notification, NotificationStatus status) { - var failedNotification = Notification.createFailed( + writer.saveFailedNotification( notification.getSenderType(), notification.getSenderId(), notification.getReceiverId(), @@ -167,10 +133,5 @@ private void saveFailedNotification(Notification notification, NotificationStatu notification.getBody(), status ); - save(failedNotification); - } - - private void save(Notification notification) { - notificationCommandRepository.save(notification); } } diff --git a/src/main/java/deepple/deepple/notification/command/application/NotificationTemplateInfo.java b/src/main/java/deepple/deepple/notification/command/application/NotificationTemplateInfo.java new file mode 100644 index 000000000..9b21ed1ac --- /dev/null +++ b/src/main/java/deepple/deepple/notification/command/application/NotificationTemplateInfo.java @@ -0,0 +1,50 @@ +package deepple.deepple.notification.command.application; + +import deepple.deepple.notification.command.domain.NotificationTemplate; +import deepple.deepple.notification.command.domain.NotificationType; + +import java.io.Serializable; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public record NotificationTemplateInfo( + Long id, + NotificationType type, + String titleTemplate, + String bodyTemplate +) implements Serializable { + + private static final Pattern TEMPLATE_PARAM_PATTERN = Pattern.compile("\\{(\\w+)}"); + + public static NotificationTemplateInfo from(NotificationTemplate entity) { + return new NotificationTemplateInfo( + entity.getId(), + entity.getType(), + entity.getTitleTemplate(), + entity.getBodyTemplate() + ); + } + + public String generateTitle(Map params) { + return applyTemplate(titleTemplate, params); + } + + public String generateBody(Map params) { + return applyTemplate(bodyTemplate, params); + } + + private String applyTemplate(String template, Map params) { + StringBuilder result = new StringBuilder(); + Matcher matcher = TEMPLATE_PARAM_PATTERN.matcher(template); + + while (matcher.find()) { + String key = matcher.group(1); + String replacement = params.getOrDefault(key, "{error}"); + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } + + matcher.appendTail(result); + return result.toString(); + } +} diff --git a/src/main/java/deepple/deepple/notification/command/domain/NotificationPreference.java b/src/main/java/deepple/deepple/notification/command/domain/NotificationPreference.java index 02608992e..325ba575b 100644 --- a/src/main/java/deepple/deepple/notification/command/domain/NotificationPreference.java +++ b/src/main/java/deepple/deepple/notification/command/domain/NotificationPreference.java @@ -12,7 +12,10 @@ import static jakarta.persistence.EnumType.STRING; @Entity -@Table(name = "notification_preferences") +@Table( + name = "notification_preferences", + indexes = {@Index(name = "idx_notification_preferences_member_id", columnList = "memberId")} +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class NotificationPreference extends SoftDeleteBaseEntity { diff --git a/src/main/java/deepple/deepple/notification/infra/FcmNotificationSender.java b/src/main/java/deepple/deepple/notification/infra/FcmNotificationSender.java index 272d6ca37..6f9c02e01 100644 --- a/src/main/java/deepple/deepple/notification/infra/FcmNotificationSender.java +++ b/src/main/java/deepple/deepple/notification/infra/FcmNotificationSender.java @@ -1,6 +1,7 @@ package deepple.deepple.notification.infra; import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; import deepple.deepple.notification.command.application.NotificationSendFailedException; import deepple.deepple.notification.command.domain.ChannelType; @@ -53,6 +54,19 @@ public void send(Notification notification, DeviceRegistration deviceRegistratio @SuppressWarnings("unused") private void sendFallback(Notification notification, DeviceRegistration deviceRegistration, Exception exception) { + if (exception instanceof FirebaseMessagingException fme) { + log.warn("[FCM 전송 실패] receiverId={}, errorCode={}, message={}", + notification.getReceiverId(), + fme.getMessagingErrorCode(), + fme.getMessage() + ); + } else { + log.warn("[FCM 전송 실패] receiverId={}, exceptionType={}, message={}", + notification.getReceiverId(), + exception.getClass().getSimpleName(), + exception.getMessage() + ); + } throw new NotificationSendFailedException(exception); } } diff --git a/src/main/java/deepple/deepple/notification/presentation/NotificationController.java b/src/main/java/deepple/deepple/notification/presentation/NotificationController.java index 5c4a3c4d8..144d4b737 100644 --- a/src/main/java/deepple/deepple/notification/presentation/NotificationController.java +++ b/src/main/java/deepple/deepple/notification/presentation/NotificationController.java @@ -30,7 +30,6 @@ public class NotificationController { private final NotificationDeleteService notificationDeleteService; private final NotificationQueryService notificationQueryService; - // TODO: 삭제 필요(테스트 용도) private final NotificationSendService notificationSendService; @Operation(summary = "알림 읽기") @@ -64,7 +63,6 @@ public ResponseEntity> delete( return ResponseEntity.ok(BaseResponse.from(OK)); } - // TODO: 삭제 필요(테스트 용도) @Operation(summary = "(테스트) 알림 전송") @PostMapping public ResponseEntity> send( @@ -74,8 +72,7 @@ public ResponseEntity> send( return ResponseEntity.ok(BaseResponse.from(OK)); } - // TODO: 삭제 필요(k6 부하 테스트 용도 - FCM 전송 제외, 50ms 대기) - @Operation(summary = "(테스트) 알림 전송 - k6 부하 테스트용 (FCM 전송 제외)") + @Operation(summary = "(테스트) 알림 전송 - k6 부하 테스트용") @PostMapping("/test") public ResponseEntity> sendTestNotification( @AuthPrincipal AuthContext authContext, @@ -92,7 +89,7 @@ public ResponseEntity> sendTestNotification( Map.of("senderName", "테스트", "rejectionReason", "테스트 사유"), ChannelType.PUSH ); - notificationSendService.sendWithoutPush(request); + notificationSendService.send(request); return ResponseEntity.ok(BaseResponse.from(OK)); } } diff --git a/src/main/resources/db/migration/V6__add_index_to_notification_preferences.sql b/src/main/resources/db/migration/V6__add_index_to_notification_preferences.sql new file mode 100644 index 000000000..293db67b0 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_index_to_notification_preferences.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_notification_preferences_member_id + ON notification_preferences (member_id); \ No newline at end of file diff --git a/src/test/java/deepple/deepple/notification/command/application/NotificationSendServiceTest.java b/src/test/java/deepple/deepple/notification/command/application/NotificationSendServiceTest.java index 3d4325d58..88be902fa 100644 --- a/src/test/java/deepple/deepple/notification/command/application/NotificationSendServiceTest.java +++ b/src/test/java/deepple/deepple/notification/command/application/NotificationSendServiceTest.java @@ -1,6 +1,9 @@ package deepple.deepple.notification.command.application; -import deepple.deepple.notification.command.domain.*; +import deepple.deepple.notification.command.domain.DeviceRegistration; +import deepple.deepple.notification.command.domain.Notification; +import deepple.deepple.notification.command.domain.NotificationPreference; +import deepple.deepple.notification.command.domain.NotificationSender; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -15,23 +18,18 @@ import static deepple.deepple.notification.command.domain.NotificationStatus.*; import static deepple.deepple.notification.command.domain.NotificationType.LIKE; import static deepple.deepple.notification.command.domain.SenderType.SYSTEM; -import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class NotificationSendServiceTest { @Mock - NotificationCommandRepository notificationCommandRepository; + NotificationDataReader reader; @Mock - NotificationPreferenceCommandRepository notificationPreferenceCommandRepository; - - @Mock - NotificationTemplateCommandRepository notificationTemplateCommandRepository; - - @Mock - DeviceRegistrationCommandRepository deviceRegistrationCommandRepository; + NotificationDataWriter writer; @Mock NotificationSenderResolver notificationSenderResolver; @@ -44,15 +42,16 @@ class NotificationSendServiceTest { void sendSavesFailedTemplate() { // given var req = new NotificationSendRequest(SYSTEM, 1L, 2L, LIKE, Map.of(), PUSH); - when(notificationTemplateCommandRepository.findByType(LIKE)) - .thenReturn(Optional.empty()); + when(reader.findTemplate(LIKE)).thenReturn(Optional.empty()); // when service.send(req); // then - verify(notificationCommandRepository) - .save(argThat(n -> n.getStatus() == FAILED_TEMPLATE_NOT_FOUND)); + verify(writer).saveFailedNotification( + eq(SYSTEM), eq(1L), eq(2L), eq(LIKE), + any(), any(), eq(FAILED_TEMPLATE_NOT_FOUND) + ); } @Test @@ -60,17 +59,18 @@ void sendSavesFailedTemplate() { void sendSavesFailedPreference() { // given var req = new NotificationSendRequest(SYSTEM, 1L, 99L, LIKE, Map.of(), PUSH); - when(notificationTemplateCommandRepository.findByType(LIKE)) - .thenReturn(Optional.of(NotificationTemplate.of(LIKE, "", ""))); - when(notificationPreferenceCommandRepository.findByMemberId(99L)) - .thenReturn(Optional.empty()); + when(reader.findTemplate(LIKE)) + .thenReturn(Optional.of(new NotificationTemplateInfo(1L, LIKE, "", ""))); + when(reader.findPreference(99L)).thenReturn(Optional.empty()); // when service.send(req); // then - verify(notificationCommandRepository) - .save(argThat(n -> n.getStatus() == FAILED_PREFERENCE_NOT_FOUND)); + verify(writer).saveFailedNotification( + eq(SYSTEM), eq(1L), eq(99L), eq(LIKE), + any(), any(), eq(FAILED_PREFERENCE_NOT_FOUND) + ); } @Test @@ -78,20 +78,21 @@ void sendSavesFailedPreference() { void sendSavesRejectedNotification() { // given var req = new NotificationSendRequest(SYSTEM, 1L, 1L, LIKE, Map.of(), PUSH); - when(notificationTemplateCommandRepository.findByType(LIKE)) - .thenReturn(Optional.of(NotificationTemplate.of(LIKE, "", ""))); + when(reader.findTemplate(LIKE)) + .thenReturn(Optional.of(new NotificationTemplateInfo(1L, LIKE, "", ""))); var pref = NotificationPreference.of(1L); pref.disableGlobally(); - when(notificationPreferenceCommandRepository.findByMemberId(1L)) - .thenReturn(Optional.of(pref)); + when(reader.findPreference(1L)).thenReturn(Optional.of(pref)); // when service.send(req); // then - verify(notificationCommandRepository) - .save(argThat(n -> n.getStatus() == REJECTED_BY_PREFERENCE)); + verify(writer).saveFailedNotification( + eq(SYSTEM), eq(1L), eq(1L), eq(LIKE), + any(), any(), eq(REJECTED_BY_PREFERENCE) + ); } @Test @@ -99,24 +100,22 @@ void sendSavesRejectedNotification() { void sendSavesSentNotification() throws Exception { // given var req = new NotificationSendRequest(SYSTEM, 10L, 20L, LIKE, Map.of(), PUSH); - when(notificationTemplateCommandRepository.findByType(LIKE)) - .thenReturn(Optional.of(NotificationTemplate.of(LIKE, "t", "b"))); - when(notificationPreferenceCommandRepository.findByMemberId(20L)) + when(reader.findTemplate(LIKE)) + .thenReturn(Optional.of(new NotificationTemplateInfo(1L, LIKE, "t", "b"))); + when(reader.findPreference(20L)) .thenReturn(Optional.of(NotificationPreference.of(20L))); - when(deviceRegistrationCommandRepository.findByMemberIdAndIsActiveTrue(20L)) + when(reader.findActiveDevice(20L)) .thenReturn(Optional.of(DeviceRegistration.of(20L, "device", "token"))); var sender = mock(NotificationSender.class); - when(notificationSenderResolver.resolve(PUSH)) - .thenReturn(Optional.of(sender)); + when(notificationSenderResolver.resolve(PUSH)).thenReturn(Optional.of(sender)); // when service.send(req); // then verify(sender).send(any(Notification.class), any(DeviceRegistration.class)); - verify(notificationCommandRepository) - .save(argThat(n -> n.getStatus() == SENT)); + verify(writer).save(argThat(n -> n.getStatus() == SENT)); } @Test @@ -124,19 +123,20 @@ void sendSavesSentNotification() throws Exception { void sendSavesFailedDevice() { // given var req = new NotificationSendRequest(SYSTEM, 1L, 30L, LIKE, Map.of(), PUSH); - when(notificationTemplateCommandRepository.findByType(LIKE)) - .thenReturn(Optional.of(NotificationTemplate.of(LIKE, "", ""))); - when(notificationPreferenceCommandRepository.findByMemberId(30L)) + when(reader.findTemplate(LIKE)) + .thenReturn(Optional.of(new NotificationTemplateInfo(1L, LIKE, "", ""))); + when(reader.findPreference(30L)) .thenReturn(Optional.of(NotificationPreference.of(30L))); - when(deviceRegistrationCommandRepository.findByMemberIdAndIsActiveTrue(30L)) - .thenReturn(Optional.empty()); + when(reader.findActiveDevice(30L)).thenReturn(Optional.empty()); // when service.send(req); // then - verify(notificationCommandRepository) - .save(argThat(n -> n.getStatus() == FAILED_DEVICE_NOT_FOUND)); + verify(writer).saveFailedNotification( + eq(SYSTEM), eq(1L), eq(30L), eq(LIKE), + any(), any(), eq(FAILED_DEVICE_NOT_FOUND) + ); } @Test @@ -144,21 +144,22 @@ void sendSavesFailedDevice() { void sendSavesUnsupportedChannel() { // given var req = new NotificationSendRequest(SYSTEM, 10L, 20L, LIKE, Map.of(), PUSH); - when(notificationTemplateCommandRepository.findByType(LIKE)) - .thenReturn(Optional.of(NotificationTemplate.of(LIKE, "t", "b"))); - when(notificationPreferenceCommandRepository.findByMemberId(20L)) + when(reader.findTemplate(LIKE)) + .thenReturn(Optional.of(new NotificationTemplateInfo(1L, LIKE, "t", "b"))); + when(reader.findPreference(20L)) .thenReturn(Optional.of(NotificationPreference.of(20L))); - when(deviceRegistrationCommandRepository.findByMemberIdAndIsActiveTrue(20L)) + when(reader.findActiveDevice(20L)) .thenReturn(Optional.of(DeviceRegistration.of(20L, "device", "token"))); - when(notificationSenderResolver.resolve(PUSH)) - .thenReturn(Optional.empty()); + when(notificationSenderResolver.resolve(PUSH)).thenReturn(Optional.empty()); // when service.send(req); // then - verify(notificationCommandRepository) - .save(argThat(n -> n.getStatus() == FAILED_UNSUPPORTED_CHANNEL)); + verify(writer).saveFailedNotification( + eq(SYSTEM), eq(10L), eq(20L), eq(LIKE), + any(), any(), eq(FAILED_UNSUPPORTED_CHANNEL) + ); } @Test @@ -166,16 +167,15 @@ void sendSavesUnsupportedChannel() { void sendSavesFailedException() throws Exception { // given var req = new NotificationSendRequest(SYSTEM, 10L, 20L, LIKE, Map.of(), PUSH); - when(notificationTemplateCommandRepository.findByType(LIKE)) - .thenReturn(Optional.of(NotificationTemplate.of(LIKE, "t", "b"))); - when(notificationPreferenceCommandRepository.findByMemberId(20L)) + when(reader.findTemplate(LIKE)) + .thenReturn(Optional.of(new NotificationTemplateInfo(1L, LIKE, "t", "b"))); + when(reader.findPreference(20L)) .thenReturn(Optional.of(NotificationPreference.of(20L))); - when(deviceRegistrationCommandRepository.findByMemberIdAndIsActiveTrue(20L)) + when(reader.findActiveDevice(20L)) .thenReturn(Optional.of(DeviceRegistration.of(20L, "device", "token"))); var sender = mock(NotificationSender.class); - when(notificationSenderResolver.resolve(PUSH)) - .thenReturn(Optional.of(sender)); + when(notificationSenderResolver.resolve(PUSH)).thenReturn(Optional.of(sender)); doThrow(new NotificationSendFailedException(new RuntimeException("FCM 전송 실패"))) .when(sender).send(any(Notification.class), any(DeviceRegistration.class)); @@ -183,7 +183,9 @@ void sendSavesFailedException() throws Exception { service.send(req); // then - verify(notificationCommandRepository) - .save(argThat(n -> n.getStatus() == FAILED_EXCEPTION)); + verify(writer).saveFailedNotification( + eq(SYSTEM), eq(10L), eq(20L), eq(LIKE), + any(), any(), eq(FAILED_EXCEPTION) + ); } -} +} \ No newline at end of file From c35a147c8e04a29ac6d9a9be9b42bd58c225f00f Mon Sep 17 00:00:00 2001 From: hoyunjung Date: Tue, 13 Jan 2026 21:27:31 +0900 Subject: [PATCH 20/22] =?UTF-8?q?feat:=20CircuitBreaker=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CircuitBreaker 상태 전환, 실패율 초과, 느린응답률 초과 이벤트에 대한 로그 등록 - 이벤트 리스너 등록 로직 추가 및 @Slf4j 어노테이션 적용 --- .../config/ResiliencePolicyRegistrar.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/deepple/deepple/common/config/ResiliencePolicyRegistrar.java b/src/main/java/deepple/deepple/common/config/ResiliencePolicyRegistrar.java index 4a156b8ef..4ef209c0a 100644 --- a/src/main/java/deepple/deepple/common/config/ResiliencePolicyRegistrar.java +++ b/src/main/java/deepple/deepple/common/config/ResiliencePolicyRegistrar.java @@ -3,11 +3,13 @@ import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; import io.github.resilience4j.retry.RetryRegistry; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.context.annotation.Configuration; import java.util.List; +@Slf4j @Configuration @RequiredArgsConstructor public class ResiliencePolicyRegistrar implements SmartInitializingSingleton { @@ -20,5 +22,28 @@ public void afterSingletonsInstantiated() { for (var c : configurers) { c.configure(retryRegistry, circuitBreakerRegistry); } + registerCircuitBreakerEventListeners(); + } + + private void registerCircuitBreakerEventListeners() { + circuitBreakerRegistry.getAllCircuitBreakers().forEach(cb -> { + cb.getEventPublisher() + .onStateTransition(event -> log.warn( + "[CircuitBreaker 상태 전환] name={}, {} -> {}", + event.getCircuitBreakerName(), + event.getStateTransition().getFromState(), + event.getStateTransition().getToState() + )) + .onFailureRateExceeded(event -> log.warn( + "[CircuitBreaker 실패율 초과] name={}, failureRate={}%", + event.getCircuitBreakerName(), + event.getFailureRate() + )) + .onSlowCallRateExceeded(event -> log.warn( + "[CircuitBreaker 느린응답률 초과] name={}, slowCallRate={}%", + event.getCircuitBreakerName(), + event.getSlowCallRate() + )); + }); } } From f674c972073e67de236e370a8c5e096086496995 Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Wed, 14 Jan 2026 11:01:50 +0900 Subject: [PATCH 21/22] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 테이블에 복합 인덱스 추가 (receiverId, deletedAt, id) - 데이터베이스 및 엔티티 매핑 수정 #394 --- .../deepple/notification/command/domain/Notification.java | 5 ++++- .../db/migration/V7__add_index_to_notifications.sql | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V7__add_index_to_notifications.sql diff --git a/src/main/java/deepple/deepple/notification/command/domain/Notification.java b/src/main/java/deepple/deepple/notification/command/domain/Notification.java index 6fce7159f..d8b784cb8 100644 --- a/src/main/java/deepple/deepple/notification/command/domain/Notification.java +++ b/src/main/java/deepple/deepple/notification/command/domain/Notification.java @@ -12,7 +12,10 @@ import static jakarta.persistence.EnumType.STRING; @Entity -@Table(name = "notifications") +@Table( + name = "notifications", + indexes = @Index(name = "idx_notifications_receiver_deleted_id", columnList = "receiverId, deletedAt, id") +) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Notification extends SoftDeleteBaseEntity { diff --git a/src/main/resources/db/migration/V7__add_index_to_notifications.sql b/src/main/resources/db/migration/V7__add_index_to_notifications.sql new file mode 100644 index 000000000..4ce5320fd --- /dev/null +++ b/src/main/resources/db/migration/V7__add_index_to_notifications.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_notifications_receiver_deleted_id + ON notifications (receiver_id, deleted_at, id); \ No newline at end of file From 7b8c6c1a0e2745daa15a502ff724a27301109937 Mon Sep 17 00:00:00 2001 From: Hoyun Jung Date: Wed, 14 Jan 2026 17:06:08 +0900 Subject: [PATCH 22/22] =?UTF-8?q?feat:=20Bizgo=20API=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bizgo API 응답에서 에러 상태 코드 및 본문 로깅 추가 - 예외 발생 시 디버깅 용이하도록 개선 #409 --- .../member/command/infra/member/sms/BizgoMessanger.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java b/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java index b6138f0e6..bfb9becb0 100644 --- a/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java +++ b/src/main/java/deepple/deepple/member/command/infra/member/sms/BizgoMessanger.java @@ -44,6 +44,9 @@ private void sendRequest(String message, String phoneNumber) { .body(new BizgoMessageRequest(message, fromPhoneNumber, phoneNumber)) .retrieve() .onStatus(HttpStatusCode::isError, (request, httpResponse) -> { + String responseBody = new String(httpResponse.getBody().readAllBytes()); + log.error("Bizgo API 에러 응답 - statusCode: {}, body: {}", + httpResponse.getStatusCode().value(), responseBody); throw new BizgoMessageSendException(httpResponse.getStatusCode().value()); } ).toEntity(String.class);