Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3c50043
[fix] 도커 컨테이너 타임존을 KST(Asia/Seoul)로 통일
You-Hyuk Sep 6, 2026
0b45f77
[perf] concert_artist(artist_id) 단일 컬럼 인덱스 추가
You-Hyuk Sep 6, 2026
d921afd
[fix] WebClient에 connectTimeout/responseTimeout 명시
You-Hyuk Sep 6, 2026
24d4a77
[fix] PIPELINE_TIMEOUT 예외 신설 및 DataPipelineClient 타임아웃 매핑
You-Hyuk Sep 6, 2026
5e4eba3
[fix] PIPELINE_CONFLICT 응답 메시지 개선
You-Hyuk Sep 6, 2026
695bcb5
[feat] 관리자 아티스트 수집 요청에 Redis mbid 단위 dedup 락 추가
You-Hyuk Sep 6, 2026
f8eecc7
[test] #113 신규 테스트 메서드명을 snake_case 컨벤션에 맞게 정리
You-Hyuk Sep 7, 2026
a5b14d4
[style] #113 신규 테스트 코드 100자 초과 라인 개행
You-Hyuk Sep 7, 2026
706d6fe
[test] DataPipelineClient.collectArtist의 404/409/500 onStatus 분기 회귀 테…
You-Hyuk Sep 7, 2026
265eea5
[fix] Data 검색 엔드포인트에 504 PIPELINE_TIMEOUT 응답 문서화
You-Hyuk Sep 7, 2026
d591c53
[fix] ArtistCollectLockRepository.unlock을 소유권 토큰 기반으로 변경
You-Hyuk Sep 7, 2026
0560839
[fix] 락 해제 실패가 collectArtist의 원래 결과·예외를 가리지 않도록 방어
You-Hyuk Sep 7, 2026
6f2a9bb
[fix] concert_artist(artist_id) 인덱스 생성을 CONCURRENTLY로 전환
You-Hyuk Sep 7, 2026
1c6cb11
[test] CI test job에 timeout-minutes 추가, DB 커넥션에 statement_timeout 임시 주입
You-Hyuk Sep 7, 2026
2cc0137
[fix] V29 마이그레이션의 CREATE INDEX CONCURRENTLY 무기한 대기에 상한 추가
You-Hyuk Sep 7, 2026
67a087c
[test] CI에 pg_stat_activity/pg_locks 실시간 캡처 진단 스텝 추가
You-Hyuk Sep 7, 2026
aaf58d9
[fix] V29를 CREATE INDEX CONCURRENTLY에서 평범한 CREATE INDEX로 되돌림
You-Hyuk Sep 7, 2026
0cc8209
[chore] 콘솔 로그 타임스탬프를 KST(Asia/Seoul)로 고정
You-Hyuk Sep 9, 2026
8f1e2f2
[fix] DataPipelineClientTest의 공유 응답 타임아웃으로 인한 flaky 실패 수정
You-Hyuk Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10

services:
postgres:
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +47,7 @@ public List<DataArtistSearchResult> searchArtists(String name) {
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<DataArtistSearchResult>>() {})
.doOnError(e -> log.warn("Data pipeline artist search failed: name={}, error={}", name, e.getMessage()))
.onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.block();
}

Expand All @@ -56,6 +60,7 @@ public List<DataConcertSearchResult> searchConcerts(String title) {
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<DataConcertSearchResult>>() {})
.doOnError(e -> log.warn("Data pipeline concert search failed: title={}, error={}", title, e.getMessage()))
.onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException())
.block();
}

Expand All @@ -64,6 +69,7 @@ public List<DataConcertSearchResult> searchConcerts(String title) {
*
* @throws PipelineNotFoundException MusicBrainz에 해당 MBID가 없는 경우
* @throws PipelineConflictException 동일 MBID에 대한 수집이 이미 처리 중인 경우
* @throws PipelineTimeoutException Data 파이프라인 응답이 설정된 시간 내에 오지 않은 경우
*/
public PipelineArtistCollectResult collectArtist(String mbid) {
return webClient.post()
Expand All @@ -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();
}

Expand All @@ -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()
Expand All @@ -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();
}

Expand All @@ -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();
}

Expand All @@ -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()
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,14 @@ public ResponseEntity<Void> removeArtistFromConcert(
}

@Operation(summary = "Data 파이프라인 아티스트 검색")
@ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT")
@GetMapping("/data/search/artists")
public ResponseEntity<List<DataArtistSearchResult>> 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<List<DataConcertSearchResult>> searchConcerts(@RequestParam String title) {
return ResponseEntity.ok(adminService.searchConcerts(title));
Expand All @@ -235,6 +237,7 @@ public ResponseEntity<List<DataConcertSearchResult>> 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<PipelineArtistCollectResult> collectArtist(@RequestBody @Valid AdminArtistCollectRequest request) {
return ResponseEntity.ok(adminService.collectArtist(request));
Expand All @@ -243,13 +246,15 @@ public ResponseEntity<PipelineArtistCollectResult> 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<PipelineConcertCollectResult> collectConcert(@RequestBody @Valid AdminConcertCollectRequest request) {
return ResponseEntity.ok(adminService.collectConcert(request));
}

@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<Void> triggerArtistReleases(@PathVariable Long id) {
adminService.triggerArtistReleases(id);
Expand All @@ -259,6 +264,7 @@ public ResponseEntity<Void> 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<PipelineSetlistCollectResult> triggerConcertSetlist(@PathVariable Long id) {
return ResponseEntity.ok(adminService.triggerConcertSetlist(id));
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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
public class ArtistCollectLockRepository {

private static final String KEY_PREFIX = "ARTIST_COLLECT_LOCK:";
// WebClient responseTimeout(120s)보다 여유 있게 잡아, 정상 흐름에서는 항상 명시적 unlock으로
// 해제되고 TTL은 서버 재시작 등 예외 상황의 안전망 역할만 한다.
private static final long TTL_SECONDS = 150;

// 저장된 토큰이 일치할 때만 삭제한다. TTL 만료 후 다른 요청이 같은 키를 선점했다면,
// 이전 보유자의 unlock이 그 새 락을 실수로 지우지 않도록 막는다.
private static final RedisScript<Long> 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<String, String> redisTemplate;

public ArtistCollectLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}

/**
* mbid에 대한 락을 선점한다.
*
* @return 선점에 성공하면 이 호출을 식별하는 토큰, 이미 다른 요청이 보유 중이면 null
*/
public String tryLock(String mbid) {
String token = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + mbid, token, TTL_SECONDS, TimeUnit.SECONDS);
return Boolean.TRUE.equals(acquired) ? token : null;
}

/**
* 전달된 토큰이 현재 저장된 값과 일치할 때만 락을 해제한다.
*/
public void unlock(String mbid, String token) {
redisTemplate.execute(UNLOCK_SCRIPT, List.of(KEY_PREFIX + mbid), token);
}
}
23 changes: 22 additions & 1 deletion src/main/java/com/Coming/Backend/admin/service/AdminService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -61,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;
Expand All @@ -73,6 +76,7 @@
import java.util.Set;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
Expand All @@ -89,6 +93,7 @@ public class AdminService {
private final ConcertBookingLinkRepository concertBookingLinkRepository;
private final ConcertImageRepository concertImageRepository;
private final DataPipelineClient dataPipelineClient;
private final ArtistCollectLockRepository artistCollectLockRepository;

/**
* 어드민 아티스트 단건을 조회한다. 존재하지 않는 아티스트 ID이면 ArtistNotFoundException을 던진다.
Expand Down Expand Up @@ -645,9 +650,25 @@ public List<DataConcertSearchResult> searchConcerts(String title) {

/**
* MBID 기반으로 아티스트를 동기 수집한다. 수집 결과를 반환한다.
* 동일 MBID에 대한 요청이 이미 처리 중이면 Data 파이프라인까지 가지 않고 즉시 거부한다.
*
* @throws PipelineConflictException 동일 MBID에 대한 수집 요청이 이미 처리 중인 경우
*/
public PipelineArtistCollectResult collectArtist(AdminArtistCollectRequest request) {
return dataPipelineClient.collectArtist(request.mbid());
String mbid = request.mbid();
String lockToken = artistCollectLockRepository.tryLock(mbid);
if (lockToken == null) {
throw new PipelineConflictException();
}
try {
return dataPipelineClient.collectArtist(mbid);
} finally {
try {
artistCollectLockRepository.unlock(mbid, lockToken);
} catch (Exception e) {
log.warn("Failed to release artist collect lock: mbid={}", mbid, e);
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ 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 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."),

// Common
RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."),
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE INDEX idx_concert_artist_artist_id ON concert_artist (artist_id);
Loading
Loading