Repository: BootTalk-Service/boot-talk-back
Pull request #24: feat : 부트캠프 중복 등록 방지 및 존재하지 않는 카테고리 출력 로그 작성
Author: login: shCHO9801
Labels: name: enhancement
description: New feature or request
Created: 2025-04-01T06:57:49Z | Merged: 2025-04-01T15:46:19Z

### 변경사항

**AS-IS**
- Employ24ApiService에서 모든 로직이 하나의 메서드(saveAllFromEmploy24)에 집중되어 있어 가독성과 유지보수성이 낮았음
- 수집 대상 중 ncsName이 잘못된 경우 저장 실패 원인을 추적할 수 없었음
- 실패한 카테고리명을 사용자 또는 운영자 입장에서 확인할 방법이 없었음
- BootcampCategoryType이 일부 카테고리만 포함되어 있어 실제 수집 대상과 불일치하는 경우가 자주 발생했음

**TO-BE**
- 수집 및 저장 로직을 processCategoryAndNcs, processBootcampList, saveBootcamp 등으로 분리하여 단일 책임 원칙을 반영
- 정의되지 않은 ncsName을 저장하지 않고, failedCategoryNames Set에 누적하여 추후 로그로 확인 가능
- 배치 완료 후 BootcampFetchScheduler에서 실패한 카테고리명을 로그로 출력하여 문제 파악 가능
- BootcampCategoryType에 실제 수집 대상 카테고리 10여 개를 추가하여 수집 실패를 최소화하고 유효성 검증을 강화




Diff:
diff --git a/boottalk/src/main/java/com/icandoit/boottalk/batch/scheduler/BootcampFetchScheduler.java b/boottalk/src/main/java/com/icandoit/boottalk/batch/scheduler/BootcampFetchScheduler.java
index 7d18105f..ed412a17 100644
--- a/boottalk/src/main/java/com/icandoit/boottalk/batch/scheduler/BootcampFetchScheduler.java
+++ b/boottalk/src/main/java/com/icandoit/boottalk/batch/scheduler/BootcampFetchScheduler.java
@@ -1,6 +1,7 @@
 package com.icandoit.boottalk.batch.scheduler;
 
 import java.time.LocalDateTime;
+import java.util.Set;
 import java.util.UUID;
 
 import org.springframework.batch.core.Job;
@@ -10,6 +11,8 @@
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
+import com.icandoit.boottalk.bootcamp.service.Employ24ApiService;
+
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 
@@ -20,6 +23,7 @@ public class BootcampFetchScheduler {
 
 	private final JobLauncher jobLauncher;
 	private final Job bootcampFetchJob;
+	private final Employ24ApiService employ24ApiService;
 
 	@Scheduled(cron = "0 0 0 * * *") // 매일 자정
 	public void runBootcampFetchJob() {
@@ -32,6 +36,10 @@ public void runBootcampFetchJob() {
 
 			jobLauncher.run(bootcampFetchJob, parameters);
 
+			Set<String> failedCategories = employ24ApiService.getFailedCategoryNames();
+			if (!failedCategories.isEmpty()) {
+				log.warn("배치 완료 후 저장 실패한 카테고리 목록: {}", failedCategories);
+			}
 			log.info("Bootcamp Fetch Batch 실행 완료");
 		} catch (Exception e) {
 			log.error("Bootcamp Fetch Batch 실행 실패", e);
diff --git a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/dto/BootcampEmploy24Response.java b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/dto/BootcampEmploy24Response.java
index 18896fa2..238c8f51 100644
--- a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/dto/BootcampEmploy24Response.java
+++ b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/dto/BootcampEmploy24Response.java
@@ -16,7 +16,8 @@ public record BootcampListResponseDto(
 		@JsonProperty("trainstCstId") String trainingCenterId,
 		@JsonProperty("trprDegr") String bootcampDegree,
 		@JsonProperty("trprId") String bootcampId,
-		@JsonProperty("yardMan") String maxCapacity
+		@JsonProperty("yardMan") String maxCapacity,
+		@JsonProperty("courseMan") String cost
 	) {
 	}
 
diff --git a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/entity/BootcampCategoryType.java b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/entity/BootcampCategoryType.java
index 05a404f3..7d8a107d 100644
--- a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/entity/BootcampCategoryType.java
+++ b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/entity/BootcampCategoryType.java
@@ -16,7 +16,17 @@ public enum BootcampCategoryType {
 	INFORMATION_SECURITY_MANAGEMENT("정보보호관리·운영"),
 	SW_PRODUCT_PLANNING("SW제품기획"),
 	BIGDATA_ANALYSIS("빅데이터분석"),
-	AI_SERVICE_IMPLEMENTATION("인공지능서비스구현");
+	AI_SERVICE_IMPLEMENTATION("인공지능서비스구현"),
+	UI_UX_ENGINEERING("UI/UX엔지니어링"),
+	SECURITY_ENGINEERING("보안엔지니어링"),
+	ELECTRONIC_HARDWARE_DEV("전자응용기기하드웨어개발"),
+	DIGITAL_BIZ_SUPPORT_SERVICE("디지털비즈니스지원서비스"),
+	SEMICONDUCTOR_DEV("반도체개발"),
+	CLOUD_INFRA_ENGINEERING("클라우드인프라스트럭쳐엔지니어링"),
+	BIGDATA_PLATFORM_DEV("빅데이터플랫폼구축"),
+	DB_ENGINEERING("DB엔지니어링"),
+	CLOUD_SOLUTION_ARCH("클라우드솔루션아키텍처"),
+	IOT_SYSTEM_INTEGRATION("IoT시스템연동");
 
 	private final String koreanName;
 	private static final Map<String, String> englishToKoreanMap = new HashMap<>();
@@ -34,11 +44,15 @@ public enum BootcampCategoryType {
 
 	}
 
+	// TODO : 추후에 리스트 조회시 사용할 예정
 	public String getKoreanNameByEnglishName(String englishName) {
 		return englishToKoreanMap.getOrDefault(englishName, "알 수 없음");
 	}
 
-	//TODO : 부트캠프 openAPI에서 가져올 때 사용 예정
+	public static boolean isValidKoreanName(String koreanName) {
+		return koreanToEnumMap.containsKey(koreanName);
+	}
+
 	public static BootcampCategoryType fromKoreanName(String koreanName) {
 		if(!koreanToEnumMap.containsKey(koreanName)) {
 			throw new BootcampCustomException(BootcampErrorCode.INVALID_CATEGORY_NAME);
diff --git a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/entity/TrainingCenter.java b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/entity/TrainingCenter.java
index 127f825e..807a317d 100644
--- a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/entity/TrainingCenter.java
+++ b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/entity/TrainingCenter.java
@@ -29,16 +29,12 @@ public class TrainingCenter extends BaseEntity {
 	@Column(nullable = false)
 	private String trainingCenterName;
 
-	@Column(nullable = false)
 	private String trainingCenterPhoneNumber;
 
-	@Column(nullable = false)
 	private String trainingCenterEmail;
 
-	@Column(nullable = false)
 	private String trainingCenterAddress;
 
-	@Column(nullable = false)
 	private String trainingCenterUrl;
 
 	public static TrainingCenter of(String name, String email, String address, String url, String phoneNumber) {
diff --git a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/repository/BootcampRepository.java b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/repository/BootcampRepository.java
index ce435b55..987bf430 100644
--- a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/repository/BootcampRepository.java
+++ b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/repository/BootcampRepository.java
@@ -5,4 +5,5 @@
 
 public interface BootcampRepository extends JpaRepository <Bootcamp, Long> {
 
+	boolean existsByBootcampNameAndBootcampDegree(String bootcampName, int bootcampDegree);
 }
diff --git a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/service/Employ24ApiService.java b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/service/Employ24ApiService.java
index 69af3241..832eb89b 100644
--- a/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/service/Employ24ApiService.java
+++ b/boottalk/src/main/java/com/icandoit/boottalk/bootcamp/service/Employ24ApiService.java
@@ -6,7 +6,9 @@
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 import org.springframework.http.HttpMethod;
 import org.springframework.http.ResponseEntity;
@@ -23,6 +25,7 @@
 import com.icandoit.boottalk.bootcamp.repository.BootcampRepository;
 import com.icandoit.boottalk.bootcamp.repository.TrainingCenterRepository;
 
+import lombok.Getter;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 
@@ -34,6 +37,7 @@ public class Employ24ApiService {
 	private static final String HOST = "www.work24.go.kr";
 	private static final String LIST_API_PATH = "/cm/openApi/call/hr/callOpenApiSvcInfo310L01.do";
 	private static final String DETAIL_API_PATH = "/cm/openApi/call/hr/callOpenApiSvcInfo310L02.do";
+	// TODO : 추후에 AUTH_KEY 관리 방식 지정하기
 	private static final String AUTH_KEY = "646d5bd7-af0c-42b1-94db-fd6d2c6908f7";
 
 	private final ObjectMapper objectMapper = new ObjectMapper();
@@ -41,94 +45,104 @@ public class Employ24ApiService {
 	private final TrainingCenterRepository trainingCenterRepository;
 	private final BootcampRepository bootcampRepository;
 
-	DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
+	private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
 
+	@Getter
+	private final Set<String> failedCategoryNames = new HashSet<>();
 
+	// Employ24 API 에서 카테고리/분야 조합별로 데이터를 수집 및 저장
 	public void saveAllFromEmploy24() {
 		List<String> categoryCodes = List.of("C0061", "C0104", "C0105");
 		List<String> ncsCodes = List.of("19", "20");
 
-		for(String categoryCode : categoryCodes) {
-			for(String ncsCode : ncsCodes) {
-				try {
-					List<BootcampListResponseDto> list = fetchBootcampList(categoryCode, ncsCode);
-					for(BootcampListResponseDto dto : list) {
-						saveBootcamp(dto);
-					}
-				} catch (Exception e) {
-					log.warn("데이터 수집 실패: categoryCode={}, ncsCode={}, message={}", categoryCode, ncsCode, e.getMessage());
-				}
+		for (String categoryCode : categoryCodes) {
+			for (String ncsCode : ncsCodes) {
+				processCategoryAndNcs(categoryCode, ncsCode);
 			}
 		}
 	}
 
-	private List<BootcampListResponseDto> fetchBootcampList(String categoryCode, String ncsCode) {
-		String url = buildListApiUrl(categoryCode, ncsCode);
-
-		ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
-		validateResponse(response);
-
+	// 주어진 카테고리와 NCS 코드로 리스트 조회 후 저장 처리
+	private void processCategoryAndNcs(String categoryCode, String ncsCode) {
 		try {
-			JsonNode srchList = objectMapper.readTree(response.getBody()).get("srchList");
-
-			if(srchList == null || srchList.isEmpty()) {
-				throw new BootcampCustomException(API_DATA_IS_EMPTY);
-			}
-
-			List<BootcampListResponseDto> result = new ArrayList<>();
+			List<BootcampListResponseDto> list = fetchBootcampList(categoryCode, ncsCode);
+			processBootcampList(list);
+		} catch (Exception e) {
+			log.warn("데이터 수집 실패: categoryCode={}, ncsCode={}, message={}", categoryCode, ncsCode, e.getMessage());
+		}
+	}
 
-			for (JsonNode node : srchList) {
-				result.add(objectMapper.treeToValue(node, BootcampListResponseDto.class));
+	// 중복이 아닌 경우에만 Bootcamp 저장
+	private void processBootcampList(List<BootcampListResponseDto> list) {
+		for (BootcampListResponseDto dto : list) {
+			if (isDuplicate(dto)) {
+				log.info("이미 등록된 부트캠프: {}", dto.bootcampName());
+				continue;
 			}
-
-			return result;
-		} catch (Exception e) {
-			throw new BootcampCustomException(DATA_PARSING_ERROR);
+			saveBootcamp(dto);
 		}
 	}
 
+	// Bootcamp 저장 (상세 정보 조회 포함)
 	private void saveBootcamp(BootcampListResponseDto dto) {
-		try {
-			BootcampDetailResponseDto detail = fetchBootcampDetail(dto.bootcampId(), dto.bootcampDegree(), dto.trainingCenterId());
-
-			TrainingCenter center = trainingCenterRepository.findByTrainingCenterName(dto.trainingCenterName())
-				.orElseGet(() -> trainingCenterRepository.save(
-					TrainingCenter.of(
-						dto.trainingCenterName(),
-						detail.trainingCenterEmail(),
-						detail.address1() + " " + detail.address2(),
-						detail.trainingCenterUrl(),
-						detail.trainingCenterTelephoneNumber()
-					)
-				));
-
-			BootcampCategoryType category = BootcampCategoryType.fromKoreanName(detail.ncsName());
-
-			boolean hasCost = !(detail.bootcampCourseName().equals("K-디지털트레이닝") || dto.maxCapacity().equals("0"));
-
-			Bootcamp bootcamp = Bootcamp.of(
-				center,
-				dto.bootcampName(),
-				category,
-				Integer.parseInt(dto.bootcampDegree()),
-				detail.address1(),
-				Integer.parseInt(dto.maxCapacity()),
-				hasCost,
-				LocalDate.parse(dto.trainingStartDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd")),
-				LocalDate.parse(dto.trainingEndDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd")),
-				dto.bootcampLink()
-			);
-
-			bootcampRepository.save(bootcamp);
-			log.info("저장 성공: {}", bootcamp.getBootcampName());
-		}catch(Exception e) {
-			log.warn("저장 실패: {}", dto.bootcampName(), e);
+		BootcampDetailResponseDto detail = fetchBootcampDetail(dto.bootcampId(), dto.bootcampDegree(), dto.trainingCenterId());
+
+		String categoryName = detail.ncsName();
+		if (!BootcampCategoryType.isValidKoreanName(categoryName)) {
+			failedCategoryNames.add(categoryName);
+			return;
 		}
+
+		TrainingCenter center = trainingCenterRepository.findByTrainingCenterName(dto.trainingCenterName())
+			.orElseGet(() -> trainingCenterRepository.save(
+				TrainingCenter.of(
+					dto.trainingCenterName(),
+					detail.trainingCenterEmail(),
+					detail.address1() + " " + detail.address2(),
+					detail.trainingCenterUrl(),
+					detail.trainingCenterTelephoneNumber()
+				)
+			));
+
+		BootcampCategoryType category = BootcampCategoryType.fromKoreanName(categoryName);
+
+		boolean hasCost = !(detail.bootcampCourseName().equals("K-디지털트레이닝") || dto.cost().equals("0"));
+
+		Bootcamp bootcamp = Bootcamp.of(
+			center,
+			dto.bootcampName(),
+			category,
+			Integer.parseInt(dto.bootcampDegree()),
+			detail.address1(),
+			Integer.parseInt(dto.maxCapacity()),
+			hasCost,
+			LocalDate.parse(dto.trainingStartDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd")),
+			LocalDate.parse(dto.trainingEndDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd")),
+			dto.bootcampLink()
+		);
+
+		bootcampRepository.save(bootcamp);
+		log.info("저장 성공: {}", bootcamp.getBootcampName());
 	}
 
+	// 중복 데이터 여부 확인
+	private boolean isDuplicate(BootcampListResponseDto dto) {
+		return bootcampRepository.existsByBootcampNameAndBootcampDegree(
+			dto.bootcampName(), Integer.parseInt(dto.bootcampDegree())
+		);
+	}
+
+	// Bootcamp 리스트 API 호출
+	private List<BootcampListResponseDto> fetchBootcampList(String categoryCode, String ncsCode) {
+		String url = buildListApiUrl(categoryCode, ncsCode);
+		ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
+		validateResponse(response);
+		return parseListResponse(response.getBody());
+	}
+
+	// Bootcamp 상세 API 호출
 	private BootcampDetailResponseDto fetchBootcampDetail(String id, String degree, String centerId) {
 		String url = buildDetailApiUrl(id, degree, centerId);
-		log.info("Fetch detail from URL: {}", url);
 
 		ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
 		validateResponse(response);
@@ -141,9 +155,29 @@ private BootcampDetailResponseDto fetchBootcampDetail(String id, String degree,
 		}
 	}
 
+	// 응답 JSON 파싱 → BootcampList DTO로 변환
+	private List<BootcampListResponseDto> parseListResponse(String body) {
+		try {
+			JsonNode jsonList = objectMapper.readTree(body).get("srchList");
+
+			if (jsonList == null || jsonList.isEmpty()) {
+				throw new BootcampCustomException(API_DATA_IS_EMPTY);
+			}
+
+			List<BootcampListResponseDto> list = new ArrayList<>();
+			for (JsonNode node : jsonList) {
+				list.add(objectMapper.treeToValue(node, BootcampListResponseDto.class));
+			}
+			return list;
+		} catch (Exception e) {
+			throw new BootcampCustomException(DATA_PARSING_ERROR);
+		}
+	}
+
+	// 리스트 API 호출 URL 생성
 	private String buildListApiUrl(String categoryCode, String ncsCode) {
 		LocalDate today = LocalDate.now();
-		LocalDate end = today.plusDays(1);
+		LocalDate end = today.plusDays(7);
 		return UriComponentsBuilder.newInstance()
 			.scheme("https")
 			.host(HOST)
@@ -161,6 +195,7 @@ private String buildListApiUrl(String categoryCode, String ncsCode) {
 			.toUriString();
 	}
 
+	// 상세 API 호출 URL 생성
 	private String buildDetailApiUrl(String id, String degree, String centerId) {
 		return UriComponentsBuilder.newInstance()
 			.scheme("https")
@@ -176,6 +211,7 @@ private String buildDetailApiUrl(String id, String degree, String centerId) {
 			.toUriString();
 	}
 
+	// API 응답 유효성 검사
 	private void validateResponse(ResponseEntity<String> response) {
 		if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
 			throw new BootcampCustomException(DATA_FETCH_ERROR);


Reviews:
user: login: jooyeon0907
body: 고생하셨습니다☺️
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T09:16:58Z
user: login: shCHO9801
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T10:15:18Z

Review comments:
diff_hunk: @@ -41,94 +44,104 @@ public class Employ24ApiService {
 	private final TrainingCenterRepository trainingCenterRepository;
 	private final BootcampRepository bootcampRepository;
 
-	DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
+	private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
 
+	@Getter
+	private final Set<String> failedCategoryNames = new HashSet<>();
 
+	// Employ24 API 에서 카테고리/분야 조합별로 데이터를 수집 및 저장
 	public void saveAllFromEmploy24() {
 		List<String> categoryCodes = List.of("C0061", "C0104", "C0105");
 		List<String> ncsCodes = List.of("19", "20");
 
-		for(String categoryCode : categoryCodes) {
-			for(String ncsCode : ncsCodes) {
-				try {
-					List<BootcampListResponseDto> list = fetchBootcampList(categoryCode, ncsCode);
-					for(BootcampListResponseDto dto : list) {
-						saveBootcamp(dto);
-					}
-				} catch (Exception e) {
-					log.warn("데이터 수집 실패: categoryCode={}, ncsCode={}, message={}", categoryCode, ncsCode, e.getMessage());
-				}
+		for (String categoryCode : categoryCodes) {
+			for (String ncsCode : ncsCodes) {
+				processCategoryAndNcs(categoryCode, ncsCode);
 			}
 		}
 	}
 
-	private List<BootcampListResponseDto> fetchBootcampList(String categoryCode, String ncsCode) {
-		String url = buildListApiUrl(categoryCode, ncsCode);
-
-		ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
-		validateResponse(response);
-
+	// 주어진 카테고리와 NCS 코드로 리스트 조회 후 저장 처리
+	private void processCategoryAndNcs(String categoryCode, String ncsCode) {
 		try {
-			JsonNode srchList = objectMapper.readTree(response.getBody()).get("srchList");
-
-			if(srchList == null || srchList.isEmpty()) {
-				throw new BootcampCustomException(API_DATA_IS_EMPTY);
-			}
-
-			List<BootcampListResponseDto> result = new ArrayList<>();
+			List<BootcampListResponseDto> list = fetchBootcampList(categoryCode, ncsCode);
+			processBootcampList(list);
+		} catch (Exception e) {
+			log.warn("데이터 수집 실패: categoryCode={}, ncsCode={}, message={}", categoryCode, ncsCode, e.getMessage());
+		}
+	}
 
-			for (JsonNode node : srchList) {
-				result.add(objectMapper.treeToValue(node, BootcampListResponseDto.class));
+	// 중복이 아닌 경우에만 Bootcamp 저장
+	private void processBootcampList(List<BootcampListResponseDto> list) {
+		for (BootcampListResponseDto dto : list) {
+			if (isDuplicate(dto)) {
+				log.info("이미 등록된 부트캠프: {}", dto.bootcampName());
+				continue;
 			}
-
-			return result;
-		} catch (Exception e) {
-			throw new BootcampCustomException(DATA_PARSING_ERROR);
+			saveBootcamp(dto);
 		}
 	}
 
+	// Bootcamp 저장 (상세 정보 조회 포함)
 	private void saveBootcamp(BootcampListResponseDto dto) {
-		try {
-			BootcampDetailResponseDto detail = fetchBootcampDetail(dto.bootcampId(), dto.bootcampDegree(), dto.trainingCenterId());
-
-			TrainingCenter center = trainingCenterRepository.findByTrainingCenterName(dto.trainingCenterName())
-				.orElseGet(() -> trainingCenterRepository.save(
-					TrainingCenter.of(
-						dto.trainingCenterName(),
-						detail.trainingCenterEmail(),
-						detail.address1() + " " + detail.address2(),
-						detail.trainingCenterUrl(),
-						detail.trainingCenterTelephoneNumber()
-					)
-				));
-
-			BootcampCategoryType category = BootcampCategoryType.fromKoreanName(detail.ncsName());
-
-			boolean hasCost = !(detail.bootcampCourseName().equals("K-디지털트레이닝") || dto.maxCapacity().equals("0"));
-
-			Bootcamp bootcamp = Bootcamp.of(
-				center,
-				dto.bootcampName(),
-				category,
-				Integer.parseInt(dto.bootcampDegree()),
-				detail.address1(),
-				Integer.parseInt(dto.maxCapacity()),
-				hasCost,
-				LocalDate.parse(dto.trainingStartDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd")),
-				LocalDate.parse(dto.trainingEndDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd")),
-				dto.bootcampLink()
-			);
-
-			bootcampRepository.save(bootcamp);
-			log.info("저장 성공: {}", bootcamp.getBootcampName());
-		}catch(Exception e) {
-			log.warn("저장 실패: {}", dto.bootcampName(), e);
+		BootcampDetailResponseDto detail = fetchBootcampDetail(dto.bootcampId(), dto.bootcampDegree(), dto.trainingCenterId());
+
+		String categoryName = detail.ncsName();
+		if (!BootcampCategoryType.isValidKoreanName(categoryName)) {
+			failedCategoryNames.add(categoryName);
+			return;
 		}
+
+		TrainingCenter center = trainingCenterRepository.findByTrainingCenterName(dto.trainingCenterName())
+			.orElseGet(() -> trainingCenterRepository.save(
+				TrainingCenter.of(
+					dto.trainingCenterName(),
+					detail.trainingCenterEmail(),
+					detail.address1() + " " + detail.address2(),
+					detail.trainingCenterUrl(),
+					detail.trainingCenterTelephoneNumber()
+				)
+			));
+
+		BootcampCategoryType category = BootcampCategoryType.fromKoreanName(categoryName);
+
+		boolean hasCost = !(detail.bootcampCourseName().equals("K-디지털트레이닝") || dto.maxCapacity().equals("0"));
path: boottalk/src/main/java/com/icandoit/boottalk/bootcamp/service/Employ24ApiService.java
user: login: jooyeon0907
body: 왜 maxCapacity가 0일 때 hasCost = false 로 판단되는지 궁금합니다!
저는 수강비 관련 필드가 0 일 때 false 가 되는게 아닌가 생각했어서요
created_at: 2025-04-01T09:01:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,94 +44,104 @@ public class Employ24ApiService {
 	private final TrainingCenterRepository trainingCenterRepository;
 	private final BootcampRepository bootcampRepository;
 
-	DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
+	private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
path: boottalk/src/main/java/com/icandoit/boottalk/bootcamp/service/Employ24ApiService.java
user: login: jooyeon0907
body: 위에 보시면 AUTH_KEY 가 코드에 노출 되어있어요 이 부분 참고 부탁드려요!
created_at: 2025-04-01T09:02:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,94 +44,104 @@ public class Employ24ApiService {
 	private final TrainingCenterRepository trainingCenterRepository;
 	private final BootcampRepository bootcampRepository;
 
-	DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
+	private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
 
+	@Getter
+	private final Set<String> failedCategoryNames = new HashSet<>();
 
+	// Employ24 API 에서 카테고리/분야 조합별로 데이터를 수집 및 저장
 	public void saveAllFromEmploy24() {
 		List<String> categoryCodes = List.of("C0061", "C0104", "C0105");
 		List<String> ncsCodes = List.of("19", "20");
 
-		for(String categoryCode : categoryCodes) {
-			for(String ncsCode : ncsCodes) {
-				try {
-					List<BootcampListResponseDto> list = fetchBootcampList(categoryCode, ncsCode);
-					for(BootcampListResponseDto dto : list) {
-						saveBootcamp(dto);
-					}
-				} catch (Exception e) {
-					log.warn("데이터 수집 실패: categoryCode={}, ncsCode={}, message={}", categoryCode, ncsCode, e.getMessage());
-				}
+		for (String categoryCode : categoryCodes) {
+			for (String ncsCode : ncsCodes) {
+				processCategoryAndNcs(categoryCode, ncsCode);
 			}
 		}
 	}
 
-	private List<BootcampListResponseDto> fetchBootcampList(String categoryCode, String ncsCode) {
-		String url = buildListApiUrl(categoryCode, ncsCode);
-
-		ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
-		validateResponse(response);
-
+	// 주어진 카테고리와 NCS 코드로 리스트 조회 후 저장 처리
+	private void processCategoryAndNcs(String categoryCode, String ncsCode) {
 		try {
-			JsonNode srchList = objectMapper.readTree(response.getBody()).get("srchList");
-
-			if(srchList == null || srchList.isEmpty()) {
-				throw new BootcampCustomException(API_DATA_IS_EMPTY);
-			}
-
-			List<BootcampListResponseDto> result = new ArrayList<>();
+			List<BootcampListResponseDto> list = fetchBootcampList(categoryCode, ncsCode);
+			processBootcampList(list);
+		} catch (Exception e) {
+			log.warn("데이터 수집 실패: categoryCode={}, ncsCode={}, message={}", categoryCode, ncsCode, e.getMessage());
+		}
+	}
 
-			for (JsonNode node : srchList) {
-				result.add(objectMapper.treeToValue(node, BootcampListResponseDto.class));
+	// 중복이 아닌 경우에만 Bootcamp 저장
+	private void processBootcampList(List<BootcampListResponseDto> list) {
+		for (BootcampListResponseDto dto : list) {
+			if (isDuplicate(dto)) {
+				log.info("이미 등록된 부트캠프: {}", dto.bootcampName());
+				continue;
 			}
-
-			return result;
-		} catch (Exception e) {
-			throw new BootcampCustomException(DATA_PARSING_ERROR);
+			saveBootcamp(dto);
 		}
 	}
 
+	// Bootcamp 저장 (상세 정보 조회 포함)
 	private void saveBootcamp(BootcampListResponseDto dto) {
-		try {
-			BootcampDetailResponseDto detail = fetchBootcampDetail(dto.bootcampId(), dto.bootcampDegree(), dto.trainingCenterId());
-
-			TrainingCenter center = trainingCenterRepository.findByTrainingCenterName(dto.trainingCenterName())
-				.orElseGet(() -> trainingCenterRepository.save(
-					TrainingCenter.of(
-						dto.trainingCenterName(),
-						detail.trainingCenterEmail(),
-						detail.address1() + " " + detail.address2(),
-						detail.trainingCenterUrl(),
-						detail.trainingCenterTelephoneNumber()
-					)
-				));
-
-			BootcampCategoryType category = BootcampCategoryType.fromKoreanName(detail.ncsName());
-
-			boolean hasCost = !(detail.bootcampCourseName().equals("K-디지털트레이닝") || dto.maxCapacity().equals("0"));
-
-			Bootcamp bootcamp = Bootcamp.of(
-				center,
-				dto.bootcampName(),
-				category,
-				Integer.parseInt(dto.bootcampDegree()),
-				detail.address1(),
-				Integer.parseInt(dto.maxCapacity()),
-				hasCost,
-				LocalDate.parse(dto.trainingStartDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd")),
-				LocalDate.parse(dto.trainingEndDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd")),
-				dto.bootcampLink()
-			);
-
-			bootcampRepository.save(bootcamp);
-			log.info("저장 성공: {}", bootcamp.getBootcampName());
-		}catch(Exception e) {
-			log.warn("저장 실패: {}", dto.bootcampName(), e);
+		BootcampDetailResponseDto detail = fetchBootcampDetail(dto.bootcampId(), dto.bootcampDegree(), dto.trainingCenterId());
+
+		String categoryName = detail.ncsName();
+		if (!BootcampCategoryType.isValidKoreanName(categoryName)) {
+			failedCategoryNames.add(categoryName);
+			return;
 		}
+
+		TrainingCenter center = trainingCenterRepository.findByTrainingCenterName(dto.trainingCenterName())
+			.orElseGet(() -> trainingCenterRepository.save(
+				TrainingCenter.of(
+					dto.trainingCenterName(),
+					detail.trainingCenterEmail(),
+					detail.address1() + " " + detail.address2(),
+					detail.trainingCenterUrl(),
+					detail.trainingCenterTelephoneNumber()
+				)
+			));
+
+		BootcampCategoryType category = BootcampCategoryType.fromKoreanName(categoryName);
+
+		boolean hasCost = !(detail.bootcampCourseName().equals("K-디지털트레이닝") || dto.maxCapacity().equals("0"));
path: boottalk/src/main/java/com/icandoit/boottalk/bootcamp/service/Employ24ApiService.java
user: login: shCHO9801
body: 확인해주셔서 감사합니다
해당 내용

courseMan == 0로 수정하겠습니다 감사합니다
created_at: 2025-04-01T10:15:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: geonuu09
created_at: 2025-04-01T08:42:33Z
body: 수고하셨습니다👍🏻
author_association: CONTRIBUTOR
user: login: P-Taeyoung
created_at: 2025-04-01T13:57:32Z
body: 고생하셨습니다~!
author_association: CONTRIBUTOR