디렉토리 구조 탐색과 이미지 메타데이터 저장
서버에 위와 같은 구조로 파일과 디렉토리가 구성되어 있을 때, 이 구조를 데이터베이스에 적합한 형식으로 저장하고 화면에 위와 같은 구조로 출력하는 기능을 작성해보자.
서버에 저장되는 디렉토리와 파일 구조는 고정되지 않고, 이미지만을 저장한다고 가정한다. (디렉토리를 몇 번 거친 후에 이미지 파일이 등장하는지는 정해지지 않았다)
<고려할 점>
1. 서버에 저장되는 이미지의 수는 적어도 10만장을 넘어간다.
-> 저장하는 로직은 스케쥴러를 통해 특정 시간마다 수행될 수 있도록 설정할 수 있어야 한다.
2. 새로 추가된 디렉토리 및 이미지를 감지할 수 있어야 하고, 디렉토리와 이미지가 수정된 경우도 감지할 수 있어야 한다.
-> 마지막으로 수정된 날짜, 저장된 이미지 수를 사용한다.
3. 특정 디렉토리를 선택하고 하위 이미지들에 대해서만 스케쥴러를 실행하는 기능을 구현해야 한다.
-> 스케쥴러를 실행하고 다른 작업을 수행할 수 있도록 스케쥴러 실행 작업은 비동기로 진행해야 한다.
-> 사용자가 여럿일 경우에 대비해 한 번에 하나의 스케쥴러만 작동할 수 있어야 한다.
4. 스케쥴러가 실행된 결과는 로그를 통해 기록해야 한다.
-> 따로 테이블을 설정해 스케쥴러 작업이 마무리 될 때 마다 로그 테이블에 insert 하는 방식으로 작업한다.
5. 이미지 저장에 실패하는 경우를 대비해야 하고, 이미지 저장 과정에서 오류가 발생하더라도 스케쥴러는 계속 진행돼야 한다.
-> try - catch 구문을 적절하게 사용하자.
6. 루트 디렉토리에서 1depth 아래에 있는 디렉토리는 프로젝트 디렉토리이다. (디렉토리 명이 프로젝트 명)
먼저 이미지 및 디렉토리 저장 작업을 특정 시간마다 실행할 수 있는 기능을 구현하자.
@PostConstruct
public void init() throws Exception {
scheduleTask();
}
public void scheduleTask() throws Exception {
log.debug("스케쥴 일정이 변경됐습니다.");
if (scheduledFuture != null) {
scheduledFuture.cancel(true);
}
ScheduleVO scheduleVO = scheduleService.getSchedule();
String cronExpression = String.format("0 %s %s %s * ?", scheduleVO.getScheduleMin(),
scheduleVO.getScheduleHour(), scheduleVO.getScheduleDay());
CronTrigger cronTrigger = new CronTrigger(cronExpression, TimeZone.getDefault());
scheduledFuture = taskScheduler.schedule(new Runnable() {
@Override
public void run() {
try {
directoryTraverse();
} catch (Exception e) {
log.error("스케쥴링 도중 오류가 발생했습니다.");
e.printStackTrace();
}
}
}, cronTrigger);
}
프론트엔드에서 스케쥴 일정을 변경하거나 스프링 컨테이너가 로드될 때 scheduleTask() 메서드가 실행된다.
이미 예약된 스케쥴러가 있다면 취소하고, 스케쥴 관련 설정을 초기화한다.
private final AtomicBoolean isRunning = new AtomicBoolean(false);
public void directoryTraverse() throws Exception {
if (isRunning.getAndSet(true)) {
log.debug("이미 스케쥴러가 실행중입니다.");
return;
}
try {
long startTime = System.nanoTime();
File rootDir;
try {
rootDir = new File(dirPath);
} catch (Exception e) {
log.error("해당 디렉토리에 파일이 없습니다. {}", dirPath);
e.printStackTrace();
return;
}
if (rootDir.exists()) projectTraverse(rootDir);
log.debug("작업 끝났습니다...");
long endTime = System.nanoTime(); // End timing
long timeElapsed = endTime - startTime;
log.debug("실행 시간 : " + timeElapsed / 1000000 + " 밀리초");
} finally {
isRunning.set(false);
}
}
directoryTraverse 메서드는 스케쥴러의 비즈니스 로직을 수행하는 메서드이다.
데이터베이스에 저장된 dirPath를 루트 디렉토리 경로로 설정하고, 해당 디렉토리 하위의 디렉토리 및 이미지를 데이터베이스에 등록하고, 수정사항을 반영한다.
AtomicBoolean 값을 사용해 한 번에 하나의 스케쥴러만 수행될 수 있도록 설정한다.
여기서 synchronized 키워드를 사용하면 이미 클라이언트의 http 요청을 pending하는 방식으로 작동하는데, 이미 스케쥴러가 실행 중인 경우 클라이언트에게 특정 메세지를 보여줘야 하는 경우에는 AtomicBoolean 을 사용하는 편이 합리적이다.
실제 운영 환경에서는 어디서 예외가 발생할 지 모르니, 특정 부분에서 예외가 발생했다고 스케쥴러 작업을 중단하지 않도록 try - catch 구문을 철저하게 작성하자.
프로젝트를 탐색할 때는 기본적으로 dfs 방식을 사용한다.
private void projectTraverse(File dir) throws Exception {
File[] projects = dir.listFiles(new FileFilter() {
@Override
public boolean accept(File pathname) {
return pathname.isDirectory() || isImage(pathname);
}
});
for (File project : projects) {
String projectName = project.getName();
String projectId = scheduleService.selectProjectId(projectName);
int projectCount = scheduleService.getProjectCount(projectName);
if (projectCount == 0) continue;
else log.debug("{} 프로젝트 등록을 시작합니다.", projectName);
imageCnt = 0;
fail1 = 0;
fail2 = 0;
fail3 = 0;
fail4 = 0;
directoryCnt = 0;
LocalDateTime nowInKorea = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
Timestamp startTime = Timestamp.valueOf(nowInKorea);
List<ImageVO> nasList = new ArrayList<>();
List<ImageDirVO> dirList = new ArrayList<>();
String absolutePath = project.getAbsolutePath();
Long createdDate = getCreatedDateByPath(absolutePath);
ImageDirParams param = ImageDirParams.builder()
.dirName(projectName)
.dirPath(absolutePath)
.firstRegisterTime(createdDate)
.build();
String dirPid = scheduleService.selectDirId(param);
ImageDirVO imageDirVO = ImageDirVO.builder()
.dirName(projectName)
.projectId(projectId)
.firstRegisterTime(createdDate)
.dirPath(absolutePath)
.build();
if (dirPid == null) {
imageDirVO = insertAndSelect(imageDirVO, param, null);
imageDirVO.setDirPath(absolutePath);
dirList.add(imageDirVO);
dirPid = imageDirVO.getDirId();
} else {
imageDirVO = scheduleService.selectImageDir(absolutePath);
imageDirVO.setDirPath(absolutePath);
dirList.add(imageDirVO);
}
if (project.exists()) recursive(project, nasList, projectId, dirPid, dirList);
List<ImageDateVO> dbList = scheduleService.selectImageDateList(projectId);
boolean flag = false;
log.debug("{} {}", projectId, projectName);
log.debug("{} 의 nas 파일 수는 {} 개 입니다.", project.getName(), nasList.size());
log.debug("{} 의 db 파일 수는 {} 개 입니다.", project.getName(), dbList.size());
if (dbList.size() != nasList.size()) flag = true;
if (!flag) {
Collections.sort(nasList, Comparator.comparing(ImageVO::getFirstRegisterDate));
for (int i = 0; i < dbList.size(); i++) {
Long nasTime = nasList.get(i).getFirstRegisterDate();
Long dbTime = dbList.get(i).getFirstRegisterDate();
if (nasTime.compareTo(dbTime) != 0) {
flag = true;
break;
}
}
}
if (flag) {
log.debug("동기화 작업을 시작합니다.");
recreateTable(nasList, projectId, dirList, true);
} else {
log.debug("동기화 할 내용이 없습니다.");
}
LocalDateTime nowInKorea2 = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
Timestamp endTime = Timestamp.valueOf(nowInKorea2);
// sheduleLogVO 설정 ...
scheduleService.insertNewLog(scheduleLogVO);
log.debug("로그를 저장했습니다.");
}
}
예외가 발생하는 종류를 여러 가지로 구분한다.
이미지의 메타데이터를 읽는 도중 예외가 발생할 수 있고..
확장자로 이미지를 인식했는데 실제로는 이미지 파일이 아닐 수도 있고...
이미지의 썸네일을 가져올 때 오류가 발생할 수 있고...
로그를 저장할 때는 어떤 이미지에서 어떤 예외가 몇 개 발생했는지 기록해야 하니, 예외의 종류를 정의하고 관리한다.
프로젝트가 수정된 여부를 확인할 때는 flag 변수를 사용한다.
실제 서버에 저장된 이미지의 수와 데이터베이스에 저장된 이미지의 수를 비교하고, 다르면 flag를 false로 설정한다.
이미지의 수가 같다면 각 이미지들의 최종 수정 날짜를 비교해 하나라도 다른 이미지가 있다면 flag를 false로 설정한다.
flag가 false라면 해당 프로젝트에 관련된 데이터베이스를 날리고 서버에 있는 내용으로 다시 등록한다.
수정된 요소들만 특정해서 데이터베이스에 업데이트하는 방식을 사용하면 더 빠르게 작업할 수 있지만, 이 방식으로 구현하려면 서버에서 수정되거나 삭제되는 내역을 감지해서 로그를 출력해 놓고 스케쥴러가 작업 할 때 마다 로그를 바탕으로 데이터베이스를 업데이트 해야 하는데...
이 방법은 너무 불편할 뿐만 아니라 nas 서버를 마운트한 위치에서 작업 시 로그가 남겨지지 않을 경우도 있어, 데이터의 확실한 정합성을 위해 프로젝트 관련 데이터베이스를 다시 작성하는 방식을 사용한다.
private void recreateTable(List<ImageVO> list, String projectId, List<ImageDirVO> dirList, boolean isDelete)
throws Exception {
if (isDelete) {
scheduleService.deleteByProjectId(projectId);
scheduleService.deleteImageDir(projectId);
}
for (ImageDirVO imageDir : dirList) deleteAndInsert(imageDir);
log.debug("넣을 이미지 수 : {}", list.size());
int forCnt = 0;
for (ImageVO image : list) {
forCnt++;
boolean isFail = false;
long startTime = System.nanoTime(); // End timing
File imageFile = new File(image.getImagePath());
Long createdDate = null;
try {
createdDate = getCreatedDateByPath(image.getImagePath());
} catch (Exception e) {
log.error("파일 이름을 읽는데 실패했습니다. {}", imageFile.getName());
e.printStackTrace();
fail3++;
isFail = true;
continue;
}
long startTime2 = System.nanoTime();
Metadata metadata = null;
try {
metadata = ImageMetadataReader.readMetadata(imageFile);
} catch (Exception e) {
log.error("메타데이터를 읽는데 실패했습니다. {}", imageFile.getAbsolutePath());
e.printStackTrace();
fail1++;
isFail = true;
continue;
}
GpsDirectory gpsDirectory = metadata.getFirstDirectoryOfType(GpsDirectory.class);
ExifSubIFDDirectory exifDirectory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
XmpDirectory xmpDirectory = metadata.getFirstDirectoryOfType(XmpDirectory.class);
JsonObject jsonMetadata = new JsonObject();
JsonObject jsonExif = new JsonObject();
JsonObject jsonDNA = new JsonObject();
JsonObject jsonXmp = new JsonObject();
if (xmpDirectory != null) {
for (Map.Entry<String, String> xmpEntry : xmpDirectory.getXmpProperties().entrySet()) {
if (xmpEntry.getKey().contains("DNA")) {
jsonDNA.addProperty(xmpEntry.getKey(), xmpEntry.getValue());
} else {
jsonXmp.addProperty(xmpEntry.getKey(), xmpEntry.getValue());
}
}
}
for (Directory directory : metadata.getDirectories()) {
for (Tag tag : directory.getTags()) {
jsonExif.addProperty(tag.getTagName(), tag.getDescription());
}
}
jsonMetadata.add("XMP", jsonXmp);
jsonMetadata.add("DNA", jsonDNA);
jsonMetadata.add("Exif", jsonExif);
String jsonString = jsonMetadata.toString();
long endTime2 = System.nanoTime(); // End timing
long timeElapsed2 = endTime2 - startTime2;
log.debug("메타데이터 추출에 걸리는 시간 : " + timeElapsed2 / 1000000 + " 밀리초");
if (gpsDirectory != null) {
latitude = gpsDirectory.getGeoLocation().getLatitude();
longitude = gpsDirectory.getGeoLocation().getLongitude();
int dirPid = Integer.parseInt(image.getDirPid());
if (!dirIdarr[dirPid]) {
try {
// 이미지가 촬영된 지역 얻어오는 로직
} catch (Exception e) {
isFail = true;
geoFailCnt++;
continue;
}
}
}
byte[] thumbnailImage = null;
try {
thumbnailImage = createThumbnail(imageFile);
} catch (Exception e) {
isFail = true;
fail2++;
log.error("썸네일 영상을 추출하는데 실패했습니다. 파일명 : {}", image.getImageName());
e.printStackTrace();
continue; // 수정 될 수 있음
}
Timestamp photoDate = null;
try {
photoDate = new Timestamp(exifDirectory.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL).getTime());
} catch (Exception e) {
isFail = true;
metadataFailCnt++;
log.error("촬영된 날짜를 가져오는데 실패했습니다. 파일명 : {}", image.getImageName());
e.printStackTrace();
continue; // 수정 될 수 있음
}
String encoded = null;
try {
encoded = Base64.getEncoder().encodeToString(thumbnailImage);
} catch (Exception e) {
isFail = true;
fail4++;
log.error("썸네일 영상을 인코딩하는데 실패했습니다. 파일명 : {}", image.getImageName());
e.printStackTrace();
continue;
}
// ImageVO 작성
scheduleService.insertImage(insertImage);
log.info(forCnt + " 번째 저장했습니다.");
String imageId = scheduleService.selectImage(image.getImagePath()).getImageID();
// MetadataVO 작성
scheduleService.insertMetadata(imageMetadata);
imageCnt++;
log.debug("저장했습니다. {} {}", imageCnt, insertImage.getImageName());
LocationVO dirLocation = scheduleService.selectImageDirLocation(insertImage.getDirPid());
long endTime = System.nanoTime();
long timeElapsed = endTime - startTime;
log.debug("이미지 등록에 걸린 총 시간 : " + timeElapsed / 1000000 + " 밀리초");
}
}
이미지를 저장할 때는 위에서 언급했듯 다양한 예외가 발생할 수 있다.
static 변수를 선언하고 이미지에서 발생하는 예외를 관리하자.
private byte[] createThumbnail(File file) throws Exception {
long startTime = System.nanoTime(); // Start timing
BufferedImage img = ImageIO.read(file);
int curHeight = img.getHeight() / 20;
int curWidth = img.getWidth() / 20;
BufferedImage resizedImage = new BufferedImage(curWidth, curHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = resizedImage.createGraphics();
/**
* 썸네일 생성 시 성능을 향상시키기 위해 사용됩니다.
*/
// g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
// g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
// g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(img, 0, 0, curWidth, curHeight, null);
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(resizedImage, "jpg", baos);
byte[] bytes = baos.toByteArray();
long endTime = System.nanoTime(); // End timing
long timeElapsed = endTime - startTime;
log.debug("이미지 썸네일 추출에 걸린 시간 : " + timeElapsed / 1000000 + " 밀리초");
return bytes;
}
이미지의 썸네일을 추출할 때는 이미지의 계단 현상에 주의해야 한다.
메타데이터에 썸네일 관련 내용이 없는 경우를 대비해서 원본 이미지를 리사이징해서 썸네일 이미지를 만드는 방식을 사용하는데, 이 때 imgscalr 라이브러리를 사용하니 계단 현상이 발생했다.
Graphics2D와 BufferedImage를 사용하니 계단 현상이 발생하지 않던데..
Graphics2D의 여러 속성을 통해 이미지의 품질을 높일 수 있다.
스케쥴러의 실행 시간이 중요하면 품질을 좀 낮추고.. 이런 방식으로 필요에 따라 수행하자.
private void recursive(File dir, List<ImageVO> list, String projectId, String dirPid, List<ImageDirVO> dirList)
throws Exception {
File[] files = dir.listFiles();
if (files == null)
return;
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
String projectName = file.getName();
String absolutePath = file.getAbsolutePath();
Long createdDate = getCreatedDateByPath(absolutePath);
ImageDirParams param = ImageDirParams.builder()
.dirName(projectName)
.dirPath(absolutePath)
.firstRegisterTime(createdDate)
.build();
String curDirPid = scheduleService.selectDirId(param);
ImageDirVO imageDirVO = ImageDirVO.builder()
.dirName(projectName)
.projectId(projectId)
.firstRegisterTime(createdDate)
.dirPath(absolutePath)
.build();
if (curDirPid == null) {
imageDirVO = insertAndSelect(imageDirVO, param, dirPid);
imageDirVO.setDirPath(absolutePath);
dirList.add(imageDirVO);
curDirPid = imageDirVO.getDirId();
} else {
imageDirVO = scheduleService.selectImageDir(absolutePath);
imageDirVO.setDirPath(absolutePath);
dirList.add(imageDirVO);
}
recursive(file, list, projectId, curDirPid, dirList);
} else {
if (isImage(file))
processImage(file, list, projectId, dirPid);
}
}
}
}
dfs 방식으로 디렉토리 구조를 순회하는데, 지금 선택된 파일이 디렉토리인 경우 계속해서 탐색하고, 이미지 인 경우 이미지 저장 로직을 수행한다.
특정 시간마다 수행되는 스케쥴 작업은 모든 루트 디렉토리 하위의 모든 프로젝트를 대상으로 진행되고, 특정 디렉토리만 선택해서 저장 로직을 수행하는 작업은 그렇지 않다.
전반적인 로직은 같으니... 위의 요소들을 고려해서 작성하자.
데이터베이스와 통신할 때는 PHANTOM READ 현상에 주의하자.
@Transactional 애너테이션을 사용해 데이터가 완전히 저장된 후 해당 데이터의 PK를 SELECT 하는 방식으로 진행해야 한다.
우선 이렇게 작동하는 코드를 작성한 후에는 리팩토링이 남아있다.
메서드를 작성할 때 Parameter의 개수를 최대한 줄이고... 관심사에 따라 코드를 분리하고 재사용하고...
Clean Code 원칙에 따라 리팩토링까지 수행하자.
'Solutions' 카테고리의 다른 글
[SQL Server] 지원하지 않는 TLS 버전 설정 (1) | 2024.06.05 |
---|---|
대용량 파일 업로드 처리 (30GB) (1) | 2023.12.03 |
[JavaScript] Shadow DOM 다루기 (0) | 2023.11.22 |
[Vue] map 함수와 Vue3의 반응형 시스템 (0) | 2023.11.16 |
[Git] Git 협업 시 충돌 해결 (1) | 2023.11.14 |
댓글
이 글 공유하기
다른 글
-
대용량 파일 업로드 처리 (30GB)
대용량 파일 업로드 처리 (30GB)
2023.12.03 -
[JavaScript] Shadow DOM 다루기
[JavaScript] Shadow DOM 다루기
2023.11.22 -
[Vue] map 함수와 Vue3의 반응형 시스템
[Vue] map 함수와 Vue3의 반응형 시스템
2023.11.16 -
[Git] Git 협업 시 충돌 해결
[Git] Git 협업 시 충돌 해결
2023.11.14