From d49fd6a790ab5708841645f3301181379bcd7ac4 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 02:11:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20recent-positions=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EC=84=A0=EB=B0=95=EC=82=AC=EC=A7=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20enrichment=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShipImageService @PostConstruct로 85K IMO 인메모리 캐시 로드 - RecentVesselPositionDto에 shipImagePath, shipImageCount 필드 추가 - VesselPositionService에서 imo 기반 O(1) lookup으로 사진 정보 주입 - ShipImageRepository에 이미지 수 포함 쿼리 추가 Co-Authored-By: Claude Opus 4.6 --- .../ship/repository/ShipImageRepository.java | 30 ++++++++++ .../domain/ship/service/ShipImageService.java | 55 ++++++++++++++----- .../vessel/dto/RecentVesselPositionDto.java | 6 ++ .../vessel/service/VesselPositionService.java | 41 ++++++++++++-- 4 files changed, 113 insertions(+), 19 deletions(-) diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/repository/ShipImageRepository.java b/src/main/java/gc/mda/signal_batch/domain/ship/repository/ShipImageRepository.java index 8e7540e..25646d9 100644 --- a/src/main/java/gc/mda/signal_batch/domain/ship/repository/ShipImageRepository.java +++ b/src/main/java/gc/mda/signal_batch/domain/ship/repository/ShipImageRepository.java @@ -53,6 +53,20 @@ public class ShipImageRepository { ORDER BY lrno, dateofphoto DESC NULLS LAST, pic_id DESC """; + private static final String FIND_ALL_WITH_COUNT_SQL = """ + SELECT s.lrno, s.pic_id, c.img_count + FROM ( + SELECT DISTINCT ON (lrno) lrno, pic_id + FROM signal.t_snp_ship_img + ORDER BY lrno, dateofphoto DESC NULLS LAST, pic_id DESC + ) s + JOIN ( + SELECT lrno, count(*) AS img_count + FROM signal.t_snp_ship_img + GROUP BY lrno + ) c ON s.lrno = c.lrno + """; + private static final RowMapper VALID_ROW_MAPPER = (rs, rowNum) -> { ValidShipImageData data = new ValidShipImageData(); data.imo = rs.getInt("lrno"); @@ -60,6 +74,14 @@ public class ShipImageRepository { return data; }; + private static final RowMapper VALID_WITH_COUNT_ROW_MAPPER = (rs, rowNum) -> { + ValidShipImageData data = new ValidShipImageData(); + data.imo = rs.getInt("lrno"); + data.picId = rs.getInt("pic_id"); + data.imageCount = rs.getInt("img_count"); + return data; + }; + /** * 선박 사진이 있는 모든 IMO의 대표 이미지 조회 (IMO당 1건, 최신 사진 우선) */ @@ -67,6 +89,13 @@ public class ShipImageRepository { return queryJdbcTemplate.query(FIND_ALL_VALID_SQL, VALID_ROW_MAPPER); } + /** + * 선박 사진이 있는 모든 IMO의 대표 이미지 + 이미지 수 조회 + */ + public List findAllValidShipImagesWithCount() { + return queryJdbcTemplate.query(FIND_ALL_WITH_COUNT_SQL, VALID_WITH_COUNT_ROW_MAPPER); + } + /** * Repository 내부에서 사용하는 Raw 데이터 클래스 */ @@ -79,5 +108,6 @@ public class ShipImageRepository { public static class ValidShipImageData { public Integer imo; public Integer picId; + public Integer imageCount; } } diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/service/ShipImageService.java b/src/main/java/gc/mda/signal_batch/domain/ship/service/ShipImageService.java index 9e8cd04..9eeab99 100644 --- a/src/main/java/gc/mda/signal_batch/domain/ship/service/ShipImageService.java +++ b/src/main/java/gc/mda/signal_batch/domain/ship/service/ShipImageService.java @@ -5,11 +5,15 @@ import gc.mda.signal_batch.domain.ship.dto.ValidShipImageDto; import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository; import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository.ShipImageRawData; import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository.ValidShipImageData; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @Slf4j @@ -19,11 +23,42 @@ public class ShipImageService { private final ShipImageRepository shipImageRepository; + /** IMO → {thumbnailPath, imageCount} 인메모리 캐시 (기동 시 1회 로드) */ + private volatile Map imageCache = Collections.emptyMap(); + + @PostConstruct + public void initImageCache() { + try { + long start = System.currentTimeMillis(); + List rawList = shipImageRepository.findAllValidShipImagesWithCount(); + + Map map = new ConcurrentHashMap<>(rawList.size()); + for (ValidShipImageData raw : rawList) { + map.put(raw.imo.longValue(), new ShipImageSummary( + buildImagePath(raw.picId) + "_1.jpg", + raw.imageCount + )); + } + imageCache = map; + + log.info("ShipImage 캐시 로드 완료: {} IMO ({}ms)", map.size(), System.currentTimeMillis() - start); + } catch (Exception e) { + log.warn("ShipImage 캐시 로드 실패 (API 조회로 대체 가능): {}", e.getMessage()); + } + } + + /** + * IMO로 캐시된 사진 요약 조회 (O(1) lookup) + * + * @return ShipImageSummary or null + */ + public ShipImageSummary getImageSummary(Long imo) { + if (imo == null || imo <= 0) return null; + return imageCache.get(imo); + } + /** * IMO로 이미지 정보 목록 조회 - * - * @param imo IMO 번호 - * @return 이미지 정보 목록 (없으면 빈 배열) */ public List getImagesByImo(Integer imo) { List rawDataList = shipImageRepository.findByImo(imo); @@ -47,9 +82,6 @@ public class ShipImageService { .collect(Collectors.toList()); } - /** - * Raw 데이터를 DTO로 변환 - */ private ShipImageDto toDto(ShipImageRawData rawData) { return ShipImageDto.builder() .picId(rawData.picId) @@ -59,16 +91,11 @@ public class ShipImageService { .build(); } - /** - * pic_id로 이미지 경로 생성 - * 규칙: 폴더명 = pic_id / 100 - * 예: pic_id=816100 → /shipimg/8161/816100 - * - * @param picId pic_id - * @return 이미지 경로 - */ private String buildImagePath(Integer picId) { int folderName = picId / 100; return String.format("/shipimg/%d/%d", folderName, picId); } + + /** 선박 사진 요약 정보 (인메모리 캐시용) */ + public record ShipImageSummary(String thumbnailPath, int imageCount) {} } diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentVesselPositionDto.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentVesselPositionDto.java index 4a5e1d4..a52d758 100644 --- a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentVesselPositionDto.java +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentVesselPositionDto.java @@ -53,4 +53,10 @@ public class RecentVesselPositionDto { @Schema(description = "최종 업데이트 시간", example = "2026-01-20 12:05:00") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime lastUpdate; + + @Schema(description = "선박 사진 썸네일 경로 (없으면 null)", example = "/shipimg/8161/816100_1.jpg") + private String shipImagePath; + + @Schema(description = "선박 사진 수 (없으면 null)", example = "3") + private Integer shipImageCount; } \ No newline at end of file diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPositionService.java b/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPositionService.java index f41acff..d1ef9f9 100644 --- a/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPositionService.java +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPositionService.java @@ -1,5 +1,7 @@ package gc.mda.signal_batch.domain.vessel.service; +import gc.mda.signal_batch.domain.ship.service.ShipImageService; +import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary; import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto; import gc.mda.signal_batch.global.util.SignalKindCode; import lombok.RequiredArgsConstructor; @@ -22,6 +24,8 @@ public class VesselPositionService { @Qualifier("queryJdbcTemplate") private final JdbcTemplate queryJdbcTemplate; + private final ShipImageService shipImageService; + // 캐시 서비스 (선택적 의존성 - 활성화 시에만 주입) @Autowired(required = false) private VesselLatestPositionCache cache; @@ -32,11 +36,14 @@ public class VesselPositionService { * 조회 전략: * 1. 캐시 활성화 시: 메모리 캐시에서 조회 (빠른 응답) * 2. 캐시 비활성화/실패 시: DB에서 직접 조회 (기존 로직) + * 3. 결과에 선박사진 정보 enrichment (인메모리 캐시 O(1) lookup) * * @param minutes 조회할 시간 범위 (분 단위) * @return 필터링된 선박 위치 목록 */ public List getRecentVesselPositions(int minutes) { + List results; + // 캐시 우선 조회 if (cache != null) { try { @@ -44,17 +51,41 @@ public class VesselPositionService { if (!cachedResult.isEmpty()) { log.debug("Cache hit: returned {} vessels for minutes={}", cachedResult.size(), minutes); - return cachedResult; + results = cachedResult; } else { log.debug("Cache returned empty result, falling back to DB query"); + results = getRecentVesselPositionsFromDB(minutes); } } catch (Exception e) { log.warn("Cache query failed, falling back to DB: {}", e.getMessage()); + results = getRecentVesselPositionsFromDB(minutes); } + } else { + results = getRecentVesselPositionsFromDB(minutes); } - // 캐시 미사용 또는 실패 시 DB 조회 (기존 로직) - return getRecentVesselPositionsFromDB(minutes); + // 선박사진 정보 enrichment + enrichWithShipImages(results); + + return results; + } + + /** + * 선박사진 인메모리 캐시에서 imo 기반 O(1) lookup → shipImagePath, shipImageCount 설정 + */ + private void enrichWithShipImages(List positions) { + int enriched = 0; + for (RecentVesselPositionDto pos : positions) { + ShipImageSummary summary = shipImageService.getImageSummary(pos.getImo()); + if (summary != null) { + pos.setShipImagePath(summary.thumbnailPath()); + pos.setShipImageCount(summary.imageCount()); + enriched++; + } + } + if (enriched > 0) { + log.debug("ShipImage enrichment: {}/{} vessels", enriched, positions.size()); + } } /** @@ -105,7 +136,7 @@ public class VesselPositionService { return results; } - + private static class VesselPositionRowMapper implements RowMapper { @Override public RecentVesselPositionDto mapRow(ResultSet rs, int rowNum) throws SQLException { @@ -137,4 +168,4 @@ public class VesselPositionService { .build(); } } -} \ No newline at end of file +}