관리 메뉴

The Nirsa Way

[Spring Data JPA] 간단한 엔티티 작성 예제 및 CRUD 사용 예제 (흐름, 영속성 컨텍스트, 더티체킹, findById 사용 시 주의점) 본문

Development/Spring Data JPA

[Spring Data JPA] 간단한 엔티티 작성 예제 및 CRUD 사용 예제 (흐름, 영속성 컨텍스트, 더티체킹, findById 사용 시 주의점)

KoreaNirsa 2025. 7. 16. 22:05
반응형

 

간단한 엔티티 작성 예제 및 CRUD 사용 예제

엔티티에 대한 설명은 다음 포스팅에 작성할 예정이며 현재 포스팅은 간단한 엔티티 작성 예제 및 CRUD 사용 방법을 소개합니다.

우선 아래의 Member Entitiy가 존재하는데 @Entity 어노테이션으로 정의를 해준 후 필드를 몇 개 추가해주었습니다. id는 기본키(PK)라고 생각하시면 됩니다.

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int age;
}

MemberRepository는 이전 포스팅에서 소개한 JpaRepository를 상속받으며 Member 엔티티, PK는 Long 타입임을 제네릭을 통해 전달해 준 형태입니다. findByName 추상 메서드를 만들어 매개변수로 name을 받습니다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByName(String name);
}

1. INSERT - save()

JPA는 내부적으로 영속성 컨텍스트을 사용하기 때문에 엔티티의 변경 사항을 감지하고 DB에 반영을 하게 됩니다. 현재 예시 코드가 실행되면 아래와 같은 흐름을 가지게 됩니다.

  1. @Transactional 어노테이션에 의해 메서드 실행 시 트랜잭션 시작
  2. memberRepository.save() 실행
  3. 영속성 컨텍스트 등록 (persist)
  4. 메서드가 종료되며 트랜잭션 커밋 직전 flush() 호출
  5. 실제 INSERT 실행 (아직 커밋되지 않은 상태)
  6. 트랜잭션 커밋
  7. DB 반영
※ 영속성 컨텍스트란?
JPA에서 엔티티 객체를 저장하고 관리하는 공간으로써 엔티티 객체 상태를 추적하며 나중에 DB에 반영할지 결정하는 공간이라고만 보시면 됩니다. 즉 DB와 바로바로 쿼리를 실행하고 결과를 받는것이 아니라 JPA가 중간에서 엔티티를 관리하게 됩니다. 영속성 컨텍스트에 대한 내용은 추후 따로 포스팅 예정입니다.
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional
    public Member saveMember(String name, int age) {
        Member member = new Member();
        member.setName(name);
        member.setAge(age);
        return memberRepository.save(member);
    }
}


save() 메서드의 코드를 살펴보면 전달받은 엔티티의 PK가 NULL인지를 분기처리하여 확인합니다. NULL이라면 EntityManger에 persist를 호출하여 insert를 수행하고, NULL이 아니라면 merge를 호출하며 update를 수행합니다.

@Override
@Transactional
public <S extends T> S save(S entity) {

    Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL);

    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}

2-1. SELECT - findById

이번에는 SELECT이며 단건 조회일 경우 입니다. 아래에서 사용된 findById도 이미 JPA에서 구현되어 있는 메서드입니다. @id 어노테이션으로 정의된 PK만 사용이 가능합니다.

우선 SELECT의 경우 Transactional에 readOnly=true를 주어 flush()와 dirty checking을 비활성화 하였으며, 실수로 엔티티 필드를 수정하더라도 DB에 반영되지 않습니다.

※ Dirty Checking 이란?
영속성 컨테이너가 관리중이 엔티티의 상태를 감지하는 기능으로써 엔티티의 상태 변경을 감지하는 정도로 이해하시면 됩니다. (필드명이 바뀌는 등)

※ SELECT에서 더티체킹을 사용하지 않는 이유
만약 더티체킹이 되는 상태에서 실수로 엔티티를 변경해버리면 트랜잭션이 끝나는 시점에 자동으로 해당 영속성 컨텍스트를 읽어 DB에 반영하게 됩니다. 이러한 행위를 방지하기 위해 readOnly=true를 주어 더티체킹을 비활성화합니다.
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional(readOnly = true)
    public Member getMember(Long id) {
        return memberRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다. ID: " + id));
    }
}

 

참고로 findById를 사용할 때 @Transactional(readOnly = true)를 안붙여도 정상적으로 동작은 하는데, 이는 findById 구현부가 있는 SimpleJpaRepository에서 클래스레벨로 @Transactional(readOnly = true)가 붙어있기 때문입니다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
    
    ... 중략
    
    @Override
    public Optional<T> findById(ID id) {
    	Assert.notNull(id, ID_MUST_NOT_BE_NULL);

        Class<T> domainType = getDomainClass();

        if (metadata == null) {
        return Optional.ofNullable(entityManager.find(domainType, id));
        }

        LockModeType type = metadata.getLockModeType();
        Map<String, Object> hints = getHints();

        return Optional.ofNullable(
        type == null ? entityManager.find(domainType, id, hints) : entityManager.find(domainType, id, type, hints));
    }
}

2-2. SELECT - findBy 커스텀

PK가 아닌 일반 필드(name 등)로 SELECT를 하고 싶다면 처음 작성해놧던 아래의 repostiory처럼 커스텀해주어야 합니다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByName(String name);
}

이러한 Query Method의 이름을 지을 때 규칙은 아래와 같으며 해당 포스팅은 간단한 CRUD 만 다루기 때문에 7가지 정도만 작성하였습니다.


SELECT - 리스트 조회

Transactional은 그 전과 같은 이유이며 findAll은 전체를 다 가져오는 기본적인 형태의 쿼리입니다. 

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional(readOnly = true)
    public List<Member> getAllMembers() {
        return memberRepository.findAll();
    }
}

 

리스트 조회에서도 6가지 기능들을 다룰텐데, 우선 첫번째로 ORDER BY ~ DESC와 같이 정렬을 추가하는 방법이 있습니다. 해당 쿼리는 age를 기준으로 내림차순입니다.

@Transactional(readOnly = true)
public List<Member> getAllMembers() {
	List<Member> members = memberRepository.findAll(Sort.by(Sort.Direction.DESC, "age"));
}

 

두번째로 페이징 처리를 하는 기능입니다. 내부적으로 LIMIT, OFFSET을 포함한 쿼리를 실행합니다.

@Transactional(readOnly = true)
public List<Member> getAllMembers() {
	Page<Member> page = memberRepository.findAll(PageRequest.of(0, 10));
}

 

세번째로 조건 기반으로 리스틀 조회하는 방법입니다. respotiroy에 커스텀해주어야 하며 "나이가 20 이상인 회원 조회"를 뜻합니다.

// 서비스 계층
@Transactional(readOnly = true)
public List<Member> getAllMembers() {
	List<Member> result = memberRepository.findByAgeGreaterThanEqual(20);
}
// 레포지토리 계층
public interface MemberRepository extends JpaRepository<Member, Long> {
	List<Member> findByAgeGreaterThanEqual(int age);
}

메서드명 규칙은 아래와 같이 사용됩니다.

  1. findBy필드명GreaterThanEqual(int age)  :  이상(>=)
  2. findBy필드명GreaterThan(int age) : 초과(>)
  3. findBy필드명LessThanEqual(int age) : 이하(<=)
  4. findBy필드명LessThan(int age) : 미만(<)

 

네번째로 조건과 정렬을 함께 주는 방법입니다. 해당 메서드는 city를 조건으로 검색하되 name을 기준으로 ASC 정렬하겠다는 의미입니다. 즉 WHERE city = ? ORDER BY name ASC

@Transactional(readOnly = true)
public List<Member> getAllMembers() {
	List<Member> findByCityOrderByNameAsc(String city);
}

 

다섯번째로 조건과 페이징 정렬을 함께 하는 방법입니다. 도시명이 "서울"인 회원을 검색하면서 이름순으로 내림차순 정렬 후 0번째 페이지의 10개 게시글을 가져오겠다는 의미입니다.

@Transactional(readOnly = true)
public List<Member> getAllMembers() {
    PageRequest pageRequest = PageRequest.of(0, 10, Sort.by("name").descending());
    Page<Member> result = memberRepository.findByCity("서울", pageRequest);
}

 

여섯번째로 @Query를 직접 작성하는 형태입니다. JPQL과 Native SQL 두가지 방식으로 나뉘어집니다. 보통 QueryDSL을 사용하지 않는 상태일 때 복잡한 쿼리를 구현해야 할 때 사용하게 됩니다. 둘의 차이점은 간단히 말하면 JPQL은 엔티티 기준, Native SQL은 DB 기준입니다.

  1. JPQL : 테이블명, 컬럼명 등은 모두 엔티티의 필드명 입니다. (즉, 자바로 작성한 엔티티 기준)
  2. Native SQL : DB SQL 문법을 그대로, 테이블 기준으로 작성하는 케이스 입니다.

JPQL의 경우 복잡한 쿼리를 작성하게 되면 결국 엔티티에 의존하게 되어 불편함이 존재하기 때문에 대부분 Native SQL을 사용하거나 Query DSL을 주로 사용합니다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    // JPQL
    @Query("SELECT m FROM Member m WHERE m.status = 'ACTIVE'")
    List<Member> findActiveMembers();

    // Native SQL
    @Query(value = "SELECT * FROM member WHERE status = 'ACTIVE'", nativeQuery = true)
    List<Member> findActiveMembersNative();
}

3. UPDATE

기본적인 UPDATE는 더티체킹으로 진행됩니다. @Transactional을 적용한 후 엔티티 상태가 변경되면 트랜잭션이 끝나는 시점(메서드 종료)에 JPA는 flush()를 호출 후 변경된 엔티티를 찾아 UPDATE를 수행합니다.

아래의 코드는 member의 나이가 18세 이상이라면 엔티티를 변경하고, 종료 시점에 UPDATE가 수행되는 코드입니다.

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional
    public Member updateMember(Member member, String newName, int newAge) 
        if (member.getAge() >= 18) {
            member.setName(newName);
            member.setAge(newAge);
        }
        return member;
    }
}

4. DELETE

DELETE도 자바 코드 내에서 분기 처리하여 조건을 검사하고 delete를 수행합니다. 아래의 코드는 유저의 status가 INACTIVE 상태일 때 delete를 호출하여 삭제하는 코드입니다. delete도 마찬가지로 구현부가 만들어져 있으므로 그대로 사용하면 됩니다.

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional
	public void deleteMember(Member member) {
        if ("INACTIVE".equals(member.getStatus())) {
            memberRepository.delete(member);
        } else {
            throw new IllegalStateException("INACTIVE 상태가 아닌 회원은 삭제할 수 없습니다.");
        }
    }
}

Transactional을 무조건 써야하는가?

필요에 따라 Transactional을 사용하지 않아야 하는 케이스도 있습니다. 무조건 써야한다기 보다는 적절히 쓰는것이 옳은 표현인 것 같습니다. 최근 카카오페이의 기술블로그인 JPA Transactional 잘 알고 쓰고 계신가요?를 읽으며 굉장히 새로운 관점에서 다시 생각해본 계기가 되었었는데, 시간 여유가 되시는 분들도 한번 읽어보시면 좋을것같습니다.


다음 포스팅에서는 엔티티를 설정하는 방법을 살펴보겠습니다.

반응형