Spring Boot와 JPA로 회원가입 기능 있는 게시판 만들기 2(불필요한 select문을 날리는 코드를 수정하자)

Hooman
21 min readNov 4, 2023

--

저번에는 DB에 insert하고 그 내용을 화면에 띄우는 것 까지 진행했다.

하지만 기존의 코드는 여러가지 문제점이 있겠지만 가장 중요한 문제점은은 insert 한 번 하는데 select문을 세 번 날려야 했던 점이었다.

이번에는 그 문제점과 함께 자잘한 실수들도 수정을 해보기로 하자.

@Service
public class MemberService {

private final MemberRepository memberRepository;
private final AccountRepository accountRepository;
private final InformationRepository informationRepository;

@Autowired
public MemberService(MemberRepository memberRepository, AccountRepository accountRepository,
InformationRepository informationRepository) {
this.memberRepository = memberRepository;
this.accountRepository = accountRepository;
this.informationRepository = informationRepository;
}

@Transactional
public void join(StudentAndAccountDto studentAndAccountDto) {

// TransactionManager tx = new Tran

System.out.println("join 메서드 시작");
Student student = Student.builder()
.name(studentAndAccountDto.getName())
.age(studentAndAccountDto.getAge())
.email(studentAndAccountDto.getEmail())
.school(informationRepository.getReferenceSchool(studentAndAccountDto.getSchoolId()))
.club(informationRepository.getReferenceClub(studentAndAccountDto.getClubId()))
.position(informationRepository.getReferenceClubPosition(studentAndAccountDto.getPositionId()))
// .school(informationRepository.schoolFindById(studentAndAccountDto.getSchoolId()))
// .club(informationRepository.clubFindById(studentAndAccountDto.getClubId()))
// .position(informationRepository.clubPositionFindById(studentAndAccountDto.getPositionId()))
.type(studentAndAccountDto.getStudentType())
.build();

Account account = Account.builder()
.id(studentAndAccountDto.getAccountId())
.passwd(studentAndAccountDto.getAccountPasswd())
.student(student)
.build();

// student.addAccountInfo(account); 저장하지 않은 객체를 집어넣고 저장하려 해서 에러남
// org.hibernate.TransientPropertyValueException: object references an unsaved
// transient instance - save the transient instance before flushing :
// jpapractice.jpapractice.domain.Student.account ->
// jpapractice.jpapractice.domain.Account
Student studentResult = memberRepository.save(student);
Account accountResult = accountRepository.save(account);
studentResult.addAccountInfo(accountResult);
memberRepository.save(studentResult);
System.out.println("join 메서드 끝");

}

@Transactional
public DefaultInfoDto findInfo(String id) {
System.out.println("findInfo 메서드 시작");

DefaultInfoDto defaultInfoDto = new DefaultInfoDto();

Optional<Account> accountOptional = accountRepository.findById(id);
if (!accountOptional.isPresent()) {

} else {
Account accountResult = accountOptional.get();
defaultInfoDto.setStudentName(accountResult.getStudent().getName());
defaultInfoDto.setAge(accountResult.getStudent().getAge());
defaultInfoDto.setEmail(accountResult.getStudent().getEmail());
defaultInfoDto.setSchoolName(accountResult.getStudent().getSchool().getSchoolName());
defaultInfoDto.setClubName(accountResult.getStudent().getClub().getName());
defaultInfoDto.setPositionName(accountResult.getStudent().getPosition().getName());
defaultInfoDto.setStudentType(accountResult.getStudent().getType().name());
}
// System.out.println("Service: " + studentResult.toString());
// System.out.println("Service: " + studentResult.getAccount().toString());

System.out.println(defaultInfoDto.toString());
System.out.println("findInfo 메서드 끝");
return defaultInfoDto;
}

}

아마 가장 눈에 띄는 점은 Student 객체를 생성하는데 갑자기 getReference~ 라는 메서드들이 튀어나온 것이라 생각한다.

그러면 저 메서드들이 있는 repository 클래스의 코드를 보자.

@Repository
public class InformationRepository {

private final EntityManager em;

@Autowired
public InformationRepository(EntityManager em) {
this.em = em;
}

public School schoolFindById(int id) {
School school = em.find(School.class, id);
return school;
}

public Club clubFindById(int id) {
Club club = em.find(Club.class, id);
return club;
}

public ClubPosition clubPositionFindById(int id) {
ClubPosition clubPosition = em.find(ClubPosition.class, id);
return clubPosition;
}

public School getReferenceSchool(int id) {
School school = em.getReference(School.class, id);
return school;
}

public Club getReferenceClub(int id) {
Club club = em.getReference(Club.class, id);
return club;
}

public ClubPosition getReferenceClubPosition(int id) {
ClubPosition clubPosition = em.getReference(ClubPosition.class, id);
return clubPosition;
}

}

EntityManager.find() 말고 EntityManager.getReference() 메서드가 튀어나왔다.

JPA를 이제 막 입문한 사람이라면 (나도 마찬가지지만) find()는 익숙하겠지만 getReference()는 아마도 처음 볼 것이다. 이것이 무슨 함수인지 공식 문서를 보자.

https://jakarta.ee/specifications/persistence/3.1/apidocs/jakarta.persistence/jakarta/persistence/entitymanager

EntityManager의 패키지 명은 jakarta.persistence.EntityManager 이다. 이 클래스는 Jakarta EE (구 JavaEE)에서 찾을 수 있다.

<T> T getReference​(Class<T> entityClass, Object primaryKey)

Get an instance, whose state may be lazily fetched.

지연 로딩을 통해 인스턴스를 가져온다고 한다. 지연 로딩은 그때그때 코드에서 바로 쿼리를 날리는게 아니고 쿼리 관련 연산이 모두 끝날 때(쿼리에 관한 코드가 끝나고 값을 가져와야할 때) 쿼리가 날아간다고 이해하면 편한 것 같다.

“근데 그래봤자 DB에 쿼리 날려야 하는건 똑같지 않냐?”

그러게 말이다.

그런데 여기서 JPA는 프록시 객체라는 개념을 들고 나온다. 간단하게 말하면 실제 엔티티 객체처럼 생긴 깡통 객체다.

메소드명이며 객체명이며 모두 길기 때문에 가독성을 위해

studentAndAccountDto -> dto

informationRepository -> infoRepo

memberRepository -> memRepo

accountRepository -> accRepo 라고 줄이도록 하겠다.

Student student = Student.builder()
.name(dto.getName())
.age(dto.getAge())
.email(dto.getEmail())
.school(infoRepo.getReferenceSchool(dto.getSchoolId()))
.club(infoRepo.getReferenceClub(dto.getClubId()))
.position(infoRepo.getReferenceClubPosition(dto.getPositionId()))
.type(dto.getStudentType())
.build();

Account account = Account.builder()
.id(dto.getAccountId())
.passwd(dto.getAccountPasswd())
.student(student)
.build();
Student studentResult = memRepo.save(student);
Account accountResult = accRepo.save(account);
studentResult.addAccountInfo(accountResult);
memberRepository.save(studentResult);

이제 하나씩 보자.

student와 account 객체는 lombok에서 제공해주는 @Builder 어노테이션을 통해 빌더 패턴으로 객체 빌딩이 가능하다.

이때 객체인 School, Club, ClubPosition 도 들어가야 한다. 지금까지의 방법대로면 얄짤없이 select문 세번에 insert문 한 번이 날아간다.

그러면 이제 프록시 객체와 지연 로딩이 어떻게 이걸 처리하는지 보자.

dto에 세팅된 schoolId, clubId, positionId 값이 전부 1 일때getReference~()의 매개변수로 해당 id값을 넣으면 Id필드의 값이 1인 프록시 객체가 생성된다. id값이 실제로 DB에 존재하는가는 아직 신경쓰지 않아도 된다. 아직 쿼리가 날아가지 않았으니깐.

Student studentResult = memRepo.save(student);
Account accountResult = accountRepository.save(account);

쿼리는 여기서 날아간다.

save()는

    public Student save(Student student) {
em.persist(student);
em.flush();
return student;
}

public Account save(Account account) {
em.persist(account);
em.flush();
return account;

}

이렇게 구성되어있다.

persist()는 엔티티를 영속성 컨텍스트에 저장한다. git으로 비유하면 git add 해서 stage에 올리는 것이다. 그리고 flush()를 해야 DB로 쿼리가 날아간다.

이에대한 자세한 설명을 해둔 블로그가 있어 링크를 걸어둔다.

flush가 이루어질 때 프록시 객체의 Id 값이 Student와 매핑된 student_default_information 테이블의 fk 값으로 들어간다. 이 때 Id값이 DB에 없는 값이면 org.springframework.dao.DataIntegrityViolationException: could not execute statement [Cannot add or update a child row: a foreign key constraint fails~ 익셉션이 발생하게 된다.

이제서야

“근데 그래봤자 DB에 쿼리 날려야 하는건 똑같지 않냐?”

에 대한 대답을 할 수 있게 되었다.

“DB에 쿼리는 날리는데 DB에 저장된 값이면 무사히 insert가 되고 없으면 익셉션을 던진다.”

우리는 프록시 객체와 지연로딩 덕분에 getReference()라는 메서드를 통해 select 문을 날릴 필요 없이 프록시 객체로 Id값을 셋팅할 수 있고 이 객체를 담은 엔티티는 그대로 매핑된 fk 컬럼에 값을 insert 할 수 있게 되었다.

이렇게 해서 insert 한 번에 설정된 객체 필드 갯수만큼의 select문이 실행되는 문제를 해결했다.

그런데 갑자기 persist() 뒤에 flush()라는 메서드가 나온 이유가 궁금할 것이다.

사실 persist()만 해도 트랜잭션 종료 시점에 알아서 flush()가 호출되어 쿼리가 날아가긴 하는데(명시적으로 flush()를 호출하지 않아도) 어째서인지 테스트 코드에서 두번째 save()에서 쿼리가 안 날아가는 문제가 생겨서 명시를 해줬다.

이에 대해서 터미널 로그를 확인해보니 두번째 save()는 메서드가 종료되고 트랜잭션이 종료되는 시점에 쿼리가 날아간다.

아래는 테스트 코드와 출력문이다.

 @Test
public void joinTest() {
StudentAndAccountDto dto = new StudentAndAccountDto();

dto.setSchoolId(1);
dto.setClubId(1);
dto.setPositionId(1);
dto.setStudentType("BACK");
dto.setName("김철수");
dto.setAge(19);
dto.setEmail("testemail@email.com");
dto.setAccountId("test1111");
dto.setAccountPasswd("test1234");

memberService.join(dto);
DefaultInfoDto result = memberService.findInfo(dto.getAccountId());

Account account = accountRepository.findById("test111").get();

Assertions.assertThat(account.getId()).isEqualTo("test111");

// System.out.println(result.toString());

}
join 메서드 시작
Hibernate:
insert
into
student_default_information
(student_age,club_id,student_email,student_name,position_id,school_id,type)
values
(?,?,?,?,?,?,?)
join 메서드 끝
Hibernate:
insert
into
member_account_information
(account_passwd,student_id,account_id)
values
(?,?,?)
findInfo 메서드 시작
Hibernate:
select
a1_0.account_id,
a1_0.account_passwd,
s1_0.student_id,
s1_0.student_age,
s1_0.club_id,
s1_0.student_email,
s1_0.student_name,
s1_0.position_id,
s1_0.school_id,
s1_0.type
from
member_account_information a1_0
left join
student_default_information s1_0
on s1_0.student_id=a1_0.student_id
where
a1_0.account_id=?
Hibernate:
select
m1_0.momotalk_id,
s1_0.student_id,
s1_0.student_age,
s1_0.club_id,
s1_0.student_email,
s1_0.student_name,
s1_0.position_id,
s1_0.school_id,
s1_0.type
from
momotalk_account_information m1_0
left join
student_default_information s1_0
on s1_0.student_id=m1_0.student_id
where
m1_0.student_id=?
Hibernate:
select
s1_0.school_id,
s1_0.school_name
from
school_info s1_0
where
s1_0.school_id=?
Hibernate:
select
c1_0.club_id,
c1_0.club_name,
c1_0.school_id
from
club_info c1_0
where
c1_0.club_id=?
Hibernate:
select
c1_0.position_id,
c2_0.club_id,
c2_0.club_name,
c2_0.school_id,
c1_0.position_name
from
club_position c1_0
left join
club_info c2_0
on c2_0.club_id=c1_0.club_id

memberService의 join 메서드에서는 insert문을 두번 수행해야한다.

하지만 join 메서드가 끝나고나서야 두번째 insert문이 수행된다. join 메서드보다 transaction 이 더 길게 살아있는 것이 이유라고 생각한다.

그리고 accountRepository.findById(id) 에 의해서 select문이 다시 여러번 날아가는 문제가 발생한다.

Account result = em.find(Account.class, id);

를 내부 함수로 가지고 있는 findById는

Account accountResult = accountOptional.get();
defaultInfoDto.setStudentName(accountResult.getStudent().getName());
defaultInfoDto.setAge(accountResult.getStudent().getAge());
defaultInfoDto.setEmail(accountResult.getStudent().getEmail());
defaultInfoDto.setSchoolName(accountResult.getStudent().getSchool().getSchoolName());
defaultInfoDto.setClubName(accountResult.getStudent().getClub().getName());
defaultInfoDto.setPositionName(accountResult.getStudent().getPosition().getName());
defaultInfoDto.setStudentType(accountResult.getStudent().getType().name());

Student 객체의 값을 가져오기 위해 select문을 날리고 Student 객체의 객체 필드의 값을 가져오기 위해 다시 select문을 N번 수행한다.

다음 글에서는 이 문제를 해결하고자 한다.

그리고 사실 테스트 코드에서 문제가 있지 실제 동작에서는 제대로 동작한다.

아무래도 내가 테스트 코드 설계를 제대로 못했고 트랜잭션의 라이프 사이클에 대해서 제대로 모르기 때문에 이런 일이 벌어진 것 같다.

지금까지의 코드는

여기서 볼 수 있다.

--

--