Merge pull request 'feat: recent-positions IMO + 선박사진 enrichment' (#57) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m46s

This commit is contained in:
htlee 2026-02-20 02:18:23 +09:00
커밋 9a1d4b7b2e
6개의 변경된 파일204개의 추가작업 그리고 18개의 파일을 삭제

파일 보기

@ -1,6 +1,7 @@
package gc.mda.signal_batch.domain.ship.controller; 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.ShipImageDto;
import gc.mda.signal_batch.domain.ship.dto.ValidShipImageDto;
import gc.mda.signal_batch.domain.ship.service.ShipImageService; import gc.mda.signal_batch.domain.ship.service.ShipImageService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@ -33,7 +34,7 @@ public class ShipImageControllerV2 {
private final ShipImageService shipImageService; private final ShipImageService shipImageService;
@GetMapping("/{imo}") @GetMapping("/{imo:\\d+}")
@Operation( @Operation(
summary = "선박 이미지 경로 조회", summary = "선박 이미지 경로 조회",
description = "IMO 번호로 선박 이미지 경로 목록을 조회합니다. 프론트엔드에서 썸네일은 path + '_1.jpg', 원본은 path + '_2.jpg'를 사용합니다." description = "IMO 번호로 선박 이미지 경로 목록을 조회합니다. 프론트엔드에서 썸네일은 path + '_1.jpg', 원본은 path + '_2.jpg'를 사용합니다."
@ -57,4 +58,26 @@ public class ShipImageControllerV2 {
return ResponseEntity.ok(images); 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<List<ValidShipImageDto>> getValidShipImageList() {
long start = System.currentTimeMillis();
List<ValidShipImageDto> result = shipImageService.getAllValidShipImages();
log.info("Valid ship image list: {} IMOs ({}ms)", result.size(), System.currentTimeMillis() - start);
return ResponseEntity.ok(result);
}
} }

파일 보기

@ -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;
}

파일 보기

@ -47,6 +47,55 @@ public class ShipImageRepository {
return queryJdbcTemplate.query(FIND_BY_IMO_SQL, ROW_MAPPER, imo); 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 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<ValidShipImageData> VALID_ROW_MAPPER = (rs, rowNum) -> {
ValidShipImageData data = new ValidShipImageData();
data.imo = rs.getInt("lrno");
data.picId = rs.getInt("pic_id");
return data;
};
private static final RowMapper<ValidShipImageData> 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건, 최신 사진 우선)
*/
public List<ValidShipImageData> findAllValidShipImages() {
return queryJdbcTemplate.query(FIND_ALL_VALID_SQL, VALID_ROW_MAPPER);
}
/**
* 선박 사진이 있는 모든 IMO의 대표 이미지 + 이미지 조회
*/
public List<ValidShipImageData> findAllValidShipImagesWithCount() {
return queryJdbcTemplate.query(FIND_ALL_WITH_COUNT_SQL, VALID_WITH_COUNT_ROW_MAPPER);
}
/** /**
* Repository 내부에서 사용하는 Raw 데이터 클래스 * Repository 내부에서 사용하는 Raw 데이터 클래스
*/ */
@ -55,4 +104,10 @@ public class ShipImageRepository {
public String copyright; public String copyright;
public java.time.LocalDate dateOfPhoto; public java.time.LocalDate dateOfPhoto;
} }
public static class ValidShipImageData {
public Integer imo;
public Integer picId;
public Integer imageCount;
}
} }

파일 보기

@ -1,13 +1,19 @@
package gc.mda.signal_batch.domain.ship.service; 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.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;
import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository.ShipImageRawData; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Slf4j
@ -17,11 +23,42 @@ public class ShipImageService {
private final ShipImageRepository shipImageRepository; private final ShipImageRepository shipImageRepository;
/** IMO → {thumbnailPath, imageCount} 인메모리 캐시 (기동 시 1회 로드) */
private volatile Map<Long, ShipImageSummary> imageCache = Collections.emptyMap();
@PostConstruct
public void initImageCache() {
try {
long start = System.currentTimeMillis();
List<ValidShipImageData> rawList = shipImageRepository.findAllValidShipImagesWithCount();
Map<Long, ShipImageSummary> 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로 이미지 정보 목록 조회 * IMO로 이미지 정보 목록 조회
*
* @param imo IMO 번호
* @return 이미지 정보 목록 (없으면 배열)
*/ */
public List<ShipImageDto> getImagesByImo(Integer imo) { public List<ShipImageDto> getImagesByImo(Integer imo) {
List<ShipImageRawData> rawDataList = shipImageRepository.findByImo(imo); List<ShipImageRawData> rawDataList = shipImageRepository.findByImo(imo);
@ -32,8 +69,19 @@ public class ShipImageService {
} }
/** /**
* Raw 데이터를 DTO로 변환 * 선박 사진이 있는 전체 IMO 목록과 썸네일 경로 조회
*/ */
public List<ValidShipImageDto> getAllValidShipImages() {
List<ValidShipImageData> rawList = shipImageRepository.findAllValidShipImages();
return rawList.stream()
.map(raw -> ValidShipImageDto.builder()
.imo(raw.imo)
.thumbnailPath(buildImagePath(raw.picId) + "_1.jpg")
.build())
.collect(Collectors.toList());
}
private ShipImageDto toDto(ShipImageRawData rawData) { private ShipImageDto toDto(ShipImageRawData rawData) {
return ShipImageDto.builder() return ShipImageDto.builder()
.picId(rawData.picId) .picId(rawData.picId)
@ -43,16 +91,11 @@ public class ShipImageService {
.build(); .build();
} }
/**
* pic_id로 이미지 경로 생성
* 규칙: 폴더명 = pic_id / 100
* : pic_id=816100 /shipimg/8161/816100
*
* @param picId pic_id
* @return 이미지 경로
*/
private String buildImagePath(Integer picId) { private String buildImagePath(Integer picId) {
int folderName = picId / 100; int folderName = picId / 100;
return String.format("/shipimg/%d/%d", folderName, picId); return String.format("/shipimg/%d/%d", folderName, picId);
} }
/** 선박 사진 요약 정보 (인메모리 캐시용) */
public record ShipImageSummary(String thumbnailPath, int imageCount) {}
} }

파일 보기

@ -20,6 +20,9 @@ public class RecentVesselPositionDto {
@Schema(description = "MMSI (9자리, 한국선박 440/441로 시작)", example = "440113620") @Schema(description = "MMSI (9자리, 한국선박 440/441로 시작)", example = "440113620")
private String mmsi; private String mmsi;
@Schema(description = "IMO 번호 (0이면 미부여)", example = "9141833")
private Long imo;
@Schema(description = "경도 (WGS84)", example = "127.0638") @Schema(description = "경도 (WGS84)", example = "127.0638")
private Double lon; private Double lon;
@ -50,4 +53,10 @@ public class RecentVesselPositionDto {
@Schema(description = "최종 업데이트 시간", example = "2026-01-20 12:05:00") @Schema(description = "최종 업데이트 시간", example = "2026-01-20 12:05:00")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastUpdate; private LocalDateTime lastUpdate;
@Schema(description = "선박 사진 썸네일 경로 (없으면 null)", example = "/shipimg/8161/816100_1.jpg")
private String shipImagePath;
@Schema(description = "선박 사진 수 (없으면 null)", example = "3")
private Integer shipImageCount;
} }

파일 보기

@ -1,5 +1,7 @@
package gc.mda.signal_batch.domain.vessel.service; 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.domain.vessel.dto.RecentVesselPositionDto;
import gc.mda.signal_batch.global.util.SignalKindCode; import gc.mda.signal_batch.global.util.SignalKindCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -22,6 +24,8 @@ public class VesselPositionService {
@Qualifier("queryJdbcTemplate") @Qualifier("queryJdbcTemplate")
private final JdbcTemplate queryJdbcTemplate; private final JdbcTemplate queryJdbcTemplate;
private final ShipImageService shipImageService;
// 캐시 서비스 (선택적 의존성 - 활성화 시에만 주입) // 캐시 서비스 (선택적 의존성 - 활성화 시에만 주입)
@Autowired(required = false) @Autowired(required = false)
private VesselLatestPositionCache cache; private VesselLatestPositionCache cache;
@ -32,11 +36,14 @@ public class VesselPositionService {
* 조회 전략: * 조회 전략:
* 1. 캐시 활성화 : 메모리 캐시에서 조회 (빠른 응답) * 1. 캐시 활성화 : 메모리 캐시에서 조회 (빠른 응답)
* 2. 캐시 비활성화/실패 : DB에서 직접 조회 (기존 로직) * 2. 캐시 비활성화/실패 : DB에서 직접 조회 (기존 로직)
* 3. 결과에 선박사진 정보 enrichment (인메모리 캐시 O(1) lookup)
* *
* @param minutes 조회할 시간 범위 ( 단위) * @param minutes 조회할 시간 범위 ( 단위)
* @return 필터링된 선박 위치 목록 * @return 필터링된 선박 위치 목록
*/ */
public List<RecentVesselPositionDto> getRecentVesselPositions(int minutes) { public List<RecentVesselPositionDto> getRecentVesselPositions(int minutes) {
List<RecentVesselPositionDto> results;
// 캐시 우선 조회 // 캐시 우선 조회
if (cache != null) { if (cache != null) {
try { try {
@ -44,17 +51,41 @@ public class VesselPositionService {
if (!cachedResult.isEmpty()) { if (!cachedResult.isEmpty()) {
log.debug("Cache hit: returned {} vessels for minutes={}", cachedResult.size(), minutes); log.debug("Cache hit: returned {} vessels for minutes={}", cachedResult.size(), minutes);
return cachedResult; results = cachedResult;
} else { } else {
log.debug("Cache returned empty result, falling back to DB query"); log.debug("Cache returned empty result, falling back to DB query");
results = getRecentVesselPositionsFromDB(minutes);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("Cache query failed, falling back to DB: {}", e.getMessage()); log.warn("Cache query failed, falling back to DB: {}", e.getMessage());
results = getRecentVesselPositionsFromDB(minutes);
} }
} else {
results = getRecentVesselPositionsFromDB(minutes);
} }
// 캐시 미사용 또는 실패 DB 조회 (기존 로직) // 선박사진 정보 enrichment
return getRecentVesselPositionsFromDB(minutes); enrichWithShipImages(results);
return results;
}
/**
* 선박사진 인메모리 캐시에서 imo 기반 O(1) lookup shipImagePath, shipImageCount 설정
*/
private void enrichWithShipImages(List<RecentVesselPositionDto> 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());
}
} }
/** /**
@ -84,6 +115,7 @@ public class VesselPositionService {
String sql = """ String sql = """
SELECT SELECT
mmsi, mmsi,
imo,
lon, lon,
lat, lat,
sog, sog,
@ -104,7 +136,7 @@ public class VesselPositionService {
return results; return results;
} }
private static class VesselPositionRowMapper implements RowMapper<RecentVesselPositionDto> { private static class VesselPositionRowMapper implements RowMapper<RecentVesselPositionDto> {
@Override @Override
public RecentVesselPositionDto mapRow(ResultSet rs, int rowNum) throws SQLException { public RecentVesselPositionDto mapRow(ResultSet rs, int rowNum) throws SQLException {
@ -118,8 +150,11 @@ public class VesselPositionService {
String nationalCode = mmsi != null && mmsi.length() >= 3 String nationalCode = mmsi != null && mmsi.length() >= 3
? mmsi.substring(0, 3) : "000"; ? mmsi.substring(0, 3) : "000";
long imo = rs.getLong("imo");
return RecentVesselPositionDto.builder() return RecentVesselPositionDto.builder()
.mmsi(mmsi) .mmsi(mmsi)
.imo(imo > 0 ? imo : null)
.lon(rs.getDouble("lon")) .lon(rs.getDouble("lon"))
.lat(rs.getDouble("lat")) .lat(rs.getDouble("lat"))
.sog(rs.getBigDecimal("sog")) .sog(rs.getBigDecimal("sog"))
@ -133,4 +168,4 @@ public class VesselPositionService {
.build(); .build();
} }
} }
} }