<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>사부작사부작</title>
    <link>https://more-n.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 13 Apr 2026 20:46:47 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>민철킴</managingEditor>
    <item>
      <title>Custom ItemReader/ItemWriter 구현방법 알아보기</title>
      <link>https://more-n.tistory.com/58</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;진행중인 배치 스터디를 학습하며 작성한 글입니다.&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1733757942033&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;[SpringBatch 연재 09] 입맛에 맞는 배치 처리를 위한 Custom ItemReader/ItemWriter 구현방법 알아보기&quot; data-og-description=&quot; &quot; data-og-host=&quot;devocean.sk.com&quot; data-og-source-url=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=167030&quot; data-og-url=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=167030&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/lky8F/hyXGBoMt7t/HnIEL8AfelvCOVRFMfKB21/img.png?width=360&amp;amp;height=202&amp;amp;face=0_0_360_202,https://scrap.kakaocdn.net/dn/cKYm7F/hyXKnP03X5/tKRmdX8DlmWbkyeVZWtHX1/img.png?width=360&amp;amp;height=202&amp;amp;face=0_0_360_202,https://scrap.kakaocdn.net/dn/HLgAB/hyXKyxdSi5/X6QTEdnEPmtdBvaduEE5nK/img.png?width=720&amp;amp;height=403&amp;amp;face=0_0_720_403&quot;&gt;&lt;a href=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=167030&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=167030&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/lky8F/hyXGBoMt7t/HnIEL8AfelvCOVRFMfKB21/img.png?width=360&amp;amp;height=202&amp;amp;face=0_0_360_202,https://scrap.kakaocdn.net/dn/cKYm7F/hyXKnP03X5/tKRmdX8DlmWbkyeVZWtHX1/img.png?width=360&amp;amp;height=202&amp;amp;face=0_0_360_202,https://scrap.kakaocdn.net/dn/HLgAB/hyXKyxdSi5/X6QTEdnEPmtdBvaduEE5nK/img.png?width=720&amp;amp;height=403&amp;amp;face=0_0_720_403');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[SpringBatch 연재 09] 입맛에 맞는 배치 처리를 위한 Custom ItemReader/ItemWriter 구현방법 알아보기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;devocean.sk.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 배치가 ItemReader, ItemWriter를 제공해주지만, 보다 딱 맞는 배치를 수행하기 위해서는 커스터마이징이 필요합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;### Custom ItemReader&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;이번 학습에선 QueryDSL을 이용한 QueryDslPagingItemReader를 구현할 예정입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QueryDSL은 스프링 배치에서 공식적으로 지원하는 ItemReader가 아닙니다. 그렇기에 &lt;span style=&quot;color: #222222; text-align: left;&quot;&gt;AbstractPagingItemReader을 이용하여 커스텀 ItemReader를 만들어 볼 것입니다. Querydsl은 이용하면 데이터를 효율적으로 읽을 수 있으며 동적 쿼리 지원으로 런타임 시에 조건에 맞는 쿼리 생성이 가능해집니다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #222222; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: left;&quot;&gt;AbstractPagingItemReader 클래스를 먼저 보겠습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1733758701787&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public abstract class AbstractPagingItemReader&amp;lt;T&amp;gt; extends AbstractItemCountingItemStreamItemReader&amp;lt;T&amp;gt;
		implements InitializingBean {

	protected Log logger = LogFactory.getLog(getClass());

	private volatile boolean initialized = false;

	private int pageSize = 10;

	private volatile int current = 0;

	private volatile int page = 0;

	protected volatile List&amp;lt;T&amp;gt; results;

	private final Lock lock = new ReentrantLock();

	public AbstractPagingItemReader() {
		setName(ClassUtils.getShortName(AbstractPagingItemReader.class));
	}

	@Nullable
	@Override
	protected T doRead() throws Exception {

		this.lock.lock();
		try {

			if (results == null || current &amp;gt;= pageSize) {

				if (logger.isDebugEnabled()) {
					logger.debug(&quot;Reading page &quot; + getPage());
				}

				doReadPage();
				page++;
				if (current &amp;gt;= pageSize) {
					current = 0;
				}

			}

			int next = current++;
			if (next &amp;lt; results.size()) {
				return results.get(next);
			}
			else {
				return null;
			}

		}
		finally {
			this.lock.unlock();
		}

	}

	...	

	abstract protected void doReadPage();

	...

	@Override
	protected void doClose() throws Exception {

		this.lock.lock();
		try {
			initialized = false;
			current = 0;
			page = 0;
			results = null;
		}
		finally {
			this.lock.unlock();
		}

	}

	...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 클래스를 상속받아 구현할 QueryDslPagingItemReader는 doRead() 메서드에서 호출중인 doReadPage() 를 구현해야 합니다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1733759061904&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class QuerydslPagingItemReader&amp;lt;T&amp;gt; extends AbstractPagingItemReader&amp;lt;T&amp;gt; {

    private EntityManager em;
    private final Function&amp;lt;JPAQueryFactory, JPAQuery&amp;lt;T&amp;gt;&amp;gt; querySupplier;
    private final Boolean alwaysReadFromZero;

    public QuerydslPagingItemReader(EntityManagerFactory entityManagerFactory, Function&amp;lt;JPAQueryFactory, JPAQuery&amp;lt;T&amp;gt;&amp;gt; querySupplier, int chunkSize) {
        this(ClassUtils.getShortName(QuerydslPagingItemReader.class), entityManagerFactory, querySupplier, chunkSize, false);
    }

    public QuerydslPagingItemReader(String name, EntityManagerFactory entityManagerFactory, Function&amp;lt;JPAQueryFactory, JPAQuery&amp;lt;T&amp;gt;&amp;gt; querySupplier, int chunkSize, Boolean alwaysReadFromZero) {
        super.setPageSize(chunkSize);
        setName(name);
        this.querySupplier = querySupplier;
        this.em = entityManagerFactory.createEntityManager();
        this.alwaysReadFromZero = alwaysReadFromZero;
    }

    @Override
    protected void doClose() throws Exception {
        if (em != null)
            em.close();
        super.doClose();
    }

    @Override
    protected void doReadPage() {
        initQueryResult();
        
        JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(em);
        long offset = 0;
        if (!alwaysReadFromZero) {
            offset = (long) getPage() * getPageSize();
        }

        JPAQuery&amp;lt;T&amp;gt; query = querySupplier.apply(jpaQueryFactory).offset(offset).limit(getPageSize());

        List&amp;lt;T&amp;gt; queryResult = query.fetch();
        for (T entity: queryResult) {
            em.detach(entity);
            results.add(entity);
        }
    }

    private void initQueryResult() {
        if (CollectionUtils.isEmpty(results)) {
            results = new CopyOnWriteArrayList&amp;lt;&amp;gt;();
        } else {
            results.clear();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성자를 보면 chunkSize는 페이지 사이즈, 즉 한번에 읽어올 아이템 수를 나타내고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;name은 ItemReader를 구분하기 위한 이름입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: left;&quot;&gt;alwaysReadFromZero는 페이징을 0부터 시작할지 여부를 나타내는 플래그입니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: left;&quot;&gt;Function&amp;lt;JPAQueryFactory, JPAQuery&amp;gt;는&amp;nbsp;&lt;span style=&quot;color: #222222; text-align: left;&quot;&gt;JPAQuery 형태의 &lt;span style=&quot;color: #222222; text-align: left;&quot;&gt;queryDSL 쿼리를 반환합니다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1733759278254&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public QuerydslPagingItemReader(String name, EntityManagerFactory entityManagerFactory, Function&amp;lt;JPAQueryFactory, JPAQuery&amp;lt;T&amp;gt;&amp;gt; querySupplier, int chunkSize, Boolean alwaysReadFromZero) {
        super.setPageSize(chunkSize);
        setName(name);
        this.querySupplier = querySupplier;
        this.em = entityManagerFactory.createEntityManager();
        this.alwaysReadFromZero = alwaysReadFromZero;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;편의를 위한 빌더 클래스&lt;/p&gt;
&lt;pre id=&quot;code_1733759763466&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class QuerydslPagingItemReaderBuilder&amp;lt;T&amp;gt; {

    private EntityManagerFactory entityManagerFactory;
    private Function&amp;lt;JPAQueryFactory, JPAQuery&amp;lt;T&amp;gt;&amp;gt; querySupplier;

    private int chunkSize = 10;

    private String name;

    private Boolean alwaysReadFromZero;

    public QuerydslPagingItemReaderBuilder&amp;lt;T&amp;gt; entityManagerFactory(EntityManagerFactory entityManagerFactory) {
        this.entityManagerFactory = entityManagerFactory;
        return this;
    }

    public QuerydslPagingItemReaderBuilder&amp;lt;T&amp;gt; querySupplier(Function&amp;lt;JPAQueryFactory, JPAQuery&amp;lt;T&amp;gt;&amp;gt; querySupplier) {
        this.querySupplier = querySupplier;
        return this;
    }

    public QuerydslPagingItemReaderBuilder&amp;lt;T&amp;gt; chunkSize(int chunkSize) {
        this.chunkSize = chunkSize;
        return this;
    }

    public QuerydslPagingItemReaderBuilder&amp;lt;T&amp;gt; name(String name) {
        this.name = name;
        return this;
    }

    public QuerydslPagingItemReaderBuilder&amp;lt;T&amp;gt; alwaysReadFromZero(Boolean alwaysReadFromZero) {
        this.alwaysReadFromZero = alwaysReadFromZero;
        return this;
    }

    public QuerydslPagingItemReader&amp;lt;T&amp;gt; build() {
        if (name == null) {
            this.name = ClassUtils.getShortName(QuerydslPagingItemReader.class);
        }
        if (this.entityManagerFactory == null) {
            throw new IllegalArgumentException(&quot;EntityManagerFactory can not be null.!&quot;);
        }
        if (this.querySupplier == null) {
            throw new IllegalArgumentException(&quot;Function&amp;lt;JPAQueryFactory, JPAQuery&amp;lt;T&amp;gt;&amp;gt; can not be null.!&quot;);
        }
        if (this.alwaysReadFromZero == null) {
            alwaysReadFromZero = false;
        }
        return new QuerydslPagingItemReader&amp;lt;&amp;gt;(this.name, entityManagerFactory, querySupplier, chunkSize, alwaysReadFromZero);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;샘플 코드를 이용해서 확인해보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 코드와 처리하려는 데이터입니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1733802725254&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Configuration
public class QueryDSLPagingReaderJobConfig {

    /**
     * CHUNK 크기를 지정한다.
     */
    public static final int CHUNK_SIZE = 2;
    public static final String ENCODING = &quot;UTF-8&quot;;
    public static final String QUERYDSL_PAGING_CHUNK_JOB = &quot;QUERYDSL_PAGING_CHUNK_JOB&quot;;

    @Autowired
    DataSource dataSource;

    @Autowired
    EntityManagerFactory entityManagerFactory;

    @Bean
    public QuerydslPagingItemReader&amp;lt;Customer&amp;gt; customerQuerydslPagingItemReader() {
        return new QuerydslPagingItemReaderBuilder&amp;lt;Customer&amp;gt;()
                .name(&quot;customerQuerydslPagingItemReader&quot;)
                .entityManagerFactory(entityManagerFactory)
                .chunkSize(2)
                .querySupplier(jpaQueryFactory -&amp;gt;
                        jpaQueryFactory
                        .select(QCustomer.customer)
                        .from(QCustomer.customer)
                        .where(QCustomer.customer.age.gt(20))
                )
                .build();
    }

    @Bean
    public FlatFileItemWriter&amp;lt;Customer&amp;gt; customerQuerydslFlatFileItemWriter() {

        return new FlatFileItemWriterBuilder&amp;lt;Customer&amp;gt;()
                .name(&quot;customerQuerydslFlatFileItemWriter&quot;)
                .resource(new FileSystemResource(&quot;./output/customer_new_v2.csv&quot;))
                .encoding(ENCODING)
                .delimited().delimiter(&quot;\t&quot;)
                .names(&quot;Name&quot;, &quot;Age&quot;, &quot;Gender&quot;)
                .build();
    }


    @Bean
    public Step customerQuerydslPagingStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) throws Exception {
        log.info(&quot;------------------ Init customerQuerydslPagingStep -----------------&quot;);

        return new StepBuilder(&quot;customerJpaPagingStep&quot;, jobRepository)
                .&amp;lt;Customer, Customer&amp;gt;chunk(CHUNK_SIZE, transactionManager)
                .reader(customerQuerydslPagingItemReader())
                .processor(new CustomerItemProcessor())
                .writer(customerQuerydslFlatFileItemWriter())
                .build();
    }

    @Bean
    public Job customerJpaPagingJob(Step customerJdbcPagingStep, JobRepository jobRepository) {
        log.info(&quot;------------------ Init customerJpaPagingJob -----------------&quot;);
        return new JobBuilder(QUERYDSL_PAGING_CHUNK_JOB, jobRepository)
                .incrementer(new RunIdIncrementer())
                .start(customerJdbcPagingStep)
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.37.28.png&quot; data-origin-width=&quot;1180&quot; data-origin-height=&quot;386&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UGt19/btsLdm39lWd/zduhQXjcpk0A6fH17ZDfFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UGt19/btsLdm39lWd/zduhQXjcpk0A6fH17ZDfFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UGt19/btsLdm39lWd/zduhQXjcpk0A6fH17ZDfFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUGt19%2FbtsLdm39lWd%2FzduhQXjcpk0A6fH17ZDfFK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1180&quot; height=&quot;386&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.37.28.png&quot; data-origin-width=&quot;1180&quot; data-origin-height=&quot;386&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6개의 데이터가 있고 QueryDSL 을 이용해 where 절에 age가 20보다 큰 조건을 줬습니다. 그렇다면 5개의 데이터를 읽어와야 정확히 동작한다고 볼 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.37.49.png&quot; data-origin-width=&quot;2560&quot; data-origin-height=&quot;884&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ebEZHZ/btsLbXxDCCN/9YUsSKxCxAVO8kikiCBSh1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ebEZHZ/btsLbXxDCCN/9YUsSKxCxAVO8kikiCBSh1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ebEZHZ/btsLbXxDCCN/9YUsSKxCxAVO8kikiCBSh1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FebEZHZ%2FbtsLbXxDCCN%2F9YUsSKxCxAVO8kikiCBSh1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2560&quot; height=&quot;884&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.37.49.png&quot; data-origin-width=&quot;2560&quot; data-origin-height=&quot;884&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 로그를 보면 청크 사이즈, 즉 페이지 크기가 2인 만큼 해당 과정은 3번의 쿼리를 보내서 처리한걸 볼 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 결과를 봐도 5개의 데이터만 가져온걸 확인할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.50.56.png&quot; data-origin-width=&quot;492&quot; data-origin-height=&quot;214&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/E73TQ/btsLcX4S45r/9jn1HLgZmaWe0LXduzmxHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/E73TQ/btsLcX4S45r/9jn1HLgZmaWe0LXduzmxHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/E73TQ/btsLcX4S45r/9jn1HLgZmaWe0LXduzmxHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FE73TQ%2FbtsLcX4S45r%2F9jn1HLgZmaWe0LXduzmxHk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;492&quot; height=&quot;214&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.50.56.png&quot; data-origin-width=&quot;492&quot; data-origin-height=&quot;214&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 age 조건을 40보다 크게 변경해보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.42.18.png&quot; data-origin-width=&quot;2570&quot; data-origin-height=&quot;702&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rUx8q/btsLbRkci2O/pqTZ59kpFdIaZH4CjLlXc1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rUx8q/btsLbRkci2O/pqTZ59kpFdIaZH4CjLlXc1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rUx8q/btsLbRkci2O/pqTZ59kpFdIaZH4CjLlXc1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrUx8q%2FbtsLbRkci2O%2FpqTZ59kpFdIaZH4CjLlXc1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2570&quot; height=&quot;702&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.42.18.png&quot; data-origin-width=&quot;2570&quot; data-origin-height=&quot;702&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.50.05.png&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;52&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bByEwN/btsLdX3UuOP/PLGjASwkspkxL34b5V2yV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bByEwN/btsLdX3UuOP/PLGjASwkspkxL34b5V2yV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bByEwN/btsLdX3UuOP/PLGjASwkspkxL34b5V2yV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbByEwN%2FbtsLdX3UuOP%2FPLGjASwkspkxL34b5V2yV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;498&quot; height=&quot;52&quot; data-filename=&quot;스크린샷 2024-12-10 오후 12.50.05.png&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;52&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때는 하나의 데이터만 조건에 해당되니 쿼리를 한 번만 보내서 데이터를 읽어온 걸 확인할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;### Custom ItemWriter&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 비슷하게 CustomItemWriter 는 &lt;span style=&quot;color: #222222; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;ItemWriter 인터페이스를 구현한 클래스이다. 인터페이스가 가지는 write() 메서드를 유연하게 원하는 기능을 추가하여 구현하면 된다.&amp;nbsp;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1733819855485&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Service
public class CustomService {

    public Map&amp;lt;String, String&amp;gt; processToOtherService(Customer item) {

        log.info(&quot;Call API to OtherService....&quot;);

        return Map.of(&quot;code&quot;, &quot;200&quot;, &quot;message&quot;, &quot;OK&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1733819911932&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Component
public class CustomItemWriter implements ItemWriter&amp;lt;Customer&amp;gt; {

    private final CustomService customService;

    public CustomItemWriter(CustomService customService) {
        this.customService = customService;
    }

    @Override
    public void write(Chunk&amp;lt;? extends Customer&amp;gt; chunk) throws Exception {
        for (Customer customer: chunk) {
            log.info(&quot;Call Porcess in CustomItemWriter...&quot;);
            customService.processToOtherService(customer);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1733819952693&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Configuration
public class MybatisItemWriterJobConfig {

    /**
     * CHUNK 크기를 지정한다.
     */
    public static final int CHUNK_SIZE = 100;
    public static final String ENCODING = &quot;UTF-8&quot;;
    public static final String MY_BATIS_ITEM_WRITER = &quot;MY_BATIS_ITEM_WRITER&quot;;

    @Autowired
    DataSource dataSource;

    @Autowired
    SqlSessionFactory sqlSessionFactory;

    @Autowired
    CustomItemWriter customItemWriter;

    @Bean
    public FlatFileItemReader&amp;lt;Customer&amp;gt; flatFileItemReader() {

        return new FlatFileItemReaderBuilder&amp;lt;Customer&amp;gt;()
                .name(&quot;FlatFileItemReader&quot;)
                .resource(new ClassPathResource(&quot;./customer.csv&quot;))
                .encoding(ENCODING)
                .delimited().delimiter(&quot;,&quot;)
                .names(&quot;name&quot;, &quot;age&quot;, &quot;gender&quot;)
                .targetType(Customer.class)
                .build();
    }

    @Bean
    public Step flatFileStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
        log.info(&quot;------------------ Init flatFileStep -----------------&quot;);

        return new StepBuilder(&quot;flatFileStep&quot;, jobRepository)
                .&amp;lt;Customer, Customer&amp;gt;chunk(CHUNK_SIZE, transactionManager)
                .reader(flatFileItemReader())
                .writer(customItemWriter)
                .build();
    }

    @Bean
    public Job flatFileJob(Step flatFileStep, JobRepository jobRepository) {
        log.info(&quot;------------------ Init flatFileJob -----------------&quot;);
        return new JobBuilder(MY_BATIS_ITEM_WRITER, jobRepository)
                .incrementer(new RunIdIncrementer())
                .start(flatFileStep)
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단한 예제를 통해서 작동과정을 확인해 볼 수 있습니다.&lt;br /&gt;&lt;br /&gt;커스텀 writer의 사용성이 궁금해서 GPT에게 예시를 요청해봤습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1733820567478&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class RestApiItemWriter implements ItemWriter&amp;lt;MyData&amp;gt; {

    private final RestTemplate restTemplate;
    private final String apiEndpoint = &quot;https://api.example.com/data&quot;;

    @Autowired
    public RestApiItemWriter(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @Override
    public void write(List&amp;lt;? extends MyData&amp;gt; items) throws Exception {
        for (MyData item : items) {
            try {
                // 외부 API로 데이터 전송
                restTemplate.postForObject(apiEndpoint, item, Void.class);
                System.out.println(&quot;Successfully sent data: &quot; + item.getId());
            } catch (Exception e) {
                // 에러 핸들링 로직
                System.err.println(&quot;Failed to send data: &quot; + item.getId() + &quot; - &quot; + e.getMessage());
                // 필요에 따라 예외를 던져 배치 작업을 중단하거나 재시도할 수 있습니다.
                throw e;
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;read, process를 거친 데이터를 외부 api에 전송하는 코드입니다. 이 밖에도 이메일 전송, s3등 클라우드 스토리지에 쓰는 등의 예시를 알 수 있었습니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;</description>
      <category>스터디</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/58</guid>
      <comments>https://more-n.tistory.com/58#entry58comment</comments>
      <pubDate>Mon, 9 Dec 2024 23:51:42 +0900</pubDate>
    </item>
    <item>
      <title>JpaPagingItemReader, JpaItemWriter</title>
      <link>https://more-n.tistory.com/57</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=166902&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://devocean.sk.com/blog/techBoardDetail.do?ID=166902&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1731407920537&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;［SpringBatch 연재 06］ JpaPagingItemReader로 DB내용을 읽고, JpaItemWriter로 DB에 쓰기&quot; data-og-description=&quot; &quot; data-og-host=&quot;devocean.sk.com&quot; data-og-source-url=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=166902&quot; data-og-url=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=166902&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/f4J5J/hyXwlMnqA0/Rv1n28f6i3azV0Kz1TOK11/img.png?width=720&amp;amp;height=403&amp;amp;face=0_0_720_403,https://scrap.kakaocdn.net/dn/kUKtc/hyXwlZUhAQ/aMiV4VE1scuetkzWIgoDV0/img.png?width=720&amp;amp;height=403&amp;amp;face=0_0_720_403,https://scrap.kakaocdn.net/dn/lJey1/hyXwobgNjj/NsNsETJbLVlPJDNR77vkX1/img.png?width=720&amp;amp;height=403&amp;amp;face=0_0_720_403&quot;&gt;&lt;a href=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=166902&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=166902&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/f4J5J/hyXwlMnqA0/Rv1n28f6i3azV0Kz1TOK11/img.png?width=720&amp;amp;height=403&amp;amp;face=0_0_720_403,https://scrap.kakaocdn.net/dn/kUKtc/hyXwlZUhAQ/aMiV4VE1scuetkzWIgoDV0/img.png?width=720&amp;amp;height=403&amp;amp;face=0_0_720_403,https://scrap.kakaocdn.net/dn/lJey1/hyXwobgNjj/NsNsETJbLVlPJDNR77vkX1/img.png?width=720&amp;amp;height=403&amp;amp;face=0_0_720_403');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;［SpringBatch 연재 06］ JpaPagingItemReader로 DB내용을 읽고, JpaItemWriter로 DB에 쓰기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;devocean.sk.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 블로그 글을 기반으로 공부한 글입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;JpaPagingItemReader&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소개 : 해당 클래스는 스프링 배치에서 제공하는 ItemReader 구현체 중 하나입니다. JPA를 이용해서 데이터베이스로부터 페이징 단위로 데이터를 읽어올 때 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 구성 요소 :&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;EntityManagerFactory: JPA 엔티티 매니저 팩토리를 설정합니다.&lt;/li&gt;
&lt;li&gt;JpaQueryProvider: 데이터를 읽을 JPA 쿼리를 제공합니다.&lt;/li&gt;
&lt;li&gt;PageSize: 한 번에 읽어올 데이터의 개수 즉, 페이지 크기를 설정합니다.&lt;/li&gt;
&lt;li&gt;SkippableItemReader: 오류 발생 시 해당 아이템을 건너뛸 수 있도록 설정합니다.&lt;/li&gt;
&lt;li&gt;ReadListener: 읽기 시작, 종료, 오류 발생 등의 이벤트를 처리할 수 있도록 설정합니다.&lt;/li&gt;
&lt;li&gt;SaveStateCallback: 잡 중단 시 현재 상태를 저장하여 재시작 시 이어서 처리할 수 있도록 설정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특징 :&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;페이징 처리 : 지정된 페이지 크기만큼 나눠서 데이터를 읽어오기 때문에 메모리 사용량을 줄일 수 있습니다. 청크 크기와 한 번에 읽어올 페이지 크기를 같게 맞춰주는게 일반적입니다.&lt;/li&gt;
&lt;li&gt;JPQL, 네이티브 쿼리 : JPQL을 사용하여 엔티티 기반의 쿼리를 작성할 수도 있고, 네이티브 SQL 쿼리를 사용할 수도 있습니다.
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;jpaPagingItemReader.setQueryString(&quot;SELECT c FROM Customer c WHERE c.age &amp;gt; :age order by id desc&quot;);
jpaPagingItemReader.setParameterValues(Collections.singletonMap(&quot;age&quot;, 20));
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;네이티브 쿼리네이티브 쿼리를 사용할 경우 setUseNativeQuery(true) 설정을 해줘야 합니다. 여기서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;setPreparedStatementSetter()&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메서드는 파라미터에 값을 바인딩하는 방식을 설정합니다. 쿼리의 '?' 위치에 20 이라는 값을 바인딩하게 됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1731407814604&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;jpaPagingItemReader.setQueryString(&quot;SELECT * FROM customer WHERE age &amp;gt; ? ORDER BY id DESC&quot;); 
jpaPagingItemReader.setUseNativeQuery(true); 
jpaPagingItemReader.setPreparedStatementSetter(new ArgumentPreparedStatementSetter(new Object[]{20}));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실습 :&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단한 코드로 실습해 보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1731407994103&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
@Table(name = &quot;customer&quot;)
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;
    private int age;
    private String gender;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1731408215600&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Configuration
public class JpaPagingReaderJobConfig {

    /**
     * CHUNK 크기를 지정한다.
     */
    public static final int CHUNK_SIZE = 2;
    public static final String ENCODING = &quot;UTF-8&quot;;
    public static final String JPA_PAGING_CHUNK_JOB = &quot;JPA_PAGING_CHUNK_JOB&quot;;

    @Autowired
    DataSource dataSource;

    @Autowired
    EntityManagerFactory entityManagerFactory;

    @Bean
    public JpaPagingItemReader&amp;lt;Customer&amp;gt; customerJpaPagingItemReader() throws Exception {
        JpaPagingItemReader&amp;lt;Customer&amp;gt; jpaPagingItemReader = new JpaPagingItemReader&amp;lt;&amp;gt;();
        jpaPagingItemReader.setQueryString(
                &quot;SELECT c FROM Customer c WHERE c.age &amp;gt; :age order by id desc&quot;
        );
        jpaPagingItemReader.setEntityManagerFactory(entityManagerFactory);
        jpaPagingItemReader.setPageSize(CHUNK_SIZE);
        jpaPagingItemReader.setParameterValues(Collections.singletonMap(&quot;age&quot;, 20));
        return jpaPagingItemReader;
    }

    @Bean
    public FlatFileItemWriter&amp;lt;Customer&amp;gt; customerJpaFlatFileItemWriter() {

        return new FlatFileItemWriterBuilder&amp;lt;Customer&amp;gt;()
                .name(&quot;customerJpaFlatFileItemWriter&quot;)
                .resource(new FileSystemResource(&quot;./output/customer_new_v2.csv&quot;))
                .encoding(ENCODING)
                .delimited().delimiter(&quot;\t&quot;)
                .names(&quot;Name&quot;, &quot;Age&quot;, &quot;Gender&quot;)
                .build();
    }


    @Bean
    public Step customerJpaPagingStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) throws Exception {
        log.info(&quot;------------------ Init customerJpaPagingStep -----------------&quot;);

        return new StepBuilder(&quot;customerJpaPagingStep&quot;, jobRepository)
                .&amp;lt;Customer, Customer&amp;gt;chunk(CHUNK_SIZE, transactionManager)
                .reader(customerJpaPagingItemReader())
                .processor(new CustomerItemProcessor())
                .writer(customerJpaFlatFileItemWriter())
                .build();
    }

    @Bean
    public Job customerJpaPagingJob(Step customerJdbcPagingStep, JobRepository jobRepository) {
        log.info(&quot;------------------ Init customerJpaPagingJob -----------------&quot;);
        return new JobBuilder(JPA_PAGING_CHUNK_JOB, jobRepository)
                .incrementer(new RunIdIncrementer())
                .start(customerJdbcPagingStep)
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;db의 데이터와 로그들입니다. 나이가 20살이 넘는 고객만 조회해오기 때문에 2개의 데이터에 대한 로그만 존재합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-11-12 오전 12.27.52.png&quot; data-origin-width=&quot;3300&quot; data-origin-height=&quot;2604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c5a57S/btsKGJr387F/3Yvqahkk1I4QPM5UCjeVrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c5a57S/btsKGJr387F/3Yvqahkk1I4QPM5UCjeVrk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c5a57S/btsKGJr387F/3Yvqahkk1I4QPM5UCjeVrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc5a57S%2FbtsKGJr387F%2F3Yvqahkk1I4QPM5UCjeVrk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3300&quot; height=&quot;2604&quot; data-filename=&quot;스크린샷 2024-11-12 오전 12.27.52.png&quot; data-origin-width=&quot;3300&quot; data-origin-height=&quot;2604&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 결과로 생성된 csv 파일입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-11-12 오후 7.43.00.png&quot; data-origin-width=&quot;486&quot; data-origin-height=&quot;104&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bw9Kgj/btsKG8kEEsz/JSFDSQbhWjJnw4QRbHCef0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bw9Kgj/btsKG8kEEsz/JSFDSQbhWjJnw4QRbHCef0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bw9Kgj/btsKG8kEEsz/JSFDSQbhWjJnw4QRbHCef0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbw9Kgj%2FbtsKG8kEEsz%2FJSFDSQbhWjJnw4QRbHCef0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;486&quot; height=&quot;104&quot; data-filename=&quot;스크린샷 2024-11-12 오후 7.43.00.png&quot; data-origin-width=&quot;486&quot; data-origin-height=&quot;104&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;JpaItemWriter&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소개 : 해당 클래스는 스프링 배치에서 제공하는 ItemWriter의 구현체 중 하나입니다. JPA를 이용해서 데이터를 데이터베이스에 저장할 때 사용됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 구성 요소&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #222222; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;EntityManagerFactory: JPA EntityManager 생성을 위한 팩토리 객체&lt;/li&gt;
&lt;li&gt;JpaQueryProvider: 저장할 엔터티를 위한 JPA 쿼리를 생성하는 역할&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실습 :&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단한 코드로 실습해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSV 파일의 데이터를 읽어와, customer 테이블에 데이터를 저장해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어올 CSV 파일입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-11-12 오후 7.50.34.png&quot; data-origin-width=&quot;458&quot; data-origin-height=&quot;140&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ccqruM/btsKFFjJpjI/sPec348ayjLyayCeMQDOkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ccqruM/btsKFFjJpjI/sPec348ayjLyayCeMQDOkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ccqruM/btsKFFjJpjI/sPec348ayjLyayCeMQDOkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FccqruM%2FbtsKFFjJpjI%2FsPec348ayjLyayCeMQDOkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;458&quot; height=&quot;140&quot; data-filename=&quot;스크린샷 2024-11-12 오후 7.50.34.png&quot; data-origin-width=&quot;458&quot; data-origin-height=&quot;140&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1731408767247&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Configuration
public class JpaItemJobConfig {

    /**
     * CHUNK 크기를 지정한다.
     */
    public static final int CHUNK_SIZE = 200;
    public static final String ENCODING = &quot;UTF-8&quot;;
    public static final String JPA_ITEM_WRITER_JOB = &quot;JPA_ITEM_WRITER_JOB&quot;;

    @Autowired
    EntityManagerFactory entityManagerFactory;

    @Bean
    public FlatFileItemReader&amp;lt;Customer&amp;gt; flatFileItemReader() {

        return new FlatFileItemReaderBuilder&amp;lt;Customer&amp;gt;()
                .name(&quot;FlatFileItemReader&quot;)
                .resource(new ClassPathResource(&quot;./customer.csv&quot;))
                .encoding(ENCODING)
                .delimited().delimiter(&quot;,&quot;)
                .names(&quot;name&quot;, &quot;age&quot;, &quot;gender&quot;)
                .targetType(Customer.class)
                .build();
    }

    @Bean
    public JpaItemWriter&amp;lt;Customer&amp;gt; jpaItemWriter() {
        return new JpaItemWriterBuilder&amp;lt;Customer&amp;gt;()
                .entityManagerFactory(entityManagerFactory)
                .usePersist(true)
                .build();
    }


    @Bean
    public Step flatFileStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
        log.info(&quot;------------------ Init flatFileStep -----------------&quot;);

        return new StepBuilder(&quot;flatFileStep&quot;, jobRepository)
                .&amp;lt;Customer, Customer&amp;gt;chunk(CHUNK_SIZE, transactionManager)
                .reader(flatFileItemReader())
                .writer(jpaItemWriter())
                .build();
    }

    @Bean
    public Job flatFileJob(Step flatFileStep, JobRepository jobRepository) {
        log.info(&quot;------------------ Init flatFileJob -----------------&quot;);
        return new JobBuilder(JPA_ITEM_WRITER_JOB, jobRepository)
                .incrementer(new RunIdIncrementer())
                .start(flatFileStep)
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jpaItemWriter() 코드를 보시면 엔티티 매니저를 통해 아이템을 persist하는 것을 보실 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 customer 테이블의 결과를 보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-11-12 오전 1.12.15.png&quot; data-origin-width=&quot;1154&quot; data-origin-height=&quot;398&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5fqf1/btsKGYPYWYT/IPSNedGkWCILOkjbpDWiTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5fqf1/btsKGYPYWYT/IPSNedGkWCILOkjbpDWiTk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5fqf1/btsKGYPYWYT/IPSNedGkWCILOkjbpDWiTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5fqf1%2FbtsKGYPYWYT%2FIPSNedGkWCILOkjbpDWiTk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1154&quot; height=&quot;398&quot; data-filename=&quot;스크린샷 2024-11-12 오전 1.12.15.png&quot; data-origin-width=&quot;1154&quot; data-origin-height=&quot;398&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존의 데이터에 읽어온 CSV파일의 데이터가 추가된걸 확인할 수 있습니다.&amp;nbsp;&lt;/p&gt;</description>
      <category>스터디</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/57</guid>
      <comments>https://more-n.tistory.com/57#entry57comment</comments>
      <pubDate>Tue, 12 Nov 2024 17:37:32 +0900</pubDate>
    </item>
    <item>
      <title>JdbcPagingItemReader와 JdbcBatchItemWriter</title>
      <link>https://more-n.tistory.com/56</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JdbcPagingItemReader 클래스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 JdbcPagingItemReader와 flatfileItemReader의 상속 관계를 비교해보겠습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;### JdbcPagingItemReader&lt;/h4&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;AbstractItemStreamItemReader&amp;lt;T&amp;gt; (implements ItemStream, ItemReader&amp;lt;T&amp;gt;)
└── AbstractItemCountingItemStreamItemReader&amp;lt;T&amp;gt;
└── AbstractPagingItemReader&amp;lt;T&amp;gt;
└── JdbcPagingItemReader&amp;lt;T&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;### flatfileItemReader&lt;/h4&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;AbstractItemStreamItemReader&amp;lt;T&amp;gt; (implements ItemStream, ItemReader&amp;lt;T&amp;gt;)
└── AbstractItemCountingItemStreamItemReader&amp;lt;T&amp;gt;
└── FlatFileItemReader&amp;lt;T&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AbstractItemCountingItemStreamItemReader&amp;lt;T&amp;gt; 는 아이템 수를 카운트하고 기본적인 상태관리 기능을 제공해주는 클래스입니다. 이 클래스를 바로 상속받아 csv 등의 flat 파일 형태의 데이터를 읽어오는 책임을 담당하는 클래스가 flatfileItemReader&amp;lt;T&amp;gt; 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이와 비슷하게 AbstractPagingItemReader&amp;lt;T&amp;gt; 는 AbstractItemCountingItemStreamItemReader&amp;lt;T&amp;gt; 를 상속받아서 페이징 기능을 추가하여 대용량 데이터를 읽어오게 추상화한 클래스입니다. JdbcPagingItemReader&amp;lt;T&amp;gt;는 AbstractPagingItemReader&amp;lt;T&amp;gt; 클래스를 상속해서 JDBC를 통해 데이터베이스에서 페이징 단위로 데이터를 읽어오는 구체 클래스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단히 말하면 &lt;b&gt;JdbcPagingItemReader&amp;lt;T&amp;gt;는 JDBC를 통해 데이터를 페이징 단위로 읽어오는 역할&lt;/b&gt;을 담당하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;### JdbcPagingItemReader의 주요 구성요소&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DataSource: 데이터베이스 연결 정보를 설정한다.&lt;/li&gt;
&lt;li&gt;PagingQueryProvider: 데이터를 읽을 SQL 쿼리를 설정한다.&lt;/li&gt;
&lt;li&gt;RowMapper: SQL 쿼리 결과를 도메인 객체(Item)로 변환하는 역할을 한다.&lt;/li&gt;
&lt;li&gt;PageSize: 한 페이지에 포함될 아이템 수를 설정한다.&lt;/li&gt;
&lt;li&gt;FetchSize : 한 번에 읽어올 레코드 수를 지정하여 성능을 관리한다&lt;/li&gt;
&lt;li&gt;ReadListener: 읽기 시작, 종료, 오류 발생 등의 이벤트를 처리할 수 있도록 한다.&lt;/li&gt;
&lt;li&gt;SaveState : 리더의 상태를 저장할지 여부를 설정하여 배치 작업의 재시작 가능성을 지원한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;JdbcPagingItemReader 클래스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JdbcBatchItemWriter&amp;lt;T&amp;gt;는 Spring Batch에서 제공하는 ItemWriter 구현체입니다. 주로 데이터베이스에 대량의 데이터를 삽입하거나 업데이트할 때 사용합니다. JDBC를 기반으로 동작하며, SQL 문을 통해 데이터베이스에 데이터를 추가합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;### JdbcBatchItemWriter의 주요 구성요소&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DataSource: 데이터베이스 연결 정보를 지정한다.&lt;/li&gt;
&lt;li&gt;SqlStatementCreator: INSERT 쿼리를 생성하는 역할을 한다.&lt;/li&gt;
&lt;li&gt;PreparedStatementSetter: INSERT 쿼리의 파라미터 값을 설정하는 역할을 한다.&lt;/li&gt;
&lt;li&gt;ItemSqlParameterSourceProvider: Item 객체를 기반으로 PreparedStatementSetter에 전달할 파라미터 값을 생성하는 역할을 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>스터디</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/56</guid>
      <comments>https://more-n.tistory.com/56#entry56comment</comments>
      <pubDate>Tue, 5 Nov 2024 17:54:27 +0900</pubDate>
    </item>
    <item>
      <title>스프링 배치 3주차</title>
      <link>https://more-n.tistory.com/54</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주차의 스터디 주제는 Chunk Model 과 TaskletModel 입니다 .&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 들어가기 앞서 단어 뜻을 찾아보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-10-22 오전 12.10.22.png&quot; data-origin-width=&quot;456&quot; data-origin-height=&quot;230&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beD1Oq/btsKeWkMU5o/XHHtIP665n2AYIo988hyF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beD1Oq/btsKeWkMU5o/XHHtIP665n2AYIo988hyF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beD1Oq/btsKeWkMU5o/XHHtIP665n2AYIo988hyF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeD1Oq%2FbtsKeWkMU5o%2FXHHtIP665n2AYIo988hyF1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;456&quot; height=&quot;230&quot; data-filename=&quot;스크린샷 2024-10-22 오전 12.10.22.png&quot; data-origin-width=&quot;456&quot; data-origin-height=&quot;230&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-10-22 오전 12.17.18.png&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;162&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0GNzT/btsKePMNh7P/nNiarjR6gInrl0COg6kiiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0GNzT/btsKePMNh7P/nNiarjR6gInrl0COg6kiiK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0GNzT/btsKePMNh7P/nNiarjR6gInrl0COg6kiiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0GNzT%2FbtsKePMNh7P%2FnNiarjR6gInrl0COg6kiiK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;756&quot; height=&quot;94&quot; data-filename=&quot;스크린샷 2024-10-22 오전 12.17.18.png&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;162&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;chunk 는 &amp;lsquo;덩어리&amp;rsquo; 라는 뜻을 나타냅니다. tasklet 에는 한글 뜻이 없어서 번역해보면 &amp;lsquo;운영체제에서의 간단한 작업&amp;rsquo; 라고 해석할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 스터디의 주제인 batch 는 아래의 뜻을 가지고 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-10-22 오전 12.24.40.png&quot; data-origin-width=&quot;1182&quot; data-origin-height=&quot;376&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VYrbp/btsKeDzfBdZ/xTVTPo77uJqkJf3z9ojz80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VYrbp/btsKeDzfBdZ/xTVTPo77uJqkJf3z9ojz80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VYrbp/btsKeDzfBdZ/xTVTPo77uJqkJf3z9ojz80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVYrbp%2FbtsKeDzfBdZ%2FxTVTPo77uJqkJf3z9ojz80%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;744&quot; height=&quot;237&quot; data-filename=&quot;스크린샷 2024-10-22 오전 12.24.40.png&quot; data-origin-width=&quot;1182&quot; data-origin-height=&quot;376&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치는 &amp;lsquo;일괄적으로 묶은 한 회분&amp;rsquo; 의 뜻을 가지고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chunk의 뜻을 더 찾아보니 여기서 말하는 &amp;lsquo;덩어리&amp;rsquo;는 특정 큰 단위에서 나온 덩어리를 의미합니다. 단어 뜻 대로 청크모델은 배치 작업을 한 번에 모두 처리하지 않고 일정한 크기(청크)로 나누어 처리하는 방식입니다. 이에 반해 taskletModel은 단일 작업 단위로 배치를 수행하는 방밥입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단어 뜻에서도 느껴지듯이 스프링은 대규모 데이터를 다루기위한 ChunkModel과 간단한 배치작업을 위한 TaskletModel을 제공하고 있습니다. 이를 더 살펴 보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Chunk&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Batch의 Step은 Chunk, Tasklet 2가지 나눠지고 Chunk인 경우, ItemReader -&amp;gt; ItemProcessor -&amp;gt; ItemWriter로 프로세스가 진행됩니다. 개별 데이터 row 단위로 읽고 가공한 뒤, row를 덩어리로 모아서 쓰는 과정으로 진행됩니다. 해당 과정은 아래 그림과 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;chunk-oriented-processing-with-item-processor.png&quot; data-origin-width=&quot;924&quot; data-origin-height=&quot;545&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bziiTC/btsKfd8x7py/ykqRG7qejmYoT1kmNCEPD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bziiTC/btsKfd8x7py/ykqRG7qejmYoT1kmNCEPD0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bziiTC/btsKfd8x7py/ykqRG7qejmYoT1kmNCEPD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbziiTC%2FbtsKfd8x7py%2FykqRG7qejmYoT1kmNCEPD0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;924&quot; height=&quot;545&quot; data-filename=&quot;chunk-oriented-processing-with-item-processor.png&quot; data-origin-width=&quot;924&quot; data-origin-height=&quot;545&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에 대한 예시 수도 코드입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729583715184&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;List items = new Arraylist();
for(int i = 0; i &amp;lt; commitInterval; i++){
    Object item = itemReader.read();
    if (item != null) {
        items.add(item);
    }
}

List processedItems = new Arraylist();
for(Object item: items){
    Object processedItem = itemProcessor.process(item);
    if (processedItem != null) {
        processedItems.add(processedItem);
    }
}

itemWriter.write(processedItems);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 과정에서 사용되는 ItemReader, ItemProcessor, ItemWriter 구현체들은 특정한 용도와 상황에 맞게 설계되었습니다.&lt;br /&gt;예를 들면 ItemReader 의 경우 다양한 읽기 소스에 대한 구현체가 존재합니다. CSV, 텍스트 파일의 읽기용 구현체(FlatFileItemReader), Jpa페이징 방식 읽기용 구현체(JpaPagingItemReader) 등의 여러 구현체를 제공해줍니다. ItemProcessor의 경우엔 입력된 데이터를 확인하는 구현체(ValidatingItemProcessor), 여러 개의 프로세서를 조합해서 사용하는 구현체(CompositeItemProcessor) 등을 제공해줍니다. 또한 ItemWritrer 의 경우에도 CSV, 텍스트 파일의 데이터를 쓰는 구현체(FlatFileItemWriter), JPA를 사용해서 데이터를 쓰는 구현체(JpaItemWriter) 등이 존재합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Chunk 모델은 데이터를 나눠서 처리함으로써 여러 이점을 가져갑니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션 관리 및 재시도 용이&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치로 처리할 데이터가 아주 많은 상황을 생각해봅겠습다. 스프링 배치는 배치 작업 과정에서 트랜잭션을 관리해줍니다. 배치 처리 중에 오류가 발생해 작업이 진행된 수많은 데이터의 롤백이 발생할 수 있습니다. 이 때 데이터를 특정 단위로 나눠서 반복 처리를 한다면 안정적인 작업과 효율성을 보다 보장할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;성능 최적화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;청크 단위로 데이터를 읽고, 처리하고, 쓰는 과정을 병렬로 수행할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처리할 데이터의 규모 뿐만 아니라 처리할 상황도 어떤 배치 모델을 선택할지 고려사항 중 하나입니다. 만약 외부 api 호출이나 db 업데이트 sql 처리가 필요한 상황을 생각해봅시다. 이 때 reader, processor, writer 로 구성된 청크 모델을 사용한다면 writer는 작동하지 않게 됩니다. 그렇기에 간단한 작업이 필요한 경우에도 tasklet을 사용합니다.&lt;br /&gt;&lt;br /&gt;Tasklet&lt;/p&gt;
&lt;pre id=&quot;code_1729586394158&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package org.springframework.batch.core.step.tasklet;

import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.repeat.RepeatStatus;

/**
 * Callback interface to be implemented by classes that perform single tasks within a Step.
 */
public interface Tasklet {

    /**
     * Execute the task.
     *
     * @param contribution the current step execution context
     * @param chunkContext the current chunk context
     * @return the status indicating whether processing is finished or should continue
     * @throws Exception in case of any error that should stop the step
     */
    RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;execute 메서드는 Tasklet의 핵심 메서드로, 실제 작업 로직이 구현됩니다. 반환타입의 enum은 2가지 상태를 표시해줍니다.&lt;/p&gt;
&lt;pre id=&quot;code_1729586524212&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package org.springframework.batch.repeat;

public enum RepeatStatus {
    FINISHED,
    CONTINUABLE
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업이 완료되었음을 나타내는 FINISHED를 반환하거나, 추가적인 처리가 필요할 경우 CONTINUABLE을 반환하게 됩니다. 이때 반환값으로 CONTINUABLE을 반환하면 태스크는 재실행되어 반환값이 FINISHED가 나올때까지 반복하게 됩니다. 그렇기에 무한루프에 빠지지 않기 위해선 적절한 처리가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tasklet도 Chunk방식처럼 다양한 구현체를 제공하고 있습니다. 시스템 명령어를 실행하는 구현체(SystemCommandTasklet), 지정된 파일을 삭제하는 구현체( FileDeletingTasklet) 등이 존재합니다.&lt;/p&gt;</description>
      <category>스터디</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/54</guid>
      <comments>https://more-n.tistory.com/54#entry54comment</comments>
      <pubDate>Tue, 22 Oct 2024 01:05:38 +0900</pubDate>
    </item>
    <item>
      <title>스프링 배치 스터디 2주차</title>
      <link>https://more-n.tistory.com/52</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-10-15 오전 12.55.21.png&quot; data-origin-width=&quot;1690&quot; data-origin-height=&quot;808&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btejjz/btsJ5DsxDPb/qT3rMYjumK6ynjGKMv6Gik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btejjz/btsJ5DsxDPb/qT3rMYjumK6ynjGKMv6Gik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btejjz/btsJ5DsxDPb/qT3rMYjumK6ynjGKMv6Gik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbtejjz%2FbtsJ5DsxDPb%2FqT3rMYjumK6ynjGKMv6Gik%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1690&quot; height=&quot;808&quot; data-filename=&quot;스크린샷 2024-10-15 오전 12.55.21.png&quot; data-origin-width=&quot;1690&quot; data-origin-height=&quot;808&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치 아키텍처를 살펴보면 위와 같습니다. 이 구성 요소들을 포함하여 레이어 단위로 나눠서 간단히 알아보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;batch 레이어.jpg&quot; data-origin-width=&quot;325&quot; data-origin-height=&quot;155&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dNoLxJ/btsJ7oICxiG/44056imE7rcmVxnpBT5Gg0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dNoLxJ/btsJ7oICxiG/44056imE7rcmVxnpBT5Gg0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dNoLxJ/btsJ7oICxiG/44056imE7rcmVxnpBT5Gg0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdNoLxJ%2FbtsJ7oICxiG%2F44056imE7rcmVxnpBT5Gg0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;325&quot; height=&quot;155&quot; data-filename=&quot;batch 레이어.jpg&quot; data-origin-width=&quot;325&quot; data-origin-height=&quot;155&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. Application Layer&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;개발자가 직접 작성하는 부분으로, 배치 애플리케이션의 비즈니스 로직과 배치 작업의 흐름을 정의합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 구성 요소:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Job&lt;/b&gt;: 배치 작업의 전체 흐름을 정의하는 엔티티로, 하나 이상의 Step으로 구성됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Step&lt;/b&gt;: 배치 작업의 단위 실행을 나타내며, Tasklet 또는 Chunk 로 나눠집니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Tasklet&lt;/b&gt;: 단일 작업을 수행하는 경우에 사용합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Chunk&lt;/b&gt;: 대용량 데이터를 chunk(청크)로 나누어 처리하며, ItemReader, ItemProcessor, ItemWriter를 사용합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ItemReader&lt;/b&gt;: 데이터 읽기&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ItemProcessor&lt;/b&gt;: 데이터 가공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ItemWriter&lt;/b&gt;: 데이터 쓰기&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;역할:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;비즈니스 로직 구현&lt;/b&gt;: 배치 처리에 필요한 구체적인 로직을 작성합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;배치 작업 흐름 정의&lt;/b&gt;: Job과 Step의 순서 및 조건을 설정하여 전체 배치 프로세스를 구성합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. Batch Core Layer&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Batch의 핵심 기능을 제공하는 부분으로, 배치 작업 실행과 관리를 담당합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 구성 요소:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;JobLauncher&lt;/b&gt;: 배치 Job을 실행시키는 인터페이스로, Job을 시작할 때 사용합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JobRepository&lt;/b&gt;: Job과 Step의 메타데이터 및 실행 정보를 저장하고 관리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JobExecution&lt;/b&gt;: Job 실행의 상태와 결과를 나타내는 클래스입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;StepExecution&lt;/b&gt;: Step 실행의 상태와 결과를 나타냅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JobBuilder&lt;/b&gt;, &lt;b&gt;StepBuilder&lt;/b&gt;: Job과 Step을 생성하고 구성하는 빌더 클래스입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;역할:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;배치 작업 실행 제어&lt;/b&gt;: Job과 Step의 실행 흐름을 제어하고 관리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상태 관리&lt;/b&gt;: 실행 중인 Job과 Step의 상태 정보를 유지하여 재시도나 재시작 기능을 지원합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. Batch Infrastructure Layer&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;배치 처리에 필요한 인프라 기능을 제공합니다. 데이터베이스나 파일 시스템과의 데이터 입출력, 트랜잭션 관리 등을 담당합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 구성 요소:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;DataSource&lt;/b&gt;: 데이터베이스 연결을 관리하고 제공하는 구성 요소입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TransactionManager&lt;/b&gt;: 트랜잭션 경계를 정의하고 관리하여 데이터의 일관성을 유지합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JobRepository&lt;/b&gt;: Job과 Step의 메타데이터를 실제로 저장하고 조회하는 구현체로, 주로 데이터베이스를 사용합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Resource Management&lt;/b&gt;: 파일 시스템, 메시징 시스템 등 외부 리소스와의 연동을 지원합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Partitioner&lt;/b&gt;, &lt;b&gt;Remote Chunking&lt;/b&gt;: 분산 처리 및 병렬 처리를 지원하는 구성 요소입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;역할:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;외부 시스템과의 통합&lt;/b&gt;: 데이터베이스, 파일, 메시지 큐 등과 상호 작용하여 데이터를 읽고 씁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;트랜잭션 및 리소스 관리&lt;/b&gt;: 안정적인 배치 처리를 위해 필요한 인프라스트럭처 서비스를 제공합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-10-15 172616.png&quot; data-origin-width=&quot;1464&quot; data-origin-height=&quot;1023&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvRXeQ/btsJ5w87DlK/D9sdbGwTN1xVZWB5sQmFwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvRXeQ/btsJ5w87DlK/D9sdbGwTN1xVZWB5sQmFwk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvRXeQ/btsJ5w87DlK/D9sdbGwTN1xVZWB5sQmFwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvRXeQ%2FbtsJ5w87DlK%2FD9sdbGwTN1xVZWB5sQmFwk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1464&quot; height=&quot;1023&quot; data-filename=&quot;스크린샷 2024-10-15 172616.png&quot; data-origin-width=&quot;1464&quot; data-origin-height=&quot;1023&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 처리 흐름 그림에서 보이듯 Batch Core Layer 에 속하는 JobLauncher가 배치 Job을 실행시킵니다. 이 Job은 1:N 으로 구성된 Step을 실행시키게 됩니다. 이 Step은 tasklet 또는 chunk로 구성되며 그리에서는 chunk로 구성된 예시를 보여주고 있습니다. 이 Chunk들은 데이터를 읽고, 가공하고 쓰는 과정을 거치며 chunk는 기본적으로 반복해서 수행하게 됩니다. (Chunk와 tasklet에 대해서는 다음 글에서 더 자세히 다루겠습니다.) 해당 Job과 Step에 관한 정보는 JobRepository 인터페이스에 저장,관리됩니다. 위 그림에서보면 Application Layer는 Batch Core Layer의 기능을 사용해서 배치 작업을 정의하고 실행하고 있습니다. 그림에 자세히 나오진 않았지만 Batch Infrastructure Layer는 데이터베이스 접근, 트랜잭션 관리 등을 통해서 배치 처리에 필요한 인프라를 제공하고 있습니다. 이를 통해 레이어 구성요소의 관계를 통해 배치 처리 흐름을 파악할 수 있습니다.&lt;/p&gt;</description>
      <category>스터디</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/52</guid>
      <comments>https://more-n.tistory.com/52#entry52comment</comments>
      <pubDate>Mon, 14 Oct 2024 23:23:48 +0900</pubDate>
    </item>
    <item>
      <title>@OneToOne 관계에서 Lazyloading 이슈는 왜 발생할까</title>
      <link>https://more-n.tistory.com/19</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;틀린 부분이 존재할 수 있습니다. 감사합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;객체는 그래프로 연관된 객체들을 탐색한다. 그런데 객체가 데이터베이스에 저장되어 있으므로 연관된 객체를 마음껏 탐색하기는 어렵다 &amp;nbsp;- &amp;nbsp;김영한&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;JPA는 연관된 객체들을 탐색하는 것을 도와준다. JPA는 직접 쿼리를 날려서 연관된 객체의 데이터를 가져온다. 예를 들어 고객과 주문으로 이루어진 1:N 구조에서 1번 고객이 3건의 주문을 한 데이터가 있다고 하자. 이 상태에서 1번 고객에 연관된 주문 객체들을 가져올 때, 주문 3건에 대해 각각 쿼리를 날려서 가져온다. 그렇기에 흔히 말하는 N+1 쿼리가 발생한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 JPA는 왜 쿼리를 N번이나 날려서 연관된 객체들을 조회해올까? 왜 JPA는 조인을 통해 고객을 조회할 때 연관된 주문 데이터들을 가져오지 않을까?&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;만약 1번 고객의 전화번호 필드만 조회를 한다면 주문 데이터들은 미리 가져올 이유가 없다. 그렇기에 JPA는 지연로딩과 즉시로딩을 제공해 필요한 상황에 쿼리를 날려 연관된 객체들을 가져온다. 여기에 조인을 통해 연관된 객체들을 가져오는 페치 조인 방식도 제공해준다.&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;@OneToOne 관계에서 지연로딩을 살펴보자. Member와 Locker를 일대일 양방향 관계로 설정해뒀다. 연관관계의 주인은 Locker인 상태다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1687416397862&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToOne(mappedBy = &quot;member&quot;,  fetch = FetchType.LAZY)
    private Locker locker;
}

@Entity
public class Locker {

    @Id
    @GeneratedValue
    private Long id;

    private String description;

    @OneToOne(fetch = FetchType.LAZY)
    private Member member;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이 상태에서 먼저 Locker를 조회해보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;lockerRepository.findAll();&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1687416515929&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;lockerRepository.findAll();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-06-28 오후 10.44.37.png&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;322&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdxUZL/btslJ0y5LId/EHgeiuZQqBq0f3QuNCT2lk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdxUZL/btslJ0y5LId/EHgeiuZQqBq0f3QuNCT2lk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdxUZL/btslJ0y5LId/EHgeiuZQqBq0f3QuNCT2lk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdxUZL%2FbtslJ0y5LId%2FEHgeiuZQqBq0f3QuNCT2lk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;860&quot; height=&quot;322&quot; data-filename=&quot;스크린샷 2023-06-28 오후 10.44.37.png&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;322&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Locker를 조회했다. 그와 연관된 Member 객체는 지연로딩 설정을 해뒀기에 아직 쿼리를 보내지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1687416545398&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;memberRepository.findAll();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-06-28 오후 10.44.17.png&quot; data-origin-width=&quot;808&quot; data-origin-height=&quot;666&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b68lJB/btslJfDkVtR/3F6G14RtWChKHRSDsqPsw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b68lJB/btslJfDkVtR/3F6G14RtWChKHRSDsqPsw1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b68lJB/btslJfDkVtR/3F6G14RtWChKHRSDsqPsw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb68lJB%2FbtslJfDkVtR%2F3F6G14RtWChKHRSDsqPsw1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;808&quot; height=&quot;666&quot; data-filename=&quot;스크린샷 2023-06-28 오후 10.44.17.png&quot; data-origin-width=&quot;808&quot; data-origin-height=&quot;666&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이젠 반대의 Member를 조회한 경우다. 여기선 지연로딩 설정을 해둔 Locker를 지연로딩하지 않고 바로 쿼리를 날려서 Locker객체를 조회해오고 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;같은 양방향 관계인데 왜 차이가 날까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 지연로딩으로 적용된 필드 자리에는 무언가 대신 자리를 채우고 있어야한다. JPA는&lt;b&gt;&amp;nbsp;여기에 프록시 객체를 사용한다.&lt;/b&gt; JPA는 지연로딩으로 처리되는 객체들은 프록시 객체가 대신하고 있다가, 해당 객체들을 실제 사용하는 시점에 초기화시킨다. 여기서 초기화는 데이터베이스를 조회해서 영속성 컨텍스트에 실제 객체가 생성된다는 말이다. 초기화가 되도 프록시 객체를 통해서 실제 객체를 호출해서 결과를 반환하는 식으로 작동한다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 지연로딩을 할 객체가 실제 null이라면 어떨까? &lt;b&gt;실제 데이터가 null 이라면 프록시가 아니라 null이 들어가게 된다.&lt;/b&gt; 그렇기에 &lt;b&gt;JPA는 지연로딩할 객체의 자리에 null을 넣어야할지 프록시를 넣어야할지 선택의 순간이 온다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;양방향 관계에서 외래키를 가지고 있는 연관 관계의 주인 엔티티는 지연로딩으로 설정해둔 객체 필드에 null을 넣어줄지 프록시객체를 넣어줄지 알고 있다. 하지만 외래키가 존재하지 않는, 주인이 아닌 객체는 이 판단이 불가능하다. 그렇기에 위의 경우처럼 쿼리를 하나 더 보내서 연관된 엔티티를 조회하는 과정이 필요하다. 조회를 해서 null인 경우엔 null을 넣고, 객체가 있다면 이 때는 프록시를 만들지 않고 방금 조회한 실제 객체를 바로 사용한다. 그렇기에 &lt;b&gt;즉시 로딩처럼 작동한다.&lt;/b&gt; 이러한 이유로 @OneToOne 양방향 연관관계의 주인이 아닌 상황이라면 지연로딩을 적용해도 원하는 결과를 얻을 수 없다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;그렇다면 @OneToMany 컬렉션에서는 ?&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 연관 관계의 주인이 아닌 @OneToMany 컬렉션 필드도 지연로딩이 불가능할까? 하지만 그렇지 않다. 여기선 지연로딩이 가능하다. 왜냐면 데이터가 없는 컬렉션은 null이 아니라 Empty로 표현이 가능하기 때문이다. 그렇기에 empty collection proxy가 생성되고 지연로딩이 가능하다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(참고 :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;http://www.inflearn.com/questions/224187&quot;&gt;www.inflearn.com/questions/224187&lt;/a&gt;)&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;해결책&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;JPA는 N+1 문제에서 N번 발생할 쿼리를 묶어 보내는 방식으로 최적화를 도와준다. 그 옵션이 Batchsize 설정이다. 지정한 size만큼 in절에 묶어서 조회를 하는 식이다. 하지만 batchsize 를 걸어줘도 일대일 관계에서는 null인지 확인하는 과정이 필요하기에 조회쿼리는 여전히 발생했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 이젠 페치 조인을 적용시키서, 조인을 통해 연관된 객체를 가져오자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-06-29 오전 1.54.27.png&quot; data-origin-width=&quot;1144&quot; data-origin-height=&quot;270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cb2FZQ/btslJG135Nd/xAfYQXplnvwTTO6ZyEKm61/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cb2FZQ/btslJG135Nd/xAfYQXplnvwTTO6ZyEKm61/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cb2FZQ/btslJG135Nd/xAfYQXplnvwTTO6ZyEKm61/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcb2FZQ%2FbtslJG135Nd%2FxAfYQXplnvwTTO6ZyEKm61%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1144&quot; height=&quot;270&quot; data-filename=&quot;스크린샷 2023-06-29 오전 1.54.27.png&quot; data-origin-width=&quot;1144&quot; data-origin-height=&quot;270&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-06-29 오전 1.19.21.png&quot; data-origin-width=&quot;784&quot; data-origin-height=&quot;558&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/epOrdk/btslODhVUqu/BK67yS2n4ukEImK2Ibbmi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/epOrdk/btslODhVUqu/BK67yS2n4ukEImK2Ibbmi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/epOrdk/btslODhVUqu/BK67yS2n4ukEImK2Ibbmi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FepOrdk%2FbtslODhVUqu%2FBK67yS2n4ukEImK2Ibbmi1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;784&quot; height=&quot;558&quot; data-filename=&quot;스크린샷 2023-06-29 오전 1.19.21.png&quot; data-origin-width=&quot;784&quot; data-origin-height=&quot;558&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이처럼 페치조인을 이용하면 연관된 객체를 조인방식으로 가져올 수 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;JPA는 N번의 쿼리를 날리는 방식, 조인으로 가져오는 방식으로 연관된 객체를 가져올 수 있다.&lt;/li&gt;
&lt;li&gt;연관된 객체가 null일 수 있는 경우에는 프록시를 바로 만들 수 없어서, 연관된 객체를 탐색하는 쿼리가 발생한다. (즉시로딩처럼 동작하게 된다.)&lt;/li&gt;
&lt;li&gt;2번의 경우가 @OneToOne 양방향 연관관계에서 관계의 주인이 아닌 경우에 해당한다.&lt;/li&gt;
&lt;li&gt;이 경우에 페치조인을 걸어준다면, 연관된 객체를 탐색하는 쿼리를 줄일 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책 - &amp;lt;자바 ORM 표준 JPA 프로그래밍&amp;gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;http://www.inflearn.com/questions/224187&quot;&gt;www.inflearn.com/questions/224187&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>스프링</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/19</guid>
      <comments>https://more-n.tistory.com/19#entry19comment</comments>
      <pubDate>Thu, 22 Jun 2023 15:49:19 +0900</pubDate>
    </item>
    <item>
      <title>OSIV와 더티 체킹</title>
      <link>https://more-n.tistory.com/10</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;OSIV 를 default 값인 true인 상태로 개발을 하다가, false로 바꾸고 이에 따른 더티 체킹이 작동하지 않는 문제를 겪었습니다. OSIV에 대한 설명과 개선해 간 과정을 적은 글입니다. 틀린 부분이 있을 수 있습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;먼저 OSIV를 모르는 상태로 기능을 구현하는 중이였다. 그러다가 동욱님의 블로그 글(jojoldu.tistory.com/272)을 읽다가 OSIV라는 단어를 접했고, 이걸 false로 변경하기로 했다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;OSIV (Open-Session-In-View)&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;OSIV는 Hibernate 세션(DB작업을 수행하기 위한 논리적 트랜잭션 컨텍스트)을 뷰까지 열어두는 패턴을 의미한다. 스프링에서 이 패턴을 구현한 클래스로는 OpenSessionInViewInterceptor 클래스가 있다. 이 클래스의 인스턴스가 Spring MVC의 인터셉터 레지스트리에 등록되어 해당 패턴을 사용한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public void preHandle(WebRequest request) throws DataAccessException

Open a new Hibernate Session according and bind it to the thread via the TransactionSynchronizationManager.&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException 

Unbind the Hibernate Session from the thread and close it.&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공식문서를 보면 해당 클래스는 위의 메서드들을 가지고 있다. 풀어보면 클라이언트에서 요청이 도착하면 이 인터셉터는 Hibernate 세션을 시작한다. 기본적으로는 트랜잭션 시작되면 세션이 시작되고, 트랜잭션이 끝나면 세션도 종료된다. 하지만 이 패턴에서는 요청이 시작되는 순간부터 세션이 열리고 뷰 렌더링 시점까지 같은 세션이 유지되는 특징을 가진다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;baeldung 에서도 OSIV를 논란이 많고(Controversial) 양날의 검(double-edged sword)이라고 소개하고 있다. 그러면 왜 논란이 많은지 장단점을 알아보자.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.baeldung.com/spring-open-session-in-view&quot;&gt;https://www.baeldung.com/spring-open-session-in-view&lt;/a&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;OSIV On&amp;amp;Off 장단점&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;먼저 장점은 지연로딩이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프레젠테이션 레이어에서 연관 관계가 지연 로딩이 설정된 엔티티에 접근하는 경우에&lt;span&gt;&amp;nbsp;&lt;/span&gt;LazyInitializationException&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 발생한다. 영속성 컨텍스트가 끝난 상태에서 지연 로딩으로 생성된 프록시 객체에 접근할 경우, 이 프록시 객체가 데이터베이스에서 실제 객체의 데이터를 가져올 수 없기 때문에 해당 에러가 발생한다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;OSIV를 활성화시켜서 영속성 컨텍스트를 계속 유지한다면 이러한 에러 없이 지연로딩이 가능해진다.&lt;br /&gt;즉, 이 장점 때문에 사용하는 것이다. 뷰 렌더링 단계까지 세션을 열어두어 &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;LazyInitializationException 없이 지연로딩을 허용할 수 있기 때문에 OSIV를 사용한다.&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 단점은&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;여러 트랜잭션이 하나의 영속성 컨텍스트를 공유해서 사용한다는 점이다. 이로 인해 의도치 않은 데이터가 저장될 수 있다.&lt;/li&gt;
&lt;li&gt;한 번 획득한 DB 커넥션을 뷰 렌더링이 끝날 때까지 유지한다. 이로 인해 커넥션이 모자라 성능 이슈의 원인이 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이러한 단점들로 OSIV를 off 하기로 했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;하지만 문제가 발생했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
@Component
public class MemberArgumentResolver implements HandlerMethodArgumentResolver {
		
		// ...

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(LoginMember.class);
    }

    @Override
    public Object resolveArgument(
            MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);

        String token = authExtractor.extract(request);
        if (tokenService.getBlackList(token) != null) {
            return new InvalidAccessTokenException();
        }

        String oAuth = Optional.ofNullable(token)
                .filter(t -&amp;gt; jwtTokenProvider.validateToken(t))
                .map(t -&amp;gt; jwtTokenProvider.getPayload(t))
                .orElseThrow(InvalidAccessTokenException::new);

		Member member = memberRepository.findByOAuth(oAuth);
		return member;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;먼저 나는 accessToken에서 oauth를 꺼내오고 그걸로 Repository를 호출해서 멤버 엔티티를 조회해 @LoginMember 어노테이션에 바인딩해주고 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 이 어노테이션을 컨트롤러 파라미터로 사용중이다. 아래처럼 말이다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public ResponseEntity&amp;lt;?&amp;gt; changePosition(@LoginMember final Member member) {
	memberService.changePosition(member);
	...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;또한 서비스단에서 상태 변경을 더티 체킹에 의존하게 코드를 짰다. 하지만&amp;nbsp;OSIV 를 off 시키니 더티 체킹이 더 이상 이루어지지 않았다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;원인은 다음과 같다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;더티 체킹은 영속성 컨텍스트 안에서 엔티티들의 변경 사항을 감지하는 기술이다. 이 &lt;b&gt;영속성 컨텍스트에 해당하려면 2가지 경우에만 가능하다. 트랜잭션 범위 내에서 엔티티를 조회해 온 경우이거나 save 한 엔티티만 가능하다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;즉, 파라미터로 전달받은 엔티티는 영속성 컨텍스트에 들어가지 못한다. 그렇기에 변경 감지 대상이 아니고 더티 체킹은 이뤄지지 않는다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;OSIV가 활성화된 상태라면, 클라이언트의 요청 시작 시점부터 영속성 컨텍스트의 범위에 해당한다. 그렇기에 컨트롤러 단에서 넘겨받은 객체도 더티체킹이 가능했다. 그렇다면 OSIV가 off인 상황에서 어떻게 해결할 수 있을까?&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;더티 체킹 안 하기&lt;br /&gt;더티 체킹을 하지 않고 비지니스 로직이 끝나기 전에, 직접 JpaRepository의 save() 메서드를 호출할 수 있다. 하지만 비지니스 로직과 영속성 로직이 함께 존재하게 된다.&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000; letter-spacing: 0px;&quot;&gt;서비스단에서 엔티티 조회하기&lt;br /&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;ArgumentResolver에서 엔티티를 직접 조회해서 바인딩 시키지 않고, 엔티티를 조회할 수 있는 값으로 바인딩시킨다. 컨트롤러에서 해당 값을 서비스 로직으로 보내고 거기서 엔티티를 조회하게 바꾼다.&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;나는 2번째 방법을 선택했다.&lt;/p&gt;
&lt;pre id=&quot;code_1686140293904&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;String oAuth = Optional.ofNullable(token)
            .filter(t -&amp;gt; jwtTokenProvider.validateToken(t))
            .map(t -&amp;gt; jwtTokenProvider.getPayload(t))
            .orElseThrow(InvalidAccessTokenException::new);

return new LoginMemberDto(oAuth);&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;LoginMemberDto라는 oAuth 값만 가지는 DTO 클래스를 만들고 이를 @LoginMember에 바인딩시켜 주는 방법으로 해결했다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 방법을 선택한 이유는 먼저 기존에 사용하던 더티 체킹을 유지하고 싶었다. 또한 컨트롤러단(ArgumentResolver)에서 Repository단으로 멤버를 조회하지 않기에 의존성을 줄일 수 있다는 점과 서비스단에서 엔티티를 조회하는 게 레이어 간의 역할에 더 바람직하다고 느꼈기 때문이다.&lt;/p&gt;</description>
      <category>프로젝트</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/10</guid>
      <comments>https://more-n.tistory.com/10#entry10comment</comments>
      <pubDate>Tue, 30 May 2023 21:34:12 +0900</pubDate>
    </item>
    <item>
      <title>AOP를 활용한 공통 기능 구현</title>
      <link>https://more-n.tistory.com/9</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;본&amp;nbsp;글은&amp;nbsp;AOP로&amp;nbsp;기능&amp;nbsp;구현한&amp;nbsp;내용을&amp;nbsp;다루는&amp;nbsp;글입니다.&amp;nbsp;개념적인&amp;nbsp;설명이&amp;nbsp;미흡할&amp;nbsp;수&amp;nbsp;있고,&amp;nbsp;틀린&amp;nbsp;부분이&amp;nbsp;존재할&amp;nbsp;수&amp;nbsp;있습니다.&amp;nbsp;피드백 해주시면&amp;nbsp;정말&amp;nbsp;감사하겠습니다&amp;nbsp;:)&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;어플리케이션 전반에 퍼져있는 공통된 부가 기능들을 관심사(Aspect)라고 한다. 우리가 널리 쓰고 있는 트랜잭션이 Aspect의 예다. 트랜잭션이 필요한 메서드마다 트랜잭션을 얻고 커밋과 롤백 등의 로직을 구현해야 한다면, 코드가 복잡해질 것이다. 이처럼 비지니스 로직과 달리 부가적으로 들어간 기능을 관심사(Aspect)라고 정의하고 이를 비지니스 로직에서 분리시킬 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;Aspect&amp;nbsp;Oriented&amp;nbsp;Programming(AOP)&lt;/b&gt;&lt;br /&gt;OOP는&amp;nbsp;비즈니스&amp;nbsp;로직의&amp;nbsp;중복을&amp;nbsp;제거하기&amp;nbsp;위함이고&amp;nbsp;AOP는&amp;nbsp;인프라&amp;nbsp;로직&amp;nbsp;중복을&amp;nbsp;제거하기&amp;nbsp;위함이다.&amp;nbsp;여기서&amp;nbsp;인프라&amp;nbsp;로직은&amp;nbsp;트랜잭션&amp;nbsp;로직,&amp;nbsp;로깅,&amp;nbsp;성능&amp;nbsp;측정을&amp;nbsp;예로&amp;nbsp;들&amp;nbsp;수&amp;nbsp;있다.&amp;nbsp;위의&amp;nbsp;트랜잭션의&amp;nbsp;경우처럼&amp;nbsp;인프라&amp;nbsp;로직인&amp;nbsp;많은&amp;nbsp;중복&amp;nbsp;코드를&amp;nbsp;양산할&amp;nbsp;뿐아니라,&amp;nbsp;가독성과&amp;nbsp;유지보수를&amp;nbsp;어렵게&amp;nbsp;한다.&amp;nbsp;AOP를&amp;nbsp;활용해&amp;nbsp;인프라&amp;nbsp;로직의&amp;nbsp;중복을&amp;nbsp;제거함으로써&amp;nbsp;개발자는&amp;nbsp;비즈니스&amp;nbsp;로직에&amp;nbsp;집중할&amp;nbsp;수&amp;nbsp;있다.&lt;br /&gt;&lt;br /&gt;AOP&amp;nbsp;사용하는&amp;nbsp;용어&lt;br /&gt;target&amp;nbsp;:&amp;nbsp;부가&amp;nbsp;기능을&amp;nbsp;부여할&amp;nbsp;대상&lt;br /&gt;advice&amp;nbsp;:&amp;nbsp;부가&amp;nbsp;기능을&amp;nbsp;정의한&amp;nbsp;메서드&lt;br /&gt;pointcut&amp;nbsp;:&amp;nbsp;advice가&amp;nbsp;적용될&amp;nbsp;target을&amp;nbsp;지정하는&amp;nbsp;것을&amp;nbsp;의미&lt;br /&gt;&lt;br /&gt;더&amp;nbsp;많은&amp;nbsp;용어들이&amp;nbsp;있지만,&amp;nbsp;이&amp;nbsp;3개의&amp;nbsp;용어를&amp;nbsp;중점으로&amp;nbsp;구현한&amp;nbsp;코드를&amp;nbsp;설명하려고&amp;nbsp;한다.&lt;br /&gt;&lt;br /&gt;나는&amp;nbsp;처리율&amp;nbsp;제한장치와&amp;nbsp;분산락을&amp;nbsp;AOP로&amp;nbsp;구현했다.&amp;nbsp;&lt;br /&gt;먼저&amp;nbsp;처리율&amp;nbsp;제한장치를&amp;nbsp;구현한&amp;nbsp;코드다.&lt;/p&gt;
&lt;pre id=&quot;code_1685528265208&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Aspect  
@Component  
@RequiredArgsConstructor  
public class RateLimitAspect {  
    private final RateLimiter rateLimiter;  

    @Pointcut(&quot;@annotation(org.springframework.web.bind.annotation.GetMapping) || @annotation(org.springframework.web.bind.annotation.PostMapping)&quot;)  
    public void rateLimitPointcut() {}  
  
    @Around(&quot;rateLimitPointcut()&quot;)  
    public Object rateLimitAround(ProceedingJoinPoint joinPoint) throws Throwable {  
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();  
        HttpServletRequest request = attributes.getRequest();  
        String identifier = getClientIpAddr(request);  
        String methodName = joinPoint.getSignature().getName();  
        
        if (!rateLimiter.isAllMethodAllowed(identifier,5,50)) {  
            throw new TooManyRequestException();  
        }  
        return joinPoint.proceed();  
    }  
}

@Component  
public class RateLimiter {  
  
    private final RedisTemplate&amp;lt;String, Integer&amp;gt; rateLimiterTemplate;
    
	@Autowired  
	public RateLimiter(  
            @Qualifier(&quot;rateLimiterRedisTemplateBean&quot;) RedisTemplate&amp;lt;String, Integer&amp;gt; rateLimiterTemplate  
	) {  
		this.rateLimiterTemplate = rateLimiterTemplate;  
	}
	
	public boolean isAllMethodAllowed(String identifier, int second, int limit) {  
        String key = identifier;  
        Long count = rateLimiterTemplate.opsForValue().increment(key, 1);  
        rateLimiterTemplate.expire(key, second, TimeUnit.SECONDS);  
        return count &amp;lt;= limit;  
    }  
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스 위에&amp;nbsp;@Aspect&amp;nbsp;어노테이션으로&amp;nbsp;해당&amp;nbsp;클래스가&amp;nbsp;AOP 관련&amp;nbsp;advice와&amp;nbsp;pointcut을&amp;nbsp;정의하는&amp;nbsp;클래스임을&amp;nbsp;나타내고&amp;nbsp;있다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;@Pointcut&amp;nbsp;어노테이션으로&amp;nbsp;부가&amp;nbsp;기능의&amp;nbsp;대상이&amp;nbsp;될&amp;nbsp;타겟&amp;nbsp;어노테이션을&amp;nbsp;설정해 주고&amp;nbsp;있다.&amp;nbsp;@GetMapping,&amp;nbsp;@PostMapping&amp;nbsp;이&amp;nbsp;붙은&amp;nbsp;메서드가&amp;nbsp;타겟이&amp;nbsp;되는&amp;nbsp;것이다.&lt;br /&gt;&lt;br /&gt;@Around는&amp;nbsp;위&amp;nbsp;@Pointcut&amp;nbsp;어노테이션이&amp;nbsp;적용된&amp;nbsp;rateLimitPointcut()&amp;nbsp;메서드를&amp;nbsp;적용&amp;nbsp;대상으로&amp;nbsp;설정한다.&amp;nbsp;&lt;br /&gt;return&amp;nbsp;문에&amp;nbsp;작성된 joinPoint.proceed()는&amp;nbsp;대상&amp;nbsp;메서드를&amp;nbsp;실행하는&amp;nbsp;역할을&amp;nbsp;한다.&amp;nbsp;그&amp;nbsp;위에&amp;nbsp;들어가는&amp;nbsp;ratelimiter&amp;nbsp;관련&amp;nbsp;로직이&amp;nbsp;advice에&amp;nbsp;해당하는&amp;nbsp;로직이다.&amp;nbsp;처리율&amp;nbsp;제한&amp;nbsp;로직이라는&amp;nbsp;부가&amp;nbsp;기능을&amp;nbsp;구현한&amp;nbsp;것이다.&lt;br /&gt;&lt;br /&gt;이렇게&amp;nbsp;적용을&amp;nbsp;하면,&amp;nbsp;@PostMapping&amp;nbsp;또는&amp;nbsp;@GetMapping&amp;nbsp;어노테이션이&amp;nbsp;붙은&amp;nbsp;컨트롤러&amp;nbsp;메서드는&amp;nbsp;실제&amp;nbsp;메서드가&amp;nbsp;실행되기&amp;nbsp;전에&amp;nbsp;위의&amp;nbsp;adivce로직이&amp;nbsp;실행된다.&amp;nbsp;위&amp;nbsp;경우엔&amp;nbsp;같은&amp;nbsp;ip로&amp;nbsp;5초&amp;nbsp;동안&amp;nbsp;50번&amp;nbsp;넘게&amp;nbsp;요청한&amp;nbsp;경우에&amp;nbsp;429(TooManyRequest)&amp;nbsp;에러를&amp;nbsp;내며,&amp;nbsp;대상&amp;nbsp;메서드는&amp;nbsp;실행되지&amp;nbsp;않게&amp;nbsp;된다.&lt;br /&gt;&lt;br /&gt;advice&amp;nbsp;를&amp;nbsp;적용하는&amp;nbsp;부분은&amp;nbsp;@Around,&amp;nbsp;@Before,&amp;nbsp;@After&amp;nbsp;&amp;nbsp;가능하다.&amp;nbsp;각각은&amp;nbsp;대상&amp;nbsp;메서드&amp;nbsp;실행&amp;nbsp;전과&amp;nbsp;후에&amp;nbsp;advice&amp;nbsp;로직을&amp;nbsp;적용하는&amp;nbsp;걸로&amp;nbsp;구분이&amp;nbsp;되며,&amp;nbsp;위의&amp;nbsp;내&amp;nbsp;경우엔&amp;nbsp;대상&amp;nbsp;메서드&amp;nbsp;실행&amp;nbsp;전에만&amp;nbsp;advice 로직이&amp;nbsp;적용됨으로&amp;nbsp;@Before&amp;nbsp;를&amp;nbsp;써도&amp;nbsp;무방하다.&amp;nbsp;또한&amp;nbsp;메서드의&amp;nbsp;예외&amp;nbsp;지점에도&amp;nbsp;pointcut을&amp;nbsp;적용할&amp;nbsp;수&amp;nbsp;있다(@AfterThrowing).&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1685528289690&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Aspect  
@Component  
@RequiredArgsConstructor  
@Slf4j  
public class LockAspect {  
  
    private final RedissonClient redissonClient;  
    private final Transaction4Aop transaction4Aop;  
  
    @Around(&quot;@annotation(com.project.book.common.config.aop.DistributedLock)&quot;)  
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {  
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();  
        Method method = signature.getMethod();  
        DistributedLock annotation = method.getAnnotation(DistributedLock.class);  
  
        String key = annotation.key();  
        RLock lock = redissonClient.getLock(key);  
  
        try {  
            boolean available = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), annotation.timeUnit());  
  
            if (!available) {  
                return false;  
            }  
  
            return transaction4Aop.proceed(joinPoint);  
        } catch (InterruptedException | IOException e) {  
            log.error(&quot;DistributedLock error : &quot; + e.getMessage());  
            throw new RuntimeException();  
        } finally {  
            if (lock.isHeldByCurrentThread() &amp;amp;&amp;amp; lock.isLocked()) {  
                lock.unlock();  
            }  
        }  
    }  
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이젠&amp;nbsp;분산락을&amp;nbsp;적용한&amp;nbsp;AOP 로직을&amp;nbsp;보겠다.&amp;nbsp;위의&amp;nbsp;rateLimit와&amp;nbsp;다른&amp;nbsp;부분은&amp;nbsp;따로&amp;nbsp;@Pointcut&amp;nbsp;어노테이션을&amp;nbsp;사용하지&amp;nbsp;않고,&amp;nbsp;@Around에&amp;nbsp;Pointcut&amp;nbsp;표현식으로&amp;nbsp;적용될&amp;nbsp;어노테이션을&amp;nbsp;직접&amp;nbsp;입력해 줬다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;위의&amp;nbsp;rateLimitAspect처럼&amp;nbsp;pointcut&amp;nbsp;지정&amp;nbsp;메서드를&amp;nbsp;만들어&amp;nbsp;동일한&amp;nbsp;pointcut을&amp;nbsp;참조해야 하는&amp;nbsp;경우에&amp;nbsp;재사용에&amp;nbsp;유리하게&amp;nbsp;구성해도&amp;nbsp;되고,&amp;nbsp;LockAspect&amp;nbsp;클래스처럼&amp;nbsp;별도의&amp;nbsp;@Pointcut&amp;nbsp;없이&amp;nbsp;@Around&amp;nbsp;어노테이션에&amp;nbsp;직접&amp;nbsp;지정할&amp;nbsp;수도&amp;nbsp;잇따.&lt;br /&gt;&lt;br /&gt;그리고&amp;nbsp;여기선 transaction4Aop.proceed(joinPoint); 로&amp;nbsp;대상&amp;nbsp;메서드를&amp;nbsp;호출하는데&amp;nbsp;호출&amp;nbsp;전, 후에&amp;nbsp;로직이&amp;nbsp;들어가니&amp;nbsp;@Before나&amp;nbsp;@After&amp;nbsp;가&amp;nbsp;아닌&amp;nbsp;@Around가&amp;nbsp;적절하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;인텔리제이에서는 만들어준 Aspect가 어디에 적용되는지 쉽게 확인할 수 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-05-31 오후 1.38.27.png&quot; data-origin-width=&quot;1816&quot; data-origin-height=&quot;788&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btSaBh/btsh9GRSlE3/Pd7v3sypM9tRwNWLZ2M5U0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btSaBh/btsh9GRSlE3/Pd7v3sypM9tRwNWLZ2M5U0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btSaBh/btsh9GRSlE3/Pd7v3sypM9tRwNWLZ2M5U0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtSaBh%2Fbtsh9GRSlE3%2FPd7v3sypM9tRwNWLZ2M5U0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1816&quot; height=&quot;788&quot; data-filename=&quot;스크린샷 2023-05-31 오후 1.38.27.png&quot; data-origin-width=&quot;1816&quot; data-origin-height=&quot;788&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프록시 패턴&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-05-31 오후 7.07.36.png&quot; data-origin-width=&quot;1524&quot; data-origin-height=&quot;982&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0CHNK/btsidk0BD0Q/KCtZm3pFfaj0a5Zz3w2Buk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0CHNK/btsidk0BD0Q/KCtZm3pFfaj0a5Zz3w2Buk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0CHNK/btsidk0BD0Q/KCtZm3pFfaj0a5Zz3w2Buk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0CHNK%2Fbtsidk0BD0Q%2FKCtZm3pFfaj0a5Zz3w2Buk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1524&quot; height=&quot;982&quot; data-filename=&quot;스크린샷 2023-05-31 오후 7.07.36.png&quot; data-origin-width=&quot;1524&quot; data-origin-height=&quot;982&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring&amp;nbsp;AOP는 프록시&amp;nbsp;패턴을&amp;nbsp;기반으로&amp;nbsp;동작한다.&amp;nbsp;부가&amp;nbsp;기능을 프록시로&amp;nbsp;감싸서&amp;nbsp;실행을&amp;nbsp;하는&amp;nbsp;방식이다.&amp;nbsp;여기서&amp;nbsp;부가&amp;nbsp;기능이&amp;nbsp;advice다.&amp;nbsp;&lt;br /&gt;런타임&amp;nbsp;시에&amp;nbsp;스프링은&amp;nbsp;Aspect&amp;nbsp;클래스를&amp;nbsp;검색한다. 그리고 타겟&amp;nbsp;클래스를&amp;nbsp;감싼&amp;nbsp;프록시&amp;nbsp;객체를&amp;nbsp;생성하는데 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;여기엔&amp;nbsp;&lt;/span&gt;정의된&amp;nbsp;advice를&amp;nbsp;포함해서 프록시 객체가 만들어진다&lt;/span&gt;.&amp;nbsp;프록시&amp;nbsp;객체는 타겟 클래스의 모든 메서드를 포함하고 있는건 아니다. pointcut에&amp;nbsp;매칭되는&amp;nbsp;타겟&amp;nbsp;메서드에&amp;nbsp;대해서만&amp;nbsp;감싸지며,&amp;nbsp;타겟&amp;nbsp;메서드가&amp;nbsp;아니라면&amp;nbsp;프록시&amp;nbsp;객체에&amp;nbsp;포함되지&amp;nbsp;않는다.&amp;nbsp;이렇게&amp;nbsp;pointcut으로&amp;nbsp;지정된&amp;nbsp;메서드들의&amp;nbsp;요청은&amp;nbsp;프록시&amp;nbsp;객체에&amp;nbsp;먼저&amp;nbsp;전달되어,&amp;nbsp;프록시&amp;nbsp;객체는&amp;nbsp;개발자가&amp;nbsp;구현한&amp;nbsp;어드바이스&amp;nbsp;로직을&amp;nbsp;처리한&amp;nbsp;후에&amp;nbsp;타겟&amp;nbsp;객체의&amp;nbsp;메서드를&amp;nbsp;실행한다.&amp;nbsp;advice가&amp;nbsp;@Before를&amp;nbsp;제외한&amp;nbsp;다른&amp;nbsp;종류인&amp;nbsp;경우,&amp;nbsp;타겟&amp;nbsp;객체의&amp;nbsp;메서드가&amp;nbsp;실행된&amp;nbsp;후에&amp;nbsp;반환값을&amp;nbsp;프록시로&amp;nbsp;전달되어&amp;nbsp;추가&amp;nbsp;로직&amp;nbsp;등의&amp;nbsp;작업을&amp;nbsp;거친&amp;nbsp;후에&amp;nbsp;클라이언트로&amp;nbsp;반환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고&amp;nbsp;&lt;br /&gt;&lt;a href=&quot;https://gmoon92.github.io/spring/aop/2019/01/15/aspect-oriented-programming-concept.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://gmoon92.github.io/spring/aop/2019/01/15/aspect-oriented-programming-concept.html&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://tecoble.techcourse.co.kr/post/2021-06-25-aop-transaction/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://tecoble.techcourse.co.kr/post/2021-06-25-aop-transaction/&lt;/a&gt;&lt;/p&gt;</description>
      <category>프로젝트</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/9</guid>
      <comments>https://more-n.tistory.com/9#entry9comment</comments>
      <pubDate>Thu, 25 May 2023 01:23:54 +0900</pubDate>
    </item>
    <item>
      <title>분산락을 이용한 동시성 이슈 해결</title>
      <link>https://more-n.tistory.com/8</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;분산락이 필요한 상황 설명&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;유저들이 검색한 검색어를 Redis의 Sorted Set에 저장하고, 그걸 바탕으로 인기 검색어를 구현했다. 검색될 때마다 Redis에 저장시켰는데, 통신부하를 줄여보기 위해서 검색어를 서버에 리스트 안에 모아서 100개가 되면 Redis에 보내게 바꿨다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;지금 내 토이프로젝트 상황으로는 검색어 100개를 모으려면, 100일은 더 걸릴 것이다. (사용하는 유저가 없다..ㅠ) 그렇기에 100개가 안 되더라도 Redis에 저장시키기 위해서, 스케줄러로 하루에 2번씩 모아진 검색어를 저장시키게 했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 문제가 발생했다! 왜냐하면 난 2대의 서버를 운영중이기 때문이다. 2대의 서버가 동시에 레디스로 데이터를 보내는데 여기서 동시성 이슈가 터졌다. 검색한 검색어 일부가 Redis에 저장이 안 된걸 확인했다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;분산락이란&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;분산 환경에서 동시성 이슈를 처리하기 위해 등장한 방법인 분산 락은 여러 서버 또는 프로세스에서 공유 리소스(여기선 Redis)에 대한 액세스를 조정하는 기술이다. 내 경우에서도 분산락을 적용하면 해결이 가능해보였다. Redis를 이용해 분산락을 적용했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;서버들은 락을 획득하기 위해서, Redis에 특정 키 생성을 시도한다. 키가 존재하지 않는다면 지정된 시간동안 락을 획득하고 공유 리소스에 대한 접근 권한을 얻는다. 만약 키가 존재한다면 락을 획득하려는 시도는 실패한다. 공유 리소스에 대한 작업이 끝난다면 Redis에서 해당 키를 삭제해서 락을 해제한다. 그러면 대기하던 서버가 락을 획득하게 되는 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 서버는 락이 해제되었는지 어떻게 알게 될까? 하나는 서버가 Redis에 주기적으로 확인하는 방식이고, 다른 하나는 pub/sub 방식으로 락이 해제되면 메세지를 받는 방식이다. 첫 번째 방식은 해제를 확인하기 위해서 많은 트래픽이 발생될 여지가 있기에 두 번째 방식으로 구현했다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;구현&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;두 번째 방식은 Redisson 라이브러리를 사용해 구현이 가능하다.기존에도 Redis를 사용중이기에 의존성만 추가해주면 사용이 가능하다. Redis를 사용하고 있지 않았다면, port나 호스트 주소 등을 설정해서 빈으로 등록시켜줘야 한다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;implementation 'org.redisson:redisson-spring-boot-starter:3.17.6'
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;LockService&lt;/h3&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Service
public class LockRankingService {
    private static final String LOCK_KEY =&quot;RANKING_LOCK&quot;;
    private static final int WAIT_TIME = 10;
    private static final int LEASE_TIME = 5;

    private final RedissonClient redissonClient;
    private final RankingService rankingService;

    public LockRankingService(RedissonClient redissonClient, RankingService rankingService) {
        this.redissonClient = redissonClient;
        this.rankingService = rankingService;
    }

    public void record() {
        RLock lock = redissonClient.getLock(LOCK_KEY);

        try {
            boolean available = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);

            if (available) {
                rankingService.searchKeywordToRedis();
            }

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            if (lock.isHeldByCurrentThread() &amp;amp;&amp;amp; lock.isLocked()) {
                lock.unlock();
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Lock을 위해서 별도의 서비스를 만들고 RedissonClient를 주입받는다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;Ranking_Lock&amp;rdquo;이라는 키로 락을 생성해서 락을 얻는다. 락을 얻는 것에 성공한다면 Redis에 검색어를 저장시키는 searchKeywordToRedis() 메서드가 실행된다. 마지막으로 unlock()을 통해 락을 해제한다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 waitTime은 락 획득을 하기위해 대기할 최대 시간을 나타내고, leaseTime은 락의 유효 시간을 나타나며 leaseTime이 지나면 락은 자동 해제된다.&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class SchedulerRankingService {

    private final LockRankingService lockRankingService;

    @Scheduled(cron = &quot;0 0 11,23 * * *&quot;)
    public void scheduleSearchKeywordToRedis() {
        lockRankingService.record();
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;구현한 분산락을 적용해서 데이터가 누락되는 이슈를 해결할 수 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;내 토이프로젝트에서는 책을 등록하는 메서드가 존재한다. 평점 등의 정보도 같이 입력해서 유저가 읽은 책을 등록할 수 있게 해준 기능이다. 만약 같은 책에 대해서 여러 요청이 동시에 들어온다면, 데이터의 정합성이 깨질 우려가 있다. 그러면 여기에도 분산락을 적용해보려 한다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;LockRankingService와 같은 로직을 매번 만들기는 번거롭다.락을 걸어주는 로직은 비지니스 로직과는 관련없는 코드다. 그렇기에 AOP를 적용해 락에 관한 로직을 분리시키려한다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;분산락을 적용할 메타 어노테이션&lt;/h3&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface DistributedLock {
    String key();
    long waitTime() default 10L;
    long leaseTime() default 5L;
    TimeUnit timeUnit() default TimeUnit.SECONDS;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;메타 어노테이션이 수행되는 AOP 클래스&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class LockAspect {

    private final RedissonClient redissonClient;
    private final Transaction4Aop transaction4Aop;

    @Around(&quot;@annotation(com.project.book.common.config.aop.DistributedLock)&quot;)
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock annotation = method.getAnnotation(DistributedLock.class);

        String key = annotation.key();
        RLock lock = redissonClient.getLock(key);

        try {
            boolean available = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), annotation.timeUnit());

            if (!available) {
                return false;
            }

            return transaction4Aop.proceed(joinPoint);
        } catch (InterruptedException | IOException e) {
log.error(&quot;DistributedLock error : &quot; + e.getMessage());
            throw new RuntimeException();
        } finally {
            if (lock.isHeldByCurrentThread() &amp;amp;&amp;amp; lock.isLocked()) {
                lock.unlock();
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;메타 어노테이션에 설정한 key를 바탕으로 락을 획득하는 로직을 구현했다. 위의 LockService 로직과 같다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Component
public class Transaction4Aop {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;AOP를 적용할 메서드의 트랜잭션 처리를 위해서 별도의 클래스를 만들었다. 만약 1번 락의 트랙잭션이 커밋되지 않은채로 락이 해제되고, 다음 락이 작업에 들어간다면 데이터의 정합성을 보장할 수 없다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이를 방지하기 위해서 획득한 락 안에서 새로 트랜잭션을 생성해준다. proceed() 메서드가 완료되고, 즉 트랜잭션이 커밋되고 finally 블록에서 락이 해제되기에 데이터 정합성을 지킬 수 있게 된다.&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class SchedulerRankingService {

    private final RankingService rankingService;

    @Scheduled(cron = &quot;0 0 11,23 * * *&quot;)
    @DistributedLock(key = &quot;ranking&quot;)
    public void scheduleSearchKeywordToRedis() {
        rankingService.searchKeywordToRedis();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;AOP를 적용해 변경한 로직이다. watiTime,leaseTime은 원하는 값을 @DistributedLock에 파라미터로 넣어주면 된다. 여기선 설정해둔 디폴트 값으로 실행된다.&lt;/p&gt;</description>
      <category>프로젝트</category>
      <category>분산락 #동시성</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/8</guid>
      <comments>https://more-n.tistory.com/8#entry8comment</comments>
      <pubDate>Sun, 14 May 2023 14:00:21 +0900</pubDate>
    </item>
    <item>
      <title>페이지네이션 구현과 성능 개선</title>
      <link>https://more-n.tistory.com/7</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;페이지네이션 구현&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;페이지네이션은 데이터를 페이지 단위로 나누어 화면에 보여주는 기능이다. 전체 데이터가 아니라 필요한 페이지의 데이터만 불러오기 때문에 성능을 향상시킬 수 있다. 물론 사용자 경험도 훨씬 좋다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;밑의 화면처럼 페이지네이션을 화면에 만들기 위해선 토탈 페이지 카운트를 알아야한다. 그렇기에 페이지네이션이 이루어지려면 페이지당 몇 개의 데이터를 보여줄건지와 토탈 페이지 카운트를 알려줘야 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-05-04 오후 11.43.50.png&quot; data-origin-width=&quot;354&quot; data-origin-height=&quot;59&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zeZ17/btsd4dZbTeF/lR2VHkWiaPYUkcqQl9o1FK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zeZ17/btsd4dZbTeF/lR2VHkWiaPYUkcqQl9o1FK/img.png&quot; data-alt=&quot;&amp;amp;lt;페이지네이션&amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zeZ17/btsd4dZbTeF/lR2VHkWiaPYUkcqQl9o1FK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzeZ17%2Fbtsd4dZbTeF%2FlR2VHkWiaPYUkcqQl9o1FK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;354&quot; height=&quot;59&quot; data-filename=&quot;스크린샷 2023-05-04 오후 11.43.50.png&quot; data-origin-width=&quot;354&quot; data-origin-height=&quot;59&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt;페이지네이션&amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 프로젝트의 프론트엔드는 &amp;lt;v-pagination&amp;gt; 컴포넌트를 이용해서 페이지네이션을 구현했다. 여기서 보다시피 length 속성으로 토탈 페이지 카운트를 입력받고 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;&amp;lt;v-pagination
      :length=&quot;getPageCount&quot;
      ...
    /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드에서 전체 데이터 개수로 토탈 카운트를 구할 수 있지만, 백엔드에서 토탈 카운트를 보내주는게 일반적이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;백엔드에서는 Pageable 인터페이스를 구현한 PageRequest 클래스를 사용해서 요청을 입력받고 Page 인터페이스를 구현한 PageImpl 클래스로 응답을 해줬다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Pageable는 페이지 번호, 페이지 크기, 정렬 기준 등의 요청 정보를 캡슐화한 인터페이스다. 반면 Page 인터페이스는 페이지 번호, 토탈 페이지 카운트, 페이지 내의 데이터 등의 응답 정보를 캡슐화한 인터페이스다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;public Page&amp;lt;?&amp;gt; getAllBook(final AllBookFilterDto condition, Pageable pageRequest) {
        ...
        List&amp;lt;AllBookResponseDto&amp;gt; allBooks = bookRepository.getAllBooks(condition, pageRequest);
        Long totalCount = fetchTotalCount(condition);

        return new PageImpl&amp;lt;&amp;gt; (allBooks, pageRequest, totalCount);
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;요청에 맞는 페이지 당 데이터, 전체 데이터 수, 페이지 요청 객체를 사용하여 PageImpl 객체를 생성하고 이를 반환해주고 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Limit과 Offset을 이용해서 쿼리를 보내고, 페이지 당 데이터를 가져온다. Limit은 한 페이지에 표시할 데이터의 수를 지정하는 것이며, Offset은 검색을 시작할 데이터의 위치를 지정하는 것이다. 그렇기에 Offset은 (현재 페이지 - 1) x (Limit)으로 계산됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 10개씩 보여주는 화면에서 4페이지를 눌렀다면, Offset은 (4-1) x 10 으로 30이 되며, 30번째 데이터부터 39번째 데이터까지 가져온다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;개선점&lt;/h3&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1. Offset &amp;rarr; No-Offset&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Offset 기반 페이지네이션은 뒤 페이지로 갈수록 처리 시간이 증가하게 된다. 예를 들어, n번째 페이지를 눌렀다고 하자. 그러면 1페이지부터 n페이지까지의 전체 데이터를 스캔하고 n페이지에 해당하는 데이터만 읽어온다. 즉, Full Scan 방식으로 offset + limit 에 해당하는 레코드를 모두 읽은 뒤, 그 중 마지막 limit 수의 레코드만 가져오는 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 이 단점을 보완한 No-Offset 방식도 있다. 가져올 데이터의 시작 부분을 인덱스로 찾아서 매번 Limit 수의 레코드만 읽는 방식이다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;SELECT *
FROM book
WHERE 조건문
AND id &amp;lt; 마지막 조회 id
LIMIT 페이지 사이즈
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이처럼 특정 위치를 지정해서 매번 같은 사이즈의 데이터를 읽어온다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에 페이지네이션 구현할 때, No-Offset 방식인 무한 스크롤을 가장 먼저 시도했었다. 하지만 내 경우엔 책 마다 차트를 포함하고 있다. 차트는 &amp;lt;canvas&amp;gt; 태그를 이용해서 그려준다. 무한 스크롤로 새로운 데이터를 렌더링 해줄 때, 이미 차트를 그릴 Canvas 태그가 사용 중이어서 렌더링이 안되는 에러가 발생했다. 백엔드에서 보내준 데이터는 잘 전달이 됬지만, 차트가 그려지지 않았다. 아래 사진처럼 &amp;lt;이순신&amp;gt;까지가 첫 페이지 데이터고 엑박 뜬 부분이 무한 스크롤로 불러온, 쉽게 말하면 2페이지 데이터다. 차트가 안 뜨면서 다른 데이터들도 화면에 제대로 나타나지 않았다. 여러 블로그를 참고해가며 수정하려 했지만 결국 차트를 그리지 못 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;no-offset.png&quot; data-origin-width=&quot;491&quot; data-origin-height=&quot;726&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zFebv/btsdZiHYEc5/oM0Clg5jzyM5K8pLegcyS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zFebv/btsdZiHYEc5/oM0Clg5jzyM5K8pLegcyS0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zFebv/btsdZiHYEc5/oM0Clg5jzyM5K8pLegcyS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzFebv%2FbtsdZiHYEc5%2FoM0Clg5jzyM5K8pLegcyS0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;491&quot; height=&quot;726&quot; data-filename=&quot;no-offset.png&quot; data-origin-width=&quot;491&quot; data-origin-height=&quot;726&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기에 No-Offset 방식의 페이지네이션은 구현하지 못 했고, Offset 방식의 페이지네이션을 사용하게 됐다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2. Count 쿼리 최적화&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Offset 방식은 데이터를 조회하는 쿼리와 count 쿼리가 동시에 발생한다. count 쿼리는 전체 데이터를 확인하기 때문에 데이터가 많아질수록 응답 시간은 늘어난다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 매번 count 쿼리가 필요할까?&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;첫 페이지 요청때 백엔드에서 넘겨준 토탈 카운트를 프론트에서 캐싱하고 있다가 2번째 페이지 요청부터는 토탈 카운트를 같이 백엔드에 보내게 변경을 했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드 코드&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-04-03 오전 2.14.42.png&quot; data-origin-width=&quot;1184&quot; data-origin-height=&quot;158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/clbv7c/btsd13WNQdc/8veKqS2YcW9AIhPfuvyNOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/clbv7c/btsd13WNQdc/8veKqS2YcW9AIhPfuvyNOK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/clbv7c/btsd13WNQdc/8veKqS2YcW9AIhPfuvyNOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fclbv7c%2Fbtsd13WNQdc%2F8veKqS2YcW9AIhPfuvyNOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1184&quot; height=&quot;158&quot; data-filename=&quot;스크린샷 2023-04-03 오전 2.14.42.png&quot; data-origin-width=&quot;1184&quot; data-origin-height=&quot;158&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;백엔드 응답값에서 토탈 카운트를 저장한다.&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2023-04-03 오전 2.24.49.png&quot; data-origin-width=&quot;1584&quot; data-origin-height=&quot;52&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/37zhM/btsd1hHJ1Nf/CYpp9CkHM5gvnC2Gbj20U1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/37zhM/btsd1hHJ1Nf/CYpp9CkHM5gvnC2Gbj20U1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/37zhM/btsd1hHJ1Nf/CYpp9CkHM5gvnC2Gbj20U1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F37zhM%2Fbtsd1hHJ1Nf%2FCYpp9CkHM5gvnC2Gbj20U1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1584&quot; height=&quot;52&quot; data-filename=&quot;스크린샷 2023-04-03 오전 2.24.49.png&quot; data-origin-width=&quot;1584&quot; data-origin-height=&quot;52&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;그리고 페이징 요청시 카운트도 함께 보내게 변경했다.&lt;br /&gt;&lt;br /&gt;백엔드 코드(condition안에 토탈 카운트 포함)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;public Page&amp;lt;?&amp;gt; getAllBook(final AllBookFilterDto condition, Pageable pageRequest) {
        ...

        List&amp;lt;AllBookResponseDto&amp;gt; allBooks = bookRepository.getAllBooks(condition, pageRequest);
        Long totalCount = fetchTotalCount(condition);

        return new PageImpl&amp;lt;&amp;gt; (allBooks, pageRequest, totalCount);
    }

// 카운트 가져오는 메서드
private Long fetchTotalCount(AllBookFilterDto condition) {
        if (condition.getTotalCount() == null) {
            return bookRepository.countAllBooks(condition);
        }
        return condition.getTotalCount();
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 변경해서 첫 페이지 외의 요청에서 count쿼리를 호출하지 않게 된다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트</category>
      <category>페이지네이션</category>
      <author>민철킴</author>
      <guid isPermaLink="true">https://more-n.tistory.com/7</guid>
      <comments>https://more-n.tistory.com/7#entry7comment</comments>
      <pubDate>Sat, 6 May 2023 02:26:18 +0900</pubDate>
    </item>
  </channel>
</rss>