서버를 세팅할 때 처음에 사람인에서 채용공고를 가져와야 했다.
총 12,000개 가량의 데이터가 있었고 스케줄러에 기존 로직으로 저장을 했더니 평균 20분 정도 소요가 됐었다.
우리는 채용 공고를 전날 새로 올라온 데이터를 00시에 갱신해오기 때문에 채용 공고는 만아야 500개 안쪽이라 약 1분~2분 정도면 실시간성이 중요한 부분이 아닌지라 그렇게 사용자가 불편함을 겪을 수치는 아니라고 생각은 들었으나 그래도 발생하는 쿼리의 양이 만만치 않다고 느껴져서 리팩토링을 진행하게 되었다.
JobPosting 엔티티 변환key: Job.id, value: Job 데이터)JobPosting.jobId와 일치하는 Job을 Map에서 가져옴jobCode를 가져와 ","로 분리jobCode를 순회하며:Job_Skill 테이블에서 jobCode 조회 (병목 발생 가능)JobPostingJobSkillList에 추가 후 저장 (병목 발생 가능)INSERT 쿼리 발생SELECT 쿼리 발생UPDATE 쿼리 발생JobPosting 저장 시 1회 INSERTJobCode 조회 시 최소 1회, 최대 jobCodeArray.length 만큼 추가 SELECT더티 체킹으로 인한 추가 UPDATE 발생Redis에 JobSkill을 저장해서 캐싱 처리개선 전 -> 1,100개 - 87초
전체 저장을 마지막으로 보냈을 때 -> 1,100개 - 80초 / 7초 개선
개선 후 -> 1,100개 -> 11초 / 개선 전보다 76초 개선
개선 후 87% 성능 개선이 되었다.

@Slf4j
@Service
@RequiredArgsConstructor
public class SchedulerService {
private final JobSkillRepository jobSkillRepository;
private final JobPostingRepository jobPostingRepository;
private final ObjectMapper objectMapper;
private final RestTemplate restTemplate;
private final RetryTemplate retryTemplate;
// URI로 조합할 OPEN API URL
private final String API_URL = "https://oapi.saramin.co.kr/job-search";
// URI로 조합할 apiKey
@Value("${api.key}")
private String apiKey;
// URI로 조합할 한 페이지당 가져올데이터 수
@Value("${api.count}")
private Integer count;
/**
* 매일 자정(00:00)에 실행될 스케줄러 메서드입니다.
* <p>
* - retryTemplate.execute(context -> { ... }) -> API 요청이 실패할 경우 재시도를 수행하는 `RetryTemplate`을
* 사용합니다. - processJobPostings (totalCount, totalJobs, pageNumber) -> API에서 채용 공고 데이터를 가져와
* 데이터베이스에 저장하는 핵심 로직을 실행합니다.
*/
@Scheduled(cron = "0 0 0 * * ?", zone = "Asia/Seoul")
@Transactional
public void savePublicData() {
retryTemplate.execute(context -> {
int pageNumber = 0;
int totalCount = 0;
int totalJobs = 1100; //1. 1,100개 기준 성능 측정
LocalDateTime start = LocalDateTime.now();
processJobPostings(totalCount, totalJobs, pageNumber);
LocalDateTime end = LocalDateTime.now();
// 시간 차이 계산
Duration duration = Duration.between(start, end);
// 결과값 출력
log.info("작업 실행 시간: {} 밀리초", duration.toMillis());
log.info("작업 실행 시간: {} 초", duration.getSeconds());
return null;
});
}
/**
* - 클래스 내에서 핵심로직이며, fetchJobPostings() 메소드를 통해 가져온 채용공고 데이터들을 저장하기위한 List<JobPosting>,
* List<JobSkill> 로 변환하여, 저장하도록 하는 메서드이다.
* - 오늘 가져올수있는 총 공고수(totalJobs) 보다 데이텁베이스에 저장된 공고수(totalCount) 크면 callBack 함수가 멈춘다.
*
* @param totalCount 현재 저장된 공고수
* @param totalJobs 오늘 총 공고 수
* @param pageNumber 현재 페이지 번호
*/
public void processJobPostings(int totalCount, int totalJobs, int pageNumber) {
Jobs jobs = fetchJobPostings(pageNumber, count);
// JobPosting 클래스로 담기
List<JobPosting> jobPostingList = jobs.getJobsDetail().getJobList().stream()
.map(Job::toEntity)
.toList();
// 전체 저장
List<JobPosting> savedJobPostingList = saveNewJobs(jobPostingList);
//JSON 응답 파싱
List<Job> jobList = jobs.getJobsDetail().getJobList();
Map<Long, Job> jobMap = jobList.stream()
.collect(Collectors.toMap(job -> Long.parseLong(job.getId()), job -> job));
for (JobPosting jobPosting : savedJobPostingList) {
//채용 공고랑 jobPosting이랑 일치하는 애 찾는 if문
// 한 페이지에 해당하는 110개의 데이터를 방금 저장한 공고들인 jobPosting과 비교하여, 손수 job-code의 code를 꺼내기 위한 작업.
Job findJob = jobMap.get(jobPosting.getJobId());
String jobCode = findJob.getPositionDto().getJobCode().getCode();
//여러개면 , 기준으로 짜르기
String[] jobCodeArray = jobCode.split(",");
for (String s : jobCodeArray) {
// db에 저장된 jobSkill, code로 조회
Optional<JobSkill> jobSkillOptional = jobSkillRepository.findByCode(
Integer.parseInt(s.trim()));
//jobSkill DB에 없다면
if (jobSkillOptional.isEmpty()) {
continue;
} else {
JobSkill jobSkill = jobSkillOptional.get();
//JobPosting에 jobskill 설정
//더티 체킹으로 인해 업데이트 쿼리 자동 발생
jobPosting.getJobPostingJobSkillList().add(
JobPostingJobSkill.builder()
.jobPosting(jobPosting)
.jobSkill(jobSkill)
.build());
}
}
}
//총 가져와야되는 개수 초기화
if (totalJobs == Integer.MAX_VALUE) {
totalJobs = Integer.parseInt(jobs.getJobsDetail().getTotal());
}
totalCount += jobPostingList.size();
if (totalCount < totalJobs) {
processJobPostings(totalCount, totalJobs, ++pageNumber);
}
}
/**
* 지정된 페이지 번호와 가져올 데이터 개수를 기준으로 채용공고 데이터를 가져오는 메서드입니다.
* <p>
* - restTemplate : 주어진 URI로 채용공고 api 서버에 GET 요청을 보내, 응답 데이터를 받아오는 역할수행 - objectMapper : JSON
* 문자열을 Jobs 객체로 변환하는 즉 역직렬화 역할수행.
*
* @param pageNumber 현재 페이지 번호
* @param count 가져올 데이터 개수
*/
private Jobs fetchJobPostings(int pageNumber, int count) {
URI uri = UriComponentsBuilder.fromHttpUrl(API_URL)
.queryParam("access-key", apiKey)
//.queryParam("published", getPublishedDate())
.queryParam("job_mid_cd", "2")
.queryParam("start", pageNumber) // 현재 페이지숫자
.queryParam("count", count)
.queryParam("fields", "count")//한 번 호출시 가지고 오는 데이터 양
.build()
.encode()
.toUri();
try {
String jsonResponse = restTemplate.getForObject(uri, String.class);
Jobs dataResponse = objectMapper.readValue(jsonResponse, Jobs.class);
if (dataResponse.getJobsDetail() == null || dataResponse.getJobsDetail().getJobList()
.isEmpty()) {
log.error(GlobalErrorCode.NO_DATA_RECEIVED.getMessage());
throw new GlobalException(GlobalErrorCode.NO_DATA_RECEIVED);
}
return dataResponse;
} catch (JsonProcessingException e) {
log.error("JSON 파싱 실패", e);
throw new GlobalException(GlobalErrorCode.JSON_PARSING_FAILED);
}
}
/**
* scheduler가 자정에 실행되기 때문에 전날 데이터를 가져오게 만든 메서드
*/
private String getPublishedDate() {
// 전날데이터
LocalDate today = LocalDate.now().minusDays(1);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
return today.format(formatter);
}
/**
* JobPosting, JobSkill 데이터들을 데이터베이스에 저장하기위한 메서드
*
* @param newJobs 가공된 JobPosting 데이터 리스트
*/
private List<JobPosting> saveNewJobs(List<JobPosting> newJobs) {
try {
List<JobPosting> savedJobPostingList = jobPostingRepository.saveAll(newJobs);
log.info("총 {}개의 공고를 저장했습니다.", savedJobPostingList.size());
return savedJobPostingList;
} catch (Exception e) {
log.error(GlobalErrorCode.DATABASE_SAVE_FAILED.getMessage(), e);
throw new GlobalException(GlobalErrorCode.DATABASE_SAVE_FAILED);
}
}
}

@Slf4j
@Service
@RequiredArgsConstructor
public class SchedulerService {
private final JobSkillRepository jobSkillRepository;
private final JobPostingRepository jobPostingRepository;
private final ObjectMapper objectMapper;
private final RestTemplate restTemplate;
private final RetryTemplate retryTemplate;
// URI로 조합할 OPEN API URL
private final String API_URL = "https://oapi.saramin.co.kr/job-search";
// URI로 조합할 apiKey
@Value("${api.key}")
private String apiKey;
// URI로 조합할 한 페이지당 가져올데이터 수
@Value("${api.count}")
private Integer count;
/**
* 매일 자정(00:00)에 실행될 스케줄러 메서드입니다.
* <p>
* - retryTemplate.execute(context -> { ... }) -> API 요청이 실패할 경우 재시도를 수행하는 `RetryTemplate`을
* 사용합니다. - processJobPostings (totalCount, totalJobs, pageNumber) -> API에서 채용 공고 데이터를 가져와
* 데이터베이스에 저장하는 핵심 로직을 실행합니다.
*/
@Scheduled(cron = "0 0 0 * * ?", zone = "Asia/Seoul")
@Transactional
public void savePublicData() {
retryTemplate.execute(context -> {
int pageNumber = 0;
int totalCount = 0;
int totalJobs = 1100; //1. 1100개 기준 성능 측정
LocalDateTime start = LocalDateTime.now();
processJobPostings(totalCount, totalJobs, pageNumber);
LocalDateTime end = LocalDateTime.now();
// 시간 차이 계산
Duration duration = Duration.between(start, end);
// 결과값 출력
log.info("작업 실행 시간: {} 밀리초", duration.toMillis());
log.info("작업 실행 시간: {} 초", duration.getSeconds());
return null;
});
}
/**
* - 클래스 내에서 핵심로직이며, fetchJobPostings() 메소드를 통해 가져온 채용공고 데이터들을 저장하기위한 List<JobPosting>,
* List<JobSkill> 로 변환하여, 저장하도록 하는 메서드이다.
* - 오늘 가져올수있는 총 공고수(totalJobs) 보다 데이텁베이스에 저장된 공고수(totalCount) 크면 callBack 함수가 멈춘다.
*
* @param totalCount 현재 저장된 공고수
* @param totalJobs 오늘 총 공고 수
* @param pageNumber 현재 페이지 번호
*/
public void processJobPostings(int totalCount, int totalJobs, int pageNumber) {
Jobs jobs = fetchJobPostings(pageNumber, count);
// JobPosting 클래스로 담기
List<JobPosting> jobPostingList = jobs.getJobsDetail().getJobList().stream()
.map(Job::toEntity)
.toList();
//JSON 응답 파싱
List<Job> jobList = jobs.getJobsDetail().getJobList();
Map<Long, Job> jobMap = jobList.stream()
.collect(Collectors.toMap(job -> Long.parseLong(job.getId()), job -> job));
for (JobPosting jobPosting : jobPostingList) {
//채용 공고랑 jobPosting이랑 일치하는 애 찾는 if문
// 한 페이지에 해당하는 110개의 데이터를 방금 저장한 공고들인 jobPosting과 비교하여, 손수 job-code의 code를 꺼내기 위한 작업.
Job findJob = jobMap.get(jobPosting.getJobId());
String jobCode = findJob.getPositionDto().getJobCode().getCode();
//여러개면 , 기준으로 짜르기
String[] jobCodeArray = jobCode.split(",");
for (String s : jobCodeArray) {
// db에 저장된 jobSkill, code로 조회
Optional<JobSkill> jobSkillOptional = jobSkillRepository.findByCode(
Integer.parseInt(s.trim()));
//jobSkill DB에 없다면
if (jobSkillOptional.isEmpty()) {
continue;
} else {
JobSkill jobSkill = jobSkillOptional.get();
//JobPosting에 jobskill 설정
jobPosting.getJobPostingJobSkillList().add(
JobPostingJobSkill.builder()
.jobPosting(jobPosting)
.jobSkill(jobSkill)
.build());
}
}
}
// 전체 저장 (위치 변경)
List<JobPosting> savedJobPostingList = saveNewJobs(jobPostingList);
//총 가져와야되는 개수 초기화
if (totalJobs == Integer.MAX_VALUE) {
totalJobs = Integer.parseInt(jobs.getJobsDetail().getTotal());
}
totalCount += jobPostingList.size();
if (totalCount < totalJobs) {
processJobPostings(totalCount, totalJobs, ++pageNumber);
}
}
/**
* 지정된 페이지 번호와 가져올 데이터 개수를 기준으로 채용공고 데이터를 가져오는 메서드입니다.
* <p>
* - restTemplate : 주어진 URI로 채용공고 api 서버에 GET 요청을 보내, 응답 데이터를 받아오는 역할수행 - objectMapper : JSON
* 문자열을 Jobs 객체로 변환하는 즉 역직렬화 역할수행.
*
* @param pageNumber 현재 페이지 번호
* @param count 가져올 데이터 개수
*/
private Jobs fetchJobPostings(int pageNumber, int count) {
URI uri = UriComponentsBuilder.fromHttpUrl(API_URL)
.queryParam("access-key", apiKey)
// .queryParam("published", getPublishedDate())
.queryParam("job_mid_cd", "2")
.queryParam("start", pageNumber) // 현재 페이지숫자
.queryParam("count", count)
.queryParam("fields", "count")//한 번 호출시 가지고 오는 데이터 양
.build()
.encode()
.toUri();
try {
String jsonResponse = restTemplate.getForObject(uri, String.class);
Jobs dataResponse = objectMapper.readValue(jsonResponse, Jobs.class);
if (dataResponse.getJobsDetail() == null || dataResponse.getJobsDetail().getJobList()
.isEmpty()) {
log.error(GlobalErrorCode.NO_DATA_RECEIVED.getMessage());
throw new GlobalException(GlobalErrorCode.NO_DATA_RECEIVED);
}
return dataResponse;
} catch (JsonProcessingException e) {
log.error("JSON 파싱 실패", e);
throw new GlobalException(GlobalErrorCode.JSON_PARSING_FAILED);
}
}
/**
* scheduler가 자정에 실행되기 때문에 전날 데이터를 가져오게 만든 메서드
*/
private String getPublishedDate() {
// 전날데이터
LocalDate today = LocalDate.now().minusDays(1);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
return today.format(formatter);
}
/**
* JobPosting, JobSkill 데이터들을 데이터베이스에 저장하기위한 메서드
*
* @param newJobs 가공된 JobPosting 데이터 리스트
*/
private List<JobPosting> saveNewJobs(List<JobPosting> newJobs) {
try {
List<JobPosting> savedJobPostingList = jobPostingRepository.saveAll(newJobs);
log.info("총 {}개의 공고를 저장했습니다.", savedJobPostingList.size());
return savedJobPostingList;
} catch (Exception e) {
log.error(GlobalErrorCode.DATABASE_SAVE_FAILED.getMessage(), e);
throw new GlobalException(GlobalErrorCode.DATABASE_SAVE_FAILED);
}
}
}

@Slf4j
@Service
@RequiredArgsConstructor
public class SchedulerService {
private final JobSkillRepository jobSkillRepository;
private final JobPostingRepository jobPostingRepository;
private final ObjectMapper objectMapper;
private final RestTemplate restTemplate;
private final RetryTemplate retryTemplate;
private final RedisRepository redisRepository;
// URI로 조합할 OPEN API URL
private final String API_URL = "https://oapi.saramin.co.kr/job-search";
// URI로 조합할 apiKey
@Value("${api.key}")
private String apiKey;
// URI로 조합할 한 페이지당 가져올데이터 수
@Value("${api.count}")
private Integer count;
/**
* 매일 자정(00:00)에 실행될 스케줄러 메서드입니다.
* <p>
* - retryTemplate.execute(context -> { ... }) -> API 요청이 실패할 경우 재시도를 수행하는 `RetryTemplate`을
* 사용합니다. - processJobPostings (totalCount, totalJobs, pageNumber) -> API에서 채용 공고 데이터를 가져와
* 데이터베이스에 저장하는 핵심 로직을 실행합니다.
*/
@Scheduled(cron = "0 0 0 * * ?", zone = "Asia/Seoul")
@Transactional
public void savePublicData() {
retryTemplate.execute(context -> {
int pageNumber = 0;
int totalCount = 0;
// int totalJobs = Integer.MAX_VALUE;
int totalJobs = 1000; //1. 1000개 기준 성능 측정
// int totalJobs = 10000; //2. 10000개 기준 성능 측정
LocalDateTime start = LocalDateTime.now();
processJobPostings(totalCount, totalJobs, pageNumber);
LocalDateTime end = LocalDateTime.now();
// 시간 차이 계산
Duration duration = Duration.between(start, end);
// 결과값 출력
log.info("작업 실행 시간: {} 밀리초", duration.toMillis());
log.info("작업 실행 시간: {} 초", duration.getSeconds());
return null;
});
}
/**
* - 클래스 내에서 핵심로직이며, fetchJobPostings() 메소드를 통해 가져온 채용공고 데이터들을 저장하기위한 List<JobPosting>,
* List<JobSkill> 로 변환하여, 저장하도록 하는 메서드이다. - 오늘 가져올수있는 총 공고수(totalJobs) 보다 데이텁베이스에 저장된
* 공고수(totalCount) 크면 callBack 함수가 멈춘다.
*
* @param totalCount 현재 저장된 공고수
* @param totalJobs 오늘 총 공고 수
* @param pageNumber 현재 페이지 번호
*/
public void processJobPostings(int totalCount, int totalJobs, int pageNumber) {
Jobs jobs = fetchJobPostings(pageNumber, count);
// JobPosting 클래스로 담기
List<JobPosting> jobPostingList = jobs.getJobsDetail().getJobList().stream()
.map(Job::toEntity)
.toList();
//JSON 응답 파싱
List<Job> jobList = jobs.getJobsDetail().getJobList();
Map<Long, Job> jobMap = jobList.stream()
.collect(Collectors.toMap(job -> Long.parseLong(job.getId()), job -> job));
for (JobPosting jobPosting : jobPostingList) {
// JobId로 분류된 JobMap에서 Job 꺼내기
Job findJob = jobMap.get(jobPosting.getJobId());
//꺼내온 Job 안에 JobCode 꺼내기
String jobCode = findJob.getPositionDto().getJobCode().getCode();
//여러개면 , 기준으로 짜르기
String[] jobCodeArray = jobCode.split(",");
for (String s : jobCodeArray) {
String key = JobSkillConstant.JOB_SKILL_REDIS_KEY.getKey() + s;
//Redis에서 KEY값이 있는지 없는지 조회
//exists
boolean hasKeyResult = redisRepository.hasKey(key);
//만약 있다면 Redis에서 VALUE 조회해서 jobSkill 객체 생성
if (hasKeyResult) {
//JobSkillId 가져오는 로직
Long jobSkillId = Long.valueOf(redisRepository.get(key).toString());
//JobSkill 생성
JobSkill jobSkill = JobSkill.builder()
.id(jobSkillId)
.build();
jobPosting.getJobPostingJobSkillList().add(
JobPostingJobSkill.builder()
.jobPosting(jobPosting)
.jobSkill(jobSkill)
.build());
}
}
}
// 전체 저장
List<JobPosting> savedJobPostingList = saveNewJobs(jobPostingList);
//총 가져와야되는 개수 초기화
if (totalJobs == Integer.MAX_VALUE) {
totalJobs = Integer.parseInt(jobs.getJobsDetail().getTotal());
}
totalCount += savedJobPostingList.size();
if (totalCount < totalJobs) {
processJobPostings(totalCount, totalJobs, ++pageNumber);
}
}
/**
* 지정된 페이지 번호와 가져올 데이터 개수를 기준으로 채용공고 데이터를 가져오는 메서드입니다.
* <p>
* - restTemplate : 주어진 URI로 채용공고 api 서버에 GET 요청을 보내, 응답 데이터를 받아오는 역할수행 - objectMapper : JSON
* 문자열을 Jobs 객체로 변환하는 즉 역직렬화 역할수행.
*
* @param pageNumber 현재 페이지 번호
* @param count 가져올 데이터 개수
*/
private Jobs fetchJobPostings(int pageNumber, int count) {
URI uri = UriComponentsBuilder.fromHttpUrl(API_URL)
.queryParam("access-key", apiKey)
// .queryParam("published", getPublishedDate())
.queryParam("job_mid_cd", "2")
.queryParam("start", pageNumber) // 현재 페이지숫자
.queryParam("count", count)
.queryParam("fields", "count")//한 번 호출시 가지고 오는 데이터 양
.build()
.encode()
.toUri();
try {
String jsonResponse = restTemplate.getForObject(uri, String.class);
Jobs dataResponse = objectMapper.readValue(jsonResponse, Jobs.class);
if (dataResponse.getJobsDetail() == null || dataResponse.getJobsDetail().getJobList()
.isEmpty()) {
log.error(GlobalErrorCode.NO_DATA_RECEIVED.getMessage());
throw new GlobalException(GlobalErrorCode.NO_DATA_RECEIVED);
}
return dataResponse;
} catch (JsonProcessingException e) {
log.error("JSON 파싱 실패", e);
throw new GlobalException(GlobalErrorCode.JSON_PARSING_FAILED);
}
}
/**
* scheduler가 자정에 실행되기 때문에 전날 데이터를 가져오게 만든 메서드
*/
private String getPublishedDate() {
// 전날데이터
LocalDate today = LocalDate.now().minusDays(1);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
return today.format(formatter);
}
/**
* JobPosting, JobSkill 데이터들을 데이터베이스에 저장하기위한 메서드
*
* @param newJobs 가공된 JobPosting 데이터 리스트
*/
private List<JobPosting> saveNewJobs(List<JobPosting> newJobs) {
try {
List<JobPosting> savedJobPostingList = jobPostingRepository.saveAll(newJobs);
log.info("총 {}개의 공고를 저장했습니다.", savedJobPostingList.size());
return savedJobPostingList;
} catch (Exception e) {
log.error(GlobalErrorCode.DATABASE_SAVE_FAILED.getMessage(), e);
throw new GlobalException(GlobalErrorCode.DATABASE_SAVE_FAILED);
}
}
}