개발일기

2부 itzip 프로젝트에 단위테스트 적용하기 본문

취준생 프로젝트

2부 itzip 프로젝트에 단위테스트 적용하기

한둥둥 2024. 12. 6. 12:24

우선적으로 Mockito를 사용하여 단위테스트를 적용해보았지만 아래와 같은 이유 때문에 채택하지 않게되었다. 

아래와 같은 이유때문에 변경하게 되었습니다. 

1. Mockito는 테스트를 위한 강력한 라이브러리지만, 사용법을 익히는 데에 러닝 커브가 존재했습니다.

특히, given, when, thenReturn과 같은 패턴을 이해하고 적용하는 과정에서 시간이 필요했습니다. 라이브러리의 문서를 읽고 테스트 코드를 작성하는 데 추가적인 학습 시간이 소요되었습니다.

 

2. 테스트 대상 코드가 디자이너들의 잦은 요구사항 변경에 따라 빈번히 수정되었습니다.

이로 인해 Mockito를 기반으로 작성한 테스트 코드도 지속적으로 업데이트해야 했습니다.

Mock 객체의 반환 값, 호출 순서 등도 함께 변경해야 했기 때문에 유지보수 부담이 증가

 

3. 복잡한 데이터 구조 

테스트 대상이 되는 코드에서 여러 리스트 객체를 불러와 처리해야 하는 경우가 많았습니다.

이런 상황에서 Mockito로 Mock 객체를 설정하고, 각 리스트의 동작을 시뮬레이션하는 코드를 작성하는 것이 번거로웠습니다.

Mock 설정 자체가 코드의 가독성과 간결함을 저하시켰고, 실제 동작과 차이가 생길 위험도 있었습니다.

 

4. Mockito는 특정한 패턴(예: given-when-then)에 따라 테스트를 작성해야 하는데, 프로젝트에서의 요구사항과 패턴이 반드시 일치하지 않는 경우가 있었습니다.

예를 들어, 동적 데이터를 처리하거나, 예상치 못한 예외 처리를 테스트하는 경우, Mock 설정이 오히려 방해가 되었습니다.

실제 비즈니스 로직과 Mock 간의 불일치로 인해 테스트 코드가 신뢰성을 잃는 상황이 발생했습니다.

 

결론적으로 이러한 이유가 발생하는 이유는 비즈니스 로직이 Service단에 있기 때문에 테스트하기 어렵다고 생각하게 되었습니다. 

왜냐면 Service단에 코드는 Repository에서 값을 가져오는 부분과 실제 핵심 중요한 비즈니스 로직이 합쳐져있기 때문에 Repository는 테스트하기 어려운데, 같은 계층에 존재하기에 어렵다고 판단하였습니다. 

실제로, 우리가 테스트할 때, 중요한 부분은 핵심 비즈니스로직이라는 생각을 하였습니다. 그래서, 저는 비즈니스 로직을 도메인 옮겨서 리팩토링을 진행하였습니다. 

이렇게 할 때, 좋은 점은 Mockito 라이브러리를 굳이 모든 부분에 사용하지 않아도 된다는 부분이였습니다. 물론 불가피하게 테스트하는 부분은 Mockito라이브러리를 사용하는 것은 좋은 방법입니다. 도메인에 핵심 비즈니스로직을 담는다면 해당 코드를 충분히 Junit5가지고 테스트 할 수 있었습니다. 

 

수정하기 전 JobInfoConnectServiceImpl 클래스 

@Slf4j
@Service
@RequiredArgsConstructor
public class JobInfoConnectServiceImpl implements JobInfoConnectService {

    private final JobInfoRepository jobInfoRepository;
    private final JobInfoScrapRepository jobInfoScrapRepository;
    @Value("${job.api-url}")
    private String jobUrl;

    @Value("${job.api-key}")
    private String jobKey;

    /**
     * API와 연결하여 모든 JobInfo 데이터를 가져옵니다.
     * 데이터를 페이지 단위로 나누어 가져와서 리스트에 추가한 후, 전체 데이터를 반환합니다.
     *
     * @return API로부터 가져온 모든 JobInfo 데이터의 리스트
     */
    @Override
    public List<JobInfo> jobInfoConnect() {
        // API에서 전체 JobInfo 데이터의 개수를 가져옴
        int totalCount = ConnectJobInfo.getTotalCount(jobUrl, jobKey);

        // 총 아이템 수를 기준으로 필요한 페이지 수를 계산
        int pages = calculatePageCount(totalCount);

        // API로부터 가져온 모든 JobInfo 데이터를 저장할 리스트
        List<JobInfo> apiDataList = new ArrayList<>();

        // 각 페이지의 데이터를 가져와 리스트에 추가
        for(int i=0;i<pages; i++){
            apiDataList.addAll(ConnectJobInfo.getJobInfoData(jobUrl, jobKey, i));
        }

        // 모든 페이지의 데이터를 가져온 후 반환
        return apiDataList;
    }

    /**
     * API에서 가져온 JobInfo 데이터와 데이터베이스에 저장된 JobInfo 데이터를 비교하여,
     * 데이터베이스에만 존재하는 데이터를 삭제하고, 삭제된 데이터의 수를 반환합니다.
     *
     * @param apiDataList API로부터 가져온 최신 JobInfo 데이터 목록
     * @param dbList 데이터베이스에서 조회한 기존 JobInfo 데이터 목록
     * @return 삭제된 JobInfo 항목의 총 개수
     */
    @Override
    public Long jobInfoDelete(List<JobInfo> apiDataList, List<JobInfo> dbList) {
        // dbList가 비어있을 경우, 즉시 반환하여 추가 작업을 방지
        if(dbList.isEmpty()) return 0L;


        List<Long> deleteList = makeDeleteList(apiDataList, dbList);


        long totalDeletedCount = 0L;
        // batchSize를 설정하여 500개씩 나누어 삭제 작업을 수행 (대량 삭제 시 성능 최적화)
       for(int i=0; i < deleteList.size(); i+= 500){
           List<Long> batch = deleteList.subList(i, Math.min(i + 500, deleteList.size()));
           jobInfoScrapRepository.deleteDeleteByPositionIds(batch);
           totalDeletedCount += jobInfoRepository.bulkDeleteByPositionIds(batch);
       }

       // 최종적으로 삭제된 레코드의 총 개수를 반환
       return totalDeletedCount;
    }


    /**
     * API에서 가져온 최신 JobInfo 데이터와 데이터베이스에 저장된 기존 데이터를 비교하여,
     * 변경된 JobInfo 객체들을 찾아 업데이트를 수행합니다. 업데이트된 레코드의 총 개수를 반환합니다.
     *
     * @param apiDataList API로부터 가져온 최신 JobInfo 데이터 목록
     * @param dbList 데이터베이스에서 조회한 기존 JobInfo 데이터 목록
     * @return 업데이트된 레코드의 총 개수
     */
    @Override
    public Long jobInfoUpdate(List<JobInfo> apiDataList, List<JobInfo> dbList) {
        // 변경된 JobInfo 객체들을 찾기 위한 리스트 생성
        List<JobInfoEntity> updateList = makeUpdateList(apiDataList, dbList);

        // 업데이트된 레코드의 총 개수를 저장할 변수
        long totalUpdatedCount = 0L;

        // 업데이트 작업을 배치 단위로 나누어 처리 (500개씩)
        for(int i=0; i< updateList.size(); i+=500){

            // 500개씩 잘라낸 부분 리스트를 배치로 처리
            List<JobInfoEntity> batch = updateList.subList(i, Math.min(i + 500, updateList.size()));

            // 현재 배치를 데이터베이스에 저장하고, 저장된 레코드 수를 총 개수에 누적
            totalUpdatedCount += jobInfoRepository.saveAll(batch).size();
        }

        // 최종적으로 업데이트된 레코드의 총 개수를 반환
        return totalUpdatedCount;
    }



    /**
     * 주어진 API 데이터 목록과 데이터베이스 목록을 비교하여, 데이터베이스에 없는 새로운 JobInfo 객체들을
     * 저장하고, 저장된 레코드의 총 개수를 반환합니다.
     *
     * @param apiDataList API로부터 가져온 최신 JobInfo 데이터 목록
     * @param dbList 데이터베이스에서 조회한 기존 JobInfo 데이터 목록
     * @return 저장된 레코드의 총 개수
     */
    @Override
    public Long jobInfoSave(List<JobInfo> apiDataList, List<JobInfo> dbList) {
        List<JobInfoEntity> insertList = makeSaveList(apiDataList, dbList);

        long totalSaveCount = 0L;

        // 배치 단위로 500개씩 나누어 저장 작업을 수행
        for(int i=0; i < insertList.size() ; i+=500) {
            List<JobInfoEntity> batch =insertList.subList(i, Math.min(i + 500, insertList.size()));

            totalSaveCount += jobInfoRepository.saveAll(batch).size();
        }

        // 배치 단위로 500개씩 나누어 저장 작업을 수행
        return totalSaveCount;
    }

    /**
     * 주어진 총 아이템 수(totalCount)를 기준으로 페이지 수를 계산합니다.
     * 한 페이지에 110개의 아이템이 포함된다고 가정합니다.
     *
     * @param totalCount 전체 아이템 수
     * @return 필요한 총 페이지 수
     */
    private int calculatePageCount(int totalCount) {

        // 전체 아이템 수를 110으로 나누어 페이지 수를 계산하고, 나머지가 있으면 추가 페이지를 고려
        int pages = totalCount / 110 + 1;

        return pages;
    }


    /**
     * 두 JobInfo 객체의 필드를 비교하여, 모든 필드가 다른 경우 true를 반환합니다.
     * 만약 하나라도 같은 필드가 있으면 false를 반환합니다.
     *
     * @param dbJobInfo 데이터베이스에서 조회한 JobInfo 객체
     * @param apiJobInfo API로부터 가져온 JobInfo 객체
     * @return 모든 필드가 서로 다른 경우 true, 하나라도 같은 필드가 있는 경우 false
     */
    private boolean checkNotEquals(JobInfo dbJobInfo, JobInfo apiJobInfo) {

        return !dbJobInfo.getActive().equals(apiJobInfo.getActive()) // active 필드 비교
                || !dbJobInfo.getUrl().equals(apiJobInfo.getUrl()) // URL 필드 비교
                || !dbJobInfo.getTitle().equals(apiJobInfo.getTitle()) // 제목 필드 비교
                || !dbJobInfo.getIndustryCode().equals(apiJobInfo.getIndustryCode()) // 산업 코드 비교
                || !dbJobInfo.getIndustryName().equals(apiJobInfo.getIndustryName()) // 산업 이름 비교
                || !dbJobInfo.getLocationCode().equals(apiJobInfo.getLocationCode()) // 위치 코드 비교
                || !dbJobInfo.getLocationName().equals(apiJobInfo.getLocationName()) // 위치 이름 비교
                || !dbJobInfo.getJobTypeCode().equals(apiJobInfo.getJobTypeCode()) // 직무 유형 코드 비교
                || !dbJobInfo.getJobTypeName().equals(apiJobInfo.getJobTypeName()) // 직무 유형 이름 비교
                || !dbJobInfo.getJobMidCode().equals(apiJobInfo.getJobMidCode()) // 중간 직무 코드 비교
                || !dbJobInfo.getJobMidName().equals(apiJobInfo.getJobMidName()) // 중간 직무 이름 비교
                || !dbJobInfo.getJobName().equals(apiJobInfo.getJobName()) // 직무 이름 비교
                || !dbJobInfo.getJobCode().equals(apiJobInfo.getJobCode()) // 직무 코드 비교
                || !dbJobInfo.getExperienceCode().equals(apiJobInfo.getExperienceCode()) // 경력 코드 비교
                || !dbJobInfo.getExperienceName().equals(apiJobInfo.getExperienceName()) // 경력 이름 비교
                || !dbJobInfo.getSalaryName().equals(apiJobInfo.getSalaryName()) // 급여 이름 비교
                || !dbJobInfo.getPostingDate().equals(apiJobInfo.getPostingDate()) // 게시 날짜 비교
                || !dbJobInfo.getExpirationDate().equals(apiJobInfo.getExpirationDate()) // 만료 날짜 비교
                || !dbJobInfo.getCloseTypeCode().equals(apiJobInfo.getCloseTypeCode()) // 마감 유형 코드 비교
                || !dbJobInfo.getCloseTypeName().equals(apiJobInfo.getCloseTypeName()); // 마감 유형 이름 비교

    }

    /**
     * API로부터 가져온 데이터와 데이터베이스에 저장된 데이터를 비교하여,
     * API 데이터에는 존재하지 않지만 데이터베이스에는 존재하는 JobInfo 객체들의 Position ID 리스트를 생성합니다.
     *
     * @param apiDataList API로부터 가져온 최신 JobInfo 데이터 목록
     * @param dbList 데이터베이스에서 조회한 기존 JobInfo 데이터 목록
     * @return 삭제해야 할 JobInfo 객체들의 Position ID 리스트 (API 데이터에 존재하지 않는 ID들)
     */
    protected static List<Long> makeDeleteList(List<JobInfo> apiDataList, List<JobInfo> dbList) {
        // 데이터베이스에 있는 JobInfo들의 Position ID를 Set에 저장
        Set<Long> dbIdSet = dbList.stream()
                .map(JobInfo::getPositionId)
                .collect(Collectors.toSet());

        // API에서 가져온 JobInfo들의 Position ID를 Set에 저장
        Set<Long> apiSet = apiDataList.stream()
                .map(JobInfo::getPositionId)
                .collect(Collectors.toSet());

        // 데이터베이스에 있지만 API에는 없는 Position ID들을 추려내어 삭제 대상 리스트를 만듦
        List<Long> deleteList = dbIdSet.stream()
                .filter(id-> !apiSet.contains(id))
                .toList();
        return deleteList;
    }

    /**
     * API에서 가져온 최신 데이터와 데이터베이스에 저장된 데이터를 비교하여,
     * 업데이트가 필요한 JobInfoEntity 리스트를 생성합니다.
     *
     * @param apiDataList API로부터 가져온 최신 JobInfo 데이터 목록
     * @param dbList 데이터베이스에서 조회한 기존 JobInfo 데이터 목록
     * @return 업데이트가 필요한 JobInfoEntity 객체들의 리스트
     */
    protected List<JobInfoEntity> makeUpdateList(List<JobInfo> apiDataList, List<JobInfo> dbList) {
        // API 데이터 목록을 Position ID를 키로 하는 맵으로 변환하여, 빠른 조회가 가능하도록 함
        Map<Long, JobInfo> apiDataMap = apiDataList.stream()
                .collect(Collectors.toMap(
                        JobInfo::getPositionId,
                        jobInfo -> jobInfo,
                        (existing, replacement) -> replacement
                ));
        List<JobInfoEntity> updateList = new ArrayList<>();

        // 데이터베이스의 JobInfo와 API 데이터를 비교하여 업데이트가 필요한 항목을 리스트에 추가
        dbList.forEach(dbJobInfo -> {
            JobInfo apiJobInfo = apiDataMap.get(dbJobInfo.getPositionId()); // 동일한 Position ID를 가진 API 데이터 조회
            // API 데이터가 존재하고, 해당 데이터가 기존 데이터와 다를 경우 업데이트 리스트에 추가
            if (apiJobInfo != null && checkNotEquals(dbJobInfo, apiJobInfo)) {
                updateList.add(apiJobInfo.toIdEntity()); // API 데이터를 엔티티로 변환하여 리스트에 추가
            }
        });


        return updateList;
    }

    /**
     * API 데이터와 데이터베이스 데이터를 비교하여 새로 저장해야 할 JobInfoEntity 리스트를 생성합니다.
     *
     * @param apiDataList API로부터 가져온 최신 JobInfo 데이터 목록
     * @param dbList 데이터베이스에서 조회한 기존 JobInfo 데이터 목록
     * @return 저장할 새로운 JobInfoEntity 객체들의 리스트
     */
    protected static List<JobInfoEntity> makeSaveList(List<JobInfo> apiDataList, List<JobInfo> dbList) {
        // 데이터베이스에 있는 JobInfo들의 Position ID를 Set에 저장
        Set<Long> dbSet = new HashSet<>();
        dbList.stream().map(JobInfo::getPositionId).forEach(dbSet::add);

        // API 데이터 중에서 DB에 존재하지 않는 JobInfo들을 필터링하여 저장할 리스트 생성
        List<JobInfoEntity> insertList = apiDataList.stream()
                .filter(jobInfo -> !dbSet.contains(jobInfo.getPositionId()))
                .map(JobInfo::toEntity)
                .collect(Collectors.toList());

        return insertList;
    }

}

 

수정한 Service 코드 

package darkoverload.itzip.feature.job.service.connect;

import darkoverload.itzip.feature.job.domain.ConnectJobInfo;
import darkoverload.itzip.feature.job.domain.job.JobInfo;
import darkoverload.itzip.feature.job.domain.job.JobInfoAggregator;
import darkoverload.itzip.feature.job.domain.job.JobInfoIds;
import darkoverload.itzip.feature.job.domain.job.JobInfos;
import darkoverload.itzip.feature.job.repository.JobInfoRepository;
import darkoverload.itzip.feature.job.repository.JobInfoScrapRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class JobInfoConnectServiceImpl implements JobInfoConnectService {

    private final JobInfoRepository jobInfoRepository;
    private final JobInfoScrapRepository jobInfoScrapRepository;

    @Value("${job.api-url}")
    private String jobUrl;

    @Value("${job.api-key}")
    private String jobKey;

    /**
     * API와 연결하여 모든 JobInfo 데이터를 가져옵니다.
     * 데이터를 페이지 단위로 나누어 가져와서 리스트에 추가한 후, 전체 데이터를 반환합니다.
     *
     * @return API로부터 가져온 모든 JobInfo 데이터의 리스트
     */
    @Override
    public List<JobInfo> jobInfoConnect() {
        int totalCount = ConnectJobInfo.getTotalCount(jobUrl, jobKey);

        int pages = calculatePageCount(totalCount);

        List<JobInfo> apiDataList = new ArrayList<>();

        for (int i = 0; i < pages; i++) {
            apiDataList.addAll(ConnectJobInfo.getJobInfoData(jobUrl, jobKey, i));
        }

        // 모든 페이지의 데이터를 가져온 후 반환
        return apiDataList;
    }

    @Override
    public Long jobInfoDelete(JobInfoAggregator jobInfoAggregator) {
        if (jobInfoAggregator.isDbJobInfosEmpty()) return 0L;
        JobInfoIds jobInfoIds = jobInfoAggregator.makeDeleteJobInfoIds();

        long totalDeletedCount = 0L;
        for (int i = 0; i < jobInfoIds.size(); i += 500) {
            jobInfoScrapRepository.bulkDeleteByPositionIds(jobInfoIds.subList(i, Math.min(i + 500, jobInfoIds.size())));
            totalDeletedCount += jobInfoRepository.bulkDeleteByPositionIds(jobInfoIds.subList(i, Math.min(i + 500, jobInfoIds.size())));
        }

        return totalDeletedCount;
    }

    @Override
    public Long jobInfoUpdate(JobInfoAggregator jobInfoAggregator) {
        JobInfos jobInfos = jobInfoAggregator.makeUpdateJobInfos();

        long totalUpdatedCount = 0L;
        for (int i = 0; i < jobInfos.size(); i += 500) {
            totalUpdatedCount += jobInfoRepository.saveAll(jobInfos.subList(i, Math.min(i + 500, jobInfos.size())))
                    .size();
        }

        return totalUpdatedCount;
    }

    @Override
    public Long jobInfoSave(JobInfoAggregator jobInfoAggregator) {
        JobInfos saveJobInfos = jobInfoAggregator.makeSaveJobInfos();

        long totalSaveCount = 0L;
        for (int i = 0; i < saveJobInfos.size(); i += 500) {
            totalSaveCount += jobInfoRepository.saveAll(saveJobInfos.subList(i, Math.min(i + 500, saveJobInfos.size()))).size();
        }

        return totalSaveCount;
    }

    /**
     * 주어진 총 아이템 수(totalCount)를 기준으로 페이지 수를 계산합니다.
     * 한 페이지에 110개의 아이템이 포함된다고 가정합니다.
     *
     * @param totalCount 전체 아이템 수
     * @return 필요한 총 페이지 수
     */
    private int calculatePageCount(int totalCount) {
        return totalCount / 110 + 1;
    }

}

해당 코드는 사람인 API에서 데이터를 가져오기 위하여 작성한 코드입니다. 아래와 같은 코드였습니다. 

 

이렇게 보았을 때, Service에 굉장히 많은 코드들이 들어가있었습니다. 하지만 해당 로직들을 도메인으로 옮겨준 결과 코드를 조금 더 깔끔하게 유지보수 할 수 있게 되었고, 완벽한 객체지향은 아니지만 어느정도 객체지향이 이전보다 잘 지켜지고 있습니다. 

즉, SOLID 원칙을 조금 더 지킬 수 있게 되었습니다. 

저만 그런걸 수도 있지만, 많은 사람들이 비즈니스 로직을 Service코드에 담아서 프로젝트를 진행하고 있는 거 같습니다. 하지만 이것이 과연 객체지향의 원칙을 잘 지키고 있는지 고민해봐야하는 것 같습니다. 

 

 

🔥 JobInfo 클래스 

package darkoverload.itzip.feature.job.domain.job;

import darkoverload.itzip.global.entity.AuditingFields;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;

import java.time.LocalDateTime;

@ToString
@Entity
@Getter @Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access= AccessLevel.PROTECTED)
@EqualsAndHashCode(callSuper = false)
@Table(name="job_infos")
public class JobInfo extends AuditingFields {

    private static final String MAP_JOB_SCARP_COUNT_KEY = "jobScrapCount:";


    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    // 공고번호
    @Column(name="position_id")
    private Long positionId;

    // 기업명
    @Column(name="company_name", length = 100)
    private String companyName;

    //기업정보 URL
    @Column(name="company_href", length = 5000)
    private String companyHref;

    //채용정보  URL
    @Column(length = 5000)
    private String url;

    //공고 진행 여부 (1: 진행중, 0:마감)
    @Column(length = 1)
    private String active;

    //공고제목
    private String title;

    //업종 코드
    @Column(name="industry_code", length=50)
    private String industryCode;

    //업종 명
    @Column(name="industry_name", length=50)
    private String industryName;

    // 지역 코드

    @Column(name="location_code")
    private String locationCode;

    // 지역명
    @Column(name="location_name")
    private String locationName;

    // 근무형태 코드
    @Column(name="job_type_code")
    private String jobTypeCode;

    // 근무형태명
    @Column(name="job_type_name")
    private String jobTypeName;

    // 상위 직무 코드
    @Column(name="job_mid_code")
    private String jobMidCode;

    // 상위 직무 명
    @Column(name="job_mid_name")
    private String jobMidName;

    // 직무명
    @Column(name="job_name", length = 5000)
    private String jobName;

    // 직종 코드
    @Column(name="job_code", length = 5000)
    private String jobCode;

    // 경력 코드 (1: 신입, 2: 경력, 3: 신입/경력, 0: 경력무관)
    @Column(name="experience_code")
    private String experienceCode;

    // 경력 최소 값
    @Column(name="experience_min")
    private Long experienceMin;

    // 경력 최대 값
    @Column(name="experience_max")
    private Long experienceMax;

    // 경력명
    @Column(name="experience_name")
    private String experienceName;

    // 학력 코드 (표 참고)
    @Column(name="required_education_code", length=100)
    private String requiredEducationCode;

    // 학력명
    @Column(name="required_education_name", length=50)
    private String requiredEducationName;

    // 키워드
    @Column(length=5000)
    private String keyword;

    // 연봉코드
    @Column(name="salary_code", length=50)
    private String salaryCode;

    // 연봉명
    @Column(name="salary_name", length=50)
    private String salaryName;

    // 채용일
    @Column(name="posting_date")
    private LocalDateTime postingDate;

    // 채용마감일
    @Column(name="expiration_date")
    private LocalDateTime expirationDate;

    // 마감일 코드
    @Column(name="close_type_code", length=50)
    private String closeTypeCode;

    // 마감일 명
    @Column(name="close_type_name", length=50)
    private String closeTypeName;


    @Column(name="scrap_count")
    @ColumnDefault("0")
    private Integer scrapCount;

    public static String makeScrapCountRedisKey(Long jobInfoId) {
        StringBuilder sb = new StringBuilder();
        return sb.append(MAP_JOB_SCARP_COUNT_KEY)
                .append(jobInfoId).toString();
    }


    public int updateScrapCount(int scrapCount) {
       this.scrapCount += scrapCount;
       return this.scrapCount;
    }

}

 

해당 코드는 JobInfo 도메인 해당하는 코드입니다. 

보다시피 컬럼이 엄청 많습니다. 초기에 사람인 API에서 가져올 때, 프론트에서 어떤 걸 사용할지 명확하게 알지 못하였고 그래서 보다시피 많은 컬럼을 저장해서 사용하고 있습니다. 아마 사용되지 않는 필드 값은 삭제할 예정입니다. 

또한 원래는 DB의 컬럼과 도메인에 있는 필드 값이 그대로 일치하는 것은 사실 객체 지향의 원칙이 안지켜질 확률이 높다고 합니다. 저도 이부분에 대해서 많이 부족하여 추가적으로 많이 학습할 것 같습니다. 

 

JobInfoAggregator 

package darkoverload.itzip.feature.job.domain.job;

import lombok.EqualsAndHashCode;

import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;

@EqualsAndHashCode
public class JobInfoAggregator {
    private final JobInfos apiJobInfos;
    private final JobInfos dbJobInfos;

    public JobInfoAggregator() {
        this.apiJobInfos = new JobInfos();
        this.dbJobInfos = new JobInfos();
    }

    public JobInfoAggregator(JobInfos apiJobInfos, JobInfos dbJobInfos) {
        this.apiJobInfos = apiJobInfos;
        this.dbJobInfos = dbJobInfos;
    }

    public static JobInfoAggregator create(JobInfos apiJobInfos, JobInfos dbJobInfos) {
        return new JobInfoAggregator(apiJobInfos, dbJobInfos);
    }

    public boolean isDbJobInfosEmpty() {
        return dbJobInfos.getJobInfos().isEmpty();
    }

    public JobInfoIds makeDeleteJobInfoIds() {
        return JobInfoIds.deleteIds(dbJobInfos.makeSetIds(), apiJobInfos.makeSetIds());
    }

    public JobInfos makeUpdateJobInfos() {
        return dbJobInfos.getUpdateJobInfos(apiJobInfos.maekJobInfoMap());
    }

    public JobInfos makeSaveJobInfos() {
        Set<Long> dbSet = new HashSet<>();
        dbJobInfos.getJobInfos().stream().map(JobInfo::getPositionId).forEach(dbSet::add);

        return new JobInfos(apiJobInfos.getJobInfos().stream()
                .filter(jobInfo -> !dbSet.contains(jobInfo.getPositionId()))
                .collect(Collectors.toList()));
    }

}

 

isDbJobInfosEmpty()

dbJobInfos가 비어 있는지 확인합니다.

 

 makeDeleteJobInfoIds()

DB에만 존재하고 API에 없는 데이터(JobInfo)의 positionId를 구합니다.

 

비즈니스 로직 흐름 

1. dbJobInfosapiJobInfospositionId를 각각 Set으로 변환합니다.

2. JobInfoIds.deleteIds() 메서드를 호출하여, DB에만 있는 ID들을 추출합니다.

 

 makeUpdateJobInfos()

API 데이터와 DB 데이터를 비교하여 업데이트가 필요한 JobInfo 객체를 추출합니다.

 

비즈니스 로직 흐름 

1. dbJobInfos의 데이터를 기준으로 apiJobInfos 데이터와 비교합니다.

2. 두 객체의 필드 값이 다를 경우, 업데이트 대상 리스트에 추가합니다.

3. 결과 리스트를 JobInfos로 반환합니다.

JobInfoAggregatorTest

package darkoverload.itzip.feature.job.domain.job;

import darkoverload.itzip.feature.job.mock.JobInfoMockData;
import darkoverload.itzip.feature.job.util.TimeStampUtil;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class JobInfoAggregatorTest {

    public JobInfo jobInfoUpdateData = JobInfo.builder()
            .active("1")
            .closeTypeCode("1")
            .closeTypeCode("접수마감일")
            .companyHref("http://www.saramin.co.kr/zf_user/company-info/view?csn=2208134781&utm_source=job-search-api&utm_medium=api&utm_campaign=saramin-job-search-api")
            .companyName("(주)이수시스템")
            .experienceCode("2")
            .experienceMin(5L)
            .experienceMax(10L)
            .experienceName("경력 5~10년")
            .expirationDate(TimeStampUtil.convertToLocalDateTime("1725448097"))
            .industryCode("301")
            .industryName("솔루션·SI·ERP·CRM")
            .jobCode("84,87,91,92,95,216,235,236,239,240,256,257,263,291,292,293,312,89,118,136,149,156,235,236,239,240,256,257,291,312")
            .jobMidCode("2")
            .jobMidName("IT개발·데이터")
            .jobName("소프트웨어개발,백엔드/서버개발,웹개발,퍼블리셔,프론트엔드,DBA,ECMAScript,Java,Javascript,jQuery,JSP,MyBatis,MySQL,OracleDB,Spring,SpringBoot,SQL,Vue.js,유지보수,솔루션,클라우드,ERP,IoT")
            .jobTypeCode("1")
            .jobTypeName("정규직")
            .keyword("소프트웨어개발")
            .locationCode("101000,101150")
            .locationName("서울 &gt; 서울전체,서울 &gt; 서초구")
            .positionId(48953038L)
            .expirationDate(TimeStampUtil.convertToLocalDateTime("1726757999"))
            .requiredEducationCode("8")
            .requiredEducationName("대학교졸업(4년)이상")
            .salaryCode("0")
            .salaryName("회사내규에 따름")
            .title("Cloud 인사시스템 개발자")
            .url("https://itzip.co.kr")
            .build();

    @Test
    void 정적_메서드_생성_테스트_코드() {

        JobInfoAggregator aggregator = JobInfoAggregator.create(new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)), new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)));

        assertThat(aggregator).isEqualTo(new JobInfoAggregator(new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)), new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree))));
    }

    @Test
    void 디비_일급_컬렉션_EMPTY_메소드_TRUE_테스트() {
        JobInfoAggregator aggregator = JobInfoAggregator.create(new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)), new JobInfos(List.of()));

        assertThat(aggregator.isDbJobInfosEmpty()).isTrue();
    }

    @Test
    void 디비_일급_컬렉션_EMPTY_메소드_False_테스트() {
        JobInfoAggregator aggregator = JobInfoAggregator.create(new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)), new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)));

        assertThat(aggregator.isDbJobInfosEmpty()).isFalse();
    }

    @Test
    void 채용_공고_삭제_처리_리스트_테스트() {
        JobInfoAggregator aggregator = JobInfoAggregator.create(new JobInfos(List.of(JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)), new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)));

        assertThat(aggregator.makeDeleteJobInfoIds()).isEqualTo(new JobInfoIds(List.of(48953038L)));
    }

    @Test
    @DisplayName("API 데이터 기준으로 반환해야함")
    void 채용_공고_업데이트_처리_리스트_테스트() {
        JobInfoAggregator aggregator = JobInfoAggregator.create(new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)), new JobInfos(List.of(jobInfoUpdateData, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)));

        assertThat(aggregator.makeUpdateJobInfos()).isEqualTo(new JobInfos(List.of(JobInfoMockData.jobInfoDataOne)));
    }

    @Test
    void 채용_저장_테스트() {
        JobInfoAggregator aggregator = JobInfoAggregator.create(new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond, JobInfoMockData.jobInfoDataThree)), new JobInfos(List.of(JobInfoMockData.jobInfoDataOne, JobInfoMockData.jobInfoDataSecond)));

        assertThat(aggregator.makeSaveJobInfos()).isEqualTo(new JobInfos(List.of(JobInfoMockData.jobInfoDataThree)));

    }

}

위에와 같이 테스트 코드를 작성하였다. 

 

최대한 end point 테스트를 하기 위해 노력하였다. 

 

자주 사용될 JoInfo를 JobInfoMockData 에서 관리해주는 클래스이다. 

package darkoverload.itzip.feature.job.mock;

import darkoverload.itzip.feature.job.domain.job.JobInfo;
import darkoverload.itzip.feature.job.util.TimeStampUtil;

public class JobInfoMockData {

    public static JobInfo jobInfoDataOne = JobInfo.builder()
            .active("1")
            .closeTypeCode("1")
            .closeTypeCode("접수마감일")
            .companyHref("http://www.saramin.co.kr/zf_user/company-info/view?csn=2208134781&utm_source=job-search-api&utm_medium=api&utm_campaign=saramin-job-search-api")
            .companyName("(주)이수시스템")
            .experienceCode("2")
            .experienceMin(5L)
            .experienceMax(10L)
            .experienceName("경력 5~10년")
            .expirationDate(TimeStampUtil.convertToLocalDateTime("1725448097"))
            .industryCode("301")
            .industryName("솔루션·SI·ERP·CRM")
            .jobCode("84,87,91,92,95,216,235,236,239,240,256,257,263,291,292,293,312,89,118,136,149,156,235,236,239,240,256,257,291,312")
            .jobMidCode("2")
            .jobMidName("IT개발·데이터")
            .jobName("소프트웨어개발,백엔드/서버개발,웹개발,퍼블리셔,프론트엔드,DBA,ECMAScript,Java,Javascript,jQuery,JSP,MyBatis,MySQL,OracleDB,Spring,SpringBoot,SQL,Vue.js,유지보수,솔루션,클라우드,ERP,IoT")
            .jobTypeCode("1")
            .jobTypeName("정규직")
            .keyword("소프트웨어개발")
            .locationCode("101000,101150")
            .locationName("서울 &gt; 서울전체,서울 &gt; 서초구")
            .positionId(48953038L)
            .expirationDate(TimeStampUtil.convertToLocalDateTime("1726757999"))
            .requiredEducationCode("8")
            .requiredEducationName("대학교졸업(4년)이상")
            .salaryCode("0")
            .salaryName("회사내규에 따름")
            .title("Cloud 인사시스템 개발자")
            .url("http://www.saramin.co.kr/zf_user/jobs/relay/view?rec_idx=48953038&utm_source=job-search-api&utm_medium=api&utm_campaign=saramin-job-search-api")
            .build();

    public static JobInfo jobInfoDataSecond = JobInfo.builder()
            .active("1")
            .closeTypeCode("1")
            .closeTypeCode("접수마감일")
            .companyHref("http://www.saramin.co.kr/zf_user/company-info/view?csn=2118848320&utm_source=job-search-api&utm_medium=api&utm_campaign=saramin-job-search-api")
            .companyName("(주)넥스트테크놀로지")
            .experienceCode("1")
            .experienceMin(0L)
            .experienceMax(0L)
            .experienceName("신입")
            .expirationDate(TimeStampUtil.convertToLocalDateTime("1725448097"))
            .industryCode("308")
            .industryName("정보보안·백신")
            .jobCode("90,715,719,751,796,798,2203")
            .jobMidCode("2,8")
            .jobMidName("IT개발·데이터,영업·판매·무역")
            .jobName("IDS·IPS,네트워크보안,방화벽,보안,정보보안,네트워크영업,솔루션기술영업,IT영업,H/W,S/W,영업")
            .jobTypeCode("1")
            .jobTypeName("정규직")
            .keyword("IDS·IPS,네트워크보안,방화벽,보안,정보보안")
            .locationCode("101000")
            .locationName("서울 &gt; 서울전체")
            .positionId(48953031L)
            .expirationDate(TimeStampUtil.convertToLocalDateTime("1726757999"))
            .requiredEducationCode("7")
            .requiredEducationName("대학졸업(2,3년)이상")
            .salaryCode("99")
            .salaryName("면접후 결정")
            .title("보안솔루션 및 네트워크장비 영업")
            .url("http://www.saramin.co.kr/zf_user/jobs/relay/view?rec_idx=48953031&utm_source=job-search-api&utm_medium=api&utm_campaign=saramin-job-search-api")
            .build();

    public static JobInfo jobInfoDataThree = JobInfo.builder()
            .active("1")
            .closeTypeCode("1")
            .closeTypeCode("접수마감일")
            .companyHref("http://www.saramin.co.kr/zf_user/company-info/view?csn=8558702525&utm_source=job-search-api&utm_medium=api&utm_campaign=saramin-job-search-api")
            .companyName("(주)다담서포트")
            .experienceCode("2")
            .experienceMin(3L)
            .experienceMax(15L)
            .experienceName("경력 3~15년")
            .expirationDate(TimeStampUtil.convertToLocalDateTime("1725448097"))
            .industryCode("304")
            .industryName("쇼핑몰·오픈마켓")
            .jobCode("209,1483,1487,1490,1501,1504,1513,1600,1611,1614,1981,291")
            .jobMidCode("2,15,21")
            .jobMidName("IT개발·데이터,디자인,고객상담·TM")
            .jobName("쇼핑몰,오픈마켓,전자상거래,CSS,광고디자인,로고디자인,문구디자인,완구디자인,일러스트레이터,컨셉디자인,일러스트,HTML,PhotoShop,고객관리,Spring")
            .jobTypeCode("1")
            .jobTypeName("정규직")
            .keyword("쇼핑몰,오픈마켓,전자상거래")
            .locationCode("101040")
            .locationName("서울 &gt; 강서구")
            .positionId(48952926L)
            .expirationDate(TimeStampUtil.convertToLocalDateTime("1726757999"))
            .requiredEducationCode("0")
            .requiredEducationName("학력무관")
            .salaryCode("99")
            .salaryName("면접후 결정")
            .title("[베스트냅킨] 일회용품 쇼핑몰 편집 디자이너 모집")
            .url("http://www.saramin.co.kr/zf_user/jobs/relay/view?rec_idx=48952926&utm_source=job-search-api&utm_medium=api&utm_campaign=saramin-job-search-api")
            .build();

}

 

 

JobInfos 클래스

package darkoverload.itzip.feature.job.domain.job;

import lombok.EqualsAndHashCode;
import lombok.ToString;

import java.util.*;
import java.util.stream.Collectors;

@ToString
@EqualsAndHashCode
public class JobInfos {

    private final List<JobInfo> jobInfos;

    public JobInfos() {
        this.jobInfos = new ArrayList<>();
    }

    public JobInfos(List<JobInfo> jobInfos) {
        this.jobInfos = jobInfos;
    }

    public void add(JobInfo jobInfo) {
        jobInfos.add(jobInfo);
    }

    public int size() {
        return jobInfos.size();
    }

    public List<JobInfo> subList(int start, int end) {
        return Collections.unmodifiableList(jobInfos.subList(start, end));
    }

    public List<JobInfo> getJobInfos() {
        return jobInfos;
    }

    public Map<Long, JobInfo> maekJobInfoMap() {
        return jobInfos.stream()
                .collect(Collectors.toMap(
                        JobInfo::getPositionId,
                        jobInfo -> jobInfo,
                        (existing, replacement) -> replacement
                ));
    }

    public JobInfos getUpdateJobInfos(Map<Long, JobInfo> apiDataMap) {
        JobInfos updateJobInfos = new JobInfos();
        jobInfos.forEach(jobInfo -> {
            JobInfo apiJobInfo = apiDataMap.get(jobInfo.getPositionId());

            if(apiJobInfo != null && checkNotEquals(jobInfo, apiJobInfo)) {
                updateJobInfos.add(apiJobInfo);
            }
        });

        return updateJobInfos;
    }

    /**
     * 두 JobInfo 객체의 필드를 비교하여, 모든 필드가 다른 경우 true를 반환합니다.
     * 만약 하나라도 같은 필드가 있으면 false를 반환합니다.
     *
     * @param dbJobInfo  데이터베이스에서 조회한 JobInfo 객체
     * @param apiJobInfo API로부터 가져온 JobInfo 객체
     * @return 모든 필드가 서로 다른 경우 true, 하나라도 같은 필드가 있는 경우 false
     */
    private boolean checkNotEquals(JobInfo dbJobInfo, JobInfo apiJobInfo) {

        return !dbJobInfo.getActive().equals(apiJobInfo.getActive()) // active 필드 비교
                || !dbJobInfo.getUrl().equals(apiJobInfo.getUrl()) // URL 필드 비교
                || !dbJobInfo.getTitle().equals(apiJobInfo.getTitle()) // 제목 필드 비교
                || !dbJobInfo.getIndustryCode().equals(apiJobInfo.getIndustryCode()) // 산업 코드 비교
                || !dbJobInfo.getIndustryName().equals(apiJobInfo.getIndustryName()) // 산업 이름 비교
                || !dbJobInfo.getLocationName().equals(apiJobInfo.getLocationName()) // 위치 이름 비교
                || !dbJobInfo.getJobMidCode().equals(apiJobInfo.getJobMidCode()) // 중간 직무 코드 비교
                || !dbJobInfo.getJobMidName().equals(apiJobInfo.getJobMidName()) // 중간 직무 이름 비교
                || !dbJobInfo.getJobName().equals(apiJobInfo.getJobName()) // 직무 이름 비교
                || !dbJobInfo.getJobCode().equals(apiJobInfo.getJobCode()) // 직무 코드 비교
                || !dbJobInfo.getExperienceCode().equals(apiJobInfo.getExperienceCode()) // 경력 코드 비교
                || !dbJobInfo.getExperienceName().equals(apiJobInfo.getExperienceName()) // 경력 이름 비교
                || !dbJobInfo.getSalaryName().equals(apiJobInfo.getSalaryName()) // 급여 이름 비교
                || !dbJobInfo.getExpirationDate().equals(apiJobInfo.getExpirationDate()) // 만료 날짜 비교
                || !dbJobInfo.getCloseTypeCode().equals(apiJobInfo.getCloseTypeCode()); // 마감 유형 코드 비교
    }

    public Set<Long> makeSetIds() {
        return jobInfos.stream()
                .map(JobInfo::getPositionId)
                .collect(Collectors.toSet());
    }

}

 

subList(int start, int end) 

  • 리스트의 특정 범위 데이터를 반환합니다.
  • 반환된 리스트는 읽기 전용(unmodifiable)입니다.

 

makeJobInfoMap()

  • jobInfos 리스트를 Map<Long, JobInfo> 형태로 변환합니다.
  • 키는 positionId, 값은 JobInfo 객체입니다.
  • 동일한 positionId가 여러 개 있을 경우, 나중 값(replacement)이 덮어씌워집니다.

 

 getUpdateJobInfos(Map<Long, JobInfo> apiDataMap)

  • 각 JobInfo 객체의 positionId를 기준으로 apiDataMap에서 데이터를 검색합니다.
  • checkNotEquals 메서드로 필드 값이 다른지 비교합니다.
  • 다른 값이 있는 경우, 해당 apiJobInfo를 결과 리스트에 추가합니다.

 

해당 클래스는 테스트 코드를 작성하지 않았다. 중복 테스트이기도하고, 이미 앞에 테스트 코드를 작성하면 충분히 검증이 된다고 판단하였다. 

 

JobInfoIds 클래스 

package darkoverload.itzip.feature.job.domain.job;

import lombok.EqualsAndHashCode;
import lombok.ToString;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;

@ToString
@EqualsAndHashCode
public class JobInfoIds {

    private final List<Long> idList;

    public JobInfoIds() {
        this.idList = new ArrayList<>();
    }

    public JobInfoIds(List<Long> idList) {
        this.idList = idList;
    }

    public static JobInfoIds deleteIds(Set<Long> dbIdSet, Set<Long> apiSet) {
        List<Long> deleteList = dbIdSet.stream()
                .filter(id -> !apiSet.contains(id))
                .toList();

        return new JobInfoIds(deleteList);
    }

    public List<Long> subList(int start, int end) {
        return Collections.unmodifiableList(idList.subList(start, end));
    }

    public int size() {
        return idList.size();
    }

    public List<Long> getIdList() {
        return idList;
    }
}

 

 public static JobInfoIds deleteIds(Set<Long> dbIdSet, Set<Long> apiSet)

  • DB에 존재하지만 API에 없는 ID를 추출하여 삭제 대상 리스트를 생성

 

JobInfoIdsTest

package darkoverload.itzip.feature.job.domain.job;

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

class JobInfoIdsTest {

    @Test
    void 정적_메소드_deleteIds_테스트() {
        JobInfoIds result = JobInfoIds.deleteIds(Set.of(1L, 2L, 3L, 4L, 5L), Set.of(3L, 4L, 5L, 6L));
        assertThat(result.size()).isEqualTo(2);
    }

    @Test
    void 리스트_자르기_테스트() {
        JobInfoIds jobInfoIds = new JobInfoIds(List.of(1L, 2L, 3L, 4L, 5L));

        assertThat(jobInfoIds.subList(0, 3)).isEqualTo((List.of(1L, 2L, 3L)));
    }

}

JobInfoIds 테스트를 위해 작성한 코드이다. 

size를 통해서도 충분히 검증이 가능하다는 생각으로 작성했다. isEqualTo를 쓰면 Set 은 순서가 보장되는 자료구조가 아니기에 size로 측정하였다.