본문 바로가기
BackEnd/Project

[SNS] Ch04. 코드 최적화

by 개발 Blog 2024. 9. 10.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

이전에는 대규모 트래픽이 발생할 때 SNS 애플리케이션에서 어떤 문제점들이 발생할 수 있는지 살펴보았다. 이번 시간에는 중복된 DB IO 감소 및 쿼리 최적화 문제를 해결하는 과정을 다룬다.

1. 중복된 DB 조회 문제

우선, 알람 리스트를 호출했을 때 발생하는 쿼리 문제를 확인한다. 현재 JWT 토큰 검증 과정과 유저 서비스에서 유저 정보를 조회하는 중복된 쿼리가 발생하고, 마지막으로 알람을 유저 ID로 가져올 때 또 한 번 쿼리가 실행된다. 총 세 번의 SELECT 쿼리가 발생하는 것이다. 이를 하나로 줄이는 작업을 진행한다.

 

알람 호출 시 쿼리 확인

총 3번의 SELECT 쿼리가 발생한다.

  1. JWT 토큰 체크
  2. 유저 서비스에서 유저를 다시 조회
  3. 알람을 유저 ID로 가져오는 쿼리

 

이를 최적화하여 알람 조회 시 유저 엔티티를 중복 조회하지 않도록 코드를 수정한다.

2. 코드 수정

UserController 수정

// 수정 전
@GetMapping("/alarm")
public Response<Page<AlarmResponse>> alarm(Pageable pageable, Authentication authentication) {
    return Response.success(userService.alarmList(authentication.getName(), pageable).map(AlarmResponse::fromAlarm));
}

// 수정 후
@GetMapping("/alarm")
public Response<Page<AlarmResponse>> alarm(Pageable pageable, Authentication authentication) {
    User user = ClassUtils.getSafeCastInstance(authentication.getPrincipal(), User.class)
        .orElseThrow(() -> new SnsApplicationException(ErrorCode.INTERNAL_SERVER_ERROR,
        "Casting to User class failed"));
    return Response.success(userService.alarmList(user.getId(), pageable).map(AlarmResponse::fromAlarm));
}
  • 변경 내용: authentication.getName()으로 사용자의 이름을 가져오던 부분을 authentication.getPrincipal()로 사용자 객체를 받아 User 클래스의 인스턴스로 변환해, 사용자 이름 대신 사용자 ID를 직접 가져오는 방식으로 변경했다.
  • 이유: 사용자 이름을 통해 사용자 ID를 매번 조회하는 것은 불필요한 작업이므로, ID를 바로 사용하는 방식으로 성능을 개선했다. ClassUtils.getSafeCastInstance() 유틸을 사용해 안전하게 타입 캐스팅을 하고, 캐스팅 실패 시 예외를 던지도록 처리했다.

AlarmEntityRepository 수정

// 수정 전
@Repository
public interface AlarmEntityRepository extends JpaRepository<AlarmEntity, Integer> {
    Page<AlarmEntity> findAllByUser(UserEntity user, Pageable pageable);
}

// 수정 후
@Repository
public interface AlarmEntityRepository extends JpaRepository<AlarmEntity, Integer> {
    Page<AlarmEntity> findAllByUserId(Integer userId, Pageable pageable);
}
  • 변경 내용: UserEntity 객체로 알람을 조회하던 메서드를, 이제는 userId로 직접 조회하도록 변경했다.
  • 이유: 전체 UserEntity 객체를 전달하는 대신, 필요한 값인 사용자 ID만 전달해 불필요한 객체 사용을 줄였다. 이는 성능 향상에 기여한다.

UserService 수정

// 수정 전
public Page<Alarm> alarmList(String userName, Pageable pageable) {
    UserEntity userEntity = userEntityRepository.findByUserName(userName)
        .orElseThrow(() -> new SnsApplicationException(ErrorCode.USER_NOT_FOUND, String.format("%s not founded", userName)));
    return alarmEntityRepository.findAllByUser(userEntity, pageable).map(Alarm::fromEntity);
}

// 수정 후
public Page<Alarm> alarmList(Integer userId, Pageable pageable) {
    return alarmEntityRepository.findAllByUserId(userId, pageable).map(Alarm::fromEntity);
}
  • 변경 내용: userName을 통해 UserEntity를 조회하던 로직을 제거하고, 직접적으로 userId를 받아 알람을 조회하도록 변경했다.
  • 이유: 사용자의 이름을 통해 매번 UserEntity를 조회하는 것이 비효율적이기 때문에, 사용자의 ID를 바로 받아서 사용해 성능을 최적화했다.

ClassUtils 추가

package com.example.sns.util;

import java.util.Optional;

public class ClassUtils {

    public static <T> Optional<T> getSafeCastInstance(Object o, Class<T> clazz) {
        return clazz != null && clazz.isInstance(o) ? Optional.of(clazz.cast(o)) : Optional.empty();
    }
}
  • 역할: 주어진 객체를 지정된 클래스 타입으로 안전하게 캐스팅하는 역할을 한다. 캐스팅이 실패할 경우 예외를 발생시키지 않고 Optional.empty()를 반환해 안전한 타입 변환을 보장한다.
 

코드를 최적화한 후에는 알람 테이블 조회 전에 유저 정보를 한 번만 조회하게 되어 중복된 DB 조회가 제거되었다.

3. LIKE 부분 간단 수정

다음으로 Like 기능을 간단히 살펴본다. Like 관련 코드는 성능 향상과 데이터 타입의 일관성을 위해 수정되었다. 주요 변경 사항으로는 countByPost 메서드의 반환 타입을 Integer에서 long으로 변경한 점이다. 이에 따라 서비스 계층과 컨트롤러 계층에서도 관련 코드를 함께 수정했다.

@Repository
public interface LikeEntityRepository extends JpaRepository<LikeEntity, Integer> {

    Optional<LikeEntity> findByUserAndPost(UserEntity user, PostEntity post);

//    @Query(value = "SELECT COUNT(*) FROM LikeEntity entity WHERE entity.post =:post")
//    Integer countByPost(@Param("post") PostEntity post);

    long countByPost(PostEntity post);

    List<LikeEntity> findAllByPost(PostEntity post);

    @Transactional
    @Modifying
    @Query("UPDATE LikeEntity entity SET deleted_at = NOW() where entity.post = :post")
    void deleteAllByPost(@Param("post") PostEntity postEntity);

}
  • 변경 사항: countByPost 메서드의 반환 타입을 Integer에서 long으로 변경했다.
  • 이유: long 타입은 데이터가 매우 클 경우에도 안전하게 처리할 수 있기 때문에, count와 같은 값에 적합하다. 또한 일관성 있는 데이터 타입을 유지하기 위해 관련된 코드도 모두 long 타입으로 맞추는 것이 좋다.

postService 수정

public long likeCount(Integer postId) {}
  • 변경 사항: 반환 타입을 int에서 long으로 변경했다.
  • 이유: countByPost 메서드가 long을 반환하도록 변경되었으므로, 서비스 계층에서도 동일한 타입을 사용하여 반환 타입 불일치를 방지하고 코드의 일관성을 유지한다.

postController 수정

@GetMapping("/{postId}/likes")
    public Response<Long> likeCount(@PathVariable Integer postId, Authentication authentication) {
        return Response.success(postService.likeCount(postId));
    }
  • 변경 사항: 반환 타입을 Long으로 변경했다.
  • 이유: 서비스 계층에서 반환되는 long 타입에 맞추어 컨트롤러 계층에서도 동일하게 반환 타입을 Long으로 설정했다. 이로 인해 API에서 like 개수를 클라이언트로 전달할 때 타입 불일치가 발생하지 않도록 했다.

4. N+1 문제 확인 및 해결 과정

ManyToOne 관계에서 발생하는 N+1 문제

먼저, AlarmEntity와 UserEntity 간의 관계에서 발생하는 N+1 문제를 확인하기 위해 로그를 분석한다. 기본적으로 ManyToOne 관계는 EAGER 로딩이 적용되어 있어서, 알람 데이터를 조회할 때마다 관련된 유저 데이터를 즉시 불러온다. 이로 인해 불필요한 중복 쿼리가 발생하게 된다.

 

알람 조회 전 먼저, EAGER 로딩으로 설정된 AlarmEntity 코드를 살펴본다.

  • AlarmEntity 클래스는 UserEntity와 ManyToOne 관계로 설정되어 있으며, 기본적으로 즉시 로딩(EAGER) 전략을 따르고 있다. 즉, 알람 데이터를 조회할 때 유저 데이터도 함께 로딩된다.
  • 그러나 이 과정에서 N+1 문제가 발생할 가능성이 있다.

N+1 문제 발생

알람 테이블에서 데이터를 가져올 때, 그와 연관된 유저 데이터를 조인해서 가져와야 하는데, 이를 비효율적으로 처리할 경우 N번의 추가 쿼리가 발생할 수 있다. 예를 들어, 알람 엔티티를 조회한 후 해당 알람에 연결된 유저 데이터를 각각 다시 가져오는 경우, 불필요하게 여러 번의 데이터베이스 조회가 발생한다.

 

이 문제는 아래와 같은 형태로 발생할 수 있다.

 

첫 번째로 알람 테이블을 조회한다.

알람 테이블의 데이터와 유저 테이블의 데이터를 같이 결합해서 가져오고 싶을 때 조인을 사용한다. 아래와 같이 알람 테이블 뒤에 유저 테이블이 붙어서 나오는 형태가 기대한 결과다.

하지만 비효율적으로 조인을 하게 될 수도 있다. 아래 쿼리 결과를 보면, 처음에는 alarm 테이블에서 데이터를 가져오고, 그 후에 user 테이블에서 연관된 데이터를 각각 다시 가져오는 쿼리가 발생한다.

이런 형태의 쿼리는 성능에 영향을 미칠 수 있다.

 

LAZY 로딩을 통한 개선

로딩 전략을 LAZY로 변경하면, 알람 테이블에서 데이터를 가져올 때는 유저 데이터가 로딩되지 않고, 실제로 유저 데이터를 참조할 때 그때서야 추가 쿼리가 발생한다.

 

하지만, N+1 문제의 근본적인 해결은 아니며, 필요할 때만 데이터를 가져오는 것으로 쿼리 최적화를 할 수 있다.

 

N+1 문제 해결을 위한 전략

근본적인 해결 방법은 필요 없는 데이터를 로드하지 않거나, 필요한 데이터를 한 번에 가져올 수 있는 쿼리를 직접 작성하는 것이다.

예를 들어, join fetch를 사용하여 한 번의 쿼리로 알람과 유저 데이터를 모두 가져오는 방식으로 최적화할 수 있다.

5. 게시글 삭제 시 댓글과 좋아요 같이 삭제 처리

게시글을 삭제할 때 게시글만 삭제되고, 그에 딸린 댓글이나 좋아요는 삭제되지 않는 문제가 있었다. 실제 서비스에서는 게시글이 삭제될 때, 해당 게시글에 달린 댓글과 좋아요도 함께 삭제되어야 한다. 이를 수정하여, 게시글 삭제 시 해당하는 댓글과 좋아요도 함께 삭제되도록 처리한다.

 

5.1 기존 방식의 문제

기존에는 게시글을 삭제할 때 게시글만 삭제되고, 댓글이나 좋아요는 따로 삭제되지 않았다. 이를 해결하기 위해 게시글 삭제 시 해당하는 댓글과 좋아요를 함께 삭제하는 로직을 추가한다. 그러나 JPA의 기본 삭제 방식은 각 데이터를 개별적으로 조회한 후 하나씩 삭제하는 방식으로, 성능 저하가 발생할 수 있다.

 

게시글 삭제 전, 댓글 4개가 달린 게시글을 삭제할 때 아래와 같이 각각 select로 데이터를 조회하고 하나씩 삭제하는 비효율적인 과정이 로그에 나타난다.

  • 이처럼 각 데이터를 일일이 조회하고 삭제하는 방식은 비효율적이며, 삭제해야 할 데이터가 많을수록 성능이 저하되는 문제를 일으킨다.

5.2 수정 후 – 댓글과 좋아요 함께 삭제

이 문제를 해결하기 위해 PostService에서 게시글을 삭제할 때 연관된 댓글과 좋아요도 함께 삭제되도록 로직을 추가한다. 이를 통해 게시글 삭제 시 연관된 데이터를 일일이 조회할 필요 없이, 한 번에 삭제 처리가 가능하다.

 

수정된 PostService는 아래와 같다.

likeEntityRepository.deleteAllByPost(postEntity);
commentEntityRepository.deleteAllByPost(postEntity);
  • 이 코드는 게시글과 연관된 좋아요와 댓글을 게시글 삭제 시 함께 삭제하는 역할을 한다. 이를 통해 게시글 삭제 시 연관된 데이터를 함께 삭제한다.

5.3 댓글 및 좋아요 삭제를 위한 Repository 수정

각각의 리포지토리에서도 댓글과 좋아요를 한꺼번에 삭제하기 위한 메서드를 추가한다. 이를 통해 게시글 삭제 시 연관된 데이터를 함께 삭제할 수 있게 한다.

 

CommentEntityRepository 수정

수정 전 

@Repository
public interface CommentEntityRepository extends JpaRepository<CommentEntity, Integer> {

    Page<CommentEntity> findAllByPost(PostEntity post, Pageable pageable);
}

 

수정 후 

@Repository
public interface CommentEntityRepository extends JpaRepository<CommentEntity, Integer> {

    Page<CommentEntity> findAllByPost(PostEntity post, Pageable pageable);

    @Transactional
    @Modifying
    @Query("UPDATE CommentEntity entity SET deleted_at = NOW() where entity.post = :post")
    void deleteAllByPost(@Param("post") PostEntity postEntity);

//    void deleteAllByPost(PostEntity post);
}
  • 변경 사항: deleteAllByPost 메서드를 추가해 특정 게시글과 연관된 모든 댓글을 한 번에 삭제하는 로직을 추가했다. 단, 실제 삭제가 아닌 소프트 딜리트를 적용하기 위해 deleted_at 필드를 업데이트하는 쿼리를 작성했다.
  • 이유: JPA의 기본 삭제 방식은 각 데이터를 개별적으로 조회한 후 삭제하는 방식이므로, 게시글과 연관된 모든 데이터를 한꺼번에 처리하기 위해 직접 쿼리를 작성했다. 이를 통해 성능을 최적화하고 데이터의 무결성을 보장할 수 있다.

LikeEntityRepository 수정

수정 전 

@Repository
public interface LikeEntityRepository extends JpaRepository<LikeEntity, Integer> {

    Optional<LikeEntity> findByUserAndPost(UserEntity user, PostEntity post);

    @Query(value = "SELECT COUNT(*) FROM LikeEntity entity WHERE entity.post =:post")
    Integer countByPost(@Param("post") PostEntity post);

    List<LikeEntity> findAllByPost(PostEntity post);
}

 

수정 후 

@Repository
public interface LikeEntityRepository extends JpaRepository<LikeEntity, Integer> {

    Optional<LikeEntity> findByUserAndPost(UserEntity user, PostEntity post);

//    @Query(value = "SELECT COUNT(*) FROM LikeEntity entity WHERE entity.post =:post")
//    Integer countByPost(@Param("post") PostEntity post);

    long countByPost(PostEntity post);

    List<LikeEntity> findAllByPost(PostEntity post);

    @Transactional
    @Modifying
    @Query("UPDATE LikeEntity entity SET deleted_at = NOW() where entity.post = :post")
    void deleteAllByPost(@Param("post") PostEntity postEntity);

}
  • 변경 사항: deleteAllByPost 메서드를 추가해 특정 게시글과 연관된 모든 좋아요 데이터를 한 번에 삭제하는 로직을 추가했다. 이 역시 소프트 딜리트를 적용하여 deleted_at 필드를 업데이트하는 쿼리로 처리된다.
  • 이유: 게시글 삭제 시 연관된 좋아요 데이터를 개별적으로 삭제하는 것이 아니라, 한 번의 쿼리로 모든 데이터를 처리하여 성능을 최적화하고 데이터 처리 속도를 개선했다.

5.4 웹 테스트 결과

수정 후 실제 게시글을 삭제할 때, 해당 게시글에 달린 댓글과 좋아요가 함께 삭제되는 것을 확인한다. 

 

서버 로그를 통해 삭제가 정상적으로 처리되었음을 알 수 있다.

  • 게시글이 삭제되면서 관련된 댓글과 좋아요도 함께 소프트 딜리트 처리되는 것을 확인한다.

5.5 최종 결과 확인

아래는 삭제 후 post 테이블과 comment 테이블에서 삭제 처리된 데이터를 확인한 결과이다. 두 쿼리 모두 삭제된 데이터를 확인할 수 있으며, deleted_at 필드에 삭제 시간이 기록된다.

이렇게 게시글과 연관된 댓글과 좋아요가 함께 삭제 처리되는 것을 확인할 수 있다.

 

이번 작업을 통해 게시글 삭제 시 연관된 댓글과 좋아요도 함께 삭제되도록 수정했다. 불필요한 데이터를 효율적으로 처리함으로써 성능 최적화를 이루었으며, 전체적인 삭제 프로세스가 더욱 일관성 있게 개선되었다.