From 27891baada30ee4e7dcde97369416546fc238e73 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 02:05:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20recent-positions=20IMO=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20+=20=EC=84=A0=EB=B0=95?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EB=B3=B4=EC=9C=A0=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecentVesselPositionDto에 imo 필드 추가 (0이면 null 처리) - GET /api/v2/shipimg/valid-list: 선박사진 보유 IMO 전체 목록 + 썸네일 경로 - 초기 로딩 시 선박 썸네일 즉시 표시 용도 (85K+ IMO) Co-Authored-By: Claude Opus 4.6 --- .../controller/ShipImageControllerV2.java | 23 +++++++++++++++++ .../domain/ship/dto/ValidShipImageDto.java | 21 ++++++++++++++++ .../ship/repository/ShipImageRepository.java | 25 +++++++++++++++++++ .../domain/ship/service/ShipImageService.java | 16 ++++++++++++ .../vessel/dto/RecentVesselPositionDto.java | 3 +++ .../vessel/service/VesselPositionService.java | 4 +++ 6 files changed, 92 insertions(+) create mode 100644 src/main/java/gc/mda/signal_batch/domain/ship/dto/ValidShipImageDto.java diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java b/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java index 831b899..83c47fa 100644 --- a/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java +++ b/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java @@ -1,6 +1,7 @@ package gc.mda.signal_batch.domain.ship.controller; import gc.mda.signal_batch.domain.ship.dto.ShipImageDto; +import gc.mda.signal_batch.domain.ship.dto.ValidShipImageDto; import gc.mda.signal_batch.domain.ship.service.ShipImageService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -57,4 +58,26 @@ public class ShipImageControllerV2 { return ResponseEntity.ok(images); } + + @GetMapping("/valid-list") + @Operation( + summary = "선박 사진 보유 IMO 전체 목록", + description = "선박 사진이 등록된 모든 IMO 번호와 대표 썸네일 경로를 반환합니다. 초기 로딩 시 선박 썸네일 표시 용도입니다." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ValidShipImageDto.class))) + ) + }) + public ResponseEntity> getValidShipImageList() { + long start = System.currentTimeMillis(); + + List result = shipImageService.getAllValidShipImages(); + + log.info("Valid ship image list: {} IMOs ({}ms)", result.size(), System.currentTimeMillis() - start); + + return ResponseEntity.ok(result); + } } diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/dto/ValidShipImageDto.java b/src/main/java/gc/mda/signal_batch/domain/ship/dto/ValidShipImageDto.java new file mode 100644 index 0000000..38c1fd8 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/ship/dto/ValidShipImageDto.java @@ -0,0 +1,21 @@ +package gc.mda.signal_batch.domain.ship.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "선박 사진 보유 IMO 정보 (썸네일 경로 포함)") +public class ValidShipImageDto { + + @Schema(description = "IMO 번호", example = "9141833") + private Integer imo; + + @Schema(description = "썸네일 이미지 경로", example = "/shipimg/8161/816100_1.jpg") + private String thumbnailPath; +} 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 a97989f..8e7540e 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 @@ -47,6 +47,26 @@ public class ShipImageRepository { return queryJdbcTemplate.query(FIND_BY_IMO_SQL, ROW_MAPPER, imo); } + private static final String FIND_ALL_VALID_SQL = """ + SELECT DISTINCT ON (lrno) lrno, pic_id + FROM signal.t_snp_ship_img + ORDER BY lrno, dateofphoto DESC NULLS LAST, pic_id DESC + """; + + private static final RowMapper VALID_ROW_MAPPER = (rs, rowNum) -> { + ValidShipImageData data = new ValidShipImageData(); + data.imo = rs.getInt("lrno"); + data.picId = rs.getInt("pic_id"); + return data; + }; + + /** + * 선박 사진이 있는 모든 IMO의 대표 이미지 조회 (IMO당 1건, 최신 사진 우선) + */ + public List findAllValidShipImages() { + return queryJdbcTemplate.query(FIND_ALL_VALID_SQL, VALID_ROW_MAPPER); + } + /** * Repository 내부에서 사용하는 Raw 데이터 클래스 */ @@ -55,4 +75,9 @@ public class ShipImageRepository { public String copyright; public java.time.LocalDate dateOfPhoto; } + + public static class ValidShipImageData { + public Integer imo; + public Integer picId; + } } 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 eb80200..9e8cd04 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 @@ -1,8 +1,10 @@ package gc.mda.signal_batch.domain.ship.service; import gc.mda.signal_batch.domain.ship.dto.ShipImageDto; +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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -31,6 +33,20 @@ public class ShipImageService { .collect(Collectors.toList()); } + /** + * 선박 사진이 있는 전체 IMO 목록과 썸네일 경로 조회 + */ + public List getAllValidShipImages() { + List rawList = shipImageRepository.findAllValidShipImages(); + + return rawList.stream() + .map(raw -> ValidShipImageDto.builder() + .imo(raw.imo) + .thumbnailPath(buildImagePath(raw.picId) + "_1.jpg") + .build()) + .collect(Collectors.toList()); + } + /** * Raw 데이터를 DTO로 변환 */ 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 d3117d8..4a5e1d4 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 @@ -20,6 +20,9 @@ public class RecentVesselPositionDto { @Schema(description = "MMSI (9자리, 한국선박 440/441로 시작)", example = "440113620") private String mmsi; + @Schema(description = "IMO 번호 (0이면 미부여)", example = "9141833") + private Long imo; + @Schema(description = "경도 (WGS84)", example = "127.0638") private Double lon; 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 9aa68ef..f41acff 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 @@ -84,6 +84,7 @@ public class VesselPositionService { String sql = """ SELECT mmsi, + imo, lon, lat, sog, @@ -118,8 +119,11 @@ public class VesselPositionService { String nationalCode = mmsi != null && mmsi.length() >= 3 ? mmsi.substring(0, 3) : "000"; + long imo = rs.getLong("imo"); + return RecentVesselPositionDto.builder() .mmsi(mmsi) + .imo(imo > 0 ? imo : null) .lon(rs.getDouble("lon")) .lat(rs.getDouble("lat")) .sog(rs.getBigDecimal("sog")) From d49fd6a790ab5708841645f3301181379bcd7ac4 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 02:11:45 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20recent-positions=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20=EC=84=A0=EB=B0=95=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=A0=95=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 +} From 8a9efdaf95e502885df4952a8cccd2dd1652e0a1 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 02:17:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20shipimg=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=EC=88=98=EC=A0=95=20=E2=80=94=20/{imo}=20?= =?UTF-8?q?=EC=88=AB=EC=9E=90=20=ED=8C=A8=ED=84=B4=20=EC=A0=9C=EC=95=BD=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /{imo} → /{imo:\d+}로 변경하여 /valid-list 경로와 충돌 방지 Co-Authored-By: Claude Opus 4.6 --- .../domain/ship/controller/ShipImageControllerV2.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java b/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java index 83c47fa..2ab428f 100644 --- a/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java +++ b/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java @@ -34,7 +34,7 @@ public class ShipImageControllerV2 { private final ShipImageService shipImageService; - @GetMapping("/{imo}") + @GetMapping("/{imo:\\d+}") @Operation( summary = "선박 이미지 경로 조회", description = "IMO 번호로 선박 이미지 경로 목록을 조회합니다. 프론트엔드에서 썸네일은 path + '_1.jpg', 원본은 path + '_2.jpg'를 사용합니다."