My-Books-projects / etc

0 stars 1 forks source link

OSIV/ #148

Open damho-lee opened 6 months ago

damho-lee commented 6 months ago

1. 개요

Session per request 는 영속성 세션을 연결하고 라이프 사이클을 함께 요청하는 트랜잭션 패턴이다. 놀랍지도 않게, Spring 은 이 패턴의 자체구현인 OpenSessionInViewInterceptor 을 따르고, 이는 lazy association 을 다루는 것을 용이하게 하고, 개발생산성을 향상시킨다. 첫째로, 이 튜토리얼에서 우리는 내부적으로 interceptor 가 어떻게 동작하는지 배우고, 그리고 나서 이 논란 많은 패턴이 우리 애플리케이션에게 어떻게 양날의 검이 될 수 있는지 살펴볼 것이다.

2. Open Session in View 소개

OSIV 를 이해하는데 도움을 주기 위해 우리에게 들어온 요청이 있다고 가정해보자.

  1. Spring 은 request 이 시작될 때 새로운 Hibernate Session 을 연다. 이러한 Session 들은 무조건적으로 database 에 연결되지는 않는다. (database 와 연결되지 않는 Session 들도 있다.)
  2. Application 이 Session 이 필요한 모든 순간에, Application 은 이미 존재하는 Session 을 재사용 할 것이다.
  3. 요청이 끝나면, 동일한 interceptor 가 Session 을 닫는다.

처음에는, 이 기능을 활성화하는 것이 합리적일 수 있다. 어쨌든, framework 가 Session 생성과 종료를 알아서 다루기 때문에 개발자들은 이런 보이는 수준이 낮은 세부사항에 대해 걱정할 필요가 없다. 결국 이것은 개발 생산성을 북돋는다.

그러나 때때로, OSIV 는 production 에서 미묘한 성능 문제를 일으킬 수 있다.

스크린샷 2024-02-27 오후 1 29 33

스크린샷 2024-02-27 오후 1 29 04

2.1 Spring Boot

기본적으로, OSIV 는 Spring Boot Application 에서 활성화 되어있다. Spring Boot 2.0 부터는 명시적으로 구성하지 않은 경우 애플리케이션 시작 시 OSIV 가 활성화되어 있는 사실을 경고한다.

spring.jpa.open-in-view is enabled by default. Therefore, database 
queries may be performed during view rendering.Explicitly configure 
spring.jpa.open-in-view to disable this warning

spring.jpa.open-in-view configuration property 를 사용해서 OSIV 를 비활성화할 수 있다.

spring.jpa.open-in-view=false

2.2 Pattern or Anti-Pattern?

OSIV 를 향한 혼합된 반응들이 항상 있어왔다. OSIV 를 지지하는 측의 주요 주장은 개발자 생산성이며, 특히 lazy association 을 다룰 때이다.

반면에, database 성능 이슈가 OSIV 를 지지하지 않는 측의 주요 주장이다. 나중에, 우리는 두 주장 모두 자세하게 다룰 것이다.

3. Lazy Initialization Hero

OSIV 가 Session lifecycle 을 각 요청에 바인딩하기 때문에 Hibernate는 명시적인 @Transactional 서비스를 실행한 후에도 lazy association 을 해결할 수 있다.

이를 더 잘 이해하기 위해, 우리가 우리의 users 와 그들의 security permission 을 모델링 한다고 가정해보자.

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @ElementCollection
    private Set<String> permissions;

    // getters and setters
}

다른 one-to-many 와 many-to-many 관계들과 비슷하게, permissions 속성은 lazy collection 이다. 그리고 우리의 service 계층 구현에서, @Transactional 을 사용해 우리의 transactional boundary 를 명시적으로 정해보자.

@Service
public class SimpleUserService implements UserService {

    private final UserRepository userRepository;

    public SimpleUserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public Optional<User> findOne(String username) {
        return userRepository.findByUsername(username);
    }
}

3.1 The Exception

우리 코드가 findOne 메서드를 호출할 때 우리가 기대하는 동작은 다음과 같다.

  1. 처음에 Spring proxy 가 call 을 가로채고 현재 transaction 을 가지거나 존재하지 않으면 새로 하나 만든다.
  2. 그리고 나서 Spring proxy 는 메서드 호출을 우리의 구현에 위임한다.
  3. 마지막으로, proxy 는 transaction 을 commit 하고 결과적으로 기반이 되는 session 을 닫는다. 결국 우리는 service 계층에서만 해당 세션을 필요로 한다.

findOne 메서드 구현에서, 우리는 permission collection 을 초기화하지 않는다. 그로므로, 우리는 findOne 메서드가 return 한 후 permissions 를 사용할 수 없다. 만약 이 속성을 순회(iterate)한다면, LazyInitializationException 을 받을 것이다.

3.2 Welcome to the Real World

"permissions" 속성을 사용할 수 있는지 확인하기 위해 간단한 REST 컨트롤러를 작성해 보자.

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{username}")
    public ResponseEntity<?> findOne(@PathVariable String username) {
        return userService
                .findOne(username)
                .map(DetailedUserDto::fromEntity)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

여기서, 우리는 entity 를 DTO 로 변환하는 동안 permissios 를 순회한다. 우리는 LazyInitializationException 이 발생할 것으로 예상하기 때문에 밑의 테스트는 통과되지 않아야 한다.

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserControllerIntegrationTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        User user = new User();
        user.setUsername("root");
        user.setPermissions(new HashSet<>(Arrays.asList("PERM_READ", "PERM_WRITE")));

        userRepository.save(user);
    }

    @Test
    void givenTheUserExists_WhenOsivIsEnabled_ThenLazyInitWorksEverywhere() throws Exception {
        mockMvc.perform(get("/users/root"))
          .andExpect(status().isOk())
          .andExpect(jsonPath("$.username").value("root"))
          .andExpect(jsonPath("$.permissions", containsInAnyOrder("PERM_READ", "PERM_WRITE")));
    }
}

그러나, 이 테스트는 어떠한 exception 도 던지지 않고 통과된다.

request 가 시작될 때 OSIV 가 새로운 세션을 생성하기 때문에, transactional proxy 는 새로운 것을 생성하는 대신에 현재 사용 가능한 세션을 사용한다.

그래서, 우리의 예상과는 다르게, 명시적인 @Transactional 밖에서도 permissions 속성을 사용할 수 있다. 더욱이, 현재 요청 범위 어디에서든지 이러한 종류의 lazy associations 를 가져올 수 있다.

3.3 On Developer Productivity

OSIV 가 활성화되지 않았다면, Transactional Context 에서 필수적인 모든 lazy association 를 일일이 초기화 해줘야 한다.

@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    user.ifPresent(u -> Hibernate.initialize(u.getPermissions()));

    return user;
}

이제 OSIV 가 개발자 생산성에 미치는 영향이 명백하다. 그러나 항상 개발자 생산성만이 중요한 것은 아니다.

4. Performance Villain

@Override
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    if (user.isPresent()) {
        // remote call
    }

    return user;
}

여기서는 원격 서비스를 기다리는 동안 연결된 세션을 유지할 필요가 확실히 없기 때문에 @Transactional 을 지우고 있다.

4.1. Avoiding Mixed IOs

만약 @Transactional 을 지우지 않는다면 어떤 일이 발생하는지 살펴보자. 새로운 원격 서비스가 평소보다 약간 늦게 응답한다고 가정해보자.

  1. 먼저 Spring proxy 는 현재 세션을 얻거나 새로운 세션을 만든다. 어느 쪽이든, 이 세션은 아직 연결되지 않는다. 즉, pool 의 어떠한 커넥션도 사용하지 않는다.
  2. user 를 찾기 위해 쿼리를 실행하면, 세션은 연결되고 pool 에서 커넥션을 빌린다.
  3. 만약 전체 메서드가 trasactional 이라면, 빌린 커넥션을 유지한 채 느린 원격 서비스를 호출한다.

이 기간동안, fetchOne 메서드 호출이 폭주한다고 상상해보자. 그런 다음, 잠시 후 모든 커넥션이 해당 api 호출을 기다릴 수 있다. 따라서 곧 데이터베이스 커넥션이 고갈될 수 있다.

transactional context 안에서 database IO 를 다른 타입의 IO 와 함께 사용하는 것은 bad smell 이고 어떤 대가를 치뤄서라도 피해야 한다.

어쨌든, 서비스에서 @Transactional 어노테이션을 지웠기 때문에 안전할 것이라고 예상된다.

4.2 Exhausting the Connection Pool

OSIV 가 활성화 중일 때, @Transactional 을 지우더라도 항상 현재 request 범위에 세션이 존재한다. 이 세션은 초기에 연결되지는 않더라도, 첫 database io 이후, 세션은 연결되고 request 가 끝날 때까지 유지된다. 그래서, 우리의 무해해 보이고 최적화 되어 보이는 서비스 구현은 OSIV 가 존재할 경우 재앙의 레시피가 될 수. 있다.

@Override
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    if (user.isPresent()) {
        // remote call
    }

    return user;
}

OSIV 가 활성화 되어 있는 동안 발생하는 일은 다음과 같다.

  1. 요청이 시작될 때, 해당 필터는 새로운 세션을 생성한다.
  2. findByUsername 메서드를 부를 때, 그 세션은 풀에서 커넥션을 빌린다.
  3. 그 세션은 request 가 끝날 때까지 유지된다.

우리의 서비스 코드가 커넥션 풀을 고갈시키지 않는다고 예상할지라도, OSIV 의 단순한 존재만으로 전체 Application 이 응답하지 않을 수 있다.

더 심각한 문제는 문제의 원인(느린 원격 서비스)과 증상(데이터베이스 커 넥션풀 )관련이 없다는 것이다. 이러한 작은 상관 관계 때문에, 이러한 성능 문제를 production 환경에서 진단하기가 어렵다.

4.3 Unnecessary Queries

안타깝게도, 연결 풀을 고갈시키는 것은 OSIV 와 관련된 유일한 성능 문제가 아니다.

세션이 request lifecycle 전체에 열려 있기 때문에, 일부 속성 탐색은 transaction context 외부에서 몇 가지 불필요한 쿼리를 추가로 유발할 수 있다. 이로 인해 n+1 select 문제가 발생할 수도 있으며, 최악의 경우 이를 production 에서까지 인지하지 못할 수 있습니다.

설상가상으로 세션이 auto-commit 모드에서 모든 추가 쿼리를 실행한다는 것이다. auto-commit 모드에서는 각 SQL 문이 트랜잭션으로 간주되어 실행 즉시 자동으로 commit 된다. 이로 인해 데이터베이스에 많은 압력이 가해진다.

5. Choose Wisely

OSIV 가 패턴인지 아니면 안티 패턴인지는 중요하지 않다. 여기서 가장 중요한 것은 우리가 살아가는 현실이다.

만약 우리가 단순한 CRUD 서비스를 개발 중이라면, OSIV 를 사용하는 것이 합리적일 수 있다. 왜냐하면 우리는 이러한 성능 문제를 전혀 마주치지 않을 수도 있기 때문이다.

반면에, 만약 우리가 많은 원격 서비스를 호출하거나 우리의 transaction context 외부에서 많은 작업이 이루어지고 있다면, OSIV 를 완전히 비활성화하는 것이 매우 권장된다.

의심스러울 때는 OSIV 없이 시작하는 편이 좋다. 나중에 필요하면 쉽게 활성화할 수 있기 때문이다. 반면에 이미 활성화된 OSIV 를 비활성화하는 것은 LazyInitializationExceptions 를 처리해야 할 수 있기 때문에 번거로울 수 있다.

중요한 점은 OSIV 를 사용하거나 무시할 때 발생하는 trade-off 에 대해 인식해야 한다는 것이다.

(trade-off : '[이율배반](http://terms.naver.com/ncrEntry.nhn?dicId=common_sense&ncrDocId=ba2_19-1-100)'의 의미를 가지는 용어로, 임금이나 물가의 안정과 [완전고용](http://terms.naver.com/ncrEntry.nhn?dicId=common_sense&ncrDocId=ba2_8-3-101)을 동시에 실현시키는 것이 힘들며 양자가 서로 상충하는 관계에 있음을 설명한다. https://terms.naver.com/entry.naver?docId=929839&cid=43667&categoryId=43667)

6. Alternatives

만약 OSIV 를 비활성화한다면, lazy associations을 다룰 때 잠재적인 LazyInitializationExceptions을 방지할 방법을 찾아야 한다. lazy associations을 처리하는 몇 가지 접근 방법 중에서, 여기서 두 가지를 나열해보겠다.

6.1 Entity Graph

Spring Data JPA 에서 쿼리 메서드를 정의할 때, 엔티티의 일부를 즉시 가져오기 위해 쿼리 메서드에 @EntityGraph 어노테이션을 사용할 수 있다.

public interface UserRepository extends JpaRepository<User, Long> {

    @EntityGraph(attributePaths = "permissions")
    Optional<User> findByUsername(String username);
}

여기서 우리는 기본적으로 lazy collection 인 permissions 속성을 즉시 로드하기 위해 ad-hoc entity graph 를 정의하고 있다.

동일한 쿼리에서 여러 프로젝션을 반환해야 하는 경우, 다른 엔티티 그래프 구성을 가진 여러 쿼리를 정의해야 한다.

public interface UserRepository extends JpaRepository<User, Long> {
    @EntityGraph(attributePaths = "permissions")
    Optional<User> findDetailedByUsername(String username);

    Optional<User> findSummaryByUsername(String username);
}

6.2 Caveats When Using Hibernate.initialize()

어떤 사람들은 엔티티 그래프 대신에 필요한 곳마다 악명 높은 Hibernate.initialize()를 사용하여 lazy associations 를 가져올 수 있다고 주장할 수 있다.

@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    user.ifPresent(u -> Hibernate.initialize(u.getPermissions()));

    return user;
}

그들은 이를 영리하게 활용하며 getPermissions() 메서드를 호출하여 가져오는 프로세스를 트리거할 것을 제안할 수도 있다.

Optional<User> user = userRepository.findByUsername(username);
user.ifPresent(u -> {
    Set<String> permissions = u.getPermissions();
    System.out.println("Permissions loaded: " + permissions.size());
});

두 가지 방법 모두 권장되지 않는데, 원래의 쿼리 외에도 (최소한) 하나의 추가 쿼리를 발생시키기 때문이다. 즉, Hibernate 는 user 와 그들의 권한을 가져오기 위해 다음과 같은 쿼리를 생성한다.

> select u.id, u.username from users u where u.username=?
> select p.user_id, p.permissions from user_permissions p where p.user_id=?

대부분의 데이터베이스는 두 번째 쿼리를 실행하는 데에 상당히 능숙하지만, 우리는 그 추가 네트워크 왕복을 피해야 한다.

반면에, 엔티티 그래프나 심지어 Fetch Joins 를 사용한다면, Hibernate 는 단 하나의 쿼리로 모든 필요한 데이터를 가져올 것입니다.

> select u.id, u.username, p.user_id, p.permissions from users u 
  left outer join user_permissions p on u.id=p.user_id where u.username=?

7. Conclusion