Project/Findex

Findex - 개인 개발 리포트

승주우에요 2026. 3. 20. 10:26

1.  프로젝트 개요

Findex는 외부 API를 활용하여 금융 지수 데이터를 수집하고, 이를 효율적으로 관리 및 시각화하는 금융 분석 대시보드 프로젝트입니다.
본 프로젝트에서는 다양한 금융 지수 데이터를 API를 통해 연동하고, 데이터베이스에 저장한 뒤 사용자에게 한눈에 확인할 수 있는 형태로 제공하는 기능을 구현하였습니다.

외부 데이터 수집, 데이터 처리 및 저장, 그리고 사용자에게 제공하는 전체 흐름을 직접 설계하고 구현하는 경험을 목표로 한 초급 프로젝트입니다.

 

 

2.  담당한 작업

지수 정보 Controller - Service - Repository 구현하였습니다.
  1. 지수 정보 외부 API 연동
  • 공공 데이터 포털 API로 부터 응답 데이터를 수신한 후, 이를 지수 정보 엔티티로 변환하는 로직을 구현하였습니다. 또한 연동 과정에서 발생하는 이력 추적을 위해 연동 이력(SyncJob)을 생성하고, 자동 연동 설정(AutoSyncConfig)까지 함께 생성,저장하도록 설계하였습니다.
  1. 지수 정보 CRUD 기능 구현
  • 단건 조회, 수정, 삭제, 기능 구현
  • 다건 조회 시 페이지네이션 적용
  • QueryDSL을 활용하여 동적 조건 및 정렬 처리 등 쿼리 성능 개선
  1. 데이터베이스 연결 및 환경 설정
  • 팀원이 Railway로 배포한 PostgreSQL 데이터베이스를 애플리케이션과 연결하였습니다. 이때, 데이터베이스의 주소, 비밀번호는 환경변수로 분리해 따로 관리하도록 했습니다. 또한, 환경에 따라 설정을 분리하기 위해 YAML 설정 파일을 개발용/배포용으로 분리하여 관리하였습니다.
  1. 이슈 해결 및 개선 작업
    서비스 배포 후 이슈 대응을 담당하였습니다
  • 지수 데이터 목록 조회 시 데이터가 섞이고 끊겨 보이던 문제를 대응해 해결했습니다.
    • 정렬 기준 및 조회 로직을 개선하여 일관된 데이터 출력을 보장하였습니다
  • 지수 차트에서 날짜가 내림차순으로 출력되던 문제 해결
    • 시계열 데이터의 정렬 기준을 오름차순으로 수정해 정상 출력되도록 개선하였습니다.

    5. PR 리뷰

  •  3/13-16일 PR 리뷰를 담당하였습니다

3.  기술적 성과

  • Sprint Boot : Rest API 기반 서버를 빠르게 구축하기 위해 사용했습니다. 설정 자동화와 내장 서버를 통해 개발 생산성을 높이고, 외부 API 연동 및 데이터 처리 로직을 안정적으로 구현하기 위해서 사용했습니다.

 

  • Sprint Data JPA : 반복적인 CRUD 작성을 줄이고, 객체 중심으로 데이터를 관리하기 위해 사용했습니다. 엔티티 기반 설계를 통해 도메인 구조를 명확히 하고 개발 효율성을 향상할 수 있었습니다.

 

  • QueryDSL : 동적 조건 검색 및 정렬이 필요한 복잡한 조회를 안정적으로 처리하기 위해 사용했습니다. 쿼리문 대신 자바 문법처럼 사용해 편리하다는 점을 느꼈고, 잘못된 쿼리문을 작성하면 런타임 중에 오류가 발생하는 데 이를 줄일 수 있었습니다.

 

  • MapStruct : Entity, DTO 간 변환 시에 발생하는 반복 코드를 줄이기 위해 사용했습니다. 컴파일 타임에 코드를 생성해 성능 저하 없이 변환처리가 가능했습니다.

 

  • PostgreSQL : 실제 서비스 환경과 유사한 구조에서 데이터 저장, 조회를 수행하기 위해 선택했습니다.

 

  • Railway : 별도의 인프라 구축없이 데이터베이스를 빠르게 배포하고 연결하기 위해 사용했습니다. 클라우드 환경에서 DB와 서비스를 배포했습니다.

 

 

4.  문제점 및 해결 과정

  1. 지수 정보 삭제 시에 서비스 레이어에 관련 엔티티들 삭제하는 로직을 분명히 추가했음에도 불구하고 삭제가 수행되지 않았습니다. 처음에는 서비스 로직이나 삭제 순서의 문제라고 판단했지만, 실제 원인은 스키마 설계에 있었습니다.
  • 지수 정보 삭제 시 연관된 엔티티들의 로직을 점검하고, 실패의 원인을 파악하여 삭제가 이루어지도록 수정해야 했습니다.

 

  • 삭제 로직 자체는 서비스 레이어에 이미 구현되어 있었기 때문에, 실제로는 엔티티 연관관계와 제약 조건을 중심으로 원인을 분석했습니다. 그 결과 삭제되면 안되는 이력 엔티티에 대해 외래 키가 nullable=false로 설정되어 있었고, 지수 정보 삭제 시 해당 FK가 null처리 되어야 하는 제약 조건 오류가 발생하고 있음을 확인했습니다.

 

  • 엔티티 설계를 수정한 이후에 지수 정보 삭제가 정상적으로 수행되었고, 서비스 의도에 맞게 정리할 수 있었습니다.

 

  • 이를 통해 서비스 로직만 올바르다고 해서 기능이 정상 동작하는 것은 아니며, 엔티티 설계와 FK 제약 조건을 고려해야한다는 점을 배웠습니다. 또한, 왜 삭제가 안되었는지에 대해 생각을 해보았는데 @Transactional 애노테이션이 적용되어 있었기 때문에 예외가 발생하자 전체 삭제 작업이 모두 롤백되면서 삭제가 되지 않았다는 점을 점검하게 되었습니다.

 

 

5.  협업 및 피드백

  • 팀원이 한명빼고, 모두 I여서, 처음에 소통할 때 힘들었던 것 같습니다 ㅎㅎ 이는 따로 피드백은 하지 않았고, 팀 룰로 의견 적극적으로 내기를 추가했고, 시간이 해결해주었습니다.
  • 팀원들과 기능 단위로 역할을 분리하였고, feature 브랜치에서 개발 후 develop 브랜치로 변합하는 방식으로 관리하였으며, 충돌 발생 시 팀원 간 소통을 통해 빠르게 해결하였습니다.
  • 코드 리뷰 과정에서는 주석 처리, 명세서에 빠진 parameter 등에 대한 피드백을 주고 받으며 코드 품질을 개선했습니다.
  • 코드 리뷰 시에는 반드시 리뷰어 2명을 두었고, 두 명에게 모두 승인을 받고 develop 브랜치에 merge하는 형식으로 진행했습니다.

 

 

6.  코드 품질 및 최적화

  • 코드 가독성 및 유지 보수성 고려
    • 주석을 적극적으로 활용하였습니다. 엔티티에서는 경제 용어가 많아 "이름/ 뜻"의 형태로 팀원 모두가 이해할 수 있도록 작성하였습니다.
    • 서비스 레이어의 비즈니스 로직을 활용할 때는 메서드 전체에 어떤 기능을 하는 지 작성했고, 로직 부분에서는 단계 별로 목적이 드러나도록 주석을 추가했습니다.
/*
    지수 정보 연동 Api
     */
    @Transactional
    public List<SyncJobDto> syncIndexInfos(String worker) {
        // 기준 날짜 변경 : 어제 날짜
        LocalDate today = LocalDate.now().minusDays(1);
        String baseDate = today.format(DateTimeFormatter.BASIC_ISO_DATE);

        // 외부 API 호출 후 동기적으로 응답 받음 (.block() = 응답 올 때까지 대기)
        StockMarketIndexResponseDto response = findexOpenApiClient
                .fetchStockIndexInfo(baseDate).block();

        // 응답이 null이거나 내부 데이터가 없으면 빈 리스트 반환 (빈 리스트 이슈 처리)
        if (response == null
                || response.response() == null
                || response.response().body() == null
                || response.response().body().items() == null
                || response.response().body().items().item() == null
                || response.response().body().items().item().isEmpty()) {
            return List.of();
        }

        // 응답에서 실제 지수 목록만 꺼냄
        List<StockMarketIndexResponseDto.IndexItem> items =
                response.response().body().items().item();

        // API 응답의 각 항목을 "분류::이름" 형태의 key Set으로 만듦 (DB 조회용)
        Set<String> targetKeys = items.stream()
                .map(item -> item.indexClassification() + "::" + item.indexName())
                .collect(Collectors.toSet());

        // DB에서 전체 지수 조회 후, API 응답에 해당하는 것만 필터링해서 Map으로 구성
        // key = "분류::이름", value = IndexInfo 엔티티
        // (a, b) -> a : 혹시 중복 key 있으면 먼저 나온 것 유지
        Map<String, IndexInfo> existingIndexMap = indexInfoRepository.findAll().stream()
                .filter(info -> targetKeys.contains(info.getIndexClassification() + "::" + info.getIndexName()))
                .collect(Collectors.toMap(
                        info -> info.getIndexClassification() + "::" + info.getIndexName(),
                        info -> info,
                        (a, b) -> a
                ));

        // 최종 저장할 지수들을 담는 Map (중복 key면 마지막 상태로 덮어씀)
        Map<String, IndexInfo> toSaveMap = new LinkedHashMap<>();
        // 신규 생성된 지수만 따로 모음 (AutoSyncConfig 생성 대상)
        List<IndexInfo> newIndexInfos = new ArrayList<>();
        // 연동 이력 저장용 리스트
        List<SyncJob> syncJobs = new ArrayList<>();

        for (StockMarketIndexResponseDto.IndexItem item : items) {
            // 현재 처리 중인 item의 key 생성
            String key = item.indexClassification() + "::" + item.indexName();

            try {
                // API 응답의 기준일자를 LocalDate로 변환
                LocalDate targetDate = LocalDate.parse(item.baseDate(), DateTimeFormatter.BASIC_ISO_DATE);
                // API 응답의 기준 시점을 LocalDate로 변환
                LocalDate basePointInTime = LocalDate.parse(item.basePointTime(), DateTimeFormatter.BASIC_ISO_DATE);

                // Map에서 기존 지수 조회 (DB 호출 없음)
                IndexInfo indexInfo = existingIndexMap.get(key);

                if (indexInfo != null) {
                    // 기존 지수가 있으면 → 정보 업데이트
                    // 즐겨찾기(favorite)는 기존 값 유지
                    IndexInfoUpdateRequestDto request = new IndexInfoUpdateRequestDto(
                            item.employedItemsCount(),
                            basePointInTime,
                            BigDecimal.valueOf(item.baseIndex()),
                            indexInfo.isFavorite()
                    );
                    indexInfo.update(request);
                    // sourceType을 OPEN_API로 변경
                    indexInfo.updateSourceType(SourceType.OPEN_API);
                } else {
                    // 기존 지수가 없으면 → 신규 생성
                    indexInfo = IndexInfo.builder()
                            .indexClassification(item.indexClassification())
                            .indexName(item.indexName())
                            .employedItemsCount(item.employedItemsCount())
                            .basePointInTime(basePointInTime)
                            .baseIndex(BigDecimal.valueOf(item.baseIndex()))
                            .sourceType(SourceType.OPEN_API)
                            .favorite(false)
                            .build();

                    // 같은 배치 내에서 동일 key가 또 나와도 중복 생성 방지
                    existingIndexMap.put(key, indexInfo);
                    // AutoSyncConfig 생성 대상에 추가
                    newIndexInfos.add(indexInfo);
                }

                // 동일 key는 마지막 상태로 1회만 저장되도록 Map에 put
                toSaveMap.put(key, indexInfo);

                // 성공 연동 이력 생성
                syncJobs.add(SyncJob.builder()
                        .jobType(JobType.INDEX_INFO)
                        .indexInfo(indexInfo)
                        .targetDate(targetDate)
                        .worker(worker)
                        .jobTime(LocalDateTime.now())
                        .result(JobResult.SUCCESS)
                        .build());

            } catch (Exception e) {
                // 처리 중 예외 발생 시 → 실패 연동 이력 생성 후 다음 item 계속 진행
                syncJobs.add(SyncJob.builder()
                        .jobType(JobType.INDEX_INFO)
                        .indexInfo(null)
                        .targetDate(null)
                        .worker(worker)
                        .jobTime(LocalDateTime.now())
                        .result(JobResult.FAILED)
                        .build());
            }
        }

        // 지수 정보 일괄 저장 (UPDATE + INSERT 한 번에)
        indexInfoRepository.saveAll(toSaveMap.values());
        // 연동 작업 저장
        syncJobRepository.saveAll(syncJobs);

        // 신규 지수에 대해서만 자동 연동 설정 생성
        if (!newIndexInfos.isEmpty()) {
            List<AutoSyncConfig> autoSyncConfigsToSave = newIndexInfos.stream()
                    .map(info -> AutoSyncConfig.builder()
                            .indexInfo(info)
                            .enabled(false)
                            .build())
                    .toList();
            autoSyncConfigRepository.saveAll(autoSyncConfigsToSave);
        }

        // 저장된 연동 이력을 DTO로 변환하여 반환
        return syncJobs.stream()
                .map(syncJobMapper::toDto)
                .toList();
    }
  • 초기에는 외부 API로 부터 밭은 데이터를 항목마다 for문을 통해 DB에서 find를 수행하고 이후 save하는 방식으로 구현했습니다. 이 방식은 구현하기 단순하지만, 데이터 개수가 많아질수록 DB 접근 횟수가 급격히 증가하는 문제가 발생하였고, 실제로 성능 저하의 원인이 되었습니다.
  • 이를 해결하기 위해 먼저 API 응답 데이터를 기반으로 식별할 수 있는 key 집합을 구성한 후, 한 번의 조회로 가져와 Map 형태로 메모리에 캐싱하는 방식으로 구조를 개선했습니다. 최종적으로는 saveAll을 활용해 배치 처리 방식으로 일괄 저장하도록 변경해, 불필요한 DB 호출을 제거하고 전체 처리 성능을 개선하였습니다.

 

 

7.  향후 개선 사항 및 제안

  • 페이지네이션 응답 Dto를 관리할 때 엔티티 별로 공통된 부분이 있어 이를 한번에 관리하도록 수정할 수 있을 것 같습니다.
  • 페이지네이션 성능이 그렇게 좋지 않은 것 같아 이를 수정할 필요가 있는 것 같습니다.
  • 프론트엔드 쪽에 오류가 있어 이에 대해 대응도 해야합니다.

'Project > Findex' 카테고리의 다른 글

Findex 프로젝트 회고록  (0) 2026.03.20