인스타그램의 팔로우, 네이버 블로그의 이웃추가 등 서로의 관계를 데이터베이스에서 효율적으로 관리하는 방법이 궁금해졌다. Spring boot로 인스타그램 클론 코딩을 진행하며 팔로우 관계에 대한 데이터를 효율적으로 관리해보고자 시도했던 과정을 기록한다. 좋은 방법을 찾을 때까지 해당 포스터에 기록하려고 한다...😰
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private List<Long> followerList;
private List<Long> followingList;
}
처음에는 User entity
가 팔로워, 팔로잉 관계를 List 에 담아 가지고 있도록 구현하려고 했다. 만약 apple
사용자가 kiwi
사용자를 팔로우 하려고 한다. 이를 처리하기 위해 아래 과정을 거처야 한다.
apple
사용자에 followingList 에 kiwi
사용자를 추가한다.kiwi
사용자에 followerList 에 apple
사용자를 추가한다.즉, apple
사용자에 의해 발생되는 문제를 해결하기 위해 kiwi
사용자의 원본 객체에 접근한다는 것은 안전하지 않다고 판단해 해당 방법으로 구현하지 않았다.
@NoArgsConstructor
@Entity
public class Follow {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne // default EAGER
@JoinColumn(name = "to_user")
private User toUser;
@ManyToOne
@JoinColumn(name = "from_user")
private User fromUser;
public Follow(User toUser, User fromUser){
this.toUser = toUser;
this.fromUser = fromUser;
}
}
fromUser
와 toUser
를 참조하는 Follow 객체를 생성했다. id field는 IDENTITY 전략으로 설정했지만 사실상 의미없다.
apple(ID: 1)
, kiwi(ID: 2)
, banana(ID: 3)
, mango(ID: 4)
4명의 사용자를 생성했다. 그 후 apple(ID: 1)
사용자가 나머지 사용자를 모두 팔로우한다.
insert
into
follow
(id, form_user, to_user)
values
(null, 1, 4); // apple(ID: 1) -> mango(ID: 4)
apple(ID: 1)
사용자가 mango(ID: 4)
사용자를 팔로우하기 위해 http://localhost:8080/follow/mango/apple
PUT 요청하면 위와 같이 1개의 INSERT 쿼리가 발생하고 정상적으로 데이터베이스에 저장된다.
사용자 프로필 화면에 출력되는 정보 중 게시물
, 팔로워
그리고 팔로우
필드는 개수만 나타내면 된다.
public interface FollowRepository extends JpaRepository<Follow, Long> {
Long countByToUser(User user); // 팔로워 수 (follower)
Long countByFromUser(User user); // 팔로우 수 (following)
}
개수만 얻기 위해 Spring Data JPA 에서 제공하는 countBy~()
메소드를 사용했다. apple
사용자의 프로필 정보를 얻기 위해 http://localhost:8080/profile/apple
GET 요청하면 팔로워 수를 얻기 위해 countByToUser()
가, 팔로우 수를 얻기 위해 countByFromUser()
가 실행된다.
select
count(follow0_.id) as col_0_0_
from
follow follow0_
where
follow0_.from_user=1;
팔로우 수를 알기 위해 countByFromUser()
가 실행되면 위와 같이 SELECT 쿼리가 1번 발생한다.
프로필에서 팔로우를 클릭하면 사용자가 팔로우하고 있는 사용자의 profile photo
, Username(활동 ID)
, name(실제 이름)
정보를 출력한다.
public interface FollowRepository extends JpaRepository<Follow, Long> {
List<Follow> findAllByFromUser(Long userId); // 사용자가 팔로우한 관계를 가져옴
List<Follow> findAllByToUser(Long userId); // 사용자를 팔로우하는 관계를 가져옴
}
@Service
public class FollowService {
public List<FollowSimpleListDto> getFollowingList(String username){
User user = userRepository.findByUsername(username).orElseThrow(UserException::new);
// toUser, fromUser가 초기화되는 시점
List<Follow> followingList = followRepository.findAllByFromUser(user);
List<FollowSimpleListDto> followSimpleListDtoList = new ArrayList<>();
for(Follow follow : followingList) {
followSimpleListDtoList.add(
new FollowSimpleListDto(follow.getToUser().getUsername())
);
}
return followSimpleListDtoList;
}
}
Persistence Layer에 있는 Controller에게 Entity를 전달하는 것은 위험하므로 FollowSimpleListDto에 profile photo
, Username(활동 ID)
, name(실제 이름)
field만 담아 전달한다. 또한, toUser 정보에 무조건 접근해야하므로 @ManyToOne
fetch 타입을 EAGER 로 설정했다. (default가 EAGER로 동작)
select
user0_.id as id1_4_0_,
user0_.email as email3_4_0_,
user0_.name as name4_4_0_,
user0_.password as password5_4_0_, // 일부 생략
from
users user0_
where
user0_.id=2; // kiwi(ID: 2) ... 해당 쿼리가 ID 2, 3 그리고 4에 대해 3번 발생
그렇기 때문에 FollowRepository 의 findAllByFromUser()
를 호출해 instance를 가져올 때 toUser
, fromUser
field를 실제 객체로 설정하기 위한 SELECT 쿼리가 발생한다. 즉, N명의 toUser 객체를 가져오기 위해 N개의 SELECT Query가 발생하므로 이 부분에서 JPA N+1 Query 문제가 발생한다.
FollowSimpleListDto로 변환하기 위해 for 문을 시작하기 전에 toUser
, fromUser
field는 proxy 객체의 타겟이 실제 객체로 설정되어있으며, for문에서 JPA N+1 query 문제가 발생하는 것이 아님을 주의한다.
apple(ID: 1)
사용자는 3명의 사용자를 팔로잉하기 때문에 3개의 Follow 인스턴스를 가져온다. 3개의 Follow 인스턴스에 대한 toUser
를 원본 객체로 설정하는 과정에서 총 3번의 SELECT 쿼리가 발생한다.
하지만 3개의 Follow 인스턴스에 대한 fromUser
를 원본 객체로 설정하기 위한 SELECT 쿼리가 발생하지 않았다. 발생하지 않은 이유를 추측하면 다음과 같다.
fromUser인 (잘못된 내용입니다.)apple(ID: 1)
에 대한 User 정보를 얻기 위해 findByUsername(apple)
호출하면 apple(ID: 1)
의 인스턴스가 1차 캐시에 저장된다. 그렇기 때문에 apple(ID: 1)
에 대한 SELECT 쿼리가 발생하지 않았다고 추측한다.
이유 추가예정...
@Entity
public class Follow {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "to_user")
private User toUser;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "from_user")
private User fromUser;
}
@Service
public class FollowService {
public List<FollowSimpleListDto> getFollowingList(String username){
User user = userRepository.findByUsername(username).orElseThrow(UserException::new);
List<Follow> followingList = followRepository.findAllByFromUser(user);
List<FollowSimpleListDto> followSimpleListDtoList = new ArrayList<>();
for(Follow follow : followingList) {
// getUsername() 을 호출하는 시점에 SELECT 쿼리 발생
followSimpleListDtoList.add(
new FollowSimpleListDto(follow.getToUser().getUsername())
);
}
return followSimpleListDtoList;
}
}
toUser
, fromUser
을 지연 로딩으로 설정해도 FollowSimpleListDto로 변환하기 위해 User
인스턴스에 대한 getUsername()
을 호출하는 시점에 SELECT 쿼리가 발생한다. 즉, JPA N+1 쿼리 문제를 해결할 수 없고 EAGER와 다르게 for문에서 N + 1 쿼리 문제가 발생한다.
Follow와 User는 @ManyToOne
연관 관계이다. 연관 관계라는 것은 서로가 연관이 있다는 뜻인데 여기서 의문점이 생겼다. Follow 관계가 과연 Follow 객체와 User 객체 간의 상태일까?
쉬운 예제로 Member는 1개의 Team에 소속될 수 있는 경우를 생각볼 수 있다. Member와 Team은 Member가 어떤 팀을 선택하는지에 따라 계속해서 변경될 수 있다. 그러므로 변경 될때매다 외래키를 갖고 있는 Member 객체에서 수정하면 된다. 이 경우 두 객체 또는 테이블의 관계가 맞다.
하지만 Follow 관계는 toUser와 fromUser 간의 상태로 인해 결정되는 것이지, Follow 객체와 User 객체 간의 상태가 아니라는 생각이 들었다. 그래서 연관 관계를 설정하지 않고 복합키를 사용하기로 결정했다.
- Composite key 설정 커밋: https://github.com/evelyn82ny/instagram-api/commit/246fdf1d16f27f9081b3e522bd428c380946446a
- uniqueConstraints 설정 커밋: https://github.com/evelyn82ny/instagram-api/commit/f4c4f11c6fb0b553b5c3f0b8c6b0f4fc6775aeea
@NoArgsConstructor
@Entity
@Table(
uniqueConstraints = @UniqueConstraint(columnNames = {"to_user", "from_user"})
)
@IdClass(Follow.PK.class)
public class Follow {
@Id
@Column(name = "to_user", insertable = false, updatable = false)
private Long toUser;
@Id
@Column(name = "from_user", insertable = false, updatable = false)
private Long fromUser;
@Builder
public Follow(Long toUser, Long fromUser) {
this.toUser = toUser;
this.fromUser = fromUser;
}
public static class PK implements Serializable {
Long toUser;
Long fromUser;
}
}
Entity를 @Id 로 설정할 수 없기때문에 toUser
와 fromUser
는 User 객체 중 변하지 않는 Id field값을 갖도록 설정한다. 그 후 toUser
와 fromUser
필드로 복합키(Composite key)를 생성한다.
즉, FOLLOW
와 USERS
는 서로 참조하는 상태가 아니다.
이번에도 4명의 사용자를 생성하고 ID가 apple
인 사용자가 나머지 사용자를 모두 follow 했다. Follow Entity
는 User Entity
를 참조하고 있는 것이 아니라 User Entity
의 Id field값만 가지고 있는 상태이다.
insert
into
follow
(from_user, to_user)
values
(1, 4); // apple(ID: 1) -> mango(ID: 4)
apple(Id: 1)
사용자가 mango(Id: 4)
를 팔로우하기 위해 http://localhost:8080/follow/mango/apple
PUT 요청하면 위와 같이 1개의 INSERT 쿼리가 발생한다.
사용자 프로필 화면에 출력되는 정보 중 게시물
, 팔로워
, 팔로우
field는 개수만 나타내면 된다.
public interface FollowRepository extends JpaRepository<Follow, Follow.PK> {
Long countByToUser(Long userId); // 팔로워 수 (follower)
Long countByFromUser(Long userId); // 팔로우 수 (following)
}
Failed Attempt 2
와 마찬가지로 apple
사용자의 프로필 정보를 얻기 위해 http://localhost:8080/profile/apple
GET 요청하면 팔로워 수를 얻기 위해 countByToUser()
가, 팔로우 수를 얻기 위해 countByFromUser()
가 실행된다.
select
count(*) as col_0_0_
from
follow follow0_
where
follow0_.from_user=1;
팔로우 수를 알기 위해 countByFromUser()
가 실행되면 위와 같이 1개의 SELECT 쿼리가 발생한다.
프로필에서 팔로우를 클릭하면 사용자가 팔로우하고 있는 사용자의 profile photo
, Username(활동 ID)
, name(실제 이름)
정보를 출력해야 한다.
public interface FollowRepository extends JpaRepository<Follow, Follow.PK> {
List<Follow> findAllByFromUser(Long userId); // 내가 팔로우한 관계를 가져옴
List<Follow> findAllByToUser(Long userId); // 나를 팔로우하는 관계를 가져옴
}
@Entity
@IdClass(Follow.PK.class)
public class Follow {
@Id
@Column(name = "to_user", insertable = false, updatable = false)
private Long toUser;
@Id
@Column(name = "from_user", insertable = false, updatable = false)
private Long fromUser;
// 아래 생략
}
@Service
public class FollowService {
public List<FollowSimpleListDto> getFollowingList(String username){
User user = userRepository.findByUsername(username).orElseThrow(UserException::new);
List<Follow> followingList = followRepository.findAllByFromUser(user.getId());
List<FollowSimpleListDto> followSimpleListDtoList = new ArrayList<>();
for(Follow follow : followingList) {
// SELECT 쿼리 발생 (N + 1 쿼리 문제 발생 지점)
User target = userRepository.findById(follow.getToUser()).orElseThrow(UserException::new);
followSimpleListDtoList.add(new FollowSimpleListDto(target.getUsername(), target.getName()));
}
return followSimpleListDtoList;
}
}
현재 Follow entity
는 User Entity
를 참조하고 있지 않고, Long 타입인 User의 Id 필드의 값만 가지고 있다. 그러므로 findAllByFromUser()
를 호출하면 팔로우하고 있는 User의 Id 필드값만 가져온다.
select
user0_.id as id1_4_0_,
user0_.email as email3_4_0_,
user0_.name as name4_4_0_,
user0_.password as password5_4_0_, // 일부 생략
from
users user0_
where
user0_.id=2; // kiwi(ID: 2) ... 해당 쿼리가 3번 발생
findAllByFromUser()
로 toUser의 Id 필드값만 가져오면 toUser의 특정 데이터를 가져오기 위해 UserRepository 에서 Id(PK)가 일치하는 User의 원본 객체를 가져와야 한다.
즉, N 명의 User를 찾기 위해 N개의 SELECT 쿼리가 발생하므로 Failed Attempt 2
와 동일하게 N + 1 쿼리 문제가 발생한다.
Failed attempt 3
에서 복합키로 변경한 Follow entity
를 그대로 사용한다. 대신 팔로워 목록을 가져오는 findAllByFromUser()
, 팔로잉 목록을 가져오는 findAllByToUser()
에 @Query 를 추가한다.
public interface FollowRepository extends JpaRepository<Follow, Follow.PK> {
@Query(value = "select new com...파일 경로...FollowSimpleListDto(u.username, u.name)"
+ "from Follow f INNER JOIN User u"
+ "ON f.toUser = u.id where f.fromUser = :userId")
List<FollowSimpleListDto> findAllByFromUser(@Param("userId") Long userId);
}
public interface FollowRepository extends JpaRepository<Follow, Follow.PK> {
@Query(value = "select new com...파일 경로...FollowSimpleListDto(u.username, u.name)"
+ "from Follow f INNER JOIN User u"
+ "ON f.fromUser = u.id where f.toUser = :userId")
List<FollowSimpleListDto> findAllByToUser(@Param("userId") Long userId);
}
동일하게 4명의 사용자를 생성하고 위와 같은 관계를 설정했다.
apple(Id: 1)
사용자가 팔로우한 목록을 가져오기 위해 http://localhost:8080/follower/apple
GET 요청하면 FollowService 에서 getFollowingList()
가 실행된다.
public List<FollowSimpleListDto> getFollowingList(String username){
User user = userRepository.findByUsername(username).orElseThrow(UserException::new);
return followRepository.findAllByFromUser(user.getId());
}
FollowRepository의 findAllByFromUser()
는 List<FollowSimpleListDto>
를 반환하기 때문에 FollowService 를 위와 같이 변경한다. 팔로우 리스트를 가져오기 위해 getFollowingList()
가 호출하면 다음과 같은 쿼리가 발생한다.
UserRepository 의 findByUsername("apple")
을 호출하면 1개의 SELECT 쿼리가 발생한다.
select
user0_.id as id1_4_,
user0_.bio as bio2_4_,
user0_.email as email3_4_,
user0_.name as name4_4_,
user0_.password as password5_4_,
user0_.phone_number as phone_nu6_4_,
user0_.profile_image_url as profile_7_4_,
user0_.username as username8_4_,
user0_.website as website9_4_
from
users user0_
where
user0_.username='apple';
select
user1_.username as col_0_0_,
user1_.name as col_1_0_
from
follow follow0_
inner join
users user1_
on (
follow0_.to_user=user1_.id
)
where
follow0_.from_user=1; // apple 사용자의 id 값
FOLLOW
와 USERS
가 INNER JOIN하여 팔로잉 리스트 뷰에 출력할 Username(활동 ID)
, name(실제 이름)
정보만 가져오는데 1개의 SELECT 쿼리가 발생한다. 즉, N 명의 정보를 요청해도 1개의 SELECT 쿼리만 발생하므로 N + 1 쿼리 문제를 해결할 수 있다.
postman으로 팔로잉 목록을 확인해 보면 정상적으로 처리됨을 알 수 있다.
apple(Id: 1)
사용자를 팔로잉한 follower 목록을 가져오기 위해 http://localhost:8080/follower/apple
GET 요청하면 다음과 같은 query 가 발생한다.
select
user0_.id as id1_4_,
user0_.bio as bio2_4_,
user0_.email as email3_4_,
user0_.name as name4_4_,
user0_.password as password5_4_,
user0_.phone_number as phone_nu6_4_,
user0_.profile_image_url as profile_7_4_,
user0_.username as username8_4_,
user0_.website as website9_4_
from
users user0_
where
user0_.username='apple';
select
user1_.username as col_0_0_,
user1_.name as col_1_0_
from
follow follow0_
inner join
users user1_
on (
follow0_.from_user=user1_.id
)
where
follow0_.to_user=1;
팔로우 리스트와 마찬가지로 팔로워 리스트를 가져올 때도 1개의 SELECT 쿼리만 발생하고 postman으로 팔로워 목록을 확인해보니 정상적으로 처리됨을 알 수 있다.
현재 @Query
를 사용해 N + 1 쿼리 문제를 해결하였고 Service 코드가 깔끔해졌다.
하지만 Followers(팔로워), Following(팔로우) 관계를 가져오는 findAllByToUser
, findAllByFromUser
에 @Query
설정을 통해 username(활동 ID)
과 name(주민등록상 이름)
의 필드만 가져오기 때문에 활용성이 떨어진다는 새로운 문제가 발생한다. 또한, 클라이언트가 원하는 데이터가 달라지면 @Query
를 계속 수정해야 하기 때문이다.
commit: https://github.com/evelyn82ny/instagram-api/commit/9f2480924c9516f3552c69aafb78f5c8eeacc263
@Query(value = "select u from Follow f INNER JOIN User u"
+"ON f.toUser = u.id where f.fromUser = :userId")
List<User> findAllByFromUser(@Param("userId") Long userId);
Repository에서는 User Entity를 반환하고 Service에서 민감한 정보를 제외해 Dto로 반환하면 해당 문제를 해결할 수 있다.