구현
쿠키 생성과, 조회수 기능을 구현했다.
쿠키
쿠키의 생성에는 제목, 쿠키의 설명, 카테고리, 연결될 시리즈 그리고 비디오, 썸네일, 첨부파일을 받도록 하였다. 쿠키를 생성할 때 다양한 레포지토리, 서비스 클래스들을 주입받아야 했는데 이 부분은 쿠키 생성을 하는 퍼사드 클래스를 만들었다.
@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(
AuthUser user,
CookieRequest.Create createDto,
MultipartFile video,
MultipartFile thumbnail,
MultipartFile attachment) {
Channel channel = channelRepository.findChannelWithUserByUserId(user.getUserId())
.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.uploadFileAsync(UploadType.IMAGE, thumbnail);
UploadFile uploadedAttachment = attachment == null ? null : uploadService.uploadFileAsync(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();
}
}
public CookieResponse.Create createCookie(AuthUser auth, Create requestDto,
MultipartFile video,
MultipartFile thumbnail,
MultipartFile attachment) {
return cookieCreateFacade.createCookie(auth, requestDto, video, thumbnail, attachment);
}
생성하는 부분은 업로드하는 부분을 다구현해놓고 하니 어렵지 않았다.
그다음, 조회하는 부분이 되게 다양했다. 일단 페이징은 기본으로 구현해야 했고 카테고리로 쿠키들을 탐색할 때 무한 스크롤 방식으로 진행하자고 해서 커서 방식으로도 구현했다.
페이징 방식은 늘 해왔던 방식이라 그리 어렵지 않았는데 문제는 쿼리 dsl로 하는 건 처음이었다. 다행히 전에 부트캠프에서 특강을 해주어서 대략 어떤 식으로 해야 하는지 감을 잡는데 오래 걸리지 않았다. 아래는 키워드 검색하는 부분이다.
@Override
public Response.Page<CookieResponse.List> searchCookieListByKeyword(Pageable pageable, CookieSearch search) {
QUser user = QUser.user;
QCookie cookie = QCookie.cookie;
QChannel channel = QChannel.channel;
QUploadFile thumbnail = new QUploadFile("thumbnailFile");
List<CookieResponse.List> fetch = queryFactory.
select(Projections.constructor(CookieResponse.List.class,
user.id,
user.nickname,
cookie.id,
cookie.title,
thumbnail.s3Url,
cookie.proccessStatus,
cookie.createdAt))
.distinct()
.from(channel)
.join(channel.cookies, cookie)
.join(channel.user, user)
.join(cookie.thumbnailFile, thumbnail)
.where(cookie.proccessStatus.eq(ProcessStatus.SUCCESS).and(byKeyword(search.getKeyword())))
.orderBy(cookie.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long count = queryFactory.select(Wildcard.count)
.distinct()
.from(cookie)
.where(cookie.proccessStatus.eq(ProcessStatus.SUCCESS).and(byKeyword(search.getKeyword())))
.fetchOne();
return Response.Page.of(fetch, count,pageable);
}
BooleanExpression byKeyword(String keyword) {
if(!StringUtils.hasText(keyword)) {
return null;
}
return QCookie.cookie.title.containsIgnoreCase(keyword);
}
동적쿼리의 경우 다른곳에서도 쓸 수 있게 BolleanExpression으로 재사용 가능하도록 구현했다.
다음은 무한 스크롤 방식이다.
@Override
public Response.Slice<CookieResponse.List> getSliceByCategoryId(int size, LocalDateTime cursor,
CookieSearch search) {
QUser user = QUser.user;
QCookie cookie = QCookie.cookie;
QChannel channel = QChannel.channel;
QUploadFile thumbnail = new QUploadFile("thumbnailFile");
QCookieCategory cookieCategory = QCookieCategory.cookieCategory;
QCategory category = QCategory.category;
BooleanBuilder cursorBooleanBuilder = new BooleanBuilder();
if(cursor != null) {
cursorBooleanBuilder.and(cookie.createdAt.lt(cursor));
}
List<CookieResponse.List> fetch = queryFactory.
select(Projections.constructor(CookieResponse.List.class,
user.id,
user.nickname,
cookie.id,
cookie.title,
thumbnail.s3Url,
cookie.proccessStatus,
cookie.createdAt))
.distinct()
.from(cookie)
.join(cookie.cookieCategories, cookieCategory)
.join(cookieCategory.category, category)
.join(cookie.thumbnailFile, thumbnail)
.join(cookie.channel, channel)
.join(channel.user, user)
.where(cursorBooleanBuilder.and(cookie.proccessStatus.eq(ProcessStatus.SUCCESS).and(byCategory(search.getCategoryId()))))
.orderBy(cookie.createdAt.desc())
.limit(size + 1)
.fetch();
boolean hasNextPage = false;
if(fetch.size() > size) {
hasNextPage = true;
fetch.remove(size);
}
return new Response.Slice<>(fetch, hasNextPage, fetch.size(), fetch.isEmpty() ? null : fetch.get(fetch.size() - 1).createdAt());
}
커서는 LocalDateTime으로 만들었고 생성일 기준으로 무한 스크롤을 할 수 있게 구현했다.
반환타입은 기존에 있는 Page, Slice가 아닌 직접 만든 타입이다. 스프링의 페이징과 슬라이스를 반환하면 쓸데없는 정보들이 너무 많아서 새로 클래스를 만들었다.
public sealed interface Response permits Page, Slice {
record Page<T>(
List<T> contents,
int pageNumber,
int pageSize,
long totalElements,
int totalPages
) implements Response {
public static <T>Page<T> of(List<T> contents, Long count, Pageable pageable) {
count = count == null ? 0 : count;
double ceil = Math.ceil(count.floatValue() / (float) pageable.getPageSize());
return new Page<>(contents, pageable.getPageNumber() + 1, pageable.getPageSize(), count, (int) ceil);
}
}
record Slice<T>(
List<T> contents,
boolean hasNextPage,
int size,
LocalDateTime nextCursor
) implements Response{ }
}
조회수
조회수는 현재 중요한 기능은 아니어서 어렵게 구현하지 않았다. 유튜브 같은 경우 조회수가 곧 수익으로 직결되니 어뷰징을 생각해서 구현해야 하는데 현재 조회수관련해서 중요한 것들이 없다 보니 그냥 쿠키의 디테일 정보를 조회 시 views가 1씩 증가하도록 구현했다. 물론 이 방식은 조회할 때마다 쿼리가 한 개씩 나가다 보니 누군가 새로고침을 계속하 거나하면 성능에 악영향을 끼칠 수 있지만, 일단 다른 중요한 기능들이 있어서 최적화는 안 했다. 생각은 해봤는데 cookieId: 1 views: 5 이런식으로 레디스에 저장해 두고 스케쥴러로 하루에 세네 번씩 일괄 업데이트를 진행하면 좋을 것 같다.
고민했던 점
1. 이번 프로젝트는 일반 부트캠프에서하는 프로젝트와 다르게 프런트엔드와 협업을 하다 보니 어떻게 해야 프런트 입장에서 더 편하게 사용하고, 어떻게 restful 한 api인지 계속 생각해 보는 시간이 많았다. 내가 이해한 restful 한 api는 uri에 의도가 나타나야 한다고 생각한다. 그래야 사용하는 사람입장에서도 이게 뭔지 이해하기 쉬울 것 같다.
2. 프론트와 협업하다 보니 프런트 쪽에서도 서버를 실행해서 직접 받아보는 테스트를 하고 싶어 했다. 물론 지금 배포하기에는 aws가 프리티어라 부담이 됐다. 그래서 도커를 깔도록 해서, 도커 컴포즈로 구성을 해주고 프런트 쪽에서도 문제없이 실행하고 테스트를 할 수 있게 환경을 만들어주었다.
'사이드 프로젝트 > 쿠키톡' 카테고리의 다른 글
jwt 인증/인가 (0) | 2024.09.29 |
---|---|
기존의 업로드 방식 리팩토링 (0) | 2024.09.29 |
HLS 변환 (0) | 2024.09.15 |
사이드 프로젝트 시작 (0) | 2024.09.15 |