From 3e338b84dc641084915f5d2c35d8fe7c166999e7 Mon Sep 17 00:00:00 2001 From: Jiyoon Kim Date: Sat, 7 Nov 2026 16:28:55 -0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=A7=84=ED=96=89=20=EC=A4=91=20=EB=B3=B4=EB=93=9C=C2=B7?= =?UTF-8?q?=EC=A7=91=EA=B3=84=EB=A5=BC=20START=5FAT=20=EA=B2=BD=EA=B3=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=8C=90=EB=8B=A8=20(=EA=B2=B0=EC=A0=95=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ONGOING 상태로 전환하지 않기로 한 결정 5(study-meeting spec)에 맞게 STATUS=ONGOING 판정을 STATUS=OPEN+START_AT<=now 로 교체한다. - StudyParticipantRepository: findActiveInOngoingStudies → findActiveInStartedOpenStudies(Instant asOf) - BackofficeStudyListFilter: startedOnly 필드 추가 — DAO에서 START_AT 조건 적용 - AdminStatsService.ongoingBoard: status=OPEN + startedOnly=true - AdminStatsService.studiesByCategory: 진행 중 카운트에 OPEN+startAt 경과 포함 - DashboardSummaryAggregateService: 변경된 메서드 호출로 업데이트 - AdminStatsIntegrationTest: 시드를 OPEN+과거 START_AT 으로 변경, 보드 테스트 조건 명확화 Co-Authored-By: Claude Sonnet 4.6 --- .../api/stats/AdminStatsService.java | 18 ++++++-- .../DashboardSummaryAggregateService.java | 3 +- .../api/study/AdminStudyController.java | 3 +- .../api/study/BackofficeStudyListFilter.java | 5 ++- .../api/study/BackofficeStudyQueryDao.java | 4 ++ .../api/stats/AdminStatsIntegrationTest.java | 44 ++++++++++++------- .../study/AdminStudyQueryIntegrationTest.java | 12 ++++- .../StudyParticipantRepository.java | 11 +++-- 8 files changed, 73 insertions(+), 27 deletions(-) diff --git a/backend/api/src/main/java/com/studyclub/api/stats/AdminStatsService.java b/backend/api/src/main/java/com/studyclub/api/stats/AdminStatsService.java index e19982bf7..1c877e9b3 100644 --- a/backend/api/src/main/java/com/studyclub/api/stats/AdminStatsService.java +++ b/backend/api/src/main/java/com/studyclub/api/stats/AdminStatsService.java @@ -229,7 +229,11 @@ public StudiesByCategoryResponse studiesByCategory() { for (Study study : studies) { long[] pair = counts.computeIfAbsent(study.getCategory(), c -> new long[2]); pair[1]++; // total - if (study.getStatus() == StudyStatus.ONGOING) { + // 결정 5: OPEN→ONGOING 전환을 당분간 일으키지 않으므로 START_AT 경과로 진행 중을 판단한다. + if (study.getStatus() == StudyStatus.ONGOING + || (study.getStatus() == StudyStatus.OPEN + && study.getStartAt() != null + && !asOf.isBefore(study.getStartAt()))) { pair[0]++; } } @@ -245,16 +249,21 @@ public StudiesByCategoryResponse studiesByCategory() { } private StudyBoardResponse ongoingBoard(Instant asOf, ZoneId zone, int limit) { + // 결정 5: OPEN→ONGOING 전환을 당분간 일으키지 않으므로 START_AT 경과로 진행 중을 판단한다. BackofficeStudyListResponse page = backofficeStudyListService.getStudies( new BackofficeStudyListFilter( null, null, - StudyStatus.ONGOING, + StudyStatus.OPEN, + null, + null, + null, + BackofficeStudySort.TITLE_ASC, null, null, null, - BackofficeStudySort.TITLE_ASC), + true), 0, limit, asOf); @@ -336,7 +345,8 @@ static DashboardDeadlineBadge deadlineBadge( } private ActiveCrewStats activeCrewStats() { - List actives = studyParticipantRepository.findActiveInOngoingStudies(); + List actives = + studyParticipantRepository.findActiveInStartedOpenStudies(Instant.now()); Set enrollments = actives.stream() .map(p -> p.getAccountId() + ":" + p.getStudyId()) diff --git a/backend/api/src/main/java/com/studyclub/api/stats/DashboardSummaryAggregateService.java b/backend/api/src/main/java/com/studyclub/api/stats/DashboardSummaryAggregateService.java index 7267b4806..de8afb503 100644 --- a/backend/api/src/main/java/com/studyclub/api/stats/DashboardSummaryAggregateService.java +++ b/backend/api/src/main/java/com/studyclub/api/stats/DashboardSummaryAggregateService.java @@ -40,7 +40,8 @@ class DashboardSummaryAggregateService { @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) ParticipationResult participation() { - List actives = studyParticipantRepository.findActiveInOngoingStudies(); + List actives = + studyParticipantRepository.findActiveInStartedOpenStudies(Instant.now()); Set enrollments = actives.stream() .map(p -> p.getAccountId() + ":" + p.getStudyId()) diff --git a/backend/api/src/main/java/com/studyclub/api/study/AdminStudyController.java b/backend/api/src/main/java/com/studyclub/api/study/AdminStudyController.java index 2a31238d2..89f9522a0 100644 --- a/backend/api/src/main/java/com/studyclub/api/study/AdminStudyController.java +++ b/backend/api/src/main/java/com/studyclub/api/study/AdminStudyController.java @@ -100,7 +100,8 @@ public BackofficeStudyListResponse list( sort, keyword, hasApplicationForm, - recruitFilter), + recruitFilter, + null), offset, limit); } diff --git a/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyListFilter.java b/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyListFilter.java index 90d689087..d6c5037ef 100644 --- a/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyListFilter.java +++ b/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyListFilter.java @@ -16,7 +16,9 @@ public record BackofficeStudyListFilter( BackofficeStudySort sort, String keyword, Boolean hasApplicationForm, - AdminStudyRecruitFilter recruitFilter) { + AdminStudyRecruitFilter recruitFilter, + /** true 이면 START_AT 이 asOf 이하인 행만 — 대시보드 진행 중 보드에서 사용 (결정 5). */ + Boolean startedOnly) { public BackofficeStudyListFilter( StudyCategory category, @@ -36,6 +38,7 @@ public BackofficeStudyListFilter( sort, null, null, + null, null); } diff --git a/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyQueryDao.java b/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyQueryDao.java index 478ca8c0d..b14840d94 100644 --- a/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyQueryDao.java +++ b/backend/api/src/main/java/com/studyclub/api/study/BackofficeStudyQueryDao.java @@ -123,6 +123,10 @@ private static Sql assemble(BackofficeStudyListFilter filter, Instant asOf, bool conditions.add("(" + HAS_FORM + ") = :hasForm"); params.put("hasForm", filter.hasApplicationForm() ? 1 : 0); } + if (Boolean.TRUE.equals(filter.startedOnly())) { + conditions.add("s.START_AT IS NOT NULL AND s.START_AT <= :asOf"); + params.put("asOf", Timestamp.from(asOf)); + } boolean recruitFilter = filter.recruitStatus() != null || filter.recruitFilter() != null; if (filter.recruitStatus() != null) { conditions.add("s.STATUS = 'OPEN'"); diff --git a/backend/api/src/test/java/com/studyclub/api/stats/AdminStatsIntegrationTest.java b/backend/api/src/test/java/com/studyclub/api/stats/AdminStatsIntegrationTest.java index 020ab69e0..00d5ed704 100644 --- a/backend/api/src/test/java/com/studyclub/api/stats/AdminStatsIntegrationTest.java +++ b/backend/api/src/test/java/com/studyclub/api/stats/AdminStatsIntegrationTest.java @@ -55,8 +55,15 @@ void seed() { insertAccount(CREW_B, "crew-b@example.com", SystemRole.MEMBER, "America/New_York", now); // 제목을 AAA 로 시작해 보드 미리보기(최대 4건) 정렬에서 항상 앞에 오게 한다 - insertStudy(ONGOING_STUDY, "AAA 대시보드 진행중", "ALGORITHM", "ONGOING", now); - insertStudy(OPEN_STUDY, "AAA 대시보드 모집중", "SOFTWARE", "OPEN", now); + // 결정 5: OPEN→ONGOING 전환 없이 START_AT 경과로 진행 중 판정 — OPEN + 과거 START_AT 으로 세팅 + insertStudy( + ONGOING_STUDY, + "AAA 대시보드 진행중", + "ALGORITHM", + "OPEN", + Timestamp.from(Instant.now().minus(1, ChronoUnit.DAYS)), + now); + insertStudy(OPEN_STUDY, "AAA 대시보드 모집중", "SOFTWARE", "OPEN", null, now); insertGroup(GROUP_ID, ONGOING_STUDY, now); insertParticipant(CREW_A, ONGOING_STUDY, GROUP_ID, ParticipantStatus.ACTIVE, now); insertParticipant(CREW_B, ONGOING_STUDY, GROUP_ID, ParticipantStatus.ACTIVE, now); @@ -140,27 +147,21 @@ void ongoingBoard() { } @Test - @DisplayName("성공 - 진행중 보드는 전체 목록의 첫 최대 4건과 같은 DB 정렬을 쓴다") - void ongoingBoardMatchesAdminListOrder() { + @DisplayName("성공 - 진행중 보드는 START_AT 경과 OPEN 스터디만 표시한다 (결정 5)") + void ongoingBoardShowsStartedOpenOnly() { var board = rest.exchange( "/api/admin/stats/study-board?view=ONGOING", HttpMethod.GET, auth(ADMIN_ID), Map.class); - var list = - rest.exchange( - "/api/admin/studies?status=ONGOING&sort=TITLE_ASC&offset=0&limit=20", - HttpMethod.GET, - auth(ADMIN_ID), - Map.class); assertThat(board.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(list.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(studyIds(board.getBody())) - .isEqualTo(studyIds(list.getBody()).stream().limit(4).toList()); - assertThat(((Number) board.getBody().get("total")).longValue()) - .isEqualTo(((Number) list.getBody().get("total")).longValue()); + List ids = studyIds(board.getBody()); + // ONGOING_STUDY: STATUS=OPEN, START_AT 과거 → 보드에 포함 + assertThat(ids).contains(ONGOING_STUDY); + // OPEN_STUDY: STATUS=OPEN, START_AT 없음 → 보드에 미포함 + assertThat(ids).doesNotContain(OPEN_STUDY); } @Test @@ -364,6 +365,16 @@ private void insertAccount( } private void insertStudy(Long id, String title, String category, String status, Timestamp now) { + insertStudy(id, title, category, status, null, now); + } + + private void insertStudy( + Long id, + String title, + String category, + String status, + Timestamp startAt, + Timestamp now) { jdbcTemplate.update( "INSERT INTO STUDY_PROGRAM (ID, TITLE, STUDY_KIND, CREATED_AT, UPDATED_AT)" + " VALUES (?, ?, 'STUDY', ?, ?)", @@ -373,13 +384,14 @@ private void insertStudy(Long id, String title, String category, String status, now); jdbcTemplate.update( "INSERT INTO STUDY (ID, PROGRAM_ID, TITLE, ONE_LINE_SUMMARY, CATEGORY, STATUS," - + " CREATED_AT, UPDATED_AT) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + + " START_AT, CREATED_AT, UPDATED_AT) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", id, id, title, "요약", category, status, + startAt, now, now); } diff --git a/backend/api/src/test/java/com/studyclub/api/study/AdminStudyQueryIntegrationTest.java b/backend/api/src/test/java/com/studyclub/api/study/AdminStudyQueryIntegrationTest.java index 9633a5c03..391293b36 100644 --- a/backend/api/src/test/java/com/studyclub/api/study/AdminStudyQueryIntegrationTest.java +++ b/backend/api/src/test/java/com/studyclub/api/study/AdminStudyQueryIntegrationTest.java @@ -355,7 +355,17 @@ private BackofficeStudyListFilter filter( BackofficeStudySort sort, StudyStatus status) { return new BackofficeStudyListFilter( - StudyCategory.OTHER, null, status, null, null, null, sort, keyword, form, recruit); + StudyCategory.OTHER, + null, + status, + null, + null, + null, + sort, + keyword, + form, + recruit, + null); } private List ids(BackofficeStudyListResponse response) { diff --git a/backend/domain/src/main/java/com/studyclub/domain/participant/StudyParticipantRepository.java b/backend/domain/src/main/java/com/studyclub/domain/participant/StudyParticipantRepository.java index 8ea355007..64014f2de 100644 --- a/backend/domain/src/main/java/com/studyclub/domain/participant/StudyParticipantRepository.java +++ b/backend/domain/src/main/java/com/studyclub/domain/participant/StudyParticipantRepository.java @@ -1,6 +1,7 @@ package com.studyclub.domain.participant; import jakarta.persistence.LockModeType; +import java.time.Instant; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -17,13 +18,17 @@ public interface StudyParticipantRepository extends JpaRepository findByStudyIdAndStatusIn( Long studyId, Collection statuses); - /** 진행 중 기수의 ACTIVE 참여 명부. 대시보드 활성 크루·1인당 참여·시간대 분포용 (specs/dashboard/spec.md 2-1). */ + /** + * 진행 중 기수(STATUS=OPEN, START_AT 경과)의 ACTIVE 참여 명부. 대시보드 활성 크루·1인당 참여·시간대 분포용 + * (specs/dashboard/spec.md 2-1). 결정 5에 따라 OPEN→ONGOING 전환을 당분간 일으키지 않으므로 START_AT 경과로 판단한다. + */ @Query( "SELECT p FROM StudyParticipant p, Study s" + " WHERE p.studyId = s.id" - + " AND s.status = com.studyclub.domain.study.StudyStatus.ONGOING" + + " AND s.status = com.studyclub.domain.study.StudyStatus.OPEN" + + " AND s.startAt IS NOT NULL AND s.startAt <= :asOf" + " AND p.status = com.studyclub.domain.participant.ParticipantStatus.ACTIVE") - List findActiveInOngoingStudies(); + List findActiveInStartedOpenStudies(@Param("asOf") Instant asOf); void deleteByStudyId(Long studyId); From 7a9f01c021af07a51ad8874639c4092bce5446e4 Mon Sep 17 00:00:00 2001 From: Jiyoon Kim Date: Sat, 7 Nov 2026 16:32:49 -0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=8E=99=20=E2=80=94=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=20=EC=A4=91=20=EA=B8=B0=EC=88=98=20=ED=8C=90=EC=A0=95=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9D=84=20START=5FAT=20=EA=B2=BD=EA=B3=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20(=EA=B2=B0=EC=A0=95=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회차 스펙 결정 5(OPEN→ONGOING 전환 없음)에 맞게 dashboard/spec.md 의 '진행 중 기수' 정의와 집계 조건을 STATUS=ONGOING → STATUS=OPEN+START_AT 경과로 수정한다. - 용어 표, 2-1절 판정 근거, 3-1절 보드 조건, 6절 카테고리 범위, 4.3절 categories[].ongoingCount 설명, 5절 검증 기준, FE 주소-쿼리 연결 표 7곳 갱신 - 결정 5로 두 보드 모두 OPEN 대상이 돼 동시 포함 가능성 명시 Co-Authored-By: Claude Sonnet 4.6 --- specs/dashboard/spec.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/specs/dashboard/spec.md b/specs/dashboard/spec.md index eb1b238e5..8847ec128 100644 --- a/specs/dashboard/spec.md +++ b/specs/dashboard/spec.md @@ -32,7 +32,7 @@ | 프로그램 | 기수의 상위 묶음인 `STUDY_PROGRAM` | | 반 | 기수 안의 `STUDY_GROUP`. 반을 나눠도 기수 수는 늘지 않는다 | | 참여 명부 | `STUDY_PARTICIPANT`. `STUDY_ID`로 기수, `STUDY_GROUP_ID`로 반에 연결한다 | -| 진행 중 기수 | 저장된 `STUDY.STATUS`가 `ONGOING`인 기수 | +| 진행 중 기수 | `STATUS = OPEN`이고 `START_AT ≤ asOf`인 기수. 회차 스펙 결정 5에 따라 `OPEN → ONGOING` 전환을 당분간 일으키지 않으므로 `START_AT` 경과로 판정한다 (`Study.phase()` 와 같은 기준). | | `asOf` | 서버가 정한 응답의 집계 기준 시각 | | 조회자 시간대 | 갱신 시각·모집 날짜·주간 집계에 적용하는 시간대. 응답의 `timeZone` 값 | @@ -147,7 +147,7 @@ 신청 시에는 신청서만 저장한다. 캡틴이 반을 배정할 때 참여 명부를 생성한다. 대시보드는 실제 명부를 조회하며 별도 반 배정 필터를 추가하지 않는다. -진행 중 판정에는 저장된 `STUDY.STATUS = ONGOING`을 사용한다. 첫 미팅 등록에 따른 상태 전환은 스터디 도메인에서 처리한다. 시작일 경과로 진행 상태를 추정하지 않는다. +진행 중 판정에는 `STATUS = OPEN`이고 `STUDY.START_AT ≤ asOf`인 기수를 사용한다. 회차 스펙 결정 5에 따라 `OPEN → ONGOING` 전환을 당분간 일으키지 않으므로 시작일 경과로 판정한다 (2026-11-07 변경). **논의 중 — 활성 크루 카드의 목록 이동** @@ -239,12 +239,12 @@ ### 3-1. 진행중 스터디 -- `STUDY.STATUS = ONGOING`인 모든 기수를 표시한다. 참여자가 없어도 포함한다. +- `STATUS = OPEN`이고 `START_AT ≤ asOf`인 모든 기수를 표시한다. 참여자가 없어도 포함한다. - 이름만 표시한다. 출석률·회차 수는 추가하지 않는다. - 제목 오름차순으로 정렬한다. 제목이 같으면 기수 ID 오름차순으로 정렬한다. - 대상이 없으면 ‘진행중 스터디가 없습니다.’를 표시한다. -같은 시점에 같은 기수가 두 보드에 동시에 포함되지는 않는다. 같은 프로그램의 서로 다른 기수는 각 보드에 표시할 수 있다. +결정 5로 두 보드 모두 `STATUS = OPEN`을 대상으로 하므로, `START_AT`이 경과하면서 아직 모집 중인 기수는 두 보드에 동시에 표시될 수 있다. 같은 프로그램의 서로 다른 기수는 각 보드에 표시할 수 있다. ### 3-2. 모집중 스터디 @@ -346,7 +346,7 @@ KST·ET·PT는 차트 그룹 코드다. 실제 날짜 계산에는 IANA 시간 | 항목 | 규칙 | |---|---| | 기본 범위 | ‘진행중’. 새로고침하면 기본값 적용 | -| 진행중 | `STUDY.STATUS = ONGOING` | +| 진행중 | `STATUS = OPEN`이고 `START_AT ≤ asOf`. 결정 5에 따라 `OPEN → ONGOING` 전환 없이 시작일 경과로 판정 | | 누적 | 조회 시점의 `OPEN / ONGOING / ENDED / CLOSED`. `DRAFT` 제외 | | 집계 단위 | `STUDY.CATEGORY` 하나에 기수당 1개. `STUDY.ID`로 중복 제거 | | 표시 | 카테고리 이름·개수·가로 막대. 선택 범위에서 0개인 카테고리는 숨김 | @@ -634,9 +634,9 @@ KST·ET·PT는 차트 그룹 코드다. 실제 날짜 계산에는 IANA 시간 | 프론트 주소의 진입 조건 | 목록 API Query | |---|---| | 일반 목록 `/studies` | `sort=RECRUITMENT_FIRST` | -| `view=ongoing` | `status=ONGOING&sort=TITLE_ASC` | +| `view=ongoing` | `status=OPEN&sort=TITLE_ASC` (전체 OPEN 목록 — 보드보다 많을 수 있음) | | `view=recruitment` | `status=OPEN&sort=RECRUITMENT` | -| `view=ongoing&category={code}` | `category={code}&status=ONGOING&visibility=PUBLIC&sort=TITLE_ASC` | +| `view=ongoing&category={code}` | `category={code}&status=OPEN&visibility=PUBLIC&sort=TITLE_ASC` | | `view=cumulative&category={code}` | `category={code}&visibility=PUBLIC&sort=ID_DESC` | - 진입 시 나머지 검색·종류·신청 폼 조건을 초기화한다. 적용한 필터를 화면에 선택된 상태로 표시한다. @@ -790,7 +790,7 @@ KST·ET·PT는 차트 그룹 코드다. 실제 날짜 계산에는 IANA 시간 | asOf | Datetime | N | 공통 규칙 | | categories | Array | N | 카테고리별 집계. 없으면 [] | | categories[].code | String | N | `STUDY.CATEGORY`의 StudyCategory 코드. 화면 라벨·목록 필터에 사용 | -| categories[].ongoingCount | Long | N | 해당 카테고리에서 상태가 `ONGOING`인 고유 `STUDY.ID` 수 | +| categories[].ongoingCount | Long | N | 해당 카테고리에서 진행 중(`STATUS = OPEN`이고 `START_AT ≤ asOf`)인 고유 `STUDY.ID` 수 | | categories[].totalCount | Long | N | 해당 카테고리에서 상태가 `OPEN / ONGOING / ENDED / CLOSED`인 고유 `STUDY.ID` 수 | 두 범위 모두 0인 카테고리는 생략할 수 있다. 두 범위 모두 대상이 없으면 `categories=[]`다. 일부 카테고리의 실패를 0으로 바꾸지 않는다. 프론트는 선택 범위에 따라 정렬·막대·숨김을 적용한다. @@ -801,7 +801,7 @@ KST·ET·PT는 차트 그룹 코드다. 실제 날짜 계산에는 IANA 시간 |---|---| | 권한·입력 | 미로그인·계정 없음 401, 일반 회원 403, 잘못된 Query 400 | | 집계 단위 | 회원은 `ACCOUNT_ID`, 참여는 `(ACCOUNT_ID, STUDY_ID)`, 기수는 `STUDY.ID`로 중복 제거 | -| 진행 상태·명부 | `ONGOING`·실제 `ACTIVE` 명부만 활성 집계. 신청만 한 회원과 시작일만 지난 `OPEN` 기수는 제외 | +| 진행 상태·명부 | `STATUS = OPEN`이고 `START_AT ≤ asOf`인 기수의 실제 `ACTIVE` 명부만 활성 집계. 신청만 한 회원(`STUDY_PARTICIPANT` 없음) 제외. `START_AT` 없는 `OPEN` 기수는 제외 | | 출석 참여 기간 | `JOINED_AT`·집계 상한 경계 포함. 탈퇴·하차 전 기록 보존. `LEFT_AT` 누락 시 해당 참여자 제외 | | 출석 점수·빈 값 | 모두 `EXCUSED` 거나 미입력이면 분모 0 → `null`. 결석만 있으면 0% | | 공통 계산 | 같은 대상·시각이면 기존 출석 계산과 분자·분모 일치. 백분율 변환은 서버에서 한 번만 수행 |