문제

 

RabbitMQ를 어떤 용도에 따라 사용하느냐에 따라 발생하는 문제 및 해결 방법이 달라지겠지만,

이번에는 Consumer에서 데이터 파싱 로직 처리 이후 DB에 insert 또는 update, delete 하는 경우에 대한 문제다.

여기서 문제는 3가지가 발생했다.

 

  1. 테스트 메서드가 RabbitMQ보다 빨리 끝나 처리된 데이터 검증이 제대로 진행되지 않는 문제
  2. Consumer가 테스트 코드에서 저장해둔 데이터에 접근하지 못하는 문제
  3. RabbitMQ를 통해 insert 된 데이터가 롤백되지 않는 문제

 

예시 및 문제 원인, 문제별 해결 방안

 

발생 환경에 대한 가정을 먼저 세운다.

  1. 테스트는 서비스에 대한 통합 테스트라고 가정.
  2. RabbitMQ에서는 저장 또는 수정, 삭제할 데이터의 파싱을 처리하고 DB로 요청을 보내 데이터 처리를 수행.
    내부 비즈니스 로직은 메시지를 통해 받은 데이터의 파싱 정도이기 때문에 중요도가 높지 않아 Consumer 코드는 파싱코드라고 가정하고 생략.
  3. 처리되는 테이블로는 SalesSummary에 저장, Product에 수정, Cart에 삭제가 처리된다고 가정하며 3개의 consumer가 호출되는 환경. Product는 stock이라는 재고를 수정, Cart는 무조건 해당 사용자의 장바구니 삭제를 처리.
  4. 테스트에서 호출되는 서비스 메서드는 Order 테이블에 주문 데이터를 저장한 뒤 3개의 메시지를 발행해 위 처리를 수행하는 서비스라고 가정. Order 테이블의 경우 동기적으로 처리
  5. 테스트 메서드에서는 서비스 메서드 호출 이후 데이터가 정상적으로 저장, 수정, 삭제 되었는지 검증을 해야 한다고 가정.

 

각 엔티티 코드는 중요도가 높지 않기 때문에 생략하고 서비스와 테스트 코드만 예시로 들어 정리.

 

 

서비스메서드 및 테스트 클래스

@Service
@RequiredArgsConstructor
public class TestService {
    
    private final OrderRepository orderRepository;
    
    private final RabbitTemplate rabbitTemplate;
    
    @Transactional
    public void payment(PaymentDTO paymentDTO) {
        //최초 데이터 파싱
        OrderDataDTO orderDataDTO = createOrderDataDTO(paymentDTO);
        Order order = orderDataDTO.getOrder();
        
        orderRepository.save(order);
        
        //RabbitMQ 메시지 발행 메서드
        //3개의 메시지를 발행해 Message Broker에게 보내는 메서드라고 가정.
        //이 메서드를 통해 메시지를 받은 각 Consumer에서는 데이터 파싱 이후 각 처리를 진행.
        sendOrderQueueMessage(paymentDTO, orderDataDTO);
    }
}



@SpringBootTest(classes = TestApplication.class)
@ActiveProfiles("test")
@Transactional
public class TestServiceIntegrationTest {
    
    @Autowired
    private TestService testService;
    
    @Autowired
    private SalesSummaryRepository salesSummaryRepository;
    
    @Autowird
    private OrderRepository orderRepository;
    
    @Autowird
    private ProductRepository productRepository;
    
    @Autowired
    private CartRepository cartRepository;
    
    //...
    
    //paymentDTO에 담아 보낼 상품 엔티티
    private Product product;
    
    //주문 요청하는 사용자 엔티티
    private Member member;
    
    @BeforeEach
    void init() {
        //테스트에 필요한 데이터 생성 및 저장
        //여기에서 생성된 데이터로 product, member 할당
    }
    
    @Test
    void rabbitMQTest() {
        PaymentDTO paymentDTO = OrderFixture.createPaymentDTO(product);
        int productStockFixture = product.getStock() - paymentDTO.getOrderCount();
        
        assertDoesNotThrow(() -> testService.payemnt(paymentDTO));
        
        //저장된 order 데이터 조회
        List<Order> saveOrderList = orderRepository.findAll();
        assertFalse(saveOrderList.isEmpty());
        assertEquals(1, saveOrderList.size());
        
        //저장된 order 데이터 검증
        //...
        
        //저장된 SalesSummary 조회
        List<SalesSummary> saveSummaryList = salesSummaryRepository.findAll();
        assertFalse(saveSummaryList.isEmpty());
        
        //저장된 SalesSummary 검증
        //...
        
        //수정된 Product 재고 검증
        Product patchProduct = productRepository.findById(productId).orElse(null);
        assertNotNull(patchProduct);
        assertEquals(productStockFixture, patchProduct.getStock());
        
        //Cart 삭제 검증
        Cart deleteCart = cartRepository.findCartByUserId(member.getUserId());
        assertNull(deleteCart);
    }
}

 

 

문제 원인 1) 테스트 메서드가 RabbitMQ보다 빨리 끝나 처리된 데이터 검증이 제대로 진행되지 않는 문제

 

위와 같이 코드를 작성하고 테스트를 수행하게 되면 order 데이터 검증까지는 성공하지만, SalesSummary 검증부터는 실패하게 된다.

RabbitMQ는 비동기로 동작하기 때문에 테스트가 끝날때까지 consumer의 처리와 DB 요청이 미처 마무리되지 못할 수 있다.

서비스메서드에서는 메시지를 발행하고 바로 종료되기 때문에 테스트 코드에서는 바로 검증을 시작하려고 시도하게 되기 때문이다.

 

consumer의 로직이 굉장히 단순하고 빠르게 처리된다고 하더라도 RabbitMQ와의 연결에서 지연시간이 발생하게 되면 오류가 발생할 수 있는 여지가 있기 때문에 테스트의 일관성을 위해서는 RabbitMQ로의 요청이 완료된 것을 기다렸다가 검증해야 한다.

 

 

해결 방안

 

가장 먼저 떠오를 수 있는 방법은 Thread.sleep()이 있다.

이걸 테스트 메서드에서 처리한다면 예상되는 RabbitMQ 처리시간을 대기했다가 검증하는 방법으로 문제를 해결할 수 있다.

하지만, Thread.sleep()은 아무래도 유연성과 효율성에서 단점이 발생하게 된다.

테스트 과정에서 평균 5초 이내에 처리가 되는것을 확인하고 Thread.sleep(5000) 을 걸어뒀다고 가정해보자.
그럼 여기서 2가지의 문제가 발생할 여지가 생기게 된다.

만약 평균 1초로 개선이 되었다면? 불필요하게 5초까지 꼭 대기해야 하는 비효율이 발생한다.

또 다른 문제로 연결이 지연되어 10초가 걸리는 상황이 발생했다면? 5초만 대기했기 때문에 검증에 다시 실패하게 된다.

 

이러한 문제들로 인해 Thread.sleep()을 사용하는 것은 하나의 방법이 될 수 있지만 너무 정적인 방법이라고 볼 수 있다.

이러한 단점을 상쇄시킬 수 있는 다른 방법은 await().untilAsserted()다.

의존성을 추가해야 사용할 수 있지만, 긴 대기시간을 주더라도 주기적으로 확인해 테스트가 통과되는 즉시 완료되기 때문에 Thread.sleep에서 발생할 수 있는 문제를 해결할 수 있다.

 

의존성은 아래와 같이 추가해주면 된다.

testImplementation("org.awaitility:awaitility:4.2.0")

 

그리고 테스트 클래스는 아래와 같이 수정하게 된다.

import static org.awaitility.Awaitility.await;

@SpringBootTest(classes = TestApplication.class)
@ActiveProfiles("test")
@Transactional
public class TestServiceIntegrationTest {
    
    @Autowired
    private TestService testService;
    
    @Autowired
    private SalesSummaryRepository salesSummaryRepository;
    
    @Autowird
    private OrderRepository orderRepository;
    
    @Autowird
    private ProductRepository productRepository;
    
    @Autowired
    private CartRepository cartRepository;
    
    //...
    
    //paymentDTO에 담아 보낼 상품 엔티티
    private Product product;
    
    //주문 요청하는 사용자 엔티티
    private Member member;
    
    @BeforeEach
    void init() {
        //테스트에 필요한 데이터 생성 및 저장
        //여기에서 생성된 데이터로 product, member 할당
    }
    
    @Test
    void rabbitMQTest() {
        PaymentDTO paymentDTO = OrderFixture.createPaymentDTO(product);
        int productStockFixture = product.getStock() - paymentDTO.getOrderCount();
        
        assertDoesNotThrow(() -> testService.payemnt(paymentDTO));
        
        //저장된 order 데이터 조회
        List<Order> saveOrderList = orderRepository.findAll();
        assertFalse(saveOrderList.isEmpty());
        assertEquals(1, saveOrderList.size());
        
        //저장된 order 데이터 검증
        //...
        
        await()
            .atMost(10, TimeUnit.SECONDS) // 총 대기시간 ( 10초 )
            .pollInterval(200, TimeUnit.MILLISECONDS) // 재시도 수행간격 ( 200ms )
            .untilAsserted(() -> {
                //저장된 SalesSummary 조회
                List<SalesSummary> saveSummaryList = salesSummaryRepository.findAll();
                assertFalse(saveSummaryList.isEmpty());
        
                //저장된 SalesSummary 검증
                //...
        
                //수정된 Product 재고 검증
                Product patchProduct = productRepository.findById(productId).orElse(null);
                assertNotNull(patchProduct);
                assertEquals(productStockFixture, patchProduct.getStock());
        
                //Cart 삭제 검증
                Cart deleteCart = cartRepository.findCartByUserId(member.getUserId());
                assertNull(deleteCart);
            })
    }
}

 

위와 같이 작성하게 되면 최대 10초까지 대기하게 되며, 200ms마다 재시도를 수행하게 된다.

그리고 그 과정에서 untilAsserted 내부의 검증 로직이 모두 통과되는 순간 즉시 종료되게 된다.

 

그럼 인프라 상황이 좋아서 1초만에 마무리 되더라도 정적인 설정 시간을 대기할 필요 없이 1초로 마무리될 수 있고,

연결 이슈로 인해 지연시간이 발생하더라도 atMost에 설정해둔 시간 내에만 처리된다면 정상적으로 처리할 수 있기 때문에 문제가 없다.

그런 경우는 잘 없겠지만 정말 극단적으로 30초의 총 대기시간을 주고 처리하게 되면 왠만한 이슈에는 대응할 수 있으며, '테스트가 통과되는 시점' 이라는 조건에 의해 불필요한 예비 시간까지 대기하도록 할 필요가 없어진다.

 

 

그럼 여기까지 했을 때 1번 문제는 해결됐다.

 

 

문제 원인 2) Consumer가 테스트 코드에서 저장해둔 데이터에 접근하지 못하는 문제

 

하지만 그럼에도 위 테스트는 통과되지 않을 것이다.

예시 가정 상, SalesSummary는 독립적으로 다른 데이터를 참조하지 않는 테이블이기 때문에 SalesSummary 검증은 통과하게 될 것이고 insert 역시 잘 수행될 것이다.

하지만 기존 데이터가 필요한 Product, Cart는 절대 통과되지 않는다.

 

이유는 테스트 Transaction을 RabbitMQ가 공유하지 않기 때문이다.

테스트 클래스에는 @Transactional이 정의되어 있다.

각 테스트 메서드가 종료되었을 때마다 데이터가 롤백되기 때문에 독립성을 보장할 수 있다.

특히 BeforeEach와 함께 사용할 경우 매 테스트 메서드마다 일관된 상태로 테스트를 수행하기 때문에 유리하다.

 

하지만 RabbitMQ가 이 트랜잭션을 공유해야만 Product, Cart에 대한 조회가 정상적으로 이루어지고 처리할 수 있게 되는데, RabbitMQ는 Application 외부에서 비동기적으로 동작하는 Message Broker이므로 트랜잭션을 공유할 수 없다.

메시지 발행 시점까지는 같은 트랜잭션에 위치하지만, 메시지가 RabbitMQ에 전달되는 순간 외부로 이동하는 것이기 때문에 트랜잭션에서 벗어나게 되고 Consumer에 메시지를 전달하더라도 다시 트랜잭션에 들어가지 못하므로 별개의 트랜잭션에서 동작하게 된다.

즉, 서버 -> 외부(Message Broker(RabbitMQ)) -> 서버 (Consumer) 순서로 처리되기 때문에 외부를 거쳐 호출된 consumer는 다른 트랜잭션을 가질 수 밖에 없다.

 

@Transactional을 테스트 클래스에서 정의하는 경우 save()한 데이터가 commit 되지 않기 때문에 consumer에서는 데이터에 접근할 수 없게 된다.

좀 더 명확하게 정리해보자면 테스트 환경과 consumer의 트랜잭션이 분리되었기 때문에 Transaction Isolation에 의해 consumer가 아직 commit 되지 않은 데이터에 접근할 수 없는 것이다.

이 격리 수준을 READ_UNCOMMITED로 수정하는 방법도 있지만, 일반적으로 다른 트랜잭션의 접근을 허용하는 것은 데이터 정합성 문제를 발생시킬 수 있기 때문에 특수한 상황이 아니라면 좋은 방법은 아니다.

특히나, 테스트만을 위해 격리 수준을 수정하는 건 더욱 더.

 

그럼 결과적으로 원인은 다른 트랜잭션을 사용하는 것이 원인이므로 테스트 환경의 트랜잭션을 제거하면 된다.

 

 

해결 방안

 

정말 단순하게 테스트 클래스 단위에 정의되어 있는 @Transactional을 제거해주면 된다.

단, 이 경우 @BeforeEach에서 저장했던 데이터를 제거해주는 처리가 필요하다.

이 처리를 @AfterEach를 통해 삭제하도록 해주면 @Transactional과 동일한 효과를 볼 수 있다.

물론, 애초에 데이터가 아예 없는 데이터베이스를 활용하는 경우라는 전제하에.

데이터가 이미 존재하는 테이블을 대상으로 테스트 한다면 좀 더 신경써야 하는 부분이 많아진다.

 

import static org.awaitility.Awaitility.await;

@SpringBootTest(classes = TestApplication.class)
@ActiveProfiles("test")
@Transactional
public class TestServiceIntegrationTest {
    
    @Autowired
    private TestService testService;
    
    @Autowired
    private SalesSummaryRepository salesSummaryRepository;
    
    @Autowird
    private OrderRepository orderRepository;
    
    @Autowird
    private ProductRepository productRepository;
    
    @Autowired
    private CartRepository cartRepository;
    
    //...
    
    //paymentDTO에 담아 보낼 상품 엔티티
    private Product product;
    
    //주문 요청하는 사용자 엔티티
    private Member member;
    
    @BeforeEach
    void init() {
        //테스트에 필요한 데이터 생성 및 저장
        //여기에서 생성된 데이터로 product, member 할당
    }
    
    @AfterEach
    void cleanUp() {
        //Product, member 데이터 제거
    }
    
    @Test
    void rabbitMQTest() {
        PaymentDTO paymentDTO = OrderFixture.createPaymentDTO(product);
        int productStockFixture = product.getStock() - paymentDTO.getOrderCount();
        
        assertDoesNotThrow(() -> testService.payemnt(paymentDTO));
        
        //저장된 order 데이터 조회
        List<Order> saveOrderList = orderRepository.findAll();
        assertFalse(saveOrderList.isEmpty());
        assertEquals(1, saveOrderList.size());
        
        //저장된 order 데이터 검증
        //...
        
        await()
            .atMost(10, TimeUnit.SECONDS) // 총 대기시간 ( 10초 )
            .pollInterval(200, TimeUnit.MILLISECONDS) // 재시도 수행간격 ( 200ms )
            .untilAsserted(() -> {
                //저장된 SalesSummary 조회
                List<SalesSummary> saveSummaryList = salesSummaryRepository.findAll();
                assertFalse(saveSummaryList.isEmpty());
        
                //저장된 SalesSummary 검증
                //...
        
                //수정된 Product 재고 검증
                Product patchProduct = productRepository.findById(productId).orElse(null);
                assertNotNull(patchProduct);
                assertEquals(productStockFixture, patchProduct.getStock());
        
                //Cart 삭제 검증
                Cart deleteCart = cartRepository.findCartByUserId(member.getUserId());
                assertNull(deleteCart);
            })
    }
}

 

 

문제 원인 3) RabbitMQ를 통해 insert 된 데이터가 롤백되지 않는 문제

 

이건 '롤백'되지 않는 다는 말이 조금 애매하긴 하다.

애초에 테스트 클래스에서 @Transactional을 사용하지 않는다면 @AfterEach에서 애초에 제거하도록 해야 하기 때문이다.

insert 된 데이터라고 한다면 예시에서 SalesSummary 데이터가 있다.

이건 BeforeEach에서 저장하는 데이터가 아니기 때문에 애초에 AfterEach로 제거해야할 필요가 있다.

 

이 문제를 따로 정리하는 이유는 2번 문제 초반쪽에서 정리했다시피 consumer에서 다른 테이블 데이터를 조회한다거나 하는 참조가 필요없고 테이블 자체도 외래키가 없는 독립적인 테이블이라면, 데이터가 정상적으로 저장되기 때문이다.

즉, @Transactional이 테스트 클래스에 정의되어 있더라도 저장이 되는 케이스라는 것이다.

 

이런 경우 롤백이 되지 않고 데이터가 남아있기 때문에 따로 제거해줄 필요가 있다.

데이터가 남아있는건 2번까지 이해했다면 당연한 문제다.

간결하게 테스트 트랜잭션을 Transaction1이라고 하고, consumer의 트랜잭션을 Transaction2라고 해보자.

그럼 Transaction1은 자기가 담당한 데이터만 롤백한다.

하지만 SalesSummary는 Transaction2가 처리했고, 이 트랜잭션은 롤백을 수행해야 한다는걸 알지 못하기 때문에 롤백을 처리하지 않게 된다.

즉, 테스트 트랜잭션이 자기가 알고있는것만 롤백할 수 있기 때문에 consumer가 insert 한 데이터는 제거할 수 없다는 말이 된다.

 

 

해결방안

 

결국 이 문제의 해결은 매우 간단하다.

해당 테스트 메서드에서 검증이 끝난 이후 consumer가 insert 한 테이블의 데이터를 제거해주거나,

@AfterEach에서 해당 데이터의 제거까지 처리하도록 작성해주면 된다.

이건 간단하기 때문에 해결 코드는 생략.

 

문제

 

통합 테스트에서 양방향 매핑 상태인 엔티티가 존재할 때, 하위 엔티티에 대한 Lazy Loading이 정상적으로 조회하지 못하는 문제가 발생.

두 엔티티를 각자의 Repository로 조회하는 경우에는 문제가 되지 않지만, 상위 엔티티 조회만으로 Lazy Loading을 활용하고자 하는 경우에는 필드값이 null로 처리된다는 문제가 발생한다.

 

 

예시와 문제 발생 이유

 

엔티티 구조

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "member")
@Getter
public class Member {
    
    @Id
    private String userId;
    
    private String userPw;
    
    private String userName;
    
    @OneToMany(mappedBy = "member")
    private final List<Auth> auths = new ArrayList<>();
    
    public void addMemberAuth(Auth auth) {
        auths.add(auth);
        auth.setMember(this);
    }
}



@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "auth")
@Getter
public class Auth {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    private String auth;
    
    public void setMember(Member member) {
        this.member = member;
    }
    
    @Override
    public String toString() {
        return "Auth{" +
               "id=" + id +
               ", auth='" + auth + '\'' +
               '}';
    }
}

 

두 엔티티는 이렇게 양방향 매핑이며 1 : N 관계를 갖고 있다.

 

 

서비스 메서드 및 테스트 클래스

@Service
@RequiredArgsConstructor
public class TestService {
    
    private final MemberRepository memberRepository;
    
    public void testServiceMethod(String userId) {
        //JPA Query Method가 아닌 직접 구현한 RepositoryMethod라고 가정.
        //fetchJoin 여부는 문제 해결에 도움이 되지 않으므로 단순 조회 및 fetchJoin 모두 상관 x
        Member member = memberRepository.findByUserId(userId);
        
        List<Auth> auths = member.getAuths();
        System.out.println("auths : " + auths); // [Auth{id=null, auth='null'}]
        
        Auth auth = auths.get(0);
        System.out.println("auth : " + auth); // Auth{id=null, auth='null'}
    }
}

@SpringBootTest(classes = TestApplication.class)
@ActiveProfiles("test")
@Transactional
public class TestServiceIntegrationTest {
	
    @Autowired
    private MemberRepository memberRepository;
	
    @Autowired
    private AuthRepository authRepository;
	
    @Autowired
    private TestService testService;
	
    private Member member;
	
    @BeforeEach
    void init() {
        //MemberFixtureDTO의 필드로 Member member 와 Auth auth가 있다고 가정.
        MemberFixtureDTO memberFixture = MemberFixture.createMember();
        member = memberFixture.getMember();
		
        memberRepository.save(member);
        authRepository.save(memberFixture.getAuth());
    }
	
    @Test
    void lazyLoadingTest() {
        String userId = member.getUserId();
		
        testService.testServiceMethod(userId);
    }
}

 

 

위 테스트 코드 실행 결과는 서비스 메서드의 주석과 같다.

처음 확인했을 때 특이하다고 생각했는데 emptyList가 아닌 모든 필드가 null이라는 점이었다.

또한, 만약 3개의 auth를 저장했다면 3개의 Auth가 나오지만 동일하게 모든 필드가 null로 나오게 된다.

이 문제는 서비스 메서드의 주석에서 적었듯이 fetchJoin()을 하더라도 동일한 결과를 보인다.

 

이렇게 처리되는 이유는 Lazy Loading 과정이 어떻게 처리되는가와 테스트 환경에서의 데이터 처리가 어떻게 되느냐에 있다.

우선 LazyLoading의 경우 말 그대로 '지연 로딩'이다.

fetchJoin()이 아니고서는 Member를 조회했을 때 모든 Auth 객체를 같이 조회한 상태가 아니라 프록시 상태로 대기하고 있다가 접근이 발생하는 순간 프록시 객체를 초기화되며 동작하고, Auth를 조회하는 쿼리를 DB에 날리게 된다.

 

테스트 환경에서는 @BeforeEach를 통해 매 테스트 메서드 실행 전 데이터를 저장하게 되는데, 이때 테스트 클래스 단위로 정의된 @Transactional에 의해 insert 쿼리가 DB로 날아가지 않고, 영속성 컨텍스트 내부의 1차 캐시에만 존재하게 된다.

즉, 테스트 환경에서 insert 쿼리가 발생하지 않고 영속성 컨텍스트 1차 캐시에만 데이터가 존재하기 때문에, 

Member는 1차 캐시에서 정상적으로 가져오지만, Auth에 대해서까지는 1차 캐시의 Member에 같이 담겨있지 않으므로 
JPA의 프록시 객체가 동작해 Auth를 조회하려고 DB에 쿼리를 날리게 된다.

하지만 DB에는 데이터가 없기 때문에 필드들을 제대로 가져올 수 없는 것이다.

 

여기까지 다시 정리해보면,

  1. BeforeEach에서 저장한 데이터는 @Transactional에 의해 DB에 실제 저장된 것이 아닌 영속성 컨텍스트의 1차 캐시에만 저장된 상태다.
  2. 서비스 메서드에서 Member를 조회하는 경우 영속성 컨텍스트에 해당 데이터가 존재하기 때문에 DB를 통해 조회하는 것이 아니라 캐시에서 바로 가져오게 된다.
  3. Auth에 접근 시 JPA의 프록시 객체가 동작해 1차 캐시가 아닌 DB에 바로 조회 쿼리를 날린다.
  4. 하지만 DB에 실제로 저장되지 않았기 때문에 제대로된 Auth 객체를 가져오지 못한다.

 

그래서 이런 생각을 했다.

'그럼 List<Auth>의 크기를 알고 있기도 한데 왜 필드값만 null인걸까? 애초에 emptyList 혹은 Auth 자체가 null로 나왔어야 하는거 아닐까??'

 

이건 Hibernate가 내부적으로 하위 엔티티 인스턴스를 new 키워드를 통해 넣는것이 아니라 bytecode instrumentation을 통해 프록시 객체를 생성한 뒤 프록시 객체를 넣기 때문이다.

마치 Lazy Loading을 위해 대기하는 것 처럼 하위 엔티티는 '프록시 객체'가 존재할 뿐, 필드가 초기화된 상태가 아니므로 모든 필드가 null이라는 값을 갖게 되는 것이다.

 

그럼 리스트의 크기는 어떻게 알 수 있는지가 궁금해진다.

이것도 Hibernate의 내부 컬렉션 매핑 구조와 1차 캐시 구조 때문이다.

 

Member 엔티티 내부에서 Auth를 담는 addMemberAuth()와 같은 코드가 @BeforeEach(Fixture 내부에서 생성 시 수행)에서 수행되었을 때, JPA는 Member 내부에 3의 크기를 갖는 List<Auth>가 존재한다는 정보를 1차 캐시에서 유지하고 있게 되는 것이다.

그래서 바로 직전에 말한 것 처럼 각 Auth들에 대해 프록시 객체를 생성하게 되고 3개의 프록시 객체를 Collection에 넣어두기 때문에 1차 캐시에서 조회하더라도 해당 리스트의 크기는 알 수 있지만, 필드가 초기화 되지 않은 상태이므로 각 필드값들이 null로 나오게 된다.

 

그리고 BeforeEach에서 저장할 때 Transactional에 의해 DB에 실제 저장된 것이 아니라 1차 캐시에 저장된다고 했다.

그럼 @Transactional이 없다면 잘 될까??

그것도 아니라고 한다. @Transactional이 없는 경우 save() 시 flush()가 발생할 '수도' 있지만 그렇지 않을수도 있기 때문이다.

즉, 테스트의 일관성이 사라져 언제는 되고, 언제는 안되는 문제가 발생할 수 있다.

 

 

해결방안을 정리하기 전 원인에 대해 다시 정리.

  1. @Transactional이 정의된 통합 테스트에서 데이터를 저장하는 경우 DB에 실제 저장되는 것이 아니라 영속성 컨텍스트의 1차 캐시에만 저장된다.
  2. 이때, 하위 엔티티인 Auth의 경우 객체 인스턴스 자체가 생성되며 저장되는 것이 아닌 bytecode instrumentation을 통해 프록시 객체로 생성된 뒤 이 프록시 객체가 담기게 된다. 이때, 프록시 객체가 생성될 뿐, 필드가 초기화되는 것은 아니기 때문에 모든 필드의 값은 null이 된다.
  3. Auth가 여러개인 경우 그 크기만큼 프록시 객체가 생성되어 담기게 된다.
  4. 서비스 메서드에서 Member를 조회할때는 1차 캐시에 데이터가 있기 때문에 DB로 요청을 보내는 것이 아닌 1차 캐시에서 데이터를 가져오게 된다.
  5. Auth에 접근하는 순간 JPA는 프록시 객체를 초기화시켜 DB로 요청을 보내게 된다. 하지만 1차 캐시에만 데이터가 존재하므로 데이터를 정상적으로 조회하지 못하고, 1차 캐시에 담긴 프록시 객체 자체를 참조해 모든 필드가 null 상태인 그대로 가져오게 된다.

 

문제 해결 방안

 

문제 해결은 간단하다.

데이터 저장 이후 flush(), clear()를 통해 강제로 DB에 반영되도록 해주기만 하면 된다.

 

@SpringBootTest(classes = TestApplication.class)
@ActiveProfiles("test")
@Transactional
public class TestServiceIntegrationTest {
	
    @Autowired
    private MemberRepository memberRepository;
	
    @Autowired
    private AuthRepository authRepository;
	
    @Autowired
    private TestService testService;
    
    @Autowired
    private EntityManager em;
	
    private Member member;
	
    @BeforeEach
    void init() {
        //MemberFixtureDTO의 필드로 Member member 와 Auth auth가 있다고 가정.
        MemberFixtureDTO memberFixture = MemberFixture.createMember();
        member = memberFixture.getMember();
		
        memberRepository.save(member);
        authRepository.save(memberFixture.getAuth());
        
        em.flush();
        em.clear();
    }
	
    @Test
    void lazyLoadingTest() {
        String userId = member.getUserId();
		
        testService.testServiceMethod(userId);
    }
}

 

문제 원인의 마지막 부분에서 '@Transactional이 없더라도 flush()가 발생할수도, 그렇지 않을 수도 있다.' 라고 했다.

그 말은 즉, flush()가 발생하면 문제가 해결된다는 의미이다.

 

그래서 정말 간단하게 entityManager.flush()와 entityManager.clear()를 강제로 발생시켜 DB에 반영 되도록 해주기만 한다면 JPA가 프록시 객체를 초기화시켜 DB에 조회 요청을 보내더라도 정상적인 조회가 가능해지게 된다.

프로젝트 리펙토링을 진행하면서 발생한 문제.

다중 insert를 처리해야 하는데 서버에서 반복문을 통해 호출하도록 하지 않고 처리하고자 했으나 Sequence가 정상동작하지 않아 발생한 문제다.

 

프로젝트 환경

Spring MVC

MyBatis

Oracle 18c

 

발생한 문제

기존에 진행했던 프로젝트를 Spring Boot 환경으로 재구성한 프로젝트와 동일하게 리펙토링 하기 위해 테이블 구조를 해당 MySQL 환경의 구조와 동일한 설계의 데이터베이스 환경으로 수정.

수정하게 되면서 많은 테이블의 Primary Key가 NUMBER 타입으로 수정되었고 IDENTITY 설정을 통해 Sequence를 통한 처리를 하도록 수정했다.

그러나 다중 insert 처리 시 SQLIntegrityConstraintViolationException: ORA-00001: unique constraint ... 오류가 발생.

Unique가 보장되어야 하는 Primary key가 중복되는 요청으로 인해 오류가 발생했다.

 

 

원인 파악

여러 테스트를 해보고 검색도 해보니 Oracle에서 다중 insert 즉, INSERT ALL 처리 시 Sequence는 매번 호출되는 것이 아니라 최초 호출된 값을 재사용한다.

INSERT ALL
INTO testTable (title, content, userId)
VALUES(title, content, userId)
INTO testTable (title, content, userId)
VALUES(title2, content2, userId2)
SELECT * FROM DUAL;

이러한 쿼리를 실행하면서 IDENTITY가 설정된 id의 값이 1, 2 로 처리되어야 하지만 최초 호출된 1이 계속해서 재사용되기 때문에 1, 1로 처리되는 것이 문제였다.

이건 Oracle의 메커니즘이 그렇기 때문에 어떻게 할 수 있는 방법이 없었다.

 

 

문제 해결 방법

Sequence를 계속해서 호출하도록 수정할 수 없었기 때문에 다른 문제 해결 방법을 찾아야 했다.

그래서 찾은 방법은 3가지가 있었다.

  1. 서버에서 반복문을 통해 mapper를 여러번 호출한다.
  2. xml에서 PL/SQL을 직접 작성해 처리한다.
  3. 데이터베이스에 Procedure를 정의하고 xml에서 Procedure를 호출하도록 해 처리한다.

여기서 1번 방법은 동적 처리가 아닌 매번 호출하는 방법이다.

적은 반복 횟수가 보장된다면 괜찮은 방법이지만 반복 횟수를 보장할 수 없다면 큰 문제가 발생할 수 있다.

가장 큰 단점으로는 반복문을 통해 매번 mapper를 호출하기 때문에 매 요청마다 Connection 요청으로 인해 Connection Overhead가 증가하게 된다. 그렇기에 횟수에 제한을 두지 않은 경우 선택하기에는 어려운 방법이다.

 

2번의 PL/SQL을 직접 작성해서 처리하는 방식의 경우 Java 코드와 더 가까운 형태로 작성할 수 있기 때문에 Java 코드의 흐름을 유지하면서 데이터베이스 작업을 처리하는데 유리하다.

또한, 배포 역시 코드가 애플리케이션에 포함되어있어 데이터베이스의 업데이트를 따로 수행하지 않아도 된다는 장점이 있다.

그러나 데이터베이스에 대한 각 호출은 Network Overhead를 초래할 수 있고, 많은 데이터를 처리할 때 성능 저하가 발생할 수 있다.

PL/SQL 코드가 여러번 호출되는 경우 더 느려질 가능성도 존재한다.

 

3번의 Procedure 정의 후 호출하는 방식의 경우 데이터베이스에서 Procedure가 직접 실행되기 때문에 성능이 뛰어나다.

그만큼 Network Overhead 역시 줄일 수 있으며 Procedure 정의 시 조건문, 반복문을 통해 복잡한 로직을 처리하기에 더 적합하다.

하지만, 스키마가 변경되는 경우 프로시저 역시 같이 수정해야 하는 경우가 발생할 수 있기 때문에 가독성이 떨어지고 유지보수 측면에서 불리할 수 있다.

그리고 Java 디버깅 도구를 사용할 수 없기 때문에 문제발생 시 원인을 찾기가 다른 방법들에 비해 더 어려울 수 있다.

배포 역시 데이터베이스에 직접 배포해야 하기 때문에 배포 처리 과정이 복잡해 질 수 있다는 단점이 존재한다.

 

각 방법에 대해 장단점이 존재하지만 처리하는 기능은 적은 횟수만 존재할 것이라고 보장할 수 없기 때문에 1번은 택하지 않게 되었다.

2, 3번 중 고민했는데 좀 더 성능적으로 유리한 3번 방법으로 문제를 해결했다.

 

 

각 해결 방법에 대한 정리

해결 방법 정리를 하며 예제 코드를 정리하기 위해 Entity 클래스와 Table 구조를 먼저 정리.

public class Board {
    private Long id;
    private String title;
    private String content;
    private String userId;
}

 

CREATE TABLE board (
    id NUMBER GENERATED BY DEFAULT AS IDENTITY
                START WITH 1
                INCREMENT BY 1
                NOCACHE
                NOCYCLE
                NOMAXVALUE
                PRIMARY KEY,
    title VARCHAR2(100),
    content CLOB,
    userId VARCHAR2(50)
);

 

우선 IDENTITY에 대해 조금 정리.

IDNETITY는 MySQL에서의 auto_increment와 유사하게 동작한다.

IDENTITY는 12c 부터 도입된 기능이다. 기존에는 Sequence를 따로 정의하고 SQL에서 SEQ.nextval을 명시해야 사용할 수 있었던 반면, MySQL의 auto_increment처럼 명시하지 않아도 자동 증가를 처리할 수 있다.

기본적으로는 Sequence가 생성되고 Oracle에서 해당 Sequence를 통해 자동으로 처리해주는 개념이기 때문에 IDENTITY가 Sequence와 완전 다른 개념이라고 보기에는 어렵다고 생각한다. 편의성정도..?

IDENTITY의 옵션 설정에 대해서는 Sequence와 동일한 옵션을 갖는다.

차이로는 AS IDENTITY 이전에 DEFAULT, DEFAULT ON NULL, ALWAYS라는 옵션을 설정할 수 있다.

크게 나누면 DEFAULT, ALWAYS로 나눠볼 수 있는데 ALWAYS는 SQL에 값을 명시하더라도 Sequence의 값을 따른다.

그러나 DEFAULT는 값을 SQL에 직접 명시한다면 Sequence의 값이 아닌 해당 값을 따른다.

즉, ALWAYS 설정에서 id에 2를 넣도록 SQL을 작성하더라도 Sequence의 값이 1이라면 1이 들어가게 되고 DEFAULT는 2가 들어가게 된다.

DEFAULT ON NULL의 경우는 NULL로 명시하는 경우 Sequence의 값으로 처리되는 방법이다.

그래서 DEFAULT로 설정하는 경우 NULL로 명시했을 때 NULL을 그대로 저장해주지만 DEFAULT ON NULL을 설정하게 되면 NULL을 넣고 싶어도 Sequence 값이 들어가기 때문에 담을 수 없게 된다.

 

IDENTITY에 대한 주의 사항으로는 생성 이후 수정이 불가능하다는 점이다.

이것 때문에 발생한 문제도 있었는데 기존 MySQL에 담아두었던 더미데이터를 옮기는 과정에서 문제가 있었다.

연관관계 설정이 되어있기 때문에 IDENTITY가 동작하지 않고 그대로 데이터를 담도록 처리했다.

하지만, IDENTITY의 START WITH 값을 그대로 1로 두었고 이걸 수정할 수 없다보니 이후 처리에 대해서 계속 1만 가져오니 오류가 발생했다.

그래서 더미데이터를 최초 삽입하는 경우 IDENTITY를 통해 처리하도록 하면 간단하지만, 기존 더미데이터를 옮기는 경우에는 IDENTITY의 START WITH값을 더미데이터 이후의 값으로 설정해야 한다.

 

 

1. 서버에서 반복문을 통해 mapper 호출

 

서버에서 반복문을 통해 mapper를 호출하는 방법은 간단하기 때문에 설명없이 코드만 정리한다.

@Override
public void insertBoard(List<Board> boardList) {
    for(Board board : boardList)
    	mapper.saveEntity(board);
}
<insert id="saveEntity" parameterType="Board">
    INSERT INTO board(title, content, userId)
    VALUES (#{title}, #{content}, #{userId})
</insert>

 

 

2. PL/SQL을 xml에 직접 작성해서 처리

@Override
public void insertBoard(List<Board> boardList) {
    mapper.saveEntity(boardList);
}
<insert id="saveEntity" parameterType="java.util.List">
    BEGIN
    <foreach collection="list" item="board" seperator=";">
        INSERT INTO board(titie, content, userId)
        VALUES (#{board.title}, #{board.content}, #{board.userId})
    </foreach>
    END;
</insert>

 

PL/SQL을 직접 작성하는 경우 BEGIN 과 END; 사이에 foreach를 통해 list를 반복하게 처리해주면 된다.

 

3. Procedure 정의 후 서버에서 Procedure 호출

 

이 방법은 위 두 방법에 비해서 조금 더 설정해야 하는 부분들이 존재한다.

우선 데이터베이스에서 Procedure를 정의해야 한다. 그리고 처리하는 방법에 따라 Type을 정의해야 하기도 한다.

이때 Type은 Oracle 사용자 정의 타입(User-Defined Type, UDT)을 말한다.

좀 더 가볍게 정리하면 Oracle의 Type은 Java에서 Class와 비슷한 역할을 한다고 생각하면 편하다.

그래서 조금 여러 방식의 요청을 정리한다.

 

1. 클래스 리스트를 매개변수로 받아 처리하는 방법

2. 클래스 리스트와 단일 객체를 받아 처리하는 방법

3. 클래스가 아닌 객체 리스트를 받아 처리하는 방법

 

모두 비슷하지만 정의할 때 타입에 대한 처리와 같은 부분에서 차이가 조금씩 발생한다.

 

3-1. 클래스 리스트를 매개변수로 받아 처리하는 방법

매개변수인 클래스 리스트는 Entity List라고 가정하고 정리한다.

우선적으로 데이터베이스에서 Procedure를 정의해야 하는데 클래스를 매개변수로 처리하는 경우 Type을 먼저 정의한 후에 Procedure를 정의하면 된다.

CREATE OR REPLACE TYPE BOARD_OBJ AS OBJECT(
    title VARCHAR2(100),
    content CLOB,
    userId VARCHAR2(50)
);

CREATE OR REPLACE TYPE BOARD_OBJ_LIST AS TABLE OF BOARD_OBJ;

CREATE OR REPLACE PROCEDURE insert_board(
    board_data IN BOARD_OBJ_LIST
) IS
BEGIN
    FOR i IN 1..board_data.COUNT LOOP
        INSERT INTO board(title, content, userId)
        VALUES(board_data(i).title
            , board_data(i).content
            , board_data(i).userId
        )
    END LOOP;
END insert_board;

 

가장 먼저 정의된 BOARD_OBJ는 매개변수로 받을 클래스의 필드들을 작성해주면 된다.

그리고 이 BOARD_OBJ라는 Type은 자바에서 Class를 정의한 것과 유사하다고 했다.

BOARD_OBJ_LIST는 BOARD_OBJ들을 담고 있는 배열이다.

자바 코드로 정리해보면 아래와 같은 개념이다.

public class Board_obj {
    private String title;
    private String content;
    private String userId;
}

Board_obj[] board_obj_list;

 

그리고 이 Type들을 통해 서버에서 Oracle에서 이해할 수 있도록 변환해 Procedure 매개변수에 매핑을 하게 된다.

그래서 Procedure 코드를 보면 BOARD_OBJ_LIST 타입으로 매개변수가 선언되어있다.

서버에서 List<Board> 를 BOARD_OBJ_LIST로 변환해서 요청하게 될 것이고 Procedure에서는 해당 타입의 데이터를 받아 board_data라는 변수를 통해 받게 된다.

그리고 이후 LOOP를 통해 insert 를 여러번 처리하게 되는 것이다.

그럼 Sequence가 정상적으로 동작한다.

왜냐하면 INSERT ALL에서 Sequence가 정상적으로 증가되지 않는 것이 문제인데 여기에서는 LOOP를 통해 개별적인 INSERT 요청을 처리하는 것이 되기 때문이다.

 

여기서 깊게 생각 안하고 겉으로만 본다면 Procedure에서도 LOOP를 통해 insert를 여러번 반복하네? 라고 볼 수도 있지만,

초반에 정리했듯이 mapper를 여러번 호출하면 connection을 계속 요청해야 한다는 문제가 있지만, 이건 한번의 connection으로 Procedure를 호출하고 procedure 내부에서 여러번 반복을 처리하는 경우다. 또한, Procedure 동작이 데이터베이스에서 이루어지기 때문에 더 효율적으로 처리할 수 있게 된다.

 

데이터베이스에서는 여기까지 정의하면 끝이다.

 

이후는 서버에서 처리하는 방법에 대해 정리한다.

우선 Java Class 리스트를 Oracle에서 그대로 받을 수 없기 떄문에 Oracle에서 받을 수 있는 타입으로 변환이 필요하다.

그래서 TypeHandler를 정의해야 한다.

MyBatis에서는 일반적인 SQL 타입인 VARCHAR, INTEGER 등의 단일 객체 타입은 자동으로 변환해서 Oracle에 전달하지만 Class 객체나 리스트같은 경우는 알아서 변환하지 못하기 때문에 데이터베이스에서도 Type을 정의해야 하고 서버에서는 TypeHandler를 통해 변환한 뒤 넘겨줘야 한다.

 

import oracle.jdbc.OracleArray;
import oracle.jdbc.OracleConnection;
import oracle.jdbc.OracleStruct;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeHandler;
import com.example.domain.entity.Board;

import java.sql.*;
import java.util.List;
public class BoardTypeHandler implements TypeHandler<List<Board>> {
    @Override
    public void setParameter(PreparedStatement ps
                                        , int i
                                        , List<Board> parameter
                                        , JdbcType jdbcType) throws SQLException {
        if(parameter != null && !parameter.isEmpty()) {
            OracleConnection oracleConn = ps.getConnection()
                                            .unwrap(OracleConnection.class);
            OracleStruct[] structArray = new OracleStruct(parameter.size());
			
            for(int idx = 0; idx < parameter.size(); idx++) {
                Board board = parameter.get(idx);
				
                Object[] boardAttributes = {
                    board.getTitle(),
                    board.getContent(),
                    board.getUserId()
                };
				
                OracleStruct struct = (OracleStruct) oracleConn
                                           .createStruct("BOARD_OBJ", boardAttributes);
                structArray[idx] = struct;
            }
			
            OracleArray array = (OracleArray) oracleConn
                                     .createOracleArray("BOARD_OBJ_LIST", structArray);
            ps.setArray(i, array);
        }else
            ps.setNull(i, Types.ARRAY);
    }
    
    @Override
    public List<Board> getResult(ResultSet rs, String columnName) throws SQLException {
        return null;
    }
    
    @Override
    public List<Board> getResult(ResultSet rs, int columnIdx) throws SQLException {
        return null;
    }
    
    @Override
    public List<Board> getResult(CallableStatement cs, int columnIdx) throws SQLException {
        return null;
    }
}

 

TypeHandler에 대한 코드를 조금 정리하고 넘어간다.

  • setParameter()
    • 매개변수 i는 parameter의 인덱스를 의미한다.
    • jdbcType은 여기서 사용되지 않았으나 JDBC 타입 정보다.
    • OracleConnection 객체는 Oracle의 고유한 타입인 Struct, Array를 다루기 위해 생성한다.
    • 반복문 내에서는 Board 리스트의 각 요소들을 OracleStruct 객체로 변환한 뒤 배열에 담아준다.
    • 반복문 종료 이후 반복문 내에서 생성한 배열로 OracleArray를 생성한다.
    • 생성된 OracleArray를 PreparedStatement의 파라미터로 생성한다.
  • getResult()
    • SQL 쿼리의 결과를 Java 객체로 변환하는 역할을 한다.
    • 예제에서는 return null만 처리하기 때문에 구현되지 않은 상태다.
    • String columnName을 받는 메소드는 컬럼 이름을 통해 ResultSet에서 값을 추출한다.
    • int columnIndex를 받는 메소드는 인덱스 값을 통해 ResultSet에서 값을 추출한다.
    • CallableStatement를 받는 메소드는 CallableStatement의 출력 파라미터에서 값을 추출한다.

setParameter 메소드 반복문 내부에 대해 조금 더 정리하면 oracleConn.createStruct()를 통해 데이터베이스에서 정의한 BOARD_OBJ라는 이름을 가진 Struct타입으로 boardAttributes를 변환하는 것이다.

이후 createOracleArray()에서도 역시 BOARD_OBJ_LIST라는 이름을 가진 OracleArray를 생성하는데 그 안에 Struct 배열을 담도록 처리하는 과정이다.

이렇게 변환을 거쳐 Oracle에 정의한 Procedure에서 정상적으로 매개변수를 받을 수 있게 된다.

 

getResult()의 경우 아무것도 작성하지 않았는데 프로젝트 진행중에서도 필요하지 않은 처리였고 해서 사용해보진 않았다.

다중 insert를 위해 사용하기 때문에 따로 결과값을 반환 받을 것도 없었다.

이 getResult 메소드에 대해 알아봤을 때 JDBCTemplate에서 ResultSet을 통해 결과를 받아보듯이 사용하는 것을 확인할 수 있었다.

 

그럼 이제 서비스에서 Mapper를 호출할 때 데이터를 어떻게 담아야 하고, xml에서는 어떻게 데이터를 넘기는 프로시저 호출을 작성해야 하는지 정리.

환경은 serviceImpl - MapperInterface - mapper.xml 구조다.

 

@Service
@RequiredArgsConstructor
public class BoardServiceImpl implements BoardService {
    private final BoardMapper mapper;
    
    @Override
    public void insertBoard(List<Board> boardList) {
        Map<String, Object> params = new HashMap<>();
        params.put("boards", boardList);
        mapper.saveAll(params);
    }
}

public interface BoardMapper {
    void saveAll(Map<String, Object> boards);
}
<insert id="saveAll" parameterType="map">
    {CALL insert_board(
        #{boards, jdbcType=ARRAY, jdbcTypeName="BOARD_OBJ_LIST",
                typeHandler=com.example.mapper.typeHandler.BoardTypeHandler}
    )}
</insert>

 

서비스단에서는 해당 List를 Map<String, Object> 타입의 Map에 담아 Mapper를 호출한다.

그리고 xml에서는 받은 매개변수명, jdbcType, jdbcTypeName, typeHandler를 작성한다.

jdbcType은 현재 배열을 넘기는 것이기 때문에 ARRAY로 정의한다.

jdbcTypeName은 데이터베이스에서 정의한 컬렉션 Type의 이름을 정의한다.

typeHandler는 해당 데이터를 변환할 TypeHandler 경로를 작성해주면 된다.

그럼 TypeHandler를 통해 데이터를 변환한 뒤에 Procedure를 호출해 정상적으로 처리할 수 있게 된다.

 

3-2. 클래스 리스트와 단일 객체를 받아 처리하는 방법

다음은 클래스 리스트와 단일 객체를 넘기는 방법이다.

그래서 title, content만 필드로 갖는 BoardDTO라는 클래스가 존재한다고 가정하고, userId는 String으로 넘겨 insert 처리 시 모든 데이터가 동일한 userId를 갖도록 한다.

 

이 경우는 1번의 클래스 리스트를 넘기는 방법과 크게 다르지 않다.

Type 정의 시 userId는 단일 객체로 받을 것이기 때문에 포함하지 않는다.

그리고 Procedure의 매개변수에 userId를 받는 매개변수를 추가해주면 된다.

 

CREATE OR REPLACE TYPE BOARD_OBJ AS OBJECT(
    title VARCHAR2(100),
    content CLOB
);

CREATE OR REPLACE TYPE BOARD_OBJ_LIST AS TABLE OF BOARD_OBJ;

CREATE OR REPLACE PROCEDURE insert_board(
    user_id IN VARCHAR2,
    board_data IN BOARD_OBJ_LIST
) IS
BEGIN
    FOR i IN 1..board_data.COUNT LOOP
        INSERT INTO board(title, content, userId)
        VALUES(board_data(i).title
            , board_data(i).content
            , user_id
        )
    END LOOP;
END insert_board;

 

이정도만 간단하게 수정된다.

Type 정의에서 userId를 제외하고, Procedure 에서는 userId를 VARCHAR2 타입으로 받도록 해 문자열을 받을 수 있도록 한다.

이때 만약 userId가 Integer, Long 과 같은 타입이라면 NUMBER 타입으로 받도록 처리하면 된다.

 

그럼 TypeHandler에서 역시 boardAttributes 정의 시 userId를 제외하도록 하기만 하면 나머지는 동일하다.

import oracle.jdbc.OracleArray;
import oracle.jdbc.OracleConnection;
import oracle.jdbc.OracleStruct;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeHandler;
import com.example.domain.entity.Board;

import java.sql.*;
import java.util.List;
public class BoardDTOTypeHandler implements TypeHandler<List<BoardDTO>> {
    @Override
    public void setParameter(PreparedStatement ps
                                        , int i
                                        , List<BoardDTO> parameter
                                        , JdbcType jdbcType) throws SQLException {
        if(parameter != null && !parameter.isEmpty()) {
            OracleConnection oracleConn = ps.getConnection()
                                            .unwrap(OracleConnection.class);
            OracleStruct[] structArray = new OracleStruct(parameter.size());
			
            for(int idx = 0; idx < parameter.size(); idx++) {
                BoardDTO board = parameter.get(idx);
				
                Object[] boardAttributes = {
                    board.getTitle(),
                    board.getContent()
                };
				
                OracleStruct struct = (OracleStruct) oracleConn
                                           .createStruct("BOARD_OBJ", boardAttributes);
                structArray[idx] = struct;
            }
			
            OracleArray array = (OracleArray) oracleConn
                                     .createOracleArray("BOARD_OBJ_LIST", structArray);
            ps.setArray(i, array);
        }else
            ps.setNull(i, Types.ARRAY);
    }
    
    @Override
    public List<BoardDTO> getResult(ResultSet rs, String columnName) throws SQLException {
        return null;
    }
    
    @Override
    public List<BoardDTO> getResult(ResultSet rs, int columnIdx) throws SQLException {
        return null;
    }
    
    @Override
    public List<BoardDTO> getResult(CallableStatement cs, int columnIdx) throws SQLException {
        return null;
    }
}

 

서비스와 xml 에서의 처리는 아래와 같이 하면 된다.

@Service
@RequiredArgsConstructor
public class BoardServiceImpl implements BoardService {
    private final BoardMapper mapper;
    
    @Override
    public void insertBoard(List<BoardDTO> boardList, String userId) {
        Map<String, Object> params = new HashMap<>();
        params.put("boards", boardList);
        params.put("userId", userId);
        mapper.saveAll(params);
    }
}

public interface BoardMapper {
    void saveAll(Map<String, Object> boards);
}
<insert id="saveAll" parameterType="map">
    {CALL insert_board(
        #{userId, jdbcType=VARCHAR},
        #{boards, jdbcType=ARRAY, jdbcTypeName="BOARD_OBJ_LIST"
                , typeHandler=com.example.mapper.typeHandler.BoardDTOTypeHandler}
    )}
</insert>

 

처리는 의외로 간단하다.

서비스에서는 Map에 userId를 추가로 담아주면 된다.

xml에서는 userId의 jdbcType을 VARCHAR로 처리해주기만 하면 된다.

여기서 만약 userId가 Integer, Long 타입인 경우에는 NUMERIC으로 명시해주면 된다.

 

3-3. 클래스가 아닌 객체 리스트를 받아 처리하는 방법

이번에는 클래스를 전혀 넘기지 않고 각 객체들을 넘기는 방법이다.

조금 여러 케이스를 정리하기 위해 Board 필드 객체 타입에 살짝 억지를 부려서

List<Integer> title, List<String> content, List<Long> userId 라는 값들을 받는다고 가정한다.

 

그럼 여기서는 Procedure에 매개변수로 클래스를 넘기지 않는다.

그렇기 때문에 Type 정의를 해줄 필요가 없게 된다.

Procedure만 정의해주면 되는데 아래와 같이 정의한다.

CREATE OR REPLACE PROCEDURE insert_board(
    board_title IN SYS.ODCINUMBERLIST,
    board_content IN SYS.ODCIVARCHAR2LIST,
    user_id IN SYS.ODCINUMBERLIST
) IS
BEGIN
    FOR i IN 1..board_title.COUNT LOOP
        INSERT INTO board(title, content, userId)
        VALUES(board_title(i), board_content(i), user_id(i))
    END LOOP;
END insert_board;

 

굳이 매개변수 타입을 조금 억지 부린 이유가 여기있다.

Integer, Long 타입인 title, userId의 경우 SYS.ODCINUMBERLIST 타입으로 선언하게 된다.

Java에서는 Integer, Long으로 나눠서 처리하지만 Oracle에서는 동일하게 NUMBER 타입이기 때문이다.

그리고 String의 경우 SYS.ODCIVARCHAR2LIST 타입으로 받아주게 된다.

이 타입들의 경우 Oracle의 내장컬렉션이기 때문에 Type 정의를 해줄 필요가 없다.

 

그리고 이렇게 클래스가 아닌 객체 리스트 역시 변환이 필요하다.

최초 Entity를 넘겨 처리하는 방법에서 정리했다시피 MyBatis에서는 List 역시 제대로 변환하지 못하기 때문에 TypeHandler를 정의해야 할 필요가 있다.

TypeHandler는 타입만 조금 바꿔주면 되는 정도로 유사하기 때문에 Integer 타입에 대한 Handler만 정리한다.

import oracle.jdbc.OracleConnection;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeHandler;

import java.sql.*;
import java.util.List;

public class IntegerArrayTypeHandler implements TypeHandler<List<Integer>> {
    
    @Override
    public void setParameter(PreparedStatements ps
                            , int i
                            , List<Integer> parameter
                            , JdbcType jdbcType) throws SQLException {
        if(parameter != null && !parameter.isEmpty()) {
            OracleConnection oracleConn = ps.getConnection()
                                            .unwrap(OracleConnection.class);
            Integer[] arrayData = parameter.toArray(new Inteter[0]);
            Array oracleArray = oracleConn
                                .createOracleArray("SYS.ODCINUMBERLIST", arrayData);
            ps.setArray(i, oracleArray);
        }else
            ps.setNull(i, Types.ARRAY);
    }
    
    @Override
    public List<Integer> getResult(ResultSet rs
                                  , String columnName) throws SQLException {
        return null;
    }
    
    @Override
    public List<Integer> getResult(ResultSet rs
                                  , int columnIdx) throws SQLException {
        return null;
    }
    
    @Override
    public List<Integer> getResult(CallableStatement cs
                                  , int columnIdx) throws SQLException {
        return null;
    }
}

 

attributes 객체를 따로 생성해줄 필요가 없기 때문에 코드가 좀 더 간결하다.

다른 타입들의 TypeHandler와의 차이점으로는 타입 선언부분에서의 차이, createOracleArray에서 타입명의 차이 정도가 있다.

Long 타입의 경우 동일하게 SYS.ODCINUMBERLIST로 처리되지만 String의 경우 SYS.ODCIVARCHAR2LIST로 수정해주면 된다.

 

서비스와 Mapper는 아래와 같다.

@Service
@RequiredArgsConstructor
public class BoardServiceImpl implements BoardService {
    private final BoardMapper boardMapper;
    
    @Override
    public void insertBoard(List<Integer> titleList
                          , List<String> contentList
                          , List<Long> userIdList) {
        Map<String, Object> params = new HashMap<>();
        params.put("titleList", titleList);
        params.put("contentList", contentList);
        params.put("userIdList", userIdList);
        
        mapper.saveAll(params);
    }
}

public interface BoardMapper {
    void saveAll(Map<String, Object> boards);
}
<insert id="saveAll" parameterType="map">
    {CALL insert_board(
        #{titleList, jdbcType=ARRAY, jdbcTypeName="SYS.ODCINUMBERLIST",
                typeHandler=com.example.mapper.typeHandler.IntegerArrayTypeHandler},
        #{contentList, jdbcType=ARRAY, jdbcTypeName="SYS.ODCIVARCHAR2LIST",
                typeHandler=com.example.mapper.typeHandler.StringArrayTypeHandler},
        #{userIdList, jdbcType=ARRAY, jdbcTypeName="SYS.ODCINUMBERLIST",
                typeHandler=com.example.mapper.typeHandler.LongArrayTypeHandler}
    )}
</insert>

 

Procedure를 호출해 사용하는 방법들은 이렇게 여러가지 방법이 있다.

사실 여러가지 방법이라기 보다도 User-Defined Type을 어떤 경우 정의해야 하며, 어떻게 정의해야 하고 Procedure에서 매개변수 타입에 대한 설정을 어떻게 처리할지, TypeHandler는 언제 작성해야 하고 어떻게 처리해야 할지, MyBatis에서 호출할때는 어떻게 해야할지만 이해한다면 조합해서 사용하는 정도이다.

 

이번 문제해결을 하며 느낀점.

결과적으로 이 문제가 발생한 이유는 Sequence가 INSERT ALL에서는 정상적으로 동작하지 않는다는 데에서 시작한 것이다.

최초 프로젝트 수행시에는 11g를 사용했고 SEQ.nextval을 직접 작성해가며 처리했었다.

이번에 리펙토링하면서 18c로 업그레이드를 해줬고 IDENTITY가 자동 증가처리를 해준다길래  MySQL 설계와 동일하게 처리했더니 완전 폭탄 맞았다.

보통 id를 정수형으로 사용하는 이유는 검색 속도 때문이라고 알고 있다.

아무래도 문자열을 index로 갖고 검색하는 것 보다는 정수형이 정렬도 빠르기 때문이다.

 

그래서 이번에 리펙토링을 하면서 Oracle에서도 그렇게 써봐야지 했던건데 아무래도 일이 커졌다..

만약 성능에 영향이 덜하다면 굳이 정수형을 사용해서 IDENTITY를 설정하고 다중 insert를 이렇게 처리할 필요는 없지 않을까? 라는 생각이 들었다.

아무래도 Procedure를 사용하거나 PL/SQL을 직접 작성해 처리하도록 하면 복잡도도 늘어나고 관리해야 할 포인트들이 늘어난다.

그렇다고 서버에서 반복문을 통해 처리하자니 대량의 데이터를 처리해야 한다면 그건 그거대로 또 문제가 될 수 있다.

Oracle 환경에서는 성능에 큰 영향이 없고 앞으로의 운영 또는 확장에 대해서도 성능이 보장되는 경우에는 서버에서 직접 기본키를 생성하고 넘기는 형태가 더 유리하지 않을까 라는 생각이 들었다.

물론, 성능 측면에서는 관리 포인트가 늘어나더라도 조금이라도 더 좋은 성능을 낼 수 있는 방법을 택하는 것이 우선적이겠지만.

이번 문제 해결을 통해 데이터베이스 설계시 Sequence 역시 고려해야 할 포인트가 MySQL에 비해 더 많다는 것을 느낄 수 있었다.

 

Kotlin 프로젝트에서 build.gradle.kts를 열었을 때 모든 라인이 빨간 밑줄이 생긴 것에 대한 문제 해결입니다.

 

문제해결이 다 된 상태라 캡쳐를 하지 못한점은 아쉽지만 문제 해결과정에서 찾지 못한 방법이었고 어이없게 해결할 수 있었던 방법이기 때문에 뭘 해도 안된다 하시는 분들은 한번 확인 해보시는 것도 좋을 것 같습니다.

 

Unresolved reference: plugins

Cannot access script base class 'org.gradle.kotlin.dsl.KotlinBuildScript'. Check your module classpath for missing or conflicting dependencies

 

이런 오류가 발생.

IDE는 IntelliJ를 사용하고 있습니다.

이 오류를 해결하는 방법으로 대부분의 블로그에서는 File -> Invalidate caches를 눌러 Clear VCS Log caches and indexes와 Mark downloded shared indexes as exluded를 선택하고 INVALIDATE AND RESTART 버튼을 눌러 캐시 삭제와 재부팅을 수행하도록 권장하고 있습니다.

실제로 그렇게 해결하신 분들도 많은 것으로 보입니다.

 

하지만 이 방법으로 문제가 해결되지 않았고 직접 맥북의 /User/{username}/.gradle/caches를 직접 삭제하는 방법까지 수행해봤으나 아무런 변화도 없었습니다.

 

그래서 이리저리 방법을 알아보고 chatGPT에도 물어봤으나 이미 확인했던 비슷한 답변만 존재해서 답답하던 와중.

해당 프로젝트의 Application.kt 파일을 무심코 열어봤는데 상단에 오류가 떠있었습니다.

 

Kotlin runtime library and 4 other jars have an unsupported binary format

코틀린 런타임 라이브러리 및 4개 jar에 지원되지 않는 바이너리 형식이 있다는 내용.

 

그리고 옆에는 update plugin 버튼이 있었고 버튼을 눌러 update 해줬습니다.

여기서 kotlin Plugin을 설치하지 않은 상태도 아니었고 chatGPT가 내놓은 해답 중 하나가 kotlin plugin 설치를 확인하라는 것이었기 때문에 설치를 확인까지 했으나 업데이트를 확인하지 않은것이 문제였던 것 같습니다.

 

업데이트가 종료되고 나면 재부팅 안내 창이 뜰텐데 재부팅 해주고 다시 build.gradle.kts를 열어보면 그대로 빨간 밑줄이 있습니다.

다시 File -> Invalidate caches를 눌러 캐시 삭제 후 재부팅을 해주고 나니 빨간 밑줄이 사라지는 것을 확인할 수 있었습니다.

 

build.gradle.kts에 의존성을 추가하고 build 했을 때 build 탭에는 SUCCESS로 나오긴 하지만 

 

> Task :prepareKotlinBuildScriptModel UP-TO-DATE

Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

For more on this, please refer to https://docs.gradle.org/8.8/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.

BUILD SUCCESSFUL in 1s

 

이렇게 gradle 버전이 문제라는 말만 나오지 플러그인에 대한 내용이 전혀 없어서 버전 문제 혹은 설정 문제일 것이라고 생각했고 실제 문제 해결에 대한 방법을 찾았을 때도 캐시 또는 버전 문제였는데 설마 Kotlin 플러그인 업데이트 문제일 것이라고는 생각도 못했습니다.

이번 문제는 운빨로 정말 우연히 해결하게 되었는데 눈에 보이는 코드만이 아니라 좀 더 폭 넓게 확인하는 습관을 들여야겠습니다 ㅠㅠ

문제

로그인 기능이 있는 프로젝트이다 보니 로그인한 사용자만 접근할 수 있도록 해야 하는 페이지가 존재한다.

간단한 게시판 프로젝트인 만큼 대부분의 권한이 필요한 페이지는 서버에 요청을 보내 데이터를 받는 경우가 대부분이기 때문에 접근에 대해 딱히 신경쓰지 않더라도 서버 요청에서 403이 발생해 접근할 수 없다.

데이터 역시 반환되지 않기 때문에 데이터가 출력될 걱정 역시 없고.

하지만 게시글 작성 페이지의 경우 문제가 달랐다.

아무런 데이터 요청이 필요하지 않았고 그러다보니 이걸 굳이 서버에 요청하게 두고 싶지 않았다.

 

글 작성 페이지는 애초에 서버에 get 요청을 보내지도 않기 때문에 아무런 정보가 존재하지 않고 페이지 내의 유일한 기능인 작성을 요청하기 위해서는 토큰을 같이 전송해야 한다.

토큰이 존재하지 않는데 이걸 어찌어찌 뚫고 들어온다고 하더라도 할 수 있는게 없다.

그럼 굳이 서버에 요청해 처리시간과 요청에 대한 비용을 발생시켜야 할까? 라는 생각이었다.

그래서 클라이언트에서 가볍게 검증할 수 있는 방법을 찾고자 했었다.

 

해결 방안으로 택했던 것은 Redux를 통한 로그인 상태 관리.

Navbar 컴포넌트는 처음 렌더링 될 때 서버에 사용자 로그인 여부를 요청하고 해당 응답에 따라 dispatch를 수행해 Redux에 상태 정보를 담을 수 있도록 처리했다.

 

게시글 작성 페이지에서는 이 Redux에 저장된 값을 useSelector로 가져와 로그인 상태가 아니라면 로그인 페이지로 이동하도록 처리했다.

 

처음에는 당연히 잘 되었으나, 해당 페이지에서 새로고침을 하는 경우 Redux에 있는 값이 초기화 되기 때문에 로그인한 사용자라도 로그인 페이지로 이동이 되었다.

또한 로그인하지 않은 사용자가 게시글 작성 페이지에 url 입력으로 접근하는 경우 페이지가 일단 렌더링 되었다가 로그인 페이지로 이동한다는 문제 역시 존재했다.

 

 

문제해결 과정

1. Redux persist로 해결하는 방안을 고려

가장 먼저 Redux를 통해 문제를 해결하고자 했다.

로그인 상태를 localStorage에 담고 Redux persist를 통해 렌더링 이전 스토리지에서 데이터를 확인하고 상태 값을 담은 뒤 렌더링 하도록 한다.

Redux persist를 사용해 localStorage에 담는 것은 이미 가장 처음에 Redux를 적용하면서 처리해본 방법이긴 했다.

현재는 persist를 통해 관리하지 않도록 수정한 상태인데 이유는 localStorage에 저장된 데이터가 얼마나 정확한지 파악할 수 없다는 것이 이유였다.

localStorage에 로그인이 된 상태라고 저장이 되어있더라도 토큰이 모두 만료되어 로그인 상태라고 판단할 수 없을 수 있다고 생각했다.

 

JWT를 공부하고 여러 케이스를 보면서 Authorization 토큰의 경우 localStorage 또는 쿠키에 담는 것이 보통이고 요청 이전 header에 담아 토큰을 체크한 뒤 사용자 요청을 보내거나 한번의 요청으로 토큰 체크까지 수행하는 경우가 많다.

이렇게 처리하는 경우 localStorage에 저장된 토큰이 유효한 토큰인지 요청마다 체크할 수 있게 되는데 그만큼 서버에 대한 요청 횟수가 많아지고 부담이 생길것이라고 생각했다.

아직은 제대로 학습하지 않았지만 MSA 처럼 여러개의 서버로 분리하는 아키텍쳐 구조라면 토큰만 검증하는 요청이 존재하고 사용자 요청은 다른 서버로 전달 될 것이기 때문에 부담이 줄어들겠지만 진행하고 있는 프로젝트처럼 단일 서버 구조에서라면 한번에 처리하는 방법도 나쁘지 않을 것이라고 판단해 설계했다.

그래서 모든 토큰을 쿠키에 저장하고 한번의 요청으로 처리하게 설계했는데, 단순하게 localStorage에 저장한 로그인 상태를 위해 서버에 요청하는 것은 AccessToken을 localStorage에 저장하는 것 보다 비효율적이라고 생각했다.

이렇게 처리되어야 한다면 생각되는 순서는 아래와 같았다.

 

localStorage에서 데이터 조회 -> 같이 저장한 데이터 저장일자 또는 만료일자 값을 통해 상태 체크 -> 만료 상태가 정상이라면 Redux에 저장, 비정상이라면 서버에 요청 후 localStorage에 재 저장한 뒤 Redux에 저장 -> 렌더링

 

'만료 상태가 정상이라면 서버에 요청할 필요가 없고 비정상인 경우에만 요청하면 된다. 그만큼 서버에 요청하는 횟수가 줄어들 것이다.' 라고 생각했다.

AccessToken의 경우 클라이언트에서 만료체크를 하는 것 보다는 서버에 보내 확실하게 체크하는 것이 더 낫다고 생각했기 때문에 Redux persist로 처리한다면 로그인 상태만 관리하도록 하거나 아니면 AccessToken 저장을 쿠키가 아닌 localStorage에 저장하도록 해 처리하도록 아예 수정하는 것이 낫겠다 싶었다.

그래서 메모만 해두고 일단 보류.

 

2. 비동기가 문제?

게시글 작성 페이지를 접근하면 아래와 같은 순서로 처리가 된다.

게시글 작성 페이지 접근 -> 렌더링 -> 선언된 useSelector 값을 useEffect에서 체크 -> useSelector 값에 따라 이후 처리

 

그리고 여기서 새로고침을 하면 아래와 같이 된다.

게시글 작성 페이지 접근 -> 렌더링 -> Navbar에서 useEffect를 통해 서버에 사용자 로그인 상태 요청 및 dispatch -> dispatch에 따른 작성 페이지 useEffect 수행 -> 이후 처리

 

이때 문제에 대해 알아보면서 알게 된 것이 useEffect가 렌더링 이후에 수행된다는 점이었다.

useEffect가 가장 먼저 수행이 될 것이라는 착각에서 발생한 문제였다.

하지만 useEffect를 통해 처리하지 않는다면 그건 또 그거대로 렌더링이 발생할때마다 해당 요청을 보내 처리한다는 문제가 있었다.

그래서 이 처리가 마무리될때까지 지연시킬 방법이 없을까 고민해봤다.

작성 페이지가 렌더링 되는 조건을 Navbar가 렌더링이 마무리 된 이후로 처리할 수 없을까? 라는 생각을 해봤는데

이건 방법을 찾을 수가 없었다..

 

그나마 처리할 수 있는 방법을 찾은게 persistGate를 통해 Redux가 처리되기 전까지 렌더링을 하지 않도록 막는 것이었는데 그렇게 하게 되면 localStorage에 사용자 상태를 담아야 했다.

좀 더 확실하게 처리할 수 있는 방법을 찾기 위해 1번 방법을 보류해두었는데 이건 사실상 그 방법에 대한 연장선이 되었다.

 

 

3. 페이지 접근 시 서버에 요청 전달

계속 이 방법은 피하고자 했으나 사실 가장 확실한 방법이긴 했다.

'별 기능이 없고 출력되는 데이터도 없다보니 서버에 굳이 요청을 안보내는 방법을 생각해보자' 라는 것에서 시작된 문제였지만 그래도 권한이 필요한 페이지이기 때문에 확인은 서버에 요청하는게 가장 베스트이긴 했다.

useEffect를 통해 해당 페이지 접근 시 서버에 요청을 보내고 그 결과에 따라 로그인 상태라면 setUserStatus로 state 값을 수정.

렌더링 조건으로 userStatus가 true 인 경우에만 수행하도록 처리해 처리되는 동안 먼저 렌더링되어 출력하는 것을 막을 수 있었다.

 

이 방법이 가장 확실하다고 생각해 수정하게 되었고 어제까지 이 상태로 올라가있었다.

 

4. 중복 요청

3번 처리 후 프로젝트에 대해 정리하면서 알게된 점이 있다.

작성 페이지 접근 후 새로고침을 하게 되면 로그인 상태 확인 요청이 두번 발생한다는 것이었다.

Navbar에서도 요청을 보내 dispatch를 수행하고 작성 페이지의 useEffect에서도 요청을 보낸다는 점.

수정 당시에는 생각도 못한 문제였다.

 

 

 

최종적인 해결방안

마지막 4번 문제에 대해서는 생각보다 간단하게 해결할 수 있었다.

처음에는 여러가지 방안을 고려해봤다.

insert 로 끝나는 url이라면 Navbar에서 요청을 수행하지 못하도록 막는다.

작성 컴포넌트에서 useSelector 값이 초기값인 default인 경우 아무것도 수행하지 않도록 하고 useEffect가 첫 렌더링시에만 수행하는 것이 아닌 매번 수행하도록 한다.

default 상태이더라도 useState 값을 건드려 계속해서 재렌더링 하도록 한다.

 

전부 다 비효율적이고 문제가 있는 방법들이었다.

첫번재 방법은 다른 페이지에서의 재 렌더링에서도 해당 조건문을 타기 때문에 불필요한 과정이 하나 늘어난다.

두번째 방법은 작성 페이지의 input 값에 대해 onChange로 state에 저장해 관리하는데 그럼 매번 useEffect가 수행이 된다.

세번째 방법은 만약 요청이 지연되서 useSelector 값이 들어오는데 시간이 걸린다면 default 인 경우 얼마나 값을 바꾸도록 수행할 것인가. 그리고 그로 인한 계속적인 재 렌더링을 어떻게 감당할 것인가.

 

그러다 정말 바보처럼 당연한 것에 대한 해답이 떠올랐다.

useSelector값의 변화에 따라 useEffect를 수행하게 하면 된다.

Redux에는 초기값으로 default가 설정되고 로그인한 상태라면 'loggedIn', 로그아웃 상태라면 'loggedOut' 값을 갖는다.

그럼 useEffect에서는 default인 경우 아무것도 처리하지 않고 useSelector 값이 변화될때마다 수행되도록 하면 이후 값에 대한 처리를 통해 해결할 수 있게 된다.

 

그럼 중복 문제도 해결할 수 있고 loggedIn 인 경우 userStatus를 true로 바꿔줄테니 setState에 의해 재 렌더링 되면서 조건을 만족해 제대로 렌더링 할 수 있다.

또한 userStatus가 true가 아니라면 컴포넌트를 제대로 출력하지 않기 때문에 불필요한 엘리먼트가 노출되는 것도 막을 수 있다.

 

 

정리

이걸 해결하는데 이렇게 오래 걸린 이유는...

가장 먼저 useEffect의 동작 원리에 대한 낮은 이해도, Redux 활용에 대한 문제가 가장 컸던 것 같다.

실제로 초기 로그인 상태값에 대한 Redux state 값은 boolean으로 처리했었다.

그래서 아직 요청이 확인되기 전에도 false 값을 가져 로그인하지 않은 사용자로 처리되곤 했었다.

이 문제가 발생하게 되면서 이것 저것 시도하면서 default, loggedIn, loggedOut으로 나눠 기본값을 default 로 가져가도록 수정하게 된 것인데 그렇게 수정한 것을 제대로 사용할 방법에 대해 좀 더 빨리 깨달았다면 금방 해결 될 문제였다.

 

기존 JWT를 사용한 프로젝트를 진행할 때 Redis를 활용한다는 것은 알고 있었지만 쓸 줄 몰랐기에 DB를 통해 처리하는 방법으로 마무리를 했었다.

 

이번에 Redis를 학습하면서 이 프로젝트를 리펙토링하고자 리펙토링 계획을 세우고 있는데 고려해야 하는 사항이 많이 발생해서 그 내용을 정리한다.

 

고민에 대한 정리글이기 때문에 문제 해결에 대한 정답이나 명확한 방법을 찾은것은 아니고 개인적인 의견이니 보시고 다른 의견이나 피드백은 환영합니다.

 

 

일단 현재 구조를 먼저 정리.

AccessToken과 RefreshToken 두 가지를 생성해 처리 중.

AccessToken의 Claim으로는 userId를 갖고 있으며 RefreshToken은 DB 조회를 위해 rtIndex라는 난수를 Claim으로 갖고 있음.

AccessToken이 만료되어 재발급 받아야 하는 경우 RefreshToken 역시 재발급 해 갱신하는 RefreshToken Rotation 방식으로 처리하는 중.

목적은 RDB가 아닌 Redis로 토큰을 관리하는 것이 목적이며, 여러 디바이스에서의 로그인을 허용할 수 있도록 하는 것이 목표.

 

 

고민 및 문제점

Redis로 토큰을 관리하는 것은 Redis를 어떻게 사용해야 하는지만 배우면 되는 부분이라 문제가 되지 않았다.

문제는 RDB처럼 여러 필드를 통해 저장하지 않고 K:V 형태로 어떻게 관리를 할 것이며,

다중 로그인에 대한 관리 및 탈취에 대한 대비는 어떻게 할 것인가였다.

 

몇일을 JWT 다중 로그인에 대해 알아봤지만 마땅한 답을 얻기 어려웠다.

 

그러다 팁을 얻을 수 있는 블로그 포스팅을 발견했다.

https://junior-datalist.tistory.com/352

 

Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응

I. 서론 JWT와 Session 비교 및 JWT의 장점 소개 II. 본론 Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까? Refresh Token Rotation Redis 저장 방식 변경 III. 결론

junior-datalist.tistory.com

 

다중 로그인을 처리하신 것은 아니지만 생각하지 못했던 부분을 잡아 낼 수 있었다.

생각지 못했던 탈취당한 토큰으로 사용자보다 먼저 재발급을 받게 되는 경우까지 고려한 글이었다.

탈취자가 사용자보다 먼저 재발급을 받게 된다면 사용자는 정상적인 재발급을 받을 수 없게 되고, 로그아웃 처리가 되는 형태가 되어버린다.

그럼 탈취자는 재발급 받은 토큰으로 계속 사용이 가능하게 되며 사용자는 재로그인을 하게 되면서 한 아이디에 토큰이 두개가 되는 형태가 될 수 있다.

이걸 방지하기 위해 DB에 RefreshToken을 저장하는 것이고 사용자가 요청을 보냈을 때 자신의 아이디로 이미 RefreshToken이 존재한다면 탈취되었다고 판단하고 해당 데이터를 삭제하는 방법으로 탈취자의 사용을 막을 수 있게 된다.

 

이 포스팅을 보고 각 토큰의 Redis 저장 형태를 잡을 수 있었다.

rtuserId : RefreshTokenValue, atuserId : AccessTokenValue

 

처음에는 RefreshTokenValue : AccessTokenValue,  AccessTokenValue : userId 형태로 처리하려고 했다.

다중 로그인을 처리하고자 하다보니 중복되지 않은 키값으로 각 토큰의 값을 key로 갖는것이 좋겠다고 생각했기 때문이다.

그리고 현재 구현되어있는 구조도 그런 구조로 되어있다.

RefreshToken의 Claim인 rtIndex가 기본키로 되어있고 RefreshTokenValue와 userId를 담고 있다.

이 구조에서도 이에 대한 대비가 부족하다고 이번에 느꼈다.

RefreshToken에서 Claim을 꺼내더라도 userId는 찾을 수 없고, DB 조회를 통해서만 알 수 있었다.

그럼 이미 갱신되어버린 이전 값으로는 사용자의 아이디도 찾을 수 없기 때문에 토큰 존재여부를 확인할 수 없다.

 

그래서 해당 블로그에서는 userId를 키값으로 갖는 구조를 얘기한다.

userId : RefreshTokenValue 이 구조라면 key값은 유지되고 value값만 갱신되기 때문에 value와 서버에서 받은 RefreshTokenValue가 다르다면 탈취한것으로 판단할 수 있다는 것이다.

 

그래서 RefreshToken의 Claim 역시 사용자 아이디로 수정하기로 했다.

그로인해 서버에서 받은 RefreshToken의 claim으로 key값을 알 수 있게 되고, value를 비교할 수 있게 된다.

 

 

 

 

 

그럼 탈취에 대한 대비는 어느정도 되었으니 다음 문제다.

'key가 userId 구조라면 다중 디바이스 로그인 처리에서 중복될텐데 이걸 어떻게 처리할 것인가?'

 

이 문제 역시 다양한 방안을 고민해봤다.

처음에는 기기 정보를 얻을 수 있으면 해결되지 않나 싶었다.

Mobile, Tablet, else 형태로 구분을 하는 방법은 있었다.

하지만, 각 디바이스별 앱인지, 웹앱인지를 또 구분해야 하고 그에 따른 차이도 필요했다.

그럼 디바이스 식별자 정보로 key를 생성하는것도 조금 애매해졌다.

 

https://wildeveloperetrain.tistory.com/297

 

JWT 인증 단점과 중복 로그인 관련 해결 방안

JWT 인증 기능의 단점과 문제점 그리고 중복 로그인 문제 해당 포스팅은 'jwt 인증 기능을 사용하면서 느꼈던 단점과 발생했던 문제점, 그리고 중복 로그인에 대한 나름의 해결 방안'을 정리한 것

wildeveloperetrain.tistory.com

이 문제는 여기서 답을 찾을 수 있었다.

 

로그인시 고유값을 하나 만들어서 클라이언트에 전달해주는 방법이다.

이 방법으로 UUID를 통해 임시로 tno라는 변수를 만들고 이걸 클라이언트에게 전달하는 방법을 생각했다.

 

그럼 디바이스별, 브라우저별로 각각 tno가 모두 다를것이기 때문에 key가 중복되는 일을 없앨 수 있다고 생각했다.

그래서 구조를 이렇게 생각했다.

"rt" + tno + userId : RefreshTokenValue,   "at" + tno + userId : AccessTokenValue

그럼 모바일 기준 앱 환경에서 로그인 할때의 tno와 크롬이나 브라우저 애플리케이션에서 로그인할때의 tno가 다를 것이고 다중 로그인을 성공적으로 해결할 수 있다.

 

하지만 여기서 고려해야할 사항이 하나 더 있다.

tno는 사용자가 로그인을 할 때 마다 생성해서는 안된다.

처음에는 tno를 로그인 요청 프로세스 사이에 끼워서 매번 생성하고자 했다.

 

하지만 그렇게 처리하면 첫번째 고려사항인 탈취 대비에 대해 문제가 발생한다.

coco라는 아이디의 유저가 처음 로그인을 하고 asdf 라는 tno가 생성되었다고 치자.

그럼 rfasdfcoco : RefreshTokenValue, atasdfcoco : AccessTokenValue 이렇게 redis에 저장될 것이다.

여기서. 탈취가 되었다고 가정하자.

그럼 탈취자는 rfasdfcoco의 value값을 재발급 받게 될 것이다.

이때 사용자가 다시 접근한다면 동일한 tno를 갖고 있기 때문에 RefreshTokenValue가 다르다는 것을 서버가 알게되고 Redis에서 데이터를 삭제하게 될 것이다.

 

하지만, 만약 사용자가 RefreshToken의 만료기간 이상의 기간동안 접속하지 않는다면?

사용자는 RefreshToken 쿠키가 만료되어 토큰 정보를 전혀 갖고 있지 않게 될 것이다.

그럼 재 로그인을 해야될 것이고 그때 새로운 tno를 생성해 로그인 처리를 한다면

rfqwercoco : RefreshTokenValue 라는 데이터가 Redis에 담기게 될 것이고, 탈취자는 아무런 지장없이 rfasdfcoco 데이터를 통해 계속해서 기능 사용이 가능할 것이다.

 

이걸 방지하기 위해서는 최초 로그인시에 발급된 tno를 디바이스에서 계속 보관하도록 해야 하며, 만료기간이 없어야 한다.

그리고 재 로그인시에는 최초 발급된 tno를 통해 처리하도록 해야 중복되는 키값을 확인할 수 있고 서버가 탈취에 대한 대비를 할 수 있게 된다.

 

 

그럼 이제 아직 해결하지 못한 문제 하나는

이 tno를 어디다 저장할 것인가 이다.

쿠키의 경우 브라우저 설정에서 쿠키 삭제가 가능하다.

이걸 막을 수 있는 방법이 있는지는 아직 잘 모르겠고, 설정중에 브라우저 종료시마다 쿠키를 삭제하는 설정도 있었던 기억이 있다.

그럼 tno가 쿠키에 담기는 경우 이런 불상사가 일어날 수 있다.

그럼 localStorage에 저장해야 하나? 라는 생각을 했지만 

localStorage는 아무래도 js에서 접근하기가 수월해 이것도 애매하다.

이 문제는.....

어떻게 해결할지 아직 답이 없다..

 

 

 

정리

1. Redis에 RefreshToken만 담는 것 보다 AccessToken까지 담는것이 재발급 요청에서의 토큰 탈취에 대비하기 더 좋다.

    재발급 요청이 왔을 때 AccessToken의 만료기간을 체크해 아직 만료기간이 남아있다면 탈취된 토큰이라고 볼 수 있기 때문이다.

 

2. 토큰 탈취에 대비해 key값은 사용자 아이디 같은 사용자 고유의 정보가 포함되는 것이 좋다.

    탈취자가 사용자보다 먼저 재발급을 받는 경우에도 토큰을 수월하게 비교할 수 있기 때문이다.

 

3. 다중 로그인을 허용하기 위해서는 각 디바이스별, 각 브라우저별 고유값을 최초 로그인시에 부여하고 삭제되지 않도록 해 관리하는것이 좋다.

    고유값을 로그인마다 재 생성해 반환한다면 이전 고유값의 데이터와 토큰값이 탈취당한 경우 잡아낼 수 없다.

    단, 이 고유값을 클라이언트에서 어디에 어떻게 저장하고 관리하도록 할지에 대한 고민이 남아있다.

 

매번 MyBatis, JPA 만 쓰다가 이번에 Servlet&JSP로 구현하면서 JDBCTemplate을 처음 사용해봤다.

크게 어려운점도 없었고 문제도 없었지만 동적쿼리를 사용하는 점에서 고민이 있었다.

 

MyBatis나 JPA에서 동적쿼리를 사용하는데에 있어서는 처리 방안이 따로 마련되어 있다.

하지만 JDBCTempate의 경우 String 타입의 문자열로 쿼리를 작성해두고 사용하기 때문에 막상 동적쿼리를 사용하려고 하니 어? 하는 부분이 생겼다.

 

일단 사용하는 부분들은 insert 처리와 delete 처리였다.

INSERT INTO board(boardNo, boardTitle, boardContent)
VALUES(?, ?, ?)
,(?, ?, ?);

DELETE FROM board WHERE boardNo IN (?, ?);

동적쿼리가 필요한 만큼 value의 개수는 매번 달라질 수 있었기 때문에 방법이 필요했다.

 

일단 insert의 경우 List<DTO> 타입으로 받아와서 처리를 했고,

delete의 경우 List<Long> 타입으로 받아오도록 처리를 했다.

 

그럼 이 List의 size에 따라 value 부분이 조정되도록 처리해야 했다.

 

가장 먼저 든 생각은 반복문으로 필요한 부분을 추가해서 만들자는 것이었다.

JDBCTemplate은 문자열로 쿼리를 먼저 작성하니 원하는 만큼 반복해서 붙여넣으면 되지 않나? 라는 생각이었다.

 

근데 효율성에서 고민이 들었다.

그렇게 처리하고자 한다면 크게 봤을 때, 쿼리문을 만드는데 반복문 한번, PreparedStatment를 통해 값을 넣을 때 또 반복문 한번.

이렇게 두번의 반복문을 거쳐 처리할 필요가 있었다.

그리고 세부적으로 본다면 쿼리문을 만드는 반복문 안에서 , 의 위치를 맞춰서 넣어줄 수 있도록 조건문 역시 필요하겠다는 생각이 들었다.

 

그럼 반복문을 돌리면서 조건문도 체크하고 이거 끝나면 반복문이 또 돌아가야된다.

그래서 String 클래스에서 뭔가 방법이 있지 않을까 하고 찾아봤다.

 

역시 방법은 있었다.

찾은 방법은 String.join() 이었다.

전체적인 코드를 먼저 보면 아래와 같다.

//insert sql 
//List<DTO> insertDTO size = 3 

String insertSQL = "INSERT INTO board(boardNo, boardTitle, boardContent) VALUES";
String valueSQL = String.join(",", Collections.nCopies(insertDTO.size(), "(?, ?, ?)"));
String insertSQL = insertSQL.concat(valueSQL);
/*
insertSQL = "INSERT INTO board(boardNo, boardTitle, boardContent)
VALUES(?, ?, ?)
,(?, ?, ?)
,(?, ?, ?)"
*/


//delete sql
//list<Long> deleteNoList size = 3 

String deleteSQL = "DELETE FROM board WHERE boardNo IN (%s)";
String valueSQL = String.join(",", Collections.nCopies(deleteNoList.size(), "?"));
String deleteSQL = String.format(deleteSQL, valueSQL);

/*
deleteSQL = "DELETE FROM board WEHRE boardNo IN (?, ?, ?)"
*/

String.join은 (추가 문자열 사이에 들어갈 문자열, 추가하고자 하는 문자열의 리스트 혹은 배열) 형태로 처리한다.

insert 처리의 valueSQL을 나눠서 보면 ","과 Collections~~~로 나눠서 볼 수 있다.

그럼 추가 문자열 사이에 ' , '를 넣겠다는 것이고, 추가 문자열은 insertDTO.size 만큼의 "(?, ?, ?)"가 들어있는 리스트라는 것이다.

그럼 insertDTO의 size를 3으로 가정한 만큼 (?, ?, ?) (?, ?, ?) (?, ?, ?) 이렇게 세개의 값을 가진 리스트가 생성이 되는것이다.

그리고 join을 통해 저 값 사이사이에 ' , '가 들어가게 되어 (?, ?, ?), (?, ?, ?), (?, ?, ?)라는 문자열이 완성이 된다.

 

delete의 경우도 마찬가지이다. insert와 구조적인 차이는 있지만 ? ? ? 라는 세개의 값을 가진 리스트를 만든 것이고,

이 세개의 값 사이사이에 ' , '를 넣어 문자열을 만들었기 때문에 ?,?,?라는 문자열이 만들어지게 된다.

 

이후 처리에서는 insert와 delete가 다른 방법으로 처리된다.

concat()과 format()으로 처리한 경우인데 일단 format부터 보자면 printf와 같은 원리다.

deleteSQL을 보면 IN 절 뒤에 (%s)가 들어가게 된다.

그래서 format을 통해 deleteSQL의 %s 부분에 valueSQL이 들어가게 되고

IN (?,?,?) 형태로 문자열이 완성될 수 있는것이다.

printf 로 보자면 System.out.printf("~~~IN (%s)", valueSQL); 이렇게 볼 수 있다.

 

concat은 문자열 뒤에 문자열을 추가해준다.

그래서 VALUES 까지만 작성된 insertSQL뒤에 필요했던 (?, ?, ?), (?, ?, ?), (?, ?, ?) 형태로 문자열을 완성할 수 있는것이다.

 

concat의 경우는 조금 더 알아야 할 점이 있다.

언뜻 보기에는 StringBuilder에서 append와 동일해 보일 수 있다.

하지만 차이는 있다.

 

가장 먼저 concat()에서 기존 문자열에 null은 들어갈 수 없다.

기존 문자열이 null인 경우에는 NullPointerException이 발생하게 된다.

그리고 기존 문자열과 concat 수행 후 문자열의 주소값이 달라진다.

Hello와 World를 concat으로 연결해준다고 가정했을 때

예를 들어 Hello의 주소값이 100이었다면 HelloWorld로 concat을 수행한 뒤에는 200으로 변경된다.

 

반면 StringBuilder는 기존값이 null이어도 정상적으로 수행한다.

단, null뒤에 append를 통해 World를 붙이게 된다면 World가 되는것이 아닌 nullWorld가 된다.

이점은 주의해야 한다.

그리고 StringBuilder의 경우는 주소값이 변경되지 않는다.

 

문자열을 합칠 때 + 연산자도 많이 사용하게 되는데 이 + 연산자는 자바 버전에 따라 다르다고 한다.

1.5버전을 기준으로 하는데 1.5 이전에는 concat과 같은 처리였지만 1.5 이후에는 StringBuilder와 같은 처리가 된다고 한다.

 

 

Reference

https://devdy.tistory.com/9

 

[JAVA] 문자열 붙이는 방식의 차이(concat, +, StringBuilder)

자바에서 String타입을 붙일 때 사용하는 방법은 다양하다. 기본 연산자인 +를 비롯하여 String Builder, concat 모두 들어보거나 써본 용어일 것이다. 근데 동작 방식에 어떤 차이가 있을까? 먼저 결과

devdy.tistory.com

 

Spring에서는 Ajax 요청 후 응답을 받고자 하면 보통 컨트롤러에서 해당 메소드에 @ResponseEntity Annotation을 달아놓고 return 으로 반환하면 잘 받는다.

 

하지만 Servlet에는 @ResponseEntity Annotation이 없으니 다르게 리턴해야 했다.

 

요청을 처리 후 결과는 PrintWriter로 처리하면 응답을 보낼 수 있었다.

@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
                                             throws ServletException, IOException {
    
    String result = boardService.delete(request);
    
    PrintWriter out = response.getWriter();
    out.print(result);
}

 

자 여기서 문제점.

String 타입으로 응답을 받고자 했고 정상적으로 처리가 된 경우 "success"를 받고 문제가 발생한 경우에는 "fail"을 받고자 했었다.

Ajax에서는 if(result == "success")로 처리를 하고 있었다.

근데 여기서 해당 조건에 걸리질 않았다.

 

ajax에서 console로 찍어봐도 success를 출력하는데 전혀 조건에 걸리질 않았다.

javaScript에서 문자열 비교할 때 다른 방법이 있나 찾아보고 다 해봤지만 하나도 안됐다.

 

문제는 PrintWriter 처리에서 있었다.

out.print(result)로 보내야 했지만 코드를 마저 다 쓰는것이 귀찮았던지라 자동완성으로 out.println(result)를 찍었던것..

 

out.print로 수정하니 정상적으로 잘 체크할 수 있었다.

 

정확하게 아! 이거때문에! 라고 할 순 없지만 아마 내 생각에는

print의 경우 "success"로 넘어왔을 것이고 개행을 하는 println의 경우는 "success\n"으로 넘어와서 그 차이에 비교를 할 수 없었던 것 아닐까 싶다.

 

 

 

정리하는김에 getJSON 요청에 대한 처리도 정리.

jQuery를 통해 getJSON 요청을 보내 데이터를 파싱하고 처리하는 부분이 있었다.

이것 역시 Spring에서는 ResponseEntity로 리턴을 해 쉽게 처리했었다.

Servlet에서는 역시 ResponseEntity가 없다..

 

그럼 이걸 처리하기 위해서는 json으로 변환을 해줘야 할 필요가 있었고, 그 데이터를 보내줘야 했다.

json 변환을 처리하기 위해서는 gson 라이브러리를 사용했고 코드는 아래와 같이 처리했다.

 

@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
                                             throws ServletException, IOException {
    CommentDTO dto = commentService.getComment(request);
    
    response.setContentType("application/json");
    response.setCharacterEncoding("UTF-8");
    
    String gson = new GsonBuilder().setDateFormat("yyyy-mm-dd").create().toJson(dto);
    
    response.getWriter().write(gson);
}

일단 gson 처리에서 문제가 있다.

이 프로젝트에서 Date 구조의 경우 yyyy-mm-dd 구조로 데이터베이스에 담겨있고 이 구조대로 출력이 된다.

하지만 gson으로 변환하면 Date 구조가 00월 00일 00년 형태로 지맘대로 바꿔버린다.

그래서 변환을 할 때 setDateFormat으로 설정해줄 필요가 있다.

 

그리고 응답의 경우 print() 가 아닌 write()로 응답하면 된다.

 

+ Recent posts