Subscribe, Follow 기능 구현 & JPQL new operator 사용법
안녕하세요😁~ 오늘은 구독(Subscribe, Follow) 기능을 구현해 보고 해당 User에 대한 Follower Data를 전달해 주는 RestAPI를 구현해 보도록 하겠습니다~. 오늘 포스팅의 핵심은 아래 💡 팔로워 목록 쿼리 구현 (SQL, JPQL)
부분입니다.
가이드 && 환경
본 포스팅은 아래의 환경 및 가이드를 따라갑니다~
- 💡 SpringBoot 3.2.2, SpringDataJPA, JPQL
- 💡 Subscribe Entity
- 💡 구독, 구독 취소 구현
- 💡 팔로워 목록 쿼리 구현 (SQL, JPQL)
- 💡 테스트
Issue & Entity
Issue
Subscribe Entity
@Builder
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
@Table(
name="subscribe",
uniqueConstraints={
@UniqueConstraint(
name = "subscribe_uk",
columnNames={"fromUserId","toUserId"}
)
}
)
public class Subscribe extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "subscribe_id")
private Long id;
@JoinColumn(name = "from_user_id")
@ManyToOne(fetch = FetchType.LAZY)
private User fromUser; // 팔로우 하는 사람
@JoinColumn(name = "to_user_id")
@ManyToOne(fetch = FetchType.LAZY)
private User toUser; // 팔로우 당하는 사람
public Subscribe(User fromUser, User toUser){
this.fromUser = fromUser;
this.toUser = toUser;
}
}
기능 구현
@Override
@Transactional
public void 구독하기(Long fromUserSocialId, Long toUserSocialId) {
User fromUser = userRepository.findBySocialId(fromUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독하는 사용자를 찾을 수 없습니다. SocialID: " + fromUserSocialId));
User toUser = userRepository.findBySocialId(toUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독 받는 사용자를 찾을 수 없습니다. SocialID: " + toUserSocialId));
if(subscribeRepository.existsByFromUserAndToUser(fromUser,toUser)){
throw new IllegalStateException("이미 구독 중입니다.");
}
Subscribe subscribe = new Subscribe(fromUser,toUser);
subscribeRepository.save(subscribe);
}
@Override
@Transactional
public void 구독취소(Long fromUserSocialId, Long toUserSocialId) {
User fromUser = userRepository.findBySocialId(fromUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독자를 찾을 수 없습니다: " + fromUserSocialId));
User toUser = userRepository.findBySocialId(toUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독 대상자를 찾을 수 없습니다: " + toUserSocialId));
Subscribe subscribe = subscribeRepository.findByFromUserAndToUser(fromUser, toUser)
.orElseThrow(() -> new IllegalArgumentException("구독정보를 찾을 수 없습니다."));;
subscribeRepository.delete(subscribe);
}
✅ 구독과 구독취소는 비교적 간단하게 구현할 수 있었습니다~
팔로워 목록 쿼리 구현 ( SQL, JPQL )
자 이제 메인의 팔로워 목록 쿼리를 구현해볼건데요 생각보다 시간이 많이 걸렸습니다.😂
JPQL에 대한 지식이 많이 없어서 바로 프로젝트에 적용하기 이전에, 로컬 DB에서 직접 Entity를 만들고 쿼리를 짜면서 SQL 쿼리부터 만들었는데요. 이 쿼리를 이용하여 Native Query = True 로 하는 방법도 있습니다. 하지만 JPQL의 new operator라는 방법을 사용해 보고 싶었습니다. 왜냐하면 JPQL의 new operator 방식이 DTO로 바로 데이터를 출력해 주기 때문에 현재 요구사항에 맞는 것 같아서 적용하게 되었습니다.
SubscribeRespDto
@AllArgsConstructor
@NoArgsConstructor
@Data
public class SubscribeRespDto {
private Long userSocialId;
private String nickname;
private String image;
private boolean subscribeState; // 지금 이 DTO의 사람을 구독한 상태인지
private boolean equalState; // 나 자신인지
}
✅ 위의 Dto는 Target User의 구독자 정보를 담습니다.
💡 각 변수에 대한 설명은 아래의 인스타그램 사진을 참고하시면 이해가 잘 되실겁니다.
💡 userSocialId, nickname, image는 팔로워들의 유저 정보를 불러옵니다. 중요한 점은 subscribeState와 equalState
인데요. 위의 그림처럼 팔로워 목록의 User들이 나도 팔로워 한 사람인지 혹은 나 자신인지 분별하기 위한 변수입니다. 저도 이 데이터가 최적화된 것인지 더 좋은 방법이 있는지 고민하고 있기에 더 좋은 의견 있으시다면 댓글 부탁드립니다😁
SQL
-- 팔로워 목록 출력 쿼리
SELECT
u.social_id,
u.nickname,
u.image,
EXISTS (
SELECT 1
FROM subscribe s2
WHERE s2.fromUserId = 1 AND s2.toUserId = s.fromUserId
) AS subscribeState,
(s.fromUserId = 1) AS equalState
FROM
subscribe s
JOIN
users u ON s.fromUserId = u.user_id
WHERE
s.toUserId = 2;
✅ 조금 더 직관적으로 쿼리를 이해하기 위해 직접 숫자를 삽입하였습니다.
✅ 팔로워 목록을 조회하는 User의 아이디는 1번이고 조회할 User는 2번으로 설정하였습니다.
JPQL
@Query("SELECT new com.book_everywhere.domain.follow.dto.SubscribeRespDto(" +
"u.socialId, u.nickname, u.image, " +
"(SELECT COUNT(s2) > 0 FROM Subscribe s2 WHERE s2.fromUser.id = :fromUserId AND s2.toUser.id = s.fromUser.id), " +
"(CASE WHEN s.fromUser.id = :fromUserId THEN true ELSE false END)) " +
"FROM Subscribe s " +
"JOIN s.fromUser u " +
"WHERE s.toUser.id = :pageUserId")
List<SubscribeRespDto> findSubscribersByPageUserId(@Param("fromUserId") Long fromUserId, @Param("pageUserId") Long pageUserId);
✅ JPQL new operator를 사용하여 Dto에 직접 매핑했습니다. 위 SQL쿼리와 동작 방식은 일치하지만 SQL과 JPQL사이의 몇 가지 차이점
이 있습니다.
- 조인 방식의 차이:
- 네이티브 SQL 쿼리는 subscribe 테이블과 users 테이블을 조인할 때 JOIN users u ON s.fromUserId = u.user_id를 사용합니다. 이는 subscribe 테이블의 fromUserId와 users 테이블의 user_id를 기준으로 조인합니다.
- JPQL 쿼리에서는 FROM Subscribe s JOIN s.fromUser u로 표현되며, 이는 Subscribe 엔티티와 연관된 fromUser를 User 엔티티와 조인하는 것을 의미합니다. JPQL에서는 엔티티 간의 관계를 통해 조인을 정의합니다.
- 서브쿼리 구현의 차이:
- 네이티브 SQL 쿼리는 EXISTS를 사용하여 subscribeState를 계산합니다. 이는 해당 조건을 만족하는 레코드가 존재하는지에 대한 불리언 값을 반환합니다.
- JPQL 쿼리에서는 SELECT COUNT(s2) > 0을 사용하여 같은 조건을 만족하는 레코드의 수가 0보다 큰지를 통해 불리언 값을 계산합니다.
- 조건식의 표현 방식 차이:
- 네이티브 SQL 쿼리에서는 (s.fromUserId = 1) AS equalState와 같이 바로 조건식의 결과를 equalState로 반환합니다.
- JPQL 쿼리에서는 CASE WHEN s.fromUser.id = :fromUserId THEN true ELSE false END와 같이 CASE 문을 사용하여 조건식의 결과를 계산합니다.
JPQL new operator
JPQL의 New 연산자는 조회결과를 직접적으로 Dto나 다른 객체로 맵핑할 때 사용합니다. 이 연산자를 사용하면, 조회된 각 컬럼의 데이터를 특정 클래스의 생성자에 직접 전달하여, 결과를 해당 클래스의 인스턴스 리스트로 받을 수 있게 됩니다.
- 사용 방법
- new 연산자를 사용하기 위해서는 대상 클래스에 적절한 생성자가 정의되어 있어야 합니다. 그리고 JPQL 쿼리 내에서 SELECT 절에 new 키워드와 함께 클래스의 전체 이름(패키지명 포함)을 명시한 뒤, 생성자에 전달할 파라미터를 괄호 안에 넣어주면 됩니다.
- 장점
효율성 :
직접 필요한 데이터만 선택하여 DTO로 변환하기 때문에 불필요한 데이터의 조회를 방지할 수 있습니다. 이는 애플리케이션의 성능을 향상시킬 수 있습니다.분리 :
엔티티 클래스와 프레젠테이션 레이어 간의 의존성을 줄일 수 있습니다. 엔티티 클래스가 변경되더라도 DTO는 영향을 받지 않으며, 보안상 민감한 정보를 숨길 때도 유용합니다.가독성 :
쿼리 결과를 직접적으로 객체로 변환하기 때문에, 결과 처리 로직이 더 간결해지고 가독성이 향상됩니다.
- 주의할 점
- 사용하려는 클래스는
적절한 생성자
를 가져야 합니다. - 클래스의 전체 이름(패키지명 포함)을
정확히 명시
해야 합니다. - 생성자 파라미터의
순서와 타입
이쿼리의 선택 항목과 일치
해야 합니다.
- 사용하려는 클래스는
구독 서비스 최종 구현
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class SubscribeServiceImpl implements SubscribeService{
private final UserRepository userRepository;
private final SubscribeRepository subscribeRepository;
@Override
public List<SubscribeRespDto> 구독리스트(Long fromUserSocialId, Long pageUserSocialId) {
User fromUser = userRepository.findBySocialId(fromUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독하는 사용자를 찾을 수 없습니다. SocialID: " + fromUserSocialId));
User pageUser = userRepository.findBySocialId(pageUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독 받는 사용자를 찾을 수 없습니다. SocialID: " + pageUserSocialId));
return subscribeRepository.findSubscribersByPageUserId(fromUser.getId(), pageUser.getId());
}
@Override
@Transactional
public void 구독하기(Long fromUserSocialId, Long toUserSocialId) {
User fromUser = userRepository.findBySocialId(fromUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독하는 사용자를 찾을 수 없습니다. SocialID: " + fromUserSocialId));
User toUser = userRepository.findBySocialId(toUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독 받는 사용자를 찾을 수 없습니다. SocialID: " + toUserSocialId));
if(subscribeRepository.existsByFromUserAndToUser(fromUser,toUser)){
throw new IllegalStateException("이미 구독 중입니다.");
}
Subscribe subscribe = new Subscribe(fromUser,toUser);
subscribeRepository.save(subscribe);
}
@Override
@Transactional
public void 구독취소(Long fromUserSocialId, Long toUserSocialId) {
User fromUser = userRepository.findBySocialId(fromUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독자를 찾을 수 없습니다: " + fromUserSocialId));
User toUser = userRepository.findBySocialId(toUserSocialId)
.orElseThrow(() -> new IllegalArgumentException("구독 대상자를 찾을 수 없습니다: " + toUserSocialId));
Subscribe subscribe = subscribeRepository.findByFromUserAndToUser(fromUser, toUser)
.orElseThrow(() -> new IllegalArgumentException("구독정보를 찾을 수 없습니다."));;
subscribeRepository.delete(subscribe);
}
}
혼합색상 소제목
테스트 코드
@DisplayName("Service_구독하기_테스트")
@Test
void 구독하기_테스트() {
//given
subscribeService.구독하기(fromUserTest.getSocialId(), toUserTest.getSocialId());
subscribeService.구독하기(toUserTest2.getSocialId(), toUserTest.getSocialId());
//when
List<SubscribeRespDto> subscriptions = subscribeService.구독리스트(fromUserTest.getSocialId(), toUserTest.getSocialId());
//then
assertThat(subscriptions).hasSize(2);
assertThat(subscriptions.get(0).getNickname()).isEqualTo(fromUserTest.getNickname());
assertThat(subscriptions.get(1).getNickname()).isEqualTo(toUserTest2.getNickname());
}
간단하게 Test코드를 구현해 보았습니다. 코드의 내용은 2명이 toUserTest를 구독하고 구독리스트를 불러왔을 때 구독자가 2명인지, 첫 번째 구독자의 이름은 같은지, 두 번째 구독자의 이름도 일치하는지 테스트하는 동작입니다.
이외의 구독취소, 구독중복예외 테스트도 성공했습니다.
다음 포스팅은 실제 환경에 적용하여 정상작동하는지 확인 후에 성능테스트를 통해 더 효율적인 방법을 고안해 보도록 하겠습니다.
감사합니다😊
'Backend > 🌿Spring' 카테고리의 다른 글
[SpringBoot] N + 1 문제 해결 및 성능 개선 (0) | 2024.04.10 |
---|---|
[SpringBoot] Redis 캐싱을 통한 좋아요 조회 성능 비교 (0) | 2024.04.08 |
[SpringBoot] Docker를 통한 EC2환경 Redis 설치 & 테스트 통과하기 (0) | 2024.04.02 |
[SpringBoot] 좋아요 기능 구현 및 생각 (0) | 2024.03.27 |
[SpringBoot] 협업을 위한 Swagger 사용법 (0) | 2024.03.11 |