실무에서 해결해야할 로직이 있었는데 매우 쉽게 풀려서, 공유하고자 작성한다.
회원제 서비스로 돌아가는 펀치 머신이 있다고 가정하자.
그래서 우리는 회원이 자신의 정보를 기계에 인증하면, 회원의 최고 스코어를 뽑는 로직을 짜고싶다.
일단 회원의 스코어 히스토리만 구현한다 치고, MYSQL
로 테이블을 구현하면 이런식이 될 것이다.
create table punch_history (
id bigint not null,
member_id varchar(255),
score integer,
primary key (id)
)
스프링에서 엔티티로 구현하면 이런식이다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PunchHistory {
@Id
@GeneratedValue
private long id;
@Column
private String memberId;
@Column
private int score;
public PunchHistory(String memberId, int score) {
this.memberId = memberId;
this.score = score;
}
public int getScore() {
return score;
}
public long getId() {
return id;
}
}
이 상황에서 sql로 해당 데이터를 추출하는 방법은 아래와 같다. 여기서는 A의 최대값 row를 뽑는다 가정해보자.
SELECT * FROM punch_history
WHERE score = (SELECT MAX(score) FROM punch_history WHERE member_id = "A");
이제 우리는 이걸 Spring Boot 환경에서 JPA를 통해 구현하면 된다.
난 이걸 JPA에서 구현하는 방법, 총 3개가 생각났다.
@Query
를 통해 JPQL
로 쿼리를 직접 작성해 구하기QueryDsl
사용score
가 높은 값을 추출1번을 하기에는 JPQL
이 조금만 복잡해져도 코드가 더러워져서 구현하기 싫었고, 그렇다고 2번을 사용하자니 쿼리 하나 때문에 gradle
부터 설정을 복잡하게 세팅하며 충돌 방지하는게 귀찮았다.
그래서 3번으로 시도를 했다.
@Repository
public interface PunchHistoryRepository extends JpaRepository<PunchHistory, Long> {
List<PunchHistory> findAllByMemberId(String memberId);
}
먼저 JpaRepository
를 확장하여 Repository
를 구현한 후, JpaRepository
의 기능을 이용해, memberId
로 모든 엔티티를 검색해오는 로직을 구현했다.
이제 테스트문을 작성해보자.
@Slf4j
@SpringBootTest
public class JpaTests {
@Autowired
PunchHistoryRepository repository;
@Test
public void howToFindMax() throws Exception {
//given
PunchHistory ph1 = new PunchHistory("A", 500);
PunchHistory ph2 = new PunchHistory("B", 600);
PunchHistory ph3 = new PunchHistory("C", 700);
PunchHistory ph4 = new PunchHistory("A", 990);
PunchHistory ph5 = new PunchHistory("B", 550);
PunchHistory ph6 = new PunchHistory("A", 999);
List<PunchHistory> punchHistories = Arrays.asList(ph1, ph2, ph3, ph4, ph5, ph6);
repository.saveAll(punchHistories);
//when
List<PunchHistory> aPunchList = repository.findAllByMemberId("A");
if (aPunchList.size() == 0) {
throw new RuntimeException("A의 기록이 없습니다");
}
PunchHistory aMaxPunchHistory = Collections.max(
aPunchList, Comparator.comparingInt(PunchHistory::getScore)
);
//then
Assertions.assertEquals(999, aMaxPunchHistory.getScore());
}
}
각각 다른 punch_history
들을 DB안에 넣고, 앞서 구현한 findAllByMemberId
메소드로, 해당하는 PunchHistory
엔티티 리스트를 불러온다.
이후 리스트의 크기가 0이면 예외처리하고, 그게 아니면, Collecions
와 Comparator
의 기능을 활용해, 안의 객체들의 값을 서로 비교하여 가장 큰 값을 뽑는다.
이 후, 해당하는 값의 숫자와 가장 큰 엔티티인 aMaxPunchHistory
의 스코어가 같은지 비교한다.
결과는 성공적으로 진행이 된다.
하지만 이 로직은 문제점이 있다.
첫 번째는 모든 A의 히스토리를 다 뽑아온다는 점에 있어서 문제고, 두번째는 거기서 또 모든 값을 비교해서 max를 뽑아낸다는게 문제다.
지금이야 값이 총 6개밖에 안되니까 충분히 문제없이 돌릴 수 있지만, 만약 A의 기록이 1000개, 10000개, 혹은 그 이상이라면?
우리는 총 10000개의 리스트를 뽑아온 다음, 그 리스트의 객체들을 다시 일일이 비교를 해서 최대값을 뽑아야한다.
이는 매우 비효율적인 로직이며, 성능저하를 불러일으킬 수 있다.
그래서 이걸 어떻게 효과적으로 구현할 수 있을까 고민하던 찰나에, JpaRepository
, 즉 Spring Data JPA
의 메서드 생성 기능을 통해 구현할 수 있음을 깨달았다.
바로 Top
키워드를 이용하는 것이었다.
이를 Repository
를 통해 구현하면 이런 메서드가 된다.
@Repository
public interface PunchHistoryRepository extends JpaRepository<PunchHistory, Long> {
Optional<PunchHistory> findTopByMemberIdOrderByScoreDesc(String memberId);
}
이제 테스트를 해보자.
@Test
public void howToFindMaxByTop() throws Exception {
//given
//when
PunchHistory a = repository.findTopByMemberIdOrderByScoreDesc("A").orElseThrow(RuntimeException::new);
//then
Assertions.assertEquals(999, a.getScore());
}
성공적으로 진행이 된다.
그러면 쿼리를 어떻게 날리길래 이런 로직이 실행이 되는 걸까?
로그를 보자.
처음에 중첩 SELECT 문을 써서 조회했던 것과 달리, Spring Data JPA
는 where
조건문에 아이디를 넣고 order by
를 DESC
로 구현한 다음(스코어가 높은 것을 가져와야하니까!) limit
를 1로 걸어 단 한가지만 가져온 것이다!
즉 이걸 풀어서 SQL문으로 쓰면 이렇게 된다!
select * from punch_history ph WHERE member_id = "A" order by score DESC limit 1;