오늘의 주제는 다음과 같다.
다음 문장을 읽고 바로 그 질문에 대한 해답이 떠오른다면 본 게시글을 굳이 읽지 않아도 될 것이다.
"Spring Data JPA에서, Service나 Repository 계층에
트랜잭션(Transaction)을 시작한다는 것을 명시하지 않았는데,
데이터의 수정 작업이 어떻게 올바르게 이루어질 수 있었는가"
다음은 내 개인 프로젝트중 한 부분이며,
서비스 계층에 존재하는, 회원의 별명(nick name)을 변경하는 메소드이다.
회원의 id와 nickname을 입력받아,
같은 별명을 사용하고 있는 회원이 존재하는 지 확인을 한 후, 없다면 해당 id에 해당하는 회원의 별명을 바꾸어 저장한다.
@Service
public class MemberService {
private final memberRepository;
//@Autowired 생략 가능
public MemberService(Repository memberRepository) {
this.memberRepository = memberRepository; // 생성자를 통한 Repository Bean 주입
}
....
// 트랜잭션을 시작한다는 것을 명시하지 않았는데 어떻게 데이터의 수정이 올바르게 되는가
public Member updateNickname(Long id, String nickname) {
Member foundMember = memberRepository.findById(id).get();
if(!memberRepository.existsByNickname(nickname)) {
foundMember.setNickname(nickname);
memberRepository.save(foundMember);
}
return foundMember;
}
}
위 기능은 올바르게 동작한다.
그런데 올바르게 동작한다는 것을 확인하고 나니 한가지 의문이 들었다.
JPA를 공부하면서 다음과 같은 사실을 확신하고 있었기 때문이다.
"JPA에서 모든 데이터의 변경은 트랜잭션(Transaction) 내부에서 일어나야 합니다."
JPA에서 데이터의 수정 작업은 반드시 트랜잭션(Transaction)내부에서 일어나야 한다.
JPA를 통해 프로젝트를 진행하였을 때에도 서비스 계층에서 트랜잭션을 시작하도록 코드를 작성하였었다. 그런데 나는 서비스(Service) 계층에도, 레포지토리(Repository) 계층에도 @Transactional 어노테이션을 통해 트랜잭션 시작을 명시하지 않았다. 다음과 같이 말이다.
@Service
// @Transactional 선언이 없음!
public class MemberService {
...
}
@Repository
//@Transactional 선언이 없음!
public interface MemberRepository implements JpaRepository<Member, Long> {
...
}
이렇게 Transaction을 시작한다고 명시하지 않았는데
어떻게 위처럼 데이터의 수정작업이 올바르게 진행될 수가 있었을까?
그 이유는 SimpleJpaRepository라는 구현 클래스의 소스코드를 분석하면 알 수가 있다.
우리가 JpaRepository를 상속하여 정의한 MemberRepository를 바탕으로, 스프링 부트 애플리케이션 시작 후 프록시(Proxy) 객체라는 것이 만들어진다.
이 프록시 객체의 내부에는 JpaRepository의 구현체, 정확히는 JpaRepositoryImplementation인터페이스의 구현체 클래스인 SimpleJpaRepository를 참조로 가지는데, 이 구현클래스에 정의된 메소드(수정 / 삭제 등)에 @Transactional어노테이션이 붙어있다.
한마디로 말하자면, 서비스 계층이나 레포지토리 계층에 직접 @Transactional을 명시하지 않아도
SpringDataJpa에서의 데이터 수정작업은 모두 트랜잭션 안에서 일어나게 된다.
JpaRepository 인터페이스의 save메소드
JpaRepository 인터페이스를 살펴보자.
먼저 JpaRepository의 상속관계는 다음과 같다.
파란 색 박스는 인터페이스, 초록 색 박스는 클래스이다.
save메서드는 저 파란색으로 표시된 CrudRepository 인터페이스에 정의되어 있는 추상 메서드(abstract method)인데, 쭉 상속되어 JpaRepository까지 타고 내려온다.
우리는 MemberRepository라는 인터페이스를 JpaRepository 인터페이스를 상속하는 형태로 정의하기만 하면,
애플리케이션 실행 후, 우리가 정의한 MemberRepository 인터페이스를 바탕으로
프록시(proxy) 객체라는것을 만들어 스프링 빈(Spring Bean)으로 등록을 한다.
그리고 이 MemberRepository 객체가 필요한 곳곳(예를 들면 서비스 계층)에 주입을 해준다.
memberRepository의 save()메소드를 실행하면 memberRepository 객체에 정의된 save메소드가 아니라,
memberRepository객체는 참조의 형태로 SimpleJpaRepository라는 클래스에 접근을 할 수가 있는데, 여기에 정의된 save()메소드를 호출하게 된다.
그러면 이 SimpleJpaRepository라는 곳에 구현되어 있는 save()메소드를 살펴보자.
정의된 save()메소드 위에 @Transactional이라는 어노테이션이 붙어져있다.
즉, SpringDataJpa를 이용할 시 Service계층 혹은 Repository 계층에 직접 Transactional을 명시하지 않아도 데이터의 수정 및 작업은 모두 위 메소드 내에서 일어나므로 진행이 가능했던 것이었다.
주의사항
다시 돌아가서 다음 코드를 보자.
본 글의 첫 번째에서 인용했던 프로젝트의 서비스 계층 메소드의 한 부분이다.
@Service
public class MemberService {
....
// 트랜잭션을 시작한다는 것을 명시하지 않았는데 어떻게 데이터의 수정이 올바르게 되는가
public Member updateNickname(Long id, String nickname) {
Member foundMember = memberRepository.findById(id).get();
if(!memberRepository.existsByNickname(nickname)) {
foundMember.setNickname(nickname);
memberRepository.save(foundMember);
}
return foundMember;
}
}
memberRepository의 findById메소드를 통해 해당 id에 대응하는 엔티티(Member)가 반환되었고, 이의 닉네임을 바꾸려고 한다. Service계층에 따로 트랜잭션을 명시하지 않았기 때문에 findById라는 메소드 단에서 이미 트랜잭션이 끝나버렸고, 따라서 영속성 컨텍스트(Persistence Context)가 날아가버린 상태이다. 찾아진 foundMember라는 엔티티는 더이상 영속성 컨텍스트의 관리대상이 아니다. 이런 엔티티의 상태를 바로 준영속(detached)상태라고 한다.
준영속 상태의 엔티티는 영속성 컨텍스트에서 관리되는 대상이 아니기 때문에, dirty checking을 통한 수정을 할 수 없다. 따라서 다음과 같은 코드는 동작하지 않는다.
@Service
public class MemberService {
....
// 트랜잭션을 시작한다는 것을 명시하지 않았는데 어떻게 데이터의 수정이 올바르게 되는가
public Member updateNickname(Long id, String nickname) {
Member foundMember = memberRepository.findById(id).get();
if(!memberRepository.existsByNickname(nickname)) {
foundMember.setNickname(nickname);
// memberRepository.save(foundMember); 이 부분을 주석처리하면 수정작업이 일어나지 않는다
}
return foundMember;
}
}
영속성 컨텍스트에서 관리되는 엔티티는 1차 캐시에 저장되어 있으며, 이 엔티티가 처음 1차 캐시에 저장될 때의 원본(snapshot)을 또한 저장한다. 트랜잭션이 커밋(commit)되는 시점에 해당 엔티티를 snapshot과 비교를 하고, 달라진 것이 있으면 이에 대한 update query를 flush()시점에 데이터베이스로 보낸다. 하지만 이건 엔티티가 영속성 컨텍스트에서 관리되고 있을 때의 이야기이지, 위와 같은 상황에는 해당하지 않는다.
그래서 엔티티 수정을 위해서는 위 주석 부분을 해제해야만 하는 것이다.
엔티티 생성이 아닌, 수정할 때도 save() 메소드를 사용한다?
save메소드를 다시 살펴보자.
소스코드를 분석 해보니,save() 메소드에 엔티티가 들어가면 다음과 같이 동작한다.
"인자로 전달된 엔티티가 새로운 것이라면 persist()를,
새로운 것이 아니라면 merge()를 호출한다"
여기서 새롭다는 것이 무엇을 의미하는가?
isNew라는 메소드가 어떻게 구현되어 있는 지 살펴보기 위하여 다음 AbstractEntityInformation클래스를 확인해 보았다.
여기서 isNew메소드를 확인해 보자.
일단 entity를 인자로 전달 받는다.
그리고 entity의 id(식별자)를 받아서 다음과 같은 판단을 한다.
"전달된 엔티티의 id가 primitive type이라면 이 값이 0일 때 true를 반환한다"
"전달된 엔티티의 id가 참조 타입(reference type)이라면 이 값이 null일 때 true를 반환한다."
나의 경우는 다음과 같은 Member 엔티티를 저장했었다.
@Entity
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String password;
private String nickname;
@Enumerated(EnumType.STRING)
private Grade grade;
엔티티의 식별자로 사용이 되는 id 필드의 경우는 wrapper class인 Long으로 되어 있다.
이 경우를 예로 들어 보면 save메소드의 인자로 member엔티티가 입력되었을 때
이 Member 엔티티의 id 필드 값이 null이면 new, 그렇지 않다면 not new로 판단을 하게 되는 것이다.
그런데 저 id필드의 경우에는 다음과 같은 어노테이션이 붙어있다.
@GeneratedValue(strategy = GenerationType.IDENTITY)
이것은, 엔티티를 데이터베이스에 저장한 후, 데이터베이스에서 자동으로 생성하여 부여해 주는 식별자 값을 해당 필드에 넣어주겠다는 설정이다. 즉, 맨 처음에 member 엔티티를 데이터베이스에 저장할 때는, id값을 세팅하지 않고 나머지 필드만을 세팅해서 save()메소드의 인자로 전달하며, 이렇게 되면 isNew()가 true를 반환하게 되고,(id필드가 null이므로), 이에 따라 persist(member)가 호출이 되는 것이다.
member 엔티티를 처음으로 데이터베이스에 등록 시도할 때에 발생하는 과정
"save(member) 호출" → isNew() true반환 → persist(member) 호출
그러나 다음과 같은 경우는 어떨까?
public Member updateNickname(Long id, String nickname) {
Member foundMember = memberRepository.findById(id).get();
if(!memberRepository.existsByNickname(nickname)) {
foundMember.setNickname(nickname);
memberRepository.save(foundMember); 이 부분을 주석처리하면 수정작업이 일어나지 않는다
}
return foundMember;
}
findById()메소드를 통해 찾아진 엔티티의 경우, 이 엔티티는 이미 데이터베이스 내에 저장이 되어있었을 것이다. 그러면 당연히 member가 처음 데이터베이스에 저장이 될 때에, 이 member 엔티티의 id필드에는 DBMS로부터 부여받은 식별자 값이 주입되었을 것이므로, null이 아닌 상태이다. 이렇게 null이 아닌 상태의 엔티티가 save()메소드의 인자로 전달이 되면 다음과 같은 과정이 일어난다.
"save(member) 호출" → isNew() false반환 → merge(member) 호출
이 merge라는 메소드를 호출하게 되면, 인자로 전달된 엔티티를 DataBase로부터 찾고(쿼리가 반드시 날아간다), 이렇게 찾아진 엔티티에, 인자로 전달된 엔티티의 필드 속성을 주입하게 된다. 그리고 이렇게 속성이 주입된 엔티티는 영속 상태에 존재하다가, 트랜잭션이 커밋할 시점에 다시 데이터베이스로 저장이 되는 것이다.
즉, 기존에 존재하던 엔티티가 업데이트 되는 것이다. 이렇게 merge라는 메소드를 호출하게 되면, merge의 인자로 전달된 엔티티에 해당하는 데이터가 존재하는 지 여부를 확인하는 쿼리문이 날아간다는 것에 주의하면 된다.
'Back-End > Spring' 카테고리의 다른 글
왜 Spring Security 의존성을 추가 하기만 해도 모든 엔드포인트에 인증과정이 요구되는가 (0) | 2024.10.22 |
---|---|
[디버깅] Spring Security 사용 중 템플릿에 CSS가 적용되지 않는 현상 (0) | 2024.07.18 |
[디버깅] Maven으로 빌드한 스프링부트 프로젝트에서 템플릿 파일 인식 (0) | 2024.07.18 |
데이터베이스의 엔티티(Entity)에 대한 고찰, 그리고 JPA의 @Entity 어노테이션 (0) | 2024.04.12 |
상황을 통해 이해하는 JPA 영속성 전이 및 CASCADE 옵션 (0) | 2024.04.10 |