팀에서 처음으로 Spring Batch를 사용하다 보니 문제 상황이 생기면 물어볼수도 없어서 혼자 끙끙대는 중인데 가끔 스스로 에게 하는 질문이나 의문 자체가 잘못된 느낌도 들고, Spring Batch에 대해 다시 처음부터 공부하고 시작해야 하는게 아닌가 하는 불안감도 들고 개발 하는 내내 싱숭생숭하다; 개발하면서 또 이런 적은 처음이라 당황스럽다-_-; 일단 개발은 시작했는데 내일은 다시 좀 기본 컨셉, 개념들을 살펴보고 코드들을 전체적으로 훑어봐야겠다..

 

1) 복잡한 Query를 질의해야 한다.

 

단순 조인으로만 이루어진 쿼리가 아니라 union all이나 여러 개의 내부 select절이 포함된 복잡한 쿼리를 Reader에서 던져야 했다. (JPA를 사용하는 스프링 부트 프로젝트에선 QueryDSL로도 좀 만들기 까다로운 경우에는 Native Query를 사용해서 질의를 해왔다.) 후보에는 JpaPagingItemReader, JdbcPagingItemReader, JdbcCursorItemReader이 올랐고, Cursor의 경우는 Socket Time Out의 문제로 Connection 이 끊어지지 않게끔 설정을 해줘야 되는 번거로움이 존재했다. 다른 블로그에서도 Paging을 추천하기도 했고, Jpa가 익숙해서 JpaPagingItemReader를 사용하고 있었는데 JpaPagingItemReader로는 복잡한 쿼리를 질의하는 케이스를 아무리 찾아봐도 보이지가 않는다(그때는..) 여차 저차 비슷한 걸 찾아서 JpaNativeQueryProvider를 이용해보았는데 실패; (메인 쿼리 전에 Jpa 에서 사전? 쿼리를 날리는데 이게 문제가 된다) 그래서 JdbcPagingItemReader를 사용해보려고 했는데 setSelectClase()같이 쿼리를 구분해서 넣어주기엔 쿼리가 너무 복잡해서 어쩔 수 없이 JdbcCursorItemReader를 사용하게 됐다. 

 

    @Bean
    public JdbcCursorItemReader<TestEntity> jdbcCursorItemReader() {
        return new JdbcCursorItemReaderBuilder<TestEntity>()
                            .fetchSize(chunkSize)
                            .dataSource(dataSource)
                            .rowMapper(new TestEntityMapper()) // 원래는 이 부분에서 BeanPropertyRowMapper를 사용했었음
                            .sql(getQuery())
                            .name("jdbcCursorItemReader")
                            .build();
    }

간단하게 바꿔서 가져와 봤다. 여기서 또 다른 난관을 맞닥뜨리게 되는데; rowMapper 부분이다;

현재 DB는 PostgreSQL을 쓰고 있고, 컬럼 타입이 Array인 경우가 많다. 이런 경우에는 아래와 같이 타입을 선언해주고 CRUD를 처리했는데 JdbcCursorItemReader의 rowMapper로 전달한 BeanPropertyRowMapper가 해당 타입을 변환해주지 못하고 에러를 뱉는다. 

@Column(name = "emails", columnDefinition = "varchar[]")
@Type(type = "~~~.CustomStringArrayType")
private String[] emails;

그래서 RowMapper를 Implements한 Custom Mapper를 생성해주고 아래와 같이 Array.타입을 변환해 mapping해주게 하였다.

public class TestEntityMapper implements RowMapper<TestEntity> {
    @Override
    public RiExpirationTarget mapRow(ResultSet rs, int rowNum) throws SQLException {
        TestEntity testEntity = new TestEntity();
        testEntity.setEmails((String[]) rs.getArray("emails").getArray());
     	~~~~
        return testEntity;
    }
}

여기까지 왔는데 아래 포스팅을 발견...  갑자기 이런 의문이 든다;

아무리 복잡한 쿼리라고 해도 우아한 형제들에서 쓰는 쿼리보다 복잡하려나? 내가 혹시 이상한 길로 간게 아닐까 하는 의구심이 들며... 우선은 이번 프로젝트를 마치고 바로 해당 부분을 리팩토링 하는 것을 목표로 삼겠다.. 

https://techblog.woowahan.com/2662/

 

2) Processor에서 DB에 접근해 데이터를 가져오는 것이 찝찝한 느낌.. 그래도 될까? (Reader에서 처리되어야 할 것 같은 너낌 적인 너낌)

 

Spring Batch의 Processor 단계에서 DB에 접속해서 데이터를 가져오는 로직이 존재해도 된다고 한다.

Processor는 일반적으로 데이터의 변환 및 가공을 수행하는 단계로 데이터 소스에서 필요한 데이터를 가져와 가공하는 것이 일반적이라고 한다.

 

그러나 다음과 같은 사항에 유의해야 한다고 한다.

  1. 성능 문제: Processor가 데이터 소스에 매번 접속하고 데이터를 가져오는 것은 성능상 좋지 않을 수 있습니다. 따라서 대량의 데이터를 처리할 때는 Chunk 단위로 처리하거나, 데이터베이스 쿼리 최적화를 고려해야 합니다.
  2. 트랜잭션 처리: Processor에서 데이터 소스에 접속하고 데이터를 가져올 때, 트랜잭션 처리에 대한 고민이 필요합니다. 

따라서 Processor에서 DB에 접속해서 데이터를 가져오는 로직이 존재해도 되지만, 성능과 트랜잭션 처리에 대한 고민이 필요한데 Spring Batch에서는 일반적으로 Chunk 단위로 트랜잭션 처리를 하므로, Processor에서도 Chunk 단위로 트랜잭션 처리를 해야한다. 

Processor에서 사용하는 메소드에 @Transactional을 선언할 수 있지만, 이 경우 Chunk 단위로 트랜잭션이 처리되지 않을 수 있다. 따라서 @Transactional을 사용할 경우, Processor가 Chunk 단위로 실행되도록 설정해야 한다.

 

Chunk 단위로 트랜잭션 처리를 하기 위해서는 ItemReader에서 데이터를 읽어올 때, 트랜잭션을 시작하고, ItemWriter에서 데이터를 처리한 후에 트랜잭션을 커밋하면 된다. 이렇게 Chunk 단위로 트랜잭션 처리를 하면, Processor에서 DB에 접속해서 데이터를 가져오는 로직을 작성해도 안전하게 사용할 수 있게 된다.

신규 서비스에 Java 17 버전을 사용할까 싶어 이참에 다시 한 번 자바 버전별 특징에 대해 정리해보기로 했다. 

 

 

Java 8 (2014년 3월 출시) - LTS

  • Lambda 표현식 추가 (익명 함수를 생성할 수 있는 기능)
  • Stream API 추가 (컬렉션 요소를 다루는 기능)
  • Date and Time API(Joda Time 기반) 추가 (thread-safe)
  • PermGen 메모리 영역 삭제, 메모리 구조 변경
  • Optional 클래스 (null 값을 처리)
  • 메서드 참조
  • 인터페이스 변경 (default 메서드와 static 메서드를 추가)
  • Completable Future (멀티 스레드 프로그래밍)

Java 9 (2017년 9월 출시)

  • 모듈 시스템 추가(필요한 모듈만 로드하여 불필요한 모듈을 로드하지 않아 보다 경량화된 애플리케이션 개발 가능)
  • 컬렉션 팩토리 메서드 강화
    • Set, List, Map 인터페이스에 Immutable 객체를 생성할 수 있는 새로운 메서드가 추가되었다.
      • List.of("a", "b", "c"), Set.of("a","b"), Map.of("a","abc", "가", "가나다")
  • 인터페이스의 private 메소드 지원
  • G1 GC 기본 GC로 설정 

Java 10 (2018년 3월 출시)

  • 지역 변수 형 추론(var) 추가
  • GC 인터페이스 개선 (Java 10에서는 G1 GC의 최대 힙 크기에 대한 기본값이 변경)

Java 11 (2018년 9월 출시) - LTS

  • HTTP 클라이언트 API 추가 (자바에서도 http로 api 호출이 가능했음 이전에는 라이브러리를 이용해야지만 가능했음)
  • String 클래스에 새로운 메소드 추가 (isBlank(), strip() 등)
  • Files 클래스에 새로운 메소드 추가 (writeString(~), readString() 등)
  • 컬렉션 인터페이스에 새로운 메소드 추가 (.toArray()) -> 원하는 타입의 배열을 선택하여 반환 가능해짐
    • String[] sampleArray = Arrays.asList("Java", "Kotlin").toArray(String[]::new);

Java 12 (2019년 3월 출시)

  • Switch 표현식 개선
  • G1 GC 개선

Java 13 (2019년 9월 출시)

  • Switch 표현식 개선(불필요한 break 문 없이 사용 가능)
String fruit = "banana";
int numLetters = switch (fruit) {
    case "apple", "pear" -> 5;
    case "banana", "kiwi", "orange" -> 6;
    case "avocado", "mango" -> 7;
    default -> 0;
};
  • Text Blocks 추가 (이후 14에서 공식 지원됨)
    • 문자열을 더욱 가독성 있게 작성할 수 있습니다. Text Blocks는 따옴표 대신 백틱(backtick) 기호(`)를 사용하고, 여러 줄의 문자열을 쉽게 작성할 수 있습니다.
String xml = """
    <document>
        <content>
            Text Blocks Example
        </content>
    </document>
""";

Java 14 (2020년 3월 출시)

  • Switch 표현식 개선(화살표 연산자 사용 가능)
  • Record 클래스 추가(데이터 저장 클래스로 final로 선언, getter 메서드와 equals() 및 hashCode() 메서드 자동 생성)
  • 패턴 매칭 instanceof 연산자
if (obj instanceof String) {
    String str = (String) obj;
    // str 객체를 사용합니다.
}

14버전에서는.. 아래와 같이 변경됨으로써 캐스팅 과정을 따로 거치지 않을 수 있게 되었다. 

if (obj instanceof String str) {
    // str 객체를 사용합니다.
}
  • NullPointerException 개선 (발생한 객체의 이름과 값이 함께 출력되어 오류 해결데 도움됨)

Java 15 (2020년 9월 출시)

  • Sealed 클래스 도입(클래스의 상속 제한 가능)
  • Hidden Classes 추가 (Reflection API를 통해 접근할 수 없는 클래스)

Java 16 (2021년 3월 출시)

  • (14의 기능으로부터 확정) instanceof 패턴 매칭 강화
  • Records 클래스 확장
  • Vector API 추가

Java 17 (2021년 9월 출시) - LTS

  • Sealed 클래스 확장
  • 패턴 매칭과 Switch 문 확장
  • AOT(Head of Time) 컴파일러 추가
  • ZGC, Shenandoah GC 개선 등 성능 향상

 

현재 11버전을 사용하고 있고, Optional이나 Stream, 디폴트인터페이스, 람다 등은 이제는 굉장히 유용하게, 빈번하게 사용하는 지라 17로 업그레이드를 할 경우 많은 부분에서 업그레이드가 되지 않을까 하는 기대감이 드는데 더욱 자세히 알아봐야겠다..

- AWS SES

 Amazon SES는 모든 애플리케이션에 통합하여 대량으로 이메일을 전송할 수 있는 클라우드 이메일 서비스 공급자입니다. Simple Mail Transfer Protocol(SMTP) 시스템을 구현하지 않아도 됩니다.

 

- 비용 & 호출 방법

애플리케이션이 Amazon Elastic Compute Cloud(Amazon EC2)에서 실행되는 경우 Amazon SES를 사용하여 추가 비용 없이 매월 62,000개의 이메일을 전송할 수 있습니다. AWS SDK를 사용하거나, Amazon SES SMTP 인터페이스를 사용하거나, Amazon SES API를 직접 호출하여 Amazon EC2에서 이메일을 보낼 수 있습니다.

 

AWS SES 클라이언트 설정

implementation platform('software.amazon.awssdk:bom:2.3.9')
implementation 'software.amazon.awssdk:sesv2:+'
    @Bean
    public SesV2Client getAmazonSimpleEmailServiceClient() {
        AwsCredentials credentials = null;
        if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) {
            credentials = AwsBasicCredentials.create(accessKey, secretKey);
        }
        AwsCredentialsProvider provider = StaticCredentialsProvider.create(credentials);
        return SesV2Client.builder()
                .credentialsProvider(provider)
                .region(Region.of(sesRegion))
                .build();
    }

 

이메일 구성

try {
    Content subject = Content.builder().data(sendContent.getTitle()).build();
    Content content = Content.builder().data(thymeLeafHtml).build();
    Message msg = Message.builder().subject(subject).body(Body.builder().html(content).build()).build();
    EmailContent emailContent = EmailContent.builder().simple(msg).build();
    Destination destination = Destination.builder().toAddresses("test@naver.com").build();

    SendEmailRequest emailRequest = SendEmailRequest.builder()
                                                    .destination(destination)
                                                    .content(emailContent)
                                                    .fromEmailAddress("testSender@naver.com")
                                                    .build();

    sesV2Client.sendEmail(emailRequest);
} catch (SesV2Exception e) {
            log.error("Encountered an error processing sending email.\n");
            e.printStackTrace();
            throw e;
}

 

- 예외는 어떻게? (AWS SDK 사용의 경우)

예외를 사용하는 프로그래밍 언어로 AWS SDK를 사용하는 경우 SES 호출이 MessageRejectedException을 발생시킬 수 있습니다. 

 

- MIME

Multipurpose Internet Mail Extension으로 전자 우편을 위한 인터넷 표준 포맷입니다. 전자우편은 7비트 ASCII 문자를 사용하여 전송되기 때문에, 8비트 이상의 코드를 사용하는 문자나 이진 파일들은 MIME 포맷으로 변환되어 SMTP로 전송됩니다.

 

- MIME과 AWS SES의 관계?

AWS SES(Simple Email Service)는 이메일 메시지를 전송하기 위해 MIME(Multipurpose Internet Mail Extensions) 프로토콜을 사용합니다. MIME은 이메일 메시지를 텍스트, 이미지, 오디오, 비디오 등 다양한 멀티미디어 형식으로 변환하고, 인코딩 및 디코딩을 지원하는 표준 규약입니다. 이를 통해 다양한 종류의 파일을 이메일로 전송할 수 있습니다.

 

AWS SES는 MIME 형식을 준수하는 이메일 메시지를 전송할 수 있습니다. 이를 위해 AWS SES에서는 이메일 메시지 본문과 첨부 파일을 MIME 형식으로 구성해야 합니다. 이를 위해서는 이메일 메시지의 콘텐츠 유형(Content-Type), 인코딩 방식(Content-Transfer-Encoding), 그리고 각 파트의 경계선(Boundary)을 설정해야 합니다.

 

AWS SES에서는 이러한 MIME 형식으로 구성된 이메일 메시지를 전송하기 위해 다양한 API 및 SDK를 제공합니다.

이를 통해 개발자는 자신이 사용하는 프로그래밍 언어에 맞는 인터페이스를 통해 이메일을 보낼 수 있습니다.

 

따라서, AWS SES와 MIME은 이메일 전송에서 밀접한 관계가 있으며, MIME의 규약을 준수하는 이메일 메시지를 AWS SES에서 전송함으로써 다양한 형식의 파일을 이메일로 전송할 수 있습니다.\

 

-AWS SES를 이용해 이메일을 전송할 때 quoted printable( 이메일 본문에 특수 문자나 이메일 주소와 같은 ASCII 이외의 문자가 포함될 경우, 이메일 내용을 안전하게 전송하기 위해 필요한 인코딩 방식) 을 고려해야 하나요?

 

일반적으로 AWS SES(Simple Email Service)를 사용하여 이메일을 보낼 때, quoted-printable 인코딩은 자동으로 적용됩니다. 따라서 별도로 quoted-printable 인코딩을 고려할 필요는 없습니다. AWS SES를 사용하는 경우, 이메일을 전송할 때 필요한 인코딩 방식은 자동으로 선택되므로, 이메일의 내용이 안전하게 전송되도록 보장됩니다.

 

그러나, 이메일의 특정 부분에 대해 다른 인코딩 방식을 사용하고자 하는 경우, AWS SES는 이를 수행할 수 있는 API를 제공합니다. 이를 통해 별도의 인코딩 방식을 선택할 수 있습니다. 따라서 필요에 따라 인코딩 방식을 선택하여 이메일을 전송할 수 있습니다.

 

PostgreSQL

 

관계형 DBMS 중 하나로 MySQL이 오라클에 인수됨에 따라 요즘 더욱 각광을 받고 있는 추세이다.

주요 특징은 무료 오픈소스, 타 DBMS보다 안정성과 트랜잭션 및 ACID가 좋으며

JSONB나 ARRAY같은 타입을 사용할 수 있다는 장점이 있다. 

 

내가 사용하는 버전은 13.7으로 정확히 말하면 AmazonAurora Postgresql 13이다. Amazon Aurora(Aurora)는 MySQL 및 PostgreSQL과 호환되는 완전 관리형 관계형 데이터베이스 엔진이다 .

(Aurora는 볼륨 크기를 증가시킬 수 있고 또한 데이터베이스 구성 및 관리의 가장 어려운 측면 중 하나인 데이터베이스 클러스터링 및 복제를 자동화하고 표준화하는 이점이 있습니다).

 

더보기

ACID

  • 원자성(Atomicity)은 트랜잭션과 관련된 작업들이 부분적으로 실행되다가 중단되지 않는 것을 보장하는 능력이다. 예를 들어, 자금 이체는 성공할 수도 실패할 수도 있지만 보내는 쪽에서 돈을 빼 오는 작업만 성공하고 받는 쪽에 돈을 넣는 작업을 실패해서는 안된다. 원자성은 이와 같이 중간 단계까지 실행되고 실패하는 일이 없도록 하는 것이다.
  • 일관성(Consistency)은 트랜잭션이 실행을 성공적으로 완료하면 언제나 일관성 있는 데이터베이스 상태로 유지하는 것을 의미한다. 무결성 제약이 모든 계좌는 잔고가 있어야 한다면 이를 위반하는 트랜잭션은 중단된다.
  • 독립성(Isolation)은 트랜잭션을 수행 시 다른 트랜잭션의 연산 작업이 끼어들지 못하도록 보장하는 것을 의미한다. 이것은 트랜잭션 밖에 있는 어떤 연산도 중간 단계의 데이터를 볼 수 없음을 의미한다. 은행 관리자는 이체 작업을 하는 도중에 쿼리를 실행하더라도 특정 계좌간 이체하는 양 쪽을 볼 수 없다. 공식적으로 고립성은 트랜잭션 실행내역은 연속적이어야 함을 의미한다. 성능관련 이유로 인해 이 특성은 가장 유연성 있는 제약 조건이다. 자세한 내용은 관련 문서를 참조해야 한다.
  • 지속성(Durability)은 성공적으로 수행된 트랜잭션은 영원히 반영되어야 함을 의미한다. 시스템 문제, DB 일관성 체크 등을 하더라도 유지되어야 함을 의미한다. 전형적으로 모든 트랜잭션은 로그로 남고 시스템 장애 발생 전 상태로 되돌릴 수 있다. 트랜잭션은 로그에 모든 것이 저장된 후에만 commit 상태로 간주될 수 있다.
Elasticsearch

 

Lucene을 기반으로 한 검색 엔진이다.

내가 생각하는 주요 특징으로는 전문검색(full-text), 역색인, 분산처리로 실시간성 빠른 검색 가능,

Schemaless, RESTful API, Multi-tenancy가 있다.

  • 전문 검색
    • 내용 전체를 색인해서 특정 단어가 포함된 문서를 검색할 수 있다.
  • 역색인 
    • 일반적인 색인은 책의 목차를 말하는 것이라면 역색인은 책 뒤편의 단어별 색인 페이지라고 보면 된다. 문서의 위치를 찾아 빠르게 접근하겠다는 목적이 아니라 문서 내의 문자 내용과 같은 내용 물에 빠르게 접근하겠다는 목적에서 나온 개념이다.
  • Schemaless
    • 비정형 문서도 자동으로 색인하고, 검색할 수 있게 한다. 
  • Multi-tenancy
    • 여러 인덱스에 걸쳐 검색이 가능하다 (필드명이 같다는 전제 하에)

 

 

참고 :

https://jaemunbro.medium.com/elastic-search-%EA%B8%B0%EC%B4%88-%EC%8A%A4%ED%84%B0%EB%94%94-ff01870094f0

 

 

1) 데이터 일부가 read/write이 안된다..

같은 Entity를 Reader로 읽고, Writer로 Update하려니까 전체가 100개라고 하면 약 50개 밖에 Update가 안되는 문제가 있었다. JpaPagingItermReader를 사용해서 그런 것이었고, 원인은 update를 chunk 단위로 함에 따라 limist과 offset를 이용해 select하는 결과값이 달라져서였다. 해결 방법은 Cursor를 사용하거나 PagingReader를 Overriding 하는 것이고, 자세한 내용은 아래에 글에 나와있다. (빨리 원인을 찾고 해결해서 다행..ㅠ)

 

https://jojoldu.tistory.com/337

 

 

Spring Batch Paging Reader 사용시 같은 조건의 데이터를 읽고 수정할때 문제

안녕하세요. 이번 시간에는 Spring Batch를 사용하시는 분들이 자주 묻는 질문 중 하나인 같은 조건의 데이터를 읽고 수정할때 어떻게 해야하는지 에 대해서 소개드리려고 합니다. 모든 코드는 Githu

jojoldu.tistory.com

 

2) 데이터를 가공해야만 한다..

DB에 저장된 원본 데이터 1row를 읽고 다른 테이블에 1row를 저장하는 설계를 바탕으로 개발을 했는데 N rows : 1 row의 데이터 연관관계가 있었다; 이에 따라 N rows를 1rows로 만드는 Step(2개)을 만들어 메인 Step전에 수행시켜 줬다. 

 

 

Main Step이 실행되기 전에 Sub1과 Sub2를 거쳐야 하며 Sub1이 실패가 나던, Sub2가 실패가 나던 무조건 StepMain은 실행해야 되는 논리구조다.

  • Sub1, Sub2 둘 다 실패가 나더라도 Step Main 은 실행
  • Sub1이 실패하면 Sub2->Main, Sub2가 실패하면 면 Sub1->Main

 

 

 

위와 같은 논리 구조를 아래와 같이 구현해봤는데, 살짝 의구심이 들었지만 테스트를 해보니 원하는 대로 동작한다. 

    @Bean
    public Job expirationNotificationJob() {
        return jobBuilderFactory.get("expirationNotificationJob")
                                .start(sub1ExpirationStep())
                                    .on("*")
                                    .to(sub2ExpirationStep())
                                    .on("*")
                                    .to(mainExpirationStep())
                                    .on("*")
                                    .end()
                                .end()
                                .build();
    }

+ Recent posts