본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.
커리어 성장을 위한 최고의 실무교육 아카데미 | 패스트캠퍼스
성인 교육 서비스 기업, 패스트캠퍼스는 개인과 조직의 실질적인 '업(業)'의 성장을 돕고자 모든 종류의 교육 콘텐츠 서비스를 제공하는 대한민국 No. 1 교육 서비스 회사입니다.
fastcampus.co.kr
0416 수
학습 내용 정리
03. 좋아요 여부 보여주는 필드 추가하기
수강후기
오늘은 NestJS를 기반으로 한 프로젝트에서 영화 리스트 API 응답에 사용자의 "좋아요 여부"를 포함하는 기능을 구현하였다. 이는 단순히 리스트를 불러오는 기능을 넘어서 사용자 맞춤형 인터페이스를 제공하고자 하는 중요한 단계였다. 특히, 프론트엔드에서 좋아요 UI 버튼의 상태를 제어하거나, 사용자가 이전에 좋아요를 눌렀던 콘텐츠를 빠르게 식별하는 데 도움이 되는 실질적인 기능이기도 하다.
작업은 크게 컨트롤러 레벨과 서비스 레벨로 나눠서 진행하였다. 컨트롤러에서 userId를 받아오는 구조를 수정하고, 서비스에서는 해당 userId를 기반으로 각 영화에 대한 좋아요 여부를 매핑하는 로직을 작성하였다.
먼저 @UserId() 커스텀 데코레이터를 사용하여 인증된 사용자라면 userId를 컨트롤러에서 주입받을 수 있도록 구현하였다. 이는 인증이 필요 없는 퍼블릭 API이면서도 사용자 맞춤형 정보를 제공하기 위한 장치로써 매우 유용하다. NestJS의 장점 중 하나는 이렇게 커스텀 데코레이터를 통한 미들웨어 수준의 데이터 주입이 매우 직관적이고 강력하다는 점이다.
컨트롤러에서는 다음과 같이 getMovies 메서드에서 userId를 인자로 받아 서비스로 넘기도록 처리했다.
@Get()
@Public()
getMovies(
@Query() dto?: GetMoivesDto,
@UserId() userId?: number,
) {
return this.movieService.findAll(dto, userId);
}
이제 핵심은 findAll() 메서드 내에서 이 userId를 이용하여 좋아요 상태를 판별하고, 각 영화 응답 객체에 likeStatus 필드를 추가하는 것이다. 먼저 기존대로 createQueryBuilder()를 사용해 영화 리스트를 조회하고, 여기에 leftJoin을 통해 director, genres 등의 정보를 함께 가져왔다.
그 다음, userId가 존재할 경우에만 추가로 해당 사용자가 좋아요를 누른 영화를 조회한다. 이를 위해 movieUserLikeRepository를 사용해 사용자와 영화 ID 기반으로 다중 조회 쿼리를 작성했다. 이때 IN (:...movieIds) 조건을 사용하여 한번에 여러 영화 ID에 대한 정보를 가져오는 방식으로 성능을 고려했다.
const likedMovies = movieIds.length < 1 ? [] : await this.movieUserLikeRepository.createQueryBuilder('mul')
.leftJoinAndSelect('mul.user', 'user')
.leftJoinAndSelect('mul.movie', 'movie')
.where('movie.id IN(:...movieIds)', { movieIds })
.andWhere('user.id= :userId', { userId })
.getMany();
조회된 결과는 각 영화 ID와 isLike(boolean) 값을 담은 배열이며, 이를 객체 형태의 map으로 변환하였다. 이 map은 영화 ID를 키로, isLike 값을 값으로 하여 빠르게 접근 가능하도록 구성되었다. 그 다음 data.map()을 통해 각 영화 객체에 likeStatus 속성을 추가해주었다. 이 속성은 다음과 같은 세 가지 상태를 가질 수 있다.
- true: 좋아요 상태
- false: 싫어요 상태 (추후 싫어요도 구현될 수 있음)
- null: 좋아요/싫어요 모두 누르지 않은 상태
const likedMovieMap = likedMovies.reduce((acc, next) => ({
...acc,
[next.movie.id]: next.isLike,
}), {});
data = data.map((x) => ({
...x,
likeStatus: x.id in likedMovieMap ? likedMovieMap[x.id] : null,
}));
이 구현에서 가장 중요한 포인트는 불필요한 쿼리를 줄이고, 사용자 인증 유무에 따라 유동적으로 동작하게 하며, 프론트엔드에 필요한 최소한의 정보를 가공하여 제공하는 것이다. 만약 userId가 전달되지 않았다면 해당 로직은 건너뛰게 되며, 좋아요 상태 정보도 포함되지 않는다. 이는 API 사용성을 해치지 않으면서도 인증된 사용자에게는 개인화된 정보를 제공하는 매우 유연한 구조이다.
이 과정에서 나는 몇 가지 기술적인 고민도 하게 되었다. 예를 들어, "likeStatus"를 기존 엔티티에 직접 넣는 방식으로 처리하는 것이 좋은가? 아니면 별도의 DTO를 만들어 응답 구조를 분리해야 하는가에 대한 고민이었다. 현재는 간단하게 ...x, likeStatus: ... 형태로 추가했지만, 이후 응답 포맷이 복잡해지면 직렬화(@Expose, @Transform)를 사용하는 방향도 고려해야 할 것이다.
또 하나 흥미로웠던 점은, 비동기 처리를 효율적으로 하기 위한 쿼리 최적화였다. 만약 영화 리스트가 수십 개, 수백 개가 된다면 각각의 영화마다 좋아요 여부를 확인하는 구조는 매우 비효율적이다. 따라서 IN 절을 활용한 다중 조회 쿼리로 한 번에 정보를 가져오는 구조는 실무에서도 매우 중요한 성능 최적화 테크닉이라고 할 수 있다.
그리고 NestJS에서 데이터 응답을 조작할 때는 불변성을 지켜주는 것이 매우 중요하다. 기존 객체를 수정하지 않고 새로운 객체를 리턴하는 방식으로 구성함으로써, 데이터 흐름을 명확히 하고 예기치 않은 참조 오류를 방지할 수 있었다. 오늘 작성한 map() 함수는 이러한 구조를 잘 따르고 있었고, 이는 추후 상태 관리나 테스트 시에도 장점으로 작용할 것이다.
한편, 이번 작업을 하면서도 예외 처리에 대해 다시 한 번 고민해보게 되었다. 사용자 ID가 유효하지 않거나 영화 ID에 해당하는 좋아요 정보가 없다면 어떻게 응답할 것인가? 이번엔 단순히 null로 처리했지만, 나중에 싫어요 기능까지 확장된다면 이 구조도 조금 더 명확하게 분리해야 할지도 모른다.
마지막으로, 오늘 이 기능을 구현하면서 사용자의 행동 데이터를 기반으로 응답을 맞춤화하는 것이 얼마나 중요한지를 다시 한 번 느꼈다. 단순히 데이터를 나열하는 것과, 사용자 중심으로 데이터를 가공해서 제공하는 것에는 큰 차이가 있다. 오늘 내가 구현한 likeStatus 필드 하나가 사용자 경험에 미치는 영향은 분명 클 것이며, 이는 더 나아가 추천 알고리즘, 개인화된 피드, 선호도 기반 필터링 등 다양한 기능으로 발전할 수 있는 기반이 된다.
앞으로는 이 데이터를 활용해 다음과 같은 기능으로 확장할 수 있을 것이다.
- 좋아요 누른 영화만 필터링해서 보는 전용 API (GET /movie/liked)
- 영화 상세 페이지에서도 likeStatus 반영
- 좋아요 수를 집계하여 인기 순 정렬
- 좋아요 기반 추천 영화 기능 (협업 필터링 기반)
- 사용자 마이페이지에서 좋아요 히스토리 확인
이처럼 단순한 필드 하나의 추가가 전체 서비스 구조와 UX를 어떻게 변화시킬 수 있는지 체감하면서, 내가 만든 코드 한 줄 한 줄이 사용자와의 상호작용에 얼마나 큰 영향을 줄 수 있는지를 실감했다. 앞으로도 이처럼 사용자 중심적인 관점에서 API와 서비스를 설계해 나가고자 한다.
사진



