diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..84c8c711 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,62 @@ +name: Test + +# 배포(deploy.yml)는 테스트를 건너뛰고 이미지를 만든다. PR에서 DB 테스트까지 돌려 병합 전에 막는다. +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + # 빈 DB에 Flyway가 V1부터 적용하고, ddl-auto: validate까지 같이 확인된다. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: repit + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test -d repit" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + # 외부 서버는 부르지 않는다(테스트가 목으로 바꾼다). 값이 비면 기동이 실패해 자리만 채운다. + env: + DB_URL: jdbc:postgresql://localhost:5432/repit + DB_USERNAME: test + DB_PASSWORD: test + AWS_S3_ACCESS_KEY: test + AWS_S3_SECRET_KEY: test + S3_BUCKET: test-bucket + S3_URL: https://test-bucket.s3.ap-northeast-2.amazonaws.com + AWS_REGION: ap-northeast-2 + AUTH_SERVER: http://127.0.0.1:9 + AI_SERVER: http://127.0.0.1:9 + CHAT_SERVER: http://127.0.0.1:9 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Test + run: ./gradlew test --no-daemon + + - name: Upload test report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequest.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequest.java index 21b23ee1..7266a243 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequest.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequest.java @@ -36,5 +36,7 @@ public static class Error { @JsonAlias("statusCode") private Integer status_code; private String message; + // 원인 코드. REPO_NOT_PUBLIC / INVALID_MATERIAL / INSUFFICIENT_EVIDENCE / INTERNAL. 분기는 message가 아니라 이것으로 한다. + private String code; } } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java index 2bc51f77..9759bd9c 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.Set; /** 마이페이지와 면접 설정이 보는 최근 종합 데이터 분석 상태. */ @Getter @@ -44,11 +45,17 @@ public static ProfileStatusResponse none() { } /** - * 실패 사유. 분석 서버는 잘못된 자료와 근거 부족을 같은 422로 보내 메시지로 가른다. + * 실패 사유. 분석 서버가 보낸 원인 코드(error.code)가 이 네 값 중 하나면 그대로 쓴다. + * + *
코드가 없거나 모르는 값이면 상태 코드로 고른다 — 코드를 보내기 전에 저장된 실패이거나 분석 서버가 아직 + * 코드를 보내지 않는 경우다. 그때는 잘못된 자료와 근거 부족이 같은 422라 메시지 문구로 가른다. * *
웹은 사유마다 안내가 다르다 — 저장소 공개 설정, 자료 교체, 자료 보강, 다시 시도.
*/
- public static String reasonOf(Integer statusCode, String message) {
+ public static String reasonOf(Integer statusCode, String code, String message) {
+ if (code != null && Set.of(REPO_NOT_PUBLIC, INVALID_MATERIAL, INSUFFICIENT_EVIDENCE, INTERNAL).contains(code)) {
+ return code;
+ }
if (statusCode == null) {
return INTERNAL;
}
@@ -56,7 +63,6 @@ public static String reasonOf(Integer statusCode, String message) {
return REPO_NOT_PUBLIC;
}
if (statusCode == 422) {
- // ponytail: 분석 서버 문구("근거가 부족합니다")에 기댄다. 문구가 바뀌면 잘못된 자료로 읽힌다. 코드를 나누면 그쪽으로 옮긴다.
return message != null && message.contains("근거") ? INSUFFICIENT_EVIDENCE : INVALID_MATERIAL;
}
return INTERNAL;
diff --git a/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java b/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java
index 00d8f62a..48c5221a 100644
--- a/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java
+++ b/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java
@@ -56,6 +56,10 @@ public class AnalysisDataEntity {
@Column(columnDefinition = "TEXT")
private String errorMessage;
+ // 실패 콜백의 원인 코드(REPO_NOT_PUBLIC 등). 코드 없이 온 실패면 비어 있다.
+ @Column(length = 40)
+ private String errorCode;
+
// 콜백을 저장한 시각. 같은 jobId로 분석을 다시 요청했을 때 여기 남은 결과가
// 이번 실행의 것인지 지난 실행의 것인지는 이 값으로만 가릴 수 있다.
private LocalDateTime completedAt;
diff --git a/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java b/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java
index 2a7bebc9..a4946f21 100644
--- a/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java
+++ b/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java
@@ -109,6 +109,7 @@ Optional 영상 콜백은 어긋난 본문까지 원문으로 남기므로 인증 없이는 받지 않는다(CallbackPaths.ALWAYS_AUTHENTICATED).
* 그 상태로 요청을 보내면 콜백은 모두 401로 튕기고 결과는 조회로만 늦게 들어오는데, 겉으로는 정상처럼 보인다.
* 요청 인증값이 없으면 접수가 모두 401로 거절돼 닫힌다. 잘못된 설정을 배포 시점에 드러낸다 — 블루/그린 배포라
* 새 서버가 뜨지 않으면 이전 서버가 그대로 남는다.
+ *
+ * 기준 시각이 비어 있을 때 "제한 없음"으로 읽으면 켜는 순간 그동안 쌓인 영상(업로드 89일 이내)이 모두 나간다.
+ * 켠 뒤 올라온 영상만 분석하기로 했으므로 값을 빠뜨린 배포를 막는다.
*/
@PostConstruct
- void requireAuthTokens() {
+ void requireSettings() {
if (!enabled) {
return;
}
@@ -146,6 +158,18 @@ void requireAuthTokens() {
throw new IllegalStateException("영상 분석(VIDEO_ANALYSIS_ENABLED)을 켜려면 VIDEO_ANALYSIS_API_TOKEN이 필요합니다. "
+ "없으면 분석 서버가 영상 요청을 모두 401로 거절합니다.");
}
+ if (analyzeUploadedAfterSetting == null || analyzeUploadedAfterSetting.isBlank()) {
+ throw new IllegalStateException("영상 분석(VIDEO_ANALYSIS_ENABLED)을 켜려면 VIDEO_ANALYSIS_UPLOADED_AFTER가 필요합니다. "
+ + "없으면 켜기 전에 올라온 영상까지 분석합니다.");
+ }
+ try {
+ analyzeUploadedAfter = OffsetDateTime.parse(analyzeUploadedAfterSetting.trim())
+ .atZoneSameInstant(ZoneId.systemDefault())
+ .toLocalDateTime();
+ } catch (DateTimeParseException e) {
+ throw new IllegalStateException("VIDEO_ANALYSIS_UPLOADED_AFTER는 오프셋을 포함한 ISO-8601 시각이어야 합니다. "
+ + "예: 2026-10-20T09:00:00+09:00", e);
+ }
}
/**
@@ -159,8 +183,12 @@ public void sweep() {
return;
}
LocalDateTime startedAt = now();
+ LocalDateTime uploadedAfter = startedAt.minus(ANALYZABLE_FOR);
+ if (analyzeUploadedAfter != null && analyzeUploadedAfter.isAfter(uploadedAfter)) {
+ uploadedAfter = analyzeUploadedAfter;
+ }
for (InterviewRecordingEntity video : analysisRepository.findVideosToStart(
- startedAt.minus(UPLOAD_GRACE), startedAt.minus(ANALYZABLE_FOR), Limit.of(BATCH_SIZE))) {
+ startedAt.minus(UPLOAD_GRACE), uploadedAfter, Limit.of(BATCH_SIZE))) {
guarded("영상 분석 요청을 만들지 못했습니다.", video.getInterviewId(),
() -> open(video, newChainId(), VideoAnalysisRequestedBy.AUTO));
}
@@ -184,7 +212,7 @@ public VideoAnalysisResponse get(Long userId, Long interviewId) {
List 두 정수 키 잠금이라 {@code AnalysisDataRepository.lockProfileLaunch}(bigint 키)와 키가 겹치지 않는다.
+ * hashtext가 겹치면 다른 사이클이 잠깐 기다릴 뿐 결과는 같다.
+ */
+ @Query(value = "select 1 from (select pg_advisory_xact_lock(hashtext(:profileJobId), hashtext(:mode))) locked",
+ nativeQuery = true)
+ Integer lockCycleCreation(@Param("profileJobId") String profileJobId, @Param("mode") String mode);
+
/** 이 종합 데이터의 사이클이 이 코드로 실패한 적이 있는지. */
boolean existsByProfileJobIdAndErrorStatusCode(String profileJobId, Integer errorStatusCode);
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java
index fe45a4f1..00fe9ba0 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java
@@ -232,11 +232,12 @@ private QuestionCycleEntity prepareCycle(AnalysisDataEntity profile, InterviewMo
return null;
}
LocalDateTime now = LocalDateTime.now();
+ // 같은 사이클을 동시에 처음 만드는 요청이 줄을 선다. 늦은 쪽은 앞의 커밋을 기다렸다가 만들어진 행을 본다.
+ questionCycleRepository.lockCycleCreation(profile.getJobId(), mode.name());
QuestionCycleEntity cycle = questionCycleRepository
.findByProfileJobIdAndModeAndCycleNo(profile.getJobId(), mode, cycleNo)
.orElse(null);
if (cycle == null) {
- // ponytail: 같은 사이클을 두 요청이 동시에 처음 만들면 유니크 제약에 한쪽이 500으로 끝난다. 다시 누르면 열린다.
return questionCycleRepository.save(QuestionCycleEntity.builder()
.userId(profile.getUserId())
.profileJobId(profile.getJobId())
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 88610c48..95fb880d 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -120,13 +120,17 @@ app:
recording-url-ttl: 6h
video-analysis:
# 분석 서버가 Chrome·Safari 샘플로 검증을 마치고 영상 분석을 열면 켠다. 꺼져 있으면 면접 화면 녹화를 받아
- # 두기만 하고 보내지 않는다. 켜는 순간 그동안 쌓인 최종 영상(업로드 89일 이내)도 스윕마다 20건씩 나간다.
+ # 두기만 하고 보내지 않는다. 켠 뒤에는 analyze-uploaded-after 이후에 올라온 최종 영상만 스윕마다 20건씩 나간다.
# 켜려면 INTERNAL_AUTH_TOKEN이 있어야 한다. 없으면 서버가 뜨지 않는다 -- 영상 콜백은 인증 없이 받지 않는다.
# 값을 넣으면 기존 콜백에도 검사가 강제되므로, 분석·채팅 서버가 헤더를 보내도록 배포한 뒤에 켠다.
enabled: ${VIDEO_ANALYSIS_ENABLED:false}
# 분석 서버로 보내는 영상 요청·작업 조회에 X-Internal-Token으로 싣는 값. 분석 서버 VIDEO_API_TOKEN과 같아야 한다.
# 콜백 인증값(INTERNAL_AUTH_TOKEN)과는 다른 값이다 -- 서로 바꿔 쓰지 않는다. 켰는데 비어 있으면 서버가 뜨지 않는다.
api-token: ${VIDEO_ANALYSIS_API_TOKEN:}
+ # 영상 분석을 켠 시각. 오프셋을 포함한 ISO-8601로 넣는다(예: 2026-10-20T09:00:00+09:00). 이 시각까지 올라온 영상은
+ # 보내지 않고, 상태 조회는 NONE, 재분석은 409로 받는다. 켰는데 비어 있거나 형식이 틀리면 서버가 뜨지 않는다 --
+ # 비워 둔 것을 "제한 없음"으로 읽으면 켜는 순간 그동안 쌓인 영상(업로드 89일 이내)이 모두 나간다.
+ analyze-uploaded-after: ${VIDEO_ANALYSIS_UPLOADED_AFTER:}
# 업로드를 마친 영상을 요청 자리에 올리고, 보내고, 작업을 조회하고, 자동 재분석을 잇는 간격.
# 재전송·조회·재분석 한도는 분석 서버와 맞춘 계약이라 코드(VideoAnalysisService)에 둔다.
sweep-interval: 30s
diff --git a/src/main/resources/db/migration/V32__add_analysis_data_error_code.sql b/src/main/resources/db/migration/V32__add_analysis_data_error_code.sql
new file mode 100644
index 00000000..451a9b60
--- /dev/null
+++ b/src/main/resources/db/migration/V32__add_analysis_data_error_code.sql
@@ -0,0 +1,4 @@
+-- /profile 실패 콜백의 원인 코드(error.code). 실패 사유(failureReason)를 메시지 문구 대신 이것으로 고른다.
+-- 분석 서버가 코드를 보내기 전에 저장된 실패와 코드 없이 온 콜백은 NULL이다. 그때는 상태 코드와 메시지로 고른다.
+ALTER TABLE analysis_data
+ ADD COLUMN error_code VARCHAR(40);
diff --git a/src/test/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequestDeserializationTest.java b/src/test/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequestDeserializationTest.java
index 2f1bba74..9f6f860d 100644
--- a/src/test/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequestDeserializationTest.java
+++ b/src/test/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequestDeserializationTest.java
@@ -36,4 +36,25 @@ class CallbackSuccessRequestDeserializationTest {
assertThat(request.getError().getMessage()).isEqualTo("private 저장소입니다.");
});
}
+
+ // 분석 서버 docs/api.md 「POST /profile」의 실패 예시. camelCase이고 원인 코드가 따로 온다.
+ private static final String FAILURE_CALLBACK_WITH_CODE = """
+ {
+ "jobId": "3f0a",
+ "status": "failed",
+ "error": { "statusCode": 422, "message": "근거가 부족합니다.", "code": "INSUFFICIENT_EVIDENCE" }
+ }
+ """;
+
+ @Test
+ void camelCase_실패_콜백의_상태_코드와_원인_코드를_읽는다() {
+ contextRunner.run(context -> {
+ ObjectMapper objectMapper = context.getBean(ObjectMapper.class);
+
+ CallbackSuccessRequest request = objectMapper.readValue(FAILURE_CALLBACK_WITH_CODE, CallbackSuccessRequest.class);
+
+ assertThat(request.getError().getStatus_code()).isEqualTo(422);
+ assertThat(request.getError().getCode()).isEqualTo("INSUFFICIENT_EVIDENCE");
+ });
+ }
}
diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceLatestProfileTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceLatestProfileTest.java
index 882193e3..193377d8 100644
--- a/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceLatestProfileTest.java
+++ b/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceLatestProfileTest.java
@@ -70,6 +70,33 @@ private AnalysisDataEntity failed(int statusCode, String message) {
.isEqualTo(ProfileStatusResponse.INTERNAL);
}
+ private AnalysisDataEntity failed(int statusCode, String code, String message) {
+ AnalysisDataEntity data = failed(statusCode, message);
+ data.setErrorCode(code);
+ return data;
+ }
+
+ /** 분석 서버가 원인 코드를 보내면 문구가 아니라 코드로 고른다. 문구가 바뀌어도, 코드와 엇갈려도 코드가 맞다. */
+ @Test
+ void 원인_코드가_있으면_메시지보다_코드를_따른다() {
+ assertThat(latest(failed(422, "INSUFFICIENT_EVIDENCE", "카테고리 2종 미만입니다.")).getFailureReason())
+ .isEqualTo(ProfileStatusResponse.INSUFFICIENT_EVIDENCE);
+ assertThat(latest(failed(422, "INVALID_MATERIAL", "근거를 찾을 PDF를 열 수 없습니다.")).getFailureReason())
+ .isEqualTo(ProfileStatusResponse.INVALID_MATERIAL);
+ assertThat(latest(failed(403, "REPO_NOT_PUBLIC", "private")).getFailureReason())
+ .isEqualTo(ProfileStatusResponse.REPO_NOT_PUBLIC);
+ assertThat(latest(failed(500, "INTERNAL", "오류")).getFailureReason())
+ .isEqualTo(ProfileStatusResponse.INTERNAL);
+ }
+
+ @Test
+ void 모르는_원인_코드는_상태_코드로_고른다() {
+ assertThat(latest(failed(422, "SOMETHING_NEW", "근거가 부족합니다.")).getFailureReason())
+ .isEqualTo(ProfileStatusResponse.INSUFFICIENT_EVIDENCE);
+ assertThat(latest(failed(403, "SOMETHING_NEW", "?")).getFailureReason())
+ .isEqualTo(ProfileStatusResponse.REPO_NOT_PUBLIC);
+ }
+
@Test
void 분석_중이면_사유를_싣지_않는다() {
ProfileStatusResponse response = latest(AnalysisDataEntity.builder()
diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceSaveResultTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceSaveResultTest.java
index fb00a9c7..05748ea6 100644
--- a/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceSaveResultTest.java
+++ b/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceSaveResultTest.java
@@ -120,4 +120,26 @@ void setUp() {
assertThat(saved.getError().getStatus_code()).isEqualTo(422);
assertThat(saved.getError().getMessage()).isEqualTo("분석 불가");
}
+
+ @Test
+ void 실패의_원인_코드를_남기고_같은_작업이_성공하면_비운다() {
+ AnalysisDataEntity row = AnalysisDataEntity.builder().jobId("job-5").status(AnalysisStatus.PENDING).build();
+ when(analysisDataRepository.findById("job-5")).thenReturn(Optional.of(row));
+
+ service.saveResult(CallbackSuccessRequest.builder()
+ .jobId("job-5")
+ .status("failed")
+ .error(CallbackSuccessRequest.Error.builder()
+ .status_code(422).code("INSUFFICIENT_EVIDENCE").message("근거 부족").build())
+ .build(), AnalysisResultType.PROFILE);
+ assertThat(row.getErrorCode()).isEqualTo("INSUFFICIENT_EVIDENCE");
+
+ service.saveResult(CallbackSuccessRequest.builder()
+ .jobId("job-5")
+ .status("succeeded")
+ .result(Map.of("profile", Map.of()))
+ .build(), AnalysisResultType.PROFILE);
+ assertThat(row.getErrorCode()).isNull();
+ assertThat(row.getErrorStatusCode()).isNull();
+ }
}
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java
index 7cf3e9fe..ed7411df 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java
@@ -191,6 +191,23 @@ void tearDown() {
.doesNotContain(older.getRecordingId(), justUploaded.getRecordingId(), alreadyRequested.getRecordingId());
}
+ /** 스윕은 분석을 켠 시각을 uploadedAfter로 넘긴다. 그 시각까지 올라온 영상은 고르지 않는다. */
+ @Test
+ void 분석을_켜기_전에_올라온_영상은_처음_요청할_영상으로_고르지_않는다() {
+ LocalDateTime enabledAt = LocalDateTime.now().minusHours(1);
+ InterviewRecordingEntity before = video(interview, enabledAt.minusMinutes(1));
+
+ InterviewEntity later = otherInterview();
+ InterviewRecordingEntity after = video(later, enabledAt.plusMinutes(1));
+
+ LocalDateTime now = LocalDateTime.now();
+ List 트랜잭션이 커밋돼야 다른 스레드가 보므로 테스트 트랜잭션을 쓰지 않고, 만든 행은 끝나고 지운다.
+ */
+@SpringBootTest
+class QuestionPoolServiceConcurrencyTest {
+
+ @Autowired
+ private QuestionPoolService questionPoolService;
+ @Autowired
+ private AnalysisDataRepository analysisDataRepository;
+ @Autowired
+ private QuestionCycleRepository questionCycleRepository;
+ @Autowired
+ private TransactionTemplate transactionTemplate;
+ @Autowired
+ private JdbcTemplate jdbc;
+
+ @MockitoBean
+ private AiServerClient aiServerClient;
+
+ private final Long userId = ThreadLocalRandom.current().nextLong(1_000_000_000L, 2_000_000_000L);
+ private final String profileJobId = UUID.randomUUID().toString();
+
+ @BeforeEach
+ void setUp() {
+ analysisDataRepository.saveAndFlush(AnalysisDataEntity.builder()
+ .jobId(profileJobId)
+ .userId(userId)
+ .resultType(AnalysisResultType.PROFILE)
+ .status(AnalysisStatus.SUCCEEDED)
+ .result(Map.of("profile", Map.of(), "projectSummary", Map.of()))
+ .completedAt(LocalDateTime.now())
+ .build());
+ when(aiServerClient.requestQuestionCycle(any()))
+ .thenAnswer(invocation -> GenerateResponse.builder().jobId("cycle-" + UUID.randomUUID()).build());
+ }
+
+ @AfterEach
+ void tearDown() {
+ jdbc.update("delete from question_cycle where user_id = ?", userId);
+ jdbc.update("delete from analysis_data where user_id = ?", userId);
+ }
+
+ /** 종합 데이터 콜백이 재전송되는 순간처럼 두 요청이 같은 지점에 서 있다가 함께 출발한다. */
+ @Test
+ void 같은_사이클을_동시에_처음_만들어도_한_번만_만들고_한_번만_요청한다() throws Exception {
+ CountDownLatch holding = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ CompletableFuture