https://github.com/Gseungmin/playlist/pull/4
<aside>
PlayListItem에는 순서 정보를 표시해야 한다.
가장 쉬운 방식은 0,1,2,3,4,5 이런식으로 부여하는것이다.
하지만 이는 중간에 한 데이터의 순서를 바꾸면 뒤의 전체 데이터 순서가 바뀌게 된다.
따라서 최악의 경우 N개에 대한 업데이트 쿼리가 날라간다.
이중 연결리스트로 하면 순서 정보만 바뀐 데이터에 대해서 업데이트만 진행하면 된다.
즉 0,1,2,3,4,5 순서 방식에 비해 업데이트문을 훨씬 효율적으로 작성할 수 있다.
처음에는 이중연결 리스트 자료구조를 선택했지만 이 자료구조의 가장 큰 문제는 페이징이다.
이중 연결 리스트를 가지려면 이전 노드와 앞 노드에 대한 Id 정보를 추가로 필드로 가진다.
그리고 별도의 순서 정보가 아닌 이 ID 정보를 통해 페이징을 진행해야한다.
따라서 페이징을 구현할때 모든 데이터를 어플리케이션으로 불러온 후 커서를 기반으로 자른다.
이는 커서기반 페이지네이션의 장점을 활용하지 못하고 메모리 로드되는 비용이커지므로 좋지 않다
Gap-based numbering 방식은 10000, 20000, 30000 이런식으로 오더를 주는 것이다.
즉 순서를 0,1,2,3,4 이런 연속적인 아닌 특정 GAP으로 주는 것이다.
이로 인해 만약 30000의 오더를 가진 데이터가 10000과 20000사이로 오게 된다면 이 둘의 평균값인 15000으로 오는 방식이다.
즉, 10000, 15000, 20000이 되는 것이다.
이를 통해서 하나의 데이터만 업데이트해도 되는 것이므로 업데이트에서의 비효율 문제를 해결한다.
또한 Long 타입을 통해 순서 정보를 가지고 있으므로 페이징쿼리도 효율적이다.
사실 갭은 클수록 좋다. 그 이유는 갭이 더이상 좁혀질 수 없을 때 리오더링을 진행하기 때문이다.
리오더링의 성능은 좋지 않다. 모든 데이터에 대해 순서를 업데이트해야하기 때문이다.
기본적으로 현재 순서는 position이라는 필드로 Long타입을 가지고 있다.
또한 플레이리스트당 최대 1000개의 노래를 가질 수 있다는 점을 고려했을때 GAP을 Long의 최댓값 // 1000 과 근사한 수치로 가지면 된다.
9214157878975800L
위의 값을 Gap으로 설정한다. 즉, 첫 순서의 값이 9214157878975800L
, 두번째 순서의 값이 9214157878975800L * 2
이다.
</aside>
<aside>
@Entity
@Getter
@Setter(AccessLevel.NONE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
uniqueConstraints = {
@UniqueConstraint(
name = "uk_playlist_music",
columnNames = { "playListId", "musicId" }
),
@UniqueConstraint(
name = "uk_playlist_position",
columnNames = { "playListId", "position" }
)
}
)
public class PlayListItem extends BaseEntity {
@Id @SnowflakeId
@GeneratedValue
@Column(name = "playListItemId")
private Long id;
@Column(nullable = false)
@Comment("정렬용 숫자 값으로 같은 플레이리스트 내에서 유일해야 한다")
private Long position;
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "playListId")
private PlayList playList;
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "musicId")
private Music music;
}
B 트리는 균형잡힌 트리 구조를 가지기 위해 페이지 분할과 페이지 병합이 발생한다.
당연히 이 과정은 성능에 문제가 될 수 있다.
하지만 인덱스는 최악의 경우 페이지 분할과 병합이 동시에 나타날 수 있고 이는 성능저하의 원인이 될 수 있다.
우리는 순서 정보에 멀티컬럼 인덱스가 적용이 되어있다. position
필드를 보면 알 수 있다.
따라서 최소한의 배치 처리를 하여 트리의 변동의 수를 줄인다.
</aside>
<aside>
캐시와 배치를 통한 성능최적화 에서 볼 수 있듯이, 캐시와 배치 처리를 적절히 사용하여 배치 업데이트를 할 수 있다.
이렇게 하면 당연히 배치 처리가 줄어들어 성능은 높아질 수 있다.
하지만 중요한건 성능테스트이고 상황에 따라 position 필드에 대한 인덱스 제거도 고려해볼만하다.
그 이유는 PlayListItem 자체의 로우가 최대 1000개이다. 그리고 보통 100개 이하일 것이다.
따라서 인덱스를 업데이트하는 비용과 100를 탐색하는 비용에서의 성능 차이를 고려해봐야 한다.
</aside>
<aside>
배치처리를 하는 과정에서 순서에 대한 복합키 중복 문제가 발생할 수 있다.
이럴 경우 서버 단에서 해당 플레이리스트의 전체 곡에 대한 리오더링을 진행한다.
private PlayListReOrderResponse bulkUpdate(
List<ReorderPlayListItemsRequest> dto,
Long playListId
) {
try {
jdbcBulkRepository.bulkUpdatePosition(dto);
return new PlayListReOrderResponse(true, dto);
} catch (DuplicateKeyException ex) {
List<ReorderPlayListItemsRequest> reorderList = reorderingPositions(playListId);
jdbcBulkRepository.bulkUpdatePosition(reorderList);
return new PlayListReOrderResponse(false, reorderList);
}
}
private List<ReorderPlayListItemsRequest> reorderingPositions(Long playListId) {
List<PlayListItemProjection> playListItemAll =
playListItemRepository.findPlayListItemAll(playListId);
List<ReorderPlayListItemsRequest> reorderList = new ArrayList<>(playListItemAll.size());
long position = GAP;
for (PlayListItemProjection p : playListItemAll) {
reorderList.add(new ReorderPlayListItemsRequest(p.getPlayListItemId(), position));
position += GAP;
}
return reorderList;
}
하지만 findPlayListItemAll
과정에서 최대 1000개의 데이터가 로드될 수 있다.
한두번의 요청에서는 괜찮겠지만 대규모 요청에서는 메모리 사용량이 급증할 수 있다.
따라서 DTO 프로젝션을 원하는 데이터만 로드하여 메모리 효율을 높인다.
</aside>
<aside>
해당 페이지에서 GAP-BASED_NUMBERING 방식이 더 좋은 성능을 가지는 것을 테스트 했다
</aside>
<aside>
사용자 요청에 대한 유효성 검사가 필요하다.
@Query("select pli from PlayListItem pli " +
"join fetch pli.playList pl " +
"join fetch pl.member m " +
"where pli.id in :ids")
List<PlayListItem> findPlayListItemForUpdate(List<Long> ids);
</aside>