관리자 대시보드에 최대 10,000개의 음성 파일을 한 번에 다운로드할 수 있는 기능이 필요했습니다. 하지만 이렇게 많은 파일을 서버에서 압축하고, 동시에 다운로드할 수 있게 하는 기능은 서버에 큰 부담을 주기 때문에, 아래와 같은 문제들이 발생했습니다.
이 문제를 해결하기 위해 대량 데이터 처리를 효율적으로 수행할 수 있는 Spring Batch를 도입하게 되었습니다. Spring Batch는 단계별로 작업을 분리하고 상태를 추적할 수 있어 대용량 처리에 적합합니다.
Spring Batch는 작업을 여러 단계(Step)로 나누어 처리할 수 있는 프레임워크입니다. 각 스텝을 개별 작업으로 나누고, 각 파일을 청크 단위로 읽고 변환하며 처리함으로써, 서버 과부하를 줄일 수 있었습니다.
아래는 Spring Batch를 사용하여 ZIP 파일 생성 작업을 구현한 코드입니다. Spring Batch의 Job과 Step을 정의하고, 각 음성 파일을 ZIP 파일에 추가하는 과정을 청크 단위로 처리합니다.
BatchConfig
)Spring Batch의 작업(Job)과 단계를 정의하여 ZIP 파일 생성 작업을 설정합니다.
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
@Configuration
@EnableBatchProcessing
public class BatchConfig {
@Bean
public Job zipCreationJob(JobBuilderFactory jobBuilderFactory, Step zipStep) {
return jobBuilderFactory.get("zipCreationJob")
.incrementer(new RunIdIncrementer())
.flow(zipStep)
.end()
.build();
}
@Bean
public Step zipStep(StepBuilderFactory stepBuilderFactory,
ItemReader<URI> uriReader,
ItemProcessor<URI, Resource> uriProcessor,
ItemWriter<Resource> zipWriter) {
return stepBuilderFactory.get("zipStep")
.<URI, Resource>chunk(100) // 청크 단위로 100개의 URI 처리
.reader(uriReader)
.processor(uriProcessor)
.writer(zipWriter)
.build();
}
}
UriItemReader
)ZIP 파일에 포함할 음성 파일 URI 목록을 읽어들입니다.
import org.springframework.batch.item.ItemReader;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.util.Iterator;
import java.util.List;
@Component
public class UriItemReader implements ItemReader<URI> {
private final VoiceMetaDataService voiceMetaDataService;
private Iterator<URI> uriIterator;
public UriItemReader(VoiceMetaDataService voiceMetaDataService) {
this.voiceMetaDataService = voiceMetaDataService;
}
@Override
public URI read() {
if (uriIterator == null) {
List<URI> uris = voiceMetaDataService.getAudioUrlsLimit10000();
uriIterator = uris.iterator();
}
return uriIterator.hasNext() ? uriIterator.next() : null;
}
}
UriToResourceProcessor
)음성 파일 URI를 ZIP에 포함할 수 있는 ByteArrayResource
로 변환합니다.
import org.springframework.batch.item.ItemProcessor;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URI;
@Component
public class UriToResourceProcessor implements ItemProcessor<URI, ByteArrayResource> {
private final RestTemplate restTemplate = new RestTemplate();
@Override
public ByteArrayResource process(URI uri) throws Exception {
try (InputStream in = restTemplate.getForObject(uri, InputStream.class);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
if (in == null) return null;
byte[] buffer = new byte[1024];
int length;
while ((length = in.read(buffer)) > 0) {
out.write(buffer, 0, length);
}
return new ByteArrayResource(out.toByteArray()) {
@Override
public String getFilename() {
return Path.of(uri.getPath()).getFileName().toString();
}
};
}
}
}
ZipFileWriter
)ZIP 파일을 작성하는 Writer입니다.
import org.springframework.batch.item.ItemWriter;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Component
public class ZipFileWriter implements ItemWriter<ByteArrayResource> {
private final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
private final ZipOutputStream zos = new ZipOutputStream(byteArrayOutputStream);
@Override
public void write(List<? extends ByteArrayResource> items) throws IOException {
for (ByteArrayResource resource : items) {
ZipEntry zipEntry = new ZipEntry(resource.getFilename());
zos.putNextEntry(zipEntry);
zos.write(resource.getByteArray());
zos.closeEntry();
}
}
public byte[] getZipData() throws IOException {
zos.finish();
return byteArrayOutputStream.toByteArray();
}
}
ZipJobLauncher
와 컨트롤러 (ZipController
)작업을 실행하고, ZIP 파일을 다운로드하는 API를 제공합니다.
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import java.io.ByteArrayInputStream;
@RestController
@RequestMapping("/api/zip")
@RequiredArgsConstructor
public class ZipController {
private final ZipJobLauncher zipJobLauncher;
private final ZipFileWriter zipFileWriter;
@PostMapping("/create")
public ResponseEntity<String> createZip() throws Exception {
zipJobLauncher.launchJob();
return ResponseEntity.ok("ZIP 작업이 시작되었습니다.");
}
@GetMapping("/download")
public ResponseEntity<InputStreamResource> downloadZip() throws Exception {
byte[] zipData = zipFileWriter.getZipData();
InputStreamResource resource = new InputStreamResource(new ByteArrayInputStream(zipData
));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=downloads.zip")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
}
@Service
@RequiredArgsConstructor
public class ZipJobLauncher {
private final JobLauncher jobLauncher;
private final Job zipCreationJob;
public void launchJob() throws Exception {
jobLauncher.run(zipCreationJob, new JobParametersBuilder()
.addLong("time", System.currentTimeMillis())
.toJobParameters());
}
}
Spring Batch를 도입한 후 성능과 안정성이 다음과 같이 개선되었습니다.
Spring Batch를 활용해 서버 과부하를 줄이고, 대량 파일 다운로드 작업을 안정적으로 수행할 수 있었습니다. 이 방식은 대량 데이터 처리에 최적화된 솔루션으로, 안정성과 성능을 모두 확보할 수 있는 좋은 선택이었습니다.