본문 바로가기

개발새발 개발자/기타

휴면 처리를 하시겠읍니까, 휴먼? - 2

이번 편은 당일 기준으로 접속한 지 1년이 된 유저를 휴면 상태로 전환하는 로직에 대해 공유하려 한다.

3. 휴면 유저 전환

1편에서 설명했듯 하나의 Job은 여러 Step으로 구성된다. 그리고 이 Step은 Reader, Processor, Writer가 된다. 이 프로젝트에서는 휴면 유저를 선별하는 코드를 Reader에, 휴면 상태로 전환해서 저장하는 부분은 Writer에 구현한다.

 

@Configuration
@RequiredArgsConstructor
public class ConvertToDormantUserBatchConfig {

    ...

    @Bean
    public Job convertToDormantUserJob() {
        return jobBuilderFactory.get(JOB)
                .preventRestart()
                .incrementer(new DailyIncrementer())
                .listener(executionLoggingListener)
                .start(convertToDormantUserJobStep())
                .build();
    }

    @Bean
    @JobScope
    public Step convertToDormantUserJobStep() {
        return stepBuilderFactory.get(JOB_STEP)
                .<InputObject, OutputObject>chunk(CHUNK_SIZE)
                .reader(convertToDormantUserReader())
                .writer(convertToDormantUserWriter())
                .listener(executionLoggingListener)
                .build();
    }

    @Bean
    @StepScope
    public ListItemReader<InputObject> convertToDormantUserReader() {
    	// user-common 모듈에 있는 도메인 로직으로 위임
        var results = findPreDormantUsers.execute();
        return new ListItemReader<>(results);
    }

    @Bean
    @StepScope
    public ItemWriter<OutputObject> convertToDormantUserWriter() {
    	// user-common 모듈에 있는 도메인 로직으로 위임
    	return results -> convertToDormantUser.execute(results)
    }
}

기본적인 Spring Batch 사용법만 알고 있다면 크게 어렵지 않다. 이 배치는 매일 밤 한 번씩 작동한다.

4. 기존 유저 마이그레이션

문제는, 당장 1년이 된 유저 뿐 아니라 그동안 1년이 훨씬 지나도록 접속하지 않은 기존 유저들에 대해서도 휴면 처리를 하는 과정이었다. 소급 적용할 대상은 무려 240만 명. 대용량의 데이터를 처리하느라 프러덕션에서 잘 동작하고 있는 서비스에 영향이 가면 안 되기 때문에 조심스러웠다.

 

일단 대용량이므로 이전과 같이 Spring Batch 애플리케이션을 통해 작업하기로 했다.

Q. 애플리케이션 로직 대신 DB 단에서 직접 처리하면 더 빠르게 할 수 있지 않나요?

여기엔 두 가지 문제가 있었다.

  1. 개발자가 직접 DB에서 작업하는 것은 위험할 수 있어서 관련 부서에 위임하도록 회사 정책적으로 막아놓고 있었는데, 해당 부서 직원분이 퇴사하셨다(...)
  2. 우리 서비스 DB뿐 아니라 서드 파티에 저장된 유저 정보도 휴면으로 수정해야 하는 니즈가 있었다. 즉, 어떻게든 애플리케이션 단에서 API 콜하는 로직은 필요했다.

대상 유저 조회

일단 마이그레이션 할 유저를 조회해보자. 유저 조회 조건은 다음과 같다.

  • activeStatus 컬럼이 null이거나 active이면서
  • loginAt 컬럼이 null이거나 마이그레이션 실행일로부터 1년 이상이 지난 유저

대상자는 약 240만명이므로 읽기 부하를 줄이기 위해 나눠서 불러오면 좋을 것 같다. 그럼 이전처럼 chunk를 사용하면 될까? 땡, 틀렸다! 그 이유는 휴면 처리 요구사항과 관련이 있다.

  • 기존 유저 테이블의 activeStatus 컬럼을 휴면으로 변경한다.

조회 조건에서 activeStatus 값으로 유저를 긁어오고 같은 컬럼을 업데이트하면 문제가 발생한다.

 

chunk을 1로 지정하고 유저를 조회하고 휴면 처리한다고 가정해보자. 보통의 경우 chunk와 page 사이즈는 같기 때문에 chunk 1개씩 불러온다면 페이지 1만큼 불러온다.

 

  1. reader에서 DB 1번 유저를 가져온다.
  2. 휴면으로 activeStatus를 업데이트 한다.
  3. writer에서 커밋한다.
  4. 다시 read할 때 DB에는 새롭게 반영된 순서로 저장되어 있다.
  5. 하지만 애플리케이션은 이미 페이지 1번을 처리했으므로 페이지 2번을 불러온다.
  6. 빵꾸가 생긴다!

그래서 이 작업은 페이지를 무조건 0으로 고정하고 조회하도록 할 필요가 있었다. (페이지는 항상 0부터 시작한다.)

?? : 어떻게 페이지를 고정하셨죠?

https://www.youtube.com/shorts/QfmYJW4Y0C4

그럼 페이지는 어떻게 고정할 수 있을까? ItemReader에는 다양한 구현체가 있는데 나는 JPA를 사용하고 있었으므로 JpaPagingItemReader를 확인해 보았다.

 

JpaPagingItemReader는 데이터를 읽을 때 doReadPage()를 호출한다. 이때 getPage() * getPageSize() 만큼 데이터를 불러오게 된다. 그렇다면! 이 getPage()를 0으로 고정하면 되겠구나!

 

AbstractPagingItemReader

모든 PagingItemReader는 AbstractPagingItemReader를 상속한다. AbstractPagingItemReader에 있는 이 getPage를 재정의(override)하면 될 것 같다.

    @Bean
    @StepScope
    public JpaPagingItemReader<User> migrateDormantUsersReader() {
        
        JpaPagingItemReader<User> reader = new JpaPagingItemReader<>() {
            @Override
            public int getPage() {
                return 0;
            }
        };
        
        reader.setName(READER);
        reader.setEntityManagerFactory(entityManagerFactory);
        reader.setPageSize(PAGE_SIZE);
        reader.setQueryString("select ...");
        
        ...
     }

이렇게 reader에 먼저 page 값을 고정해 준 뒤 쿼리를 세팅해 주면 정상적으로 동작한다.

휴면 유저로 전환

자, 이제 불러온 데이터를 휴면 상태로 바꾼 뒤 write 해보자.

 

    @Bean
    @StepScope
    public ItemWriter<User> migrateDormantUsersWriter() {
        
        ...
        
        return results;
    }

어라? 이상하다. activeStatus가 수정되지 않고 그대로다. 분명 reader에서 조회도 잘하고, activeStatus 값이 휴면으로 바뀌는 것도 디버거로 확인했는데 정작 DB에는 반영되지 않는다. 더티 체킹이 안된다!

 

JpaPagingItemReader

이유는 앞서 사용했던 JpaPagingItemReader에 있다. 아까 봤던 doReadPage()로 돌아가보자. if문의 transacted의 기본값은 true인데, read를 하고 나면 바로 커밋을 때려버린다. 엥,,,? 내가 알던 JPA 영속성 관리랑 다른데,,,?

 

JpaPagingItemReader

문서를 다시 읽어보니 이유가 있었다. 영속성 컨텍스트에서 많은 양을 퍼올리면 메모리 관리가 힘들기 때문에 각 페이지를 읽고 나면 바로 flush를 해버린다고 한다. 읽었던 엔티티가 모두 준영속 상태가 되는 것이다. 만약 엔티티의 변화를 다시 영속화하고 싶다면 명시적으로 merge를 하라고 한다...^^....

 

    @Bean
    @StepScope
    public ItemWriter<User> migrateDormantUsersWriter() {
        JpaItemWriter<User> writer = new JpaItemWriter<>() {
            @Override
            public void write(List<? extends User> users) {
                users.forEach(User::toBeDormant);
                userRepository.saveAll(users);
            }
        };
       
        return writer;
    }

제가 무슨 힘이 있나요. 시키는 대로 해야죠....😤 JpaItemWriter의 write() 메서드를 재정의 해서 save()를 직접 호출해 줬다. 일상적으로는 더티체킹으로 해결되는 것을 명시적으로 추가해주다 보니 딱히 마음에 들진 않지만 어쨌든 문제는 해결이다.

 

그 밖에도 자잘하게 다양한 이슈들이 있었지만 구구절절 설명하는 것보다 이 정도 선에서 마무리하는 것이 좋을 것 같다. 

5. 회고

다행히 초기에 몇 번 버그가 발생했던 것을 제외하면 지금까지 잘 동작하고 있다. 이번 프로젝트로 얻은 것은 아래와 같다.

 

  • 법적인 문제와 레거시 코드의 영향으로 PM과 긴밀하게 커뮤니케이션할 수 있는 기회를 얻었다. PM이 설명해 주는 법 내용은 내가 이해를 못 하고, 코드 이슈를 설명하면 PM이 이해를 못 해서 서로 차근차근 그림을 그려가며 눈높이 교육을 실천했다. 워낙 레거시가 많아서 초기에 예상치 못한 버그를 발견하곤 했는데, 서로 빠르게 소통하면서 이슈를 해결할 수 있었다.
  • Spring Batch에 대한 이해도도 높아졌다. 완전 처음 써보는 상황이었는데 국내/외 통틀어 자료가 많지 않아 무척 당황했었다. 공식 문서와 향로(jojoldu)님의 블로그를 제외하면 많은 부분은 혼자 삽질하며 알아가야 한다. 덕분에 Spring Batch를 개발한 멋쟁이 엔지니어들의 코드를 읽으면서 많이 배울 수 있었고 타인의 코드를 이해하기 위해 나름대로 다시 구조화해서 이해하는 연습도 할 수 있었다.

어떤 분들은 실낱같은 희망을 가지고 이 블로그에 들렸다가 답을 찾지 못하셨을 수도 있다. 일단은 공식 문서를 다시 꼼꼼히 읽고 코드를 파헤쳐보는 것을 추천한다. 진짜 이게 안된다고? 이게 없다고? 하며 포기하게 될 때쯤 분명 답이 나온다.

 

Spring Batch 개발을 맡은 여러분, 모두 화이팅이다!