1편 - 프로젝트 소개 & 환경 설정
2편 - IndexData 엔티티 & CRUD 구현
3편 - 목록 조회 & 커서 페이지네이션
4편 - CSV Export 구현 & 성능 최적화
5편 - CSV Export 개선 & 버그 수정
6편 - Validation 리팩토링
7편 - Git 협업 & 회고
Findex는 금융위원회 공공데이터 API를 활용한 주가지수 데이터 연동 서비스예요. 팀 프로젝트로 진행했으며 저는 M2 담당으로 IndexData 도메인을 맡았어요.
기술스택

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

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을 꼼꼼히 확인하는 습관을 들여야겠다고 생각했어요.
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로 선택한 이유는 두 가지예요.
final로 선언한 이유는 ID는 한 번 생성되면 변경되면 안 되는 불변 값이기 때문이에요.
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()로 먼저 확인DataIntegrityViolationException catch로 이중 방어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);
}
포인트
update() 메서드로 캡슐화java
@Transactional
public void delete(UUID id) {
IndexData indexData = indexDataRepository.findById(id)
.orElseThrow(() -> new ApiException(ERROR.INDEX_DATA_NOT_FOUND));
indexDataRepository.delete(indexData);
}
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 이중 방어는 멘토님 리뷰를 통해 배운 좋은 패턴이었어요.
IndexData 목록 조회는 다양한 필터 조건과 정렬, 페이지네이션을 지원해야 했어요.
조회 조건
일반적인 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로 동적 쿼리를 작성했어요.
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)));
}
}
잘못된 sortField 값이 들어왔을 때 QueryDSL에서 IllegalArgumentException이 발생했어요. 근데 GlobalExceptionHandler에서 처리를 못 하고 500 에러가 반환됐어요.
IllegalArgumentException은 ApiException이 아니라서 핸들러가 잡지 못했어요.
서비스 레이어에서 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 정책도 명시하면 좋다는 피드백을 받아서 다음에는 꼭 적용해봐야겠어요.
IndexData를 CSV 파일로 다운로드하는 기능이에요. 최대 50만 건 이상의 대용량 데이터를 처리해야 했어요.
요구사항
처음에는 단순하게 구현했어요.
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) 위험이 있었어요.
데이터를 한 번에 올리지 않고 순차적으로 처리하는 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"
| 방식 | TTFB | Heap 증가량 |
|---|---|---|
| List 방식 | 2.135초 | 1.4GB |
| Stream (fetchSize 미적용) | 1.823초 | 1.2GB |
| Stream (fetchSize=1000) | 0.018초 | 200MB |
fetchSize 적용 후 TTFB가 2.135초 → 0.018초로 99% 개선됐어요!
Stream으로 처리할 때 각 IndexData마다 IndexInfo를 조회하는 쿼리가 추가로 발생했어요.
SELECT * FROM index_data WHERE ... -- 1번
SELECT * FROM index_info WHERE id = ? -- N번 (건마다 추가 쿼리)
50만 건이면 50만 + 1번의 쿼리가 발생하는 거예요.
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 파일을 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배 개선된 경험은 정말 인상 깊었어요. 성능 최적화의 재미를 느낀 작업이었어요.
4편에서 구현한 CSV Export에 대해 멘토님 코드리뷰에서 세 가지 피드백을 받았어요.
각각 어떻게 해결했는지 정리해볼게요.
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;
}
다운로드된 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("기준일자,지수분류,지수명,...");
멘토님이 "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'
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 (로컬 설치) | ❌ 깨짐 |
' 없음 ✅Microsoft Excel 인코딩 문제
BOM(\uFEFF)을 추가했음에도 불구하고 Microsoft Excel 환경에서는 여전히 한글이 깨지는 현상이 남아있어요. Microsoft Excel은 BOM 처리 방식이 다른 뷰어들과 달라서 추가적인 해결 방법이 필요해요. 이 부분은 추후 개선 과제로 남겨뒀어요.
단순해 보이는 CSV Export 기능에서 인코딩, 수식 인젝션, 방어선 구축까지 다양한 문제를 만났어요. 특히 response.sendError()와 Content-Type 충돌 문제는 HTTP 응답 처리 흐름을 더 깊이 이해하는 계기가 됐어요. Iterator 방식으로 Stream을 유지하면서 방어선을 구축한 아이디어가 가장 뿌듯했어요.
멘토님 코드리뷰에서 Validation 처리 방식에 대한 피드백을 받았어요.
기존에는 서비스 레이어에서 직접 유효성 검사를 하고 있었는데 멘토님이 DTO 내부에서 처리하는 방식을 권장하셨어요.
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);
}
// ...
}
문제점
복잡한 조건 검증은 @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 패턴의 장점
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());
}
}
@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는 메서드명이 반드시 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 패턴은 처음에는 생소했지만 복잡한 조건 검증을 표현력 있게 작성할 수 있어서 앞으로도 자주 사용할 것 같아요. 멘토님 코드리뷰가 없었다면 계속 서비스에서 검증했을 것 같아서 리뷰의 소중함을 다시 한번 느꼈어요.
팀 레포를 직접 수정하지 않고 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 템플릿
제목: [BUG] CSV Export 음수 값 작은따옴표 추가 및 Excel 인코딩 깨짐, 방어선 누락
## 버그 요약
CSV Export 시 세 가지 문제가 발견되었습니다.
1. 음수 값(-) 앞에 작은따옴표(')가 붙어 Excel에서 문자열로 인식됩니다.
2. Excel에서 열 때 한글이 깨져 보입니다.
3. Export 실패 시 사용자에게 적절한 피드백이 없습니다.
## 재현 방법
## 기대 결과
## 실제 결과
## 원인
## 관련 파일
Issue를 먼저 작성하면서 작업 범위를 명확히 하는 습관이 생겼어요.
PR 규칙
./gradlew build -x test 필수PR 체크리스트
- [x] 셀프 리뷰를 완료했습니다.
- [x] 코드 컨벤션을 지켰습니다.
- [x] 공통 응답 포맷 및 공통 유틸을 사용했습니다.
- [x] API 키, 비밀번호 등 민감 정보가 포함되지 않았습니다.
- [x] 불필요한 주석 및 디버그 코드를 제거했습니다.
| 브랜치 | 내용 |
|---|---|
| feature/index-data/entity | IndexData 엔티티 설계 |
| feature/index-data/cud | CRUD 구현 |
| feature/index-data/list | 목록 조회 & 커서 페이지네이션 |
| feature/index-data/export | CSV Export 구현 & 성능 최적화 |
| fix/index-data/csv-export | CSV Export 버그 수정 & 방어선 추가 |
| fix/index-data/minor-fixes | @DeleteMapping 슬래시 누락 수정 |
| refactor/index-data/query-condition-validation | Validation 리팩토링 |
이번 프로젝트에서 겪은 주요 트러블슈팅이에요.
환경 설정
PUBLIC_DATA_BASE_URL 전체 경로 미설정으로 API 연동 실패CSV Export
response.getWriter() 인코딩 문제 → OutputStreamWriter 명시적 UTF-8 지정- 제거response.sendError() Content-Type 충돌 → Iterator 방식으로 response 설정 순서 변경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 환경에서는 여전히 한글이 깨지는 문제가 남아있어요. 추가적인 해결 방법을 찾아봐야 해요.
처음으로 팀 프로젝트를 진행하면서 혼자서는 배울 수 없는 것들을 많이 배웠어요. 코드리뷰, Git 협업, 대용량 처리, 성능 최적화까지 짧은 기간에 정말 많은 걸 경험했어요.
부족한 부분도 많았지만 그만큼 성장한 프로젝트였어요. 다음 프로젝트에서는 테스트 코드 작성과 미해결 이슈들을 꼭 개선해볼 거예요.
Findex 팀원들 모두 수고하셨습니다! 🎉