728x90
반응형

Spring

비대면 고객케어 솔루션 개발 도중 타 모듈로부터 연동데이터를 File로 받아야 하는 요구사항이 들어왔다.
하루 평균 2~3만건의 데이터가 연동되고 이후 데이터량이 증가될 것을 고려하여 Spring Batch를 활용하기로 했다.

구체적인 개발 내용은 타 모듈로부터 File형태로 데이터들을 연동받으면,
Spring Batch가 주기적으로 연동받은 File을 읽어서 PostgreSQL DBdp 저장하는 것이다.

개발을 위해 Spring Batch에 대해 공부해야 했다. 👉Spring Batch란?

초기 환경설정

  • 버전 정보
    JDK 1.8, Spring Boot 2.6.4

개발 시작 전, Batch 환경을 위한 설정을 추가하였다.
(아래 코드들은 개발 시 실제 작성했던 코드와 네이밍을 다르게 하여 작성했다.)

  • application.yml
ENV_POSTGRESQL: 127.0.0.1
ENV_POSTGRESQL_PORT: 5432
ENV_POSTGRESQL_DB: batch
ENV_POSTGRESQL_SCHEMA: daewoong
POSTGRESQL_USERNAME: postgres
POSTGRESQL_PASSWORD: postgres

server:
    port: 8080

spring:
    main:
        allow-bean-definition-overriding: true

    datasource:
        url: jdbc:postgresql://${ENV_POSTGRESQL}:${ENV_POSTGRESQL_PORT}/${ENV_POSTGRESQL_DB}
        username: ${POSTGRESQL_USERNAME}
        password: ${POSTGRESQL_PASSWORD}

    jpa:
        hibernate:
            ddl-auto: create
        database-platform: org.hibernate.dialect.PostgreSQLDialect
        properties:
            hibernate:
                format_sql: true
                show_sql: true

위 설정을 간략히 요약하면,

  1. DB 연결을 위한 설정, jdbc:postgresql://127.0.0.1:5432/batch, postgres/postgres
  2. AP서버 port : 8080
  3. allow-bean-definition-overriding: true >> 수동생성 빈과 자동생성 빈의 중복문제 방지
  4. ddl-auto: create >> AP서버 부팅 시, DB 초기화
  5. database-platform: org.hibernate.dialect.PostgreSQLDialect >> PostgreSQL 방언 설정
  6. log에 SQL형태로 SQL을 나타냄.

그리고 아래와 같이 main클래스에 @EnableBatchProcessing어노테이션을 붙이면 공통 Batch 설정이 마무리 된다.
(@EnableScheduling어노테이션은 Batch와 직접적인 연관은 없지만, Batch Job을 주기적으로 실행시키기 위해 선언되어야 한다.)

  • InterfaceApplication.java
@EnableBatchProcessing
@EnableScheduling
@SpringBootApplication
public class InterfaceApplication {

    public static void main(String[] args) {
        SpringApplication.run(InterfaceApplication.class, args);
    }

}

배치 job 구현

Spring Batch 구현방식은 Chunk-Oriented방식을 활용하였다.
전달받는 파일은 아래 format으로 구성되어 있다.
파일 상의 1개 row는 DB에 저장될 row이고, |문자를 구분자로 하여 4개 컬럼에 나눠 저장될 것이다. 컬럼 지정은 AP 코드 상에서 진행된다.

  • INTERFACE_202304182300.txt
AAA|2023-04-17|112233|test1
BBB|2023-04-17|123123|test2
CCC|2023-04-17|321321|test3
DDD|2023-04-18|332211|test4

이제 위 파일을 읽어서 DB에 저장할 배치job을 정의해야 한다.
아래는 스케줄러로 실행시킬 배치 job의 config이다.

  • InterfaceFileReaderBatchJobConfig.java
@Configuration
@RequiredArgsConstructor
public class InterfaceFileReaderBatchJobConfig {

    private final JobBuilderFactory jobBuilderFactory;
      private final StepBuilderFactory stepBuilderFactory;

    private final InterfaceProcessor interfaceProcessor;
    private final InterfaceWriter interfaceWriter;

    private static final int chunkSize = 1000;

    @Bean
    public Job interfaceJob(Step interfaceStep) throws IOException {
        return jobBuilderFactory.get("interfaceJob")
                .start(interfaceStep)
                .build();
    }

    @Bean
    public Step interfaceStep(FlatFileItemReader<Result> resultFlatFileItemReader) throws IOException {
        return stepBuilderFactory.get("interfaceStep")
                .<Result, Result>chunk(chunkSize)
                .reader(resultFlatFileItemReader)
                .processor(interfaceProcessor)
                .writer(interfaceWriter)
                .build();
    }
}

위 config는 핵심 객체인 Job객체와 Job이 실행시킬 Step 객체를 생성하기 위한 설정이다.

Chunk-Oriented방식에서 Step은 데이터를 읽는 ItemReader, 데이터 처리 ItemProcessor, 데이터 쓰기 ItemWriter 3개 역할로 나눠서 수행된다.
chunkSize는 임의대로 1000으로 설정하였다. Result객체 단위로 파일에서 1000개 row씩 읽어서 데이터를 처리한다.
ItemReader의 대상은 File이기때문에 FlatFileItemReader객체를 활용하여 데이터를 파싱한다.

  • InterfaceReader.java
@Component
@RequiredArgsConstructor
public class InterfaceReader {

    public FlatFileItemReader<Result> resultFlatFileItemReader() throws IOException {

        String targetFile = null;
        List<String> findFileList = new ArrayList<String>();
        String rootDir = "/save/";
        String fileNamePrefix = "INTERFACE_";
        String fileFullname = rootDir + fileNamePrefix;

        FlatFileItemReader<Result> flatFileItemReader = new FlatFileItemReader<>();
        flatFileItemReader.setEncoding("UTF-8");

        try {

            // FileReader 설정
            setFileReaderConfiguration(Result.class, new ResultFieldSetMapper(), flatFileItemReader);

            Path dirPath = Paths.get(rootDir);
            Stream<Path> walk = Files.walk(dirPath, 1);
            List<Path> fileList = walk.collect(Collectors.toList());

            // Read 대상 파일 찾기
            for (Path path : fileList) {
                int extensionIdx = path.toString().lastIndexOf(".");
                if (path.toString().startsWith(fileFullname) && extensionIdx != -1 && !path.toString().substring(0, extensionIdx).endsWith("END")) {
                    targetFile = path.toString();
                    findFileList.add(path.toString());
                }
            }

            flatFileItemReader.setResource(new FileSystemResource(targetFile));

        }
        catch (NoSuchFileException e) {
            System.out.println(e.toString());
        }
        catch (NullPointerException e) {
            System.out.println(e.toString());
        }
        catch (Exception e) {
            System.out.println(e.toString());
        }

        return flatFileItemReader;
    }

    private <T> void setFileReaderConfiguration(Class<T> c, FieldSetMapper<T> mapClass, FlatFileItemReader<T> flatFileItemReader) {

        // File 내 구분자 설정
        DelimitedLineTokenizer delimitedLineTokenizer = new DelimitedLineTokenizer("|");
        // 구분자 기준, 저장될 컬럼 이름 및 순서 지정
        delimitedLineTokenizer.setNames("NAME", "DATE", "SEQ", "STRING");

        // Mapper 설정
        DefaultLineMapper<T> defaultLineMapper = new DefaultLineMapper<>();
        BeanWrapperFieldSetMapper<T> beanWrapperFieldSetMapper = new BeanWrapperFieldSetMapper<>();
        defaultLineMapper.setLineTokenizer(delimitedLineTokenizer);
        if (mapClass != null) {
            defaultLineMapper.setFieldSetMapper(mapClass);
        }
        else {
            beanWrapperFieldSetMapper.setTargetType(c);
            defaultLineMapper.setFieldSetMapper(beanWrapperFieldSetMapper);
        }

        flatFileItemReader.setLineMapper(defaultLineMapper);

    }

}

위 코드를 보면 파일에서 읽은 데이터를 객체에 알맞게 매핑하여 넣어줄 용도로 ResultFieldSetMapper Mapper클래스를 활용한다.
해당 클래스는 커스터마이징하여 임의로 정의한 Mapper클래스이다.
default Mapper클래스가 아닌 custom Mapper클래스를 활용하는 이유는 데이터를 받을 클래스는 Entity클래스이고, 날짜 형식이나 Not null조건과 같이 Entity 필드 조건에 맞게 데이터가 입력되어야 하기 때문에 default Mapper를 활용하면 Mapping error가 발생한다.
해당 error를 방지하기 위해 날짜형식을 가공하여 데이터를 Mapping시켜주는 Custom Mapper클래스를 정의하여 활용하였다.

아래는 해당 Mapper클래스와 Entity객체이다.

  • ResultFieldSetMapper.java
public class ResultFieldSetMapper implements FieldSetMapper<Result> {

    public Result mapFieldSet(FieldSet fieldSet) {

        Result result = new Result();

        if (fieldSet == null) {
            return null;
        }

        // 읽은 데이터의 date 형식과 entity date형식의 차이로 인한 오류를 방지
        // 아래 정의된 형식대로 데이터가 들어오지 않아 Exception이 발생하여 데이터 저장에 오류를 발생하는 것을 방지하기 위해 catch문을 통한 null입력 구문 추가
        try {
            result.setDate(fieldSet.readDate("DATE", "yyyy-MM-dd"))
        } catch {
            result.setDate(null);
        }

        result.setName(fieldSet.readString("NAME"));
        result.setSeq(fieldSet.readString("SEQ"));
        result.setString(fieldSet.readString("STRING"));

    }

}
  • Result.java
@Table(name = "tb_result")
@Data
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@Entity
@IdClass(ResultId.class)
public class Result implements Serializable {

    @Id
    @Column(name = "name", length = 20, nullable = false)
    private String name;

    @Id
    @Column(name = "seq", length = 10, nullable = false)
    private String seq;

    @Column(name = "date")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private Date date;

    @Column(name = "string", length = 20)
    private String string;

}
  • ResultId.java
public class ResultId implements Serializable {

    @Column(name = "name", length = 20, nullable = false)
    private String name;

    @Column(name = "seq", length = 10, nullable = false)
    private String seq;

}

복수 개의 ID 컬럼이 있을 경우, 위와 같이 Serializable인터페이스를 상속 받아 ID클래스를 별도로 정의하여 설정한다.

위 Job을 통해 데이터를 읽고, 아래 ResultProcessorResultWriter 객체를 통해 데이터를 처리하여 DB에 저장한다.

  • ResultProcessor.java
@Configuration
public class ResultProcessor implements ItemProcessor<Result, Result> {

    public Result process(Result result) throws Exception {
        // Result Entity 객체로 읽은 데이터의 추가 처리가 필요할 시, 해당 메소드에 처리 로직을 구성하면 된다.
        return result;
    }

}
  • ResultWriter.java
@Configuration
@RequiredArgsConstructor
public class ResultWriter implements ItemWriter<Result> {

    private final ResultRepository resultRepository;

    @Override
    public void write(List<? extends Result> list) throws Exception {

        resultRepository.saveAll(new ArrayList<Result>(list));

    }

}
  • ResultRepository.java
public interface ResultRepository extends JpaRepository<Result, Long> {

}

이제 Job 정의는 다 끝났다.

위와 같이 정의된 Job을 주기적으로 실행하기 위해 Scheduler를 정의한다.

정의된 job InterfaceFileReaderBatchJobConfig을 주입받고
주입받은 Job을 JobLauncher를 활용하여 실행하는데, Job 실행단위인 JobInstance 간의 차이를 두기 위해
JobParameters를 JobLauncher에 job과 같이 parameter로 넘겨준다.

그리고 해당 JobLauncher를 Scheduler로 실행하는 형태로 구성하여
최종적으로 일정주기마다 배치 job이 실행되는 형태를 구성할 수 있다.

  • JobScheduler.java
@Component
@RequiredArgsConstructor
public class JobScheduler {

    private final JobLauncher jobLauncher;

    private final InterfaceFileReaderBatchJobConfig interfaceFileReaderBatchJobConfig;

    private final InterfaceReader interfaceReader;

    // 스케줄러 주기 설정
    @Scheduled(cron = "0 * * * * *")
    public void runJob() throws IOException {

        // JobParameters를 실시간 시간으로 설정
        Map<String, JobParameter> confMap = new HashMap<>();
        confMap.put("time", new JobParameter(System.currentTimeMillis()));
        JobParameters jobParameters = new JobParameters(confMap);

        try {

            // 배치 job 실행
            jobLauncher.run(interfaceFileReaderBatchJobConfig.interfaceJob(
                interfaceFileReaderBatchJobConfig.interfaceStep(interfaceReader)), jobParameters
            );

        } catch (JobExecutionAlreadyRunningException | JobInstanceAlreadyCompleteException | JobParametersInvalidException | JobRestarException e) {
            System.out.println(e.getMessage())
        }

    }

}

이어서

지금까지 File to DB 기능을 구현하기 위한 기본적인 개발 과정을 거쳤다.
하지만 완벽한 기능 구현까지의 몇 차례의 시행착오가 있었고,
Batch의 추가적인 기능이 필요했다.

다음 글에서는 앞서 겪었던 시행착오와 Batch 추가기능을 통해 File 처리하는 개발과정을 기록할 것이다.

반응형
728x90
반응형


비대면 고객케어 개발 프로젝트를 진행하면서 spring batch를 활용할 일이 생겼다.
연동을 위해 필요한데, 타 시스템에서 파일형태로 대용량의 데이터를 넘겨주면
주기적으로 넘겨받은 파일을 읽어서 DB에 저장하는 연동이다.

연동 Flow는 간단하지만, 일 20만건 이상의 대용량의 데이터를 다루기 때문에 batch를 고려하게 되었다.

1. Spring Batch란?

Spring Batch는 Spring환경에서 대용량의 데이터 처리를 위한 기능을 제공하는 프레임워크이다.
Batch의 사전적 의미도 일괄처리인 것처럼 Spring환경에서 대용량의 데이터 처리를 위해 사용된다.

대용량의 데이터 처리를 위한 기능으로는 로깅/추적, 트랜잭션 관리, 작업 처리 통계, 작업 재시작, 건너뛰기, 리소스 관리 등이 있다.

Batch에 대한 오해

"Spring Batch는 스케줄러가 아니다."
Spring Batch를 제대로 접하기 전까지 나는 Batch가 특정 작업을 일정 주기마다 반복적으로 실행시키기 위한 프레임워크로 알고 있었다. 하지만 Batch와 스케줄러는 별개의 기능이다.

Batch는 데이터를 대용량으로 일괄처리하기 위한 Job이라는 형태의 객체를 제공할 뿐이고, 해당 Job을 제공받아 실행하는 스케줄러는 별도로 정의해주어야 한다.

스케줄러의 형태는 @Scheduled어노테이션을 붙이는 가장 기본적인 형태부터 Quartz까지 다양하게 정의될 수 있다.
본 글에서는 기본적 형태의 스케줄러를 적용하여 Batch를 알아볼 것이다.

2. Batch 아키텍처

Batch는 기본적으로 아래 사진처럼 구성되어 있다. 하나씩 살펴보자.

Job Scheduler

: 정의된 배치Job을 일정 주기마다 실행시켜주는 Scheduler 객체이다. 위에서 언급했던 것처럼 Batch와는 직접적인 연관이 없다.

Job Launcher

: 실제로 배치Job을 실행시켜주는 객체이다. Scheduler와 함께 사용한다면, Scheduler 객체 안에서 Job Launcher를 활용하여 배치Job을 실행시켜주는 방식으로 사용된다. Job Instance를 구분하기 위한 Job Parameter를 함께 입력할 수 있다.

Job

: 실제 데이터를 Batch로 처리하기 위한 작업 단위이다. 비유한다면, Job Launcher는 작업자이고 Job은 작업자에게 할당된 작업의 내용이라고 보면 된다.

Step

: 정의된 Job을 효율적이고 안정적으로 처리하기 위해 작업을 나눈 단위이다. Job을 요리에 비유한다면, 요리라는 Job은 재료준비-재료손질-조리라는 과정대로 진행될 것이고, 재료준비와 같은 각 순서들이 Step이라고 볼 수 있다.

Job Repository

: 정의된 Job의 처리를 위한 메타데이터가 저장되어있는 저장소이다. 해당 메타데이터는 위에서 언급한 Batch의 대용량 처리를 위한 기능(로깅/추적, 작업재시작, 건너뛰기 등)에 활용된다. 위에서 비유한 것처럼 Job이 요리라면, 나의 요리일지(?)나 미리 손질된 재료들의 정보들이 메타데이터이고 Job Repository에 기록된다고 보면 된다.

tasklet 방식

tasklet은 Spring Batch의 Step을 구현하는 기본 방식이다.

chunk 방식

chunk방식은 chunk 단위 만큼 Transaction을 수행하게 할 수 있는 방식이다.
step을 정의할 때, chunk의 수를 정할 수 있다. step이 한번 수행될 때마다 chunk 수만큼 transaction이 실행되므로
step 실행 도중 실패시, rollback도 chunk수만큼 수행된다.

chunk방식은 step이 아래 3개 모듈로 나눠서 실행된다.

ItemReader

: 데이터를 읽는 모듈이다. File을 읽을 수도 있고, DB를 읽을 수도 있다.

ItemProcessor

: 데이터를 처리하는 모듈이다. 데이터를 저장하기 전에 가공이 필요할 경우, 해당 모듈에서 진행하게 된다.
해당 모듈은 option이다.

ItemWriter

: 데이터를 저장하는 모듈이다. DB에 저장하거나 File로 남길 수 있다.

3. 예제

  • Job Scheduler
    : JobLauncher를 활용하여 주기적으로 job을 실행시켜주는 객체
@Configuration
@RequiredArgsConstructor
public class JobScheduler {

    private final JobLauncher jobLauncher;
    private final ExampleJobConfig exampleJobConfig ;

    @Scheduled(cron = "1 * * * * *")
    public void jobScheduled() throws JobParametersInvalidException, JobExecutionAlreadyRunningException,
            JobRestartException, JobInstanceAlreadyCompleteException {

            // make job parameter
            Map<String, JobParameter> jobParametersMap = new HashMap<>();
            SimpleDateFormat format1 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS");
            Date time = new Date();
            String time1 = format1.format(time);
            jobParametersMap.put("date",new JobParameter(time1));
            JobParameters parameters = new JobParameters(jobParametersMap);

            // job start
            JobExecution jobExecution = jobLauncher.run(exampleJobConfig.exampleJob, parameters);

    }

}
  • Job (tasklet 방식)
    : 실제 실행되는 job 정의
@Configuration
@RequiredArgsConstructor
public class ExampleJobConfig {

    private final JobBuilderFactory jobBuilderFactory;
    private final StepBuilderFactory stepBuilderFactory;

    @Bean
    public Job exampleJob() {
        return jobBuilderFactory.get("exampleJob")
                .start(exampleStep())
                .build();
    }

    @Bean
    public Step exampleStep() {
        return stepBuilderFactory.get("exampleStep")
                .tasklet(new ExampleTasklet())
                .build();
    }

}
  • tasklet
@Slf4j
public class ExampleTasklet implements Tasklet, StepExecutionListener {

    @Override
    @BeforeStep
    public void beforeStep(StepExecution stepExecution) {
        log.info("Before Step");
    }

    @Override
    @AfterStep
    public ExitStatus afterStep(StepExecution stepExecution) {

        log.info("After Step");

        return ExitStatus.COMPLETED;
    }

    @Override
    public RepeatStatus execute(StepContribution stepContribution, ChunkContext chunkContext) throws Exception {

        //비즈니스 로직
        log.info("Business Logic");

        return RepeatStatus.FINISHED;
    }

}
  • Job (chunk 방식)
    : 실제 실행되는 job 정의, Reader, Processor, Writer를 각각 정의해주어야 한다. (외부 클래스로 빼도 됨)
@Configuration
@RequiredArgsConstructor
public class ExampleJobConfig {

    private final JobBuilderFactory jobBuilderFactory;
    private final StepBuilderFactory stepBuilderFactory;

    @Bean
    public Job exampleJob() {
        return jobBuilderFactory.get("exampleJob")
                .start(exampleStep())
                .build();
    }

    @Bean
    public Step exampleStep() {
        return stepBuilderFactory.get("exampleStep")
                .<Member,Member>chunk(10)
                .reader(reader())
                .processor(processor())
                .writer(writer())
                .build();
    }

    @Bean
    @StepScope
    public JdbcPagingItemReader<Member> reader() throws Exception {

        Map<String,Object> parameterValues = new HashMap<>();
        parameterValues.put("amount", "10000");

        //pageSize와 fethSize는 동일하게 설정
        return new JdbcPagingItemReaderBuilder<Member>()
                .pageSize(10)
                .fetchSize(10)
                .dataSource(dataSource)
                .rowMapper(new BeanPropertyRowMapper<>(Member.class))
                .queryProvider(customQueryProvider())
                .parameterValues(parameterValues)
                .name("JdbcPagingItemReader")
                .build();
    }

    @Bean
    @StepScope
    public ItemProcessor<Member, Member> processor(){

        return new ItemProcessor<Member, Member>() {
            @Override
            public Member process(Member member) throws Exception {

                //1000원 추가 적립
                member.setAmount(member.getAmount() + 1000);

                return member;
            }
        };
    }

    @Bean
    @StepScope
    public JdbcBatchItemWriter<Member> writer(){
        return new JdbcBatchItemWriterBuilder<Member>()
                .dataSource(dataSource)
                .sql("UPDATE MEMBER SET AMOUNT = :amount WHERE ID = :id")
                .beanMapped()
                .build();

    }

    public PagingQueryProvider customQueryProvider() throws Exception {
        SqlPagingQueryProviderFactoryBean queryProviderFactoryBean = new SqlPagingQueryProviderFactoryBean();

        queryProviderFactoryBean.setDataSource(dataSource);

        queryProviderFactoryBean.setSelectClause("SELECT ID, NAME, EMAIL, NICK_NAME, STATUS, AMOUNT ");
        queryProviderFactoryBean.setFromClause("FROM MEMBER ");
        queryProviderFactoryBean.setWhereClause("WHERE AMOUNT >= :amount");

        Map<String,Order> sortKey = new HashMap<>();
        sortKey.put("id", Order.ASCENDING);

        queryProviderFactoryBean.setSortKeys(sortKey);

        return queryProviderFactoryBean.getObject();

    }

}

이어서

다음은 실제 진행했던 개발 내용과 겪었던 시행착오에 대해 다룰 것이다.

반응형
728x90
반응형

spring

0. 서론

회사에서 프로젝트를 진행하면서 최근에 코드 합칠 일이 많았다.
프로젝트 초기에는 형상관리를 위한 SVN 저장소가 없는 상태였고, 자동 빌드배포를 위한 CM서버도 구축되어 있지 않았기 때문에
각자 맡은 모듈을 각자의 프로젝트로서 생성하여 구현하였다.
그 후, SVN 저장소가 생성되고 CM서버 구축도 막바지였기 때문에 각자 작성한 코드를 합쳐서 하나의 프로젝트로 SVN에 올려야 했다.

코드 합치는 과정은 순탄치가 않았다.
서비스 백엔드 서버에 필요한 로그인-인증 코드, 연동 서버에 필요한 SOAP연동-배치 코드로 총 두 번의 합치는 과정이 있었다.
합치는 두 번의 과정에서 공통적으로 bean생성 오류가 있었고, bean생성 순서가 꼬였던 것이 원인이었다.

한 번은 main에서의 어노테이션 한 개를 지워서 해결했고(어떤 어노테이션인지는 회사pc로 확인 후 작성할 에정),
다른 한 번은 @Configuration어노테이션이 선언된 config 파일의 위치를 바꿔서 해결하였다. => 프로젝트(디렉토리) 구조에 따라 bean생성 순서가 달라진다.

두 번의 삽질을 경험하니... Bean이란 놈이 어떻게 생성되는지가 궁금해진다.

1. Bean 이란?

Bean이란?
Spring의 IoC(제어의 역전)특성을 활용하는 Spring Container에 의해 관리되는 Java 객체.
개발자가 작성한 new연산자에 의해 생성되는 것이 아닌, Application이 실행될 때 ApplicationContext에 의해 생성되고 관리되는 객체이다.

IoCDI에 대한 개념은 👉여기에서 확인

Life Cycle

BeanSpring Container에 의해 생성되고 관리되기 때문에 Java객체인 Bean의 생명주기(Life Cycle)도
객체생성 -> 의존설정 -> 초기화 -> 사용 -> 소멸
형태로 Spring Container에 의해 관리된다.

Bean 사용 이유

Bean은 위와 같은 Life Cycle로 Spring Container에 의해 관리된다.
그래서 해당 application으로 요청이 올 때마다 객체를 생성하는 것이 아닌,
Spring Container에 의해 초기화 과정에서 생성된 Bean객체를 활용하기 때문에 성능상 이점이 있다.

또한, 의존성 관리와 java 객체의 life cycle관리에도 용이하게 때문에 Bean을 활용한다.

Bean의 생성

Bean은 다음과 같이 두 가지 방법으로 생성될 수 있다.

  1. @Component어노테이션을 활용한 자동등록 방식
  2. @Configuration + @Bean어노테이션을 활용한 수동등록 방식

2. Bean 생성 방식

@Component 활용 자동 방식

Bean으로 등록하고자 하는 클래스에 @Component 어노테이션을 붙이는 방식이다.

@Component
public class testClass {

    public void testMethod() {
        ...
    }

}

위와 같이 클래스이름 상단에 @Component어노테이션을 붙이면 Bean으로 등록된다.
뿐만 아니라 @Component어노테이션을 활용하는 @Controller, @Service, @Repository어노테이션을 붙인 클래스도 Bean으로 등록된다.

@Comfiguration + @Bean 활용 수동 방식

아래 코드와 같이 @Configuration어노테이션을 붙여도 Bean으로 등록된다.
@Configuration어노테이션 역시 하위 어노테이션으로 @Component어노테이션이 있기 때문이다.

@Configuration
public class Config {

    @Bean
    public SimpleBean simpleBean() {
        return new SimpleBean();
    }

    @Bean
    public SimpleBeanConsumer simpleBeanConsumer() {
        return new SimpleBeanConsumer(simpleBean());
    }

}

@Configuration어노테이션을 붙이는 방식은 클래스 내부 메소드에 @Bean어노테이션을 붙이는 방식이 수반된다.

여기서 궁금증이 생길 것이다.

@Configuration + @Bean 수동 방식은 왜 활용하는 것인가?

클래스에 @Component어노테이션 하나만 붙여도 Bean으로 등록되어 Spring Container가 관리할 수 있도록 IoC(제어의 역전)형태가 만들어진다. 그런데 굳이 @Configuration어노테이션을 붙이고 내부 메소드에도 @Bean어노테이션을 붙여서 Bean으로 등록해야 할까?

@Component를 이용한 자동방식과 @Configuration + @Bean을 이용한 수동방식의 차이를 이해하면 궁금증이 해결될 것이다.
아래 예제를 보자.

=== 1 ===

@Component
public class Config {

    @Bean
    public SimpleBean simpleBean() {
        return new SimpleBean();
    }

    @Bean
    public SimpleBeanConsumer simpleBeanConsumer() {
        return new SimpleBeanConsumer(simpleBean());
    }

}

=== 2 ===

@Configuration
public class Config {

    @Bean
    public SimpleBean simpleBean() {
        return new SimpleBean();
    }

    @Bean
    public SimpleBeanConsumer simpleBeanConsumer() {
        return new SimpleBeanConsumer(simpleBean());
    }

}

1번 코드와 2번 코드는 클래스에 붙은 어노테이션을 제외하면 차이가 없다. 하지만 동작 상에는 큰 차이가 있다.

@Component가 붙은 1번 코드에서 simpleBeanConsumer메소드가 실행되면 의존관계에 있는 simpleBean메소드가 실행되어 새로운 SimpleBean객체가 생성된다.

하지만,
@Configuration이 붙은 2번 코드에서 simpleBeanConsumer메소드가 실행되면 의존관계에 있는 simpleBean메소드가 실행되는데, 새로운 SimpleBean객체가 생성되진 않고 Bean등록 과정에서 Spring Context에 등록된 SimpleBean Bean이 불러와진다.

1번 코드에서는 객체를 새로 생성하지만, 2번 코드에서는 기존에 생성된 객체를 불러온다는 큰 차이가 있다는 것이다.
2번 코드를 @Component어노테이션을 활용하여 아래 코드와 같이 재구성해보면 이해가 쉬울 것이다.

@Component
public class Config {

    @Autowired
    SimpleBean simpleBean;

    @Bean
    public SimpleBean simpleBean() {
        return new SimpleBean();
    }

    @Bean
    public SimpleBeanConsumer simpleBeanConsumer() {
        return new SimpleBeanConsumer(simpleBean);
    }

}

이러한 두 방식의 차이는 CGLIB(Code Generation Library)가 두 어노테이션에 동작하는 방식이 다르기 때문에 발생한다고 한다.
@Configuration어노테이션 내부에서 호출된 메소드가 @Bean어노테이션이 붙은 메소드라면 Spring Context에 등록된 Bean을 반환하도록 되어있다고 한다. (자세한건 CGLIB에 대해 알아보면서 파악하면 될 것 같다.)

중간 정리

@Component를 활용한 자동방식과 @Configuration + @Bean을 활용한 수동방식의 차이는 Spring Context에 등록된 Bean을 반환하냐 안하냐의 차이이다. 이러한 차이가 발생한 이유는 두 어노테이션에 CGLIB가 작동하는 방식이 다르기 때문이다.

이러한 특징때문에 자동방식은 보통 비즈니스 로직을 구현할 때 활용된다.
그리고 수동방식은 config class와 같은 기술 지원 로직에 활용된다. 해당 방식은 context 내부에 등록된 Bean을 적극 활용하기 때문에 application 전반에 영향을 미쳐야 하는 기술지원 로직에 적합하기 때문이다.

3. Bean 생성 과정

Bean은 자동/수동방식 공통적으로 @ComponentScan어노테이션에 의해 등록된다.
그리고 @ComponentScan어노테이션은 @SpringBootApplication어노테이션의 하위 어노테이션이기 때문에 일반적으로 루트 클래스에서 동작하게 된다.

@ComponentScan어노테이션은 지정된 클래스부터 @Component어노테이션이 붙은 하위 클래스를 스캔하기 시작한다.
@Configuration, @Controller, @Service, @Repository 어노테이션도 역시 포함이다.
그리고 스캔된 클래스들은 위 Bean의 Life Cycle 섹션에서 언급했던 것처럼 객체생성 -> 의존설정 -> 초기화 과정을 거쳐 Bean으로 등록된다.

마무리

사실 고백하자면... 일주일이 넘도록 이 글 하나만 썼다. Bean에 대해 파도파도 끝이 없어서 어디서부터 어떻게 써야할지 고민을 많이 했다.
결국 타협해서 Bean 개념에 대해 간단히 짚고 생성방식과 과정에 대해서만 작성했다.

관련하여 다룰 개념들이 아직도 많은데, 추후에는 프록시패턴, 데코레이터패턴 등과 같은 디자인 패턴들을 심도있게 다뤄볼까 한다.
또한, 위에서 언급했던 CGLIB에 대해 다뤄봐도 좋을 것 같고,
시간이 좀 더 지나면 코드를 보면서 Spring Context의 구조와 동작 방식도 파악해보면 좋을 것 같다.

반응형
728x90
반응형

Spring

0. 서론

새로 참여하게된 비대면 고객케어 솔루션 프로젝트에서 로그인 연동 개발을 맡게 되었고, 다른 팀원분이 작성한 인증 관련 코드를 합쳐야 했다.


해당 인증관련 코드에는 Spring Security 프레임워크가 활용되었고, 적절하게 잘 합치기 위해서는 해당 코드에 대한 이해가 수반되어야 했다.
이를 위해 Spring Security에 대해 알아보았다.

1. 사전 학습

A. Spring Security란?

Spring Security는 Spring기반 애플리케이션의 보안을 담당하는 Spring 하위 프레임워크 중 하나로서, 해당 프레임워크를 활용하여 인증인가에 대한 처리를 진행하게 된다.

B. 인증과 인가

  • 인증(Authentication) : 접근을 요청한 사용자와 입력한 사용자정보가 일치하는지 확인하는 절차. 여기서 사용자는 접근주체, 사용자정보는 접근 시 입력했던 ID/PW와 같은 정보를 가리킨다.
  • 인가(Authorization) : 접근 사용자의 권한으로 요청 자원에 접근할 수 있는지 확인하는 절차. 예를들어 '/config' 라는 path로 사용자가 접근했을 때, Spring Security가 해당 path로의 권한이 사용자에게 있는지 확인한 후, 없다고 판단이 되면 접근을 통제하게 된다.

C. 인증 처리 flow

상황을 가정해보자.

국민 플랫폼인 naver가 Spring Security 프레임워크를 활용하여 인증과 인가처리를 한다고 가정해보자.

사용자 A는 naver에 가입되어 있지 않고, 사용자 B는 naver에 가입되어 있다.
이때, 두 사용자가 naver를 활용하기 위해 로그인이라는 행위를 통해 인증을 시도한다면,
가입되어 있지 않은 사용자 A는 인증처리가 되지 않을 것이고,
가입되어 있는 사용자 B는 인증처리에 성공하여 naver를 활용할 수 있게 될 것이다.

naver는 Spring Security를 통해 자체 인증 로직을 구성했을 것이다.
그리고 해당 인증 로직은 일반적으로 로그인을 시도한 사용자의 정보가 자체 DB에 있는지 확인하는 절차일 것이다.

사용자 B의 정보는 가입되어 있는 사용자이기 때문에 자체 DB에 해당 정보가 저장되어 있을 것이고,
사용자 A의 정보는 가입되어 있지 않은 사용자이기 때문에 자체 DB에 해당 정보가 저장되어 있지 않을 것이다.
그렇기 때문에, 자체 DB를 확인하는 절차를 통해 인증을 수행하는 naver에 사용자 A가 로그인을 시도할 경우, 인증처리가 되지 않는 것이다.

이러한 상황을 Spring Security에 적용해보자.(다음 글에서...)

D. Dispatcher-Servlet과 Filter

일반적으로 Spring Framework는 아래 사진과 같은 flow를 통해 Http 요청이 처리된다.

dispatchef-servlet filter

Security의 동작원리를 이해하기 위해 짚고 넘어가자.

위 사진에서 참고할 주요 키워드는 Web Context, Spring Context, Filter, Dispatcher Servlet이다.

  1. Web Context : Servlet Context라고도 불린다. Tomcat과 같은 Servlet Container에 의해 실행된다.
    a. Servlet : 웹 프로그래밍을 위한 Java 기술이다. HTTP요청/응답 처리, 동적 웹사이트 처리 등의 작업을 수행한다. 그리고 이러한 Servlet이 객체형태로 구현된 것을 Servlet Context라고 한다.

  2. Spring Context : Spring MVC를 처리하는 Java 객체이다. Spring Container에 의해 실행된다. Spring Context의 proxy역할을 하는 Dispatcher ServletServlet Context에 의해 관리되기 때문에, 위 사진과 같은 구조로 구성되어 있다고 한다.

  3. Dispatcher Servlet : 위에서 언급한 것처럼 Spring Context의 proxy 역할을 하는 Java 객체이다. Servlet Context를 통해 들어온 HTTP요청을 분석하여 Spring Context내부의 어떤 Controller를 호출할 지 결정하고, 호출된 Controller 내부 비즈니스 로직에 의해 반환된 응답 객체를 HTTP응답으로서 client에 반환하거나 매핑된 view에 연결시켜준다.

  4. Filter : Dispatcher Servlet에 HTTP요청이 전달되기 전후에 http url path에 맞게 실행되는 기능이다. Servlet Context가 실행되는 Servlet Container에 의해 실행되고, Spring Context내부의 Spring Bean을 주입받아 처리될 수도 있다.

위 사진에 대한 설명을 최대한 간략하게 적어봤는데, 사실 이렇게 간단하게 언급할 수 있는 내용은 아니었다. 추후에 별도로 자세히 알아봐야겠다.

이어서

Spring Security를 알아보기 위해 사전에 알고 있어야할 개념들이 너무나 방대했고 파악하는 데 시간이 오래걸렸다. 하지만 위 개념들을 먼저 알고있어야 Security에 대한 이해가 쉬울 것 같아서 먼저 언급하였다.

위에서 언급한 개념들은 Spring Security와 밀접한 관련이 있다. 우리가 Java 소스코드로 구현한 비즈니스 로직은 일반적으로 Spring Context로 구현되어 실행될 것이다. 그리고 Security에서 제공하는 인증인가기능은 Spring Context가 아닌 Servlet ContextFilter단에서 실행될 것이다.

다음 글부터는 본격적으로 Spring Security에 대해 알아볼 것이다.

반응형

+ Recent posts