
대용량 파일 다운로드 기능을 구현해보자
JAVA
2025.07.22.
몇일 전 tus를 사용해 대용량 업로드 기능을 구현했는데 추가로 다운로드 기능도 구현이 필요했다.
최대 70GB의 데이터를 DB에서 가져와 csv나 geojson형식으로 변환해 다운로드를 해야한다.
간략하게 정리해보자
1. myBatis cursor 활용
Mybatis 3.4.0 버전부터 도입된 Cursor
try-with-resources 구문과 함께 사용하여 DB 커넥션을 안전하게 관리하면서 결과를 하나씩 가져올 수 있다.
2. 구현
- Mapper XML 작성
<select id="streamCsvData"
parameterType="java.lang.String"
resultType="java.util.Map"
fetchSize="1000"
resultSetType="FORWARD_ONLY">
SELECT
*
FROM
clct_storage.${tableName}
</select>
- DAO, Mapper 작성
@Repository
@RequiredArgsConstructor
public class WrkClctDaoImpl implements WrkClctDao {
private final WrkClctMapper wrkClctMapper;
...
@Override
public Cursor<Map<String, Object>> streamCsvData(String tableName) {
return wrkClctMapper.streamCsvData(tableName);
}
...
}
@Mapper
public interface WrkClctMapper {
...
Cursor<Map<String, Object>> streamCsvData(String tableName);
}
- 서비스 계층 작성
@Slf4j
@Service
@RequiredArgsConstructor
public class StreamServiceImpl implements StreamService {
private final WrkClctDao wrkClctDao;
private final TransactionTemplate transactionTemplate;
private final ObjectMapper objectMapper;
private static final String GEOMETRY_COLUMN = "geom";
private static final String GEOJSON_GEOMETRY_ALIAS = "geojson_geometry";
/**
* @param writer CSV 데이터를 쓸 Writer 객체
* @param tableName 데이터를 조회할 테이블 이름
*/
@Override
public void streamCsvData(Writer writer, String tableName) {
// 테이블 존재 여부 확인
if (wrkClctDao.tableExistsClctStorage(tableName) == 0) {
throw new BusinessException(ErrorCode.NOT_FOUND);
}
transactionTemplate.setReadOnly(true);
transactionTemplate.execute(status -> {
try (Cursor<Map<String, Object>> cursor = wrkClctDao.streamCsvData(tableName)) {
boolean headerWritten = false;
for (Map<String, Object> row : cursor) {
if (!headerWritten) {
writer.write(String.join(",", row.keySet()) + "\n");
headerWritten = true;
}
String rowData = row.values().stream()
.map(value -> value != null ? value.toString() : "")
.map(this::escapeCsv)
.collect(Collectors.joining(","));
writer.write(rowData + "\n");
}
writer.flush();
} catch (IOException e) {
status.setRollbackOnly();
throw new RuntimeException("CSV 작성 중 IO 에러 발생", e);
}
return null;
});
}
private String escapeCsv(String value) {
if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
return value;
}
}
- Controller 작성
@RestController
@RequestMapping("/wrk/api/clct")
@Tag(name = "Clct Job API", description = "연계 작업 관리 API 입니다.")
@RequiredArgsConstructor
@Slf4j
public class WrkClctController {
private final WrkClctService wrkClctService;
private final StreamService streamService;
@GetMapping("/download-csv/{tableName}")
@Operation(summary = "테이블 정보 csv형태로 다운로드", description = "테이블 명을 받아 csv파일 형태로 가져옵니다.")
public ResponseEntity<StreamingResponseBody> downloadCsv(@PathVariable("tableName") String tableName) {
StreamingResponseBody responseBody = outputStream -> {
try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
streamService.streamCsvData(writer, tableName);
} catch (Exception e) {
log.warn("CSV 스트리밍 파이프가 클라이언트 또는 서버 에러로 인해 닫혔습니다: {}", e.getMessage());
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + tableName + ".csv")
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
.body(responseBody);
}
}