Findex.. 개발참여기..

Jihye Gim·2026년 4월 24일

Codeit SB11

목록 보기
12/22

1편 - 프로젝트 소개 & 환경 설정

  • Findex 소개, 기술스택
  • 개발 환경 설정 트러블슈팅 (PUBLIC_DATA_BASE_URL 문제 등)

2편 - IndexData 엔티티 & CRUD 구현

  • 엔티티 설계, Repository
  • 등록/수정/삭제 API 구현

3편 - 목록 조회 & 커서 페이지네이션

  • QueryDSL 동적 쿼리
  • 복합 커서 페이지네이션 설계 및 구현
  • IllegalArgumentException → ApiException 변환

4편 - CSV Export 구현 & 성능 최적화

  • Stream 기반 대용량 처리
  • fetchSize=1000 성능 비교 (TTFB, Heap)
  • N+1 문제 해결 (JOIN FETCH)

5편 - CSV Export 개선 & 버그 수정

  • 음수 값 작은따옴표 제거
  • 한글 인코딩 깨짐 수정
  • 방어선 구축 (Iterator 방식)

6편 - Validation 리팩토링

  • @AssertTrue 패턴으로 DTO 내부 이동
  • 트러블슈팅 정리

7편 - Git 협업 & 회고

  • Issue/PR/코드리뷰 경험
  • 개인 회고

[Findex 개발기] 1편 - 프로젝트 소개 & 환경 설정

프로젝트 소개

Findex는 금융위원회 공공데이터 API를 활용한 주가지수 데이터 연동 서비스예요. 팀 프로젝트로 진행했으며 저는 M2 담당으로 IndexData 도메인을 맡았어요.

기술스택

팀 구성
M1- 리더! 프로젝트 총괄, 초기셋팅, 공통 유틸, 지수정보(IndexInfo), 배포
나 ->M2- 지수데이터(IndexData)
M3- OpenAPI 클라이언트, 지수 정보 연동
M4- 연동작업(SyncJob)
M5- 자동 연동 설정, 배치
M6- 대시보드

프로젝트 요구사항

개발 환경 설정 트러블슈팅

문제: API 연동이 안 됨

POST /api/sync-jobs/index-infos 호출 시 계속 실패했어요.

원인 IntelliJ 환경변수 PUBLIC_DATA_BASE_URL이 잘못 설정되어 있었어요.

# 잘못된 설정
PUBLIC_DATA_BASE_URL=https://apis.data.go.kr

# 올바른 설정
PUBLIC_DATA_BASE_URL=https://apis.data.go.kr/1160100/service/GetMarketIndexInfoService

해결 IntelliJ Run Configuration에서 환경변수 전체 경로로 수정 후 정상 동작 확인했어요.


느낀 점

환경 설정 하나 때문에 API 연동이 안 되는 경험을 하면서 로컬 환경변수 관리의 중요성을 느꼈어요. 앞으로는 환경변수 설정 시 전체 URL을 꼼꼼히 확인하는 습관을 들여야겠다고 생각했어요.


[Findex 개발기] 2편 - IndexData 엔티티 & CRUD 구현

IndexData 엔티티 설계

IndexData는 주가지수 데이터를 저장하는 핵심 엔티티예요. IndexInfo와 다대일 관계로 설계했어요.

java

@Entity
@Table(name = "index_data")
public class IndexData extends BaseEntity {

    @Id
    private final UUID id = UUID.randomUUID();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "index_info_id", nullable = false)
    private IndexInfo indexInfo;

    private LocalDate baseDate;

    @Enumerated(EnumType.STRING)
    private SourceType sourceType;

    private BigDecimal marketPrice;
    private BigDecimal closingPrice;
    private BigDecimal highPrice;
    private BigDecimal lowPrice;
    private BigDecimal versus;
    private BigDecimal fluctuationRate;
    private Long tradingQuantity;
    private BigDecimal tradingPrice;
    private BigDecimal marketTotalAmount;
}

설계 포인트

UUID를 ID로 선택한 이유는 두 가지예요.

  • 분산 환경에서 DB 없이도 고유한 ID 생성 가능
  • 순차적 숫자 ID보다 보안상 안전 (예측 불가)

final로 선언한 이유는 ID는 한 번 생성되면 변경되면 안 되는 불변 값이기 때문이에요.


CRUD 구현

등록 API

java

@Transactional
public IndexDataResponse create(IndexDataCreateRequest request) {
    IndexInfo indexInfo = indexInfoRepository.findById(request.indexInfoId())
        .orElseThrow(() -> new ApiException(ERROR.INDEX_INFO_NOT_FOUND));

    if (indexDataRepository.existsByIndexInfoAndBaseDate(indexInfo, request.baseDate())) {
        throw new ApiException(ERROR.INDEX_DATA_DUPLICATED);
    }

    IndexData indexData = IndexData.builder()
        .indexInfo(indexInfo)
        .baseDate(request.baseDate())
        .sourceType(request.sourceType() != null ? request.sourceType() : SourceType.USER)
        // ...
        .build();

    try {
        return indexDataMapper.toResponse(indexDataRepository.save(indexData));
    } catch (DataIntegrityViolationException e) {
        throw new ApiException(ERROR.INDEX_DATA_DUPLICATED);
    }
}

포인트

  • 중복 체크를 existsByIndexInfoAndBaseDate()로 먼저 확인
  • DB 레벨 Unique 제약 위반 시 DataIntegrityViolationException catch로 이중 방어

수정 API

java

@Transactional
public IndexDataResponse update(UUID id, IndexDataUpdateRequest request) {
    IndexData indexData = indexDataRepository.findById(id)
        .orElseThrow(() -> new ApiException(ERROR.INDEX_DATA_NOT_FOUND));

    indexData.update(
        request.marketPrice(),
        request.closingPrice(),
        // ...
    );

    return indexDataMapper.toResponse(indexData);
}

포인트

  • 더티 체킹을 활용해서 별도 save() 호출 없이 수정
  • 엔티티 내부 update() 메서드로 캡슐화

삭제 API

java

@Transactional
public void delete(UUID id) {
    IndexData indexData = indexDataRepository.findById(id)
        .orElseThrow(() -> new ApiException(ERROR.INDEX_DATA_NOT_FOUND));

    indexDataRepository.delete(indexData);
}

트러블슈팅: MapStruct 매핑 문제

문제

MapStruct로 엔티티 → DTO 변환 시 중첩 객체 매핑이 안 됐어요.

원인

IndexData에서 IndexInfo의 필드를 DTO에 매핑할 때 명시적 매핑 설정이 필요했어요.

해결

java

@Mapper(componentModel = "spring")
public interface IndexDataMapper {

    @Mapping(source = "indexInfo.indexClassification", target = "indexClassification")
    @Mapping(source = "indexInfo.indexName", target = "indexName")
    IndexDataResponse toResponse(IndexData indexData);
}

느낀 점

처음에는 단순한 CRUD라고 생각했는데 중복 체크 이중 방어, 더티 체킹 활용, MapStruct 매핑 설정 등 생각보다 신경 써야 할 부분이 많았어요. 특히 DataIntegrityViolationException 이중 방어는 멘토님 리뷰를 통해 배운 좋은 패턴이었어요.


[Findex 개발기] 3편 - 목록 조회 & 커서 페이지네이션

목록 조회 API 설계

IndexData 목록 조회는 다양한 필터 조건과 정렬, 페이지네이션을 지원해야 했어요.

조회 조건

  • indexInfoId (선택)
  • startDate / endDate (선택)
  • sortField (baseDate, marketPrice 등)
  • sortDirection (ASC / DESC)
  • 커서 페이지네이션

커서 페이지네이션이란?

일반적인 offset 페이지네이션은 이런 문제가 있어요.

sql

SELECT * FROM index_data LIMIT 20 OFFSET 10000

offset이 클수록 앞의 데이터를 전부 읽고 버리기 때문에 성능이 떨어져요.

커서 페이지네이션은 마지막으로 읽은 데이터의 커서값을 기준으로 다음 데이터를 가져와요.

sql

SELECT * FROM index_data
WHERE base_date < :lastBaseDate
   OR (base_date = :lastBaseDate AND id < :lastId)
ORDER BY base_date DESC, id DESC
LIMIT 20

데이터가 아무리 많아도 항상 일정한 성능을 유지할 수 있어요.


QueryDSL 동적 쿼리 구현

필터 조건이 선택적이라서 QueryDSL로 동적 쿼리를 작성했어요.

java

@Override
public CursorPageResponse<IndexDataResponse> findAll(IndexDataQueryCondition condition) {

    BooleanBuilder builder = new BooleanBuilder();

    // indexInfoId 필터
    if (condition.indexInfoId() != null) {
        builder.and(indexData.indexInfo.id.eq(condition.indexInfoId()));
    }

    // 날짜 범위 필터
    if (condition.startDate() != null) {
        builder.and(indexData.baseDate.goe(condition.startDate()));
    }
    if (condition.endDate() != null) {
        builder.and(indexData.baseDate.loe(condition.endDate()));
    }

    // 커서 조건
    if (condition.cursor() != null) {
        builder.and(cursorCondition(condition));
    }

    List<IndexData> result = queryFactory
        .selectFrom(indexData)
        .join(indexData.indexInfo, indexInfo).fetchJoin()
        .where(builder)
        .orderBy(getOrderSpecifier(condition))
        .limit(condition.size() + 1)
        .fetch();

    // 다음 페이지 존재 여부 확인
    boolean hasNext = result.size() > condition.size();
    if (hasNext) {
        result.remove(result.size() - 1);
    }

    return CursorPageResponse.of(result, hasNext, indexDataMapper::toResponse);
}

복합 커서 설계

단일 필드 커서는 중복값이 있을 때 문제가 생겨요.

예를 들어 baseDate만 커서로 쓰면 같은 날짜 데이터가 많을 때 일부가 누락될 수 있어요.

그래서 baseDate + id 복합 커서를 사용했어요.

java

private BooleanExpression cursorCondition(IndexDataQueryCondition condition) {
    LocalDate cursorDate = condition.cursorDate();
    UUID cursorId = condition.cursorId();

    if (condition.sortDirection() == SortDirection.ASC) {
        return indexData.baseDate.gt(cursorDate)
            .or(indexData.baseDate.eq(cursorDate)
                .and(indexData.id.gt(cursorId)));
    } else {
        return indexData.baseDate.lt(cursorDate)
            .or(indexData.baseDate.eq(cursorDate)
                .and(indexData.id.lt(cursorId)));
    }
}

트러블슈팅: IllegalArgumentException 처리

문제

잘못된 sortField 값이 들어왔을 때 QueryDSL에서 IllegalArgumentException이 발생했어요. 근데 GlobalExceptionHandler에서 처리를 못 하고 500 에러가 반환됐어요.

원인

IllegalArgumentExceptionApiException이 아니라서 핸들러가 잡지 못했어요.

해결

서비스 레이어에서 sortField 유효성 검사를 추가하고 ApiException으로 변환했어요.

java

private OrderSpecifier<?> getOrderSpecifier(IndexDataQueryCondition condition) {
    return switch (condition.sortField()) {
        case "baseDate" -> condition.isAsc()
            ? indexData.baseDate.asc()
            : indexData.baseDate.desc();
        case "marketPrice" -> condition.isAsc()
            ? indexData.marketPrice.asc()
            : indexData.marketPrice.desc();
        default -> throw new ApiException(ERROR.INVALID_SORT_FIELD);
    };
}

느낀 점

커서 페이지네이션은 처음에 개념이 어려웠는데 직접 구현하면서 왜 대용량 서비스에서 사용하는지 이해할 수 있었어요. 특히 복합 커서 설계에서 중복값 처리를 고려하지 않으면 데이터 누락이 생긴다는 점이 인상 깊었어요. 멘토님 리뷰에서 NULLS FIRST/LAST 정책도 명시하면 좋다는 피드백을 받아서 다음에는 꼭 적용해봐야겠어요.


[Findex 개발기] 4편 - CSV Export 구현 & 성능 최적화

CSV Export 기능 소개

IndexData를 CSV 파일로 다운로드하는 기능이에요. 최대 50만 건 이상의 대용량 데이터를 처리해야 했어요.

요구사항

  • 조건별 필터링 (indexInfoId, startDate, endDate)
  • CSV 파일 다운로드
  • 대용량 데이터 처리

1차 구현 - 기본 방식

처음에는 단순하게 구현했어요.

java

@Transactional(readOnly = true)
public void exportCsv(IndexDataExportRequest request, HttpServletResponse response)
    throws IOException {

    response.setContentType("text/csv; charset=UTF-8");
    response.setCharacterEncoding("UTF-8");
    response.setHeader("Content-Disposition", "attachment; filename=\"index-data.csv\"");

    PrintWriter writer = response.getWriter();
    writer.println("기준일자,지수분류,지수명,시가,종가,고가,저가,전일대비,등락률,거래량,거래대금,시가총액");

    List<IndexData> dataList = indexDataRepository.findAll();
    dataList.forEach(data -> writer.println(/* ... */));

    writer.flush();
}

문제점

50만 건 데이터를 전부 메모리에 올리니까 Heap이 1.4GB까지 올라갔어요. OOM(Out Of Memory) 위험이 있었어요.


2차 구현 - Stream 방식으로 개선

Stream 기반 처리

데이터를 한 번에 올리지 않고 순차적으로 처리하는 Stream 방식으로 변경했어요.

java

@Query("SELECT d FROM IndexData d JOIN FETCH d.indexInfo WHERE ...")
@QueryHints(@QueryHint(name = "org.hibernate.fetchSize", value = "1000"))
Stream<IndexData> streamForExport(
    @Param("indexInfoId") UUID indexInfoId,
    @Param("startDate") LocalDate startDate,
    @Param("endDate") LocalDate endDate
);

fetchSize=1000의 역할

DB에서 데이터를 가져올 때 한 번에 1000건씩 가져와요. 전체를 한 번에 올리지 않아서 메모리 사용량이 크게 줄어요.

java

try (Stream<IndexData> stream = indexDataRepository.streamForExport(
    request.indexInfoId(),
    request.startDate(),
    request.endDate()
)) {
    stream.forEach(data -> writer.println(/* ... */));
}

성능 테스트 결과

time curl로 직접 성능을 측정했어요.

time curl -o /dev/null -w "%{time_total}" \
  "http://localhost:8080/api/index-data/export/csv"
방식TTFBHeap 증가량
List 방식2.135초1.4GB
Stream (fetchSize 미적용)1.823초1.2GB
Stream (fetchSize=1000)0.018초200MB

fetchSize 적용 후 TTFB가 2.135초 → 0.018초로 99% 개선됐어요!


N+1 문제 해결

문제 발견

Stream으로 처리할 때 각 IndexData마다 IndexInfo를 조회하는 쿼리가 추가로 발생했어요.

SELECT * FROM index_data WHERE ...  -- 1번
SELECT * FROM index_info WHERE id = ?  -- N번 (건마다 추가 쿼리)

50만 건이면 50만 + 1번의 쿼리가 발생하는 거예요.

해결 - JOIN FETCH

java

@Query("SELECT d FROM IndexData d JOIN FETCH d.indexInfo WHERE ...")
Stream<IndexData> streamForExport(...);

JOIN FETCH로 단일 쿼리로 처리해서 N+1 문제를 해결했어요.

SELECT * FROM index_data d JOIN index_info i ON i.id = d.index_info_id WHERE ...  -- 1번

CSV 수식 인젝션 방어

CSV 파일을 Excel에서 열 때 =, +, @로 시작하는 값이 수식으로 실행될 수 있어요. 이를 방어하기 위해 앞에 작은따옴표를 붙였어요.

java

private static String csvCell(String raw) {
    if (raw == null) return "";
    String safe = raw;
    if (!safe.isEmpty() && "=+@".indexOf(safe.charAt(0)) >= 0) {
        safe = "'" + safe;
    }
    if (safe.contains("\"")) {
        safe = safe.replace("\"", "\"\"");
    }
    if (safe.contains(",") || safe.contains("\n") || safe.contains("\r")) {
        return "\"" + safe + "\"";
    }
    return safe;
}

느낀 점

처음에는 단순히 데이터를 가져와서 파일로 내려주면 된다고 생각했는데 대용량 처리에서 메모리 관리가 얼마나 중요한지 깨달았어요. fetchSize 하나로 TTFB가 100배 개선된 경험은 정말 인상 깊었어요. 성능 최적화의 재미를 느낀 작업이었어요.


[Findex 개발기] 5편 - CSV Export 개선 & 버그 수정

멘토링 피드백

4편에서 구현한 CSV Export에 대해 멘토님 코드리뷰에서 세 가지 피드백을 받았어요.

  1. 음수 값 앞에 작은따옴표가 붙는 문제
  2. Excel에서 한글이 깨지는 문제
  3. Export 실패 시 방어선 누락

각각 어떻게 해결했는지 정리해볼게요.


버그 1 - 음수 값 앞에 작은따옴표 문제

문제

CSV 파일을 Excel에서 열었을 때 음수 값이 이렇게 표시됐어요.

'-5.7500
'-33.99

숫자가 아닌 문자열로 인식되어 계산이 안 되는 문제가 있었어요.

원인

수식 인젝션 방어를 위해 =+-@로 시작하는 값 앞에 작은따옴표를 붙였는데 -가 포함되어 있어서 음수 값도 영향을 받은 거였어요.

// 문제 코드
if (!safe.isEmpty() && "=+-@".indexOf(safe.charAt(0)) >= 0) {
    safe = "'" + safe;
}

해결

-를 방어 문자에서 제거했어요. -는 수식 인젝션 위험이 없어요.

// 수정 코드
if (!safe.isEmpty() && "=+@".indexOf(safe.charAt(0)) >= 0) {
    safe = "'" + safe;
}

버그 2 - Excel 한글 인코딩 깨짐

문제

다운로드된 CSV 파일을 Excel에서 열었을 때 한글 헤더가 깨져 보였어요.

기준일자 → ????
지수분류 → ????

원인

response.getWriter()를 사용하면 서블릿 컨테이너가 기본 인코딩으로 Writer를 생성해요. 이 과정에서 인코딩이 제대로 적용되지 않는 문제가 있었어요.

java

// 문제 코드
PrintWriter writer = response.getWriter();

해결

OutputStreamWriter를 직접 생성하고 UTF-8을 명시적으로 지정했어요.

java

// 수정 코드
PrintWriter writer = new PrintWriter(
    new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8)
);

BOM(\uFEFF)도 추가해서 Excel이 UTF-8로 인식하도록 했어요.

java

writer.print('\uFEFF');  // BOM 추가
writer.println("기준일자,지수분류,지수명,...");

버그 3 - Export 실패 시 방어선 누락

문제

멘토님이 "Export가 안 될 때 방어선을 구축해 놓는 방법을 생각해 보라"고 하셨어요.

기존 코드는 데이터가 없거나 Export 도중 에러가 발생해도 사용자에게 아무런 피드백이 없었어요.

데이터 없음 → 빈 파일 다운로드
Export 실패 → 빈 파일 다운로드 또는 아무 반응 없음

문제 분석

처음에는 response.sendError()로 에러를 반환하려 했어요.

java

if (count[0] == 0) {
    if (!response.isCommitted()) {
        response.sendError(HttpServletResponse.SC_NOT_FOUND);
    }
}

근데 response에 이미 Content-Type: text/csv가 설정된 상태에서 JSON 에러 응답을 보내려 하니까 충돌이 발생했어요.

No converter for [class ErrorResponse] with preset Content-Type 'text/csv;charset=UTF-8'

해결 - Iterator 방식

Stream을 Iterator로 변환해서 첫 번째 데이터가 있는지 먼저 확인하고 response 설정을 나중에 하는 방식으로 해결했어요.

java

@Transactional(readOnly = true)
public void exportCsv(IndexDataExportRequest request, HttpServletResponse response)
    throws IOException {

  try (Stream<IndexData> stream = indexDataRepository.streamForExport(
      request.indexInfoId(),
      request.startDate(),
      request.endDate()
  )) {
    Iterator<IndexData> iterator = stream.iterator();

    // 데이터 없으면 response 설정 전에 예외 던지기
    if (!iterator.hasNext()) {
      throw new ApiException(ERROR.INDEX_DATA_NOT_FOUND);
    }

    // 데이터 있을 때만 response 설정
    response.setContentType("text/csv; charset=UTF-8");
    response.setCharacterEncoding("UTF-8");
    String filename = "index-data-" + LocalDateTime.now()
        .format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".csv";
    response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");

    PrintWriter writer = new PrintWriter(
        new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8)
    );
    writer.print('\uFEFF');
    writer.println("기준일자,지수분류,지수명,시가,종가,고가,저가,전일대비,등락률,거래량,거래대금,시가총액");

    try {
      iterator.forEachRemaining(data -> writer.println(String.join(",",
          csvCell(data.getBaseDate().toString()),
          // ...
      )));
    } catch (Exception e) {
      throw new ApiException(ERROR.INDEX_DATA_CSV_EXPORT_FAILED);
    } finally {
      writer.flush();
    }
  }
}

Iterator 방식의 장점

Stream을 그대로 유지하면서 첫 번째 데이터 존재 여부를 선제 확인할 수 있어요.

List 방식Iterator 방식
메모리전체 데이터 올림fetchSize 단위 처리
방어선가능가능
성능

에러 코드 추가

CSV Export 실패를 위한 전용 에러 코드도 추가했어요.

java

// IndexData
INDEX_DATA_NOT_FOUND("INDEX_DATA_001", "지수 데이터를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
INDEX_DATA_DUPLICATED("INDEX_DATA_002", "이미 존재하는 지수 데이터입니다.", HttpStatus.CONFLICT),
INDEX_DATA_CSV_EXPORT_FAILED("INDEX_DATA_003", "CSV Export에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),

테스트 결과

다양한 환경에서 CSV 파일을 열어봤어요.

환경한글 인코딩
Google 스프레드시트✅ 정상
Extreme CSV✅ 정상
XLSX Editor Plus✅ 정상
Microsoft Excel (웹뷰어)❌ 깨짐
Microsoft Excel (로컬 설치)❌ 깨짐
  • 음수 값 앞에 ' 없음 ✅
  • 데이터 없을 시 404 JSON 응답 ✅
  • Export 실패 시 500 JSON 응답 ✅
  • 500,158건 정상 다운로드 ✅

Microsoft Excel 인코딩 문제

BOM(\uFEFF)을 추가했음에도 불구하고 Microsoft Excel 환경에서는 여전히 한글이 깨지는 현상이 남아있어요. Microsoft Excel은 BOM 처리 방식이 다른 뷰어들과 달라서 추가적인 해결 방법이 필요해요. 이 부분은 추후 개선 과제로 남겨뒀어요.


느낀 점

단순해 보이는 CSV Export 기능에서 인코딩, 수식 인젝션, 방어선 구축까지 다양한 문제를 만났어요. 특히 response.sendError()와 Content-Type 충돌 문제는 HTTP 응답 처리 흐름을 더 깊이 이해하는 계기가 됐어요. Iterator 방식으로 Stream을 유지하면서 방어선을 구축한 아이디어가 가장 뿌듯했어요.


[Findex 개발기] 6편 - Validation 리팩토링

배경

멘토님 코드리뷰에서 Validation 처리 방식에 대한 피드백을 받았어요.

기존에는 서비스 레이어에서 직접 유효성 검사를 하고 있었는데 멘토님이 DTO 내부에서 처리하는 방식을 권장하셨어요.


기존 방식 - 서비스 레이어 Validation

java

@Transactional
public IndexDataResponse create(IndexDataCreateRequest request) {
    // 서비스에서 직접 검증
    if (request.marketPrice() == null) {
        throw new ApiException(ERROR.COMMON_INVALID_REQUEST);
    }
    if (request.closingPrice().compareTo(BigDecimal.ZERO) < 0) {
        throw new ApiException(ERROR.COMMON_INVALID_REQUEST);
    }
    // ...
}

문제점

  • 서비스 로직과 검증 로직이 섞여서 코드가 복잡해져요
  • 같은 검증 로직이 여러 서비스에 중복될 수 있어요
  • 테스트하기 어려워요

개선 방식 - DTO 내부 Validation

@AssertTrue 패턴 적용

복잡한 조건 검증은 @AssertTrue를 사용해서 DTO 내부에서 처리했어요.

java

public record IndexDataCreateRequest(
    @NotNull UUID indexInfoId,
    @NotNull LocalDate baseDate,
    SourceType sourceType,
    @NotNull @Positive BigDecimal marketPrice,
    @NotNull @Positive BigDecimal closingPrice,
    @NotNull @Positive BigDecimal highPrice,
    @NotNull @Positive BigDecimal lowPrice,
    @NotNull BigDecimal versus,
    @NotNull BigDecimal fluctuationRate,
    @NotNull @PositiveOrZero Long tradingQuantity,
    @NotNull @PositiveOrZero BigDecimal tradingPrice,
    @NotNull @PositiveOrZero BigDecimal marketTotalAmount
) {
    @AssertTrue(message = "고가는 저가보다 크거나 같아야 합니다.")
    public boolean isHighPriceValid() {
        if (highPrice == null || lowPrice == null) return true;
        return highPrice.compareTo(lowPrice) >= 0;
    }

    @AssertTrue(message = "시가와 종가는 고가보다 크거나 같을 수 없습니다.")
    public boolean isPriceRangeValid() {
        if (marketPrice == null || closingPrice == null || highPrice == null) return true;
        return marketPrice.compareTo(highPrice) <= 0
            && closingPrice.compareTo(highPrice) <= 0;
    }
}

@AssertTrue 패턴의 장점

  • 검증 로직이 DTO에 응집되어 있어요
  • 서비스 코드가 깔끔해져요
  • 메서드명으로 검증 의도를 표현할 수 있어요

IndexDataExportRequest Validation

CSV Export 요청에도 Validation을 적용했어요.

java

public record IndexDataExportRequest(
    UUID indexInfoId,
    LocalDate startDate,
    LocalDate endDate,
    String sortField,
    String sortDirection
) {
    @AssertTrue(message = "시작일은 종료일보다 이전이어야 합니다.")
    public boolean isDateRangeValid() {
        if (startDate == null || endDate == null) return true;
        return !startDate.isAfter(endDate);
    }

    @AssertTrue(message = "정렬 필드가 올바르지 않습니다.")
    public boolean isSortFieldValid() {
        if (sortField == null) return true;
        return List.of("baseDate", "marketPrice", "closingPrice",
            "highPrice", "lowPrice", "tradingQuantity").contains(sortField);
    }

    @AssertTrue(message = "정렬 방향이 올바르지 않습니다.")
    public boolean isSortDirectionValid() {
        if (sortDirection == null) return true;
        return List.of("ASC", "DESC").contains(sortDirection.toUpperCase());
    }
}

GlobalExceptionHandler에서 Validation 에러 처리

@AssertTrue 등 Bean Validation 에러는 MethodArgumentNotValidException으로 발생해요. 이를 GlobalExceptionHandler에서 일관되게 처리했어요.

java

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
    MethodArgumentNotValidException e) {

    String message = e.getBindingResult()
        .getFieldErrors()
        .stream()
        .map(FieldError::getDefaultMessage)
        .collect(Collectors.joining(", "));

    return ResponseEntity
        .status(HttpStatus.BAD_REQUEST)
        .body(ErrorResponse.of(
            HttpStatus.BAD_REQUEST.value(),
            message,
            ERROR.COMMON_INVALID_REQUEST.getCode()
        ));
}

트러블슈팅: @AssertTrue 메서드명 규칙

문제

@AssertTrue를 처음 적용했을 때 검증이 동작하지 않는 문제가 있었어요.

원인

@AssertTrue는 메서드명이 반드시 is로 시작해야 해요. Bean Validation이 is로 시작하는 메서드를 검증 대상으로 인식하기 때문이에요.

java

// 동작하지 않음
public boolean highPriceValid() { ... }

// 정상 동작
public boolean isHighPriceValid() { ... }

해결

모든 검증 메서드명을 is로 시작하도록 수정했어요.


멘토님 추가 피드백

리팩토링 후 멘토님이 추가 피드백을 주셨어요.

1. disableBuilder=true 테스트 코드 추가 권장

Record DTO에 disableBuilder=true 옵션을 사용했는데 이에 대한 테스트 코드를 작성하면 좋겠다고 하셨어요.

2. Repository가 DTO 반환하는 문제

일부 Repository 메서드가 DTO를 직접 반환하는 부분이 있었는데 Repository는 엔티티를 반환하고 서비스에서 변환하는 방식이 바람직하다고 하셨어요.

3. GlobalExceptionHandler 로깅 누락

예외 처리 시 로깅이 누락된 부분이 있었는데 다른 팀원이 수정해줬어요.


느낀 점

Validation 로직을 DTO로 옮기고 나니 서비스 코드가 훨씬 깔끔해졌어요. @AssertTrue 패턴은 처음에는 생소했지만 복잡한 조건 검증을 표현력 있게 작성할 수 있어서 앞으로도 자주 사용할 것 같아요. 멘토님 코드리뷰가 없었다면 계속 서비스에서 검증했을 것 같아서 리뷰의 소중함을 다시 한번 느꼈어요.


[Findex 개발기] 7편 - Git 협업 & 회고

Git 협업 방식

Fork 기반 워크플로우

팀 레포를 직접 수정하지 않고 Fork 후 PR을 올리는 방식으로 협업했어요.

upstream (팀 레포: sb11-code-slayers/sb11-findex-team2)
    ↓ fork
origin (내 레포: gim00001/sb11-findex-team2)
    ↓ clone
local (내 로컬)

브랜치 전략

upstream/dev
    ↓ fetch & merge
local/dev
    ↓ checkout
feature/index-data/export    # 기능 개발
fix/index-data/csv-export    # 버그 수정
fix/index-data/minor-fixes   # 사소한 수정

Issue 관리

작업 시작 전에 항상 Issue를 먼저 올렸어요.

Issue 템플릿

제목: [BUG] CSV Export 음수 값 작은따옴표 추가 및 Excel 인코딩 깨짐, 방어선 누락

## 버그 요약
CSV Export 시 세 가지 문제가 발견되었습니다.
1. 음수 값(-) 앞에 작은따옴표(')가 붙어 Excel에서 문자열로 인식됩니다.
2. Excel에서 열 때 한글이 깨져 보입니다.
3. Export 실패 시 사용자에게 적절한 피드백이 없습니다.

## 재현 방법
## 기대 결과
## 실제 결과
## 원인
## 관련 파일

Issue를 먼저 작성하면서 작업 범위를 명확히 하는 습관이 생겼어요.


PR 관리

PR 규칙

  • 하나의 PR은 하나의 Issue
  • PR 올리기 전 ./gradlew build -x test 필수
  • 셀프 리뷰 후 PR 올리기
  • 리뷰어 지정

PR 체크리스트

- [x] 셀프 리뷰를 완료했습니다.
- [x] 코드 컨벤션을 지켰습니다.
- [x] 공통 응답 포맷 및 공통 유틸을 사용했습니다.
- [x] API 키, 비밀번호 등 민감 정보가 포함되지 않았습니다.
- [x] 불필요한 주석 및 디버그 코드를 제거했습니다.

내가 올린 브랜치 & PR 목록

브랜치내용
feature/index-data/entityIndexData 엔티티 설계
feature/index-data/cudCRUD 구현
feature/index-data/list목록 조회 & 커서 페이지네이션
feature/index-data/exportCSV Export 구현 & 성능 최적화
fix/index-data/csv-exportCSV Export 버그 수정 & 방어선 추가
fix/index-data/minor-fixes@DeleteMapping 슬래시 누락 수정
refactor/index-data/query-condition-validationValidation 리팩토링

트러블슈팅 모음

이번 프로젝트에서 겪은 주요 트러블슈팅이에요.

환경 설정

  • IntelliJ 환경변수 PUBLIC_DATA_BASE_URL 전체 경로 미설정으로 API 연동 실패

CSV Export

  • response.getWriter() 인코딩 문제 → OutputStreamWriter 명시적 UTF-8 지정
  • 음수 값 작은따옴표 문제 → 수식 인젝션 방어 문자에서 - 제거
  • response.sendError() Content-Type 충돌 → Iterator 방식으로 response 설정 순서 변경
  • Microsoft Excel 한글 인코딩 미해결 → BOM 추가했으나 Excel 환경에서는 여전히 깨짐

PostgreSQL

  • streamForExport() null 파라미터 UUID 타입 추론 실패
  • (:indexInfoId IS NULL OR ...) 패턴이 PostgreSQL과 호환되지 않는 문제

Validation

  • @AssertTrue 메서드명 is prefix 누락으로 검증 미동작

멘토링 주요 피드백 정리

피드백조치
GlobalExceptionHandler 로깅 누락다른 팀원이 수정 완료
Repository가 DTO 반환하는 문제타입 불일치 수정
PrintWriter try-with-resources 미사용finally로 flush 보장
커서 페이지네이션 NULLS FIRST/LAST 정책 명시추후 개선 과제
disableBuilder=true 테스트 코드 추가 권장추후 개선 과제
PR 올리기 전 ./gradlew build 습관 권장적용 완료

개인 회고

잘한 점

꼼꼼한 Issue & PR 관리 작업 시작 전 Issue를 먼저 작성하고 PR에 변경 이유와 리뷰 포인트를 상세히 작성하는 습관이 생겼어요. 팀원들과 소통이 훨씬 원활해졌어요.

성능에 대한 고민 단순히 동작하는 코드가 아니라 대용량 데이터에서도 잘 동작하는 코드를 고민했어요. fetchSize 적용으로 TTFB 99% 개선, Heap 사용량 86% 감소를 경험한 게 가장 뿌듯했어요.

버그 수정 시 근본 원인 파악 에러가 발생했을 때 표면적인 해결보다 근본 원인을 파악하려고 노력했어요. response.sendError() Content-Type 충돌 문제를 Iterator 방식으로 해결한 경험이 대표적이에요.

아쉬운 점

테스트 코드 미작성 시간 부족으로 테스트 코드를 작성하지 못했어요. 멘토님도 테스트 코드 작성을 권장하셨는데 다음 프로젝트에서는 꼭 작성해볼 거예요.

PostgreSQL null 타입 문제 미해결 streamForExport() 쿼리의 PostgreSQL null UUID 타입 추론 문제를 해결하지 못했어요. H2에서는 정상 동작하지만 PostgreSQL에서는 indexInfoId가 null일 때 에러가 발생해요. 배포 환경에서도 영향을 줄 수 있어서 아쉬움이 남아요.

Microsoft Excel 인코딩 미해결 BOM을 추가했지만 Microsoft Excel 환경에서는 여전히 한글이 깨지는 문제가 남아있어요. 추가적인 해결 방법을 찾아봐야 해요.

배운 점

  • Spring Boot, JPA, QueryDSL을 실제 프로젝트에 적용하는 경험
  • 대용량 데이터 처리에서 Stream과 fetchSize의 중요성
  • Git Fork 기반 협업 워크플로우
  • 코드리뷰의 소중함과 멘토링의 가치
  • 에러 발생 시 로그를 읽고 근본 원인을 파악하는 능력

마치며

처음으로 팀 프로젝트를 진행하면서 혼자서는 배울 수 없는 것들을 많이 배웠어요. 코드리뷰, Git 협업, 대용량 처리, 성능 최적화까지 짧은 기간에 정말 많은 걸 경험했어요.

부족한 부분도 많았지만 그만큼 성장한 프로젝트였어요. 다음 프로젝트에서는 테스트 코드 작성과 미해결 이슈들을 꼭 개선해볼 거예요.

Findex 팀원들 모두 수고하셨습니다! 🎉


profile
Rookie

0개의 댓글