준영속 객체를 merge 하는 과정에서 발생한 N+1 문제

문제 상황

마이페이지에서 사용자의 닉네임과 프로필 이미지를 수정하는 로직이 있었습니다.

당시 컨트롤러에서는 인증된 사용자 정보를 SiteUser 엔티티 형태로 전달받았습니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/my")
public class MyPageController {

    private final MyPageService myPageService;

    @PatchMapping
    public ResponseEntity<Void> updateMyPageInfo(
            @AuthorizedUser SiteUser siteUser,
            @RequestParam(value = "file", required = false) MultipartFile imageFile,
            @RequestParam(value = "nickname", required = false) String nickname
    ) {
        myPageService.updateMyPageInfo(siteUser, imageFile, nickname);
        return ResponseEntity.ok().build();
    }
}

서비스 계층에서는 전달받은 SiteUser를 직접 수정한 뒤 save()를 호출했습니다.

@Service
@RequiredArgsConstructor
public class MyPageService {

    private final SiteUserRepository siteUserRepository;
    private final S3Service s3Service;

    @Transactional
    public void updateMyPageInfo(
            SiteUser siteUser,
            MultipartFile imageFile,
            String nickname
    ) {
        if (nickname != null) {
            validateNicknameUnique(nickname);
            validateNicknameNotChangedRecently(siteUser.getNicknameModifiedAt());

            siteUser.setNickname(nickname);
            siteUser.setNicknameModifiedAt(LocalDateTime.now());
        }

        if (imageFile != null && !imageFile.isEmpty()) {
            UploadedFileUrlResponse uploadedFile =
                    s3Service.uploadFile(imageFile, ImgType.PROFILE);

            if (!isDefaultProfileImage(siteUser.getProfileImageUrl())) {
                s3Service.deleteExProfile(siteUser);
            }

            siteUser.setProfileImageUrl(uploadedFile.fileUrl());
        }

        siteUserRepository.save(siteUser);
    }
}

문제는 서비스 계층에 전달된 SiteUser가 현재 트랜잭션의 영속성 컨텍스트가 관리하는 엔티티가 아니었다는 점입니다. 즉, 서비스 계층에서 사용하던 SiteUser는 dirty checking 대상이 아닌 준영속 엔티티이었으며, 명시적으로 save()를 호출했던 것입니다.

SiteUser가 서비스 영속성 컨텍스트의 관리 대상이 아니었던 이유

당시 인증 과정에서는 SiteUserDetailsServiceSiteUser 엔티티를 조회했습니다.

@Override
public UserDetails loadUserByUsername(String username) {
    long siteUserId = Long.parseLong(username);

    SiteUser siteUser = siteUserRepository.findById(siteUserId)
            .orElseThrow(() -> new CustomException(AUTHENTICATION_FAILED));

    return new SiteUserDetails(siteUser);
}

당시 OSIV가 꺼져 있었으므로 트랜잭션이 종료되면서 해당 영속성 컨텍스트도 종료되었고, 조회한 SiteUser는 준영속 상태가 되었습니다.

SiteUserDetails는 조회한 SiteUser 엔티티 자체를 필드로 보관하고 있었습니다.

public class SiteUserDetails implements UserDetails {

    @Getter
    private final SiteUser siteUser;

    public SiteUserDetails(SiteUser siteUser) {
        this.siteUser = siteUser;
    }
}

인증이 완료되면 이 SiteUserDetailsPrincipal로 가지는 SiteUserAuthentication이 생성되고, 해당 인증 객체가 Security Context에 저장됩니다.

public SiteUserAuthentication(
        String token,
        SiteUserDetails principal
) {
    super(token, principal);
    setAuthenticated(true);
}

이후 AuthorizedUserResolver는 Principal이 보관하고 있던 SiteUser를 다시 조회하지 않고 그대로 컨트롤러에 전달합니다.

private SiteUser extractSiteUserFromAuthentication() {
    Authentication authentication =
            SecurityContextHolder.getContext().getAuthentication();

    SiteUserDetails principal =
            (SiteUserDetails) authentication.getPrincipal();

    return principal.getSiteUser();
}

따라서 인증 과정에서 조회된 SiteUser 객체가 그대로 컨트롤러를 거쳐 서비스 계층까지 전달되는 구조였습니다.

image.png

즉, 서비스 계층에 전달된 SiteUser는 현재 서비스 계층의 영속성 컨텍스트가 관리하는 엔티티가 아니었고, dirty checking 대상도 아니었습니다.

save()에서 발생한 merge

아래와 같은 코드는 단순히 준영속 Java 객체의 값만 변경합니다.

siteUser.setNickname(nickname);

준영속 엔티티는 dirty checking 대상이 아니기 때문에 트랜잭션이 커밋될 때 UPDATE 쿼리가 발생하지 않습니다.

당시에는 변경 사항을 반영하기 위해 save()를 호출했습니다.

siteUserRepository.save(siteUser);

image.png

Spring Data JPA의 save()는 내부적으로 새로운 엔티티라면 persist(), 그렇지 않다면 merge()를 호출합니다. 현재 다루고 있는 엔티티는 인증 시점에 이미 DB에서 조회한 엔티티이므로 merge()를 호출합니다.

merge()를 호출한다고 준영속 엔티티를 다시 영속 엔티티로 만드는 것은 아닙니다. 먼저 동일한 식별자를 가진 엔티티를 영속성 컨텍스트에 로드하고, 준영속 엔티티의 상태를 복사합니다.

Hibernate는 현재 영속성 컨텍스트에 동일한 식별자의 영속 SiteUser가 없는 경우 병합 대상을 확보하기 위해 SiteUser를 조회하는데, 당시 서비스 영속성 컨텍스트에는 해당 SiteUser가 관리되고 있지 않았기 때문에 한 번 더 조회합니다.

문제는 당시 SiteUser가 여러 엔티티와 연관관계를 가지고 있었으며 CascadeType.ALL이 설정되어 있었다는 점입니다.

@Entity
public class SiteUser {

    @OneToMany(
            mappedBy = "siteUser",
            cascade = CascadeType.ALL,
            orphanRemoval = true
    )
    private List<Post> postList = new ArrayList<>();

    @OneToMany(
            mappedBy = "siteUser",
            cascade = CascadeType.ALL
    )
    private List<Comment> commentList = new ArrayList<>();

    @OneToMany(
            mappedBy = "siteUser",
            cascade = CascadeType.ALL,
            orphanRemoval = true
    )
    private List<PostLike> postLikeList = new ArrayList<>();

    @OneToMany(
            mappedBy = "siteUser",
            cascade = CascadeType.ALL,
            orphanRemoval = true
    )
    private List<LanguageTestScore> languageTestScoreList = new ArrayList<>();

    @OneToMany(
            mappedBy = "siteUser",
            cascade = CascadeType.ALL,
            orphanRemoval = true
    )
    private List<GpaScore> gpaScoreList = new ArrayList<>();
}

CascadeType.ALL 범위에는 MERGE가 포함됩니다.

따라서 SiteUsermerge()하는 과정에서 연관된 엔티티까지 merge 대상으로 처리되면서 불필요한 조회 쿼리가 발생한 것입니다.

해결 1: 영속 엔티티를 직접 조회하도록 변경

이를 해결하기 위해 서비스 계층 트랜잭션 내부에서 사용자 ID로 SiteUser를 다시 조회하도록 변경했습니다.

@Service
@RequiredArgsConstructor
public class MyPageService {

    private final SiteUserRepository siteUserRepository;
    private final S3Service s3Service;

    @Transactional
    public void updateMyPageInfo(
            SiteUser siteUser,
            MultipartFile imageFile,
            String nickname
    ) {
        SiteUser user = siteUserRepository.findById(siteUser.getId())
                .orElseThrow(() ->
                        new CustomException(USER_NOT_FOUND)
                );

        if (nickname != null) {
            validateNicknameNotChangedRecently(user.getNicknameModifiedAt());
            validateNicknameUnique(nickname);

            user.setNickname(nickname);
            user.setNicknameModifiedAt(LocalDateTime.now());
        }

        if (imageFile != null && !imageFile.isEmpty()) {
            UploadedFileUrlResponse uploadedFile =
                    s3Service.uploadFile(imageFile, ImgType.PROFILE);

            if (!isDefaultProfileImage(user.getProfileImageUrl())) {
                s3Service.deleteExProfile(user);
            }

            user.setProfileImageUrl(uploadedFile.fileUrl());
        }
    }
}

기존 방식의 save()가 사라지면서 연관 엔티티를 merge하기 위해 발생하던 불필요한 조회도 제거할 수 있었습니다.

해결 2: 계층 간 엔티티 대신 ID 전달

다만 이 방식에서도 인증 과정에서 이미 조회한 SiteUser를 컨트롤러까지 전달한 뒤, 서비스에서 다시 findById()를 수행한다는 구조적인 문제는 남아 있었습니다.

더 중요한 문제는 인증 과정에서 만들어진 엔티티의 영속성 상태가 컨트롤러와 서비스 계층까지 그대로 노출된다는 점이었습니다.

이후에는 다음과 같이 계층 간에 SiteUser 엔티티 자체를 전달하지 않고 사용자 ID만 전달하도록 변경했습니다.

@PatchMapping
public ResponseEntity<Void> updateMyPageInfo(
        @AuthorizedUser long siteUserId,
        @RequestParam(value = "file", required = false) MultipartFile imageFile,
        @RequestParam(value = "nickname", required = false) String nickname
) {
    myPageService.updateMyPageInfo(
            siteUserId,
            imageFile,
            nickname
    );

    return ResponseEntity.ok().build();
}

서비스 계층에서는 필요한 시점에 현재 영속성 컨텍스트에서 사용자를 조회합니다.

@Transactional
public void updateMyPageInfo(
        long siteUserId,
        MultipartFile imageFile,
        String nickname
) {
    SiteUser siteUser = siteUserRepository.findById(siteUserId)
            .orElseThrow(() ->
                    new CustomException(USER_NOT_FOUND)
            );

    // 변경 로직
}

서비스가 자신의 트랜잭션 안에서 필요한 엔티티를 직접 관리하도록 경계를 명확하게 만들었습니다.

정리

기존 구조에서는 인증 과정에서 조회한 SiteUser 엔티티가 Principal에 보관된 뒤 준영속 상태로 컨트롤러와 서비스까지 전달되었습니다.

서비스에서 해당 객체를 수정하고 save()를 호출하면서 merge()가 수행됐고, CascadeType.ALL로 연결된 연관 엔티티까지 merge 대상이 되면서 불필요한 조회가 발생했습니다.

이를 해결하기 위해 서비스 트랜잭션 내부에서 SiteUser를 다시 조회하여 영속 상태의 엔티티를 수정하고, save() 대신 dirty checking을 사용하도록 변경했습니다.

이후에는 계층 간에 엔티티 자체를 전달하지 않고 siteUserId만 전달하도록 변경했습니다.

연관 PR