240624 Spring 심화 - 과제(2), 챌린지반 - Batch

노재원·2024년 6월 24일
0

내일배움캠프

목록 보기
67/90

Pagination + Fetch join

Fetch join에는 Pageable 쓰지 말라는 내용을 이전에 2단계로 구성하면 될 것 같다고 공부한 적 있는데 오늘은 과제에 복잡한 쿼리 구성이 나온 김에 QueryDSL을 다뤄봤다.

// arg pageable: Pageable
val postIds = queryFactory.select(post.id)
    .from(post)
    .offset(pageable.offset)
    .limit(pageable.pageSize.toLong())
    .fetch()
    
if (postIds.isEmpty()) {
    return PageImpl(emptyList(), pageable, 0L)
}

1단계로 Pagination만 적용한 id 리스트를 불러온다. 불러올 수 없으면 즉시 반환해서 리소스 낭비를 줄였다.

val postList = queryFactory.selectFrom(post)
    .where(post.id.`in`(postIds))
    .leftJoin(post.user, user)
    .fetchJoin()
    .orderBy(*getOrderSpecifiers(pageable))
    .fetch()
    
val totalCount = queryFactory.select(post.count())
    .from(post)
    .fetchOne()
    ?: 0L
    
return PageImpl(postList, pageable, totalCount)

2단계로 불러온 id 목록만큼만 조회를 했다. 이렇게 하면 Pagination + Fetch join까지 되는 효과를 누릴 수 있다. 경고문도 발생하지 않는다.

이게 효과적인지는 애매한데 Fetch join을 사용하려면 이렇게 하는게 맞다고 나는 결론을 내렸었다. Batch size를 정하는 방법도 여전히 존재하긴 하지만 뭔가 편법같은 느낌이 존재해서 2단계 쿼리로 구성했는데 N+1이 자연스럽게 남는 결과라 좀 애매한 측면이 있다.

검색 기능

검색 기능으로는 사이트들이 검색 타입, 검색 키워드를 어떻게 구분하는지 고민좀 하다가 searchType: String, keyword: String 을 쓰기로 했다.
Pagination + Fetch join은 그대로 적용했다.

val whereClause = when (searchType) {
    "title_content" -> post.title.contains(keyword).or(post.content.contains(keyword))
    "title" -> post.title.contains(keyword)
    "content" -> post.content.contains(keyword)
    else -> BooleanBuilder()
}

val postIds = queryFactory.select(post.id)
    .from(post)
    .where(whereClause)
    /*...*/

searchType을 기준으로 검색 키워드 설정을 해서 검색을 하게끔 만들었다. 솔직히 노하우가 부족하긴 한데 일단 결과는 성공적으로 나왔다. 대소문자 구분같은 세부적인 내용은 일단 뺐다.

필터링 기능

이번엔 searchCondition: MutableMap<String, String> 을 인자로 받는 Filter를 구현했다. 필터는 과제에 나온 대로 모두 구현했다.

// controller
@RequestParam(required = false) title: String?,
@RequestParam(required = false) tag: String?,
@RequestParam(required = false) category: String?,
@RequestParam(required = false) status: String?,
@RequestParam(required = false) daysAgo: Long?,

이 과정에서 category는 1:n, tag는 n:m으로 구성한다고 Entity를 구성하느라 시간이 좀 걸렸다.

// repository
val filteredBuilder = filteredBooleanBuilder(searchCondition)
/* 나머지 Pagination + Fetch join 위와 동일 */

private fun filteredBooleanBuilder(searchCondition: Map<String, String>): BooleanBuilder {
    val builder = BooleanBuilder()
    searchCondition["title"]?.let { builder.and(titleLike(it)) }
    searchCondition["category"]?.let { builder.and(categoryEq(it)) }
    searchCondition["tag"]?.let { builder.and(tagLike(it)) }
    searchCondition["status"]?.let { builder.and(stateEq(it)) }
    searchCondition["daysAgo"]?.let { builder.and(withInDays(it)) }
    return builder
}

private fun titleLike(title: String): BooleanExpression {
    return post.title.contains(title)
}

private fun categoryEq(category: String): BooleanExpression {
    return post.category.name.eq(category)
}

private fun tagLike(tag: String): BooleanExpression {
    return post.postTags.any().tag.name.contains(tag)
}

private fun stateEq(stateCode: String): BooleanExpression? {
    return try {
        val status = PostStatus.valueOf(stateCode)
        post.status.eq(status)
    } catch (e: IllegalArgumentException) {
        null
    }
}

private fun withInDays(daysAgo: String): BooleanExpression {
    val daysAgoDate = LocalDateTime.now().minusDays(daysAgo.toLong())
    val startDate = daysAgoDate.let { LocalDateTime.of(it.year, it.month, it.dayOfMonth, 0, 0, 0) }
    val midnightDate = daysAgoDate.let { LocalDateTime.of(it.year, it.month, it.dayOfMonth, 23, 59, 59) }
    return post.createdAt.between(startDate, midnightDate)
}

함수의 구성 방식은 과제 견본으로 나온대로 진행했다. 동적 쿼리 구성 형식으로 ?.let을 사용하는 흔한 경우였고 구성하는데 크게 어려울 건 없었지만 enum은 에러에 대한 처리도 겸해야 해서 조금 더 길어졌고

N일 전 에 해당하는 게시글 필터링은 before를 사용한 N일 이전으로 구현했다가 정확히 N일 전으로 체크하려면 어떻게 할까 알아보다가 00:00 ~ 23:59 까지 쓰는 레퍼런스를 주워봐서 한번 구현해봤다.
굳이 이렇게 할 필요는 없어보이긴 하는데 옛날 앱 클라이언트 구현하던 시절 생각나는 것 같다.

Post 테스트용 ApplicationRunner

@Bean
@Order(1)
fun defaultTagList() = ApplicationRunner {
    val tagList = listOf(/*name*/)
    tagRepository.saveAll(tagList.map { Tag(name = it) })
}

@Bean
@Order(2)
fun defaultCategoryList() = ApplicationRunner {
    val categories = listOf(/*name*/)
    categoryRepository.saveAll(categories.map { Category(name = it) })
}

@Bean
@Order(3)
fun defaultPostList() = ApplicationRunner {
    val tagList = tagRepository.findAll()
    val testUser = userRepository.save(/*user*/)
    
    val posts = (1..10).map {
        val randomCategory = categoryRepository.findAll().shuffled().first()
        Post(
            title = "Post $it",
            content = "Content $it",
            user = testUser,
            category = randomCategory,
        )
    }
    
    postRepository.saveAll(posts)
    
    val postTagList = posts.flatMap { post ->
        val randomTag = tagList.shuffled().take(2)
        randomTag.map {
            PostTag(
                post = post,
                tag = it
            )
        }
    }
    
    postTagRepository.saveAll(postTagList)
}

이제 Post에 달린 Category, Tag 모두 테스트해봐야 했기에 테스트 데이터가 필요해서 ApplicationRunner를 사용해 구축했다. Repository의 적용 순서도 중요해졌기 때문에 순서를 정해주는 @Order도 사용해봤다.

Category까지는 쉬웠지만 Post-PostTag-Tag 의 맵핑테이블 형식 다대다 구성은 처음이라 PostTag를 어떻게 다뤄야하나 고민을 조금 했던 것 같다. 개발 편의성을 생각해서 @ManyToMany 를 써볼까 하다가 섣불리 모르고 건드리면 항상 나중에 더 피곤했기에 가장 명시적인 구조로 가져왔다.

결과적으로 H2 Console에서 확인 결과 랜덤한 Category, Tag로 구성된 Post entity의 모습을 확인해볼 수 있었다.

조금 걸리는 점은 Post-PostTag와 PostTag-Tag의 관계가 양방향으로 설정되어 있다는 점인데 양방향이 아니면 너무 Repository 참조가 많아지거나 QueryDSL 비연관 Join으로 Tuple을 조회해야할 것 같아서 너무 복잡해질까봐 양방향을 채택하게 됐다. 개발 편의성으로는 정말 좋았다.

굳이 따지자면 Tag, Category의 추가 자체는 Post와 별개로 움직일 것이라 다행이지만 Post의 생성, 수정, 삭제의 측면에서 CascadeType.ALL로 설정시 삭제는 예상대로 이뤄질 것 같지만 생성 / 수정에 있어서는 Tag 처리를 잘 해야할 것이라 본다.

챌린지반 - Spring Scheduler 과제 코드리뷰

챌린지반은 Spring scheduler 과제를 다뤘었고 관련해서 코드리뷰를 해주시면서 팁을 주셨다.

  • @Scheduled 메소드가 붙는 계층은 비즈니스 로직과 별도로 떨어지기 때문에 서비스가 아니고 Presentation Layer가 되어야 한다고 하셨다.
    • Spring Scheduler가 아니라 Spring Batch + 외부 트리거로 바뀌었다면?
  • Scheduler의 작업이 너무 오래 걸려서 다음 작업이 시작되기 전에 끝나지 않았다면 처리를 고민해야 한다.
    1. 뒤쪽 스케쥴을 Skip한다.
    2. 동시에 작업을 진행시킨다.
    3. 에러를 발생시킨다
    • Scale-out 상황은 고민해봐놓고 이건 고민을 안해봤다
  • Scheduler 작업중 예외가 발생하면?
    1. 그냥 다음 스케쥴까지 대기
    2. 관리자가 수동 실행할 수 있는 API 제공
    3. 로직에 의한 자동 재처리
    • 이건 고민을 해봤었는데 Spring-retry를 통한 재처리가 가능하긴 하나 생각보다는 어렵게 느껴졌다
  • @Transactional 내에서 외부 API 호출시 주의할 점
    • 꼭 필요한 케이스가 아니면 지양하는게 좋다
    • DB Connection을 점유하면서 리소스 낭비, Long Transaction을 만들어내는 문제가 될 수 있다
  • 테스트 코드는 습관이다

Batch

배치성 어플리케이션은 개발자가 정의한 작업을 단발적으로 일괄 처리하는 Application을 의미한다.

배치성 어플리케이션을 빌드해 JAR, Docker Image등으로 생성하고 Triggering 해주는 방식이다. 한 번 실행하고 꺼지는 Windows Batch(.bat)과 같은 느낌이다.

API 호출을 통해 배치 작업을 처리하는 케이스도 존재한다.

배치 작업과 배치 어플리케이션은 분리해서 생각해야한다.
단발적으로 일괄적으로 처리하면 배치 작업이고 배치 어플리케이션은 빌드로 이루어진 결과물이라 생각하면 된다.

시나리오 예시를 들어주셨다.

  1. 매일 9시에 오늘의 할인 상품을 메일로 발송하고 싶다
  2. 15분에 한번 새로운 뉴스를 크롤링해 우리 DB에 적재하고 싶다
  3. 관리자가 버튼을 누르면 이벤트 참여자들을 대상으로 추첨해 쿠폰을 지급하고 싶다.

세 가지 모두 배치 작업이다.

시나리오만 봐도 알듯이 스케쥴 작업은 배치 작업이 아니고 배치 작업을 주로 스케쥴로 처리하는 것 뿐이다.

Spring Batch

Spring 환경에서 배치성 어플리케이션을 더 쉽게 개발할 수 있게 해주는 Framework다. Spring Batch가 준비해주는 요소는 다음과 같다.

Job

Spring Batch로 개발된 프로젝트에서 하나의 작업 단위를 가리킨다.

10개의 배치성 어플리케이션을 개발해야 한다면 10개의 Spring Batch 프로젝트가 필요한게 아니라 한 프로젝트에 10개의 Job을 만들면 된다. (나눠도 상관은 없긴하다)

JAR로 빌드했다면 하나의 JAR 파일 안에 N개의 Job이 들어간다고 보면 된다. 클라이언트도 job.name 으로 배치 작업을 진행시킨다.

Step

하나의 Job은 여러 개의 Step으로 이루어진다.

Step은 Method 하나 호출하는 방식처럼 독립적인 하나의 작업인 Tasklet 방식과 Chunk(N개씩 한번에 처리하는 단위) 단위로 동작하는 Reader / Processor(생략가능) / Writer 방식이 있다. 어떤 작업, 얼만큼을 처리할지에 따라 방식을 구분할 수 있다.

Chunk는 10만개의 데이터를 한번에 처리하는 방식보다 1000개씩 나누기 위해 쓴다고 생각할 수 있다. Chunk 단위로 @Transactional이 걸리므로 중간 Chunk에 해당하는 부분만 Rollback될 수도 있다. 해당 부분은 세부적인 전파 설정도 가능하다.

Step은 한가지 분기만 있지 않고 Flow라고 분기를 나눠서 진행시킬 수도 있다.

Spring Batch Meta Table

배치 작업에 대한 모니터링을 하기 위한 기본적인 메타 데이터 테이블이다. 이건 따로 찾아보는게 좋을 것 같은게 굉장히 테이블이 크고 내용이 많다.

해당 테이블은 자동 생성되어있진 않고 직접 DB에 생성해줘야 한다. 모니터링이 필요 없다면 Disable할 수도 있지만 배치 어플리케이션은 단발성이기 때문에 나중에 기록을 찾기 힘들어서 실무에서는 기본적으로 사용한다.

결제 시스템 대사 서비스 라이브코딩

대사: 기록된 내역을 비교 & 대조해서 데이터를 검증하고 일치시키는 일련의 과정

쇼핑몰의 결제 내역과 PG사의 정산 내역을 대조하는 서비스라고 생각하면 될 것 같다.

비즈니스 로직 시나리오 (하나의 Job)
1. 대사를 위한 별도 테이블 생성
대사를 했다는 내역도 남겨야 하고 불일치가 나면 관리자의 손길이 필요할 수 있다.
2. 대사를 하고자하는 날짜에 대한 정산내역을 통해 대사 테이블에 새로운 Row를 만든다.
3. 대사를 하고자하는 날짜에 대한 결제내역을 조회해 SUM한 후 대사테이블에 업데이트한다.
4. 대사 테이블의 정산 금액과 총 결제금액이 동일한지 검증한다.

해당 시나리오는 하나의 Step이 되면 좋을 것이다. 강의에서는 Step의 두가지 방식을 모두 다뤘다. 주로 다루는 데이터에 따라 결정됐다.

  • 정산내역을 통한 대사 기반 데이터 생성 => Tasklet
  • 결제내역을 조회해 대사 테이블에 업데이트 => Reader - Writer
    결제 내역은 굉장히 많을 수 있으므로 Chunk 기반으로 작성한다.
  • 대사 테이블 검증 후 상태변경 => Tasklet

여기까지 마무리하면 배치 어플리케이션을 누군가가 하루에 한 번 직접 실행해줘야 한다. 그렇기에 Triggering에 대한 처리까지 같이 해줘야 한다.

Job

  • Job 패키지는 도메인과 별도로 생성된다. 도메인에는 대사, 결제내역, 정산내역 도메인 딱 3가지만 있다고 가정한다.
  • @Configuration에 Job, Step에 대한 Bean들을 등록해준다.
  • Job에 대한 반환값은 Job이다.
  • Job의 매개변수 안에 Repository, Step들을 주입한다.
  • JobBuilder를 사용해 Job을 반환할 때 외부에서 이 Job을 Triggering할 것이기 때문에 name의 지정은 필수다.
  • JobBuilder.start(step).next(step).....build()의 형태로 구성된다.

Step

  • Step도 @Bean으로 생성해서 매개변수 안에 필요한 Repository를 주입받고 Tasklet으로 구현한다면 Tasklet을 주입한다. 그런데 Tasklet은 Tasklet을 상속받은 구현체로 진행하면 된다.
  • StepBuilder를 사용하고 Step에 대한 name도 지정해줘야 한다.
  • Reader - (Processor) - Writer의 구조면 Reader의 구현체와 Writer의 구현체가 필요하다. (Processor 선택)
  • .chunk<T: Entity, R: Entity>(Size, TxManager) 의 형태로 Reader, Writer에 쓸 타입을 결정한다.
  • 이후 .reader, .writer로 설정한다.
    • reader엔 JpaPagingItemReaderBuilder<T>를 통해 생성했다. queryString이 들어가게 되고 JPQL을 사용하게 된다.
    • writer는 ItemWriter를 상속받아 구현체로 만들었다.
      • write(chunk)를 상속받고 객체지향적인 느낌으로 chunk에 대한 처리만 이루어지면 된다.
  • 전체적으로 Step 자체가 하나의 객체가 되며 관심사의 분리가 이루어진다.

Triggering

최종적으로 실행할 때는 spring.batch.job.name="" 형태로 환경변수를 넣어주면 어플리케이션이 실행될 때 지정한 Job이 실행된다.

그리고 Job 내부의 Step을 확인하고 하나씩 실행하는 느낌이다.

외부에서 Triggering을 진행한다면 잘 구성하면 될 것 같다.


코드카타 - 프로그래머스 예상대진표

△△ 게임대회가 개최되었습니다. 이 대회는 N명이 참가하고, 토너먼트 형식으로 진행됩니다. N명의 참가자는 각각 1부터 N번을 차례대로 배정받습니다. 그리고, 1번↔2번, 3번↔4번, ... , N-1번↔N번의 참가자끼리 게임을 진행합니다. 각 게임에서 이긴 사람은 다음 라운드에 진출할 수 있습니다. 이때, 다음 라운드에 진출할 참가자의 번호는 다시 1번부터 N/2번을 차례대로 배정받습니다. 만약 1번↔2번 끼리 겨루는 게임에서 2번이 승리했다면 다음 라운드에서 1번을 부여받고, 3번↔4번에서 겨루는 게임에서 3번이 승리했다면 다음 라운드에서 2번을 부여받게 됩니다. 게임은 최종 한 명이 남을 때까지 진행됩니다.

이때, 처음 라운드에서 A번을 가진 참가자는 경쟁자로 생각하는 B번 참가자와 몇 번째 라운드에서 만나는지 궁금해졌습니다. 게임 참가자 수 N, 참가자 번호 A, 경쟁자 번호 B가 함수 solution의 매개변수로 주어질 때, 처음 라운드에서 A번을 가진 참가자는 경쟁자로 생각하는 B번 참가자와 몇 번째 라운드에서 만나는지 return 하는 solution 함수를 완성해 주세요. 단, A번 참가자와 B번 참가자는 서로 붙게 되기 전까지 항상 이긴다고 가정합니다.

문제 링크

fun solution(n: Int, a: Int, b: Int): Int {
    var answer = 0
    var first = a
    var second = b
    while (first != second) {
        answer += 1
        first = first / 2 + first % 2
        second = second / 2 + second % 2
    }
    return answer
}

처음엔 이진트리 풀이인가 싶어 자료구조 공부하게 생겼네 라고 생각했는데 숫자를 일단 손으로 계산해보기 시작하니깐 그냥 횟수만 대충 구하면 되는 문제 같아서 이렇게 풀게 됐다. 나누고 나머지를 더하는 과정에서 테스트를 좀 여러번 돌려보긴 했는데 문제 없이 제출에 성공했다.

제출하고 나서야 느꼈는데 n을 아예 안썼길래 이번에도 뭔가 문제가 있는 알고리즘이겠거니 해서 다른 사람들 풀이를 공부해보기로 했다. 그런데 의외로 n은 다른 사람들도 잘 쓰지 않았다.

fun solution2(n: Int, a: Int, b: Int): Int {
    var answer = 0
    var x = a
    var y = b
    while (x != y) {
        x = (x + 1) / 2
        y = (y + 1) / 2
        answer++
    }
    return answer
}

여기는 나머지를 연산하는게 아니라 + 1 처리를 해서 계산했는데 계산기 두들겨보니 문제가 없었다. +1을 해도 문제가 왜 없나 고민을 해봤는데 생각해보니 반올림의 원리였다. 결국 +1을 더해가며 /2를 하면 x, y가 짝수든 홀수든 /2를 한 값은 같은 결과를 도출하게 된다.

코드 실행 시간은 전혀 차이 없을 정도로 빠르지만 괜히 복잡한 코드가 된 것 같다.

그리고 가장 멋진 코드는 문제에서 유도하는 2진법을 최대한 활용한 코드였다.

fun solution3(n: Int, a: Int, b: Int) = ((a - 1) xor (b - 1)).toString(2).length

반복문도 없이 한방에 푸는 문제였는데 나도 처음엔 Math.abs(a-b)로 구해보려다가 횟수를 단박에 해결할 수가 없어서 평범한 반복문으로 풀었던 거였는데 아예 bit 연산자로 해결하는 방법이 있을 줄은 몰랐다.

물론 비트에 대해 잘 아는건 아니라서 이 풀이를 보고 문제의 예시인 8, 4, 7을 기준으로 생각을 해봤는데

4는 100, 7은 111 이므로 100 xor 111 을 한다 치면 xor은 두 수의 차이를 구하는 것이기에 (정확히는 두 비트가 다른 것만) 011 이 나온다.

여기까지는 Math.abs로 원하는 값을 똑같이 구할 수 있겠지만 -1 처리를 하면서 3, 6이 됐고 011110 이 되면서 xor은 101 이 됐는데 -1을 처리해준 이유는 숫자가 0 부터 시작해야 한다고 가정해야 자릿수 차이가 나지 않아서 그런 것 같다. 실제로 4, 16처럼 결과를 두면 length가 4, 5로 차이가 나게 된다.

어쨌든 101은 toString(2)로 바꾸면 "101" 이 될 것이고 이 길이를 구하면 3글자가 된다. 신기한 방식이고 이게 알고리즘적으로 정말 문제 없나 확신을 할 수 없는데 제출을 보면 참 신기했다.

그래서 이 풀이는 둘 다 3글자인 경우의 풀이니 319 의 연산을 해봤는데
a-1 = 2, b-1 = 18 => 10 xor 10010 => 10000 => lenght: 5로 5라운드 후에 만남을 알 수 있다.

0개의 댓글