내일배움캠프의 최종프로젝트에서 우리 팀은 음식을 AI에게 추천받고 주변맛집을 검색할 수 있는 웹서비스를 설계했다. 나는 여기서 배포를 맡았는데 배포에 들어가기 전, 유저의 프로필 사진을 업로드할 수 있는 기능을 맡아 하기로 했다.
유저와 첨부파일 이미지를 1:1 매핑하고 유저가 프로필사진을 업로드할 때 이미 프로필 사진을 업로드한 적이 있다면 이를 지우고 다시 업로드하는 방식으로 업로드와 수정 기능을 같이 구현했다.
- 유저는 db에 첨부파일 하나만을 저장할 수 있다.
- 업로드/수정 기능 : 이미 저장된 프로필 사진을 hard delete 후, 새로 업로드
- 유저에게 저장된 프로필 사진이 없다면 S3에 이미 저장된 기본이미지의 url을 반환
유저와 프로필 사진을 1:1 양방향매핑하여 연관관계를 맺고 사진 업로드 api에 이미 저장된 프로필 사진이 있으면 이를 hard delete하고 새로 업로드하게끔 구현하였는데 repository의 delete 메서드가 제대로 작동하지 않았음
deleteImage
메서드에 적용된 트랜잭션이 무시유저의 첫 번째 사진 업로드는 정상적으로 실행되어 S3와 MySQL의 db에 저장되었다. 그러나 두 번째 사진 업로드(프로필 이미지 수정)는 실행되지 않았다.
즉, 이미 업로드 된 이미지가 삭제되지 않은 채 새로운 이미지를 유저에게 할당해 저장하고 있기때문에 1:1 연관관계에 어긋나 오류가 발생하는 것이었다.
처음에 Service 레이어에 작성했던 코드는 이것이었다.
@Override
@Transactional
public ImageResponseDto uploadImage(Long memberId, MultipartFile image) throws IOException {
Member member = memberRepository.findById(memberId).orElseThrow(()->new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
// 기존에 저장된 사진을 db와 S3에서 삭제
if (member.getImage() != null) {
deleteImage(memberId);
}
Image uploadedImage = s3Uploader.uploadImage(member, image);
imageRepository.save(uploadedImage);
return new ImageResponseDto(uploadedImage.getImageUrl());
@Override
@Transactional
public void deleteImage(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(()->new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
Image image = member.getImage();
if (image == null) {
throw new BadRequestException(ErrorCode.NO_IMAGE_TO_DELETE);
}
s3Uploader.deleteS3Image(image.getFileName());
imageRepository.delete(image);
}
(사진을 삭제할 때 쓰는 deleteImage 메서드를 재사용하고 있기때문에 image가 null인지 2번 체크하게 된다...)
S3의 버킷을 확인하자 S3에서는 이미지가 삭제되는데 db에서만 삭제가 되지 않았다.
JPARepository
의 delete
는 지금껏 자주 사용해왔던 메서드인데 왜 실행이 되지 않는지 알기가 어려웠다.
deleteImage()
메서드에 문제가 있는 것일까 싶어 그것만 별개로 돌려보았는데 역시 S3에서는 이미지가 삭제되었지만 db에서는 되지 않았다.
그래서 확인차 db에서 삭제하는 메서드를 따로 만들어서 실행시켜보았다.
@Override
@Transactional
public void deleteImageAtDb(Long memberId) {
imageRepository.deleteByMemberId(memberId);
}
제대로 작동이 되었다.
그렇다면 위의 deleteImage
메서드와 같은 형태로 Image
객체로 삭제쿼리가 돌아가도록 삭제 로직을 구현해보았다.
@Override
@Transactional
public void deleteImage(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(()->new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
Image image = member.getImage();
if (image == null) {
throw new BadRequestException(ErrorCode.NO_IMAGE_TO_DELETE);
}
s3Uploader.deleteS3Image(image.getFileName());
imageRepository.deleteByMemberId(memberId);
}
실행이 되지 않았다.
그렇다면 deleteImage
에 Image
객체를 넣어 삭제하지 않고 위에서 성공한 것처럼 memberId
를 넣어 삭제해보았는데 실행이 되지 않았다.
@Override
@Transactional
public void deleteImageAtDb(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(()->new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
Image image = member.getImage();
imageRepository.delete(image);
}
삭제가 잘 된 deleteImageAtDb
와 삭제가 되지 않은 deleteImage
의 차이는 삭제가 되지 않는 쪽은 Member
와 Image
를 조회하는 쿼리가 돌아간다는 것이었다.
여기서 Member
와 Image
의 1:1 연관관계 매핑때문에 영속성 컨텍스트에서의 문제가 발생하는 것일거라고 추측했다.
다시 돌아와 이미지 삭제가 제대로 되지 않는 deleteImage
의 실행 결과를 보니 삭제 쿼리 자체가 돌아가지 않았다.
원래라면 아래처럼 삭제쿼리가 들어가야하는데 위처럼 join으로 조회하는 쿼리만이 나오고 실행이 종료되는 것이었다.
JPA의 영속성 컨텍스트에 대해서 공부를 한 적이 있었지만 연관관계에 대한 내용이 잘 기억이 나지 않아 다시 검색을 해보았다.
연관관계를 가진 객체들을 조회하면 이것들이 영속성 컨텍스트에 남게 되는데
부모와 자식 객체가 모두 영속성 컨텍스트에 올라와 있을 때, 부모 객체를 삭제하려해도 자식객체가 남아있으면 JPA가 데이터의 무결성을 지키기 위해 삭제를 제한 할 수 있다.
나의 경우, Member
의 id
¡로 이미지를 쉽게 찾아내기 위해 Image
와 Member
를 양방향으로 매핑을 하고 있었기때문에 자식이라 할 수 있는 Image
의 삭제 시도에도 이것이 적용된 것이었다.
memberRepository.findById
로 Member
를 로드하면, Member
가 Image
를 참조 중이기 때문에 JPA는 이를 삭제하면 안되는 것이라 판단하고 삭제 쿼리를 실행시키지 않을 수 있다.
이를 해결하기 위해서는 두 객체의 연관관계를 먼저 끊어주고 삭제해줄 필요가 있다.
deleteImage()
에 Member
의 Image
를 null
로 설정해 연관관계를 끊는 메서드를 추가했다.
(orphanRemoval = true
사용)
@Override
@Transactional
public void deleteImage(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(()->new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
Image image = member.getImage();
if (image == null) {
throw new BadRequestException(ErrorCode.NO_IMAGE_TO_DELETE);
}
s3Uploader.deleteS3Image(image.getFileName());
//연관관계 끊기
member.updateImage(null);
imageRepository.delete(image);
}
그러자 deleteImage
로 db에 있는 이미지가 정상적으로 삭제되는 것을 확인 할 수 있었다.
그렇다면 마지막으로 uploadImage
가 잘되는지 확인만 하면 되었다.
하지만 uploadImage
를 실행시킬 때는 여전히 db에서의 이미지 삭제가 제대로 되지 않았다.
삭제에 오류가 나면 롤백이 되도록 Transactional을 사용해 커밋이 되는 시점에 query가 날라가도록 하고 있었는데 (JPA가 지원하는 쓰기지연 기능이다)
@Transactional
메서드 안에 든 메서드에 또 @Transactional
이 또 걸려있을 경우 안에 든 메서드에는 @Transactional
이 제대로 걸리지 않는다.
이를 트랜잭션이 중첩된다고 할 수 있는데 이를 다루기 위해 스프링에서는 여러 설정을 제공하고 있다.
기본 설정은 바로 REQUIRED로 자식의 트랜잭션이 부모의 트랜잭션에 합쳐져 한번의 트랜잭션만 적용되게끔 하는 것이다.
[참고]
https://velog.io/@fastdodge7/JPA-Transactional이-중첩되면-무슨-일이-일어날까
나의 경우에도 트랜잭션이 uploadImage
메서드 한 번에서만 적용되는 바람에 deleteImage
에서는 member.updateImage(null)
로 Member
와 Image
간의 연관관계를 끊었지만, uploadImage
메서드가 다시 member.getImage()
를 호출할 때, 영속성 컨텍스트에서는 여전히 Image
객체가 참조된 상태로 남았다. 따라서 JPA가 삭제를 실행시키지 않게 되었다.
이를 해결하기 위해서는 트랜잭션 중첩 시 각각의 트랜잭션이 적용될 수 있도록 deleteImage
의 트랜잭션 설정을 REQUIRES_NEW로 설정해주거나 deleteImage
의 메서드에 flush()
를 해서 변경사항을 바로 동기화해주는 방법이 있다.
나는 후자를 선택했다.
@Override
@Transactional
public void deleteImage(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(()->new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
Image image = member.getImage();
if (image == null) {
throw new BadRequestException(ErrorCode.NO_IMAGE_TO_DELETE);
}
s3Uploader.deleteS3Image(image.getFileName());
member.updateImage(null);
// 연관관계 끊기
imageRepository.delete(image);
// 영속성 컨텍스트 동기화
imageRepository.flush();
}
그렇게 실행하자 이미지가 db에서 잘 삭제 된 채로 새로 이미지를 업로드하고 저장할 수 있었다.
JPA의 영속성 컨텍스트때문에 양방향 매핑의 연관관계를 맺은 경우 한 쪽 객체가 삭제가 잘 되지 않는 문제가 발생하였고 삭제하기 전 두 연관관계를 끊어주는 것으로 해결하였다.
중첩된 트랙잭션때문에 이 끊어진 연관관계가 바로 반영이 되지 않았는데 이를 flush()
로 동기화시켜 해결하였다.