왜 변경하게 되었나
기존에 업로드 방식은 hls로 변환하는 작업도 @Async이고, 그 후에 s3로 업로드하는 작업도 @Async로 되어있어서 체이닝하는 방식으로 사용했다. 처음에는 이 방식이 좀 더 괜찮아보였는데 생각해보니까 영상을 업로드할 때 한 번만 비동기 처리를 해주고 나머지는 안에서 동기적으로 처리를 해주는게 좀 더 깔끔하고, 에러 핸들링도 쉬울 것 같았다. (기존에는 CompletableFuture로 thenCompose와 exceptionally로 뭔가 복잡하게 했었다.)
변경
일단 hls컨버트를 담당하는 클래스는 Convert라는 인터페이스를 상속받도록 변경하였다.
public interface Converter<INPUT, RETURN> {
RETURN convert(INPUT input);
}
@Slf4j(topic = "HLS")
@Component
public class HlsConverter implements Converter<File, Optional<File>> {
private final FileUtils fileUtils;
@Value("${hls.ffmpeg.path}")
private String ffmpegPath;
@Value("${hls.ffprobe.path}")
private String ffprobePath;
@Value("${hls.output.path}")
private String hlsOutputPath;
private FFprobe ffprobe;
private FFmpeg ffmpeg;
public HlsConverter(FileUtils fileUtils) {
this.fileUtils = fileUtils;
}
@PostConstruct
public void init() {
try{
ffmpeg = new FFmpeg(ffmpegPath);
ffprobe = new FFprobe(ffprobePath);
log.info("ffmpeg 생성완료!");
log.info("ffprobe 생성완료!");
} catch (IOException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
private String getFilename(String originalFilename) {
int lastDotIndex = originalFilename.lastIndexOf(".");
return originalFilename.substring(0, lastDotIndex);
}
@Override
public Optional<File> convert(File file) {
String name = file.getName();
name = getFilename(name); // 확장자 제거 이름 추출
File outputPath = new File(hlsOutputPath + File.separator + name);
if (!outputPath.exists()) {
outputPath.mkdirs();
}
try {
FFmpegBuilder builder = new FFmpegBuilder()
.setInput(file.getAbsolutePath())
.addOutput(outputPath.getAbsolutePath() + File.separator + "video.m3u8")
.addExtraArgs("-profile:v", "baseline")
.addExtraArgs("-level", "3.0")
.addExtraArgs("-start_number", "0")
.addExtraArgs("-hls_time", "10")
.addExtraArgs("-hls_list_size", "0")
.addExtraArgs("-f", "hls")
.done();
FFmpegExecutor executor = new FFmpegExecutor(ffmpeg, ffprobe);
executor.createJob(builder)
.run();
return Optional.of(outputPath);
} catch (Exception e) {
// 문제 생기면 파일 삭제
fileUtils.deleteFileRecur(outputPath);
return Optional.empty();
}
finally {
// 변환 후 임시파일 삭제
file.delete();
}
}
}
기존에는 HlsConvert를 직접 의존했지만 이렇게 변경해서 인터페이스에 의존할 수 있게 변경했다.
파일을 업로드했을 때 convert 후 s3에 업로드하는 작업을 FileUploader라는 클래스에서 담당하게 했다.
@Component
@RequiredArgsConstructor
public class FileUploader {
private final Converter<File, Optional<File>> hlsConverter;
private final AmazonS3Uploader s3Uploader;
private final FileUtils fileUtils;
private final UploadFileRepository repository;
@Async
public void uploadVideo(File video, UploadFile uploadFile) {
File convert = hlsConverter.convert(video).orElse(null);
if (convert == null || !convert.exists() || convert.listFiles() == null) {
uploadFile.updateStatus(UploadStatus.FAILED);
repository.saveAndFlush(uploadFile);
return;
}
String prefixKey = UploadType.VIDEO.getKey() + "/" + fileUtils.getFilename(convert.getName());
S3UploadResponseDto response = s3Uploader.uploadVideoToS3(prefixKey, convert.listFiles()).orElse(null);
if(response == null){
uploadFile.updateStatus(UploadStatus.FAILED);
repository.saveAndFlush(uploadFile);
return;
}
uploadFile.registS3Url(response.getS3Url());
uploadFile.registS3Key(response.getS3Key());
uploadFile.updateStatus(UploadStatus.COMPLETED);
repository.saveAndFlush(uploadFile);
if(video.exists()) {
video.delete();
}
}
@Async
public void uploadFile(UploadType uploadType, File file, UploadFile uploadFile) {
S3UploadResponseDto res = s3Uploader.uploadToS3(uploadType.getKey(), file).orElse(null);
if(file.exists()){
file.delete();
}
if(res == null){
uploadFile.updateStatus(UploadStatus.FAILED);
return;
}
uploadFile.registS3Key(res.getS3Key());
uploadFile.registS3Url(res.getS3Url());
uploadFile.updateStatus(UploadStatus.COMPLETED);
repository.saveAndFlush(uploadFile);
}
}
중복되는 코드들이 보이지만 이 부분들은 추후에 리팩토링을 해야겠다. FileUploader 클래스는 UploadService 클래스에서 사용한다.
쿠키를 생성할 때 퍼사드 패턴을 적용했다.
@Component
@RequiredArgsConstructor
@Transactional
public class CookieCreateFacade {
private final CookieRepository cookieRepository;
private final ChannelRepository channelRepository;
private final CategoryRepository categoryRepository;
private final SeriesRepository seriesRepository;
private final UploadService uploadService;
private final SeriesCookieRepository seriesCookieRepository;
private final CookieCategoryRepository cookieCategoryRepository;
public CookieResponse.Create createCookie(
User user,
CookieRequest.Create createDto,
MultipartFile video,
MultipartFile thumbnail,
MultipartFile attachment) {
Channel channel = channelRepository.findChannelWithUserByUserId(user.getId())
.orElseThrow(() -> new InvalidRequestException("유저의 채널이 존재하지 않습니다."));
Category category = categoryRepository.findById(createDto.categoryId())
.orElseThrow(() -> new InvalidRequestException("존재하지 않는 카테고리 입니다."));
Series series = null;
if(createDto.seriesId() != null) {
series = seriesRepository.findById(createDto.seriesId()).orElseThrow(()-> new InvalidRequestException("존재하지 않는 시리즈입니다."));
if(Objects.equals(series.getChannel().getId(), channel.getId())) {
throw new InvalidRequestException("해당 채널에 해당 시리즈가 존재하지 않습니다.");
}
}
UploadFile uploadedVideo = uploadService.uploadVideo(video);
UploadFile uploadedThumbnail = uploadService.uploadFile(UploadType.IMAGE, thumbnail);
UploadFile uploadedAttachment = attachment == null ? null : uploadService.uploadFile(UploadType.ATTACHMENT, attachment);
Cookie newCookie = createNewCookie(createDto, channel, uploadedVideo, uploadedThumbnail,
uploadedAttachment);
newCookie = cookieRepository.save(newCookie);
// 시리즈 연결
if(series != null) {
SeriesCookie seriesCookie = new SeriesCookie(series, newCookie);
seriesCookieRepository.save(seriesCookie);
}
// 카테고리 연결
CookieCategory cc = new CookieCategory(newCookie, category);
cookieCategoryRepository.save(cc);
return new Create(newCookie.getId());
}
public Cookie createNewCookie( CookieRequest.Create createDto, Channel channel, UploadFile video, UploadFile thumbnail, UploadFile attachment) {
return Cookie.builder()
.channel(channel)
.title(createDto.title())
.description(createDto.description())
.status(ProcessStatus.PENDING)
.videoFile(video)
.thumbnailFile(thumbnail)
.attachmentFile(attachment)
.build();
}
이렇게 함으로써 CookieService에서는 아래와 같이 쿠키를 생성할 수 있다.
트러블 슈팅
쿼리 join 문제
만들어진 쿠키들을 조회할 때 자꾸 조회가 안되는 문제가 생겼다. 분명히 카운트 쿼리를 작성하면 제대로 개수가 나오는데 조회하는 쿼리에서 이상한 부분들을 생각해보고 join을 left join으로 변경해서 해결했다. attachment파일이 null일 수가 있는데 이 경우를 생각못했다.
public Page<Detail> findAllCookiesByChannelId(Long channelId, Pageable pageable) {
QUser user = QUser.user;
QCookie cookie = QCookie.cookie;
QChannel channel = QChannel.channel;
QUploadFile video = new QUploadFile("videoFile");
QUploadFile thumbnail = new QUploadFile("thumbnailFile");
QUploadFile attachment = new QUploadFile("attachmentFile");
QCookieCategory cookieCategory = QCookieCategory.cookieCategory;
System.out.println("pageable = " + pageable.getOffset());
System.out.println("pageable = " + pageable.getPageSize());
System.out.println("pageable = " + pageable.getPageNumber());
List<Detail> fetch = queryFactory.
select(Projections.constructor(Detail.class,
channel.id,
user.id,
user.nickname,
cookie.id,
cookie.proccessStatus,
cookie.title,
cookie.description,
video.id,
thumbnail.id,
attachment.id,
cookie.createdAt))
.distinct()
.from(cookie)
.join(cookie.channel, channel).on(channel.id.eq(channelId))
.join(channel.user, user)
.join(cookie.videoFile, video)
.join(cookie.thumbnailFile, thumbnail)
.leftJoin(cookie.attachmentFile, attachment)
.orderBy(cookie.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
System.out.println(fetch.size());
Long count = queryFactory.select(Wildcard.count)
.from(cookie)
.innerJoin(cookie.channel, channel).on(channel.id.eq(channelId))
.fetchOne();
return new PageImpl<>(fetch, pageable, count == null ? 0 : count);
}
성능 관련
파일 업로드에 비동기 방식을 사용하게 된 이유는 하나였다. 프론트쪽에서 동영상을 업로드 후 응답을 받기 까지가 매우매우매우 오래 걸리기 때문이다. 더군다나 동영상 파일이 크다면 시간은 더욱 많이 걸린다. 이러한 이유 때문에 동영상 업로드는 무조건 비동기로 처리를 해야만 했다.
시간 차이를 보면 엄청나게 차이가 나는 것을 볼 수 있다.
'사이드 프로젝트 > 쿠키톡' 카테고리의 다른 글
쿠키 CRUD 구현 (5) | 2024.10.08 |
---|---|
jwt 인증/인가 (0) | 2024.09.29 |
HLS 변환 (0) | 2024.09.15 |
사이드 프로젝트 시작 (0) | 2024.09.15 |