180 Commits

Author SHA1 Message Date
a5267d8065 Merge pull request 'select-dataset-list api solarPanelCnt 추가, spotless 적용' (#181) from feat/training_260324 into develop
Reviewed-on: #181
2026-04-02 17:45:16 +09:00
71d9835b03 select-dataset-list api solarPanelCnt 추가, spotless 적용 2026-04-02 17:44:27 +09:00
39f39a4f0c Merge pull request 'ModelType enum G4 추가' (#180) from feat/training_260324 into develop
Reviewed-on: #180
2026-04-02 16:55:14 +09:00
1df7142544 ModelType enum G4 추가 2026-04-02 16:54:25 +09:00
d99e18b38c val nan 일때 오류 수정, spotless 적용 2026-04-02 14:41:13 +09:00
d6aa612494 Merge pull request 'val nan 일때 오류 수정' (#179) from feat/training_260324 into develop
Reviewed-on: #179
2026-04-02 14:26:13 +09:00
8def356323 val nan 일때 오류 수정 2026-04-02 14:19:42 +09:00
dean
6cc9b54ba9 welcome 2026-04-01 08:55:17 +09:00
dean
50ad05b53b welcome 2026-03-31 17:44:08 +09:00
dean
822dbb252f test 2026-03-31 17:15:45 +09:00
dean
5e04df73af test 2026-03-31 17:15:29 +09:00
650b1695f0 Merge pull request '미사용 목록 주석 추가, 학습데이터 삭제 테스트' (#178) from feat/training_260324 into develop
Reviewed-on: #178
2026-03-27 18:02:34 +09:00
960e4215e0 미사용 목록 주석 추가, 학습데이터 삭제 테스트 2026-03-27 18:02:13 +09:00
08a220db4d Merge pull request '미사용 목록 주석 추가, 납품데이터 폴더 벨리데이션 위치 옮김' (#177) from feat/training_260324 into develop
Reviewed-on: #177
2026-03-27 17:04:25 +09:00
a9b49faa6c 미사용 목록 주석 추가, 납품데이터 폴더 벨리데이션 위치 옮김 2026-03-27 17:03:52 +09:00
b760e9874c Merge pull request '납품데이터셋 업로드 geojson 파일 수정' (#176) from feat/training_260324 into develop
Reviewed-on: #176
2026-03-27 10:56:57 +09:00
8698bf61d1 납품데이터셋 업로드 geojson 파일 수정 2026-03-27 10:56:38 +09:00
680e137284 Merge pull request '납품데이터셋 업로드 geojson 파일 수정' (#175) from feat/training_260324 into develop
Reviewed-on: #175
2026-03-27 10:18:15 +09:00
f4a81a34d6 납품데이터셋 업로드 geojson 파일 수정 2026-03-27 10:17:58 +09:00
479ad710e0 Merge pull request '납품데이터셋 업로드 geojson 파일 수정' (#174) from feat/training_260324 into develop
Reviewed-on: #174
2026-03-27 10:15:32 +09:00
3cb9840248 납품데이터셋 업로드 geojson 파일 수정 2026-03-27 10:14:44 +09:00
fc9543f195 Merge pull request 'feat/training_260324' (#173) from feat/training_260324 into develop
Reviewed-on: #173
2026-03-27 10:05:24 +09:00
73d0e03b08 납품데이터셋 업로드 geojson 파일 수정 2026-03-27 10:04:56 +09:00
50c965cb79 학습결과 파일 베스트 에폭 제외 삭제 추가, 납품데이터 등록 비동기 수정 2026-03-27 09:32:49 +09:00
dean
abca9467d8 docker 2026-03-26 20:07:37 +09:00
dean
4ed03f6e94 docker 2026-03-26 11:26:46 +09:00
04eddfce54 학습결과 파일 베스트 에폭 제외 삭제 2026-03-25 17:56:36 +09:00
f49b7cc850 Merge pull request '납품데이터 등록 수정' (#172) from feat/training_260324 into develop
Reviewed-on: #172
2026-03-25 12:52:25 +09:00
888c0e314b 납품데이터 등록 수정 2026-03-25 12:50:28 +09:00
6c043b0031 Merge pull request '납품데이터 등록 수정' (#171) from feat/training_260324 into develop
Reviewed-on: #171
2026-03-25 12:38:56 +09:00
531da09c5f 납품데이터 등록 수정 2026-03-25 12:37:27 +09:00
4da2a1f0d7 Merge pull request 'spotlessApply 적용' (#170) from feat/training_260324 into develop
Reviewed-on: #170
2026-03-25 12:33:04 +09:00
50b3f1ba62 spotlessApply 적용 2026-03-25 12:32:50 +09:00
f1f88c83e1 Merge pull request '납품 데이터 등록 api 추가' (#169) from feat/training_260324 into develop
Reviewed-on: #169
2026-03-25 12:30:51 +09:00
ff478452a6 납품 데이터 등록 api 추가 2026-03-25 12:30:30 +09:00
bf77725ef8 Merge pull request '납품 폴더 조회 api 추가' (#168) from feat/training_260324 into develop
Reviewed-on: #168
2026-03-24 17:30:18 +09:00
dec0f26999 납품 폴더 조회 api 추가 2026-03-24 17:30:04 +09:00
dfd4a42379 Merge pull request '납품 폴더 조회 api 추가' (#167) from feat/training_260324 into develop
Reviewed-on: #167
2026-03-24 17:19:39 +09:00
79272137ab 납품 폴더 조회 api 추가 2026-03-24 17:19:06 +09:00
73ea6176b4 Merge pull request '시스템 사용율 모니터링 기능 로그 수정' (#166) from feat/training_260303 into develop
Reviewed-on: #166
2026-03-19 17:38:45 +09:00
3d2a4049d3 시스템 사용율 모니터링 기능 로그 수정 2026-03-19 17:38:30 +09:00
26caf505b9 Merge pull request '시스템 사용율 모니터링 기능 추가' (#165) from feat/training_260303 into develop
Reviewed-on: #165
2026-03-19 17:09:08 +09:00
0cbaf53e86 시스템 사용율 모니터링 기능 추가 2026-03-19 17:08:37 +09:00
bd54854bc6 Merge pull request '시스템 사용율 모니터링 기능 테스트' (#164) from feat/training_260303 into develop
Reviewed-on: #164
2026-03-19 16:52:44 +09:00
80fd2bda3e 시스템 사용율 모니터링 기능 테스트 2026-03-19 16:52:26 +09:00
ca3d115d0e Merge pull request '시스템 사용율 모니터링 기능 테스트' (#163) from feat/training_260303 into develop
Reviewed-on: #163
2026-03-19 16:36:56 +09:00
fb647e5991 시스템 사용율 모니터링 기능 테스트 2026-03-19 16:36:39 +09:00
831ba3e616 Merge pull request '시스템 사용율 모니터링 기능 테스트' (#162) from feat/training_260303 into develop
Reviewed-on: #162
2026-03-19 16:27:19 +09:00
87575a62f7 시스템 사용율 모니터링 기능 테스트 2026-03-19 16:27:05 +09:00
a4b5e20db2 Merge pull request '시스템 사용율 모니터링 기능 테스트' (#161) from feat/training_260303 into develop
Reviewed-on: #161
2026-03-19 16:12:45 +09:00
246c11f8b0 시스템 사용율 모니터링 기능 테스트 2026-03-19 16:12:27 +09:00
da260f35ea Merge pull request '시스템 사용율 모니터링 기능 테스트' (#160) from feat/training_260303 into develop
Reviewed-on: #160
2026-03-19 15:46:40 +09:00
2c1f9bdf5c 시스템 사용율 모니터링 기능 테스트 2026-03-19 15:46:23 +09:00
6cf81bf60f Merge pull request '시스템 사용율 모니터링 기능 테스트' (#159) from feat/training_260303 into develop
Reviewed-on: #159
2026-03-19 15:41:34 +09:00
5799f7dfb2 시스템 사용율 모니터링 기능 테스트 2026-03-19 15:41:18 +09:00
ed95829a34 Merge pull request '시스템 사용율 모니터링 기능 테스트' (#158) from feat/training_260303 into develop
Reviewed-on: #158
2026-03-19 15:37:47 +09:00
9f428e9572 시스템 사용율 모니터링 기능 테스트 2026-03-19 15:37:26 +09:00
52ffe53815 Merge pull request 'feat/training_260303' (#157) from feat/training_260303 into develop
Reviewed-on: #157
2026-03-19 15:25:37 +09:00
904968a1be 시스템 사용율 모니터링 기능 테스트 2026-03-19 15:25:20 +09:00
4b44be6a29 시스템 사용율 모니터링 기능 테스트 2026-03-19 15:25:11 +09:00
5887a954ea Merge pull request '시스템 사용율 모니터링 기능 테스트' (#156) from feat/training_260303 into develop
Reviewed-on: #156
2026-03-19 15:24:38 +09:00
f9f0662f8e 시스템 사용율 모니터링 기능 테스트 2026-03-19 15:24:03 +09:00
dean
72bc2fd47b test 2026-03-17 21:02:21 +09:00
7416327cc3 ai 학습실행 run command 수정 2026-03-11 10:18:33 +09:00
da31bd9d99 ai 학습실행 run command 수정 2026-03-10 23:09:39 +09:00
f3e5347335 ai 학습실행 run command 수정 2026-03-10 22:48:10 +09:00
7d5581f60c 데이터셋 삭제 플래그 추가 2026-03-10 19:09:00 +09:00
b4428217ea hello 2026-03-10 18:01:03 +09:00
8a63fdacdd confict 2026-03-10 17:27:14 +09:00
cb2e42143a confict 2026-03-10 17:23:27 +09:00
997e85c0cc 운영환경처리 2026-03-10 17:22:27 +09:00
731ca59475 심볼릭 링크로 수정 2026-03-10 17:16:54 +09:00
fe6d37456d 하드링크 실패 시 심볼릭 링크로 만들어보기 2026-03-10 16:56:35 +09:00
6c98a48a5d 에러 수정 2026-03-10 16:34:02 +09:00
81b69caa99 운영환경처리 2026-03-10 16:00:23 +09:00
7fce070686 spotless 2026-03-10 15:25:32 +09:00
8d83505ee7 운영환경처리 2026-03-10 15:00:31 +09:00
0ff38b24d4 Merge branch 'feat/training_260202' into develop
# Conflicts:
#	src/main/java/com/kamco/cd/training/train/service/JobRecoveryOnStartupService.java
2026-03-10 14:22:14 +09:00
5c082f7c9d 운영환경처리 2026-03-10 08:39:42 +09:00
43d0e55cb7 Merge branch 'develop' of https://kamco.git.gs.dabeeo.com/MVPTeam/kamco-train-api into develop 2026-03-04 06:12:27 +09:00
df3bedfbda prod 2026-03-04 05:35:21 +09:00
c26a48d07d prod 2026-03-04 05:25:04 +09:00
1c7c213977 Merge pull request '학습 실패 처리 수정' (#155) from feat/training_260303 into develop
Reviewed-on: #155
2026-03-04 01:43:56 +09:00
6583a45abd 학습 실패 처리 수정 2026-03-04 01:43:34 +09:00
b15f77d894 Merge pull request '학습 실패 처리 수정' (#154) from feat/training_260303 into develop
Reviewed-on: #154
2026-03-04 01:34:40 +09:00
3bcd99f0db 학습 실패 처리 수정 2026-03-04 01:34:16 +09:00
5513cd60a0 Merge pull request '리커버리 수정' (#153) from feat/training_260303 into develop
Reviewed-on: #153
2026-03-04 01:18:19 +09:00
7b35d26a13 리커버리 수정 2026-03-04 01:18:04 +09:00
d92ff88ef7 Merge pull request '리커버리 수정' (#152) from feat/training_260303 into develop
Reviewed-on: #152
2026-03-04 01:15:04 +09:00
bfcddd0327 리커버리 수정 2026-03-04 01:14:35 +09:00
e193330f99 Merge pull request '리커버리 수정' (#151) from feat/training_260303 into develop
Reviewed-on: #151
2026-03-04 01:00:45 +09:00
6c0184597d 리커버리 수정 2026-03-04 01:00:26 +09:00
1e8a8d8dad Merge pull request '리커버리 수정 테스트 로그 추가,' (#150) from feat/training_260303 into develop
Reviewed-on: #150
2026-03-04 00:44:03 +09:00
eb7680b952 리커버리 수정 테스트 로그 추가, 2026-03-04 00:43:20 +09:00
2c357ebf27 Merge pull request '리커버리 수정 테스트,,' (#149) from feat/training_260303 into develop
Reviewed-on: #149
2026-03-04 00:30:36 +09:00
0daaa1c8cb 리커버리 수정 테스트,, 2026-03-04 00:30:17 +09:00
96383595df Merge pull request '리커버리 수정' (#148) from feat/training_260303 into develop
Reviewed-on: #148
2026-03-04 00:22:09 +09:00
c5e03f7ca8 리커버리 수정 2026-03-04 00:21:51 +09:00
df2acc4dfb Merge pull request '리커버리 수정' (#147) from feat/training_260303 into develop
Reviewed-on: #147
2026-03-04 00:06:21 +09:00
693de354d2 리커버리 수정 2026-03-04 00:06:04 +09:00
62cdc5015e Merge pull request '학습 리커버리 테스트' (#146) from feat/training_260303 into develop
Reviewed-on: #146
2026-03-03 23:45:46 +09:00
5c11263d55 랃그 2026-03-03 23:45:10 +09:00
dd5031ae3a Merge pull request 'log.info 추가' (#145) from feat/training_260303 into develop
Reviewed-on: #145
2026-03-03 23:40:44 +09:00
c369e01ada log.info 추가 2026-03-03 23:40:18 +09:00
54991622f1 log.info 추가 2026-03-03 23:31:25 +09:00
17d69486ec spotless 적용 2026-03-03 23:04:55 +09:00
948f1061da Merge pull request 'spotless 적용' (#144) from feat/training_260202 into develop
Reviewed-on: #144
2026-03-03 23:01:39 +09:00
42438b3cd5 Merge pull request '하드링크 수정' (#143) from feat/training_260202 into develop
Reviewed-on: #143
2026-03-03 22:51:50 +09:00
524ae200b0 prod 2026-03-03 08:44:31 +09:00
4f763d3c2e prod 2026-03-03 08:44:01 +09:00
9ebf525387 prod 2026-03-03 08:42:16 +09:00
4ab672a96e 중복 경고 2026-02-28 01:56:48 +09:00
7d2a367e3f Merge pull request '리커버리 삭제' (#142) from feat/training_260202 into develop
Reviewed-on: #142
2026-02-28 01:24:49 +09:00
67a67749c3 Merge pull request '리커버리 추가' (#141) from feat/training_260202 into develop
Reviewed-on: #141
2026-02-28 01:02:10 +09:00
251307b5c9 Merge pull request '하드링크 수정' (#140) from feat/training_260202 into develop
Reviewed-on: #140
2026-02-27 23:31:24 +09:00
8423a03d31 Merge pull request '하드링크 로그 추가' (#139) from feat/training_260202 into develop
Reviewed-on: #139
2026-02-27 23:12:17 +09:00
d6cdf6b690 Merge pull request '하드링크 로그 추가' (#138) from feat/training_260202 into develop
Reviewed-on: #138
2026-02-27 22:51:51 +09:00
9cfa299e58 Merge pull request 'feat/training_260202' (#137) from feat/training_260202 into develop
Reviewed-on: #137
2026-02-24 16:55:35 +09:00
a137e71420 Merge pull request 'feat/training_260202' (#136) from feat/training_260202 into develop
Reviewed-on: #136
2026-02-24 15:11:12 +09:00
f08f80622f Merge pull request 'feat/training_260202' (#135) from feat/training_260202 into develop
Reviewed-on: #135
2026-02-23 14:31:11 +09:00
e565fd7a34 Merge pull request '전이학습 상세 수정' (#134) from feat/training_260202 into develop
Reviewed-on: #134
2026-02-20 18:34:58 +09:00
8c45b39dcc Merge pull request 'feat/training_260202' (#133) from feat/training_260202 into develop
Reviewed-on: #133
2026-02-20 18:22:45 +09:00
b119f333ac Merge pull request 'best epoch 파일 선택 수정' (#132) from feat/training_260202 into develop
Reviewed-on: #132
2026-02-20 15:41:58 +09:00
bbe04ee458 Merge pull request 'best epoch 파일 선택 수정' (#131) from feat/training_260202 into develop
Reviewed-on: #131
2026-02-20 15:31:50 +09:00
fab3c83a69 Merge pull request 'best epoch 파일 선택 수정' (#130) from feat/training_260202 into develop
Reviewed-on: #130
2026-02-20 15:15:39 +09:00
fb87a0f32f Merge pull request '중복 수정 제거' (#129) from feat/training_260202 into develop
Reviewed-on: #129
2026-02-20 14:31:15 +09:00
63794ec4ec Merge pull request 'feat/training_260202' (#128) from feat/training_260202 into develop
Reviewed-on: #128
2026-02-20 14:25:13 +09:00
bf6dc9740f Merge pull request 'tmp 하드링크 수정' (#127) from feat/training_260202 into develop
Reviewed-on: #127
2026-02-20 13:37:04 +09:00
a23bc8dd67 Merge pull request 'tmp 하드링크 수정' (#126) from feat/training_260202 into develop
Reviewed-on: #126
2026-02-20 12:30:23 +09:00
13023a06cc Merge pull request 'feat/training_260202' (#125) from feat/training_260202 into develop
Reviewed-on: #125
2026-02-20 12:23:37 +09:00
28b50bd949 Merge pull request 'test json 수정' (#124) from feat/training_260202 into develop
Reviewed-on: #124
2026-02-20 12:20:36 +09:00
78ab928459 Merge pull request 'ing-cnt 로직에 step2도 추가, transactional' (#123) from feat/training_260202 into develop
Reviewed-on: #123
2026-02-20 12:05:40 +09:00
ae3601cff5 Merge pull request '비밀번호 변경 security 로직 수정' (#122) from feat/training_260202 into develop
Reviewed-on: #122
2026-02-20 11:37:08 +09:00
5f62f4a209 Merge pull request 'test 실행 시 회차별 데이터 적재하기' (#121) from feat/training_260202 into develop
Reviewed-on: #121
2026-02-19 18:19:40 +09:00
29bf155b4f Merge pull request 'LogErrorLevel -> CodeExpose 추가' (#120) from feat/training_260202 into develop
Reviewed-on: #120
2026-02-19 17:35:45 +09:00
da03f8b749 Merge pull request '모델학습관리 > 모델별 진행 상황 API 추가' (#119) from feat/training_260202 into develop
Reviewed-on: #119
2026-02-19 17:17:46 +09:00
6a2deff93b Merge pull request '모델학습관리 > 목록 API 메모,작성자 추가로 인한 수정' (#118) from feat/training_260202 into develop
Reviewed-on: #118
2026-02-19 15:35:03 +09:00
b0a99afcd3 Merge pull request '모델학습 2단계 패키징 시작,종료일시,상태 로직 추가' (#117) from feat/training_260202 into develop
Reviewed-on: #117
2026-02-19 14:44:12 +09:00
eedf72d7aa Merge pull request '공통코드 common-code 로 prefix 변경' (#116) from feat/training_260202 into develop
Reviewed-on: #116
2026-02-19 11:39:23 +09:00
25e9941464 Merge pull request '로그관리 로직 커밋' (#115) from feat/training_260202 into develop
Reviewed-on: #115
2026-02-19 11:16:40 +09:00
a0da0392cf Merge pull request '압축해제 시, 동일 폴더가 있으면 삭제 후 재업로드' (#114) from feat/training_260202 into develop
Reviewed-on: #114
2026-02-18 16:37:13 +09:00
a3ebee12b5 Merge pull request 'feat/training_260202' (#113) from feat/training_260202 into develop
Reviewed-on: #113
2026-02-18 16:29:55 +09:00
c5b14ca09d Merge pull request '업로드 시 uid로 중복체크 -> 삭제인 row는 제외하기' (#112) from feat/training_260202 into develop
Reviewed-on: #112
2026-02-18 15:40:38 +09:00
44b3b857b1 Merge pull request 'feat/training_260202' (#111) from feat/training_260202 into develop
Reviewed-on: #111
2026-02-18 15:29:25 +09:00
63124455fd Merge pull request '1단계 실행 시, 시작시간 update 추가' (#110) from feat/training_260202 into develop
Reviewed-on: #110
2026-02-18 13:06:33 +09:00
e75ea8d8a5 Merge pull request '하이퍼 파라미터 수정' (#109) from feat/training_260202 into develop
Reviewed-on: #109
2026-02-13 15:00:50 +09:00
31ac4209c3 Merge pull request '하이퍼 파라미터 수정' (#108) from feat/training_260202 into develop
Reviewed-on: #108
2026-02-13 14:47:19 +09:00
df09935789 Merge pull request '하이퍼 파라미터 수정' (#107) from feat/training_260202 into develop
Reviewed-on: #107
2026-02-13 14:42:54 +09:00
bb15b1b0f2 Merge pull request '하이퍼 파라미터 수정' (#106) from feat/training_260202 into develop
Reviewed-on: #106
2026-02-13 14:38:01 +09:00
f4d491ed94 Merge pull request '하이퍼 파라미터 수정' (#105) from feat/training_260202 into develop
Reviewed-on: #105
2026-02-13 14:31:13 +09:00
96cb7d2f23 Merge pull request '사용가능 용량 API 수정' (#104) from feat/training_260202 into develop
Reviewed-on: #104
2026-02-13 14:19:26 +09:00
cc6305b0df Merge pull request '이어하기 수정' (#103) from feat/training_260202 into develop
Reviewed-on: #103
2026-02-13 14:08:51 +09:00
3916b13876 Merge pull request '이어하기 수정' (#102) from feat/training_260202 into develop
Reviewed-on: #102
2026-02-13 14:04:52 +09:00
ee4a06df30 Merge pull request '이어하기 수정' (#101) from feat/training_260202 into develop
Reviewed-on: #101
2026-02-13 13:57:48 +09:00
bb5ff7c3cd Merge pull request '이어하기 로그 수정' (#100) from feat/training_260202 into develop
Reviewed-on: #100
2026-02-13 13:27:08 +09:00
312a96dda1 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#99) from feat/training_260202 into develop
Reviewed-on: #99
2026-02-13 13:24:10 +09:00
e38231e06d Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#98) from feat/training_260202 into develop
Reviewed-on: #98
2026-02-13 13:17:41 +09:00
bf6e45d706 Merge pull request 'feat/training_260202' (#97) from feat/training_260202 into develop
Reviewed-on: #97
2026-02-13 12:53:32 +09:00
7fa8921a25 Merge pull request 'flush 추가해보기' (#96) from feat/training_260202 into develop
Reviewed-on: #96
2026-02-13 12:47:11 +09:00
7c940351d9 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#95) from feat/training_260202 into develop
Reviewed-on: #95
2026-02-13 12:39:21 +09:00
6b834da912 Merge pull request 'feat/training_260202' (#94) from feat/training_260202 into develop
Reviewed-on: #94
2026-02-13 12:25:26 +09:00
25aaa97d65 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#93) from feat/training_260202 into develop
Reviewed-on: #93
2026-02-13 12:21:11 +09:00
da9d47ae4a Merge pull request '주석 처리' (#92) from feat/training_260202 into develop
Reviewed-on: #92
2026-02-13 12:10:46 +09:00
7d6a77bf2a Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#91) from feat/training_260202 into develop
Reviewed-on: #91
2026-02-13 12:01:01 +09:00
26828d0968 add log 2026-02-13 12:00:54 +09:00
e2dbae15c0 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#90) from feat/training_260202 into develop
Reviewed-on: #90
2026-02-13 11:58:55 +09:00
b246034632 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#89) from feat/training_260202 into develop
Reviewed-on: #89
2026-02-13 11:58:25 +09:00
687ea82d78 Merge pull request 'feat/training_260202' (#88) from feat/training_260202 into develop
Reviewed-on: #88
2026-02-13 10:51:07 +09:00
4ac0f19908 Merge pull request '파일 count 기능 추가' (#87) from feat/training_260202 into develop
Reviewed-on: #87
2026-02-13 10:38:48 +09:00
9e5e7595eb Merge pull request '학습실행 step1 할 때 best epoch 업데이트' (#86) from feat/training_260202 into develop
Reviewed-on: #86
2026-02-13 10:18:26 +09:00
9cd9274e99 Merge pull request '학습데이터 목록 파일 단위 MB 나오게 하기' (#85) from feat/training_260202 into develop
Reviewed-on: #85
2026-02-13 09:48:08 +09:00
5d82f3ecfe Merge pull request 'tmp 파일 링크 수정' (#84) from feat/training_260202 into develop
Reviewed-on: #84
2026-02-13 09:11:05 +09:00
2ce249ab33 Merge pull request 'tmp 파일 링크 수정' (#83) from feat/training_260202 into develop
Reviewed-on: #83
2026-02-13 08:44:37 +09:00
e34bf68de0 Merge pull request 'tmp 파일 링크 수정' (#82) from feat/training_260202 into develop
Reviewed-on: #82
2026-02-13 08:33:58 +09:00
862bda0cb9 Merge pull request '이어하기 수정' (#81) from feat/training_260202 into develop
Reviewed-on: #81
2026-02-12 23:02:12 +09:00
90f7b17d07 Merge pull request '학습데이터 다운로드 파일 정보 API 추가' (#80) from feat/training_260202 into develop
Reviewed-on: #80
2026-02-12 22:58:47 +09:00
2128baa46a Merge pull request 'feat/training_260202' (#79) from feat/training_260202 into develop
Reviewed-on: #79
2026-02-12 22:26:26 +09:00
875c30f467 Merge pull request 'feat/training_260202' (#78) from feat/training_260202 into develop
Reviewed-on: #78
2026-02-12 21:52:16 +09:00
2b29cd1ac6 Merge pull request '파라미터 변경' (#77) from feat/training_260202 into develop
Reviewed-on: #77
2026-02-12 21:30:18 +09:00
9206fff5d0 Merge pull request 'feat/training_260202' (#76) from feat/training_260202 into develop
Reviewed-on: #76
2026-02-12 21:17:33 +09:00
81 changed files with 4145 additions and 523 deletions

6
.gitignore vendored
View File

@@ -72,3 +72,9 @@ docker-compose.override.yml
*.swo *.swo
*~ *~
!/CLAUDE.md !/CLAUDE.md
### SSL Certificates ###
nginx/ssl/
*.crt
*.key
*.pem

415
DEPLOY.md Normal file
View File

@@ -0,0 +1,415 @@
# KAMCO Training API 배포 가이드 (RedHat 9.6)
## 빠른 배포 (Quick Start)
이 문서는 RedHat 9.6 환경에서 HTTPS로 KAMCO Training API를 배포하는 방법을 설명합니다.
**접속 URL**:
- `https://api.train-kamco.com`
- `https://train-kamco.com`
## 사전 요구사항
- [x] Docker & Docker Compose 설치
- [x] Git 설치
- [x] sudo 권한
- [x] 포트 80, 443 사용 가능
## 1단계: /etc/hosts 설정
```bash
# root 권한으로 도메인 추가
echo "127.0.0.1 api.train-kamco.com train-kamco.com" | sudo tee -a /etc/hosts
# 확인
cat /etc/hosts | grep train-kamco
```
**예상 결과**:
```
127.0.0.1 api.train-kamco.com train-kamco.com
```
## 2단계: 방화벽 설정 (필요시)
```bash
# 방화벽 상태 확인
sudo firewall-cmd --state
# HTTP/HTTPS 포트 개방
sudo firewall-cmd --permanent --add-port=80/tcp
sudo firewall-cmd --permanent --add-port=443/tcp
# 방화벽 재로드
sudo firewall-cmd --reload
# 확인
sudo firewall-cmd --list-ports
```
**예상 결과**: `80/tcp 443/tcp`
## 3단계: 프로젝트 디렉토리로 이동
```bash
cd /path/to/kamco-train-api
# 현재 위치 확인
pwd
# 예상: /home/username/kamco-train-api
```
## 4단계: 파일 구조 확인
배포 전 필수 파일이 모두 있는지 확인하세요:
```bash
# SSL 인증서 확인
ls -la nginx/ssl/
# 예상 결과:
# train-kamco.com.crt (인증서)
# train-kamco.com.key (개인 키)
# openssl.cnf (설정 파일)
```
```bash
# Docker Compose 파일 확인
ls -la docker-compose-prod.yml nginx/nginx.conf
# 예상: 두 파일 모두 존재
```
## 5단계: Docker 네트워크 생성 (최초 1회)
```bash
# kamco-cds 네트워크가 있는지 확인
docker network ls | grep kamco-cds
# 없으면 생성
docker network create kamco-cds
```
## 6단계: Docker Compose 배포
```bash
# 기존 컨테이너 중지 (있는 경우)
docker-compose -f docker-compose-prod.yml down
# 새로운 이미지 빌드 및 실행
docker-compose -f docker-compose-prod.yml up -d --build
# 컨테이너 상태 확인
docker-compose -f docker-compose-prod.yml ps
```
**예상 결과**:
```
NAME STATUS
kamco-cd-nginx Up (healthy)
kamco-cd-training-api Up (healthy)
```
## 7단계: 배포 확인
### 컨테이너 로그 확인
```bash
# Nginx 로그
docker logs kamco-cd-nginx --tail 50
# API 로그
docker logs kamco-cd-training-api --tail 50
# 실시간 로그 (Ctrl+C로 종료)
docker-compose -f docker-compose-prod.yml logs -f
```
### HTTP → HTTPS 리다이렉트 테스트
```bash
# HTTP 접속 시 HTTPS로 리다이렉트되는지 확인
curl -I http://api.train-kamco.com
curl -I http://train-kamco.com
# 예상 결과: 301 Moved Permanently
# Location: https://api.train-kamco.com/ 또는 https://train-kamco.com/
```
### HTTPS 헬스체크
```bash
# -k 플래그: 사설 인증서 경고 무시
curl -k https://api.train-kamco.com/monitor/health
curl -k https://train-kamco.com/monitor/health
# 예상 결과: {"status":"UP","components":{...}}
```
### 브라우저 테스트
브라우저에서 다음 URL에 접속:
- `https://api.train-kamco.com/monitor/health`
- `https://train-kamco.com/monitor/health`
**사설 인증서 경고**:
- "안전하지 않음" 경고가 표시되면 **"고급"** → **"계속 진행"** 클릭
## 8단계: SSL 인증서 확인 (선택사항)
```bash
# 인증서 정보 확인
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout | head -30
# 유효 기간 확인 (100년)
openssl x509 -in nginx/ssl/train-kamco.com.crt -noout -dates
# SAN (멀티 도메인) 확인
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout | grep -A1 "Subject Alternative Name"
# 예상 결과:
# X509v3 Subject Alternative Name:
# DNS:api.train-kamco.com, DNS:train-kamco.com
```
## 트러블슈팅
### 문제 1: "Connection refused"
**원인**: 컨테이너가 실행되지 않음
**해결**:
```bash
# 컨테이너 상태 확인
docker ps -a | grep kamco-cd
# 컨테이너 재시작
docker-compose -f docker-compose-prod.yml restart
# 로그 확인
docker logs kamco-cd-nginx
docker logs kamco-cd-training-api
```
### 문제 2: "502 Bad Gateway"
**원인**: Nginx는 실행 중이지만 API 컨테이너가 준비되지 않음
**해결**:
```bash
# API 컨테이너 상태 확인
docker logs kamco-cd-training-api
# API 헬스체크 (컨테이너 내부에서)
docker exec kamco-cd-nginx wget -qO- http://kamco-changedetection-api:8080/monitor/health
# API 컨테이너 재시작
docker-compose -f docker-compose-prod.yml restart kamco-changedetection-api
```
### 문제 3: "Name or service not known"
**원인**: /etc/hosts에 도메인이 설정되지 않음
**해결**:
```bash
# /etc/hosts 확인
cat /etc/hosts | grep train-kamco
# 없으면 추가
echo "127.0.0.1 api.train-kamco.com train-kamco.com" | sudo tee -a /etc/hosts
```
### 문제 4: 포트 80 또는 443이 이미 사용 중
**원인**: 다른 프로세스가 포트를 사용 중
**해결**:
```bash
# 포트 사용 확인
sudo lsof -i :80
sudo lsof -i :443
# 사용 중인 프로세스 종료 (예: httpd, nginx)
sudo systemctl stop httpd
sudo systemctl stop nginx
# Docker Compose 재시작
docker-compose -f docker-compose-prod.yml restart
```
### 문제 5: SELinux 권한 오류
**원인**: SELinux가 Docker 볼륨 마운트를 차단
**해결**:
```bash
# SELinux 상태 확인
getenforce
# Permissive 모드로 임시 변경 (재부팅 시 초기화됨)
sudo setenforce 0
# 영구 변경 (권장하지 않음)
sudo vi /etc/selinux/config
# SELINUX=permissive 또는 SELINUX=disabled로 변경
```
## 컨테이너 관리 명령어
### 시작/중지/재시작
```bash
# 시작
docker-compose -f docker-compose-prod.yml up -d
# 중지
docker-compose -f docker-compose-prod.yml down
# 재시작
docker-compose -f docker-compose-prod.yml restart
# 특정 서비스만 재시작
docker-compose -f docker-compose-prod.yml restart nginx
docker-compose -f docker-compose-prod.yml restart kamco-changedetection-api
```
### 로그 확인
```bash
# 전체 로그
docker-compose -f docker-compose-prod.yml logs
# 특정 서비스 로그
docker-compose -f docker-compose-prod.yml logs nginx
docker-compose -f docker-compose-prod.yml logs kamco-changedetection-api
# 실시간 로그
docker-compose -f docker-compose-prod.yml logs -f
# 마지막 N줄만 보기
docker logs kamco-cd-nginx --tail 100
```
### 컨테이너 상태 확인
```bash
# 실행 중인 컨테이너
docker-compose -f docker-compose-prod.yml ps
# 상세 정보
docker inspect kamco-cd-nginx
docker inspect kamco-cd-training-api
# 리소스 사용량
docker stats kamco-cd-nginx kamco-cd-training-api
```
### 컨테이너 내부 접속
```bash
# Nginx 컨테이너 내부 접속
docker exec -it kamco-cd-nginx sh
# API 컨테이너 내부 접속
docker exec -it kamco-cd-training-api sh
# 내부에서 빠져나오기
exit
```
## 업데이트 및 재배포
### 코드 업데이트 후 재배포
```bash
# 1. Git pull (코드 업데이트)
git pull origin develop
# 2. JAR 파일 빌드 (Jenkins에서 수행하는 경우 생략)
./gradlew clean build -x test
# 3. 컨테이너 재빌드 및 재시작
docker-compose -f docker-compose-prod.yml down
docker-compose -f docker-compose-prod.yml up -d --build
# 4. 로그 확인
docker-compose -f docker-compose-prod.yml logs -f
```
### 설정 파일만 변경한 경우
```bash
# nginx.conf 또는 docker-compose-prod.yml 변경 시
docker-compose -f docker-compose-prod.yml down
docker-compose -f docker-compose-prod.yml up -d
# 또는
docker-compose -f docker-compose-prod.yml restart nginx
```
## 모니터링
### 헬스체크 엔드포인트
```bash
# API 헬스체크
curl -k https://api.train-kamco.com/monitor/health
# 예상 결과:
# {
# "status": "UP",
# "components": {
# "db": {"status": "UP"},
# "diskSpace": {"status": "UP"}
# }
# }
```
### 시스템 리소스
```bash
# 디스크 사용량
df -h
# 메모리 사용량
free -h
# Docker 이미지 및 컨테이너 용량
docker system df
```
## 보안 권장 사항
1. **사설 인증서**: 현재 사설 인증서를 사용 중입니다. 프로덕션 환경에서는 **Let's Encrypt** 또는 **GlobalSign** 같은 공인 인증서 사용을 권장합니다.
2. **방화벽**: 필요한 포트(80, 443)만 개방하고, 불필요한 포트는 차단하세요.
3. **정기 업데이트**: Docker 이미지와 시스템 패키지를 정기적으로 업데이트하세요.
4. **로그 모니터링**: 정기적으로 로그를 확인하여 비정상적인 활동을 감지하세요.
5. **백업**: SSL 인증서 키 파일(`train-kamco.com.key`)과 데이터베이스를 정기적으로 백업하세요.
## 참고 문서
- **SSL 인증서 설정**: [nginx/SSL_SETUP.md](nginx/SSL_SETUP.md)
- **프로젝트 개요**: [README.md](README.md)
- **CLAUDE.md**: [CLAUDE.md](CLAUDE.md)
## 지원
문제가 발생하면 다음을 확인하세요:
1. 컨테이너 로그: `docker-compose -f docker-compose-prod.yml logs`
2. 컨테이너 상태: `docker-compose -f docker-compose-prod.yml ps`
3. /etc/hosts 설정: `cat /etc/hosts | grep train-kamco`
4. 방화벽 상태: `sudo firewall-cmd --list-ports`
---
**배포 완료!** 🎉
접속 URL:
- `https://api.train-kamco.com`
- `https://train-kamco.com`

443
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,443 @@
# KAMCO Train API - Production Deployment Guide
프로덕션 환경 배포 가이드
## 목차
- [사전 요구사항](#사전-요구사항)
- [초기 설정](#초기-설정)
- [배포 순서](#배포-순서)
- [개별 서비스 관리](#개별-서비스-관리)
- [롤백 절차](#롤백-절차)
- [모니터링 및 헬스체크](#모니터링-및-헬스체크)
- [트러블슈팅](#트러블슈팅)
---
## 사전 요구사항
### 시스템 요구사항
- Docker Engine 20.10+
- Docker Compose 2.0+
- 최소 메모리: 4GB
- 디스크 공간: 20GB 이상
### 네트워크 요구사항
- 도메인 설정:
- `train-kamco.com` → 서버 IP (Web UI)
- `api.train-kamco.com` → 서버 IP (API)
- 포트:
- 80 (HTTP)
- 443 (HTTPS)
- 8080 (API - internal)
- 3002 (Web - internal)
### 필수 파일
- SSL 인증서:
- `nginx/ssl/train-kamco.com.crt`
- `nginx/ssl/train-kamco.com.key`
- 환경 설정:
- `application-prod.yml` (API 설정)
- `.env` 파일 (IMAGE_TAG 등)
---
## 초기 설정
### 1. Docker 네트워크 생성
```bash
# kamco-cds 네트워크 생성 (최초 1회만)
docker network create kamco-cds
# 네트워크 확인
docker network ls | grep kamco-cds
```
### 2. SSL 인증서 배치
```bash
# 인증서 디렉토리 생성
mkdir -p nginx/ssl
# 인증서 파일 복사
cp /path/to/train-kamco.com.crt nginx/ssl/
cp /path/to/train-kamco.com.key nginx/ssl/
# 권한 설정
chmod 600 nginx/ssl/train-kamco.com.key
chmod 644 nginx/ssl/train-kamco.com.crt
```
### 3. 환경 변수 설정
```bash
# .env 파일 생성
cat > .env << EOF
IMAGE_TAG=latest
SPRING_PROFILES_ACTIVE=prod
TZ=Asia/Seoul
EOF
```
### 4. 볼륨 디렉토리 생성
```bash
# 데이터 디렉토리 생성
mkdir -p ./app/model_output
mkdir -p ./app/train_dataset
# 권한 설정
chmod -R 755 ./app
```
---
## 배포 순서
### 전체 스택 초기 배포
**중요**: 반드시 아래 순서대로 실행해야 합니다.
```bash
# 1. Nginx 시작
docker-compose -f docker-compose-nginx.yml up -d
# 2. Nginx 상태 확인
docker ps | grep kamco-train-nginx
# 3. API 서비스 시작
docker-compose -f docker-compose-prod.yml up -d
# 4. API 헬스체크 대기 (최대 40초)
sleep 40
curl -f http://localhost:8080/monitor/health
# 5. Web 서비스 시작 (kamco-train-web 프로젝트에서)
# cd ../kamco-train-web
# docker-compose -f docker-compose-prod.yml up -d
# 6. 전체 상태 확인
docker ps -a | grep kamco
```
### 배포 검증
```bash
# 서비스별 헬스체크
curl -f http://localhost:8080/monitor/health # API (internal)
curl -kf https://api.train-kamco.com/monitor/health # API (external)
curl -kf https://train-kamco.com # Web (external)
# Nginx 설정 검증
docker exec kamco-train-nginx nginx -t
# 로그 확인
docker-compose -f docker-compose-nginx.yml logs --tail=50
docker-compose -f docker-compose-prod.yml logs --tail=50
```
---
## 개별 서비스 관리
### Nginx 관리
```bash
# 설정 변경 후 리로드 (다운타임 없음)
docker exec kamco-train-nginx nginx -s reload
# 재시작
docker-compose -f docker-compose-nginx.yml restart
# 로그 확인
docker-compose -f docker-compose-nginx.yml logs -f
# 컨테이너 내부 접근
docker exec -it kamco-train-nginx sh
```
### API 서비스 관리
```bash
# 재배포 (새 이미지 빌드)
docker-compose -f docker-compose-prod.yml up -d --build
# 재시작 (이미지 변경 없이)
docker-compose -f docker-compose-prod.yml restart
# 중지
docker-compose -f docker-compose-prod.yml down
# 로그 확인
docker-compose -f docker-compose-prod.yml logs -f kamco-train-api
# 컨테이너 내부 접근
docker exec -it kamco-train-api bash
```
### Web 서비스 관리
```bash
# kamco-train-web 프로젝트에서 실행
cd ../kamco-train-web
# 재배포
docker-compose -f docker-compose-prod.yml up -d --build
# 재시작
docker-compose -f docker-compose-prod.yml restart
# 로그 확인
docker-compose -f docker-compose-prod.yml logs -f
```
---
## 롤백 절차
### 이미지 기반 롤백
```bash
# 1. 사용 가능한 이미지 확인
docker images | grep kamco-train-api
# 2. 이전 이미지 태그로 롤백
export IMAGE_TAG=previous-commit-hash
docker-compose -f docker-compose-prod.yml up -d
# 3. 헬스체크 확인
curl -f http://localhost:8080/monitor/health
```
### Git 기반 롤백
```bash
# 1. 이전 커밋으로 체크아웃
git log --oneline -10
git checkout <previous-commit-hash>
# 2. 재빌드 및 배포
docker-compose -f docker-compose-prod.yml up -d --build
# 3. 검증 후 브랜치 업데이트 (필요시)
# git checkout develop
# git reset --hard <previous-commit-hash>
# git push -f origin develop
```
---
## 모니터링 및 헬스체크
### 헬스체크 엔드포인트
```bash
# API 헬스체크
curl http://localhost:8080/monitor/health
curl http://localhost:8080/monitor/health/readiness
curl http://localhost:8080/monitor/health/liveness
# Nginx를 통한 헬스체크
curl -k https://api.train-kamco.com/monitor/health
```
### 컨테이너 상태 모니터링
```bash
# 모든 컨테이너 상태
docker ps -a | grep kamco
# 리소스 사용량 실시간 모니터링
docker stats kamco-train-nginx kamco-train-api
# 헬스체크 상태
docker inspect kamco-train-api | grep -A 10 Health
```
### 로그 모니터링
```bash
# 실시간 로그 (모든 서비스)
docker-compose -f docker-compose-nginx.yml logs -f &
docker-compose -f docker-compose-prod.yml logs -f &
# 에러 로그만 필터링
docker-compose -f docker-compose-prod.yml logs | grep -i error
# 최근 100줄
docker-compose -f docker-compose-prod.yml logs --tail=100
```
---
## 트러블슈팅
### 1. Nginx 502 Bad Gateway
**원인**: API 서비스가 준비되지 않음
```bash
# API 컨테이너 상태 확인
docker ps | grep kamco-train-api
# API 로그 확인
docker logs kamco-train-api --tail=100
# 네트워크 연결 확인
docker network inspect kamco-cds | grep kamco-train-api
# 해결: API 재시작
docker-compose -f docker-compose-prod.yml restart
```
### 2. SSL 인증서 오류
**원인**: 인증서 파일 누락 또는 권한 문제
```bash
# 인증서 파일 확인
ls -la nginx/ssl/
# Nginx 설정 검증
docker exec kamco-train-nginx nginx -t
# 해결: 인증서 재배치 및 권한 설정
chmod 600 nginx/ssl/train-kamco.com.key
chmod 644 nginx/ssl/train-kamco.com.crt
docker-compose -f docker-compose-nginx.yml restart
```
### 3. 컨테이너 시작 실패
**원인**: 포트 충돌, 볼륨 권한, 메모리 부족
```bash
# 포트 사용 확인
netstat -tulpn | grep -E '80|443|8080'
# 볼륨 권한 확인
ls -la ./app/
# 메모리 사용량 확인
free -h
docker system df
# 해결: 충돌 프로세스 종료 또는 포트 변경
# 메모리 정리
docker system prune -a
```
### 4. 네트워크 연결 문제
**원인**: kamco-cds 네트워크 미생성 또는 컨테이너 미연결
```bash
# 네트워크 확인
docker network ls | grep kamco-cds
# 네트워크 상세 정보
docker network inspect kamco-cds
# 해결: 네트워크 생성
docker network create kamco-cds
# 컨테이너를 네트워크에 연결
docker network connect kamco-cds kamco-train-api
docker network connect kamco-cds kamco-train-nginx
```
### 5. 데이터베이스 연결 실패
**원인**: application-prod.yml의 DB 설정 오류
```bash
# API 로그에서 DB 연결 에러 확인
docker logs kamco-train-api | grep -i "connection"
# DB 호스트 연결 테스트
docker exec kamco-train-api ping <db-host>
# 해결: application-prod.yml 수정 후 재배포
vim src/main/resources/application-prod.yml
docker-compose -f docker-compose-prod.yml up -d --build
```
---
## Jenkins CI/CD 연동
현재 프로젝트는 Jenkins 파이프라인으로 자동 배포됩니다.
### Jenkinsfile-dev 주요 단계
1. **Checkout**: develop 브랜치 체크아웃
2. **Build**: `./gradlew clean build -x test`
3. **Extract Commit**: IMAGE_TAG로 사용
4. **Transfer**: 배포 서버로 파일 전송
5. **Deploy**: Docker Compose 빌드 및 배포
6. **Health Check**: 30초 대기 후 헬스체크
7. **Cleanup**: 오래된 이미지 정리 (최신 5개 유지)
### 배포 서버 정보
- **서버**: 192.168.2.109
- **사용자**: space
- **배포 경로**: `/home/space/kamco-training-api`
- **헬스체크**: `http://localhost:7200/monitor/health`
---
## 백업 및 복구
### 데이터 백업
```bash
# 볼륨 데이터 백업
tar -czf backup-$(date +%Y%m%d).tar.gz ./app/model_output ./app/train_dataset
# 설정 파일 백업
tar -czf config-backup-$(date +%Y%m%d).tar.gz \
nginx/nginx.conf \
nginx/ssl/ \
src/main/resources/application-prod.yml
```
### 이미지 백업
```bash
# 현재 이미지 저장
docker save kamco-train-api:latest | gzip > kamco-train-api-latest.tar.gz
# 이미지 복구
gunzip -c kamco-train-api-latest.tar.gz | docker load
```
---
## 보안 체크리스트
- [ ] SSL 인증서 유효기간 확인
- [ ] nginx/ssl/ 디렉토리 권한 600
- [ ] application-prod.yml에 DB 비밀번호 암호화
- [ ] JWT secret key 환경변수로 관리
- [ ] Docker 소켓 권한 최소화
- [ ] 방화벽 규칙 설정 (80, 443만 외부 노출)
- [ ] 정기 보안 업데이트 (docker images)
---
## 참고 문서
- [CLAUDE.md](./CLAUDE.md) - 프로젝트 개발 가이드
- [README.md](./README.md) - 프로젝트 개요
- [Jenkinsfile-dev](./Jenkinsfile-dev) - CI/CD 파이프라인
- [nginx/nginx.conf](./nginx/nginx.conf) - Nginx 설정
---
## 연락처
문제 발생 시:
1. 로그 수집: `docker-compose logs` 출력
2. 시스템 정보: `docker ps -a`, `docker network ls`
3. 이슈 리포트: GitHub Issues 또는 내부 이슈 트래커

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Stage 1: Build stage (gradle build는 Jenkins에서 이미 수행)
FROM eclipse-temurin:21-jre-jammy
# docker CLI 설치 (컨테이너에서 호스트 Docker 제어용) 260212 추가
RUN apt-get update && \
apt-get install -y --no-install-recommends docker.io ca-certificates && \
rm -rf /var/lib/apt/lists/*
# 작업 디렉토리 설정
WORKDIR /app
# JAR 파일 복사 (Jenkins에서 빌드된 ROOT.jar)
COPY build/libs/ROOT.jar app.jar
# 포트 노출
EXPOSE 8080
# 애플리케이션 실행
# dev 프로파일로 실행
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"]

View File

@@ -5,6 +5,13 @@ services:
dockerfile: Dockerfile-dev dockerfile: Dockerfile-dev
image: kamco-cd-training-api:${IMAGE_TAG:-latest} image: kamco-cd-training-api:${IMAGE_TAG:-latest}
container_name: kamco-cd-training-api container_name: kamco-cd-training-api
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
ports: ports:
- "7200:8080" - "7200:8080"
environment: environment:

28
docker-compose-nginx.yml Normal file
View File

@@ -0,0 +1,28 @@
services:
nginx:
image: nginx:alpine
container_name: kamco-train-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- nginx-logs:/var/log/nginx
networks:
- kamco-cds
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "--no-check-certificate", "https://localhost/monitor/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
kamco-cds:
external: true
volumes:
nginx-logs:
driver: local

36
docker-compose-prod.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
kamco-train-api:
build:
context: .
dockerfile: Dockerfile
image: kamco-train-api:${IMAGE_TAG:-latest}
container_name: kamco-train-api
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
expose:
- "8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- TZ=Asia/Seoul
volumes:
- ./app/model_output:/app/model-outputs
- ./app/train_dataset:/app/train-dataset
- /var/run/docker.sock:/var/run/docker.sock
networks:
- kamco-cds
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/monitor/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 40s
networks:
kamco-cds:
external: true

414
nginx/SSL_SETUP.md Normal file
View File

@@ -0,0 +1,414 @@
# SSL 사설 인증서 설정 가이드 (RedHat 9.6)
## 개요
이 문서는 RedHat 9.6 환경에서 `https://api.train-kamco.com``https://train-kamco.com` 도메인을 위한 100년 유효한 사설 SSL 인증서 설정 방법을 설명합니다.
## 디렉토리 구조
```
nginx/
├── nginx.conf # Nginx 설정 파일
├── ssl/
│ ├── openssl.cnf # OpenSSL 설정 파일 (SAN 포함)
│ ├── train-kamco.com.crt # 사설 SSL 인증서 (멀티 도메인)
│ └── train-kamco.com.key # 개인 키 (비공개)
└── SSL_SETUP.md # 이 문서
```
## 인증서 정보
- **도메인**: api.train-kamco.com, train-kamco.com (멀티 도메인)
- **유효 기간**: 100년 (36500일)
- **알고리즘**: RSA 4096-bit
- **CN (Common Name)**: api.train-kamco.com
- **SAN (Subject Alternative Names)**: api.train-kamco.com, train-kamco.com
## 사설 SSL 인증서 생성 (이미 생성됨)
인증서가 이미 생성되어 있습니다. 재생성이 필요한 경우 아래 단계를 따르세요.
### 1. OpenSSL 설정 파일 생성
```bash
cd /path/to/kamco-train-api
cat > nginx/ssl/openssl.cnf << 'EOF'
[req]
default_bits = 4096
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req
[dn]
C=KR
ST=Seoul
L=Seoul
O=KAMCO
OU=Training
CN=api.train-kamco.com
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = api.train-kamco.com
DNS.2 = train-kamco.com
EOF
```
### 2. SSL 인증서 및 개인 키 생성
```bash
# nginx/ssl 디렉토리 생성 (없는 경우)
mkdir -p nginx/ssl
chmod 700 nginx/ssl
# 인증서 및 개인 키 생성 (100년 유효)
openssl req -new -x509 -newkey rsa:4096 -sha256 -nodes \
-keyout nginx/ssl/train-kamco.com.key \
-out nginx/ssl/train-kamco.com.crt \
-days 36500 \
-config nginx/ssl/openssl.cnf \
-extensions v3_req
# 파일 권한 설정
chmod 600 nginx/ssl/train-kamco.com.key
chmod 644 nginx/ssl/train-kamco.com.crt
```
### 3. 인증서 검증
```bash
# 인증서 정보 확인
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout
# 유효 기간 확인
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout | grep -A2 "Validity"
# SAN (멀티 도메인) 확인
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout | grep -A1 "Subject Alternative Name"
# CN 확인
openssl x509 -in nginx/ssl/train-kamco.com.crt -noout -subject
# 개인 키 확인
openssl rsa -in nginx/ssl/train-kamco.com.key -check
```
**예상 결과**:
```
X509v3 Subject Alternative Name:
DNS:api.train-kamco.com, DNS:train-kamco.com
Validity
Not Before: Mar 2 23:39:XX 2026 GMT
Not After : Feb 6 23:39:XX 2126 GMT
```
## /etc/hosts 설정 (RedHat 9.6)
### 1. hosts 파일에 도메인 추가
```bash
# root 권한으로 실행
echo "127.0.0.1 api.train-kamco.com train-kamco.com" | sudo tee -a /etc/hosts
# 확인
cat /etc/hosts | grep train-kamco
```
**예상 결과**:
```
127.0.0.1 api.train-kamco.com train-kamco.com
```
### 2. 도메인 확인
```bash
# ping 테스트
ping -c 2 api.train-kamco.com
ping -c 2 train-kamco.com
```
## Docker Compose 배포
### 1. 기존 컨테이너 중지 (실행 중인 경우)
```bash
cd /path/to/kamco-train-api
docker-compose -f docker-compose-prod.yml down
```
### 2. Production 환경 실행
```bash
# IMAGE_TAG 환경 변수 설정 (선택사항)
export IMAGE_TAG=latest
# Docker Compose 실행
docker-compose -f docker-compose-prod.yml up -d
# 컨테이너 상태 확인
docker-compose -f docker-compose-prod.yml ps
```
### 3. 로그 확인
```bash
# Nginx 로그
docker logs kamco-cd-nginx
# API 로그
docker logs kamco-cd-training-api
# 실시간 로그 확인
docker-compose -f docker-compose-prod.yml logs -f
```
## HTTPS 접속 테스트
### 1. HTTP → HTTPS 리다이렉트 테스트
```bash
# api.train-kamco.com
curl -I http://api.train-kamco.com
# train-kamco.com
curl -I http://train-kamco.com
# 예상 결과: 301 Moved Permanently
# Location: https://api.train-kamco.com/ 또는 https://train-kamco.com/
```
### 2. HTTPS 헬스체크 (-k: 사설 인증서 경고 무시)
```bash
# api.train-kamco.com
curl -k https://api.train-kamco.com/monitor/health
# train-kamco.com
curl -k https://train-kamco.com/monitor/health
# 예상 결과: {"status":"UP","components":{...}}
```
### 3. SSL 인증서 확인
```bash
# api.train-kamco.com
openssl s_client -connect api.train-kamco.com:443 -showcerts
# train-kamco.com
openssl s_client -connect train-kamco.com:443 -showcerts
# CN 및 SAN 확인
```
### 4. 브라우저 테스트
브라우저에서 다음 URL에 접속:
- `https://api.train-kamco.com/monitor/health`
- `https://train-kamco.com/monitor/health`
**주의**: 사설 인증서이므로 "안전하지 않음" 경고가 표시됩니다.
- **Chrome/Edge**: "고급" → "계속 진행" 클릭
- **Firefox**: "위험 감수 및 계속" 클릭
## 브라우저에서 사설 인증서 신뢰 설정 (선택사항)
사설 인증서를 브라우저에 등록하면 경고 없이 접속 가능합니다.
### Chrome/Edge (RedHat Desktop)
1. `chrome://settings/certificates` 접속
2. **Authorities** 탭 선택
3. **Import** 클릭
4. `nginx/ssl/train-kamco.com.crt` 선택
5. **Trust this certificate for identifying websites** 체크
6. **OK** 클릭
### Firefox
1. `about:preferences#privacy` 접속
2. **Certificates****View Certificates** 클릭
3. **Authorities** 탭 선택
4. **Import** 클릭
5. `nginx/ssl/train-kamco.com.crt` 선택
6. **Trust this CA to identify websites** 체크
7. **OK** 클릭
## 방화벽 설정 (RedHat 9.6)
### 1. 방화벽 상태 확인
```bash
sudo firewall-cmd --state
```
### 2. HTTP (80) 및 HTTPS (443) 포트 개방
```bash
# HTTP 포트 개방
sudo firewall-cmd --permanent --add-port=80/tcp
# HTTPS 포트 개방
sudo firewall-cmd --permanent --add-port=443/tcp
# 방화벽 재로드
sudo firewall-cmd --reload
# 확인
sudo firewall-cmd --list-ports
```
**예상 결과**:
```
80/tcp 443/tcp
```
## 보안 체크리스트
- [x] `train-kamco.com.key` 파일 권한이 600으로 설정됨
- [x] ssl 디렉토리가 버전 관리에서 제외됨 (.gitignore 확인)
- [x] 두 도메인(api.train-kamco.com, train-kamco.com) 모두 SAN에 포함됨
- [ ] 방화벽에서 80, 443 포트 개방 확인
- [x] HSTS 헤더 활성화 확인
- [x] TLS 1.2 이상만 허용 확인
- [ ] /etc/hosts에 도메인 매핑 확인
## 트러블슈팅
### 인증서 관련 오류
**"certificate verify failed"**
```bash
# 해결: -k 플래그 사용 (사설 인증서 경고 무시)
curl -k https://api.train-kamco.com/monitor/health
```
**"NET::ERR_CERT_AUTHORITY_INVALID" (브라우저)**
- 정상 동작: 사설 인증서이므로 브라우저 경고는 예상된 동작입니다
- 해결: 브라우저에 인증서 등록 (위 "브라우저에서 사설 인증서 신뢰 설정" 참조)
### 연결 오류
**"Connection refused"**
```bash
# 컨테이너 상태 확인
docker ps | grep kamco-cd
# 포트 바인딩 확인
docker port kamco-cd-nginx
# 예상 결과:
# 80/tcp -> 0.0.0.0:80
# 443/tcp -> 0.0.0.0:443
```
**"502 Bad Gateway"**
```bash
# API 컨테이너 상태 확인
docker logs kamco-cd-training-api
# nginx → API 연결 확인
docker exec kamco-cd-nginx wget -qO- http://kamco-changedetection-api:8080/monitor/health
```
**"Name or service not known" (도메인 해석 실패)**
```bash
# /etc/hosts 확인
cat /etc/hosts | grep train-kamco
# 없으면 추가
echo "127.0.0.1 api.train-kamco.com train-kamco.com" | sudo tee -a /etc/hosts
```
### 방화벽 관련 오류
**외부에서 접속 안 됨**
```bash
# 방화벽 확인
sudo firewall-cmd --list-ports
# 포트 개방
sudo firewall-cmd --permanent --add-port=80/tcp
sudo firewall-cmd --permanent --add-port=443/tcp
sudo firewall-cmd --reload
```
## 인증서 만료 및 갱신
### 만료 확인
```bash
# 인증서 만료일 확인
openssl x509 -in nginx/ssl/train-kamco.com.crt -noout -enddate
# 예상 결과: notAfter=Feb 6 23:39:XX 2126 GMT (100년 후)
```
### 갱신 방법 (필요시)
100년 유효한 인증서이므로 갱신이 필요하지 않지만, 재생성이 필요한 경우:
```bash
# 기존 인증서 백업
cp nginx/ssl/train-kamco.com.crt nginx/ssl/train-kamco.com.crt.bak
cp nginx/ssl/train-kamco.com.key nginx/ssl/train-kamco.com.key.bak
# 위의 "사설 SSL 인증서 생성" 단계 재실행
# nginx 재시작
docker-compose -f docker-compose-prod.yml restart nginx
```
## 주의사항
1. **사설 인증서 경고**: 브라우저에서 "안전하지 않음" 경고가 표시됩니다. 프로덕션 환경에서는 **공인 인증서(Let's Encrypt, GlobalSign 등) 사용을 권장**합니다.
2. **포트 80/443**: Docker가 자동으로 처리하지만, 이미 사용 중인 프로세스가 있으면 충돌할 수 있습니다.
```bash
# 포트 사용 확인
sudo lsof -i :80
sudo lsof -i :443
```
3. **대용량 파일 업로드**: `client_max_body_size`를 10GB로 설정했으므로, 서버 메모리 및 디스크 용량을 충분히 확보하세요.
4. **인증서 백업**: `train-kamco.com.key` 파일은 매우 중요합니다. 안전한 곳에 백업하세요.
5. **SELinux**: RedHat 9.6에서 SELinux가 활성화된 경우, Docker 볼륨 마운트 권한 문제가 발생할 수 있습니다.
```bash
# SELinux 상태 확인
getenforce
# 필요시 permissive 모드로 변경
sudo setenforce 0
```
### 2단계: 시스템 신뢰 폴더로 복사
터미널을 열고 관리자 권한(sudo)을 사용해 인증서를 시스템 폴더로 복사합니다.
```
sudo cp mycert.crt /etc/pki/ca-trust/source/anchors/
```
### 3단계: 시스템 신뢰 목록 업데이트
아래 명령어를 입력해 추가한 인증서를 시스템에 갱신시킵니다.
```
sudo update-ca-trust
```
## 참고 자료
- [OpenSSL Documentation](https://www.openssl.org/docs/)
- [Nginx SSL Configuration](https://nginx.org/en/docs/http/configuring_https_servers.html)
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Let's Encrypt (공인 인증서)](https://letsencrypt.org/)

179
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,179 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 로그 설정
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
sendfile on;
keepalive_timeout 65;
# 업로드 파일 크기 제한 (10GB)
client_max_body_size 10G;
# Upstream 설정
upstream api_backend {
server kamco-train-api:8080;
}
upstream web_backend {
server kamco-train-web:3002;
}
# HTTP → HTTPS 리다이렉트 서버
server {
listen 80;
server_name api.train-kamco.com train-kamco.com;
# 모든 HTTP 요청을 HTTPS로 리다이렉트
return 301 https://$server_name$request_uri;
}
# HTTPS 서버 설정
server {
listen 443 ssl http2;
server_name api.train-kamco.com;
# SSL 인증서 설정 (사설 인증서 - 멀티 도메인)
ssl_certificate /etc/nginx/ssl/train-kamco.com.crt;
ssl_certificate_key /etc/nginx/ssl/train-kamco.com.key;
# SSL 프로토콜 및 암호화 설정
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
# SSL 세션 캐시
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# HSTS (HTTP Strict Transport Security)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 보안 헤더
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 프록시 설정
location / {
proxy_pass http://api_backend;
proxy_http_version 1.1;
# 프록시 헤더 설정
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
# 인증 헤더 및 쿠키 전달 (JWT 토큰 전달 보장)
proxy_pass_request_headers on;
proxy_set_header Cookie $http_cookie;
proxy_set_header Authorization $http_authorization;
# 타임아웃 설정 (대용량 파일 업로드 지원)
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# 버퍼 설정
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# 헬스체크 엔드포인트
location /monitor/health {
proxy_pass http://api_backend/monitor/health;
access_log off;
}
}
# HTTPS 서버 설정 - Next.js Web Application
server {
listen 443 ssl http2;
server_name train-kamco.com;
# SSL 인증서 설정 (사설 인증서 - 멀티 도메인)
ssl_certificate /etc/nginx/ssl/train-kamco.com.crt;
ssl_certificate_key /etc/nginx/ssl/train-kamco.com.key;
# SSL 프로토콜 및 암호화 설정
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
# SSL 세션 캐시
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# HSTS (HTTP Strict Transport Security)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 보안 헤더
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# API 프록시 설정 (Web에서 API 호출 시)
location /api/ {
proxy_pass http://api_backend/api/;
proxy_http_version 1.1;
# 프록시 헤더 설정
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
# 인증 헤더 및 쿠키 전달
proxy_pass_request_headers on;
proxy_set_header Cookie $http_cookie;
# 타임아웃 설정
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# 프록시 설정
location / {
proxy_pass http://web_backend;
proxy_http_version 1.1;
# 프록시 헤더 설정
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
# Next.js WebSocket 지원을 위한 Upgrade 헤더
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 타임아웃 설정
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# 버퍼 설정
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
}
}

View File

@@ -125,6 +125,7 @@ public class CommonCodeService {
return commonCodeCoreService.getCode(parentCodeCd, childCodeCd); return commonCodeCoreService.getCode(parentCodeCd, childCodeCd);
} }
// TODO 미사용시작
/** /**
* 공통코드 이름 조회 * 공통코드 이름 조회
* *
@@ -136,6 +137,8 @@ public class CommonCodeService {
return commonCodeCoreService.getCode(parentCodeCd, childCodeCd); return commonCodeCoreService.getCode(parentCodeCd, childCodeCd);
} }
// TODO 미사용 끝
public List<CodeDto> getTypeCode(String type) { public List<CodeDto> getTypeCode(String type) {
return Enums.getCodes(type); return Enums.getCodes(type);
} }

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.common.download; package com.kamco.cd.training.common.download;
import com.kamco.cd.training.common.download.dto.DownloadSpec; import com.kamco.cd.training.common.download.dto.DownloadSpec;
@@ -46,3 +47,4 @@ public class DownloadExecutor {
.body(body); .body(body);
} }
} }
// TODO 미사용 끝

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.common.download; package com.kamco.cd.training.common.download;
import org.springframework.util.AntPathMatcher; import org.springframework.util.AntPathMatcher;
@@ -17,3 +18,4 @@ public final class DownloadPaths {
return false; return false;
} }
} }
// TODO 미사용 끝

View File

@@ -0,0 +1,8 @@
package com.kamco.cd.training.common.dto;
public class MonitorDto {
public int cpu; // CPU 사용률 (%)
public long[] memory; // "사용/전체"
public int gpu; // 🔥 전체 GPU 평균 (%)
}

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.common.enums; package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.CodeExpose; import com.kamco.cd.training.common.utils.enums.CodeExpose;
@@ -17,3 +18,4 @@ public enum DeployTargetType implements EnumType {
private final String id; private final String id;
private final String text; private final String text;
} }
// TODO 미사용 끝

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.common.enums; package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.CodeExpose; import com.kamco.cd.training.common.utils.enums.CodeExpose;
@@ -25,3 +26,4 @@ public enum ModelMngStatusType implements EnumType {
return desc; return desc;
} }
} }
// TODO 미사용 끝

View File

@@ -12,7 +12,8 @@ import lombok.Getter;
public enum ModelType implements EnumType { public enum ModelType implements EnumType {
G1("G1"), G1("G1"),
G2("G2"), G2("G2"),
G3("G3"); G3("G3"),
G4("G4");
private String desc; private String desc;

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.common.enums; package com.kamco.cd.training.common.enums;
import com.kamco.cd.training.common.utils.enums.CodeExpose; import com.kamco.cd.training.common.utils.enums.CodeExpose;
@@ -18,3 +19,4 @@ public enum ProcessStepType implements EnumType {
private final String id; private final String id;
private final String text; private final String text;
} }
// TODO 미사용 끝

View File

@@ -0,0 +1,142 @@
package com.kamco.cd.training.common.service;
import jakarta.annotation.PostConstruct;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;
@Component
@Log4j2
public class GpuDmonReader {
// =========================
// GPU 사용률 저장소
// key: GPU index (0,1,2...)
// value: 현재 GPU 사용률 (%)
// ConcurrentHashMap → 멀티스레드 안전
// =========================
private final Map<Integer, Integer> gpuUtilMap = new ConcurrentHashMap<>();
// =========================
// 외부 조회용
// SystemMonitorService에서 호출
// =========================
public Map<Integer, Integer> getGpuUtilMap() {
return gpuUtilMap;
}
// =========================
// Bean 초기화 시 실행
// - 별도 스레드에서 GPU 모니터링 시작
// - 메인 스레드 block 방지
// =========================
@PostConstruct
public void start() {
// nvidia-smi 없는 환경이면 GPU 모니터링 비활성화
if (!isNvidiaAvailable()) {
log.warn("nvidia-smi not found. GPU monitoring disabled.");
return;
}
// 데몬 스레드로 실행 (서버 종료 시 자동 종료)
Thread t = new Thread(this::runLoop, "gpu-dmon-thread");
t.setDaemon(true);
t.start();
}
// =========================
// 무한 루프
// - dmon 실행
// - 죽으면 자동 재시작
// =========================
private void runLoop() {
while (true) {
try {
runDmon(); // GPU 사용률 수집 시작
} catch (Exception e) {
// dmon 프로세스 종료되면 여기로 들어옴
log.warn("dmon restart: {}", e.getMessage());
}
// 5초 대기 후 재시작
sleep(5000);
}
}
// =========================
// nvidia-smi dmon 실행
// - GPU 사용률 스트리밍으로 계속 수신
// =========================
private void runDmon() throws Exception {
// -s u → GPU utilization만 출력
ProcessBuilder pb = new ProcessBuilder("nvidia-smi", "dmon", "-s", "u");
// 프로세스 실행 후 stdout 읽기
try (BufferedReader br =
new BufferedReader(new InputStreamReader(pb.start().getInputStream()))) {
String line;
// dmon은 계속 출력됨 (스트리밍)
while ((line = br.readLine()) != null) {
// 헤더 제거 (#로 시작)
if (line.startsWith("#")) continue;
line = line.trim();
if (line.isEmpty()) continue;
// 공백 기준 분리
String[] parts = line.split("\\s+");
// 첫 번째 값이 GPU index인지 확인
if (!parts[0].matches("\\d+")) continue;
int index = Integer.parseInt(parts[0]);
try {
// 두 번째 값이 GPU 사용률 (sm)
int util = Integer.parseInt(parts[1]);
// 최신 값 갱신
gpuUtilMap.put(index, util);
} catch (Exception ignored) {
// 파싱 실패 시 무시
}
}
}
// 여기까지 왔다는 건 dmon 프로세스 종료됨
// → runLoop에서 재시작하도록 예외 발생
throw new IllegalStateException("dmon stopped");
}
// =========================
// nvidia-smi 존재 여부 확인
// =========================
private boolean isNvidiaAvailable() {
try {
Process p = new ProcessBuilder("which", "nvidia-smi").start();
return p.waitFor() == 0;
} catch (Exception e) {
return false;
}
}
// =========================
// sleep 유틸
// =========================
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException ignored) {
}
}
}

View File

@@ -0,0 +1,224 @@
package com.kamco.cd.training.common.service;
import com.kamco.cd.training.common.dto.MonitorDto;
import java.io.BufferedReader;
import java.io.FileReader;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Log4j2
public class SystemMonitorService {
// =========================
// CPU 이전값 (delta 계산용)
// - /proc/stat은 누적값이기 때문에
// - 이전 값과 비교해서 사용률 계산
// =========================
private long prevIdle = 0;
private long prevTotal = 0;
// =========================
// 최근 30초 히스토리
// - CPU: 30개 (1초 * 30)
// - GPU: GPU별 30개
// =========================
private final Deque<Double> cpuHistory = new ArrayDeque<>();
// key: GPU index
// value: 최근 30개 사용률
private final Map<Integer, Deque<Integer>> gpuHistory = new ConcurrentHashMap<>();
// =========================
// GPU 데이터 제공 (dmon reader)
// =========================
private final GpuDmonReader gpuReader;
// =========================
// 캐시 (API 응답용)
// - 매 요청마다 계산하지 않기 위해 사용
// - volatile → 멀티스레드 안전하게 최신값 유지
// =========================
private volatile MonitorDto cached = new MonitorDto();
// =========================
// 1초마다 수집
// =========================
@Scheduled(fixedRate = 1000)
public void collect() {
try {
// =====================
// 1. CPU 수집
// =====================
double cpu = readCpu();
cpuHistory.add(cpu);
// 30개 유지 (rolling window)
if (cpuHistory.size() > 30) cpuHistory.poll();
// =====================
// 2. GPU 수집
// =====================
Map<Integer, Integer> gpuMap = gpuReader.getGpuUtilMap();
for (Map.Entry<Integer, Integer> entry : gpuMap.entrySet()) {
int index = entry.getKey();
int util = entry.getValue();
// GPU별 히스토리 생성 및 추가
gpuHistory.computeIfAbsent(index, k -> new ArrayDeque<>()).add(util);
// 30개 유지
Deque<Integer> q = gpuHistory.get(index);
if (q.size() > 30) q.poll();
}
// =====================
// 3. 캐시 업데이트
// =====================
updateCache();
} catch (Exception e) {
log.error("collect error", e);
}
}
// =========================
// CPU 사용률 계산
// - /proc/stat 사용
// - 이전값과의 차이로 계산 (delta 방식)
// =========================
private double readCpu() throws Exception {
if (!isLinux()) return 0;
try (BufferedReader br = new BufferedReader(new FileReader("/proc/stat"))) {
String[] p = br.readLine().split("\\s+");
long user = Long.parseLong(p[1]);
long nice = Long.parseLong(p[2]);
long system = Long.parseLong(p[3]);
long idle = Long.parseLong(p[4]);
long iowait = Long.parseLong(p[5]);
long irq = Long.parseLong(p[6]);
long softirq = Long.parseLong(p[7]);
long total = user + nice + system + idle + iowait + irq + softirq;
long idleAll = idle + iowait;
// 최초 실행 시 기준값만 저장
if (prevTotal == 0) {
prevTotal = total;
prevIdle = idleAll;
return 0;
}
long totalDiff = total - prevTotal;
long idleDiff = idleAll - prevIdle;
prevTotal = total;
prevIdle = idleAll;
if (totalDiff == 0) return 0;
// CPU 사용률 (%)
return (1.0 - (double) idleDiff / totalDiff) * 100;
}
}
// =========================
// Linux 환경 체크
// =========================
private boolean isLinux() {
return System.getProperty("os.name").toLowerCase().contains("linux");
}
// =========================
// Memory 조회 (/proc/meminfo)
// - OS 값 그대로 사용 (kB)
// - [사용량, 전체]
// =========================
private long[] readMemory() throws Exception {
if (!isLinux()) return new long[] {0, 0};
try (BufferedReader br = new BufferedReader(new FileReader("/proc/meminfo"))) {
long total = 0;
long available = 0;
String line;
while ((line = br.readLine()) != null) {
if (line.startsWith("MemTotal")) {
total = Long.parseLong(line.replaceAll("\\D+", ""));
} else if (line.startsWith("MemAvailable")) {
available = Long.parseLong(line.replaceAll("\\D+", ""));
}
}
long used = total - available;
return new long[] {used, total};
}
}
// =========================
// 캐시 업데이트
// - CPU: 30초 평균
// - GPU: 전체 샘플 평균
// - Memory: 현재값
// =========================
private void updateCache() throws Exception {
MonitorDto dto = new MonitorDto();
// =====================
// CPU 평균 (30초)
// =====================
dto.cpu = (int) cpuHistory.stream().mapToDouble(Double::doubleValue).average().orElse(0);
// =====================
// Memory (kB 그대로)
// =====================
dto.memory = readMemory();
// =====================
// GPU 평균 (🔥 전체 샘플 기준)
// =====================
int sum = 0;
int count = 0;
for (Deque<Integer> q : gpuHistory.values()) {
for (int v : q) {
sum += v;
count++;
}
}
dto.gpu = (count == 0) ? 0 : sum / count;
// =====================
// 캐시 교체 (atomic)
// =====================
this.cached = dto;
}
// =========================
// 외부 조회
// - Controller에서 호출
// =========================
public MonitorDto get() {
return cached;
}
}

View File

@@ -20,4 +20,18 @@ public class AsyncConfig {
executor.initialize(); executor.initialize();
return executor; return executor;
} }
@Bean("datasetExecutor")
public Executor datasetExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("dataset-");
executor.initialize();
return executor;
}
} }

View File

@@ -24,7 +24,7 @@ public class OpenApiConfig {
@Value("${swagger.dev-url:https://kamco.training-dev-api.gs.dabeeo.com}") @Value("${swagger.dev-url:https://kamco.training-dev-api.gs.dabeeo.com}")
private String devUrl; private String devUrl;
@Value("${swagger.prod-url:https://api.training-kamco.com}") @Value("${swagger.prod-url:https://api.train-kamco.com}")
private String prodUrl; private String prodUrl;
@Bean @Bean
@@ -53,10 +53,10 @@ public class OpenApiConfig {
} else if ("prod".equals(profile)) { } else if ("prod".equals(profile)) {
// servers.add(new Server().url(prodUrl).description("운영 서버")); // servers.add(new Server().url(prodUrl).description("운영 서버"));
servers.add(new Server().url("http://localhost:" + localPort).description("로컬 서버")); servers.add(new Server().url("http://localhost:" + localPort).description("로컬 서버"));
servers.add(new Server().url(devUrl).description("개발 서버")); servers.add(new Server().url(prodUrl).description("개발 서버"));
} else { } else {
servers.add(new Server().url("http://localhost:" + localPort).description("로컬 서버")); servers.add(new Server().url("http://localhost:" + localPort).description("로컬 서버"));
servers.add(new Server().url(devUrl).description("개발 서버")); servers.add(new Server().url(devUrl).description("운영 서버"));
// servers.add(new Server().url(prodUrl).description("운영 서버")); // servers.add(new Server().url(prodUrl).description("운영 서버"));
} }

View File

@@ -104,15 +104,19 @@ public class SecurityConfig {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
/** CORS 설정 */ /** CORS 설정 - application.yml에서 환경별로 관리 */
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration(); // CORS 객체 생성 CorsConfiguration config = new CorsConfiguration(); // CORS 객체 생성
// application.yml에서 환경별로 설정된 도메인 사용
config.setAllowedOriginPatterns(List.of("*")); // 도메인 허용 config.setAllowedOriginPatterns(List.of("*")); // 도메인 허용
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*")); // 헤더요청 Authorization, Content-Type, X-Custom-Header config.setAllowedHeaders(List.of("*")); // 헤더요청 Authorization, Content-Type, X-Custom-Header
config.setAllowCredentials(true); // 쿠키, Authorization 헤더, Bearer Token 등 자격증명 포함 요청을 허용할지 설정 config.setAllowCredentials(true); // 쿠키, Authorization 헤더, Bearer Token 등 자격증명 포함 요청을 허용할지 설정
config.setExposedHeaders(List.of("Content-Disposition")); config.setExposedHeaders(List.of("Content-Disposition", "Authorization"));
config.setMaxAge(3600L); // Preflight 요청 캐시 (1시간)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
/** "/**" → 모든 API 경로에 대해 이 CORS 규칙을 적용 /api/** 같이 특정 경로만 지정 가능. */ /** "/**" → 모든 API 경로에 대해 이 CORS 규칙을 적용 /api/** 같이 특정 경로만 지정 가능. */

View File

@@ -57,7 +57,7 @@ public class StartupLogger {
""" """
╔════════════════════════════════════════════════════════════════════════════════╗ ╔════════════════════════════════════════════════════════════════════════════════╗
║ 🚀 APPLICATION STARTUP INFORMATION ║ 🚀 APPLICATION STARTUP INFORMATION 3
╠════════════════════════════════════════════════════════════════════════════════╣ ╠════════════════════════════════════════════════════════════════════════════════╣
║ PROFILE CONFIGURATION ║ ║ PROFILE CONFIGURATION ║
╠────────────────────────────────────────────────────────────────────────────────╣ ╠────────────────────────────────────────────────────────────────────────────────╣

View File

@@ -16,6 +16,12 @@ public class ApiLogFilter extends OncePerRequestFilter {
protected void doFilterInternal( protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
String uri = request.getRequestURI();
if (uri.contains("/download/")) {
filterChain.doFilter(request, response);
return;
}
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response); ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);

View File

@@ -2,10 +2,14 @@ package com.kamco.cd.training.dataset;
import com.kamco.cd.training.config.api.ApiResponseDto; import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.dataset.dto.DatasetDto; import com.kamco.cd.training.dataset.dto.DatasetDto;
import com.kamco.cd.training.dataset.dto.DatasetDto.AddDeliveriesReq;
import com.kamco.cd.training.dataset.dto.DatasetObjDto; import com.kamco.cd.training.dataset.dto.DatasetObjDto;
import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetClass; import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetClass;
import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetStorage; import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetStorage;
import com.kamco.cd.training.dataset.service.DatasetAsyncService;
import com.kamco.cd.training.dataset.service.DatasetService; import com.kamco.cd.training.dataset.service.DatasetService;
import com.kamco.cd.training.model.dto.FileDto.FoldersDto;
import com.kamco.cd.training.model.dto.FileDto.SrchFoldersDto;
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;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
@@ -33,6 +37,7 @@ import org.springframework.web.bind.annotation.*;
public class DatasetApiController { public class DatasetApiController {
private final DatasetService datasetService; private final DatasetService datasetService;
private final DatasetAsyncService datasetAsyncService;
@Operation(summary = "학습데이터 관리 목록 조회", description = "학습데이터 목록을 조회합니다.") @Operation(summary = "학습데이터 관리 목록 조회", description = "학습데이터 목록을 조회합니다.")
@ApiResponses( @ApiResponses(
@@ -248,4 +253,48 @@ public class DatasetApiController {
String path = datasetService.getFilePathByUUIDPathType(uuid, pathType); String path = datasetService.getFilePathByUUIDPathType(uuid, pathType);
return datasetService.getFilePathByFile(path); return datasetService.getFilePathByFile(path);
} }
@Operation(summary = "납품 폴더 조회", description = "납품 폴더 조회 API")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = FoldersDto.class))),
@ApiResponse(responseCode = "404", description = "조회 오류", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/folder-list")
public ApiResponseDto<FoldersDto> getDir(@RequestBody SrchFoldersDto srchDto) throws IOException {
return ApiResponseDto.createOK(datasetService.getFolderAll(srchDto));
}
@Operation(summary = "납품 학습데이터셋 등록", description = "납품 학습데이터셋 등록 API")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "등록 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class))),
@ApiResponse(responseCode = "404", description = "조회 오류", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@PostMapping("/deliveries")
public ApiResponseDto<String> insertDeliveriesDataset(@RequestBody AddDeliveriesReq req) {
// 폴더 구조 검증
DatasetService.validateTrainValTestDirs(req.getFilePath());
// 파일 개수 검증
DatasetService.validateDirFileCount(req.getFilePath());
datasetAsyncService.insertDeliveriesDatasetAsync(req);
return ApiResponseDto.createOK("ok");
}
} }

View File

@@ -145,6 +145,7 @@ public class DatasetDto {
} }
} }
// TODO 미사용시작
@Schema(name = "DatasetDetailReq", description = "데이터셋 상세 조회 요청") @Schema(name = "DatasetDetailReq", description = "데이터셋 상세 조회 요청")
@Getter @Getter
@Setter @Setter
@@ -157,6 +158,8 @@ public class DatasetDto {
private Long datasetId; private Long datasetId;
} }
// TODO 미사용 끝
@Schema(name = "DatasetRegisterReq", description = "데이터셋 등록 요청") @Schema(name = "DatasetRegisterReq", description = "데이터셋 등록 요청")
@Getter @Getter
@Setter @Setter
@@ -251,6 +254,7 @@ public class DatasetDto {
private Long wasteCnt; private Long wasteCnt;
private Long landCoverCnt; private Long landCoverCnt;
private Integer solarPanelCnt;
public SelectDataSet( public SelectDataSet(
String modelNo, String modelNo,
@@ -305,6 +309,29 @@ public class DatasetDto {
this.containerCnt = containerCnt; this.containerCnt = containerCnt;
} }
public SelectDataSet(
String modelNo,
Long datasetId,
UUID uuid,
String dataType,
String title,
Long roundNo,
Integer compareYyyy,
Integer targetYyyy,
String memo,
Integer solarPanelCnt) {
this.datasetId = datasetId;
this.uuid = uuid;
this.dataType = dataType;
this.dataTypeName = getDataTypeName(dataType);
this.title = title;
this.roundNo = roundNo;
this.compareYyyy = compareYyyy;
this.targetYyyy = targetYyyy;
this.memo = memo;
this.solarPanelCnt = solarPanelCnt;
}
public String getDataTypeName(String groupTitleCd) { public String getDataTypeName(String groupTitleCd) {
LearnDataType type = Enums.fromId(LearnDataType.class, groupTitleCd); LearnDataType type = Enums.fromId(LearnDataType.class, groupTitleCd);
return type == null ? null : type.getText(); return type == null ? null : type.getText();
@@ -532,4 +559,28 @@ public class DatasetDto {
private Long totalObjectCount; private Long totalObjectCount;
private String datasetPath; private String datasetPath;
} }
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class AddDeliveriesReq {
@Schema(description = "경로", example = "/")
private String filePath;
@Schema(description = "제목", example = "")
private String title;
@Schema(description = "메모", example = "")
private String memo;
@Schema(description = "비교년도", example = "")
private Integer compareYyyy;
@Schema(description = "기준년도", example = "")
private Integer targetYyyy;
@Schema(description = "회차", example = "")
private Long roundNo;
}
} }

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.dataset.dto; package com.kamco.cd.training.dataset.dto;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm; import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
@@ -72,6 +73,7 @@ public class MapSheetDto {
private List<Long> itemIds; private List<Long> itemIds;
} }
// TODO 미사용시작
@Schema(name = "MapSheetCheckReq", description = "도엽 번호 유효성 검증 요청") @Schema(name = "MapSheetCheckReq", description = "도엽 번호 유효성 검증 요청")
@Getter @Getter
@Setter @Setter
@@ -101,3 +103,4 @@ public class MapSheetDto {
private boolean duplicate; private boolean duplicate;
} }
} }
// TODO 미사용 끝

View File

@@ -0,0 +1,124 @@
package com.kamco.cd.training.dataset.service;
import com.kamco.cd.training.common.enums.LearnDataRegister;
import com.kamco.cd.training.dataset.dto.DatasetDto.AddDeliveriesReq;
import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetMngRegDto;
import com.kamco.cd.training.postgres.core.DatasetCoreService;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
@Log4j2
@RequiredArgsConstructor
public class DatasetAsyncService {
private final DatasetService datasetService;
private final DatasetCoreService datasetCoreService;
private static final String LOG_PREFIX = "[납품 데이터셋]";
/**
* 납품 데이터셋 등록 비동기 업로드 처리 1) 데이터셋 구조/파일 검증 2) UID 생성 및 마스터 데이터 저장 3) 상태를 UPLOADING으로 변경 4) 실제
* 데이터(train/val/test) 등록 5) 완료 시 COMPLETED 상태로 변경 6) 실패 시 상태를 UPLOAD_FAILED로 변경 후 데이터 정리(삭제)
*
* @param req
*/
@Async("datasetExecutor")
public void insertDeliveriesDatasetAsync(AddDeliveriesReq req) {
long startTime = System.currentTimeMillis();
log.info("{} 업로드 시작 ==========", LOG_PREFIX);
log.info(
"{} filePath={}, targetYyyy={}, compareYyyy={}, roundNo={}",
LOG_PREFIX,
req.getFilePath(),
req.getTargetYyyy(),
req.getCompareYyyy(),
req.getRoundNo());
Long datasetUid = null;
try {
// ===== 1. UID 생성 =====
String uid = UUID.randomUUID().toString().replace("-", "").toUpperCase();
log.info("{} 생성된 UID: {}", LOG_PREFIX, uid);
// ===== 2. 마스터 데이터 생성 =====
String title = req.getTitle();
if (title == null || title.isBlank()) {
Integer compareYyyy = req.getCompareYyyy();
Integer targetYyyy = req.getTargetYyyy();
if (compareYyyy != null && targetYyyy != null) {
title = compareYyyy + "-" + targetYyyy;
} else {
title = null;
}
}
DatasetMngRegDto datasetMngRegDto = new DatasetMngRegDto();
datasetMngRegDto.setUid(uid);
datasetMngRegDto.setDataType("DELIVER");
datasetMngRegDto.setCompareYyyy(req.getCompareYyyy() == null ? 0 : req.getCompareYyyy());
datasetMngRegDto.setTargetYyyy(req.getTargetYyyy() == null ? 0 : req.getTargetYyyy());
datasetMngRegDto.setRoundNo(req.getRoundNo());
datasetMngRegDto.setTitle(title);
datasetMngRegDto.setMemo(req.getMemo());
datasetMngRegDto.setDatasetPath(req.getFilePath());
// 마스터 저장
datasetUid = datasetCoreService.insertDatasetMngData(datasetMngRegDto);
log.info("{} 마스터 저장 완료. datasetUid={}", LOG_PREFIX, datasetUid);
// ===== 3. 상태 변경 (업로드중) =====
datasetCoreService.updateDatasetUploadStatus(datasetUid, LearnDataRegister.UPLOADING);
log.info("{} 상태 변경 → UPLOADING. datasetUid={}", LOG_PREFIX, datasetUid);
// ===== 4. 데이터 등록 =====
long insertStart = System.currentTimeMillis();
// 납품 데이터 obj 등록
datasetService.insertDeliveriesDataset(req, datasetUid);
log.info(
"{} 데이터 등록 완료. datasetUid={}, 소요시간={} ms",
LOG_PREFIX,
datasetUid,
System.currentTimeMillis() - insertStart);
// ===== 5. 상태 변경 (완료) =====
datasetCoreService.updateDatasetUploadStatus(datasetUid, LearnDataRegister.COMPLETED);
log.info("{} 상태 변경 → COMPLETED. datasetUid={}", LOG_PREFIX, datasetUid);
log.info(
"{} 업로드 완료. 총 소요시간={} ms ==========", LOG_PREFIX, System.currentTimeMillis() - startTime);
} catch (Exception e) {
log.error(
"{} 업로드 실패. datasetUid={}, filePath={}", LOG_PREFIX, datasetUid, req.getFilePath(), e);
if (datasetUid != null) {
try {
// ===== 실패 처리 =====
datasetCoreService.updateDatasetUploadStatus(datasetUid, LearnDataRegister.UPLOAD_FAILED);
log.error("{} 상태 변경 → 업로드 실패. datasetUid={}", LOG_PREFIX, datasetUid);
// 실패 시 데이터 정리
datasetCoreService.deleteAllDatasetObj(datasetUid);
log.error("{} 데이터 정리 완료. datasetUid={}", LOG_PREFIX, datasetUid);
} catch (Exception ex) {
log.error("{} 실패 후 정리 작업 중 오류. datasetUid={}", LOG_PREFIX, datasetUid, ex);
}
}
}
}
}

View File

@@ -0,0 +1,199 @@
package com.kamco.cd.training.dataset.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetObjRegDto;
import com.kamco.cd.training.postgres.core.DatasetCoreService;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Log4j2
@RequiredArgsConstructor
public class DatasetBatchService {
private final DatasetCoreService datasetCoreService;
private final ObjectMapper mapper;
/**
* 배치 단위 데이터 저장
*
* <p>- 전달받은 데이터 목록을 순회하며 개별 insert 처리 - batch 단위로 트랜잭션 관리
*/
@Transactional
public void saveBatch(List<Map<String, Object>> batch, Long datasetUid, String type) {
for (Map<String, Object> map : batch) {
try {
insertTrainTestData(map, datasetUid, type);
} catch (Exception e) {
log.error("파일 단위 실패. skip. file={}", batch, e);
continue;
}
}
}
/**
* 단일 데이터 처리 및 insert DTO 생성
*
* <p>처리 흐름: 1) 경로/JSON 데이터 추출 2) 파일명에서 연도 및 도엽번호 파싱 3) label JSON → feature 단위 분리 4) feature별 DTO
* 생성 후 DB insert
*/
private void insertTrainTestData(Map<String, Object> map, Long datasetUid, String subDir) {
String comparePath = (String) map.get("input1");
String targetPath = (String) map.get("input2");
String labelPath = (String) map.get("label");
String geojsonPath = (String) map.get("geojson_path");
Object labelJson = map.get("label-json");
// JSON 파싱
JsonNode json;
try {
json = parseJson(labelJson);
if (json == null) {
log.warn("json null. skip. file={}", labelJson);
return;
}
} catch (Exception e) {
// 실패하면 skip, 다음 진행
log.error("GeoJSON 파싱 실패. skip. file={}", geojsonPath, e);
return;
}
// 파일명 파싱
String fileName = Paths.get(comparePath).getFileName().toString();
String[] fileNameStr = fileName.split("_");
if (fileNameStr.length < 4) {
log.error("파일명 파싱 실패: {}", fileName);
return;
// throw new IllegalArgumentException("잘못된 파일명 형식: " + fileName);
}
int compareYyyy = 0;
int targetYyyy = 0;
try {
compareYyyy = parseInt(fileNameStr[1], "compareYyyy", fileName);
targetYyyy = parseInt(fileNameStr[2], "targetYyyy", fileName);
} catch (Exception e) {
log.error("기준년도 파싱 실패: {}", fileName);
return;
}
String mapSheetNum = fileNameStr[3];
// JSON 유효성 체크
JsonNode featuresNode = json.path("features");
// 2. 비어있는지 확인
if (featuresNode.isEmpty()) {
log.warn("features empty. skip. file={}", geojsonPath);
return;
}
if (!featuresNode.isArray()) {
log.warn("features array 아님. skip. file={}", geojsonPath);
return; // skip
}
if (featuresNode.isMissingNode() || !featuresNode.isArray() || featuresNode.isEmpty()) {
return; // skip
}
ObjectNode base = mapper.createObjectNode();
base.put("type", "FeatureCollection");
for (JsonNode feature : featuresNode) {
try {
JsonNode prop = feature.path("properties");
String compareClassCd = prop.path("before").asText(null);
String targetClassCd = prop.path("after").asText(null);
// null 방어
if (compareClassCd == null || targetClassCd == null) {
log.warn("class 값 없음. skip. file={}", fileName);
continue;
}
ArrayNode arr = mapper.createArrayNode();
arr.add(feature);
ObjectNode root = base.deepCopy();
root.set("features", arr);
DatasetObjRegDto objRegDto =
DatasetObjRegDto.builder()
.datasetUid(datasetUid)
.compareYyyy(compareYyyy)
.compareClassCd(compareClassCd)
.targetYyyy(targetYyyy)
.targetClassCd(targetClassCd)
.comparePath(comparePath)
.targetPath(targetPath)
.labelPath(labelPath)
.mapSheetNum(mapSheetNum)
.geojson(root)
.geojsonPath(geojsonPath)
.fileName(fileName)
.build();
// 데이터 타입별 insert
insertByType(subDir, objRegDto);
} catch (Exception e) {
// 개별 feature skip
log.error("feature 처리 실패. skip. file={}", fileName, e);
}
}
}
/** 데이터 타입별 insert 처리 - type 값에 따라 대상 테이블 분기 - 잘못된 타입 입력 시 예외 발생 */
private void insertByType(String type, DatasetObjRegDto dto) {
switch (type) {
case "train" -> datasetCoreService.insertDatasetObj(dto);
case "val" -> datasetCoreService.insertDatasetValObj(dto);
case "test" -> datasetCoreService.insertDatasetTestObj(dto);
default -> throw new IllegalArgumentException("잘못된 타입: " + type);
}
}
/**
* label_json → JsonNode 변환
*
* <p>- JsonNode면 그대로 사용 - 문자열이면 파싱 수행 - 실패 시 로그 후 예외 발생
*/
private JsonNode parseJson(Object labelJson) {
try {
if (labelJson instanceof JsonNode jn) {
return jn;
}
return mapper.readTree(labelJson.toString());
} catch (Exception e) {
log.error("label_json parse error: {}", labelJson, e);
return null;
}
}
/**
* 문자열 → 정수 변환
*
* <p>- 파싱 실패 시 어떤 필드/파일에서 발생했는지 로그 기록 - 잘못된 데이터는 즉시 예외 처리
*/
private int parseInt(String value, String field, String fileName) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
log.error("{} 파싱 실패. fileName={}, value={}", field, fileName, value);
throw new IllegalArgumentException(field + " 파싱 실패: " + fileName);
}
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kamco.cd.training.common.enums.LearnDataRegister;
import com.kamco.cd.training.common.enums.LearnDataType; import com.kamco.cd.training.common.enums.LearnDataType;
import com.kamco.cd.training.common.exception.CustomApiException; import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.common.service.FormatStorage; import com.kamco.cd.training.common.service.FormatStorage;
@@ -11,6 +12,7 @@ import com.kamco.cd.training.common.utils.FIleChecker;
import com.kamco.cd.training.config.api.ApiResponseDto.ApiResponseCode; import com.kamco.cd.training.config.api.ApiResponseDto.ApiResponseCode;
import com.kamco.cd.training.config.api.ApiResponseDto.ResponseObj; import com.kamco.cd.training.config.api.ApiResponseDto.ResponseObj;
import com.kamco.cd.training.dataset.dto.DatasetDto; import com.kamco.cd.training.dataset.dto.DatasetDto;
import com.kamco.cd.training.dataset.dto.DatasetDto.AddDeliveriesReq;
import com.kamco.cd.training.dataset.dto.DatasetDto.AddReq; import com.kamco.cd.training.dataset.dto.DatasetDto.AddReq;
import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetMngRegDto; import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetMngRegDto;
import com.kamco.cd.training.dataset.dto.DatasetObjDto; import com.kamco.cd.training.dataset.dto.DatasetObjDto;
@@ -18,8 +20,11 @@ import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetClass;
import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetObjRegDto; import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetObjRegDto;
import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetStorage; import com.kamco.cd.training.dataset.dto.DatasetObjDto.DatasetStorage;
import com.kamco.cd.training.dataset.dto.DatasetObjDto.SearchReq; import com.kamco.cd.training.dataset.dto.DatasetObjDto.SearchReq;
import com.kamco.cd.training.model.dto.FileDto.FoldersDto;
import com.kamco.cd.training.model.dto.FileDto.SrchFoldersDto;
import com.kamco.cd.training.postgres.core.DatasetCoreService; import com.kamco.cd.training.postgres.core.DatasetCoreService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
@@ -27,6 +32,8 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -37,7 +44,6 @@ import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -51,13 +57,10 @@ import org.springframework.transaction.annotation.Transactional;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional
public class DatasetService { public class DatasetService {
private final DatasetCoreService datasetCoreService; private final DatasetCoreService datasetCoreService;
private final DatasetBatchService datasetBatchService;
@Value("${file.dataset-dir}")
private String datasetDir;
private static final List<String> LABEL_DIRS = List.of("label-json", "label", "input1", "input2"); private static final List<String> LABEL_DIRS = List.of("label-json", "label", "input1", "input2");
private static final List<String> REQUIRED_DIRS = Arrays.asList("train", "val", "test"); private static final List<String> REQUIRED_DIRS = Arrays.asList("train", "val", "test");
@@ -84,6 +87,7 @@ public class DatasetService {
return datasetCoreService.getOneByUuid(id); return datasetCoreService.getOneByUuid(id);
} }
// TODO 미사용시작
/** /**
* 데이터셋 등록 * 데이터셋 등록
* *
@@ -98,6 +102,7 @@ public class DatasetService {
return saved.getId(); return saved.getId();
} }
// TODO 미사용 끝
/** /**
* 데이터셋 수정 * 데이터셋 수정
* *
@@ -231,7 +236,7 @@ public class DatasetService {
return new ResponseObj(ApiResponseCode.INTERNAL_SERVER_ERROR, e.getMessage()); return new ResponseObj(ApiResponseCode.INTERNAL_SERVER_ERROR, e.getMessage());
} }
datasetCoreService.updateDatasetUploadStatus(datasetUid); datasetCoreService.updateDatasetUploadStatus(datasetUid, LearnDataRegister.COMPLETED);
return new ResponseObj(ApiResponseCode.OK, "업로드 성공하였습니다."); return new ResponseObj(ApiResponseCode.OK, "업로드 성공하였습니다.");
} }
@@ -365,7 +370,12 @@ public class DatasetService {
// 폴더별 처리 // 폴더별 처리
if ("label-json".equals(dirName)) { if ("label-json".equals(dirName)) {
// json 파일이면 파싱 // json 파일이면 파싱
try {
data.put("label-json", readJson(path)); data.put("label-json", readJson(path));
} catch (Exception e) {
log.error("파일 JSON 읽기 실패. skip. file={}", path, e);
return; // skip
}
data.put("geojson_path", path.toAbsolutePath().toString()); data.put("geojson_path", path.toAbsolutePath().toString());
} else { } else {
data.put(dirName, path.toAbsolutePath().toString()); data.put(dirName, path.toAbsolutePath().toString());
@@ -398,24 +408,6 @@ public class DatasetService {
return datasetCoreService.getFilePathByUUIDPathType(uuid, pathType); return datasetCoreService.getFilePathByUUIDPathType(uuid, pathType);
} }
private String readRemoteFileAsString(String remoteFilePath) {
String command = "cat " + escape(remoteFilePath);
List<String> lines = FIleChecker.execCommandAndReadLines(command);
return String.join("\n", lines);
}
private JsonNode parseJson(String json) {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readTree(json);
} catch (IOException e) {
throw new RuntimeException("JSON 파싱 실패", e);
}
}
private String escape(String path) { private String escape(String path) {
// 쉘 커맨드에서 안전하게 사용할 수 있도록 문자열을 작은따옴표로 감싸면서, 내부의 작은따옴표를 이스케이프 처리 // 쉘 커맨드에서 안전하게 사용할 수 있도록 문자열을 작은따옴표로 감싸면서, 내부의 작은따옴표를 이스케이프 처리
return "'" + path.replace("'", "'\"'\"'") + "'"; return "'" + path.replace("'", "'\"'\"'") + "'";
@@ -524,4 +516,164 @@ public class DatasetService {
} }
} }
} }
/**
* 폴더 조회
*
* @param srchDto 폴더 경로
* @return 폴더 리스트
* @throws IOException
*/
public FoldersDto getFolderAll(SrchFoldersDto srchDto) throws IOException {
File dir = new File(srchDto.getDirPath() == null ? "/" : srchDto.getDirPath());
// 존재 + 디렉토리 체크
if (!dir.exists() || !dir.isDirectory()) {
throw new CustomApiException("BAD_REQUEST", HttpStatus.BAD_REQUEST, "잘못된 경로입니다.");
}
// 권한 없을때
if (!dir.canRead()) {
throw new CustomApiException(
ApiResponseCode.FORBIDDEN.getId(), HttpStatus.FORBIDDEN, "디렉토리에 접근할 권한이 없습니다.");
}
String canonicalPath = dir.getCanonicalPath();
File[] files = dir.listFiles();
if (files == null) {
return new FoldersDto(canonicalPath, 0, 0, Collections.emptyList());
}
List<FIleChecker.Folder> folders = new ArrayList<>();
int folderTotCnt = 0;
int folderErrTotCnt = 0;
for (File f : files) {
// 숨김 제외
if (f.isHidden()) continue;
if (f.isDirectory()) {
// 폴더 개수 증가
folderTotCnt++;
// 폴더 유효성 여부 (기본 true, 이후 검증 로직으로 변경 가능)
boolean isValid = true;
// 유효하지 않은 폴더 카운트 증가
if (!isValid) folderErrTotCnt++;
// 현재 폴더 이름 (ex: train, images 등)
String folderNm = f.getName();
// 부모 경로 (ex: /data/datasets)
String parentPath = f.getParent();
// 부모 폴더 이름 (ex: datasets)
String parentFolderNm = new File(parentPath).getName();
// 전체 절대 경로 (ex: /data/datasets/train)
String fullPath = f.getAbsolutePath();
// 폴더 깊이 (경로 기준 depth)
// ex: /a/b/c → depth = 3
int depth = f.toPath().getNameCount();
// 하위 폴더 개수
long childCnt = FIleChecker.getChildFolderCount(f);
// 마지막 수정 시간 (문자열 포맷)
String lastModified = FIleChecker.getLastModified(f);
// Folder DTO 생성 및 리스트에 추가
folders.add(
new FIleChecker.Folder(
folderNm, // 폴더명
parentFolderNm, // 부모 폴더명
parentPath, // 부모 경로
fullPath, // 전체 경로
depth, // 깊이
childCnt, // 하위 폴더 개수
lastModified, // 수정일시
isValid // 유효성 여부
));
}
}
// 폴더 정렬
folders.sort(
Comparator.comparing(FIleChecker.Folder::getFolderNm, String.CASE_INSENSITIVE_ORDER));
return new FoldersDto(canonicalPath, folderTotCnt, folderErrTotCnt, folders);
}
/**
* 납품 데이터 등록
*
* @param req 폴더경로, 메모
* @return 성공/실패 여부0
*/
public void insertDeliveriesDataset(AddDeliveriesReq req, Long datasetUid) {
long startTime = System.currentTimeMillis();
// 처리
processType(req.getFilePath(), datasetUid, "train");
processType(req.getFilePath(), datasetUid, "val");
processType(req.getFilePath(), datasetUid, "test");
log.info("========== 전체 완료. 총 소요시간: {} ms ==========", System.currentTimeMillis() - startTime);
}
/**
* 납품 데이터 등록 처리
*
* @param path
* @param datasetUid
* @param type
*/
private void processType(String path, Long datasetUid, String type) {
long start = System.currentTimeMillis();
log.info("[납품 데이터 등록 처리][{}] 시작", type.toUpperCase());
List<Map<String, Object>> list = getUnzipDatasetFiles(path, type);
int batchSize = 1000;
int total = list.size();
int processed = 0;
for (int i = 0; i < total; i += batchSize) {
List<Map<String, Object>> batch = list.subList(i, Math.min(i + batchSize, total));
try {
log.info("[납품 데이터 등록 처리][{}] batch 시작: {} ~ {}", type, i, i + batch.size());
datasetBatchService.saveBatch(batch, datasetUid, type);
processed += batch.size();
} catch (Exception e) {
log.error("batch 실패 row 데이터: {}", batch);
log.error(
"[납품 데이터 등록 처리][{}] batch 실패. range: {} ~ {}, datasetUid={}",
type,
i,
i + batch.size(),
datasetUid,
e);
}
}
log.info(
"[납품 데이터 등록 처리][{}] 완료. 총 {}건, 소요시간: {} ms",
type,
total,
System.currentTimeMillis() - start);
}
} }

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.dataset.service; package com.kamco.cd.training.dataset.service;
import com.kamco.cd.training.dataset.dto.MapSheetDto; import com.kamco.cd.training.dataset.dto.MapSheetDto;
@@ -39,3 +40,4 @@ public class MapSheetService {
log.info("도엽 삭제 완료 - 개수: {}", deleteReq.getItemIds().size()); log.info("도엽 삭제 완료 - 개수: {}", deleteReq.getItemIds().size());
} }
} }
// TODO 미사용 끝

View File

@@ -11,9 +11,9 @@ import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelTrainMetrics;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelValidationMetrics; import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelValidationMetrics;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferDetailDto; import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferDetailDto;
import com.kamco.cd.training.model.dto.ModelTrainMngDto.Basic; import com.kamco.cd.training.model.dto.ModelTrainMngDto.Basic;
import com.kamco.cd.training.model.dto.ModelTrainMngDto.CleanupResult;
import com.kamco.cd.training.model.dto.ModelTrainMngDto.ModelProgressStepDto; import com.kamco.cd.training.model.dto.ModelTrainMngDto.ModelProgressStepDto;
import com.kamco.cd.training.model.service.ModelTrainDetailService; import com.kamco.cd.training.model.service.ModelTrainDetailService;
import com.kamco.cd.training.model.service.ModelTrainMngService;
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;
import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterIn;
@@ -34,6 +34,7 @@ import lombok.RequiredArgsConstructor;
import org.apache.coyote.BadRequestException; import org.apache.coyote.BadRequestException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -45,10 +46,9 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/models") @RequestMapping("/api/models")
public class ModelTrainDetailApiController { public class ModelTrainDetailApiController {
private final ModelTrainDetailService modelTrainDetailService; private final ModelTrainDetailService modelTrainDetailService;
private final ModelTrainMngService modelTrainMngService;
private final RangeDownloadResponder rangeDownloadResponder; private final RangeDownloadResponder rangeDownloadResponder;
@Value("${train.docker.responseDir}") @Value("${train.docker.response_dir}")
private String responseDir; private String responseDir;
@Operation(summary = "모델학습관리> 모델관리 > 상세정보탭 > 학습 진행정보", description = "학습 진행정보, 모델학습 정보 API") @Operation(summary = "모델학습관리> 모델관리 > 상세정보탭 > 학습 진행정보", description = "학습 진행정보, 모델학습 정보 API")
@@ -326,4 +326,44 @@ public class ModelTrainDetailApiController {
UUID uuid) { UUID uuid) {
return ApiResponseDto.ok(modelTrainDetailService.findModelTrainProgressInfo(uuid)); return ApiResponseDto.ok(modelTrainDetailService.findModelTrainProgressInfo(uuid));
} }
@Operation(
summary = "모델관리 > 모델 상세 > best epoch 제외 삭제 될 파일 미리보기",
description = "best epoch 제외 삭제 될 파일 미리보기 API")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CleanupResult.class))),
@ApiResponse(responseCode = "404", description = "데이터셋을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/{uuid}/cleanup/preview")
public ApiResponseDto<CleanupResult> previewCleanup(
@Parameter(description = "모델 uuid") @PathVariable UUID uuid) {
return ApiResponseDto.ok(modelTrainDetailService.previewCleanup(uuid));
}
@Operation(summary = "모델관리 > 모델 상세 > best epoch 제외 삭제", description = "best epoch 제외 파일 삭제 API")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "삭제 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = CleanupResult.class))),
@ApiResponse(responseCode = "404", description = "데이터셋을 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@DeleteMapping("/{uuid}/cleanup")
public ApiResponseDto<CleanupResult> cleanup(
@Parameter(description = "모델 uuid") @PathVariable UUID uuid) {
return ApiResponseDto.ok(modelTrainDetailService.cleanup(uuid));
}
} }

View File

@@ -1,5 +1,8 @@
package com.kamco.cd.training.model; package com.kamco.cd.training.model;
import com.kamco.cd.training.common.dto.MonitorDto;
import com.kamco.cd.training.common.enums.ModelType;
import com.kamco.cd.training.common.service.SystemMonitorService;
import com.kamco.cd.training.config.api.ApiResponseDto; import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.dataset.dto.DatasetDto; import com.kamco.cd.training.dataset.dto.DatasetDto;
import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq; import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq;
@@ -40,6 +43,7 @@ public class ModelTrainMngApiController {
private final ModelTrainMngService modelTrainMngService; private final ModelTrainMngService modelTrainMngService;
private final ModelTrainMetricsJobService modelTrainMetricsJobService; private final ModelTrainMetricsJobService modelTrainMetricsJobService;
private final ModelTestMetricsJobService modelTestMetricsJobService; private final ModelTestMetricsJobService modelTestMetricsJobService;
private final SystemMonitorService systemMonitorService;
@Operation(summary = "모델학습 목록 조회", description = "모델학습 목록 조회 API") @Operation(summary = "모델학습 목록 조회", description = "모델학습 목록 조회 API")
@ApiResponses( @ApiResponses(
@@ -140,9 +144,9 @@ public class ModelTrainMngApiController {
@Parameter( @Parameter(
description = "모델 구분", description = "모델 구분",
example = "", example = "",
schema = @Schema(allowableValues = {"G1", "G2", "G3"})) schema = @Schema(allowableValues = {"G1", "G2", "G3", "G4"}))
@RequestParam @RequestParam
String modelType, ModelType modelType,
@Parameter( @Parameter(
description = "선택 구분", description = "선택 구분",
example = "", example = "",
@@ -150,7 +154,7 @@ public class ModelTrainMngApiController {
@RequestParam @RequestParam
String selectType) { String selectType) {
DatasetReq req = new DatasetReq(); DatasetReq req = new DatasetReq();
req.setModelNo(modelType); req.setModelNo(modelType.getId());
req.setDataType(selectType); req.setDataType(selectType);
return ApiResponseDto.ok(modelTrainMngService.getDatasetSelectList(req)); return ApiResponseDto.ok(modelTrainMngService.getDatasetSelectList(req));
} }
@@ -214,4 +218,22 @@ public class ModelTrainMngApiController {
modelTestMetricsJobService.findTestValidMetricCsvFiles(); modelTestMetricsJobService.findTestValidMetricCsvFiles();
return ApiResponseDto.ok(null); return ApiResponseDto.ok(null);
} }
@Operation(summary = "학습서버 시스템 사용율 조회", description = "cpu, gpu, memory 사용율 조회")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "검색 성공",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))),
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/monitor")
public ApiResponseDto<MonitorDto> getSystem() throws IOException {
return ApiResponseDto.ok(systemMonitorService.get());
}
} }

View File

@@ -0,0 +1,160 @@
package com.kamco.cd.training.model.dto;
import com.kamco.cd.training.common.utils.FIleChecker;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
public class FileDto {
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SrchFoldersDto {
@Schema(description = "디렉토리경로(ROOT:/)", example = "")
@NotNull
private String dirPath = "/";
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SrchFilesDto {
@Schema(description = "디렉토리경로", example = "D:\\kamco\\2022\\캠코_2021_2022_34602060_D1")
@NotNull
private String dirPath;
@Schema(description = "전체(*), cpg,dbf,geojson등", example = "*")
@NotNull
private String extension;
@Schema(description = "파일명(name), 최종수정일(date)", example = "name")
@NotNull
private String sortType;
@Schema(description = "파일시작위치", example = "1")
@NotNull
private Integer startPos;
@Schema(description = "파일종료위치", example = "100")
@NotNull
private Integer endPos;
}
// TODO 미사용시작
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SrchFilesDepthDto extends SrchFilesDto {
@Schema(description = "최대폴더Depth", example = "5")
@NotNull
private Integer maxDepth;
}
@Schema(name = "FolderDto", description = "폴더 정보")
@Getter
public static class FolderDto {
private final String folderNm;
private final String parentFolderNm;
private final String parentPath;
private final String fullPath;
private final int depth;
private final long childCnt;
private final String lastModified;
private final Boolean isValid;
public FolderDto(
String folderNm,
String parentFolderNm,
String parentPath,
String fullPath,
int depth,
long childCnt,
String lastModified,
Boolean isValid) {
this.folderNm = folderNm;
this.parentFolderNm = parentFolderNm;
this.parentPath = parentPath;
this.fullPath = fullPath;
this.depth = depth;
this.childCnt = childCnt;
this.lastModified = lastModified;
this.isValid = isValid;
}
}
// TODO 미사용 끝
@Schema(name = "FoldersDto", description = "폴더목록 정보")
@Getter
public static class FoldersDto {
private final String dirPath;
private final int folderTotCnt;
private final int folderErrTotCnt;
private final List<FIleChecker.Folder> folders;
public FoldersDto(
String dirPath, int folderTotCnt, int folderErrTotCnt, List<FIleChecker.Folder> folders) {
this.dirPath = dirPath;
this.folderTotCnt = folderTotCnt;
this.folderErrTotCnt = folderErrTotCnt;
this.folders = folders;
}
}
@Schema(name = "File Basic", description = "파일 기본 정보")
@Getter
public static class Basic {
private final String fileNm;
private final String parentFolderNm;
private final String parentPath;
private final String fullPath;
private final String extension;
private final long fileSize;
private final String lastModified;
public Basic(
String fileNm,
String parentFolderNm,
String parentPath,
String fullPath,
String extension,
long fileSize,
String lastModified) {
this.fileNm = fileNm;
this.parentFolderNm = parentFolderNm;
this.parentPath = parentPath;
this.fullPath = fullPath;
this.extension = extension;
this.fileSize = fileSize;
this.lastModified = lastModified;
}
}
@Schema(name = "FilesDto", description = "파일 목록 정보")
@Getter
public static class FilesDto {
private final String dirPath;
private final int fileTotCnt;
private final long fileTotSize;
private final List<FIleChecker.Basic> files;
public FilesDto(
String dirPath, int fileTotCnt, long fileTotSize, List<FIleChecker.Basic> files) {
this.dirPath = dirPath;
this.fileTotCnt = fileTotCnt;
this.fileTotSize = fileTotSize;
this.files = files;
}
}
}

View File

@@ -48,6 +48,7 @@ public class ModelTrainMngDto {
private ZonedDateTime packingEndDttm; private ZonedDateTime packingEndDttm;
private Long beforeModelId; private Long beforeModelId;
private Integer bestEpoch;
public String getStatusName() { public String getStatusName() {
if (this.statusCd == null || this.statusCd.isBlank()) return null; if (this.statusCd == null || this.statusCd.isBlank()) return null;
@@ -327,4 +328,26 @@ public class ModelTrainMngDto {
@JsonFormatDttm private ZonedDateTime endTime; @JsonFormatDttm private ZonedDateTime endTime;
private boolean isError; private boolean isError;
} }
@Getter
@Setter
public static class CleanupResult {
// cleanup 대상 전체 파일 수 (삭제 대상 + 유지 파일 포함)
private int totalCount;
// 실제로 삭제된 파일 개수
private int deletedCount;
// 삭제 실패한 파일 개수
private int failedCount;
// 삭제 실패한 파일명 목록
private List<String> failedFiles;
// 유지된 파일명 (best epoch 기준)
private String keptFile;
// 삭제 될 파일
private List<String> deleteTargets;
}
} }

View File

@@ -1,6 +1,8 @@
package com.kamco.cd.training.model.service; package com.kamco.cd.training.model.service;
import com.kamco.cd.training.common.enums.ModelType; import com.kamco.cd.training.common.enums.ModelType;
import com.kamco.cd.training.common.enums.TrainStatusType;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq; import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq;
import com.kamco.cd.training.dataset.dto.DatasetDto.SelectTransferDataSet; import com.kamco.cd.training.dataset.dto.DatasetDto.SelectTransferDataSet;
import com.kamco.cd.training.model.dto.ModelConfigDto; import com.kamco.cd.training.model.dto.ModelConfigDto;
@@ -14,15 +16,31 @@ import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelTrainMetrics;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelValidationMetrics; import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelValidationMetrics;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferDetailDto; import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferDetailDto;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferHyperSummary; import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferHyperSummary;
import com.kamco.cd.training.model.dto.ModelTrainMngDto;
import com.kamco.cd.training.model.dto.ModelTrainMngDto.Basic; import com.kamco.cd.training.model.dto.ModelTrainMngDto.Basic;
import com.kamco.cd.training.model.dto.ModelTrainMngDto.CleanupResult;
import com.kamco.cd.training.model.dto.ModelTrainMngDto.ModelProgressStepDto; import com.kamco.cd.training.model.dto.ModelTrainMngDto.ModelProgressStepDto;
import com.kamco.cd.training.postgres.core.ModelTrainDetailCoreService; import com.kamco.cd.training.postgres.core.ModelTrainDetailCoreService;
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService; import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
import java.io.IOException;
import java.nio.file.AccessDeniedException;
import java.nio.file.FileVisitOption;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -35,6 +53,9 @@ public class ModelTrainDetailService {
private final ModelTrainDetailCoreService modelTrainDetailCoreService; private final ModelTrainDetailCoreService modelTrainDetailCoreService;
private final ModelTrainMngCoreService mngCoreService; private final ModelTrainMngCoreService mngCoreService;
@Value("${train.docker.response_dir}")
private String responseDir;
/** /**
* 모델 상세정보 요약 * 모델 상세정보 요약
* *
@@ -63,6 +84,7 @@ public class ModelTrainDetailService {
return modelTrainDetailCoreService.findByModelByUUID(uuid); return modelTrainDetailCoreService.findByModelByUUID(uuid);
} }
// TODO 미사용시작
/** /**
* 전이학습 모델선택 정보 * 전이학습 모델선택 정보
* *
@@ -129,6 +151,8 @@ public class ModelTrainDetailService {
return transferDetailDto; return transferDetailDto;
} }
// TODO 미사용 끝
public List<ModelTrainMetrics> getModelTrainMetricResult(UUID uuid) { public List<ModelTrainMetrics> getModelTrainMetricResult(UUID uuid) {
return modelTrainDetailCoreService.getModelTrainMetricResult(uuid); return modelTrainDetailCoreService.getModelTrainMetricResult(uuid);
} }
@@ -152,4 +176,262 @@ public class ModelTrainDetailService {
public List<ModelProgressStepDto> findModelTrainProgressInfo(UUID uuid) { public List<ModelProgressStepDto> findModelTrainProgressInfo(UUID uuid) {
return modelTrainDetailCoreService.findModelTrainProgressInfo(uuid); return modelTrainDetailCoreService.findModelTrainProgressInfo(uuid);
} }
/**
* 삭제될 파일목록 및 유지될 파일 목록
*
* @param uuid
* @return
*/
public CleanupResult previewCleanup(UUID uuid) {
CleanupResult result = new CleanupResult();
// ===== 모델 조회 =====
ModelTrainMngDto.Basic model = modelTrainDetailCoreService.findByModelByUUID(uuid);
if (model == null) {
throw new CustomApiException(
"NOT_FOUND_DATA", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다. UUID: " + uuid);
}
Path dir = Paths.get(responseDir, model.getUuid().toString()).toAbsolutePath().normalize();
if (!Files.exists(dir) || !Files.isDirectory(dir)) {
throw new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND, "디렉토리가 없습니다.");
}
if (!Files.isReadable(dir)) {
throw new CustomApiException("FORBIDDEN", HttpStatus.FORBIDDEN, "디렉토리 읽기 권한이 없습니다.");
}
try (Stream<Path> stream = Files.list(dir)) {
List<Path> files = stream.toList();
if (files.isEmpty()) {
throw new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND, "파일이 없습니다.");
}
// ===== keep 파일 찾기 =====
Path keep =
files.stream()
.filter(
p -> {
String name = p.getFileName().toString();
return name.endsWith(".zip") && name.contains(model.getUuid().toString());
})
.findFirst()
.orElseThrow(
() ->
new CustomApiException(
"NOT_FOUND_DATA", HttpStatus.NOT_FOUND, "zip 파일이 없습니다."));
log.info("유지 파일: {}", keep.getFileName());
// ===== 결과 세팅 =====
result.setTotalCount(files.size());
result.setKeptFile(keep.getFileName().toString());
// ===== 삭제 대상 =====
List<String> deleteTargets =
files.stream()
.filter(
p -> !p.toAbsolutePath().normalize().equals(keep.toAbsolutePath().normalize()))
.map(p -> p.getFileName().toString())
.toList();
result.setDeleteTargets(deleteTargets);
log.info(
"previewCleanup 완료. total={}, deleteTargets={}",
result.getTotalCount(),
deleteTargets.size());
return result;
} catch (IOException e) {
log.error("파일 목록 조회 실패: {}", dir, e);
throw new CustomApiException(
"INTERNAL_SERVER_ERROR", HttpStatus.INTERNAL_SERVER_ERROR, "파일 목록 조회 실패");
}
}
public CleanupResult cleanup(UUID uuid) {
// ===== 모델 조회 =====
ModelTrainMngDto.Basic model = modelTrainDetailCoreService.findByModelByUUID(uuid);
if (model == null) {
throw new CustomApiException(
"NOT_FOUND_DATA", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다. UUID: " + uuid);
}
if (!TrainStatusType.COMPLETED.getId().equals(model.getStep2Status())) {
throw new CustomApiException("CONFLICT", HttpStatus.CONFLICT, "테스트가 완료되지 않았습니다.");
}
// ===== 경로 =====
Path dir = Paths.get(responseDir, model.getUuid().toString()).toAbsolutePath().normalize();
return executeCleanup(model, dir);
}
/**
* 베스트 에폭 제외 파일 삭제, 베스트 에폭 zip 파일만 남김
*
* @param model model 정보
* @param dir response 폴더 경로
* @return 삭제 정보
*/
public CleanupResult executeCleanup(ModelTrainMngDto.Basic model, Path dir) {
CleanupResult result = new CleanupResult();
if (!Files.exists(dir) || !Files.isDirectory(dir)) {
throw new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND, "디렉토리가 없습니다.");
}
if (!Files.isReadable(dir)) {
throw new CustomApiException("FORBIDDEN", HttpStatus.FORBIDDEN, "디렉토리 읽기 권한이 없습니다.");
}
if (!Files.isWritable(dir)) {
throw new CustomApiException("FORBIDDEN", HttpStatus.FORBIDDEN, "디렉토리 삭제 권한이 없습니다.");
}
int bestEpoch = model.getBestEpoch();
if (bestEpoch <= 0) {
throw new CustomApiException(
"BAD_REQUEST", HttpStatus.BAD_REQUEST, "잘못된 bestEpoch 값 입니다. : " + bestEpoch);
}
log.info("cleanup 시작. dir={}, bestEpoch={}", dir, bestEpoch);
try (Stream<Path> stream = Files.list(dir)) {
List<Path> files = stream.toList();
if (files.isEmpty()) {
throw new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND, "파일이 없습니다.");
}
// ===== keep 파일 찾기 =====
Path keep = null;
for (Path p : files) {
String name = p.getFileName().toString();
if (name.endsWith(".zip") && name.contains(model.getUuid().toString())) {
keep = p;
break;
}
}
if (keep == null) {
throw new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND, "zip 파일이 없습니다.");
}
log.info("유지 파일: {}", keep.getFileName());
result.setTotalCount(files.size());
result.setKeptFile(keep.getFileName().toString());
int deletedCount = 0;
List<String> failed = new ArrayList<>();
// ===== 삭제 =====
for (Path p : files) {
if (p.equals(keep)) {
continue;
}
try {
// 심볼릭 링크 → 링크만 삭제
if (Files.isSymbolicLink(p)) {
Files.deleteIfExists(p);
log.info("심볼릭 링크 삭제: {}", p.getFileName());
}
// 디렉토리 → 재귀 삭제
else if (Files.isDirectory(p, LinkOption.NOFOLLOW_LINKS)) {
log.info("디렉토리 재귀 삭제: {}", p.getFileName());
deleteDirectory(p);
}
// 일반 파일
else {
Files.deleteIfExists(p);
log.info("파일 삭제: {}", p.getFileName());
}
deletedCount++;
} catch (AccessDeniedException e) {
failed.add(p.getFileName().toString());
log.error("권한 없음: {}", p.getFileName(), e);
} catch (IOException e) {
failed.add(p.getFileName().toString());
log.error("삭제 실패: {}", p.getFileName(), e);
}
}
result.setDeletedCount(deletedCount);
result.setFailedCount(failed.size());
result.setFailedFiles(failed);
} catch (IOException e) {
log.error("파일 목록 조회 실패: {}", dir, e);
throw new CustomApiException(
"INTERNAL_SERVER_ERROR", HttpStatus.INTERNAL_SERVER_ERROR, "파일 목록 조회 실패");
}
log.info(
"cleanup 완료. total={}, deleted={}, failed={}",
result.getTotalCount(),
result.getDeletedCount(),
result.getFailedCount());
return result;
}
// 디렉토리 재귀 삭제
private void deleteDirectory(Path dir) throws IOException {
if (!Files.exists(dir, LinkOption.NOFOLLOW_LINKS)) {
return;
}
// dir 자체가 심볼릭 링크면 링크만 삭제
if (Files.isSymbolicLink(dir)) {
Files.delete(dir);
return;
}
Files.walkFileTree(
dir,
EnumSet.noneOf(FileVisitOption.class), // NOFOLLOW_LINKS
Integer.MAX_VALUE,
new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
Files.delete(file); // 링크면 링크만 삭제됨
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException exc)
throws IOException {
if (exc != null) throw exc;
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
} }

View File

@@ -3,6 +3,7 @@ package com.kamco.cd.training.model.service;
import com.kamco.cd.training.common.dto.HyperParam; import com.kamco.cd.training.common.dto.HyperParam;
import com.kamco.cd.training.common.enums.HyperParamSelectType; import com.kamco.cd.training.common.enums.HyperParamSelectType;
import com.kamco.cd.training.common.enums.ModelType; import com.kamco.cd.training.common.enums.ModelType;
import com.kamco.cd.training.common.enums.TrainStatusType;
import com.kamco.cd.training.common.enums.TrainType; import com.kamco.cd.training.common.enums.TrainType;
import com.kamco.cd.training.common.exception.CustomApiException; import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq; import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq;
@@ -14,10 +15,21 @@ import com.kamco.cd.training.model.dto.ModelTrainMngDto.SearchReq;
import com.kamco.cd.training.postgres.core.HyperParamCoreService; import com.kamco.cd.training.postgres.core.HyperParamCoreService;
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService; import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
import com.kamco.cd.training.train.service.TrainJobService; import com.kamco.cd.training.train.service.TrainJobService;
import java.io.IOException;
import java.nio.file.FileVisitOption;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -32,6 +44,16 @@ public class ModelTrainMngService {
private final ModelTrainMngCoreService modelTrainMngCoreService; private final ModelTrainMngCoreService modelTrainMngCoreService;
private final HyperParamCoreService hyperParamCoreService; private final HyperParamCoreService hyperParamCoreService;
private final TrainJobService trainJobService; private final TrainJobService trainJobService;
private final ModelTrainDetailService modelTrainDetailService;
@Value("${train.docker.base_path}")
private String basePath;
@Value("${train.docker.response_dir}")
private String responseDir;
@Value("${train.docker.symbolic_link_dir}")
private String symbolicDir;
/** /**
* 모델학습 조회 * 모델학습 조회
@@ -46,11 +68,195 @@ public class ModelTrainMngService {
/** /**
* 모델학습 삭제 * 모델학습 삭제
* *
* @param uuid * <p>순서: 1. tmp 구조 검증 (예외 발생 가능) 2. DB 삭제 (트랜잭션) 3. 파일 삭제 (실패해도 로그만)
*/ */
@Transactional @Transactional
public void deleteModelTrain(UUID uuid) { public void deleteModelTrain(UUID uuid) {
log.info("deleteModelTrain 시작. uuid={}", uuid);
// ===== 1. 모델 조회 =====
ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelByUuid(uuid);
if (model == null) {
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 없음");
}
// ===== 2. 경로 생성 =====
Path tmpBase = Path.of(symbolicDir).toAbsolutePath().normalize();
Path tmp = tmpBase.resolve(model.getRequestPath()).normalize();
Path responseBase = Paths.get(responseDir).toAbsolutePath().normalize();
Path response = responseBase.resolve(model.getUuid().toString()).normalize();
// ===== 3. 경로 탈출 방지 =====
if (!tmp.startsWith(tmpBase)) {
throw new CustomApiException("INVALID_PATH", HttpStatus.BAD_REQUEST, "잘못된 tmp 경로");
}
if (!response.startsWith(responseBase)) {
throw new CustomApiException("INVALID_PATH", HttpStatus.BAD_REQUEST, "잘못된 response 경로");
}
// ===== 4. 상태 로그 =====
log.info(
"tmp 상태: exists={}, isDir={}, isSymlink={}",
Files.exists(tmp, LinkOption.NOFOLLOW_LINKS),
Files.isDirectory(tmp, LinkOption.NOFOLLOW_LINKS),
Files.isSymbolicLink(tmp));
log.info(
"response 상태: exists={}, isDir={}, isSymlink={}",
Files.exists(response, LinkOption.NOFOLLOW_LINKS),
Files.isDirectory(response, LinkOption.NOFOLLOW_LINKS),
Files.isSymbolicLink(response));
// ===== 5. tmp 구조 검증 =====
validateTmpStructure(tmp);
// ===== 6. DB 삭제 =====
modelTrainMngCoreService.deleteModel(uuid); modelTrainMngCoreService.deleteModel(uuid);
log.info("DB 삭제 완료. uuid={}", uuid);
// ===== 7. tmp 삭제 =====
log.info("tmp 삭제 시작: {}", tmp);
try {
deleteTmpDirectory(tmp);
log.info("tmp 삭제 완료: {}", tmp);
} catch (Exception e) {
log.error("tmp 삭제 실패 (DB는 이미 삭제됨): {}", tmp, e);
}
// ===== 8. response 삭제 =====
log.info("response 삭제 시작: {}", response);
try {
// 테스트 완료되었으면 베스트 에폭은 삭제안함
if (TrainStatusType.COMPLETED.getId().equals(model.getStep2Status())) {
modelTrainDetailService.executeCleanup(model, response);
} else {
deleteResponseDirectory(response);
}
log.info("response 삭제 완료: {}", response);
} catch (Exception e) {
log.error("response 삭제 실패 (DB는 이미 삭제됨): {}", response, e);
}
log.info("deleteModelTrain 완료. uuid={}", uuid);
}
/** tmp 디렉토리 삭제 */
private void deleteTmpDirectory(Path dir) throws IOException {
if (!Files.exists(dir, LinkOption.NOFOLLOW_LINKS)) {
log.warn("삭제 대상 없음: {}", dir);
return;
}
Files.walkFileTree(
dir,
EnumSet.noneOf(FileVisitOption.class),
Integer.MAX_VALUE,
new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException exc)
throws IOException {
if (exc != null) {
throw exc;
}
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
/** response 디렉토리 삭제 */
private void deleteResponseDirectory(Path dir) throws IOException {
if (!Files.exists(dir, LinkOption.NOFOLLOW_LINKS)) {
log.warn("삭제 대상 없음: {}", dir);
return;
}
Files.walkFileTree(
dir,
EnumSet.noneOf(FileVisitOption.class),
Integer.MAX_VALUE,
new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException exc)
throws IOException {
if (exc != null) {
throw exc;
}
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
/** tmp 내부 구조 검증 - 내부는 반드시 symlink만 허용 */
private void validateTmpStructure(Path dir) {
if (!Files.exists(dir, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try {
Files.walkFileTree(
dir,
EnumSet.noneOf(FileVisitOption.class),
Integer.MAX_VALUE,
new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
// 파일은 전부 허용 (일반 + symlink)
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs)
throws IOException {
// 루트 제외 + symlink 디렉토리 금지
if (!directory.equals(dir) && Files.isSymbolicLink(directory)) {
log.error("tmp 내부에 symlink 디렉토리 존재: {}", directory);
throw new CustomApiException(
"BAD_REQUEST", HttpStatus.BAD_REQUEST, "tmp 내부에 symlink 디렉토리는 허용되지 않습니다.");
}
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
throw new CustomApiException(
"INTERNAL_SERVER_ERROR", HttpStatus.INTERNAL_SERVER_ERROR, "tmp 구조 검증 실패");
}
} }
/** /**
@@ -118,6 +324,8 @@ public class ModelTrainMngService {
public List<SelectDataSet> getDatasetSelectList(DatasetReq req) { public List<SelectDataSet> getDatasetSelectList(DatasetReq req) {
if (req.getModelNo().equals(ModelType.G1.getId())) { if (req.getModelNo().equals(ModelType.G1.getId())) {
return modelTrainMngCoreService.getDatasetSelectG1List(req); return modelTrainMngCoreService.getDatasetSelectG1List(req);
} else if (req.getModelNo().equals(ModelType.G4.getId())) {
return modelTrainMngCoreService.getDatasetSelectG4List(req);
} else { } else {
return modelTrainMngCoreService.getDatasetSelectG2G3List(req); return modelTrainMngCoreService.getDatasetSelectG2G3List(req);
} }

View File

@@ -1,6 +1,5 @@
package com.kamco.cd.training.postgres.core; package com.kamco.cd.training.postgres.core;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kamco.cd.training.common.enums.LearnDataRegister; import com.kamco.cd.training.common.enums.LearnDataRegister;
import com.kamco.cd.training.common.enums.LearnDataType; import com.kamco.cd.training.common.enums.LearnDataType;
import com.kamco.cd.training.common.exception.NotFoundException; import com.kamco.cd.training.common.exception.NotFoundException;
@@ -30,9 +29,9 @@ import org.springframework.stereotype.Service;
@Slf4j @Slf4j
public class DatasetCoreService public class DatasetCoreService
implements BaseCoreService<DatasetDto.Basic, Long, DatasetDto.SearchReq> { implements BaseCoreService<DatasetDto.Basic, Long, DatasetDto.SearchReq> {
private final DatasetRepository datasetRepository; private final DatasetRepository datasetRepository;
private final DatasetObjRepository datasetObjRepository; private final DatasetObjRepository datasetObjRepository;
private final ObjectMapper objectMapper;
/** /**
* 학습 데이터 삭제 * 학습 데이터 삭제
@@ -96,6 +95,7 @@ public class DatasetCoreService
return search(searchReq); return search(searchReq);
} }
// TODO 미사용시작
/** /**
* 학습데이터 등록 * 학습데이터 등록
* *
@@ -130,6 +130,7 @@ public class DatasetCoreService
return savedEntity.toDto(); return savedEntity.toDto();
} }
// TODO 미사용 끝
/** /**
* 학습 데이터 수정 * 학습 데이터 수정
* *
@@ -221,7 +222,6 @@ public class DatasetCoreService
return datasetRepository.insertDatasetMngData(mngRegDto); return datasetRepository.insertDatasetMngData(mngRegDto);
} }
@Transactional
public void insertDatasetObj(DatasetObjRegDto objRegDto) { public void insertDatasetObj(DatasetObjRegDto objRegDto) {
datasetObjRepository.insertDatasetObj(objRegDto); datasetObjRepository.insertDatasetObj(objRegDto);
} }
@@ -234,13 +234,20 @@ public class DatasetCoreService
datasetObjRepository.insertDatasetTestObj(objRegDto); datasetObjRepository.insertDatasetTestObj(objRegDto);
} }
public void updateDatasetUploadStatus(Long datasetUid) { /**
* 학습데이터셋 마스터 상태 변경
*
* @param datasetUid 학습데이터셋 마스터 id
* @param register 상태
*/
@Transactional
public void updateDatasetUploadStatus(Long datasetUid, LearnDataRegister register) {
DatasetEntity entity = DatasetEntity entity =
datasetRepository datasetRepository
.findById(datasetUid) .findById(datasetUid)
.orElseThrow(() -> new NotFoundException("데이터셋을 찾을 수 없습니다. ID: " + datasetUid)); .orElseThrow(() -> new NotFoundException("데이터셋을 찾을 수 없습니다. ID: " + datasetUid));
entity.setStatus(LearnDataRegister.COMPLETED.getId()); entity.setStatus(register.getId());
} }
public void insertDatasetValObj(DatasetObjRegDto objRegDto) { public void insertDatasetValObj(DatasetObjRegDto objRegDto) {
@@ -250,4 +257,15 @@ public class DatasetCoreService
public Long findDatasetByUidExistsCnt(String uid) { public Long findDatasetByUidExistsCnt(String uid) {
return datasetRepository.findDatasetByUidExistsCnt(uid); return datasetRepository.findDatasetByUidExistsCnt(uid);
} }
/**
* 데이터셋 등록 실패시 Obj 데이터 정리
*
* @param datasetUid 모델 마스터 id
*/
@Transactional
public void deleteAllDatasetObj(Long datasetUid) {
int cnt = datasetObjRepository.deleteAllDatasetObj(datasetUid);
log.info("datasetUid={} 데이터셋 실패 - 전체 삭제 완료. 총 {}건", datasetUid, cnt);
}
} }

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.postgres.core; package com.kamco.cd.training.postgres.core;
import com.kamco.cd.training.common.exception.NotFoundException; import com.kamco.cd.training.common.exception.NotFoundException;
@@ -70,3 +71,4 @@ public class MapSheetCoreService
} }
} }
} }
// TODO 미사용 끝

View File

@@ -67,10 +67,13 @@ public class ModelTrainDetailCoreService {
return modelDetailRepository.getByModelHyperParamSummary(uuid); return modelDetailRepository.getByModelHyperParamSummary(uuid);
} }
// TODO 미사용시작
public TransferHyperSummary getTransferHyperSummary(UUID uuid) { public TransferHyperSummary getTransferHyperSummary(UUID uuid) {
return modelDetailRepository.getByModelTransferHyperParamSummary(uuid); return modelDetailRepository.getByModelTransferHyperParamSummary(uuid);
} }
// TODO 미사용 끝
public List<MappingDataset> getByModelMappingDataset(UUID uuid) { public List<MappingDataset> getByModelMappingDataset(UUID uuid) {
return modelDetailRepository.getByModelMappingDataset(uuid); return modelDetailRepository.getByModelMappingDataset(uuid);
} }
@@ -80,6 +83,7 @@ public class ModelTrainDetailCoreService {
return entity.toDto(); return entity.toDto();
} }
// TODO 미사용시작
/** /**
* 모델 학습별 config 정보 조회 * 모델 학습별 config 정보 조회
* *
@@ -90,6 +94,7 @@ public class ModelTrainDetailCoreService {
return modelConfigRepository.findModelConfigByModelId(modelId).orElse(null); return modelConfigRepository.findModelConfigByModelId(modelId).orElse(null);
} }
// TODO 미사용 끝
public List<ModelTrainMetrics> getModelTrainMetricResult(UUID uuid) { public List<ModelTrainMetrics> getModelTrainMetricResult(UUID uuid) {
return modelDetailRepository.getModelTrainMetricResult(uuid); return modelDetailRepository.getModelTrainMetricResult(uuid);
} }

View File

@@ -219,6 +219,7 @@ public class ModelTrainMngCoreService {
modelConfigRepository.save(entity); modelConfigRepository.save(entity);
} }
// TODO 미사용시작
/** /**
* 데이터셋 매핑 생성 * 데이터셋 매핑 생성
* *
@@ -235,6 +236,8 @@ public class ModelTrainMngCoreService {
} }
} }
// TODO 미사용 끝
/** /**
* UUID로 모델 조회 * UUID로 모델 조회
* *
@@ -278,6 +281,7 @@ public class ModelTrainMngCoreService {
.orElseThrow(() -> new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND)); .orElseThrow(() -> new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND));
} }
// TODO 미사용시작
public ModelConfigDto.TransferBasic findModelTransferConfigByModelId(UUID uuid) { public ModelConfigDto.TransferBasic findModelTransferConfigByModelId(UUID uuid) {
ModelMasterEntity modelEntity = findByUuid(uuid); ModelMasterEntity modelEntity = findByUuid(uuid);
return modelConfigRepository return modelConfigRepository
@@ -285,6 +289,8 @@ public class ModelTrainMngCoreService {
.orElseThrow(() -> new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND)); .orElseThrow(() -> new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND));
} }
// TODO 미사용 끝
/** /**
* 데이터셋 G1 목록 * 데이터셋 G1 목록
* *
@@ -295,6 +301,7 @@ public class ModelTrainMngCoreService {
return datasetRepository.getDatasetSelectG1List(req); return datasetRepository.getDatasetSelectG1List(req);
} }
// TODO 미사용시작
/** /**
* 전이학습 데이터셋 G1 목록 * 전이학습 데이터셋 G1 목록
* *
@@ -305,6 +312,8 @@ public class ModelTrainMngCoreService {
return datasetRepository.getDatasetTransferSelectG1List(modelId); return datasetRepository.getDatasetTransferSelectG1List(modelId);
} }
// TODO 미사용 끝
/** /**
* 데이터셋 G2, G3 목록 * 데이터셋 G2, G3 목록
* *
@@ -315,6 +324,7 @@ public class ModelTrainMngCoreService {
return datasetRepository.getDatasetSelectG2G3List(req); return datasetRepository.getDatasetSelectG2G3List(req);
} }
// TODO 미사용시작
/** /**
* 전이학습 데이터셋 G2, G3 목록 * 전이학습 데이터셋 G2, G3 목록
* *
@@ -327,6 +337,18 @@ public class ModelTrainMngCoreService {
return datasetRepository.getDatasetTransferSelectG2G3List(modelId, modelNo); return datasetRepository.getDatasetTransferSelectG2G3List(modelId, modelNo);
} }
/**
* 데이터셋 G4 목록
*
* @param req
* @return
*/
public List<SelectDataSet> getDatasetSelectG4List(DatasetReq req) {
return datasetRepository.getDatasetSelectG4List(req);
}
// TODO 미사용 끝
/** /**
* 모델관리 조회 * 모델관리 조회
* *
@@ -341,6 +363,20 @@ public class ModelTrainMngCoreService {
return entity.toDto(); return entity.toDto();
} }
/**
* 모델관리 조회
*
* @param uuid
* @return
*/
public ModelTrainMngDto.Basic findModelByUuid(UUID uuid) {
ModelMasterEntity entity =
modelMngRepository
.findByUuid(uuid)
.orElseThrow(() -> new IllegalArgumentException("Model not found: " + uuid));
return entity.toDto();
}
/** 마스터를 IN_PROGRESS로 전환하고, 현재 실행 jobId를 연결 - UI/중단/상태조회 모두 currentAttemptId를 기준으로 동작 */ /** 마스터를 IN_PROGRESS로 전환하고, 현재 실행 jobId를 연결 - UI/중단/상태조회 모두 currentAttemptId를 기준으로 동작 */
@Transactional @Transactional
public void markInProgress(Long modelId, Long jobId) { public void markInProgress(Long modelId, Long jobId) {
@@ -430,6 +466,44 @@ public class ModelTrainMngCoreService {
master.setUpdatedDttm(ZonedDateTime.now()); master.setUpdatedDttm(ZonedDateTime.now());
} }
/**
* step1 정지 처리
*
* @param modelId
* @param errorMessage
*/
public void markStep1Stop(Long modelId, String errorMessage) {
ModelMasterEntity master =
modelMngRepository
.findById(modelId)
.orElseThrow(() -> new IllegalArgumentException("Model not found: " + modelId));
master.setStatusCd(TrainStatusType.STOPPED.getId());
master.setStep1State(TrainStatusType.STOPPED.getId());
master.setLastError(errorMessage);
master.setUpdatedUid(userUtil.getId());
master.setUpdatedDttm(ZonedDateTime.now());
}
/**
* step2 정지 처리
*
* @param modelId
* @param errorMessage
*/
public void markStep2Stop(Long modelId, String errorMessage) {
ModelMasterEntity master =
modelMngRepository
.findById(modelId)
.orElseThrow(() -> new IllegalArgumentException("Model not found: " + modelId));
master.setStatusCd(TrainStatusType.STOPPED.getId());
master.setStep2State(TrainStatusType.STOPPED.getId());
master.setLastError(errorMessage);
master.setUpdatedUid(userUtil.getId());
master.setUpdatedDttm(ZonedDateTime.now());
}
@Transactional @Transactional
public void markSuccess(Long modelId) { public void markSuccess(Long modelId) {
ModelMasterEntity master = ModelMasterEntity master =

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.postgres.core; package com.kamco.cd.training.postgres.core;
import com.kamco.cd.training.postgres.entity.SystemMetricsEntity; import com.kamco.cd.training.postgres.entity.SystemMetricsEntity;
@@ -64,3 +65,4 @@ public class SystemMetricsCoreService {
return isAvailable; return isAvailable;
} }
} }
// TODO 미사용 끝

View File

@@ -117,10 +117,12 @@ public class DatasetEntity {
@Column(name = "dataset_path", length = 1000) @Column(name = "dataset_path", length = 1000)
private String datasetPath; private String datasetPath;
// TODO 미사용시작
@Column(name = "class_counts") @Column(name = "class_counts")
@JdbcTypeCode(SqlTypes.JSON) @JdbcTypeCode(SqlTypes.JSON)
private Map<String, Integer> classCounts; private Map<String, Integer> classCounts;
// TODO 미사용 끝
@Size(max = 32) @Size(max = 32)
@Column(name = "uid") @Column(name = "uid")
private String uid; private String uid;

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.postgres.entity; package com.kamco.cd.training.postgres.entity;
import com.kamco.cd.training.dataset.dto.MapSheetDto; import com.kamco.cd.training.dataset.dto.MapSheetDto;
@@ -103,3 +104,4 @@ public class MapSheetEntity {
return dto; return dto;
} }
} }
// TODO 미사용 끝

View File

@@ -32,10 +32,13 @@ public class ModelDatasetMappEntity {
@Column(name = "dataset_uid", nullable = false) @Column(name = "dataset_uid", nullable = false)
private Long datasetUid; private Long datasetUid;
// TODO 미사용시작
@Size(max = 20) @Size(max = 20)
@Column(name = "dataset_type", length = 20) @Column(name = "dataset_type", length = 20)
private String datasetType; private String datasetType;
// TODO 미사용 끝
@Getter @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor

View File

@@ -141,6 +141,7 @@ public class ModelMasterEntity {
this.packingState, this.packingState,
this.packingStrtDttm, this.packingStrtDttm,
this.packingEndDttm, this.packingEndDttm,
this.beforeModelId); this.beforeModelId,
this.bestEpoch);
} }
} }

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.postgres.entity; package com.kamco.cd.training.postgres.entity;
import jakarta.persistence.Column; import jakarta.persistence.Column;
@@ -92,3 +93,4 @@ public class ModelMngEntity {
return this.uuid != null ? this.uuid.toString() : null; return this.uuid != null ? this.uuid.toString() : null;
} }
} }
// TODO 미사용 끝

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.postgres.entity; package com.kamco.cd.training.postgres.entity;
import jakarta.persistence.Column; import jakarta.persistence.Column;
@@ -53,3 +54,4 @@ public class SystemMetricsEntity {
@Column(name = "memused") @Column(name = "memused")
private Float memused; private Float memused;
} }
// TODO 미사용 끝

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.postgres.repository; package com.kamco.cd.training.postgres.repository;
import com.kamco.cd.training.postgres.entity.SystemMetricsEntity; import com.kamco.cd.training.postgres.entity.SystemMetricsEntity;
@@ -17,3 +18,4 @@ public interface SystemMetricsRepository extends JpaRepository<SystemMetricsEnti
@Query("SELECT s FROM SystemMetricsEntity s ORDER BY s.timestamp DESC LIMIT 1") @Query("SELECT s FROM SystemMetricsEntity s ORDER BY s.timestamp DESC LIMIT 1")
Optional<SystemMetricsEntity> findLatestMetrics(); Optional<SystemMetricsEntity> findLatestMetrics();
} }
// TODO 미사용 끝

View File

@@ -24,4 +24,12 @@ public interface DatasetObjRepositoryCustom {
void insertDatasetTestObj(DatasetObjRegDto objRegDto); void insertDatasetTestObj(DatasetObjRegDto objRegDto);
void insertDatasetValObj(DatasetObjRegDto objRegDto); void insertDatasetValObj(DatasetObjRegDto objRegDto);
/**
* 데이터셋 등록 실패시 Obj 데이터 정리
*
* @param datasetUid
* @return
*/
int deleteAllDatasetObj(Long datasetUid);
} }

View File

@@ -40,6 +40,7 @@ public class DatasetObjRepositoryImpl implements DatasetObjRepositoryCustom {
private final JPAQueryFactory queryFactory; private final JPAQueryFactory queryFactory;
private final QDatasetEntity dataset = datasetEntity; private final QDatasetEntity dataset = datasetEntity;
private final ObjectMapper objectMapper = new ObjectMapper();
@PersistenceContext EntityManager em; @PersistenceContext EntityManager em;
@@ -55,7 +56,6 @@ public class DatasetObjRepositoryImpl implements DatasetObjRepositoryCustom {
@Override @Override
public void insertDatasetTestObj(DatasetObjRegDto objRegDto) { public void insertDatasetTestObj(DatasetObjRegDto objRegDto) {
ObjectMapper objectMapper = new ObjectMapper();
String json; String json;
Geometry geometry; Geometry geometry;
String geometryJson; String geometryJson;
@@ -99,7 +99,6 @@ public class DatasetObjRepositoryImpl implements DatasetObjRepositoryCustom {
@Override @Override
public void insertDatasetValObj(DatasetObjRegDto objRegDto) { public void insertDatasetValObj(DatasetObjRegDto objRegDto) {
ObjectMapper objectMapper = new ObjectMapper();
String json; String json;
String geometryJson; String geometryJson;
try { try {
@@ -219,7 +218,6 @@ public class DatasetObjRepositoryImpl implements DatasetObjRepositoryCustom {
@Override @Override
public void insertDatasetObj(DatasetObjRegDto objRegDto) { public void insertDatasetObj(DatasetObjRegDto objRegDto) {
ObjectMapper objectMapper = new ObjectMapper();
String json; String json;
String geometryJson; String geometryJson;
try { try {
@@ -276,4 +274,38 @@ public class DatasetObjRepositoryImpl implements DatasetObjRepositoryCustom {
.where(datasetObjEntity.uuid.eq(uuid)) .where(datasetObjEntity.uuid.eq(uuid))
.fetchOne(); .fetchOne();
} }
@Override
public int deleteAllDatasetObj(Long datasetUid) {
int cnt = 0;
cnt =
em.createNativeQuery(
"""
delete from tb_dataset_obj
where dataset_uid = ?
""")
.setParameter(1, datasetUid)
.executeUpdate();
cnt +=
em.createNativeQuery(
"""
delete from tb_dataset_val_obj
where dataset_uid = ?
""")
.setParameter(1, datasetUid)
.executeUpdate();
cnt +=
em.createNativeQuery(
"""
delete from tb_dataset_test_obj
where dataset_uid = ?
""")
.setParameter(1, datasetUid)
.executeUpdate();
em.clear();
return cnt;
}
} }

View File

@@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface DatasetRepository public interface DatasetRepository
extends JpaRepository<DatasetEntity, Long>, DatasetRepositoryCustom { extends JpaRepository<DatasetEntity, Long>, DatasetRepositoryCustom {
// TODO 미사용시작
List<DatasetEntity> findByDeletedOrderByCreatedDttmDesc(Boolean deleted); List<DatasetEntity> findByDeletedOrderByCreatedDttmDesc(Boolean deleted);
// TODO 미사용 끝
} }

View File

@@ -18,12 +18,17 @@ public interface DatasetRepositoryCustom {
List<SelectDataSet> getDatasetSelectG1List(DatasetReq req); List<SelectDataSet> getDatasetSelectG1List(DatasetReq req);
// TODO 미사용시작
public List<SelectTransferDataSet> getDatasetTransferSelectG1List(Long modelId); public List<SelectTransferDataSet> getDatasetTransferSelectG1List(Long modelId);
public List<SelectTransferDataSet> getDatasetTransferSelectG2G3List(Long modelId, String modelNo); public List<SelectTransferDataSet> getDatasetTransferSelectG2G3List(Long modelId, String modelNo);
// TODO 미사용 끝
List<SelectDataSet> getDatasetSelectG2G3List(DatasetReq req); List<SelectDataSet> getDatasetSelectG2G3List(DatasetReq req);
List<SelectDataSet> getDatasetSelectG4List(DatasetReq req);
Long getDatasetMaxStage(int compareYyyy, int targetYyyy); Long getDatasetMaxStage(int compareYyyy, int targetYyyy);
Long insertDatasetMngData(DatasetMngRegDto mngRegDto); Long insertDatasetMngData(DatasetMngRegDto mngRegDto);

View File

@@ -4,6 +4,7 @@ import static com.kamco.cd.training.postgres.entity.QDatasetObjEntity.datasetObj
import static com.kamco.cd.training.postgres.entity.QModelDatasetMappEntity.modelDatasetMappEntity; import static com.kamco.cd.training.postgres.entity.QModelDatasetMappEntity.modelDatasetMappEntity;
import static com.kamco.cd.training.postgres.entity.QModelMasterEntity.modelMasterEntity; import static com.kamco.cd.training.postgres.entity.QModelMasterEntity.modelMasterEntity;
import com.kamco.cd.training.common.enums.DetectionClassification;
import com.kamco.cd.training.common.enums.ModelType; import com.kamco.cd.training.common.enums.ModelType;
import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetMngRegDto; import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetMngRegDto;
import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq; import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq;
@@ -98,9 +99,7 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
BooleanBuilder builder = new BooleanBuilder(); BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.isNotBlank(req.getDataType()) && !"CURRENT".equals(req.getDataType())) { builder.and(dataset.deleted.isFalse());
builder.and(dataset.dataType.eq(req.getDataType()));
}
if (StringUtils.isNotBlank(req.getDataType()) && !"CURRENT".equals(req.getDataType())) { if (StringUtils.isNotBlank(req.getDataType()) && !"CURRENT".equals(req.getDataType())) {
builder.and(dataset.dataType.eq(req.getDataType())); builder.and(dataset.dataType.eq(req.getDataType()));
@@ -124,12 +123,15 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
dataset.targetYyyy, dataset.targetYyyy,
dataset.memo, dataset.memo,
new CaseBuilder() new CaseBuilder()
.when(datasetObjEntity.targetClassCd.eq("building")) .when(
datasetObjEntity.targetClassCd.eq(DetectionClassification.BUILDING.getId()))
.then(1) .then(1)
.otherwise(0) .otherwise(0)
.sum(), .sum(),
new CaseBuilder() new CaseBuilder()
.when(datasetObjEntity.targetClassCd.eq("container")) .when(
datasetObjEntity.targetClassCd.eq(
DetectionClassification.CONTAINER.getId()))
.then(1) .then(1)
.otherwise(0) .otherwise(0)
.sum())) .sum()))
@@ -148,6 +150,7 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
.fetch(); .fetch();
} }
// TODO 미사용시작
@Override @Override
public List<SelectTransferDataSet> getDatasetTransferSelectG1List(Long modelId) { public List<SelectTransferDataSet> getDatasetTransferSelectG1List(Long modelId) {
@@ -245,18 +248,32 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
.fetch(); .fetch();
} }
// TODO 미사용 끝
@Override @Override
public List<SelectDataSet> getDatasetSelectG2G3List(DatasetReq req) { public List<SelectDataSet> getDatasetSelectG2G3List(DatasetReq req) {
String building = DetectionClassification.BUILDING.getId();
String container = DetectionClassification.CONTAINER.getId();
String waste = DetectionClassification.WASTE.getId();
String solar = DetectionClassification.SOLAR.getId();
BooleanBuilder builder = new BooleanBuilder(); BooleanBuilder builder = new BooleanBuilder();
builder.and(dataset.deleted.isFalse());
NumberExpression<Long> selectedCnt = null; NumberExpression<Long> selectedCnt = null;
NumberExpression<Long> wasteCnt = NumberExpression<Long> wasteCnt =
datasetObjEntity.targetClassCd.when("waste").then(1L).otherwise(0L).sum(); datasetObjEntity
.targetClassCd
.when(DetectionClassification.WASTE.getId())
.then(1L)
.otherwise(0L)
.sum();
// G1, G2, G4 제외
NumberExpression<Long> elseCnt = NumberExpression<Long> elseCnt =
new CaseBuilder() new CaseBuilder()
.when(datasetObjEntity.targetClassCd.notIn("building", "container", "waste")) .when(datasetObjEntity.targetClassCd.notIn(building, container, waste, solar))
.then(1L) .then(1L)
.otherwise(0L) .otherwise(0L)
.sum(); .sum();
@@ -308,6 +325,7 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
.fetch(); .fetch();
} }
// TODO 미사용시작
@Override @Override
public List<SelectTransferDataSet> getDatasetTransferSelectG2G3List( public List<SelectTransferDataSet> getDatasetTransferSelectG2G3List(
Long modelId, String modelNo) { Long modelId, String modelNo) {
@@ -418,6 +436,8 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
.fetch(); .fetch();
} }
// TODO 미사용 끝
@Override @Override
public Long getDatasetMaxStage(int compareYyyy, int targetYyyy) { public Long getDatasetMaxStage(int compareYyyy, int targetYyyy) {
return queryFactory return queryFactory
@@ -473,4 +493,53 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
.where(dataset.uid.eq(uid), dataset.deleted.isFalse()) .where(dataset.uid.eq(uid), dataset.deleted.isFalse())
.fetchOne(); .fetchOne();
} }
@Override
public List<SelectDataSet> getDatasetSelectG4List(DatasetReq req) {
BooleanBuilder builder = new BooleanBuilder();
builder.and(dataset.deleted.isFalse());
if (StringUtils.isNotBlank(req.getDataType()) && !"CURRENT".equals(req.getDataType())) {
builder.and(dataset.dataType.eq(req.getDataType()));
}
if (req.getIds() != null) {
builder.and(dataset.id.in(req.getIds()));
}
return queryFactory
.select(
Projections.constructor(
SelectDataSet.class,
Expressions.constant(req.getModelNo()),
dataset.id,
dataset.uuid,
dataset.dataType,
dataset.title,
dataset.roundNo,
dataset.compareYyyy,
dataset.targetYyyy,
dataset.memo,
new CaseBuilder()
.when(
datasetObjEntity.targetClassCd.equalsIgnoreCase(
DetectionClassification.SOLAR.getId()))
.then(1)
.otherwise(0)
.sum()))
.from(dataset)
.leftJoin(datasetObjEntity)
.on(dataset.id.eq(datasetObjEntity.datasetUid))
.where(builder)
.groupBy(
dataset.id,
dataset.uuid,
dataset.dataType,
dataset.title,
dataset.roundNo,
dataset.memo)
.orderBy(dataset.createdDttm.desc())
.fetch();
}
} }

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.postgres.repository.dataset; package com.kamco.cd.training.postgres.repository.dataset;
import com.kamco.cd.training.postgres.entity.MapSheetEntity; import com.kamco.cd.training.postgres.entity.MapSheetEntity;
@@ -11,3 +12,4 @@ public interface MapSheetRepository
long countByDatasetIdAndDeletedFalse(Long datasetId); long countByDatasetIdAndDeletedFalse(Long datasetId);
} }
// TODO 미사용 끝

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.postgres.repository.dataset; package com.kamco.cd.training.postgres.repository.dataset;
import com.kamco.cd.training.dataset.dto.MapSheetDto; import com.kamco.cd.training.dataset.dto.MapSheetDto;
@@ -7,3 +8,4 @@ import org.springframework.data.domain.Page;
public interface MapSheetRepositoryCustom { public interface MapSheetRepositoryCustom {
Page<MapSheetEntity> findMapSheetList(MapSheetDto.SearchReq searchReq); Page<MapSheetEntity> findMapSheetList(MapSheetDto.SearchReq searchReq);
} }
// TODO 미사용 끝

View File

@@ -1,3 +1,4 @@
// TODO 미사용시작
package com.kamco.cd.training.postgres.repository.dataset; package com.kamco.cd.training.postgres.repository.dataset;
import com.kamco.cd.training.dataset.dto.MapSheetDto; import com.kamco.cd.training.dataset.dto.MapSheetDto;
@@ -52,3 +53,4 @@ public class MapSheetRepositoryImpl implements MapSheetRepositoryCustom {
return new PageImpl<>(content, pageable, total); return new PageImpl<>(content, pageable, total);
} }
} }
// TODO 미사용 끝

View File

@@ -8,5 +8,7 @@ import org.springframework.stereotype.Repository;
@Repository @Repository
public interface HyperParamRepository public interface HyperParamRepository
extends JpaRepository<ModelHyperParamEntity, Long>, HyperParamRepositoryCustom { extends JpaRepository<ModelHyperParamEntity, Long>, HyperParamRepositoryCustom {
// TODO 미사용시작
Optional<ModelHyperParamEntity> findByHyperVer(String hyperVer); Optional<ModelHyperParamEntity> findByHyperVer(String hyperVer);
// TODO 미사용 끝
} }

View File

@@ -11,6 +11,7 @@ import org.springframework.data.domain.Page;
public interface HyperParamRepositoryCustom { public interface HyperParamRepositoryCustom {
// TODO 미사용시작
/** /**
* 마지막 버전 조회 * 마지막 버전 조회
* *
@@ -19,6 +20,8 @@ public interface HyperParamRepositoryCustom {
@Deprecated @Deprecated
Optional<ModelHyperParamEntity> findHyperParamVer(); Optional<ModelHyperParamEntity> findHyperParamVer();
// TODO 미사용 끝
/** /**
* 모델 타입별 마지막 버전 조회 * 모델 타입별 마지막 버전 조회
* *
@@ -27,8 +30,11 @@ public interface HyperParamRepositoryCustom {
*/ */
Optional<ModelHyperParamEntity> findHyperParamVerByModelType(ModelType modelType); Optional<ModelHyperParamEntity> findHyperParamVerByModelType(ModelType modelType);
// TODO 미사용시작
Optional<ModelHyperParamEntity> findHyperParamByHyperVer(String hyperVer); Optional<ModelHyperParamEntity> findHyperParamByHyperVer(String hyperVer);
// TODO 미사용 끝
/** /**
* 하이퍼 파라미터 상세조회 * 하이퍼 파라미터 상세조회
* *

View File

@@ -29,6 +29,7 @@ public class HyperParamRepositoryImpl implements HyperParamRepositoryCustom {
private final JPAQueryFactory queryFactory; private final JPAQueryFactory queryFactory;
// TODO 미사용시작
@Override @Override
public Optional<ModelHyperParamEntity> findHyperParamVer() { public Optional<ModelHyperParamEntity> findHyperParamVer() {
@@ -42,6 +43,8 @@ public class HyperParamRepositoryImpl implements HyperParamRepositoryCustom {
.fetchOne()); .fetchOne());
} }
// TODO 미사용 끝
@Override @Override
public Optional<ModelHyperParamEntity> findHyperParamVerByModelType(ModelType modelType) { public Optional<ModelHyperParamEntity> findHyperParamVerByModelType(ModelType modelType) {
@@ -59,6 +62,7 @@ public class HyperParamRepositoryImpl implements HyperParamRepositoryCustom {
.fetchOne()); .fetchOne());
} }
// TODO 미사용시작
@Override @Override
public Optional<ModelHyperParamEntity> findHyperParamByHyperVer(String hyperVer) { public Optional<ModelHyperParamEntity> findHyperParamByHyperVer(String hyperVer) {
@@ -75,6 +79,8 @@ public class HyperParamRepositoryImpl implements HyperParamRepositoryCustom {
.fetchOne()); .fetchOne());
} }
// TODO 미사용 끝
@Override @Override
public Optional<ModelHyperParamEntity> findHyperParamByUuid(UUID uuid) { public Optional<ModelHyperParamEntity> findHyperParamByUuid(UUID uuid) {
return Optional.ofNullable( return Optional.ofNullable(

View File

@@ -7,14 +7,18 @@ import java.util.UUID;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
public interface MembersRepositoryCustom { public interface MembersRepositoryCustom {
// TODO 미사용시작
boolean existsByUserId(String userId); boolean existsByUserId(String userId);
// TODO 미사용 끝
boolean existsByEmployeeNo(String employeeNo); boolean existsByEmployeeNo(String employeeNo);
Optional<MemberEntity> findByEmployeeNo(String employeeNo); Optional<MemberEntity> findByEmployeeNo(String employeeNo);
// TODO 미사용시작
Optional<MemberEntity> findByUserId(String userId); Optional<MemberEntity> findByUserId(String userId);
// TODO 미사용 끝
Optional<MemberEntity> findByUUID(UUID uuid); Optional<MemberEntity> findByUUID(UUID uuid);
Page<MemberEntity> findByMembers(MembersDto.SearchReq searchReq); Page<MemberEntity> findByMembers(MembersDto.SearchReq searchReq);

View File

@@ -27,6 +27,7 @@ public class MembersRepositoryImpl extends QuerydslRepositorySupport
this.queryFactory = queryFactory; this.queryFactory = queryFactory;
} }
// TODO 미사용시작
/** /**
* 사용자 ID 조회 * 사용자 ID 조회
* *
@@ -43,6 +44,8 @@ public class MembersRepositoryImpl extends QuerydslRepositorySupport
!= null; != null;
} }
// TODO 미사용 끝
/** /**
* 사용자 사번 조회 * 사용자 사번 조회
* *
@@ -59,6 +62,7 @@ public class MembersRepositoryImpl extends QuerydslRepositorySupport
!= null; != null;
} }
// TODO 미사용시작
/** /**
* 사용자 조회 user id * 사용자 조회 user id
* *
@@ -71,6 +75,8 @@ public class MembersRepositoryImpl extends QuerydslRepositorySupport
queryFactory.selectFrom(memberEntity).where(memberEntity.userId.eq(userId)).fetchOne()); queryFactory.selectFrom(memberEntity).where(memberEntity.userId.eq(userId)).fetchOne());
} }
// TODO 미사용 끝
/** /**
* 사용자 조회 employeed no * 사용자 조회 employeed no
* *

View File

@@ -6,5 +6,7 @@ import java.util.Optional;
public interface ModelConfigRepositoryCustom { public interface ModelConfigRepositoryCustom {
Optional<ModelConfigDto.Basic> findModelConfigByModelId(Long modelId); Optional<ModelConfigDto.Basic> findModelConfigByModelId(Long modelId);
// TODO 미사용시작
Optional<ModelConfigDto.TransferBasic> findModelTransferConfigByModelId(Long modelId); Optional<ModelConfigDto.TransferBasic> findModelTransferConfigByModelId(Long modelId);
// TODO 미사용 끝
} }

View File

@@ -39,6 +39,7 @@ public class ModelConfigRepositoryImpl implements ModelConfigRepositoryCustom {
.fetchOne()); .fetchOne());
} }
// TODO 미사용시작
@Override @Override
public Optional<TransferBasic> findModelTransferConfigByModelId(Long modelId) { public Optional<TransferBasic> findModelTransferConfigByModelId(Long modelId) {
QModelConfigEntity beforeConfig = new QModelConfigEntity("beforeConfig"); QModelConfigEntity beforeConfig = new QModelConfigEntity("beforeConfig");
@@ -78,4 +79,5 @@ public class ModelConfigRepositoryImpl implements ModelConfigRepositoryCustom {
.where(modelMasterEntity.id.eq(modelId)) .where(modelMasterEntity.id.eq(modelId))
.fetchOne()); .fetchOne());
} }
// TODO 미사용 끝
} }

View File

@@ -23,8 +23,11 @@ public interface ModelDetailRepositoryCustom {
HyperSummary getByModelHyperParamSummary(UUID uuid); HyperSummary getByModelHyperParamSummary(UUID uuid);
// TODO 미사용시작
TransferHyperSummary getByModelTransferHyperParamSummary(UUID uuid); TransferHyperSummary getByModelTransferHyperParamSummary(UUID uuid);
// TODO 미사용 끝
List<MappingDataset> getByModelMappingDataset(UUID uuid); List<MappingDataset> getByModelMappingDataset(UUID uuid);
ModelMasterEntity findByModelByUUID(UUID uuid); ModelMasterEntity findByModelByUUID(UUID uuid);

View File

@@ -20,8 +20,11 @@ public interface ModelMngRepositoryCustom {
Optional<ModelMasterEntity> findByUuid(UUID uuid); Optional<ModelMasterEntity> findByUuid(UUID uuid);
// TODO 미사용시작
Optional<ModelMasterEntity> findFirstByStatusCdAndDelYn(String statusCd, Boolean delYn); Optional<ModelMasterEntity> findFirstByStatusCdAndDelYn(String statusCd, Boolean delYn);
// TODO 미사용 끝
TrainRunRequest findTrainRunRequest(Long modelId); TrainRunRequest findTrainRunRequest(Long modelId);
Long findModelStep1InProgressCnt(); Long findModelStep1InProgressCnt();

View File

@@ -133,11 +133,14 @@ public class ModelMngRepositoryImpl implements ModelMngRepositoryCustom {
.fetchOne()); .fetchOne());
} }
// TODO 미사용시작
@Override @Override
public Optional<ModelMasterEntity> findFirstByStatusCdAndDelYn(String statusCd, Boolean delYn) { public Optional<ModelMasterEntity> findFirstByStatusCdAndDelYn(String statusCd, Boolean delYn) {
return Optional.empty(); return Optional.empty();
} }
// TODO 미사용 끝
@Override @Override
public TrainRunRequest findTrainRunRequest(Long modelId) { public TrainRunRequest findTrainRunRequest(Long modelId) {
return queryFactory return queryFactory

View File

@@ -23,11 +23,11 @@ import org.springframework.stereotype.Service;
public class DataSetCountersService { public class DataSetCountersService {
private final ModelTrainMngCoreService modelTrainMngCoreService; private final ModelTrainMngCoreService modelTrainMngCoreService;
@Value("${train.docker.requestDir}") @Value("${train.docker.request_dir}")
private String requestDir; private String requestDir;
@Value("${train.docker.basePath}") @Value("${train.docker.symbolic_link_dir}")
private String trainBaseDir; private String symbolicDir;
public String getCount(Long modelId) { public String getCount(Long modelId) {
ModelTrainMngDto.Basic basic = modelTrainMngCoreService.findModelById(modelId); ModelTrainMngDto.Basic basic = modelTrainMngCoreService.findModelById(modelId);
@@ -45,7 +45,7 @@ public class DataSetCountersService {
} }
// tmp // tmp
Path tmpPath = Path.of(trainBaseDir, "tmp", basic.getRequestPath()); Path tmpPath = Path.of(symbolicDir, basic.getRequestPath());
// 차이나는거 // 차이나는거
diffMergedRequestsVsTmp(uids, tmpPath); diffMergedRequestsVsTmp(uids, tmpPath);
@@ -222,7 +222,9 @@ public class DataSetCountersService {
log.info("missing = {}", missing.size()); log.info("missing = {}", missing.size());
log.info("extra = {}", extra.size()); log.info("extra = {}", extra.size());
log.info("[MISSING] total = {}", missing.size());
missing.stream().sorted().limit(50).forEach(f -> log.warn("[MISSING] {}", f)); missing.stream().sorted().limit(50).forEach(f -> log.warn("[MISSING] {}", f));
log.info("[EXTRA] total = {}", extra.size());
extra.stream().sorted().limit(50).forEach(f -> log.warn("[EXTRA] {}", f)); extra.stream().sorted().limit(50).forEach(f -> log.warn("[EXTRA] {}", f));
} }
} }

View File

@@ -31,25 +31,31 @@ public class DockerTrainService {
private String image; private String image;
// 학습 요청 데이터가 위치한 호스트 디렉토리 // 학습 요청 데이터가 위치한 호스트 디렉토리
@Value("${train.docker.requestDir}") @Value("${train.docker.request_dir}")
private String requestDir; private String requestDir;
// 학습 결과가 저장될 호스트 디렉토리 // 학습 결과가 저장될 호스트 디렉토리
@Value("${train.docker.responseDir}") @Value("${train.docker.response_dir}")
private String responseDir; private String responseDir;
// 컨테이너 이름 prefix
@Value("${train.docker.containerPrefix}")
private String containerPrefix;
// 공유메모리 사이즈 설정 (대용량 학습시 필요) // 공유메모리 사이즈 설정 (대용량 학습시 필요)
@Value("${train.docker.shmSize:16g}") @Value("${train.docker.shm_size:16g}")
private String shmSize; private String shmSize;
// data 경로 request,response 상위 폴더
@Value("${train.docker.base_path}")
private String basePath;
@Value("${train.docker.symbolic_link_dir}")
private String symbolicDir;
// IPC host 사용 여부 // IPC host 사용 여부
@Value("${train.docker.ipcHost:true}") @Value("${train.docker.ipc_host:true}")
private boolean ipcHost; private boolean ipcHost;
@Value("${spring.profiles.active}")
private String profile;
private final ModelTrainJobCoreService modelTrainJobCoreService; private final ModelTrainJobCoreService modelTrainJobCoreService;
/** /**
@@ -254,9 +260,11 @@ public class DockerTrainService {
// 요청/결과 디렉토리 볼륨 마운트 // 요청/결과 디렉토리 볼륨 마운트
c.add("-v"); c.add("-v");
c.add("/home/kcomu/data" + "/tmp:/data"); c.add(basePath + ":" + basePath); // 심볼릭 링크와 연결되는 실제 파일 경로도 마운트를 해줘야 함
c.add("-v"); c.add("-v");
c.add(responseDir + ":/checkpoints"); c.add(symbolicDir + ":/data"); // 요청할경로
c.add("-v");
c.add(responseDir + ":/checkpoints"); // 저장될경로
// 표준입력 유지 (-it 대신 -i만 사용) // 표준입력 유지 (-it 대신 -i만 사용)
c.add("-i"); c.add("-i");
@@ -273,8 +281,15 @@ public class DockerTrainService {
addArg(c, "--output-folder", req.getOutputFolder()); addArg(c, "--output-folder", req.getOutputFolder());
addArg(c, "--input-size", req.getInputSize()); addArg(c, "--input-size", req.getInputSize());
addArg(c, "--crop-size", req.getCropSize()); addArg(c, "--crop-size", req.getCropSize());
addArg(c, "--batch-size", req.getBatchSize()); // addArg(c, "--batch-size", req.getBatchSize());
addArg(c, "--gpu-ids", req.getGpuIds()); // null // addArg(c, "--gpu-ids", req.getGpuIds()); // null
if ("prod".equals(profile)) {
addArg(c, "--batch-size", 2); // 학습서버 GPU 1개인 곳은 batch-size:2 까지만 가능
addArg(c, "--gpus", "1"); // 학습서버 GPU 1개인 곳은 1이어야 함
addArg(c, "--gpu-ids", "0"); // 학습서버 GPU 1개인 곳은 0이어야 함
} else {
addArg(c, "--batch-size", req.getBatchSize()); // 학습서버 GPU 1개인 곳은 batch-size:2 까지만 가능
}
addArg(c, "--lr", req.getLearningRate()); addArg(c, "--lr", req.getLearningRate());
addArg(c, "--backbone", req.getBackbone()); addArg(c, "--backbone", req.getBackbone());
addArg(c, "--epochs", req.getEpochs()); addArg(c, "--epochs", req.getEpochs());
@@ -440,11 +455,14 @@ public class DockerTrainService {
c.add("--rm"); c.add("--rm");
c.add("--gpus"); c.add("--gpus");
c.add("all"); c.add("all");
c.add("--ipc=host"); c.add("--ipc=host");
c.add("--shm-size=" + shmSize); c.add("--shm-size=" + shmSize);
c.add("-v"); c.add("-v");
c.add("/home/kcomu/data" + "/tmp:/data"); c.add(basePath + ":" + basePath); // 심볼릭 링크와 연결되는 실제 파일 경로도 마운트를 해줘야 함
c.add("-v");
c.add(basePath + "/tmp:/data");
c.add("-v"); c.add("-v");
c.add(responseDir + ":/checkpoints"); c.add(responseDir + ":/checkpoints");

View File

@@ -20,7 +20,9 @@ import java.util.stream.Stream;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -41,13 +43,14 @@ public class JobRecoveryOnStartupService {
private final ModelTrainJobCoreService modelTrainJobCoreService; private final ModelTrainJobCoreService modelTrainJobCoreService;
private final ModelTrainMngCoreService modelTrainMngCoreService; private final ModelTrainMngCoreService modelTrainMngCoreService;
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
/** /**
* Docker 컨테이너가 쓰는 response(산출물) 디렉토리의 "호스트 측" 베이스 경로. 예) /data/train/response * Docker 컨테이너가 쓰는 response(산출물) 디렉토리의 "호스트 측" 베이스 경로. 예) /data/train/response
* *
* <p>컨테이너가 --rm 으로 삭제된 경우에도 이 경로에 val.csv / *.pth 등이 남아있으면 정상 종료 여부를 "파일 기반"으로 판정합니다. * <p>컨테이너가 --rm 으로 삭제된 경우에도 이 경로에 val.csv / *.pth 등이 남아있으면 정상 종료 여부를 "파일 기반"으로 판정합니다.
*/ */
@Value("${train.docker.responseDir}") @Value("${train.docker.response_dir}")
private String responseDir; private String responseDir;
/** /**
@@ -56,7 +59,7 @@ public class JobRecoveryOnStartupService {
* <p>@Transactional: - recover() 메서드 전체가 하나의 트랜잭션으로 감싸집니다. - Job 하나씩 처리하다가 예외가 발생하면 전체 롤백이 될 수 * <p>@Transactional: - recover() 메서드 전체가 하나의 트랜잭션으로 감싸집니다. - Job 하나씩 처리하다가 예외가 발생하면 전체 롤백이 될 수
* 있으므로 "잡 단위로 확실히 커밋"이 필요하면 (권장) 잡 단위로 분리 트랜잭션(REQUIRES_NEW) 고려하세요. * 있으므로 "잡 단위로 확실히 커밋"이 필요하면 (권장) 잡 단위로 분리 트랜잭션(REQUIRES_NEW) 고려하세요.
*/ */
// @EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
@Transactional @Transactional
public void recover() { public void recover() {
@@ -91,57 +94,92 @@ public class JobRecoveryOnStartupService {
if (out.completed()) { if (out.completed()) {
log.info("[RECOVERY] outputs look completed. mark SUCCESS. jobId={}", job.getId()); log.info("[RECOVERY] outputs look completed. mark SUCCESS. jobId={}", job.getId());
modelTrainJobCoreService.markSuccess(job.getId(), 0); modelTrainJobCoreService.markSuccess(job.getId(), 0);
// model 상태 변경
markStepSuccessByJobType(job); markStepSuccessByJobType(job);
// 결과 csv 파일 정보 등록
modelTrainMetricsJobService.findTrainValidMetricCsvFiles();
} else { } else {
// 3-3) 산출물이 부족하면 실패 처리(운영 정책에 따라 "유예"도 가능)
// 3-3) 산출물이 부족하면 중단처리
// 산출물이 부족하면 "중단/보류"로 처리
// 운영자가 재시작 할 수 있게 한다.
log.warn( log.warn(
"[RECOVERY] outputs incomplete. mark FAILED. jobId={} reason={}", "[RECOVERY] outputs incomplete. mark PAUSED/STOP for restart. jobId={} reason={}",
job.getId(), job.getId(),
out.reason()); out.reason());
modelTrainJobCoreService.markFailed( Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId());
job.getId(), -1, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");
markStepErrorByJobType(job, out.reason()); // PAUSED/STOP
modelTrainJobCoreService.markPaused(
job.getId(), modelId, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");
// 모델도 에러가 아니라 STOP으로
markStepStopByJobType(
job, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE: " + out.reason());
} }
continue; continue;
} }
// 4) 컨테이너는 존재하고, 아직 running=true // 4) 컨테이너는 존재하고, 아직 running=true
// - 서버만 재기동됐고 컨테이너는 그대로 살아있는 케이스 // - 서버만 재기동됐고 컨테이너는 그대로 살아있는 케이스
// - 이 경우 DB를 건드리면 오히려 꼬일 수 있으니 RUNNING 유지 // - 실행중 docker 를 kill 하고 이어하기를 한다,
if (state.running()) { if (state.running()) {
log.info("[RECOVERY] container still running. container={}", containerName); log.warn("[RECOVERY] container still running. force kill. container={}", containerName);
try { try {
ProcessBuilder pb = new ProcessBuilder("docker", "stop", "-t", "20", containerName); // ============================================================
// 1) docker kill (SIGKILL) 바로 전송
// - kill은 즉시 종료
// ============================================================
ProcessBuilder pb = new ProcessBuilder("docker", "kill", containerName);
pb.redirectErrorStream(true); pb.redirectErrorStream(true);
Process p = pb.start(); Process p = pb.start();
boolean finished = p.waitFor(30, TimeUnit.SECONDS); boolean finished = p.waitFor(20, TimeUnit.SECONDS);
if (!finished) { if (!finished) {
p.destroyForcibly(); p.destroyForcibly();
throw new IOException("docker stop timeout"); throw new IOException("docker kill timeout");
} }
int code = p.exitValue(); int code = p.exitValue();
if (code != 0) { if (code != 0) {
throw new IOException("docker stop failed. exit=" + code); throw new IOException("docker kill failed. exit=" + code);
} }
log.info( // ============================================================
"[RECOVERY] container stopped (will be auto removed by --rm). container={}", // 2) kill 후 실제로 죽었는지 확인
containerName); // ============================================================
DockerInspectState after = inspectContainer(containerName);
if (after.exists() && after.running()) {
throw new IOException("docker kill returned 0 but container still running");
}
// 여기서 상태를 PAUSED로 바꿔도 되고 log.info("[RECOVERY] container killed successfully. container={}", containerName);
modelTrainJobCoreService.markPaused(job.getId(), -1, "AUTO_STOP_FAILED_ON_RESTART");
// ============================================================
// 3) job 상태를 PAUSED로 변경 (서버 재기동으로 강제 중단)
// ============================================================
Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId());
modelTrainJobCoreService.markPaused(
job.getId(), modelId, "AUTO_KILLED_ON_SERVER_RESTART");
log.info("job = {}", job);
markStepStopByJobType(job, "AUTO_KILLED_ON_SERVER_RESTART");
} catch (Exception e) { } catch (Exception e) {
log.error("[RECOVERY] docker stop failed. container={}", containerName, e);
modelTrainJobCoreService.markFailed(job.getId(), -1, "AUTO_STOP_FAILED_ON_RESTART"); log.error("[RECOVERY] docker kill failed. container={}", containerName, e);
modelTrainJobCoreService.markFailed(
job.getId(), -1, "AUTO_KILL_FAILED_ON_SERVER_RESTART");
markStepErrorByJobType(job, "AUTO_KILL_FAILED_ON_SERVER_RESTART");
} }
continue; continue;
} }
@@ -154,7 +192,10 @@ public class JobRecoveryOnStartupService {
if (exitCode != null && exitCode == 0) { if (exitCode != null && exitCode == 0) {
log.info("[RECOVERY] container exited(0). mark SUCCESS. container={}", containerName); log.info("[RECOVERY] container exited(0). mark SUCCESS. container={}", containerName);
modelTrainJobCoreService.markSuccess(job.getId(), 0); modelTrainJobCoreService.markSuccess(job.getId(), 0);
// model 상태 변경
markStepSuccessByJobType(job); markStepSuccessByJobType(job);
// 결과 csv 파일 정보 등록
modelTrainMetricsJobService.findTrainValidMetricCsvFiles();
} else { } else {
// 5-2) exitCode != 0 이거나 null이면 실패로 간주 → FAILED 처리 // 5-2) exitCode != 0 이거나 null이면 실패로 간주 → FAILED 처리
@@ -166,7 +207,7 @@ public class JobRecoveryOnStartupService {
modelTrainJobCoreService.markFailed( modelTrainJobCoreService.markFailed(
job.getId(), exitCode, "SERVER_RESTART_CONTAINER_EXIT_NONZERO"); job.getId(), exitCode, "SERVER_RESTART_CONTAINER_EXIT_NONZERO");
// model 상태 변경
markStepErrorByJobType(job, "exit=" + exitCode + " status=" + status); markStepErrorByJobType(job, "exit=" + exitCode + " status=" + status);
} }
@@ -178,7 +219,7 @@ public class JobRecoveryOnStartupService {
modelTrainJobCoreService.markFailed( modelTrainJobCoreService.markFailed(
job.getId(), -1, "SERVER_RESTART_CONTAINER_INSPECT_ERROR"); job.getId(), -1, "SERVER_RESTART_CONTAINER_INSPECT_ERROR");
// model 상태 변경
markStepErrorByJobType(job, "inspect-error"); markStepErrorByJobType(job, "inspect-error");
} }
} }
@@ -204,6 +245,16 @@ public class JobRecoveryOnStartupService {
* *
* <p>예: - jobType == "EVAL" → step2(평가 단계) 에러 - 그 외 → step1 혹은 전체 에러 * <p>예: - jobType == "EVAL" → step2(평가 단계) 에러 - 그 외 → step1 혹은 전체 에러
*/ */
private void markStepStopByJobType(ModelTrainJobDto job, String msg) {
Map<String, Object> params = job.getParamsJson();
boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType")));
if (isEval) {
modelTrainMngCoreService.markStep2Stop(job.getModelId(), msg);
} else {
modelTrainMngCoreService.markStep1Stop(job.getModelId(), msg);
}
}
private void markStepErrorByJobType(ModelTrainJobDto job, String msg) { private void markStepErrorByJobType(ModelTrainJobDto job, String msg) {
Map<String, Object> params = job.getParamsJson(); Map<String, Object> params = job.getParamsJson();
boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType"))); boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType")));
@@ -310,33 +361,82 @@ public class JobRecoveryOnStartupService {
*/ */
private OutputResult probeOutputs(ModelTrainJobDto job) { private OutputResult probeOutputs(ModelTrainJobDto job) {
try { try {
log.info(
"[RECOVERY] probeOutputs start. jobId={}, modelId={}", job.getId(), job.getModelId());
// 1) 출력 디렉토리 확인
Path outDir = resolveOutputDir(job); Path outDir = resolveOutputDir(job);
if (outDir == null || !Files.isDirectory(outDir)) { if (outDir == null || !Files.isDirectory(outDir)) {
log.warn("[RECOVERY] output directory missing. jobId={}, path={}", job.getId(), outDir);
return new OutputResult(false, "output-dir-missing"); return new OutputResult(false, "output-dir-missing");
} }
log.info("[RECOVERY] output directory found. jobId={}, path={}", job.getId(), outDir);
// 2) totalEpoch 확인
Integer totalEpoch = extractTotalEpoch(job).orElse(null); Integer totalEpoch = extractTotalEpoch(job).orElse(null);
if (totalEpoch == null || totalEpoch <= 0) { if (totalEpoch == null || totalEpoch <= 0) {
log.warn(
"[RECOVERY] totalEpoch missing or invalid. jobId={}, totalEpoch={}",
job.getId(),
totalEpoch);
return new OutputResult(false, "total-epoch-missing"); return new OutputResult(false, "total-epoch-missing");
} }
Integer valInterval = extractValInterval(job).orElse(null);
if (valInterval == null || valInterval <= 0) {
log.warn(
"[RECOVERY] valInterval missing or invalid. jobId={}, valInterval={}",
job.getId(),
valInterval);
return new OutputResult(false, "val-interval-missing");
}
log.info(
"[RECOVERY] totalEpoch={}. valInterval={}. jobId={}",
totalEpoch,
valInterval,
job.getId());
// 3) val.csv 존재 확인
Path valCsv = outDir.resolve("val.csv"); Path valCsv = outDir.resolve("val.csv");
if (!Files.exists(valCsv)) { if (!Files.exists(valCsv)) {
log.warn("[RECOVERY] val.csv missing. jobId={}, path={}", job.getId(), valCsv);
return new OutputResult(false, "val.csv-missing"); return new OutputResult(false, "val.csv-missing");
} }
// 4) val.csv 라인 수 확인
long lines = countNonHeaderLines(valCsv); long lines = countNonHeaderLines(valCsv);
// “같아야 완료” 정책 // expected = 실제 val 실행 횟수
if (lines == totalEpoch) { int expectedLines = totalEpoch / valInterval;
log.info(
"[RECOVERY] val.csv lines counted. jobId={}, lines={}, expected={}",
job.getId(),
lines,
expectedLines);
// 5) 완료 판정
if (lines >= expectedLines) {
log.info("[RECOVERY] outputs look COMPLETE. jobId={}", job.getId());
return new OutputResult(true, "ok"); return new OutputResult(true, "ok");
} }
log.warn(
"[RECOVERY] val.csv line mismatch. jobId={}, lines={}, expected={}",
job.getId(),
lines,
expectedLines);
return new OutputResult( return new OutputResult(
false, "val.csv-lines-mismatch lines=" + lines + " expected=" + totalEpoch); false, "val.csv-lines-mismatch lines=" + lines + " expected=" + totalEpoch);
} catch (Exception e) { } catch (Exception e) {
log.error("[RECOVERY] probeOutputs error. jobId={}", job.getId(), e); log.error("[RECOVERY] probeOutputs error. jobId={}", job.getId(), e);
return new OutputResult(false, "probe-error"); return new OutputResult(false, "probe-error");
} }
} }
@@ -446,4 +546,19 @@ public class JobRecoveryOnStartupService {
return reason; return reason;
} }
} }
/** paramsJson에서 valInterval 추출 */
private Optional<Integer> extractValInterval(ModelTrainJobDto job) {
Map<String, Object> params = job.getParamsJson();
if (params == null) return Optional.empty();
Object v = params.get("valInterval");
if (v == null) return Optional.empty();
try {
return Optional.of(Integer.parseInt(String.valueOf(v)));
} catch (Exception ignore) {
return Optional.empty();
}
}
} }

View File

@@ -42,7 +42,7 @@ public class ModelTestMetricsJobService {
private String profile; private String profile;
// 학습 결과가 저장될 호스트 디렉토리 // 학습 결과가 저장될 호스트 디렉토리
@Value("${train.docker.responseDir}") @Value("${train.docker.response_dir}")
private String responseDir; private String responseDir;
@Value("${file.pt-path}") @Value("${file.pt-path}")

View File

@@ -33,7 +33,7 @@ public class ModelTrainMetricsJobService {
private String profile; private String profile;
// 학습 결과가 저장될 호스트 디렉토리 // 학습 결과가 저장될 호스트 디렉토리
@Value("${train.docker.responseDir}") @Value("${train.docker.response_dir}")
private String responseDir; private String responseDir;
/** 결과 csv 파일 정보 등록 */ /** 결과 csv 파일 정보 등록 */
@@ -84,18 +84,21 @@ public class ModelTrainMetricsJobService {
for (CSVRecord record : parser) { for (CSVRecord record : parser) {
int epoch = Integer.parseInt(record.get("Epoch")); int epoch = Integer.parseInt(record.get("Epoch"));
float aAcc = Float.parseFloat(record.get("aAcc"));
float mFscore = Float.parseFloat(record.get("mFscore")); Float aAcc = parseFloatSafe(record.get("aAcc"));
float mPrecision = Float.parseFloat(record.get("mPrecision")); Float mFscore = parseFloatSafe(record.get("mFscore"));
float mRecall = Float.parseFloat(record.get("mRecall")); Float mPrecision = parseFloatSafe(record.get("mPrecision"));
float mIoU = Float.parseFloat(record.get("mIoU")); Float mRecall = parseFloatSafe(record.get("mRecall"));
float mAcc = Float.parseFloat(record.get("mAcc")); Float mIoU = parseFloatSafe(record.get("mIoU"));
float changed_fscore = Float.parseFloat(record.get("changed_fscore")); Float mAcc = parseFloatSafe(record.get("mAcc"));
float changed_precision = Float.parseFloat(record.get("changed_precision"));
float changed_recall = Float.parseFloat(record.get("changed_recall")); Float changed_fscore = parseFloatSafe(record.get("changed_fscore"));
float unchanged_fscore = Float.parseFloat(record.get("unchanged_fscore")); Float changed_precision = parseFloatSafe(record.get("changed_precision"));
float unchanged_precision = Float.parseFloat(record.get("unchanged_precision")); Float changed_recall = parseFloatSafe(record.get("changed_recall"));
float unchanged_recall = Float.parseFloat(record.get("unchanged_recall"));
Float unchanged_fscore = parseFloatSafe(record.get("unchanged_fscore"));
Float unchanged_precision = parseFloatSafe(record.get("unchanged_precision"));
Float unchanged_recall = parseFloatSafe(record.get("unchanged_recall"));
batchArgs.add( batchArgs.add(
new Object[] { new Object[] {
@@ -153,4 +156,23 @@ public class ModelTrainMetricsJobService {
modelInfo.getModelId(), "step1"); modelInfo.getModelId(), "step1");
} }
} }
private Float parseFloatSafe(String value) {
try {
if (value == null) return null;
value = value.trim();
if (value.isEmpty()) return null;
if (value.equalsIgnoreCase("nan")) return null;
float f = Float.parseFloat(value);
return Float.isNaN(f) ? null : f;
} catch (Exception e) {
return null;
}
}
} }

View File

@@ -14,11 +14,11 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor @RequiredArgsConstructor
public class TmpDatasetService { public class TmpDatasetService {
@Value("${train.docker.requestDir}") @Value("${train.docker.request_dir}")
private String requestDir; private String requestDir;
@Value("${train.docker.basePath}") @Value("${train.docker.symbolic_link_dir}")
private String trainBaseDir; private String symbolicDir;
/** /**
* train, val, test 폴더별로 link * train, val, test 폴더별로 link
@@ -36,9 +36,9 @@ public class TmpDatasetService {
throw new IOException("links is empty"); throw new IOException("links is empty");
} }
Path tmp = Path.of(trainBaseDir, "tmp", uid); Path tmp = Path.of(symbolicDir, uid);
long hardlinksMade = 0; long linksMade = 0;
for (ModelTrainLinkDto dto : links) { for (ModelTrainLinkDto dto : links) {
@@ -54,27 +54,26 @@ public class TmpDatasetService {
Files.createDirectories(tmp.resolve(type).resolve("label-json")); Files.createDirectories(tmp.resolve(type).resolve("label-json"));
// comparePath → input1 // comparePath → input1
hardlinksMade += link(tmp, type, "input1", dto.getComparePath()); linksMade += link(tmp, type, "input1", dto.getComparePath());
// targetPath → input2 // targetPath → input2
hardlinksMade += link(tmp, type, "input2", dto.getTargetPath()); linksMade += link(tmp, type, "input2", dto.getTargetPath());
// labelPath → label // labelPath → label
hardlinksMade += link(tmp, type, "label", dto.getLabelPath()); linksMade += link(tmp, type, "label", dto.getLabelPath());
// geoJsonPath -> label-json // geoJsonPath -> label-json
hardlinksMade += link(tmp, type, "label-json", dto.getGeoJsonPath()); linksMade += link(tmp, type, "label-json", dto.getGeoJsonPath());
} }
if (hardlinksMade == 0) { if (linksMade == 0) {
throw new IOException("No hardlinks created."); throw new IOException("No symlinks created.");
} }
log.info("tmp dataset created: {}, hardlinksMade={}", tmp, hardlinksMade); log.info("tmp dataset created: {}, linksMade={}", tmp, linksMade);
} }
private long link(Path tmp, String type, String part, String fullPath) throws IOException { private long link(Path tmp, String type, String part, String fullPath) throws IOException {
if (fullPath == null || fullPath.isBlank()) return 0; if (fullPath == null || fullPath.isBlank()) return 0;
Path src = Path.of(fullPath); Path src = Path.of(fullPath);
@@ -87,20 +86,19 @@ public class TmpDatasetService {
String fileName = src.getFileName().toString(); String fileName = src.getFileName().toString();
Path dst = tmp.resolve(type).resolve(part).resolve(fileName); Path dst = tmp.resolve(type).resolve(part).resolve(fileName);
// 충돌 시 덮어쓰기 Files.createDirectories(dst.getParent());
if (Files.exists(dst)) {
if (Files.exists(dst) || Files.isSymbolicLink(dst)) {
Files.delete(dst); Files.delete(dst);
} }
Files.createLink(dst, src); Files.createSymbolicLink(dst, src);
log.info("symlink created: {} -> {}", dst, src);
return 1; return 1;
} }
private String safe(String s) { // TODO 미사용시작
return (s == null || s.isBlank()) ? null : s.trim();
}
/** /**
* request 전체 폴더 link * request 전체 폴더 link
* *
@@ -111,19 +109,19 @@ public class TmpDatasetService {
*/ */
public String buildTmpDatasetSymlink(String uid, List<String> datasetUids) throws IOException { public String buildTmpDatasetSymlink(String uid, List<String> datasetUids) throws IOException {
log.info("========== buildTmpDatasetHardlink START =========="); log.info("========== buildTmpDatasetSymlink START ==========");
log.info("uid={}", uid); log.info("uid={}", uid);
log.info("datasetUids={}", datasetUids); log.info("datasetUids={}", datasetUids);
log.info("requestDir(raw)={}", requestDir); log.info("requestDir(raw)={}", requestDir);
Path BASE = toPath(requestDir); Path BASE = toPath(requestDir);
Path tmp = Path.of(trainBaseDir, "tmp", uid); Path tmp = Path.of(symbolicDir, uid);
log.info("BASE={}", BASE); log.info("BASE={}", BASE);
log.info("BASE exists? {}", Files.isDirectory(BASE)); log.info("BASE exists? {}", Files.isDirectory(BASE));
log.info("tmp={}", tmp); log.info("tmp={}", tmp);
long noDir = 0, scannedDirs = 0, regularFiles = 0, hardlinksMade = 0; long noDir = 0, scannedDirs = 0, regularFiles = 0, symlinksMade = 0;
// tmp 디렉토리 준비 // tmp 디렉토리 준비
for (String type : List.of("train", "val", "test")) { for (String type : List.of("train", "val", "test")) {
@@ -134,26 +132,7 @@ public class TmpDatasetService {
} }
} }
// 하드링크는 "같은 파일시스템"에서만 가능하므로 BASE/tmp가 같은 FS인지 미리 확인(권장) // 심볼릭 링크는 파일시스템이 달라도 작동하므로 FileStore 체크 불필요
try {
var baseStore = Files.getFileStore(BASE);
var tmpStore = Files.getFileStore(tmp.getParent()); // BASE/tmp
if (!baseStore.name().equals(tmpStore.name()) || !baseStore.type().equals(tmpStore.type())) {
throw new IOException(
"Hardlink requires same filesystem. baseStore="
+ baseStore.name()
+ "("
+ baseStore.type()
+ "), tmpStore="
+ tmpStore.name()
+ "("
+ tmpStore.type()
+ ")");
}
} catch (Exception e) {
// FileStore 비교가 환경마다 애매할 수 있어서, 여기서는 경고만 주고 실제 createLink에서 최종 판단하게 둘 수도 있음.
log.warn("FileStore check skipped/failed (will rely on createLink): {}", e.toString());
}
for (String id : datasetUids) { for (String id : datasetUids) {
Path srcRoot = BASE.resolve(id); Path srcRoot = BASE.resolve(id);
@@ -191,13 +170,12 @@ public class TmpDatasetService {
} }
try { try {
// 하드링크 생성 (dst가 새 파일로 생기지만 inode는 f와 동일) // 심볼릭 링크 생성 (파일시스템이 달라도 작동)
Files.createLink(dst, f); Files.createSymbolicLink(dst, f);
hardlinksMade++; symlinksMade++;
log.debug("created hardlink: {} => {}", dst, f); log.debug("created symlink: {} => {}", dst, f);
} catch (IOException e) { } catch (IOException e) {
// 여기서 바로 실패시키면 “tmp는 만들었는데 내용은 0개” 같은 상태를 방지할 수 있음 log.error("FAILED create symlink: {} => {}", dst, f, e);
log.error("FAILED create hardlink: {} => {}", dst, f, e);
throw e; throw e;
} }
} }
@@ -206,9 +184,9 @@ public class TmpDatasetService {
} }
} }
if (hardlinksMade == 0) { if (symlinksMade == 0) {
throw new IOException( throw new IOException(
"No hardlinks created. regularFiles=" "No symlinks created. regularFiles="
+ regularFiles + regularFiles
+ ", scannedDirs=" + ", scannedDirs="
+ scannedDirs + scannedDirs
@@ -218,11 +196,11 @@ public class TmpDatasetService {
log.info("tmp dataset created: {}", tmp); log.info("tmp dataset created: {}", tmp);
log.info( log.info(
"summary: scannedDirs={}, noDir={}, regularFiles={}, hardlinksMade={}", "summary: scannedDirs={}, noDir={}, regularFiles={}, symlinksMade={}",
scannedDirs, scannedDirs,
noDir, noDir,
regularFiles, regularFiles,
hardlinksMade); symlinksMade);
return uid; return uid;
} }
@@ -233,4 +211,5 @@ public class TmpDatasetService {
} }
return Paths.get(p).toAbsolutePath().normalize(); return Paths.get(p).toAbsolutePath().normalize();
} }
// TODO 미사용 끝
} }

View File

@@ -40,7 +40,7 @@ public class TrainJobService {
private final DataSetCountersService dataSetCounters; private final DataSetCountersService dataSetCounters;
// 학습 결과가 저장될 호스트 디렉토리 // 학습 결과가 저장될 호스트 디렉토리
@Value("${train.docker.responseDir}") @Value("${train.docker.response_dir}")
private String responseDir; private String responseDir;
public Long getModelIdByUuid(UUID uuid) { public Long getModelIdByUuid(UUID uuid) {
@@ -268,13 +268,19 @@ public class TrainJobService {
try { try {
// 데이터셋 심볼링크 생성 // 데이터셋 심볼링크 생성
// String pathUid = tmpDatasetService.buildTmpDatasetSymlink(raw, uids); // String pathUid = tmpDatasetService.buildTmpDatasetSymlink(raw, uids);
// train path // train path 모델 클래스별 조회
List<ModelTrainLinkDto> trainList = modelTrainMngCoreService.findDatasetTrainPath(modelId); List<ModelTrainLinkDto> trainList = modelTrainMngCoreService.findDatasetTrainPath(modelId);
// validation path // validation path 모델 클래스별 조회
List<ModelTrainLinkDto> valList = modelTrainMngCoreService.findDatasetValPath(modelId); List<ModelTrainLinkDto> valList = modelTrainMngCoreService.findDatasetValPath(modelId);
// test path // test path 모델 클래스별 조회
List<ModelTrainLinkDto> testList = modelTrainMngCoreService.findDatasetTestPath(modelId); List<ModelTrainLinkDto> testList = modelTrainMngCoreService.findDatasetTestPath(modelId);
log.info(
"createTmpFile class list trainList = {} valList = {} testList = {}",
trainList.size(),
valList.size(),
testList.size());
// train 데이터셋 심볼링크 생성 // train 데이터셋 심볼링크 생성
tmpDatasetService.buildTmpDatasetHardlink(raw, "train", trainList); tmpDatasetService.buildTmpDatasetHardlink(raw, "train", trainList);
// val 데이터셋 심볼링크 생성 // val 데이터셋 심볼링크 생성

View File

@@ -108,6 +108,10 @@ public class TrainJobWorker {
return; return;
} }
/**
* 0 정상 종료 SUCCESS 1~125 학습 코드 에러 FAILED 137 OOMKill FAILED 143 SIGTERM (stop) STOP -1 우리 내부
* 강제 중단 STOP
*/
if (result.getExitCode() == 0) { if (result.getExitCode() == 0) {
// 성공 처리 // 성공 처리
modelTrainJobCoreService.markSuccess(jobId, result.getExitCode()); modelTrainJobCoreService.markSuccess(jobId, result.getExitCode());
@@ -124,7 +128,25 @@ public class TrainJobWorker {
} }
} else { } else {
String failMsg = result.getStatus() + "\n" + result.getLogs(); String failMsg = result.getStatus() + "\n" + result.getLogs();
log.info("training fail exitCode={} Msg ={}", result.getExitCode(), failMsg);
if (result.getExitCode() == -1
|| result.getExitCode() == 143
|| result.getExitCode() == 137) {
// 실패 처리
modelTrainJobCoreService.markPaused(
jobId, result.getExitCode(), result.getStatus() + "\n" + result.getLogs());
if (isEval) {
// 오류 정보 등록
modelTrainMngCoreService.markStep2Stop(modelId, "exit=" + result.getExitCode());
} else {
// 오류 정보 등록
modelTrainMngCoreService.markStep1Stop(modelId, "exit=" + result.getExitCode());
}
} else {
// 실패 처리 // 실패 처리
modelTrainJobCoreService.markFailed( modelTrainJobCoreService.markFailed(
jobId, result.getExitCode(), result.getStatus() + "\n" + result.getLogs()); jobId, result.getExitCode(), result.getStatus() + "\n" + result.getLogs());
@@ -137,6 +159,7 @@ public class TrainJobWorker {
modelTrainMngCoreService.markError(modelId, "exit=" + result.getExitCode()); modelTrainMngCoreService.markError(modelId, "exit=" + result.getExitCode());
} }
} }
}
} catch (Exception e) { } catch (Exception e) {
modelTrainJobCoreService.markFailed(jobId, null, e.getMessage()); modelTrainJobCoreService.markFailed(jobId, null, e.getMessage());

View File

@@ -83,6 +83,7 @@ public class UploadDto {
private UUID uuid; private UUID uuid;
} }
// TODO 미사용시작
@Schema(name = "UploadCompleteReq", description = "업로드 완료 요청") @Schema(name = "UploadCompleteReq", description = "업로드 완료 요청")
@Getter @Getter
@Setter @Setter
@@ -126,12 +127,15 @@ public class UploadDto {
@Schema(description = "상태", example = "UPLOADING") @Schema(description = "상태", example = "UPLOADING")
private String status; private String status;
// TODO 미사용시작
@Schema(description = "총 청크 수", example = "100") @Schema(description = "총 청크 수", example = "100")
private Integer totalChunks; private Integer totalChunks;
@Schema(description = "업로드된 청크 수", example = "50") @Schema(description = "업로드된 청크 수", example = "50")
private Integer uploadedChunks; private Integer uploadedChunks;
// TODO 미사용 끝
@Schema(description = "진행률 (%)", example = "50.0") @Schema(description = "진행률 (%)", example = "50.0")
private Double progress; private Double progress;
@@ -139,6 +143,8 @@ public class UploadDto {
private String errorMessage; private String errorMessage;
} }
// TODO 미사용 끝
@Schema(name = "UploadAddReq", description = "업로드 요청") @Schema(name = "UploadAddReq", description = "업로드 요청")
@Getter @Getter
@Setter @Setter

View File

@@ -26,27 +26,21 @@ public class UploadService {
private final UploadSessionCoreService uploadSessionCoreService; private final UploadSessionCoreService uploadSessionCoreService;
@Value("${file.sync-root-dir}")
private String syncRootDir;
@Value("${file.sync-tmp-dir}")
private String syncTmpDir;
@Value("${file.sync-file-extention}")
private String syncFileExtention;
@Value("${file.dataset-dir}") @Value("${file.dataset-dir}")
private String datasetDir; private String datasetDir;
@Value("${file.dataset-tmp-dir}") @Value("${file.dataset-tmp-dir}")
private String datasetTmpDir; private String datasetTmpDir;
// TODO 미사용시작
@Transactional @Transactional
public DmlReturn initUpload(UploadDto.InitReq initReq) { public DmlReturn initUpload(UploadDto.InitReq initReq) {
return new DmlReturn("success", "UPLOAD CHUNK INIT"); return new DmlReturn("success", "UPLOAD CHUNK INIT");
} }
// TODO 미사용 끝
@Transactional @Transactional
public UploadDto.UploadRes uploadChunk(UploadDto.UploadAddReq upAddReqDto, MultipartFile file) { public UploadDto.UploadRes uploadChunk(UploadDto.UploadAddReq upAddReqDto, MultipartFile file) {

View File

@@ -5,31 +5,18 @@ spring:
jpa: jpa:
show-sql: true show-sql: true
hibernate:
ddl-auto: validate
properties: properties:
hibernate: hibernate:
default_batch_fetch_size: 100 # ✅ 성능 - N+1 쿼리 방지
order_updates: true # ✅ 성능 - 업데이트 순서 정렬로 데드락 방지
use_sql_comments: true # ⚠️ 선택 - SQL에 주석 추가 (디버깅용) use_sql_comments: true # ⚠️ 선택 - SQL에 주석 추가 (디버깅용)
format_sql: true # ⚠️ 선택 - SQL 포맷팅 (가독성) format_sql: true # ⚠️ 선택 - SQL 포맷팅 (가독성)
datasource: datasource:
url: jdbc:postgresql://192.168.2.127:15432/kamco_training_db url: jdbc:postgresql://192.168.2.127:15432/kamco_training_db
# url: jdbc:postgresql://localhost:15432/kamco_training_db
username: kamco_training_user username: kamco_training_user
password: kamco_training_user_2025_!@# password: kamco_training_user_2025_!@#
hikari: hikari:
minimum-idle: 10 minimum-idle: 10
maximum-pool-size: 20 maximum-pool-size: 20
connection-timeout: 60000 # 60초 연결 타임아웃
idle-timeout: 300000 # 5분 유휴 타임아웃
max-lifetime: 1800000 # 30분 최대 수명
leak-detection-threshold: 60000 # 연결 누수 감지
transaction:
default-timeout: 300 # 5분 트랜잭션 타임아웃
jwt: jwt:
secret: "kamco_token_dev_dfc6446d-68fc-4eba-a2ff-c80a14a0bf3a" secret: "kamco_token_dev_dfc6446d-68fc-4eba-a2ff-c80a14a0bf3a"
@@ -40,21 +27,10 @@ token:
refresh-cookie-name: kamco-dev # 개발용 쿠키 이름 refresh-cookie-name: kamco-dev # 개발용 쿠키 이름
refresh-cookie-secure: false # 로컬 http 테스트면 false refresh-cookie-secure: false # 로컬 http 테스트면 false
springdoc:
swagger-ui:
persist-authorization: true # 스웨거 새로고침해도 토큰 유지, 로컬스토리지에 저장
member:
init_password: kamco1234!
swagger: swagger:
local-port: 9080 local-port: 8080
file: file:
sync-root-dir: /app/original-images/
sync-tmp-dir: ${file.sync-root-dir}tmp/
sync-file-extention: tfw,tif
dataset-dir: /home/kcomu/data/request/ dataset-dir: /home/kcomu/data/request/
dataset-tmp-dir: ${file.dataset-dir}tmp/ dataset-tmp-dir: ${file.dataset-dir}tmp/
@@ -64,9 +40,10 @@ file:
train: train:
docker: docker:
image: kamco-cd-train:latest image: kamco-cd-train:latest
requestDir: /home/kcomu/data/request base_path: /home/kcomu/data
responseDir: /home/kcomu/data/response request_dir: ${train.docker.base_path}/request
basePath: /home/kcomu/data response_dir: ${train.docker.base_path}/response
containerPrefix: kamco-cd-train symbolic_link_dir: ${train.docker.base_path}/tmp
shmSize: 16g container_prefix: kamco-cd-train
ipcHost: true shm_size: 16g
ipc_host: true

View File

@@ -3,70 +3,41 @@ spring:
activate: activate:
on-profile: prod on-profile: prod
jpa:
show-sql: true
hibernate:
ddl-auto: validate
properties:
hibernate:
default_batch_fetch_size: 100 # ✅ 성능 - N+1 쿼리 방지
order_updates: true # ✅ 성능 - 업데이트 순서 정렬로 데드락 방지
use_sql_comments: true # ⚠️ 선택 - SQL에 주석 추가 (디버깅용)
format_sql: true # ⚠️ 선택 - SQL 포맷팅 (가독성)
datasource: datasource:
url: jdbc:postgresql://127.0.01:15432/kamco_training_db url: jdbc:postgresql://kamco-cd-train-db:5432/kamco_training_db
# url: jdbc:postgresql://localhost:15432/kamco_training_db
username: kamco_training_user username: kamco_training_user
password: kamco_training_user_2025_!@# password: kamco_training_user_2025_!@#
hikari: hikari:
minimum-idle: 10 minimum-idle: 10
maximum-pool-size: 20 maximum-pool-size: 20
connection-timeout: 60000 # 60초 연결 타임아웃
idle-timeout: 300000 # 5분 유휴 타임아웃
max-lifetime: 1800000 # 30분 최대 수명
leak-detection-threshold: 60000 # 연결 누수 감지
transaction:
default-timeout: 300 # 5분 트랜잭션 타임아웃
jwt: jwt:
secret: "kamco_token_dev_dfc6446d-68fc-4eba-a2ff-c80a14a0bf3a" # ⚠️ 운영 환경에서는 반드시 별도의 강력한 시크릿 키를 사용하세요
secret: "kamco_token_prod_CHANGE_THIS_TO_SECURE_SECRET_KEY"
access-token-validity-in-ms: 86400000 # 1일 access-token-validity-in-ms: 86400000 # 1일
refresh-token-validity-in-ms: 604800000 # 7일 refresh-token-validity-in-ms: 604800000 # 7일
token: token:
refresh-cookie-name: kamco # 개발용 쿠키 이름 refresh-cookie-name: kamco
refresh-cookie-secure: false # 로컬 http 테스트면 false refresh-cookie-secure: true # HTTPS 환경에서 필수
springdoc:
swagger-ui:
persist-authorization: true # 스웨거 새로고침해도 토큰 유지, 로컬스토리지에 저장
member:
init_password: kamco1234!
swagger: swagger:
local-port: 9080 local-port: 9080
file: file:
sync-root-dir: /app/original-images/ dataset-dir: /data/training/request/
sync-tmp-dir: ${file.sync-root-dir}tmp/
sync-file-extention: tfw,tif
dataset-dir: /home/kcomu/data/request/
dataset-tmp-dir: ${file.dataset-dir}tmp/ dataset-tmp-dir: ${file.dataset-dir}tmp/
pt-path: /home/kcomu/data/response/v6-cls-checkpoints/ pt-path: /data/training/response/v6-cls-checkpoints/
pt-FileName: yolov8_6th-6m.pt pt-FileName: yolov8_6th-6m.pt
train: train:
docker: docker:
image: kamco-cd-train:latest image: kamco-cd-train:latest
requestDir: /home/kcomu/data/request base_path: /data/training
responseDir: /home/kcomu/data/response request_dir: ${train.docker.base_path}/request
basePath: /home/kcomu/data response_dir: ${train.docker.base_path}/response
containerPrefix: kamco-cd-train symbolic_link_dir: ${train.docker.base_path}/tmp
shmSize: 16g container_prefix: kamco-cd-train
ipcHost: true shm_size: 16g
ipc_host: true

View File

@@ -10,36 +10,25 @@ spring:
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
hikari: hikari:
jdbc: connection-timeout: 60000 # 60초 연결 타임아웃
time_zone: UTC idle-timeout: 300000 # 5분 유휴 타임아웃
batch_size: 50 max-lifetime: 1800000 # 30분 최대 수명
# 권장 설정 leak-detection-threshold: 60000 # 연결 누수 감지
minimum-idle: 2 # minimum-idle, maximum-pool-size 는 프로파일별 설정
maximum-pool-size: 2
connection-timeout: 20000
idle-timeout: 300000
max-lifetime: 1800000
leak-detection-threshold: 60000
data:
redis:
host: localhost
port: 6379
password:
jpa: jpa:
hibernate: hibernate:
ddl-auto: update # 스키마 자동 관리 활성화 ddl-auto: validate
properties: properties:
hibernate: hibernate:
hbm2ddl: jakarta:
auto: update
javax:
persistence: persistence:
validation: validation:
mode: none mode: none
jdbc: jdbc:
batch_size: 50 batch_size: 50
default_batch_fetch_size: 100 default_batch_fetch_size: 100
order_updates: true
show-sql: false show-sql: false
servlet: servlet:
@@ -47,6 +36,10 @@ spring:
enabled: true enabled: true
max-file-size: 10GB max-file-size: 10GB
max-request-size: 10GB max-request-size: 10GB
transaction:
default-timeout: 300 # 5분 트랜잭션 타임아웃
logging: logging:
level: level:
org: org:
@@ -54,6 +47,14 @@ logging:
web: INFO web: INFO
security: INFO security: INFO
root: INFO root: INFO
springdoc:
swagger-ui:
persist-authorization: true # 스웨거 새로고침해도 토큰 유지
member:
init_password: kamco1234!
# actuator # actuator
management: management:
health: health:
@@ -77,20 +78,3 @@ management:
exposure: exposure:
include: include:
- "health" - "health"
# GeoJSON 파일 모니터링 설정
geojson:
monitor:
watch-directory: ~/geojson/upload
processed-directory: ~/geojson/processed
error-directory: ~/geojson/error
temp-directory: /tmp/geojson_extract
cron-expression: "0/30 * * * * *" # 매 30초마다 실행
supported-extensions:
- zip
- tar
- tar.gz
- tgz
max-file-size: 104857600 # 100MB

View File

@@ -18,20 +18,12 @@ spring:
max-lifetime: 1800000 max-lifetime: 1800000
leak-detection-threshold: 60000 leak-detection-threshold: 60000
data:
redis:
host: localhost
port: 6379
password:
jpa: jpa:
hibernate: hibernate:
ddl-auto: none # 테스트 환경에서는 DDL 자동 생성/수정 비활성화 ddl-auto: none # 테스트 환경에서는 DDL 자동 생성/수정 비활성화
properties: properties:
hibernate: hibernate:
hbm2ddl: jakarta:
auto: none
javax:
persistence: persistence:
validation: validation:
mode: none mode: none
@@ -69,20 +61,6 @@ management:
include: include:
- "health" - "health"
geojson:
monitor:
watch-directory: ~/geojson/upload
processed-directory: ~/geojson/processed
error-directory: ~/geojson/error
temp-directory: /tmp/geojson_extract
cron-expression: "0/30 * * * * *"
supported-extensions:
- zip
- tar
- tar.gz
- tgz
max-file-size: 104857600
jwt: jwt:
secret: "test_secret_key_for_testing_purposes_only" secret: "test_secret_key_for_testing_purposes_only"
access-token-validity-in-ms: 86400000 access-token-validity-in-ms: 86400000
@@ -94,4 +72,3 @@ token:
member: member:
init_password: test1234! init_password: test1234!