Spring Data MongoDB + Aggregation 사용해보기

강동준·2022년 5월 4일
1

개발 기록

목록 보기
1/1
post-thumbnail

원하는 출력 결과

전 날 기준 좋아요를 가장 많이 받은 플레이리스트 상위 4개


본격적인 개발 전 준비

영화에 대한 데이터를 가지고 올 Open API 사이트

클릭 시 Movie API 사이트로 이동됩니다.

개발 환경

  • Spring Boot Version : 2.6.6
  • JDK : Open JDK 1.8
  • IDE : IntelliJ
  • Build Tool : Gradle
  • DBMS : MongoDB
  • Test Tool : Junit5

사용한 라이브러리

/* starter */
implementation 'org.springframework.boot:spring-boot-starter'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

/* MongoDB */
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'

/* lombok */
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

/* Test */
testImplementation group: 'junit', name: 'junit', version: '4.13.2'

/* REST API 호출 */
implementation('com.mashape.unirest:unirest-java:1.4.9')

/* Common Util */
implementation 'org.apache.commons:commons-lang3:3.5' -> StringUtils 사용

MongoDB Config

MongoTemplate를 사용하기 위한 빈 등록 및 MongoDB 설정

@Configuration
@EnableMongoRepositories(basePackages = "Document 객체가 있는 패키지 주소", mongoTemplateRef = "testMongoTemplate")
@EnableMongoAuditing // EnableJpaAuditing과 같은 역할, 밑의 BaseTimeException을 위해서 사용됩니다.
public class MongoConfig {
    @Bean
    public MongoTemplate testMongoTemplate(MongoClient mongoClient) {
        MongoDatabaseFactory factory = new SimpleMongoClientDatabaseFactory(mongoClient, "local");
        return new MongoTemplate(factory);
    }
}

Document 준비

다른 API 개발에 필요하여 현재 기능과는 상관없는 필드가 존재합니다!!

PlayList

@Getter
@Document(collection = "playList") // Document명
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 빈 생성자가 생기는 것을 방지합니다.
public class Playlist extends BaseTimeEntity {

    @Id
    private ObjectId _id;
    private String playlistTitle;
    private Long memberId;
    private String playlistDescription;
    private List<Tag> tags;
    private List<Integer> movieIds;
    private boolean display;

    @Builder
    public Playlist(ObjectId _id, String playListTitle, Long memberId, 
    	String playlistDescription, List<Tag> tags, List<Integer> movieIds, boolean display) {
        
        this._id = _id;
        this.playlistTitle = playlistTitle;
        this.memberId = memberId;
        this.playListDescription = playListDescription;
        this.tags = tags;
        this.movieIds = movieIds;
        this.display = display;
    }
}

Tag

@Getter
@Document(collection = "tag")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Tag extends BaseTimeEntity {
    @Id
    private ObjectId _id;
    private String tagName;

    @Builder
    public Tag(ObjectId _id, String tagName) {
        this._id = _id;
        this.tagName = tagName;
    }
}

Like

@Getter
@Document(collection = "like")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Like extends BaseTimeEntity {
    @Id
    private ObjectId _id;
    private Long memberId;
    private ObjectId targetId; // 영화 Document의 ObjectId와 매핑
    private String likeType;

    @Builder
    public Like(ObjectId _id, Long memberId, ObjectId targetId, String likeType) {
        this._id = _id;
        this.memberId = memberId;
        this.targetId = targetId;
        this.likeType = likeType;
    }
}

Like와 Playlist의 JOIN 데이터가 매핑될 객체

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PlaylistLikeJoin {

    private List<Playlist> playlist;
    private int likeCount;

    @Builder
    public PlaylistLikeJoin(int likeCount, List<Playlist> playlist) {
        this.likeCount = likeCount;
        this.playlist = playlist;
    }
}

출력 데이터가 매핑될 DTO 객체

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PlaylistOrderByLikeDto {

    @Id
    private ObjectId _id;
    private String playlistTitle;
    private String memberName;
    private List<String> representativeImagePath;
    private List<Tag> tags;
    private int moiveCount;
    private int likeCount;

    @Builder
    public PlaylistOrderByLikeDto(ObjectId _id, String playlistTitle, String memberName, List<String> representativeImagePath,
                                  List<Tag> tags, int movieCount, int likeCount) {
        this._id = _id;
        this.playlistTitle = playlistTitle;
        this.memberName = memberName;
        this.representativeImagePath = representativeImagePath;
        this.tags = tags;
        this.moiveCount = movieCount;
        this.likeCount = likeCount;
    }
}

BaseTimeEntity

데이터를 저장 혹은 수정할 때 등록일이나 수정일을 수동으로 셋팅하지 않기 위한 클래스입니다.
사용 하기 위해서 MongoConfig 클래스에 @EnableMongoAuditing 어노테이션을 등록해야합니다.

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Column(name = "reg_date")
    private LocalDateTime regDate; // 등록일

    @LastModifiedDate
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Column(name = "mod_date")
    private LocalDateTime modDate; // 수정일

    public LocalDateTime getRegDate() {
        return regDate;
    }
    public LocalDateTime getModDate() {
        return modDate;
    }
}

Repositories

LikeRepository

MongoRepository 외에 조금 더 상세한 작업이 가능한 MongoTemplate를 사용하기 위해서 커스텀된 레포지토리도 상속받습니다.

@Repository
public interface PlaylistRepository extends MongoRepository<Like, ObjectId>, CustomizedLikeRepository {

   
}

CustomizedLikeRepository

위에서 언급한 커스텀 레포지토리의 Interface

public interface CustomizedLikeRepository {

    AggregationResults<PlaylistLikeJoin> filterByTypeAndGroupByTargetId(Aggregation likeAggregation, String collection);
}

CustomizedLikeRepositoryImpl

커스텀 레포지토리의 구현 클래스, MongoTemplate를 사용할 수 있습니다.

public class CustomizedLikeRepositoryImpl implements CustomizedLikeRepository {

    MongoTemplate mongoTemplate;

    public CustomizedLikeRepositoryImpl(MongoTemplate mongoTemplate){
        this.mongoTemplate = mongoTemplate;
    }

    @Override
    public AggregationResults<PlaylistLikeJoin> filterByTypeAndGroupByTargetId(Aggregation likeAggregation, String collection) {
        return mongoTemplate.aggregate(likeAggregation, collection, PlaylistLikeJoin.class);
    }
}

테스트 코드

테스트 코드에서의 BaseException와 MovieException은 커스텀으로 만들어진 Exception 클래스입니다.

@Slf4j
@SpringBootTest
class PlaylistApiServerApplicationTests {

    @Autowired
    PlaylistRepository playlistRepository;

    @Autowired
    TagRepository tagRepository;

    @Autowired
    LikeRepository likeRepository;
    
    @Autowired
    MovieApi movieApi;

  	// Open API 사이트에 회원가입 후 발급받을 수 있습니다. 자세한 설명은 구글링해주세요.
    String API_KEY = "";
   	
  	// Open API 사용시 기본 URL입니다.
    String BASE_URL = "https://api.themoviedb.org/3/";

  	// 영화의 이미지 주소에 대한 기본 URL입니다. 뒤에 w500은 이미지 사이즈입니다(변경 가능, 필수)
    String IMG_BASE_URL = "https://image.tmdb.org/t/p/w500";

  	// JSON 데이터를 자바 객체로 변경하거나 반대의 상황 시 사용
    @Autowired
    ObjectMapper om;

    @Test
    @DisplayName("좋아요 순 플레이리스트 조회 API테스트")
    void aggregationTest(){
        Map<String, Object> param = new HashMap<>();
        param.put("api_key", movieApi.getAPI_KEY());
        HttpClientRequest request = new HttpClientRequest();
        
		// like Document와 playlist Document를 JOIN하는 LookupOperation
        AggregationOperation lookupOperation = context -> new Document(
                "$lookup",
                new Document("from", "playlist")
                        .append("let", new Document("targetId",  "$_id"))
                        .append("pipeline", Collections.singletonList(new Document("$match", new Document("$expr", new Document("$and",
                                Arrays.asList(
                                        new Document("$eq", Arrays.asList("$_id", "$$targetId")),
                                        new Document("$eq", Arrays.asList("$display", true))
                                ))))
                        ))
                        .append("as","playlist")
        );

        Aggregation likeAggregation = Aggregation.newAggregation(
                Aggregation.project("targetId", "regDate", "likeType"),
                Aggregation.match(Criteria.where("regDate").gte(LocalDate.now().minusDays(1)).and("likeType").is("M")),
                Aggregation.group("targetId").count().as("likeCount"),
                Aggregation.sort(Sort.Direction.DESC, "likeCount"),
                Aggregation.limit(4),
                lookupOperation,
                Aggregation.match(Criteria.where("playlist").not().size(0))
        );

        List<PlaylistOrderByLikeDto> filterByTypeAndGroupByTargetId = likeRepository.filterByTypeAndGroupByTargetId(likeAggregation, "like")
                .getMappedResults()
                .stream()
                .map( playlistLikeJoin -> {
                    Playlist playlistInfo = playlistLikeJoin.getPlaylist().get(0);
                    List<String> imgPathList = new ArrayList<>();
                    List<Integer> movieIds = playlistInfo.getMovieIds();
                    for(int movieId : movieIds){
                        String img = movieApi.getIMG_BASE_URL();
                        request.setUrl(String.format("%s/movie/%d/images", movieApi.getBASE_URL(), movieId));
                        request.setData(param);
                        try {
                            Map<String, Object> response = om.readValue(HttpClient.get(request), HashMap.class);
                            if(response.get("status_code") != null){
                                if(StringUtils.equals(response.get("status_code").toString(), "7") ){
                                    throw new MovieApiException(response.get("status_massage").toString(), ErrorCode.INVALID_API_KEY);
                                }
                                continue;
                            }
                            List<Map<String, Object>> posterData = (List<Map<String, Object>>) response.get("posters");
                            imgPathList.add(movieApi.getIMG_BASE_URL() + posterData.get(0).get("file_path").toString());

                        } catch (Exception e) {
                            throw new BaseException(ErrorCode.COMMON_SYSTEM_ERROR);
                        }
                    }

                    return PlaylistOrderByLikeDto.builder()
                            ._id(playlistInfo.get_id())
                            .playlistTitle(playlistInfo.getPlaylistTitle())
                            .memberName(memberRepository.findByMemberId(playlistInfo.getMemberId())
                                    .orElseThrow(() -> new BaseException(ErrorCode.USER_NOT_FOUND))
                                    .getMemberName())
                            .representativeImagePath(imgPathList)
                            .tags(playlistInfo.getTags())
                            .movieCount(playlistInfo.getMovieIds().size())
                            .likeCount(playlistLikeJoin.getLikeCount())
                            .build();
                })
                .collect(Collectors.toList());
                
        for(PlayListOrderByLikeDto playlist : filterByTypeAndGroupByTargetId){
            log.info("플레이리스트 : {}", playlist.toString());
        }
    }
}
profile
성장을 멈추지 않는 백엔드개발자입니다.

0개의 댓글