From 3c50043aaed3c165568a0bf9a29224e1ebf2f583 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 6 Sep 2026 14:02:48 +0900 Subject: [PATCH 01/19] =?UTF-8?q?[fix]=20=EB=8F=84=EC=BB=A4=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=20=ED=83=80=EC=9E=84=EC=A1=B4?= =?UTF-8?q?=EC=9D=84=20KST(Asia/Seoul)=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker logs -f로 확인한 애플리케이션 로그 타임스탬프가 호스트(KST)와 달리 UTC로 찍혀 있었다. eclipse-temurin:21-jre-jammy 베이스에는 tzdata가 포함돼 있으므로, 배포 시 be.env에 TZ 환경변수만 추가하면 JVM이 자동으로 인식한다. --- .github/workflows/cd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7d9ca93..7606011 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -80,6 +80,7 @@ jobs: printf 'DB_USERNAME=%s\n' "$V_DB_USERNAME" >> ~/compose/be.env printf 'DB_PASSWORD=%s\n' "$V_DB_PASSWORD" >> ~/compose/be.env printf 'JWT_SECRET=%s\n' "$V_JWT_SECRET" >> ~/compose/be.env + printf 'TZ=Asia/Seoul\n' >> ~/compose/be.env printf 'REDIS_HOST=redis\n' >> ~/compose/be.env printf 'REDIS_PORT=6379\n' >> ~/compose/be.env printf 'REDIS_PASSWORD=%s\n' "$V_REDIS_PASSWORD" >> ~/compose/be.env From 0b45f77fce246d0ecfbbdbee04a43af77f6f7e00 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 6 Sep 2026 13:49:31 +0900 Subject: [PATCH 02/19] =?UTF-8?q?[perf]=20concert=5Fartist(artist=5Fid)=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=EC=BB=AC=EB=9F=BC=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_artist_is_coming() 대량 UPDATE가 concert_artist에 대한 상관 서브쿼리(WHERE ca.artist_id = a.id)를 매 행마다 실행하는데, concert_artist에는 UNIQUE (concert_id, artist_id) 복합 인덱스만 있어 선행 컬럼이 concert_id인 탓에 artist_id 단독 조건에는 쓰이지 못했다. V27에서 같은 이유로 user_follow_artist.artist_id, release_group.artist_id에 추가했던 것과 동일한 패턴. Data 레포 아티스트 수집 API 15분 51초 지연 장애의 근본 원인 중 하나이며, Data #82(statement_timeout 설정)가 이 인덱스 적용을 전제로 진행 중이다. Co-Authored-By: Claude Sonnet 4.6 --- .../db/migration/V29__add_concert_artist_artist_id_index.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql diff --git a/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql new file mode 100644 index 0000000..7a2f76d --- /dev/null +++ b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql @@ -0,0 +1 @@ +CREATE INDEX idx_concert_artist_artist_id ON concert_artist (artist_id); From d921afd15c8742719f5cb33c5d9c4b5b29205890 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 6 Sep 2026 14:05:39 +0900 Subject: [PATCH 03/19] =?UTF-8?q?[fix]=20WebClient=EC=97=90=20connectTimeo?= =?UTF-8?q?ut/responseTimeout=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataPipelineClient의 .block() 호출이 타임아웃 없이 무제한 대기하고 있었다. reactor-netty HttpClient에 connectTimeout 5s, responseTimeout 120s를 설정해 Data 파이프라인 응답 지연이 무한 대기로 이어지지 않도록 방어선을 둔다. (근본 원인은 Data #82에서 별도 처리 중이며, 이 설정은 어드민이 무한정 대기 상태에 빠지는 것을 막는 임시 안전장치) --- .../Backend/common/config/WebClientConfig.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java b/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java index 694ec5a..9906da9 100644 --- a/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java @@ -1,14 +1,27 @@ package com.Coming.Backend.common.config; +import io.netty.channel.ChannelOption; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; @Configuration public class WebClientConfig { + private static final int CONNECT_TIMEOUT_MILLIS = 5000; + private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(120); + @Bean public WebClient.Builder webClientBuilder() { - return WebClient.builder(); + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MILLIS) + .responseTimeout(RESPONSE_TIMEOUT); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)); } } From 24d4a771319b1b9c26bdfbcea77c5cf4b9ed9024 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 6 Sep 2026 14:10:57 +0900 Subject: [PATCH 04/19] =?UTF-8?q?[fix]=20PIPELINE=5FTIMEOUT=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=8B=A0=EC=84=A4=20=EB=B0=8F=20DataPipelineClient?= =?UTF-8?q?=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebClient의 connectTimeout/responseTimeout 초과 시 ConnectTimeoutException /ReadTimeoutException(io.netty.handler.timeout.TimeoutException)이 WebClientRequestException에 감싸여 전달된다. 이를 PipelineTimeoutException (504)으로 매핑해 어드민이 원인 불명의 예외 대신 명확한 타임아웃 응답을 받도록 한다. --- .../admin/client/DataPipelineClient.java | 20 +++++++ .../admin/controller/AdminController.java | 4 ++ .../exception/PipelineTimeoutException.java | 11 ++++ .../Backend/common/exception/ErrorCode.java | 1 + .../admin/client/DataPipelineClientTest.java | 59 +++++++++++++++++++ 5 files changed, 95 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/admin/exception/PipelineTimeoutException.java create mode 100644 src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java diff --git a/src/main/java/com/Coming/Backend/admin/client/DataPipelineClient.java b/src/main/java/com/Coming/Backend/admin/client/DataPipelineClient.java index 72e2600..124343d 100644 --- a/src/main/java/com/Coming/Backend/admin/client/DataPipelineClient.java +++ b/src/main/java/com/Coming/Backend/admin/client/DataPipelineClient.java @@ -8,6 +8,9 @@ import com.Coming.Backend.admin.exception.PipelineConflictException; import com.Coming.Backend.admin.exception.PipelineNotFoundException; import com.Coming.Backend.admin.exception.PipelineServerException; +import com.Coming.Backend.admin.exception.PipelineTimeoutException; +import io.netty.channel.ConnectTimeoutException; +import io.netty.handler.timeout.TimeoutException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; @@ -44,6 +47,7 @@ public List searchArtists(String name) { .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) .doOnError(e -> log.warn("Data pipeline artist search failed: name={}, error={}", name, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -56,6 +60,7 @@ public List searchConcerts(String title) { .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) .doOnError(e -> log.warn("Data pipeline concert search failed: title={}, error={}", title, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -64,6 +69,7 @@ public List searchConcerts(String title) { * * @throws PipelineNotFoundException MusicBrainz에 해당 MBID가 없는 경우 * @throws PipelineConflictException 동일 MBID에 대한 수집이 이미 처리 중인 경우 + * @throws PipelineTimeoutException Data 파이프라인 응답이 설정된 시간 내에 오지 않은 경우 */ public PipelineArtistCollectResult collectArtist(String mbid) { return webClient.post() @@ -77,6 +83,7 @@ public PipelineArtistCollectResult collectArtist(String mbid) { .bodyToMono(PipelineArtistCollectResult.class) .doOnSuccess(r -> log.info("Artist collect completed: mbid={}, success={}", mbid, r.success())) .doOnError(e -> log.warn("Artist collect failed: mbid={}, error={}", mbid, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -85,6 +92,7 @@ public PipelineArtistCollectResult collectArtist(String mbid) { * * @throws PipelineNotFoundException KOPIS에 해당 ID가 없는 경우 * @throws PipelineConflictException 동일 KOPIS ID에 대한 수집이 이미 처리 중인 경우 + * @throws PipelineTimeoutException Data 파이프라인 응답이 설정된 시간 내에 오지 않은 경우 */ public PipelineConcertCollectResult collectConcert(String kopisId) { return webClient.post() @@ -98,6 +106,7 @@ public PipelineConcertCollectResult collectConcert(String kopisId) { .bodyToMono(PipelineConcertCollectResult.class) .doOnSuccess(r -> log.info("Concert collect completed: kopisId={}, success={}", kopisId, r.success())) .doOnError(e -> log.warn("Concert collect failed: kopisId={}, error={}", kopisId, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -111,6 +120,7 @@ public void triggerArtistReleases(Long artistId) { .toBodilessEntity() .doOnSuccess(r -> log.info("Data pipeline artist releases triggered: artistId={}", artistId)) .doOnError(e -> log.warn("Data pipeline artist releases failed: artistId={}, error={}", artistId, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -119,6 +129,7 @@ public void triggerArtistReleases(Long artistId) { * * @throws PipelineNotFoundException setlist.fm에 해당 공연의 셋리스트가 없는 경우 * @throws PipelineConflictException 동일 공연에 대한 수집이 이미 처리 중인 경우 + * @throws PipelineTimeoutException Data 파이프라인 응답이 설정된 시간 내에 오지 않은 경우 */ public PipelineSetlistCollectResult collectConcertSetlist(Long concertId) { return webClient.post() @@ -130,6 +141,15 @@ public PipelineSetlistCollectResult collectConcertSetlist(Long concertId) { .bodyToMono(PipelineSetlistCollectResult.class) .doOnSuccess(r -> log.info("Setlist collect completed: concertId={}, success={}", concertId, r.success())) .doOnError(e -> log.warn("Setlist collect failed: concertId={}, error={}", concertId, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } + + // WebClient의 connectTimeout/responseTimeout 초과는 각각 ConnectTimeoutException, + // ReadTimeoutException(io.netty.handler.timeout.TimeoutException)으로 전달되며, + // 둘 다 원본 예외를 감싸는 WebClientRequestException의 cause로 담겨온다. + private static boolean isTimeout(Throwable e) { + Throwable cause = e.getCause(); + return cause instanceof TimeoutException || cause instanceof ConnectTimeoutException; + } } diff --git a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java index 1763464..94d7f97 100644 --- a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java +++ b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java @@ -235,6 +235,7 @@ public ResponseEntity> searchConcerts(@RequestPara @Operation(summary = "MBID 기반 아티스트 동기 수집") @ApiResponse(responseCode = "404", description = "PIPELINE_NOT_FOUND") @ApiResponse(responseCode = "409", description = "PIPELINE_CONFLICT") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @PostMapping("/data/collect/artists") public ResponseEntity collectArtist(@RequestBody @Valid AdminArtistCollectRequest request) { return ResponseEntity.ok(adminService.collectArtist(request)); @@ -243,6 +244,7 @@ public ResponseEntity collectArtist(@RequestBody @V @Operation(summary = "KOPIS ID 기반 공연 동기 수집") @ApiResponse(responseCode = "404", description = "PIPELINE_NOT_FOUND") @ApiResponse(responseCode = "409", description = "PIPELINE_CONFLICT") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @PostMapping("/data/collect/concerts") public ResponseEntity collectConcert(@RequestBody @Valid AdminConcertCollectRequest request) { return ResponseEntity.ok(adminService.collectConcert(request)); @@ -250,6 +252,7 @@ public ResponseEntity collectConcert(@RequestBody @Operation(summary = "Data 파이프라인 아티스트 릴리즈 수집 트리거") @ApiResponse(responseCode = "404", description = "ARTIST_NOT_FOUND (Data 파이프라인 측)") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @PostMapping("/data/collect/artists/{id}/releases") public ResponseEntity triggerArtistReleases(@PathVariable Long id) { adminService.triggerArtistReleases(id); @@ -259,6 +262,7 @@ public ResponseEntity triggerArtistReleases(@PathVariable Long id) { @Operation(summary = "공연 셋리스트 동기 수집") @ApiResponse(responseCode = "404", description = "PIPELINE_NOT_FOUND") @ApiResponse(responseCode = "409", description = "PIPELINE_CONFLICT") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @PostMapping("/data/collect/concerts/{id}/setlist") public ResponseEntity triggerConcertSetlist(@PathVariable Long id) { return ResponseEntity.ok(adminService.triggerConcertSetlist(id)); diff --git a/src/main/java/com/Coming/Backend/admin/exception/PipelineTimeoutException.java b/src/main/java/com/Coming/Backend/admin/exception/PipelineTimeoutException.java new file mode 100644 index 0000000..2f2a22a --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/exception/PipelineTimeoutException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.admin.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PipelineTimeoutException extends BusinessException { + + public PipelineTimeoutException() { + super(ErrorCode.PIPELINE_TIMEOUT); + } +} diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 2c44c3f..8580fad 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -53,6 +53,7 @@ public enum ErrorCode { PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다."), PIPELINE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Data 파이프라인 서버 오류가 발생했습니다."), + PIPELINE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "Data 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."), // Common RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), diff --git a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java new file mode 100644 index 0000000..7a3c2d7 --- /dev/null +++ b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java @@ -0,0 +1,59 @@ +package com.Coming.Backend.admin.client; + +import com.Coming.Backend.admin.exception.PipelineTimeoutException; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DataPipelineClientTest { + + private HttpServer server; + private DataPipelineClient dataPipelineClient; + + @BeforeEach + void setUp() throws IOException { + // given: 지정된 응답 타임아웃(50ms)보다 오래(300ms) 지연 응답하는 로컬 서버 + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/search/artists", exchange -> { + try { + TimeUnit.MILLISECONDS.sleep(300); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + byte[] body = "[]".getBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + HttpClient httpClient = HttpClient.create().responseTimeout(Duration.ofMillis(50)); + WebClient.Builder builder = WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)); + dataPipelineClient = new DataPipelineClient(builder, "http://localhost:" + server.getAddress().getPort(), "secret"); + } + + @AfterEach + void tearDown() { + server.stop(0); + } + + @Test + void should_throwPipelineTimeoutException_when_responseExceedsConfiguredTimeout() { + // when + // then + assertThatThrownBy(() -> dataPipelineClient.searchArtists("test")) + .isInstanceOf(PipelineTimeoutException.class); + } +} From 5e4eba3280d85eda1177aa1a5fbf2f24d340091a Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 6 Sep 2026 14:11:28 +0900 Subject: [PATCH 05/19] =?UTF-8?q?[fix]=20PIPELINE=5FCONFLICT=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 409 발생 시 관리자가 즉시 재시도해도 소용없다는 사실을 알 수 없어 계속 재시도만 반복하는 상황이 있었다. "잠시 후 다시 확인해주세요" 안내를 덧붙여 재시도 대신 대기하도록 유도한다. --- .../java/com/Coming/Backend/common/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 8580fad..dfd5f65 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -51,7 +51,7 @@ public enum ErrorCode { // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), - PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다."), + PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."), PIPELINE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Data 파이프라인 서버 오류가 발생했습니다."), PIPELINE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "Data 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."), From 695bcb5a90909d9aa7a816337c9db12d85b630c6 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 6 Sep 2026 14:13:28 +0900 Subject: [PATCH 06/19] =?UTF-8?q?[feat]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A7=91=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=97=90=20Redis=20mbid=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20dedup=20=EB=9D=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data 파이프라인 응답이 지연되는 동안 어드민이 반복 재시도하면 매번 PIPELINE_CONFLICT만 반환하고 아무것도 처리하지 못하는 상태에 빠졌다. 동일 mbid에 대한 요청을 BE 선에서 짧은 TTL(150s, responseTimeout 120s보다 여유 있게) 락으로 즉시 차단해, Data까지 요청이 가기 전에 중복을 걸러낸다. 정상 흐름은 finally에서 명시적으로 unlock하고, TTL은 서버 재시작 등 예외 상황의 안전망 역할만 한다. --- .../ArtistCollectLockRepository.java | 30 ++++++++ .../Backend/admin/service/AdminService.java | 16 ++++- .../ArtistCollectLockRepositoryTest.java | 69 +++++++++++++++++++ .../admin/service/AdminServiceTest.java | 33 +++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java create mode 100644 src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java diff --git a/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java b/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java new file mode 100644 index 0000000..95aef26 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java @@ -0,0 +1,30 @@ +package com.Coming.Backend.admin.repository; + +import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistCollectLockRepository { + + private static final String KEY_PREFIX = "ARTIST_COLLECT_LOCK:"; + // WebClient responseTimeout(120s)보다 여유 있게 잡아, 정상 흐름에서는 항상 명시적 unlock으로 + // 해제되고 TTL은 서버 재시작 등 예외 상황의 안전망 역할만 한다. + private static final long TTL_SECONDS = 150; + + private final RedisTemplate redisTemplate; + + public ArtistCollectLockRepository(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public boolean tryLock(String mbid) { + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(KEY_PREFIX + mbid, "1", TTL_SECONDS, TimeUnit.SECONDS); + return Boolean.TRUE.equals(acquired); + } + + public void unlock(String mbid) { + redisTemplate.delete(KEY_PREFIX + mbid); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/service/AdminService.java b/src/main/java/com/Coming/Backend/admin/service/AdminService.java index 7164b20..8a5523a 100644 --- a/src/main/java/com/Coming/Backend/admin/service/AdminService.java +++ b/src/main/java/com/Coming/Backend/admin/service/AdminService.java @@ -25,6 +25,8 @@ import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; +import com.Coming.Backend.admin.exception.PipelineConflictException; +import com.Coming.Backend.admin.repository.ArtistCollectLockRepository; import com.Coming.Backend.artist.entity.Artist; import com.Coming.Backend.artist.entity.ArtistAlias; import com.Coming.Backend.artist.entity.ArtistUrl; @@ -89,6 +91,7 @@ public class AdminService { private final ConcertBookingLinkRepository concertBookingLinkRepository; private final ConcertImageRepository concertImageRepository; private final DataPipelineClient dataPipelineClient; + private final ArtistCollectLockRepository artistCollectLockRepository; /** * 어드민 아티스트 단건을 조회한다. 존재하지 않는 아티스트 ID이면 ArtistNotFoundException을 던진다. @@ -645,9 +648,20 @@ public List searchConcerts(String title) { /** * MBID 기반으로 아티스트를 동기 수집한다. 수집 결과를 반환한다. + * 동일 MBID에 대한 요청이 이미 처리 중이면 Data 파이프라인까지 가지 않고 즉시 거부한다. + * + * @throws PipelineConflictException 동일 MBID에 대한 수집 요청이 이미 처리 중인 경우 */ public PipelineArtistCollectResult collectArtist(AdminArtistCollectRequest request) { - return dataPipelineClient.collectArtist(request.mbid()); + String mbid = request.mbid(); + if (!artistCollectLockRepository.tryLock(mbid)) { + throw new PipelineConflictException(); + } + try { + return dataPipelineClient.collectArtist(mbid); + } finally { + artistCollectLockRepository.unlock(mbid); + } } /** diff --git a/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java new file mode 100644 index 0000000..b286788 --- /dev/null +++ b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java @@ -0,0 +1,69 @@ +package com.Coming.Backend.admin.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class ArtistCollectLockRepositoryTest { + + @InjectMocks + private ArtistCollectLockRepository artistCollectLockRepository; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Test + void should_return_true_when_tryLock_with_unheld_mbid() { + // given + String mbid = "some-mbid-123"; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.setIfAbsent("ARTIST_COLLECT_LOCK:" + mbid, "1", 150, TimeUnit.SECONDS)) + .willReturn(true); + + // when + boolean result = artistCollectLockRepository.tryLock(mbid); + + // then + assertThat(result).isTrue(); + } + + @Test + void should_return_false_when_tryLock_with_already_held_mbid() { + // given + String mbid = "some-mbid-123"; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.setIfAbsent("ARTIST_COLLECT_LOCK:" + mbid, "1", 150, TimeUnit.SECONDS)) + .willReturn(false); + + // when + boolean result = artistCollectLockRepository.tryLock(mbid); + + // then + assertThat(result).isFalse(); + } + + @Test + void should_delete_key_when_unlock_called() { + // given + String mbid = "some-mbid-123"; + + // when + artistCollectLockRepository.unlock(mbid); + + // then + verify(redisTemplate).delete("ARTIST_COLLECT_LOCK:" + mbid); + } +} diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index ac34a8d..1cec241 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -22,6 +22,8 @@ import com.Coming.Backend.admin.dto.PipelineArtistCollectResult; import com.Coming.Backend.admin.dto.PipelineConcertCollectResult; import com.Coming.Backend.admin.dto.AdminArtistDetailResponse; +import com.Coming.Backend.admin.exception.PipelineConflictException; +import com.Coming.Backend.admin.repository.ArtistCollectLockRepository; import com.Coming.Backend.artist.entity.Artist; import com.Coming.Backend.artist.entity.ArtistAlias; import com.Coming.Backend.artist.entity.ArtistUrl; @@ -122,6 +124,9 @@ class AdminServiceTest { @Mock private DataPipelineClient dataPipelineClient; + @Mock + private ArtistCollectLockRepository artistCollectLockRepository; + private static final Long USER_ID = 10L; private static final Long INQUIRY_ID = 1L; private static final Long TARGET_ID = 100L; @@ -1661,6 +1666,7 @@ void should_trigger_artist_collect_when_mbid_given() { // given AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); PipelineArtistCollectResult result = new PipelineArtistCollectResult(true, 1L, "some-mbid-123", "IU", null, List.of(), null); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(true); given(dataPipelineClient.collectArtist("some-mbid-123")).willReturn(result); // when @@ -1668,9 +1674,36 @@ void should_trigger_artist_collect_when_mbid_given() { // then verify(dataPipelineClient).collectArtist("some-mbid-123"); + verify(artistCollectLockRepository).unlock("some-mbid-123"); assertThat(actual.success()).isTrue(); } + @Test + void should_throw_PipelineConflictException_when_lock_already_held_by_another_request() { + // given + AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(false); + + // when & then + assertThatThrownBy(() -> adminService.collectArtist(request)) + .isInstanceOf(PipelineConflictException.class); + verify(dataPipelineClient, never()).collectArtist(any()); + verify(artistCollectLockRepository, never()).unlock(any()); + } + + @Test + void should_release_lock_when_dataPipelineClient_throws() { + // given + AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(true); + given(dataPipelineClient.collectArtist("some-mbid-123")).willThrow(new PipelineConflictException()); + + // when & then + assertThatThrownBy(() -> adminService.collectArtist(request)) + .isInstanceOf(PipelineConflictException.class); + verify(artistCollectLockRepository).unlock("some-mbid-123"); + } + // ------------------------------------------------------------------------- // collectConcert // ------------------------------------------------------------------------- From f8eecc7ea726b0214a8738c8ecc9d7db32d78ec8 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 15:57:40 +0900 Subject: [PATCH 07/19] =?UTF-8?q?[test]=20#113=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20snake=5Fcase=20=EC=BB=A8=EB=B2=A4=EC=85=98?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #115 코드 리뷰에서 지적된 camelCase/PascalCase 세그먼트 혼용을 should_{결과}_{when|given}_{조건} 컨벤션에 맞춰 통일한다. --- .../Coming/Backend/admin/client/DataPipelineClientTest.java | 2 +- .../admin/repository/ArtistCollectLockRepositoryTest.java | 4 ++-- .../com/Coming/Backend/admin/service/AdminServiceTest.java | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java index 7a3c2d7..9fd181c 100644 --- a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java +++ b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java @@ -50,7 +50,7 @@ void tearDown() { } @Test - void should_throwPipelineTimeoutException_when_responseExceedsConfiguredTimeout() { + void should_throw_pipeline_timeout_exception_when_response_exceeds_configured_timeout() { // when // then assertThatThrownBy(() -> dataPipelineClient.searchArtists("test")) diff --git a/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java index b286788..caa16f2 100644 --- a/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java @@ -26,7 +26,7 @@ class ArtistCollectLockRepositoryTest { private ValueOperations valueOperations; @Test - void should_return_true_when_tryLock_with_unheld_mbid() { + void should_return_true_when_try_lock_with_unheld_mbid() { // given String mbid = "some-mbid-123"; given(redisTemplate.opsForValue()).willReturn(valueOperations); @@ -41,7 +41,7 @@ void should_return_true_when_tryLock_with_unheld_mbid() { } @Test - void should_return_false_when_tryLock_with_already_held_mbid() { + void should_return_false_when_try_lock_with_already_held_mbid() { // given String mbid = "some-mbid-123"; given(redisTemplate.opsForValue()).willReturn(valueOperations); diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index 1cec241..a922d56 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -1679,7 +1679,7 @@ void should_trigger_artist_collect_when_mbid_given() { } @Test - void should_throw_PipelineConflictException_when_lock_already_held_by_another_request() { + void should_throw_pipeline_conflict_exception_when_lock_already_held_by_another_request() { // given AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(false); @@ -1692,7 +1692,7 @@ void should_throw_PipelineConflictException_when_lock_already_held_by_another_re } @Test - void should_release_lock_when_dataPipelineClient_throws() { + void should_release_lock_when_data_pipeline_client_throws() { // given AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(true); From a5b14d4548f8efa67f8f678a9cfb194f3b2ca452 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 15:58:25 +0900 Subject: [PATCH 08/19] =?UTF-8?q?[style]=20#113=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20100=EC=9E=90?= =?UTF-8?q?=20=EC=B4=88=EA=B3=BC=20=EB=9D=BC=EC=9D=B8=20=EA=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #115 코드 리뷰에서 지적된 5개 라인(101~133자)을 개행해 100자 제한을 맞춘다. --- .../Coming/Backend/admin/client/DataPipelineClientTest.java | 6 ++++-- .../admin/repository/ArtistCollectLockRepositoryTest.java | 6 ++++-- .../com/Coming/Backend/admin/service/AdminServiceTest.java | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java index 9fd181c..cea3042 100644 --- a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java +++ b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java @@ -40,8 +40,10 @@ void setUp() throws IOException { server.start(); HttpClient httpClient = HttpClient.create().responseTimeout(Duration.ofMillis(50)); - WebClient.Builder builder = WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)); - dataPipelineClient = new DataPipelineClient(builder, "http://localhost:" + server.getAddress().getPort(), "secret"); + WebClient.Builder builder = WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)); + String baseUrl = "http://localhost:" + server.getAddress().getPort(); + dataPipelineClient = new DataPipelineClient(builder, baseUrl, "secret"); } @AfterEach diff --git a/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java index caa16f2..4303a6b 100644 --- a/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java @@ -30,7 +30,8 @@ void should_return_true_when_try_lock_with_unheld_mbid() { // given String mbid = "some-mbid-123"; given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.setIfAbsent("ARTIST_COLLECT_LOCK:" + mbid, "1", 150, TimeUnit.SECONDS)) + given(valueOperations.setIfAbsent( + "ARTIST_COLLECT_LOCK:" + mbid, "1", 150, TimeUnit.SECONDS)) .willReturn(true); // when @@ -45,7 +46,8 @@ void should_return_false_when_try_lock_with_already_held_mbid() { // given String mbid = "some-mbid-123"; given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.setIfAbsent("ARTIST_COLLECT_LOCK:" + mbid, "1", 150, TimeUnit.SECONDS)) + given(valueOperations.setIfAbsent( + "ARTIST_COLLECT_LOCK:" + mbid, "1", 150, TimeUnit.SECONDS)) .willReturn(false); // when diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index a922d56..b15b5a1 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -1665,7 +1665,8 @@ void should_return_empty_list_when_no_concerts_match() { void should_trigger_artist_collect_when_mbid_given() { // given AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); - PipelineArtistCollectResult result = new PipelineArtistCollectResult(true, 1L, "some-mbid-123", "IU", null, List.of(), null); + PipelineArtistCollectResult result = new PipelineArtistCollectResult( + true, 1L, "some-mbid-123", "IU", null, List.of(), null); given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(true); given(dataPipelineClient.collectArtist("some-mbid-123")).willReturn(result); @@ -1696,7 +1697,8 @@ void should_release_lock_when_data_pipeline_client_throws() { // given AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(true); - given(dataPipelineClient.collectArtist("some-mbid-123")).willThrow(new PipelineConflictException()); + given(dataPipelineClient.collectArtist("some-mbid-123")) + .willThrow(new PipelineConflictException()); // when & then assertThatThrownBy(() -> adminService.collectArtist(request)) From 706d6fed6f1bfbbb49caa7cc6708917e01d859ec Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 15:59:42 +0900 Subject: [PATCH 09/19] =?UTF-8?q?[test]=20DataPipelineClient.collectArtist?= =?UTF-8?q?=EC=9D=98=20404/409/500=20onStatus=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=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 PR #115 코드 리뷰에서 지적된, onErrorMap 추가로 손대게 된 기존 onStatus 분기(404→PipelineNotFoundException, 409→PipelineConflictException, 5xx→PipelineServerException)에 대한 회귀 테스트가 없던 공백을 메운다. --- .../admin/client/DataPipelineClientTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java index cea3042..0b87ee6 100644 --- a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java +++ b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java @@ -1,5 +1,8 @@ package com.Coming.Backend.admin.client; +import com.Coming.Backend.admin.exception.PipelineConflictException; +import com.Coming.Backend.admin.exception.PipelineNotFoundException; +import com.Coming.Backend.admin.exception.PipelineServerException; import com.Coming.Backend.admin.exception.PipelineTimeoutException; import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.AfterEach; @@ -11,6 +14,7 @@ import java.io.IOException; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.concurrent.TimeUnit; @@ -37,6 +41,16 @@ void setUp() throws IOException { exchange.getResponseBody().write(body); exchange.close(); }); + // given: mbid에 따라 404/409/500을 돌려주는 /collect/artist 엔드포인트 + server.createContext("/collect/artist", exchange -> { + byte[] rawBody = exchange.getRequestBody().readAllBytes(); + String requestBody = new String(rawBody, StandardCharsets.UTF_8); + int status = requestBody.contains("not-found-mbid") ? 404 + : requestBody.contains("conflict-mbid") ? 409 + : 500; + exchange.sendResponseHeaders(status, -1); + exchange.close(); + }); server.start(); HttpClient httpClient = HttpClient.create().responseTimeout(Duration.ofMillis(50)); @@ -58,4 +72,25 @@ void should_throw_pipeline_timeout_exception_when_response_exceeds_configured_ti assertThatThrownBy(() -> dataPipelineClient.searchArtists("test")) .isInstanceOf(PipelineTimeoutException.class); } + + @Test + void should_throw_pipeline_not_found_exception_when_data_pipeline_returns_404() { + // when & then + assertThatThrownBy(() -> dataPipelineClient.collectArtist("not-found-mbid")) + .isInstanceOf(PipelineNotFoundException.class); + } + + @Test + void should_throw_pipeline_conflict_exception_when_data_pipeline_returns_409() { + // when & then + assertThatThrownBy(() -> dataPipelineClient.collectArtist("conflict-mbid")) + .isInstanceOf(PipelineConflictException.class); + } + + @Test + void should_throw_pipeline_server_exception_when_data_pipeline_returns_500() { + // when & then + assertThatThrownBy(() -> dataPipelineClient.collectArtist("error-mbid")) + .isInstanceOf(PipelineServerException.class); + } } From 265eea55f36234c24701c43472f8449f43c7b0a8 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 16:06:57 +0900 Subject: [PATCH 10/19] =?UTF-8?q?[fix]=20Data=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EC=97=90=20504=20?= =?UTF-8?q?PIPELINE=5FTIMEOUT=20=EC=9D=91=EB=8B=B5=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 리뷰 지적사항 반영: searchArtists/searchConcerts도 DataPipelineClient의 onErrorMap으로 PipelineTimeoutException(504)을 던질 수 있는데, 수집 엔드포인트들과 달리 @ApiResponse 문서화가 누락돼 있었다. --- .../com/Coming/Backend/admin/controller/AdminController.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java index 94d7f97..218b54c 100644 --- a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java +++ b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java @@ -221,12 +221,14 @@ public ResponseEntity removeArtistFromConcert( } @Operation(summary = "Data 파이프라인 아티스트 검색") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @GetMapping("/data/search/artists") public ResponseEntity> searchArtists(@RequestParam String name) { return ResponseEntity.ok(adminService.searchArtists(name)); } @Operation(summary = "Data 파이프라인 공연 검색") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @GetMapping("/data/search/concerts") public ResponseEntity> searchConcerts(@RequestParam String title) { return ResponseEntity.ok(adminService.searchConcerts(title)); From d591c5390c9378b4417b71a84851b0ff41ffc76e Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 16:08:54 +0900 Subject: [PATCH 11/19] =?UTF-8?q?[fix]=20ArtistCollectLockRepository.unloc?= =?UTF-8?q?k=EC=9D=84=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 리뷰 지적사항 반영: TTL(150s)이 만료되기 전에 요청 처리가 더 걸리면(이 시스템은 실측 15분 51초 지연 이력이 있다) 다른 요청이 같은 mbid 락을 선점할 수 있는데, 기존 unlock(mbid)은 조건 없이 키를 지워 이전 보유자가 새 보유자의 락을 지워버리고 3번째 요청이 중복 수집을 시작할 수 있었다. tryLock이 boolean 대신 락 소유를 식별하는 토큰(UUID)을 반환하고, unlock(mbid, token)은 Redis Lua 스크립트로 저장된 토큰이 일치할 때만 원자적으로 삭제한다. --- .../ArtistCollectLockRepository.java | 31 ++++++++++++++--- .../Backend/admin/service/AdminService.java | 5 +-- .../ArtistCollectLockRepositoryTest.java | 34 +++++++++++++------ .../admin/service/AdminServiceTest.java | 12 +++---- 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java b/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java index 95aef26..d11418f 100644 --- a/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java +++ b/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java @@ -1,7 +1,11 @@ package com.Coming.Backend.admin.repository; +import java.util.List; +import java.util.UUID; import java.util.concurrent.TimeUnit; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Repository; @Repository @@ -12,19 +16,36 @@ public class ArtistCollectLockRepository { // 해제되고 TTL은 서버 재시작 등 예외 상황의 안전망 역할만 한다. private static final long TTL_SECONDS = 150; + // 저장된 토큰이 일치할 때만 삭제한다. TTL 만료 후 다른 요청이 같은 키를 선점했다면, + // 이전 보유자의 unlock이 그 새 락을 실수로 지우지 않도록 막는다. + private static final RedisScript UNLOCK_SCRIPT = new DefaultRedisScript<>( + "if redis.call('get', KEYS[1]) == ARGV[1] then " + + "return redis.call('del', KEYS[1]) " + + "else return 0 end", + Long.class); + private final RedisTemplate redisTemplate; public ArtistCollectLockRepository(RedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } - public boolean tryLock(String mbid) { + /** + * mbid에 대한 락을 선점한다. + * + * @return 선점에 성공하면 이 호출을 식별하는 토큰, 이미 다른 요청이 보유 중이면 null + */ + public String tryLock(String mbid) { + String token = UUID.randomUUID().toString(); Boolean acquired = redisTemplate.opsForValue() - .setIfAbsent(KEY_PREFIX + mbid, "1", TTL_SECONDS, TimeUnit.SECONDS); - return Boolean.TRUE.equals(acquired); + .setIfAbsent(KEY_PREFIX + mbid, token, TTL_SECONDS, TimeUnit.SECONDS); + return Boolean.TRUE.equals(acquired) ? token : null; } - public void unlock(String mbid) { - redisTemplate.delete(KEY_PREFIX + mbid); + /** + * 전달된 토큰이 현재 저장된 값과 일치할 때만 락을 해제한다. + */ + public void unlock(String mbid, String token) { + redisTemplate.execute(UNLOCK_SCRIPT, List.of(KEY_PREFIX + mbid), token); } } diff --git a/src/main/java/com/Coming/Backend/admin/service/AdminService.java b/src/main/java/com/Coming/Backend/admin/service/AdminService.java index 8a5523a..8177bef 100644 --- a/src/main/java/com/Coming/Backend/admin/service/AdminService.java +++ b/src/main/java/com/Coming/Backend/admin/service/AdminService.java @@ -654,13 +654,14 @@ public List searchConcerts(String title) { */ public PipelineArtistCollectResult collectArtist(AdminArtistCollectRequest request) { String mbid = request.mbid(); - if (!artistCollectLockRepository.tryLock(mbid)) { + String lockToken = artistCollectLockRepository.tryLock(mbid); + if (lockToken == null) { throw new PipelineConflictException(); } try { return dataPipelineClient.collectArtist(mbid); } finally { - artistCollectLockRepository.unlock(mbid); + artistCollectLockRepository.unlock(mbid, lockToken); } } diff --git a/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java index 4303a6b..c83c7bf 100644 --- a/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java @@ -1,17 +1,22 @@ package com.Coming.Backend.admin.repository; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import java.util.List; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.script.RedisScript; @ExtendWith(MockitoExtension.class) class ArtistCollectLockRepositoryTest { @@ -26,46 +31,53 @@ class ArtistCollectLockRepositoryTest { private ValueOperations valueOperations; @Test - void should_return_true_when_try_lock_with_unheld_mbid() { + void should_return_generated_token_when_try_lock_with_unheld_mbid() { // given String mbid = "some-mbid-123"; given(redisTemplate.opsForValue()).willReturn(valueOperations); given(valueOperations.setIfAbsent( - "ARTIST_COLLECT_LOCK:" + mbid, "1", 150, TimeUnit.SECONDS)) + eq("ARTIST_COLLECT_LOCK:" + mbid), any(), eq(150L), eq(TimeUnit.SECONDS))) .willReturn(true); // when - boolean result = artistCollectLockRepository.tryLock(mbid); + String token = artistCollectLockRepository.tryLock(mbid); // then - assertThat(result).isTrue(); + assertThat(token).isNotNull(); + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(String.class); + verify(valueOperations).setIfAbsent( + eq("ARTIST_COLLECT_LOCK:" + mbid), tokenCaptor.capture(), + eq(150L), eq(TimeUnit.SECONDS)); + assertThat(tokenCaptor.getValue()).isEqualTo(token); } @Test - void should_return_false_when_try_lock_with_already_held_mbid() { + void should_return_null_when_try_lock_with_already_held_mbid() { // given String mbid = "some-mbid-123"; given(redisTemplate.opsForValue()).willReturn(valueOperations); given(valueOperations.setIfAbsent( - "ARTIST_COLLECT_LOCK:" + mbid, "1", 150, TimeUnit.SECONDS)) + eq("ARTIST_COLLECT_LOCK:" + mbid), any(), eq(150L), eq(TimeUnit.SECONDS))) .willReturn(false); // when - boolean result = artistCollectLockRepository.tryLock(mbid); + String token = artistCollectLockRepository.tryLock(mbid); // then - assertThat(result).isFalse(); + assertThat(token).isNull(); } @Test - void should_delete_key_when_unlock_called() { + void should_execute_unlock_script_with_key_and_token_when_unlock_called() { // given String mbid = "some-mbid-123"; + String token = "token-abc"; // when - artistCollectLockRepository.unlock(mbid); + artistCollectLockRepository.unlock(mbid, token); // then - verify(redisTemplate).delete("ARTIST_COLLECT_LOCK:" + mbid); + verify(redisTemplate).execute( + any(RedisScript.class), eq(List.of("ARTIST_COLLECT_LOCK:" + mbid)), eq(token)); } } diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index b15b5a1..43e95a5 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -1667,7 +1667,7 @@ void should_trigger_artist_collect_when_mbid_given() { AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); PipelineArtistCollectResult result = new PipelineArtistCollectResult( true, 1L, "some-mbid-123", "IU", null, List.of(), null); - given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(true); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn("lock-token"); given(dataPipelineClient.collectArtist("some-mbid-123")).willReturn(result); // when @@ -1675,7 +1675,7 @@ void should_trigger_artist_collect_when_mbid_given() { // then verify(dataPipelineClient).collectArtist("some-mbid-123"); - verify(artistCollectLockRepository).unlock("some-mbid-123"); + verify(artistCollectLockRepository).unlock("some-mbid-123", "lock-token"); assertThat(actual.success()).isTrue(); } @@ -1683,27 +1683,27 @@ void should_trigger_artist_collect_when_mbid_given() { void should_throw_pipeline_conflict_exception_when_lock_already_held_by_another_request() { // given AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); - given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(false); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(null); // when & then assertThatThrownBy(() -> adminService.collectArtist(request)) .isInstanceOf(PipelineConflictException.class); verify(dataPipelineClient, never()).collectArtist(any()); - verify(artistCollectLockRepository, never()).unlock(any()); + verify(artistCollectLockRepository, never()).unlock(any(), any()); } @Test void should_release_lock_when_data_pipeline_client_throws() { // given AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); - given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(true); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn("lock-token"); given(dataPipelineClient.collectArtist("some-mbid-123")) .willThrow(new PipelineConflictException()); // when & then assertThatThrownBy(() -> adminService.collectArtist(request)) .isInstanceOf(PipelineConflictException.class); - verify(artistCollectLockRepository).unlock("some-mbid-123"); + verify(artistCollectLockRepository).unlock("some-mbid-123", "lock-token"); } // ------------------------------------------------------------------------- From 056083932a4ba6fbf97bdcc4ab0d9a51cc0c8a52 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 16:10:00 +0900 Subject: [PATCH 12/19] =?UTF-8?q?[fix]=20=EB=9D=BD=20=ED=95=B4=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EA=B0=80=20collectArtist=EC=9D=98=20?= =?UTF-8?q?=EC=9B=90=EB=9E=98=20=EA=B2=B0=EA=B3=BC=C2=B7=EC=98=88=EC=99=B8?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 리뷰 지적사항 반영: finally 블록에서 unlock(mbid, lockToken)이 예외를 던지면 Java의 finally 예외 대체 규칙에 따라 try 블록의 정상 반환값이나 PipelineTimeoutException 등 원래 예외가 통째로 사라지고 unlock의 예외로 대체된다. 이 PR에서 새로 추가한 PipelineTimeoutException 자체가 묻히는 상황을 막기 위해 unlock 호출을 try/catch로 감싸 로그만 남긴다. --- .../Backend/admin/service/AdminService.java | 8 ++++- .../admin/service/AdminServiceTest.java | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Coming/Backend/admin/service/AdminService.java b/src/main/java/com/Coming/Backend/admin/service/AdminService.java index 8177bef..00b5fcf 100644 --- a/src/main/java/com/Coming/Backend/admin/service/AdminService.java +++ b/src/main/java/com/Coming/Backend/admin/service/AdminService.java @@ -63,6 +63,7 @@ import com.Coming.Backend.inquiry.exception.InvalidInquiryStatusException; import com.Coming.Backend.inquiry.repository.InquiryRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -75,6 +76,7 @@ import java.util.Set; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -661,7 +663,11 @@ public PipelineArtistCollectResult collectArtist(AdminArtistCollectRequest reque try { return dataPipelineClient.collectArtist(mbid); } finally { - artistCollectLockRepository.unlock(mbid, lockToken); + try { + artistCollectLockRepository.unlock(mbid, lockToken); + } catch (Exception e) { + log.warn("Failed to release artist collect lock: mbid={}", mbid, e); + } } } diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index 43e95a5..d318cec 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -23,6 +23,7 @@ import com.Coming.Backend.admin.dto.PipelineConcertCollectResult; import com.Coming.Backend.admin.dto.AdminArtistDetailResponse; import com.Coming.Backend.admin.exception.PipelineConflictException; +import com.Coming.Backend.admin.exception.PipelineTimeoutException; import com.Coming.Backend.admin.repository.ArtistCollectLockRepository; import com.Coming.Backend.artist.entity.Artist; import com.Coming.Backend.artist.entity.ArtistAlias; @@ -82,6 +83,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -1706,6 +1708,39 @@ void should_release_lock_when_data_pipeline_client_throws() { verify(artistCollectLockRepository).unlock("some-mbid-123", "lock-token"); } + @Test + void should_return_pipeline_result_when_unlock_fails_after_success() { + // given + AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); + PipelineArtistCollectResult result = new PipelineArtistCollectResult( + true, 1L, "some-mbid-123", "IU", null, List.of(), null); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn("lock-token"); + given(dataPipelineClient.collectArtist("some-mbid-123")).willReturn(result); + willThrow(new RuntimeException("redis down")) + .given(artistCollectLockRepository).unlock("some-mbid-123", "lock-token"); + + // when + PipelineArtistCollectResult actual = adminService.collectArtist(request); + + // then - unlock 실패가 성공 결과를 가리지 않는다 + assertThat(actual.success()).isTrue(); + } + + @Test + void should_keep_original_exception_when_unlock_also_fails() { + // given + AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn("lock-token"); + given(dataPipelineClient.collectArtist("some-mbid-123")) + .willThrow(new PipelineTimeoutException()); + willThrow(new RuntimeException("redis down")) + .given(artistCollectLockRepository).unlock("some-mbid-123", "lock-token"); + + // when & then - unlock 실패가 원래 예외(PipelineTimeoutException)를 가리지 않는다 + assertThatThrownBy(() -> adminService.collectArtist(request)) + .isInstanceOf(PipelineTimeoutException.class); + } + // ------------------------------------------------------------------------- // collectConcert // ------------------------------------------------------------------------- From 6f2a9bbf3f92096416a1d737778e09a8cae400f5 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 16:11:23 +0900 Subject: [PATCH 13/19] =?UTF-8?q?[fix]=20concert=5Fartist(artist=5Fid)=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=83=9D=EC=84=B1=EC=9D=84=20CON?= =?UTF-8?q?CURRENTLY=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 리뷰 지적사항 반영: 이 서비스는 블루그린 배포(be-blue/be-green)로, 새 슬롯이 기동해 Flyway 마이그레이션을 실행하는 동안에도 기존 슬롯이 계속 트래픽을 처리하며 같은 테이블에 쓴다. 일반 CREATE INDEX는 SHARE 락으로 INSERT/UPDATE/DELETE를 막아 배포 중 쓰기가 멈출 수 있어, CONCURRENTLY로 전환하고 Flyway가 트랜잭션으로 감싸지 않도록 executeInTransaction=false를 지정했다. CONCURRENTLY는 트랜잭션 밖에서만 실행 가능하다. IF NOT EXISTS를 더해 재시도 시에도 멱등하게 만들었다. --- .../db/migration/V29__add_concert_artist_artist_id_index.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql index 7a2f76d..d01e3ff 100644 --- a/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql +++ b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql @@ -1 +1,2 @@ -CREATE INDEX idx_concert_artist_artist_id ON concert_artist (artist_id); +-- flyway:executeInTransaction=false +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_concert_artist_artist_id ON concert_artist (artist_id); From 1c6cb115dc98b1a78afe43f868c53ede054cc74e Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 17:27:01 +0900 Subject: [PATCH 14/19] =?UTF-8?q?[test]=20CI=20test=20job=EC=97=90=20timeo?= =?UTF-8?q?ut-minutes=20=EC=B6=94=EA=B0=80,=20DB=20=EC=BB=A4=EB=84=A5?= =?UTF-8?q?=EC=85=98=EC=97=90=20statement=5Ftimeout=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무한 hang 진단: V29 마이그레이션의 CREATE INDEX CONCURRENTLY가 원인인지 확인하기 위해 CI datasource에 statement_timeout=30s를 임시로 걸어, hang 대신 명확한 에러로 실패하도록 한다. timeout-minutes는 원인과 무관하게 hang이 나면 CI가 6시간까지 도는 것을 막는 안전장치. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JSfrB86HWyYHYPjvvytrgE --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f009e2..6275e0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 10 services: postgres: @@ -34,7 +35,8 @@ jobs: - 6379:6379 env: - DB_URL: jdbc:postgresql://localhost:5432/coming_test + # TODO: V29 마이그레이션의 CREATE INDEX CONCURRENTLY가 CI hang 원인인지 진단 후 이 옵션 제거 + DB_URL: jdbc:postgresql://localhost:5432/coming_test?options=-c%20statement_timeout%3D30000 DB_USERNAME: coming DB_PASSWORD: coming JWT_SECRET: dGVzdHNlY3JldGtleWZvcmNpbWluaW11bWxlbmd0aDMyY2hhcnM= From 2cc0137d0102bc40e81c0ffe103c471c2b424ecd Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 17:36:01 +0900 Subject: [PATCH 15/19] =?UTF-8?q?[fix]=20V29=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=EC=9D=98=20CREATE=20INDEX=20CONCURR?= =?UTF-8?q?ENTLY=20=EB=AC=B4=EA=B8=B0=ED=95=9C=20=EB=8C=80=EA=B8=B0?= =?UTF-8?q?=EC=97=90=20=EC=83=81=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI에서 statement_timeout=30s를 임시로 걸어 진단한 결과, V29의 CREATE INDEX CONCURRENTLY가 다른 세션의 트랜잭션 종료를 무기한 기다리며 :test 태스크를 11분 이상 hang시키는 것을 확인했다(Postgres 로그: "canceling statement due to statement timeout" on 해당 문장). CONCURRENTLY를 쓴 원래 의도(블루그린 배포 중 쓰기 안 막힘)를 지키면서도, 대기가 무한정 늘어지지 않도록 SET statement_timeout(300s)을 추가했다. Flyway는 기본적으로 한 스크립트 안에 트랜잭션/비트랜잭션 문장이 섞이는 것을 막으므로 spring.flyway.mixed=true도 함께 켰다. V29는 아직 로컬 개발 DB 외에는 적용된 적이 없어(main 미머지) 파일을 직접 수정했다. 로컬 flyway_schema_history의 V29 row는 삭제 후 재적용시켜 새 체크섬으로 갱신했다. CI 진단용으로 걸어뒀던 DB_URL의 statement_timeout 오버라이드는 원복하고 timeout-minutes 안전장치만 유지한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JSfrB86HWyYHYPjvvytrgE --- .github/workflows/ci.yml | 3 +-- src/main/resources/application.yaml | 3 +++ .../db/migration/V29__add_concert_artist_artist_id_index.sql | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6275e0c..956a7e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,7 @@ jobs: - 6379:6379 env: - # TODO: V29 마이그레이션의 CREATE INDEX CONCURRENTLY가 CI hang 원인인지 진단 후 이 옵션 제거 - DB_URL: jdbc:postgresql://localhost:5432/coming_test?options=-c%20statement_timeout%3D30000 + DB_URL: jdbc:postgresql://localhost:5432/coming_test DB_USERNAME: coming DB_PASSWORD: coming JWT_SECRET: dGVzdHNlY3JldGtleWZvcmNpbWluaW11bWxlbmd0aDMyY2hhcnM= diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b6ccd22..7e54b73 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -28,6 +28,9 @@ spring: locations: classpath:db/migration baseline-on-migrate: true baseline-version: 1 + # CONCURRENTLY DDL 앞에 SET statement_timeout을 두는 마이그레이션(V29)이 있어, + # 트랜잭션 문장과 비트랜잭션 문장이 한 스크립트에 섞이는 것을 허용해야 한다. + mixed: true security: oauth2: diff --git a/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql index d01e3ff..b65c6af 100644 --- a/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql +++ b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql @@ -1,2 +1,5 @@ -- flyway:executeInTransaction=false +-- CONCURRENTLY는 다른 세션의 트랜잭션이 끝날 때까지 무기한 대기할 수 있다. +-- 무한 대기로 배포/마이그레이션이 멈추지 않도록 상한을 둔다. +SET statement_timeout = '300s'; CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_concert_artist_artist_id ON concert_artist (artist_id); From 67a087c3a8fa277cd6bdddf29a08488ff2ef58cd Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 17:55:17 +0900 Subject: [PATCH 16/19] =?UTF-8?q?[test]=20CI=EC=97=90=20pg=5Fstat=5Factivi?= =?UTF-8?q?ty/pg=5Flocks=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=BA=A1?= =?UTF-8?q?=EC=B2=98=20=EC=A7=84=EB=8B=A8=20=EC=8A=A4=ED=85=9D=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 V29의 CREATE INDEX CONCURRENTLY가 매 CI 실행마다 100% 재현되게 5분 statement_timeout을 꽉 채우고 실패하는 게 확인됐다. 로컬에서는 전혀 재현되지 않아 CI 환경 고유의 원인으로 보이는데, 어떤 세션이 무엇을 쥐고 있는지 알아내기 위해 gradlew test를 백그라운드로 돌리는 동안 5초 간격으로 pg_stat_activity/pg_locks를 폴링해 로그에 남긴다. 원인 확인 후 제거할 진단용 커밋. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JSfrB86HWyYHYPjvvytrgE --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 956a7e9..ab577b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,8 +58,29 @@ jobs: distribution: temurin cache: gradle - - name: Run tests - run: ./gradlew test --no-daemon + # TODO(진단용, 원인 확인 후 제거): V29의 CREATE INDEX CONCURRENTLY가 + # CI에서만 무기한 대기하는 원인을 pg_stat_activity/pg_locks로 실시간 캡처한다. + - name: Run tests with live lock diagnostics + run: | + which psql || (sudo apt-get update -y && sudo apt-get install -y postgresql-client) + ( + for i in $(seq 1 60); do + echo "----- poll #$i $(date -u +%H:%M:%S) -----" + PGPASSWORD="$DB_PASSWORD" psql -h localhost -U "$DB_USERNAME" -d coming_test -c \ + "SELECT pid, state, wait_event_type, wait_event, now()-query_start AS duration, left(query,150) AS query FROM pg_stat_activity WHERE datname='coming_test' ORDER BY query_start;" + PGPASSWORD="$DB_PASSWORD" psql -h localhost -U "$DB_USERNAME" -d coming_test -c \ + "SELECT l.pid, l.mode, l.granted, l.relation::regclass AS relation, a.state, left(a.query,100) AS query FROM pg_locks l LEFT JOIN pg_stat_activity a ON l.pid = a.pid WHERE l.relation IS NOT NULL ORDER BY l.granted, l.pid;" + sleep 5 + done + ) & + MONITOR_PID=$! + set +e + ./gradlew test --no-daemon + TEST_EXIT=$? + set -e + kill "$MONITOR_PID" 2>/dev/null || true + wait "$MONITOR_PID" 2>/dev/null || true + exit $TEST_EXIT build-image: needs: test From aaf58d9798c2d578962c0faa5a6a1cc0844eb47b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 7 Sep 2026 18:09:36 +0900 Subject: [PATCH 17/19] =?UTF-8?q?[fix]=20V29=EB=A5=BC=20CREATE=20INDEX=20C?= =?UTF-8?q?ONCURRENTLY=EC=97=90=EC=84=9C=20=ED=8F=89=EB=B2=94=ED=95=9C=20C?= =?UTF-8?q?REATE=20INDEX=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pg_stat_activity/pg_locks 실시간 캡처로 확인한 결과: Flyway가 executeInTransaction=false 마이그레이션을 실행할 때 HikariCP 풀에서 스키마 체크용 별도 커넥션(SELECT COUNT(*) FROM pg_namespace ...)을 얻어 트랜잭션을 연 채 커밋하지 않고 방치하는 동작이 있고, 그 방치된 트랜잭션 때문에 같은 마이그레이션의 CREATE INDEX CONCURRENTLY가 스스로를 무기한 블로킹한다(Flyway/HikariCP 조합의 커넥션 처리 이슈로 추정, CI뿐 아니라 이 마이그레이션이 처음 적용되는 어떤 환경에서든 재현될 것으로 보임). CONCURRENTLY를 도입한 이유(블루그린 배포 중 쓰기 안 막힘)보다 이 버그로 인한 위험(새 슬롯의 마이그레이션 자체가 무기한 멈춰 배포가 안 끝남)이 더 크다고 판단해, 원래의 평범한 CREATE INDEX로 되돌린다. 이 테이블 규모에서 짧은 SHARE 락 정도는 감수 가능한 트레이드오프로 본다. SET statement_timeout, spring.flyway.mixed=true, CI 진단 스텝도 함께 제거한다. ci.yml의 timeout-minutes: 10 안전장치는 원인과 무관하게 유효하므로 유지. 로컬 flyway_schema_history의 V29 row는 삭제 후 재적용시켜 원래 체크섬으로 되돌렸다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JSfrB86HWyYHYPjvvytrgE --- .github/workflows/ci.yml | 25 ++----------------- src/main/resources/application.yaml | 3 --- ...29__add_concert_artist_artist_id_index.sql | 6 +---- 3 files changed, 3 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab577b2..956a7e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,29 +58,8 @@ jobs: distribution: temurin cache: gradle - # TODO(진단용, 원인 확인 후 제거): V29의 CREATE INDEX CONCURRENTLY가 - # CI에서만 무기한 대기하는 원인을 pg_stat_activity/pg_locks로 실시간 캡처한다. - - name: Run tests with live lock diagnostics - run: | - which psql || (sudo apt-get update -y && sudo apt-get install -y postgresql-client) - ( - for i in $(seq 1 60); do - echo "----- poll #$i $(date -u +%H:%M:%S) -----" - PGPASSWORD="$DB_PASSWORD" psql -h localhost -U "$DB_USERNAME" -d coming_test -c \ - "SELECT pid, state, wait_event_type, wait_event, now()-query_start AS duration, left(query,150) AS query FROM pg_stat_activity WHERE datname='coming_test' ORDER BY query_start;" - PGPASSWORD="$DB_PASSWORD" psql -h localhost -U "$DB_USERNAME" -d coming_test -c \ - "SELECT l.pid, l.mode, l.granted, l.relation::regclass AS relation, a.state, left(a.query,100) AS query FROM pg_locks l LEFT JOIN pg_stat_activity a ON l.pid = a.pid WHERE l.relation IS NOT NULL ORDER BY l.granted, l.pid;" - sleep 5 - done - ) & - MONITOR_PID=$! - set +e - ./gradlew test --no-daemon - TEST_EXIT=$? - set -e - kill "$MONITOR_PID" 2>/dev/null || true - wait "$MONITOR_PID" 2>/dev/null || true - exit $TEST_EXIT + - name: Run tests + run: ./gradlew test --no-daemon build-image: needs: test diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7e54b73..b6ccd22 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -28,9 +28,6 @@ spring: locations: classpath:db/migration baseline-on-migrate: true baseline-version: 1 - # CONCURRENTLY DDL 앞에 SET statement_timeout을 두는 마이그레이션(V29)이 있어, - # 트랜잭션 문장과 비트랜잭션 문장이 한 스크립트에 섞이는 것을 허용해야 한다. - mixed: true security: oauth2: diff --git a/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql index b65c6af..7a2f76d 100644 --- a/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql +++ b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql @@ -1,5 +1 @@ --- flyway:executeInTransaction=false --- CONCURRENTLY는 다른 세션의 트랜잭션이 끝날 때까지 무기한 대기할 수 있다. --- 무한 대기로 배포/마이그레이션이 멈추지 않도록 상한을 둔다. -SET statement_timeout = '300s'; -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_concert_artist_artist_id ON concert_artist (artist_id); +CREATE INDEX idx_concert_artist_artist_id ON concert_artist (artist_id); From 0cc8209df12b447c7d71d635223c927c62fa7cf2 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 9 Sep 2026 14:52:59 +0900 Subject: [PATCH 18/19] =?UTF-8?q?[chore]=20=EC=BD=98=EC=86=94=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=ED=83=80=EC=9E=84=EC=8A=A4=ED=83=AC=ED=94=84?= =?UTF-8?q?=EB=A5=BC=20KST(Asia/Seoul)=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile에 tzdata 설치를 추가하고 logging.pattern.console에 타임존을 명시해, 배포 환경의 TZ 환경변수 설정 여부와 무관하게 로그 타임스탬프가 항상 Asia/Seoul 기준으로 찍히도록 함 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- Dockerfile | 3 +++ src/main/resources/application.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 52f4d29..18be4a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,9 @@ COPY src src RUN ./gradlew bootJar --no-daemon -x test FROM eclipse-temurin:21-jre-jammy +RUN apt-get update \ + && apt-get install -y --no-install-recommends tzdata \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=build /workspace/build/libs/*.jar app.jar ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b6ccd22..66b29c4 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -3,7 +3,7 @@ server: logging: pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{traceId:- }] [%X{userId:-anonymous}] %-5level %logger{36} - %msg%n" + console: "%d{yyyy-MM-dd HH:mm:ss.SSS, Asia/Seoul} [%X{traceId:- }] [%X{userId:-anonymous}] %-5level %logger{36} - %msg%n" spring: application: From 8f1e2f2a231efcbb3260f194a2f7dd4fe9776444 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 9 Sep 2026 15:00:16 +0900 Subject: [PATCH 19/19] =?UTF-8?q?[fix]=20DataPipelineClientTest=EC=9D=98?= =?UTF-8?q?=20=EA=B3=B5=EC=9C=A0=20=EC=9D=91=EB=8B=B5=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=95=84=EC=9B=83=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20fla?= =?UTF-8?q?ky=20=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setUp()의 HttpClient responseTimeout(50ms)이 테스트 클래스 전체에 공유되어, 즉시 응답해야 할 404/409/500 테스트까지 CI의 리소스 경합 상황에서 타임아웃으로 오탐되는 문제가 있었음. 타임아웃 테스트용 지연(300ms→2000ms)과 응답 타임아웃(50ms→500ms)의 여유를 크게 늘려 정상 응답 케이스가 영향받지 않도록 함 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/admin/client/DataPipelineClientTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java index 0b87ee6..db88fdb 100644 --- a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java +++ b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java @@ -27,11 +27,12 @@ class DataPipelineClientTest { @BeforeEach void setUp() throws IOException { - // given: 지정된 응답 타임아웃(50ms)보다 오래(300ms) 지연 응답하는 로컬 서버 + // given: 지정된 응답 타임아웃(500ms)보다 오래(2000ms) 지연 응답하는 로컬 서버 + // CI처럼 부하가 있는 환경에서도 즉시 응답하는 다른 테스트들이 오탐 타임아웃에 걸리지 않도록 여유를 크게 둔다 server = HttpServer.create(new InetSocketAddress(0), 0); server.createContext("/search/artists", exchange -> { try { - TimeUnit.MILLISECONDS.sleep(300); + TimeUnit.MILLISECONDS.sleep(2000); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } @@ -53,7 +54,7 @@ void setUp() throws IOException { }); server.start(); - HttpClient httpClient = HttpClient.create().responseTimeout(Duration.ofMillis(50)); + HttpClient httpClient = HttpClient.create().responseTimeout(Duration.ofMillis(500)); WebClient.Builder builder = WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)); String baseUrl = "http://localhost:" + server.getAddress().getPort();