인스타그램 팔로우 관계는 어떻게 관리될까?

.·2022년 3월 8일
9

인스타그램의 팔로우, 네이버 블로그의 이웃추가 등 서로의 관계를 데이터베이스에서 효율적으로 관리하는 방법이 궁금해졌다. Spring boot로 인스타그램 클론 코딩을 진행하며 팔로우 관계에 대한 데이터를 효율적으로 관리해보고자 시도했던 과정을 기록한다. 좋은 방법을 찾을 때까지 해당 포스터에 기록하려고 한다...😰

🛠Failed attempt 1: List

@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 사용자에 followingListkiwi 사용자를 추가한다.
  • kiwi 사용자에 followerListapple 사용자를 추가한다.

즉, apple 사용자에 의해 발생되는 문제를 해결하기 위해 kiwi 사용자의 원본 객체에 접근한다는 것은 안전하지 않다고 판단해 해당 방법으로 구현하지 않았다.


🛠Failed attempt 2: @ManyToOne

@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: 다른 사용자에 의해 팔로우 관계가 생성되는 사용자

fromUsertoUser 를 참조하는 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 쿼리가 발생하고 정상적으로 데이터베이스에 저장된다.

Profile 출력

사용자 프로필 화면에 출력되는 정보 중 게시물, 팔로워 그리고 팔로우 필드는 개수만 나타내면 된다.

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번 발생한다.

❗️JPA N+1 쿼리 문제발생 (Follow list)

프로필에서 팔로우를 클릭하면 사용자가 팔로우하고 있는 사용자의 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를 전달하는 것은 위험하므로 FollowSimpleListDtoprofile 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 문제가 발생하는 것이 아님을 주의한다.

❓ fromUser에 대한 SELECT 쿼리가 발생하지 않은 이유

apple(ID: 1) 사용자는 3명의 사용자를 팔로잉하기 때문에 3개의 Follow 인스턴스를 가져온다. 3개의 Follow 인스턴스에 대한 toUser를 원본 객체로 설정하는 과정에서 총 3번의 SELECT 쿼리가 발생한다.

하지만 3개의 Follow 인스턴스에 대한 fromUser를 원본 객체로 설정하기 위한 SELECT 쿼리가 발생하지 않았다. 발생하지 않은 이유를 추측하면 다음과 같다.

fromUserapple(ID: 1) 에 대한 User 정보를 얻기 위해 findByUsername(apple) 호출하면 apple(ID: 1) 의 인스턴스가 1차 캐시에 저장된다. 그렇기 때문에 apple(ID: 1) 에 대한 SELECT 쿼리가 발생하지 않았다고 추측한다.(잘못된 내용입니다.)

이유 추가예정...


Lazy 설정

@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 관계가 과연 Follow 객체와 User 객체 간의 상태일까?

Follow와 User는 @ManyToOne 연관 관계이다. 연관 관계라는 것은 서로가 연관이 있다는 뜻인데 여기서 의문점이 생겼다. Follow 관계가 과연 Follow 객체와 User 객체 간의 상태일까?

출처: https://velog.io/@conatuseus/%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91-%EA%B8%B0%EC%B4%88-1-i3k0xuve9i

쉬운 예제로 Member는 1개의 Team에 소속될 수 있는 경우를 생각볼 수 있다. Member와 Team은 Member가 어떤 팀을 선택하는지에 따라 계속해서 변경될 수 있다. 그러므로 변경 될때매다 외래키를 갖고 있는 Member 객체에서 수정하면 된다. 이 경우 두 객체 또는 테이블의 관계가 맞다.

하지만 Follow 관계는 toUser와 fromUser 간의 상태로 인해 결정되는 것이지, Follow 객체와 User 객체 간의 상태가 아니라는 생각이 들었다. 그래서 연관 관계를 설정하지 않고 복합키를 사용하기로 결정했다.


🛠Attempt 3: 복합키로 변경

@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 로 설정할 수 없기때문에 toUserfromUser 는 User 객체 중 변하지 않는 Id field값을 갖도록 설정한다. 그 후 toUserfromUser 필드로 복합키(Composite key)를 생성한다.

즉, FOLLOWUSERS 는 서로 참조하는 상태가 아니다.

팔로우하기

이번에도 4명의 사용자를 생성하고 ID가 apple인 사용자가 나머지 사용자를 모두 follow 했다. Follow EntityUser Entity를 참조하고 있는 것이 아니라 User EntityId 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 쿼리가 발생한다.

Profile 출력

사용자 프로필 화면에 출력되는 정보 중 게시물, 팔로워, 팔로우 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 쿼리가 발생한다.

❗️JPA N+1 쿼리 문제발생 (Follow list)

프로필에서 팔로우를 클릭하면 사용자가 팔로우하고 있는 사용자의 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 entityUser 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 쿼리 문제가 발생한다.


🛠Attempt 4: @Query 사용해 JPA N+1 Query 문제 해결

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명의 사용자를 생성하고 위와 같은 관계를 설정했다.

Follow list 가져오기

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() 가 호출하면 다음과 같은 쿼리가 발생한다.

1. apple User 의 ID 값을 얻기 위한 SELECT query 발생

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';

2. apple User 의 팔로우 목록 가져오는 SELECT query 발생

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 값

FOLLOWUSERSINNER JOIN하여 팔로잉 리스트 뷰에 출력할 Username(활동 ID), name(실제 이름) 정보만 가져오는데 1개의 SELECT 쿼리가 발생한다. 즉, N 명의 정보를 요청해도 1개의 SELECT 쿼리만 발생하므로 N + 1 쿼리 문제를 해결할 수 있다.

postman으로 팔로잉 목록을 확인해 보면 정상적으로 처리됨을 알 수 있다.

Follower list 가져오기

apple(Id: 1) 사용자를 팔로잉한 follower 목록을 가져오기 위해 http://localhost:8080/follower/apple GET 요청하면 다음과 같은 query 가 발생한다.

1. apple User 의 ID 값을 얻기 위한 SELECT 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';

2. apple User 의 팔로워 목록 가져오는 SELECT query 발생

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으로 팔로워 목록을 확인해보니 정상적으로 처리됨을 알 수 있다.

Attempt 4까지의 결과

현재 @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로 반환하면 해당 문제를 해결할 수 있다.

0개의 댓글