ItemProcessor는 ItemReader에서 read()로 넘겨준 데이터를 개별로 가공한다. ItemProcessor는 언제 사용할까?
ItemReader가 넘겨준 데이터를 가공하려고 할 때
데이터를 ItemWriter로 넘길지 말지 결정할 때 (null을 반환하면, ItemWriter에 전달하지 않음)
이 ItemProcessor는 필수가 아니다. 필요가 없는 경우는 어떤 경우일까?
정말 ItemProcessor가 필요없는 경우. 예를 들면, ItemReader가 데이터베이스에서 데이터를 읽은 뒤 수정없이 그대로 ItemWriter에서 File에 Write하는 경우에는 ItemProcessor가 굳이 필요하지 않다.
ItemReader나 ItemWriter에서 데이터를 직접 가공까지 하는 경우인데, 이 방법은 추천하지는 않지만 상황에 따라 어쩔 수 없이 ItemReader에서 read할 때, 수정한 데이터를 넘겨주는 경우도 있고, ItemWriter에서 쓰기 전 데이터를 수정해서 write하는 경우가 있다.
ItemProcessor는 어떻게 구현할까?
ItemProcessor는 process를 구현하면 된다. ItemProcessor는 Input과 Output이 있고, Input을 받아서 Output으로 변환한 뒤 반환해야 한다. 이때, Input과 Output의 타입은 같을 수도 있고 다를 수도 있다.
@FunctionalInterface
public interface ItemProcessor<I, O> {
@Nullable
O process(@NonNull I item) throws Exception;
}
ItemReader는 Chunk Processing에서 데이터를 제공하는 인터페이스이다. ItemReader는 반드시 read 메서드를 구현해야 한다. read 메서드를 통해서, ItemProcessor 또는 ItemWriter에게 데이터를 제공한다. read 메서드가 null을 반환하면 더이상 데이터가 없고 Step을 끝내겠다고 판단한다. 그렇기 때문에 처음부터 null을 반환했다고 하더라도 에러가 나지는 않는다. 단, 데이터가 없으니 바로 Step이 종료될 것이다.
@FunctionalInterface
public interface ItemReader<T> {
@Nullable
T read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException;
}
ItemReader를 보면 T 타입의 단일 데이터 1개를 반환한다. 더 이상 읽을 수 없을 때까지(null이 나올 때까지) 반복한다.
ItemReader의 데이터 조회 방식은 크게 두 가지로 나눌 수 있다.
정말 1개씩 데이터를 가져와서 결과로 주는 방식
한번에 대량으로 가져오고 가져온 데이터에서 하나씩 빼주는 방식 (단 대량으로 가져올 때 최대 가져올 수 있는 개수는 정해져 있어야 한다)
ItemReader가 가져오는 데이터는 정말 다양하게 있을 수 있지만 대개는 File, DB 데이터 정도이다. 그래서 Spring Batch는 우리를 위해 자주 사용될 법한 ItemReader들을 미리 만들어 두었다.
FlatFileItemReader → 보통 구분자로 나누어져 있는 파일을 읽는다. 대표적인 예로 CSV 파일
JdbcCursorItemReader → JDBC Cursor로 조회하여, 결과를 Object에 Mapping해서 넣어주는 방식
JdbcPagingItemReader → 페이징해서 데이터베이스에서 데이터를 가져온다.
JpaPagingItemReader → 위에랑 똑같은데 JPA를 사용해서 페이징해서 데이터베이스에서 데이터를 가져온다.
RepositoryItemReader → Repository에서 구현한 메서드의 이름을 넣는 방식이다. 그래서 해당 메서드를 통해 데이터를 가져오는 것이다. 이때 주의할 점은, Repository에서 구현한 메서드의 인자에 Pageable이 포함되어 있어서, Pagination을 지원하는 상황이어야 한다. 아래가 그 예시다.
Batch 프로세싱의 가장 큰 특징이 일괄 처리이면서 동시에 가장 큰 문제가 일괄 처리이다. 일괄로 한번에 데이터를 처리한다는 것은 시스템의 리소스가 한순간에 많이 필요하다는 것을 말한다. 포인트 관리 배치 프로그램이 있는데 오늘 만료 시켜야 할 포인트가 십만개라면 어떨까? 서비스가 대성공해서 백만개, 천만개라면 어떨까? 그 어떤 서버도 한번에 천만개를 처리하기 쉽지 않을 것이다. 이를 해결하기 위해서 Spring Batch에서는 Chunk라는 개념을 만들었다. Chunk는 일정 개수만큼 잘라서 처리하겠다는 의미로, Chunk Size가 1000이면 한번에 1000개씩 처리하고 완료하고 그 다음 1000개 처리하고 완료하겠다는 의미이다. 이렇게 하면 한순간에는 1000개에 해당하는 리소스만 있으면 된다.
일반적인 Chunk 기반 Step 흐름
1 → 트랜잭션 시작
2 → ItemReader가 데이터 1개 제공하기
3 → ItemProcessor를 통해 데이터 1개를 가공하기
4 → Chunk Size만큼 데이터가 쌓일때까지 2-3번을 반복
5 → ItemWriter에게 데이터 전달하기 (보통의 경우, DB에 저장)
6 → 트랜잭션 종료
7 → 2번이 더이상 진행할 수 없을때까지, 1-6번을 계속해서 반복
ChunkProcessing 구현방법
Step을 Chunk방식으로 구현하기 위해서는 다음과 같이 <A, B>chunk(...)를 사용하면 된다.
그리고 ItemReader<T>, ItemProcessor<T, G>, ItemWriter<T>는 <T,G>chunk(...) 형식이 맞아야 한다.
그럼 Chunk Size는 얼마가 적당할까?
정답은 없다. 업무의 종류, 코드의 로직, 환경등에 따라 다르다. Chunk Size가 너무 작으면, 일괄처리 효율이 떨어지고, 또 반대로 Chunk Size가 너무 커도 리소스 문제나 처리량의 한계 등 문제가 있을 수 있다. 적당한 크기의 사이즈를 찾는것도 Batch 성능에 큰 도움이 된다.
다시 보는 StepExecutionContext
StepExecutionContext를 사용하면, 1개의 Step안에서 공유하는 공간을 만들 수 있다고 했다. 즉, 1개의 Step안에 있는 ItemReader, ItemProcessor, ItemWriter가 같은 공간을 접근할 수 있다.
다시 보는 PlatformTransactionManager
@EnableBatchProcessing을 달면, 기본 트랜잭션 매니저를 가져올 수 있고 이걸 StepBuilder에서 등록할 수 있다고 했다. 이 트랜잭션 매니저를 StepBuilder에서 사용하게 되면 1개 Chunk 단위로 트랜잭션이 생기게 된다. 따라서 1개 Chunk가 끝나면 일괄적으로 트랜잭션이 끝나게 되고 ItemWriter에서 저장한 모든 대상들의 Commit은 Chunk가 끝나면 발생한다.
정리를 하자면
Chunk Processing에 대해 알아보았다. 정해진 크기만큼 쪼개서 여러번 일괄처리를 하는 방법이다. 이제는 그 방법을 사용하기 위한 ItemReader, ItemProcessor, ItemWriter에 대해 알아보자!
두 가지 예시를 가져와봤다. Tasklet의 상태는 계속 진행할지, 끝낼지 두가지로만 표현된다.
RepeatStatus.FINISHED가 반환되면 tasklet이 바로 끝나고, RepeatStatus.CONTINUABLE이 반환되면, Tasklet을 다시 실행한다. 따라서, RepeatStatus.CONTINUABLE을 반환한 예시는 영구적으로 끝나지 않고 계속해서 로그를 남기게 된다.
PriceRepository.findByDate()를 통해서 얼마나 많은 데이터를 가져올지 예측이 불가하다. 데이터가 너무 많다면 천만개의 데이터를 조회할 수도 있다. 천만개의 데이터를 한번에 가져온다면 메모리 이슈로 인해 처리가 불가해지고 OOM이 발생할 수 있다.
Tasklet 형식의 Step에 transactionManager를 추가하게 되면 해당 Tasklet은 Transaction에 묶이게 된다. 이때 Tasklet에서 너무 많은 데이터를 불러오고 쓰게 되면, Tasklet의 Transaction은 너무 거대해진다. 그 말은 Database의 Transaction 1개가 처리해야 할 일이 너무 많아지게 된다고도 할 수 있다.
이런 문제를 해결하기 위해, Chunk Processing을 사용하게 된다. Step을 구현하는 방법 중 대표적인 방법 하나인 Chunk Processing.
정리를 하자면
Tasklet에 대해 알아보았다. 간단한 스텝을 구현할 땐 유용하게 사용할 수 있지만, 조금 복잡하거나 처리할 데이터의 양이 많아지고 예측할 수 없다면 주의해야 한다. 그래서 이럴땐 Chunk Processing을 사용하면 된다. 다음 포스팅에서 알아보자!
Job에는 여러 Step이 있다. Step은 실질적으로 요청을 처리하는 객체이다. Step은 Job과 마찬가지로 행위에 대한 명세서이다. 1개의 Job에는 여러개의 Step을 포함할 수 있고 따라서 1개의 JobExecution에는 여러개의 StepExecution을 포함할 수 있다.
예시)
Job: 식당을 예약한다.
Step 1: 식당에 전화한다.
Step 2: 예약한다.
Step 3: 예약금을 송금한다.
StepExecution
Step이라는 명세서를 실행시켜 실행된 기록을 StepExecution이라고 한다. JobExecution이 Job의 실행 정보를 가지고 있는것처럼, StepExecution은 Step의 실행 정보를 가지고 있다. 다음과 같은 메서드들을 StepExecution은 가지고 있다.
getStepName() → Step의 이름
getJobExecution() → JobExecution
getStartTime() → Step의 시작 시간
getEndTime() → Step의 종료 시간
getExecutionContext() → ExecutionContext
getExitStatus() → Step의 실행 결과
getStatus() → Step의 현재 실행 상태(Batch Status)
StepExecutionContext
JobExecutionContext가 1개의 Job에서 공유하는 공간이면, StepExecutionContext는 1개의 Step 내에서 공유하는 공간(Context)이다.
PlatformTransactionManager
StepBuilder로 Step을 정의할 때, transactionManager를 받을 수 있다. @EnableBatchProcessing을 추가하면 아래와 같이 기본 transactionManager를 사용할 수 있다. (다만, 프로젝트에서 datasource가 여러개인 경우에는, 다른 말로, 데이터베이스를 여러개 사용한다면, 직접 별도로 transactionManager를 만들어서 사용해야 한다)
transactionManager를 StepBuilderFactory에 추가하면, 해당 Step은 transactionManager를 사용해서 내부의 transaction을 관리한다.
TransactionManager란, 데이터베이스 트랜잭션은 데이터베이스의 데이터가 변하는 과정이 독립적이며, 일관되고 믿을 수 있는걸 보장하는 것을 말한다. 예를 들면, A가 B에게 송금을 했을 때, A는 돈이 보내져서 계좌에서 돈이 빠졌는데, B는 어떤 에러가 발생해서 돈을 받지 못했다. 만약, 이것이 트랜잭션으로 관리가 됐다면 이런일은 일어나지 않았을 것이다. 두 과정을 1개의 트랜잭션으로 묶었다면, B가 돈을 받지 못하면 A의 돈도 빠져나가지 않았던 것이 된다. 바로 이런 트랜잭션을 관리해주는 것이 바로 트랜잭션 매니저이고 @EnableBatchProcessing을 통해 자동으로 기본 트랜잭션 매니저를 만들어 준다.
@JobScope
일반적으로, Scope를 지정하지 않는다면 처음 스프링부트가 시작될 때 모든 Bean이 생성된다. Step을 생성하는 코드에 @JobScope 애노테이션을 달면, Step을 스프링 부트가 시작될 때 바로 만드는 것이 아니라 연관된 Job이 Step을 실행하는 시점에 만들어준다. Lazy Loading과 비슷한 개념이라고 보면 된다.
1개의 스프링 배치 애플리케이션에 Job1, Job2, Job3이 있고, 3개의 Job들에 연결된 Step도 많이 만들어 두었는데, Job1만 실행시킨다면 Job1과 연관되지 않는 Step들은 굳이 만들 필요가 없다. 굳이 필요도 없는 Step을 만드는데 리소스나 시간을 낭비할 필요가 없기 때문에 이 @JobScope는 꽤나 중요하다. 또 다른 이유로는, Job이 실행된 다음에 나중에 결정되는 값이 있다면 늦게 Step을 생성하고 싶을 것이다. 예를 들면 다음과 같은 Job과 Step이 있다고 해보자.
Job1 → Step1.1 - Step1.2 - Step1.3
Job1은 세 개의 스텝으로 구성되어 있는데 Step1.2는 Step1.1의 결과를 통해 얻어낸 데이터를 가지고 Step1.2를 만들어야 한다면, Step1.2는 미리 만들수가 없다. Step1.1을 실행하고 나서 Step1.2을 만들어야 하기 때문에도 @JobScope를 사용하면 좋다.
결론은, 일반적으로는 @JobScope를 사용하면 좋다.
정리를 하자면
이번 포스팅에선 Step에 대해 알아보았다. 다음 포스팅은 Tasklet에 대해 알아보자!
1개의 작업에 대한 명세서를 의미한다. 어디까지가 1개 작업으로 봐야할지 기준이 애매할 수 있다. 그 기준은 상황별로 판단하면 된다.
다음 예시를 보자.
Job: 식당을 예약한다.
Step 1: 전화를 건다.
Step 2: 예약을 한다.
Step 3: 예약금을 송금한다.
이게 하나의 Job이라고 볼 수 있다. 이처럼,
1개의 Job은 여러개의 Step을 포함할 수 있다.
Job name을 통해 Job을 구분할 수 있다.
Job name으로 Job을 실행시킬 수 있다.
Job을 만드는 빌더는 많지만, JobBuilder로 쉽게 Job을 만들 수 있다.
아래 그림을 보자.
Job을 만들고 실행하는 과정에 대한 그림이다. 그럼 저기서 Job과 Job Instance는 어떤 차이가 있을까? 그리고 Job Execution, Job Parameter는 무엇일까?
JobInstance
Job이 명세서라면, JobInstance는 Job이 실행되어 실체화된 것이다. JobInstance는 배치 처리에서 Job이 실행될 때 하나의 Job 실행 단위이다. 같은 Job에 같은 조건(Job Parameters)이면, JobInstance는 동일하다고 판단한다. 혹시, Job이 실패해서 다시 같은 조건으로 Job을 실행한다면 같은 JobInstance라고 할 수 있다. 그럼 당연히 같은 Job에 다른 조건(Job Parameters)이면, JobInstance는 다를 것이다.
JobExecution
JobExecution은 JobInstance의 한번 실행을 뜻한다. 어떤 Job이 같은 조건으로 1번 실패하고, 1번 성공한다면 JobInstance는 1개이고, JobExecution은 2개이다. JobExecution은 실패했든지 성공했든지 간에 실제로 실행시킨 사실과 동일한 의미이기 때문에 배치 실행과 관련된 정보를 포함하고 있다.
이 JobExecution에는 들어있는 게 꽤 많다. 예를 들면 다음과 같은 메서드들을 사용할 수 있다.
getJobInstance() → JobExecution의 JobInstance
getJobParameters() → JobExecution에서 사용한 Job Parameters
getStartTime() → Job의 시작시간
getEndTime() → Job의 종료시간
getExitStatus() → Job의 실행 결과(Exit Code)
getStatus() → Job의 현재 상태(Batch Status)
getExecutionContext() → Job Execution Context
어떤 것들을 하는 메서드인지 대충봐도 감이 오는데 마지막 ExecutionContext는 무엇일까?
JobExecutionContext
1개의 Job내에서 공유하는 공간(Context)이다. 1개의 Job의 여러 Step들이 있다면, 그 Step들은 해당 공간을 공유할 수 있다.
JobParameters
Job이 시작할 때, 필요한 시작 조건을 JobParameters에 넣는다. 동일한 Job에 JobParameters까지 동일하면, 같은 JobInstance이다. 예를 들면, 다음과 같은 상황이 있다고 해보자.
JobBuilder로 Job을 만든다. 이때 Job을 가져와야 하는데 Job의 이름(Bean의 이름)을 통해 가져온다.
시작 스텝과 다음 스텝들을 지정한다.
여기서, 한개의 스텝이라도 실패하면 다음 스텝으로 진행하지 않는다.
build()를 통해 Job을 생성한다.
조금 더 복잡한 Job의 예시를 보면 다음과 같다.
@Bean
public Job reserveRestaurantJob(JobRepository jobRepository,
Step searchAvailableKoreanRestaurantStep,
Step searchAvailableAsianRestaurantStep,
Step reserveRestaurantStep,
Step sendDepositStep) {
return new JobBuilder("reserveRestaurantJob", jobRepository)
.start(searchAvailableKoreanRestaurantStep)
.on("FAILED") // searchAvailableKoreanRestaurantStep 가 FAILED 인 경우
.to(searchAvailableAsianRestaurantStep) // searchAvailableAsianRestaurantStep 실행
.on("FAILED") // searchAvailableAsianRestaurantStep 가 FAILED 인 경우
.end() // 아무것도 하지 않고 FLOW 종료
.from(searchAvailableKoreanRestaurantStep)
.on("*") // searchAvailableKoreanRestaurantStep 가 FAILED 가 아닌 경우
.to(reserveRestaurantStep) // reserveRestaurantStep 실행
.next(sendDepositStep) // sendDepositStep 실행
.from(searchAvailableAsianRestaurantStep)
.on("*") // searchAvailableAsianRestaurantStep 가 FAILED 가 아닌 경우
.to(reserveRestaurantStep) // reserveRestaurantStep 실행
.next(sendDepositStep) // sendDepositStep 실행
.end() // Job 종료
.build();
}
이번에는 한식 레스토랑 예약이 실패했을 경우 다른 방법을 찾기 위해 아시안 레스토랑을 예약하는 과정까지 넣었다.
그래서 위 코드처럼, 먼저 한식 레스토랑 예약 스텝을 실행하는데 그 스텝이 FAILED인 경우, 아시안 레스토랑 예약 스텝을 실행한다. 그런데 그마저도 실패할 경우가 있으므로, 그땐 이 Job을 종료한다.
한식 레스토랑 예약 스텝 또는 아시안 레스토랑 예약 스텝 둘 중 하나라도 실패가 아닌 경우가 생겼으면 다음 스텝들을 쭉쭉 진행한다.
저기서 JobBuilder는 어떻게 가져올까? 다음과 같이 @EnableBatchProcessing 애노테이션을 Spring Boot 엔트리 포인트 클래스에 붙여주면 된다.
@SpringBootApplication
@EnableBatchProcessing
public class DMakerApplication {
public static void main(String[] args) {
SpringApplication.run(DMakerApplication.class, args);
}
}
이렇게 애노테이션을 달면 다음과 같은 빈들을 자동으로 등록해 준다.
JobRepository
JobLauncher
JobRegistry
JobExplorer
PlatformTransactionManager
JobBuilder
StepBuilder
이렇게 사용할 수 있는 빈들 중 보이는 JobBuilder를 주입받아 사용하면 되는 것이다. 원래는 JobBuilderFactory가 있었는데 5.0 이후부터 Deprecated 됐다. 그래서 JobBuilder를 사용하면 된다.
Batch란, 일괄 처리라는 뜻을 가지고 있다. 데이터를 실시간으로 처리하지 않고 일괄적으로 모아서 한번에 처리하는 방식을 말한다. 만약에 이런 작업을 항시 Running중인 웹 기반으로 구현한다면 매우 비효율적일 것이다. 일괄처리를 작업하는 순간에만 애플리케이션이 Running되고 리소스를 사용해야 효율적인 프로그램이라 할 수 있다.
예를 들면, 특정 서비스의 포인트 예약 적립이나, 유효기간 만료와 같은 작업은 매일 한번에 데이터를 처리하는 방식이 주를 이룬다. 즉, 특정 시기에 특정 데이터들을 처리하면 되는 경우엔, 배치 작업과 굉장히 잘 어울린다고 할 수 있다.
다양한 데이터 처리 방식
그럼 배치 작업이 아닌 다른 방식은 무엇이 있을까? 다음 표가 대표적인 세가지 데이터 처리 방식이다.
Batch-Proceessing
Real-Time
Stream-Processing
데이터 처리 시간
정해진 시간에 일괄 처리
실시간으로 반응이 일어남 요청이 들어오면 즉시 처리 (웹 애플리케이션이 대표적인 예)
준실시간 반응이 일어남
데이터 처리량
정해진 때에 정해진 양의 데이터를 한번에 처리함
요청을 개별적으로 처리
Stream을 통해 데이터가 들어오면 처리하기 시작함
구현 특징
데이터를 처리할 때만 애플리케이션이 Running하도록 구현함
Web Container에서 동작하도록 구현함
제 3의 도구를 사용하는 경우가 많음 (Kafka, RabbitMQ)
데이터 처리 시 어려움
데이터 볼륨이 너무 큰 경우에 처리가 어려움 처리가 특정한 시간에 집중됨
동시에 많은 요청이 일어나는 경우에 대처가 어려움
Stream의 input과 output의 flow를 컨트롤하기 어려움 제 3의 도구와 다수의 stream으로 인해 Fail처리가 어려움
Real-Time 처리 방식은 보통의 웹 서비스, 그러니까 Spring MVC로 구현한 애플리케이션을 생각하면 된다. 사용자가 요청을 하면 그 요청에 대한 응답이 즉각적으로 일어난다.
이렇게 대표적으로 세가지 데이터 처리 방식 중에 이 카테고리에서 배워볼 방식은 Batch-Processing이다. 그리고 이를 구현하기 위해 Spring Batch를 배워볼 것이다.
Spring Batch의 특징
가볍고 포괄적인 배치 프레임워크
로깅, 추적, 트랜잭션 관리, 작업처리 통계, 재시작, 건너뛰기, 리소스 관리 등 대량의 레코드 처리에 필수적인 재사용 가능한 기능을 제공
최적화 파티셔닝 기술로 대용량 고성능 배치 작업 가능
확장성이 매우 뛰어남
Spring Batch 프레임워크는 Spring 기반 위에서 구현할 수 있기 때문에 Spring에서 제공하는 많은 것들과 같이 사용할 수 있고, 위 특징들을 누릴 수가 있어 배치 작업을 위한 최고의 프레임워크라 할 수 있다.
개발자가 개발하며 다루는 데이터는 크게 010101로 되어 있는 바이너리 데이터(또는 byte 기반의 데이터)와 "ABC", "가나다"와 같은 문자로 되어 있는 텍스트 데이터 두가지다. 텍스트 데이터가 어떤 원리를 사용해서 만들어지는지 제대로 이해하지 못하면, 한글 글자가 이상하게 깨져서 나올 때, 근본 원인을 찾아서 해결하기 어렵다. 그래서! 가장 기본적인 컴퓨터가 데이터를 저장하는 원리부터 시작해서 실무에 꼭 필요한 문자 인코딩까지 기본 이론을 확실히 이해하고 넘어가자.
컴퓨터와 데이터
컴퓨터의 메모리는 반도체로 만들어져 있는데, 이것은 쉽게 이야기해서 수많은 전구들이 모여있는 것이다. 이 전구들은 사실 트랜지스터라고 불리는 아주 작은 전자 스위치이다. 각 트랜지스터는 전기가 흐르거나 흐르지 않는 두 가지 상태를 가질 수 있어서, 이를 통해 0과 1이라는 이진수를 표현한다. 이 트랜지스터들이 모여 메모리를 구성한다. 우리가 흔히 말하는 RAM(Random Access Memory)은 이런 방식으로 만들어진 메모리의 한 종류이다. 컴퓨터가 정보를 저장하거나 처리할 때, 이 전구들을 켜고 끄는 방식으로 데이터를 기록하고 읽어들인다. 이 과정은 매우 빠르게 일어나며 현대의 컴퓨터 메모리는 초당 수십억번의 데이터 접근을 처리할 수 있다.
여기서 핵심은 메모리라는 것은 단순히 전구를 켜고 끄는 방식으로 작동한다는 점이다. 그렇다면 여기에 우리가 사용하는 10진수 숫자 데이터를 어떻게 메모리에 저장할 수 있을까?
2진수
전구를 켜고 끈다는 것은 0과 1만 나타낼 수 있는 2진수로 표현할 수 있다.
전구를 끈다: 숫자 0
전구를 켠다: 숫자 1
숫자 0을 메모리에 저장한다면 메모리의 전구를 하나 끄면 되고, 숫자 1을 저장한다면 전구를 하나 켜면 된다. 그렇다면 숫자 2나 3은 어떻게 표현할 수 있을까? 숫자 2나 3을 표현하려면 전구를 하나 더 사용하면 된다.
전구 1개는 단지 0과 1이라는 2가지를 표현할 수 있지만, 전구 2개를 함께 묶어서 사용하면 총 4가지를 표현할 수 있다. 예를 들어, 숫자 3을 메모리에 저장한다면 컴퓨터는 메모리의 전구 2개를 모두 켠다. 값을 읽을 때도 마찬가지다. 메모리에서 전구 2개를 읽고, 만약 둘 다 켜져있다면 숫자 3을 화면에 출력한다. 여기서 핵심은 컴퓨터는 사람과 같이 10진수 숫자를 이해하고 숫자를 메모리에 저장하거나 불러오는 것이 아니라는 점이다. 단지 전구의 상태만 변경하거나 확인할 뿐이다.
앞으로 0은 전구가 꺼진 상태, 1은 전구가 켜진 상태라 하겠다.
전구 1개와 같이 2가지만 표현할 수 있는 것을 1비트(1 bit)라고 한다.
1 bit: 2가지 표현
0
1
2 bit: 4가지 표현
00, 01
10, 11
3 bit: 8가지 표현
000, 001, 010, 011
100, 101, 110, 111
4 bit: 16가지 표현
0000, 0001, 0010, 0011
0100, 0101, 0110, 0111
1000, 1001, 1010, 1011
1100, 1101, 1110, 1111
1 bit를 추가할 때 마다 표현할 수 있는 숫자는 2배씩 늘어난다.
1 bit → 2(0 - 1)
2 bit → 4(0 - 3)
3 bit → 8(0 - 7)
4 bit → 16(0 - 15)
5 bit → 32(0 - 31)
6 bit → 64(0 - 63)
7 bit → 128(0 - 127)
8 bit → 256(0 - 255)
참고로, 8 bit = 1 byte이다.
숫자 저장 예시
그렇다면 우리가 일반적으로 사용하는 10진수 100을 컴퓨터에 저장한다면 어떻게 될까? 컴퓨터는 10진수를 이해하지 못한다. 10진수 100을 메모리에 저장한다면 컴퓨터는 10진수 100을 2진수로 1100100 변경해서 저장한다. bit를 다룰 때, 사용하는 2진수는 사람이 직관적으로 이해하기 어렵다. 2진수는 10진수로 쉽게 변환할 수 있으므로 앞으로는 이해하기 쉽게 2진수 대신 10진수로 설명하겠다.
참고: 음수 표현
음수를 표현해야 한다면 처음 1bit를 음수, 양수를 표현하는데 사용한다. 8 bit가 256가지를 표현할 수 있다고 했는데 이때도 두가지로 나뉠 수 있다는 것이다.
0과 양수만 표현하는 경우
8 bit 모두 숫자 표현에 사용 (0 - 255)
음수 표현이 필요한 경우
1 bit는 음수와 양수를 구분하는데 사용, 나머지 7 bit로 숫자 범위 사용
0 - 127 (양수 표현 시 첫 비트를 0으로 사용, 나머지 7 bit로 128가지 양수 또는 0을 표현을 할 수 있음)
-128 ~ -1 (음수 표현 시 첫 비트를 1로 사용, 나머지 7 bit로 128가지 음수 숫자 표현을 할 수 있음)
컴퓨터와 문자 인코딩의 역사
간단한 수학 공식을 사용하면, 사람이 사용하는 10진수를 컴퓨터가 사용하는 2진수로 쉽게 변경할 수 있다. 따라서 컴퓨터는 10진수를 2진수로 변경해서 메모리에 저장할 수 있다. 그렇다면 숫자가 아닌 문자는 어떻게 메모리에 저장할 수 있을까? 컴퓨터는 전구를 켜고 끄는 2진수만 알고 있다. 10진수는 정해진 수학 공식을 사용하면 쉽게 2진수로 변경할 수 있지만, 문자 'A', 'B'를 2진수로 변경하는 공식 같은 것은 세상에 없다. 이런 문제를 해결하기 위해 초창기 컴퓨터 과학자들은 문자 집합을 만들고, 각 문자에 숫자를 연결시키는 방법을 생각해냈다.
예를 들어, 우리가 문자 'A'를 저장하면, 컴퓨터는 문자 집합을 통해 'A'의 숫자 값 65를 찾는다. 그리고 65를 메모리에 저장한다(2진수로 변환해서). 메모리에 저장된 문자를 불러올 땐, 반대로 작동한다. 메모리에 저장된 숫자 값 65를 불러와서 문자 집합을 통해 문자 'A'를 찾아서 화면에 출력한다.
문자 인코딩: 문자 집합을 통해 문자를 숫자로 변환하는 것
문자 디코딩: 문자 집합을 통해 숫자를 문자로 변환하는 것
ASCII 문자 집합
각 컴퓨터 회사가 독자적인 문자 집합을 사용한다면, 서로 다른 컴퓨터 간 문자가 올바르게 표시되지 않는 문제가 발생할 수 있다. 이러한 호환성을 해결하기 위해 ASCII (American Standard Code for Information Interchange)라는 표준 문자 집합이 1960년도에 만들어졌다. 초기 컴퓨터에서는 주로 영문 알파벳, 숫자, 키보드의 특수문자, 스페이스, 엔터와 같은 기본적인 문자만 표현하면 충분했다. 따라서 7 bit를 사용하여 총 128가지 문자를 표현할 수 있는 ASCII 공식 문자 집합이 만들어졌다.
제어 문자 (0 - 31, 127)
출력 가능한 문자 (32 - 126)
위 문자 컬럼에 있는 ASCII의 숫자는 10진수 숫자가 아니라, 문자로 표현된 숫자이다. 예를 들어, 컴퓨터 입장에서는 문자는 그림과 같은 것이다. 여기서 설명하는 ASCII의 숫자는 컴퓨터 입장에서는 그림으로 된 숫자이다. 쉽게 이야기해서 String 타입에 들어있는 "123"으로 이해하면 된다.
ISO_8859_1
서유럽을 중심으로 컴퓨터 사용 인구가 늘어나면서, 서유럽 문자를 표현하는 문자 집합이 필요해졌다.
1980년도
기존 ASCII에 서유럽 문자의 추가 필요
국제 표준화 기구에서 서유럽 문자를 추가한 새로운 문자 규격을 만들었다.
ISO_8859_1, LATIN1, ISO-LATIN-1 등으로 불린다.
8 bit (1 byte) 문자 집합 → 256가지 표현 가능
기존 7비트 ASCII(0 - 127)를 그대로 유지
ASCII에 128가지 문자를 추가함(주로 서유럽 문자, 추가 특수 문자들이며 À, Á, Â, Ã, Ä, Å 이러한 문자들을 말한다)
기존 ASCII 문자 집합과 호환 가능
한글 문자 집합
한국에도 컴퓨터 사용인구가 늘어나면서, 한글을 표현할 수 있는 문자 집합이 필요해졌다.
EUC-KR
1980년도
초창기 등장한 한글 문자 집합(더 이전에 KS5601이 있었다)
한글의 글자는 아주 많기 때문에, 256가지만 표현할 수 있는 1byte로 표현하는 것은 불가능하다.
2byte(16bit)를 사용하면 총 65536가지 표현을 할 수 있다.
ASCII + 자주 사용하는 한글 2350개 + 한국에서 자주 사용하는 기타 글자
한국에서 자주 사용하는 한자 4,888개
일본어 가타가나등도 함께 포함
ASCII는 1byte, 한글은 2byte를 사용한다.
영어를 사용하면 1byte를, 한글을 사용하면 2byte를 메모리에 저장한다.
기존 ASCII 문자 집합과 호환 가능
MS949
1990년도
마이크로소프트가 EUC-KR을 확장하여 만든 인코딩
한글 초성, 중성, 종성 모두 조합하면 가능한 한글의 수는 총 11,172자
EUC-KR은 "쀏", "삛"과 같이 드물게 사용하는 음절을 표현하지 못함
기존 EUC-KR과 호환을 이루면서 한글 11,172자를 모두 수용하도록 만든 것이 MS949
EUC-KR과 마찬가지로 ASCII는 1byte, 한글은 2byte를 사용한다.
기존 ASCII 문자 집합과 호환 가능
윈도우 시스템에서 계속 사용된다.
전세계 문자 집합
이렇게 점진적으로 문자 집합이 만들어지다 보니, 전세계적으로 컴퓨터 인구가 늘어나면서, 전세계 문자를 대부분 다 표현할 수 있는 문자 집합이 필요해졌다.
문제
EUC-KR이나 MS949 같은 한글 문자표를 PC에 설치하지 않으면 다른 나라 사람들은 한글로 작성된 문서를 열어볼 수 없다.
우리도 마찬가지다. 히브리어, 아랍어를 보려면 각 나라의 문자표가 필요하다.
한 문서 안에 영어, 한글, 중국어, 일본어, 히브리어, 아랍어를 함께 저장해야 한다면?
1980년대 말, 다양한 문자 인코딩 표준이 존재했지만, 이들은 모두 특정 언어 또는 문자 세트를 대상으로 했기 때문에 국제적으로 호환성 문제가 많았다.
유니코드의 등장
위 문제들을 해결하기 위해 전 세계의 모든 문자들을 단일 문자 세트로 표현할 수 있는 유니코드(Unicode) 표준이 1990년대에 도입되었다.
하나의 문자 세트에 전 세계 대부분의 언어를 넣어보자! 이름하여 유니코드(Universal)
전 세계의 모든 문자와 기호를 하나의 표준으로 통합하여 표현할 수 있는 문자 집합을 만드는 것
UTF-16, UTF-8의 시작
두 표준이 비슷하게 등장, 초반에는 UTF-16이 인기
UTF-16
1990년도
16bit(2byte) 기반
자주 사용하는 기본 다국어들은 2byte로 표현, 2byte는 65536가지를 표현할 수 있다.
영어, 유럽 언어, 한국어, 중국어, 일본어등이 2byte를 사용한다.
그 외는 4byte로 표현, 4byte는 42억 가지를 표현할 수 있다.
고대 문자, 이모지, 중국어 확장 한자 등
단점: ASCII 영문도 2byte를 사용한다. 그래서 ASCII와 호환되지 않는다.
UTF-16을 사용한다면, 영문의 경우 다른 문자 집합보다 2배의 메모리를 더 사용하게 된다.
웹에 있는 문서의 80% 이상이 영문 문서이다.
ASCII와 호환되지 않는다는 점도 큰 단점 중 하나이다.
초반에는 UTF-16이 인기였어서, 이 시기에 등장한 자바도 언어 내부적으로 문자를 표현할 때 UTF-16을 사용했다. 그래서 자바의 char 타입이 2byte를 사용한다.
대부분의 문자를 2byte로 처리하기 때문에 계산이 편리하다.
UTF-8
1990년도
8bit(1byte)기반, 가변길이 인코딩
1byte ~ 4byte를 사용해서 문자를 인코딩
1byte: ASCII, 영문, 기본 라틴 문자
2byte: 그리스어, 히브리어, 라틴 확장 문자
3byte: 한글, 한자, 일본어
4byte: 이모지, 고대문자 등
단점은 상대적으로 사용이 복잡하다. 그 이유는 UTF-16은 대부분의 기본 문자들이 2byte로 표현되기 때문에, 문자열의 특정 문자에 접근하거나 문자 수를 세는 작업이 상대적으로 간단하지만 UTF-8에서는 각 문자가 가변 길이로 인코딩되므로 이런 작업이 더 복잡하다.
또 다른 단점으로는 ASCII를 제외한 일부 언어에서는 더 많은 용량을 사용한다. UTF-8은 ASCII 문자를 1byte로, 비ASCII 문자를 2~4byte로 인코딩한다. 한글, 한자, 아랍어, 히브리어와 같은 문자들은 UTF-8에서 3byte 또는 4byte를 차지한다. 반면, UTF-16에서는 이들 문자가 대부분 2byte로 인코딩된다.
장점: ASCII 문자는 1byte로 표현, ASCII와 호환
현대의 사실상 표준 인코딩 기술
1990년도 후반 ~ 2000년도 초반에 인터넷과 웹이 빠르게 성장하면서 저변 확대
2008년 W3C 웹 표준에 UTF-8 채택
현재 대부분의 웹 사이트와 애플리케이션에서 기본 인코딩으로 사용
정리를 하자면, UTF-8이 현대의 사실상 표준 인코딩 기술이 된 이유는 다음과 같다.
저장 공간 절약과 네트워크 효율성: UTF-8은 ASCII 문자를 포함한 많은 서양 언어의 문자에 대해 1byte를 사용한다. 반면, UTF-16은 최소 2byte를 사용하므로, 주로 ASCII 문자로 이루어진 영문 텍스트에서는 UTF-8이 2배 더 효율적이다. 특히 데이터를 네트워크로 전달할 때는 매우 큰 효율의 차이를 보인다. 참고로 웹에 있는 문서의 80% 이상은 영문 문서이다.
ASCII와의 호환성: UTF-8은 ASCII와 호환된다. UTF-8로 인코딩된 텍스트에서 ASCII 범위에 있는 문자는 기존 ASCII와 동일한 방식으로 처리된다. 예를 들어 문자 "A"는 65로 인코딩 된다. 많은 레거시 시스템은 ASCII 기반으로 구축되어 있다. UTF-8은 이러한 시스템과의 호환성을 유지하면서도 전 세계의 모든 문자를 표현할 수 있다.
그래서 결론적으로 UTF-8을 우리도 사용하면 된다.
참고로, 한글 윈도우의 경우 기존 윈도우와 호환성 때문에 기본 인코딩을 MS949로 유지한다. 한글 윈도우도 기본 인코딩을 UTF-8로 변경하려고 노력중이다.
String.getBytes(Charset) 메서드를 사용하면 String 문자를 byte 배열로 변경할 수 있다.
이때, 중요한 점이 있는데, 문자를 byte로 변경하려면 문자 집합이 필요하다는 점이다. 따라서 어떤 문자 집합을 참고해서 byte로 변경할지 정해야 한다. String.getBytes()의 인자로 Charset 객체를 전달하면 된다.
문자 집합을 지정하지 않으면 현재 시스템에서 사용하는 기본 문자 집합을 인코딩에 사용한다.
실행 결과
A -> [US-ASCII] 인코딩, [65], 1bytes
A -> [ISO-8859-1] 인코딩, [65], 1bytes
A -> [EUC-KR] 인코딩, [65], 1bytes
A -> [x-windows-949] 인코딩, [65], 1bytes
A -> [UTF-8] 인코딩, [65], 1bytes
A -> [UTF-16BE] 인코딩, [0, 65], 2bytes
========한글 지원=========
가 -> [EUC-KR] 인코딩, [-80, -95], 2bytes
가 -> [x-windows-949] 인코딩, [-80, -95], 2bytes
가 -> [UTF-8] 인코딩, [-22, -80, -128], 3bytes
가 -> [UTF-16BE] 인코딩, [-84, 0], 2bytes
US-ASCII, ISO-8859-1, EUC-KR, MS949, UTF-8은 모두 ASCII와 호환된다. 그래서 영문 A는 1byte만 사용하고, 숫자 65로 인코딩된다.
그러나, UTF-16은 ASCII와 호환되지 않는다. 영문 A는 2byte를 사용하고,숫자 [0, 65]로 인코딩된다. 이러한 이유로 UTF-16은 잘 사용되지 않는것이다. ASCII와 호환되지 않으니까.
그리고 한글을 보자. EUC-KR, MS949는 서로 같은 숫자로 인코딩된다. 즉, 두 문자 집합이 호환이 가능하다는 의미다. 그러나, UTF-8, UTF-16과는 다 다른 숫자로 인코딩된다. 즉, 이 둘과는 호환이 안된다는 것이다. 그리고 우리가 접하는 한글이 깨지는 현상도 대부분이 이 경우이다.
참고로, UTF-16, UTF-16BE, UTF-16LE가 있는데, 그냥 UTF-16BE를 사용하면 된다. BE, LE는 byte의 순서의 차이이다.
UTF-16BE: [-84, 0]
UTF-16LE: [0, -84]
UTF-16: 인코딩한 문자가 BE, LE 중에 어떤 것인지 알려주는 2byte가 앞에 추가로 붙는다.
어차피 UTF-16은 이제 거의 사용하지 않으니까 그냥 알아만 두고 넘어가자!
참고: byte 출력에 마이너스 숫자가 보이는 이유
전혀 중요한 부분은 아니다. 그냥 참고만 하자.
'가'를 EUC-KR로 인코딩하는 예시
가 -> [EUC-KR] 인코딩, [-80, -95], 2bytes
byte의 기본 개념
1byte는 8개의 bit로 구성된다.
1byte로 256가지 경우를 표현할 수 있다.
한글 '가'의 EUC-KR 인코딩
'가'는 EUC-KR에서 2byte로 표현된다.
첫번째 byte: 10110000 (10진수로 176)
두번째 byte: 10100001 (10진수로 161)
따라서, '가'는 10 진수로 표현하면 [176, 161]로 표현되어야 한다.
자바에서의 byte 표현
자바의 byte 타입은 양수와 음수를 모두 표현할 수 있다.
자바의 byte는 첫번째 비트(bit)가 0이면 양수, 1이면 음수로 간주된다.
0 ~ 127: 첫 비트가 0이면 양수로 간주되며, 나머지 7bit로 0부터 127까지의 128가지 숫자를 표현한다.
-127 ~ -1: 첫 비트가 1이면 음수로 간주되며, 나머지 7bit로 -128부터 -1까지의 128가지 숫자를 표현한다.
결국 자바의 byte는 256가지 값을 표현하지만, 표현 가능한 숫자의 범위는 -128 ~ 127이다.
즉, '가'라는 문자를 EUC-KR로 인코딩하면 2byte로 표현되고, 첫번째, 두번째 byte모두 첫 1bit가 1이므로 자바에서는 음수로 해석한다.
그래서 자바에서는 음수로 표현된다. 그럼에도 불구하고, 실제 메모리에 저장되는 값은 동일하다 (10110000, 10100001). 그냥 이런 이유로 음수가 보인다는 것만 알고 넘어가자.
문자 인코딩 예제2
이번에는 문자를 인코딩하고 디코딩도 해보자. 그리고 좀 더 다양한 예시를 알아보자.
package cwchoiit.charset;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static java.nio.charset.StandardCharsets.*;
public class EncodingMain2 {
private static final Charset EUC_KR = Charset.forName("EUC-KR");
private static final Charset MS_949 = Charset.forName("MS949");
public static void main(String[] args) {
System.out.println("== 영문 ASCII 인코딩 ==");
test("A", US_ASCII, US_ASCII);
test("A", US_ASCII, ISO_8859_1);
test("A", US_ASCII, EUC_KR);
test("A", US_ASCII, MS_949);
test("A", US_ASCII, UTF_8);
test("A", US_ASCII, UTF_16BE); // 디코딩 실패
System.out.println("== 한글 인코딩 - 기본 ==");
test("가", US_ASCII, US_ASCII);
test("가", ISO_8859_1, ISO_8859_1);
test("가", EUC_KR, EUC_KR);
test("가", MS_949, MS_949);
test("가", UTF_8, UTF_8);
test("가", UTF_16BE, UTF_16BE);
System.out.println("== 한글 인코딩 - 복잡한 문자 ==");
test("쀍", EUC_KR, EUC_KR);
test("쀍", MS_949, MS_949);
test("쀍", UTF_8, UTF_8);
test("쀍", UTF_16BE, UTF_16BE);
System.out.println("== 한글 인코딩 - 디코딩이 다른 경우 ==");
test("가", EUC_KR, MS_949);
test("쀏", MS_949, EUC_KR); // 인코딩은 가능하지만, 디코딩이 안된다. 이유는 MS_949는 더 많은 문자를 다룰 수 있지만 EUC_KR은 기본 한글 문자만 다룰 수 있기 떄문
test("가", EUC_KR, UTF_8); // X
test("가", MS_949, UTF_8); // X
test("가", UTF_8, MS_949); // X
System.out.println("== 영문 인코딩 - 디코딩이 다른 경우 ==");
test("A", EUC_KR, UTF_8);
test("A", MS_949, UTF_8);
test("A", UTF_8, MS_949);
test("A", UTF_8, UTF_16BE); // X UTF-16은 ASCII 를 지원하지 않는다.
}
private static void test(String text, Charset encodingCharset, Charset decodingCharset) {
byte[] encoded = text.getBytes(encodingCharset);
String decoded = new String(encoded, decodingCharset);
System.out.printf("%s -> [%s] 인코딩 -> %s %sbyte -> [%s] 디코딩 -> %s\n",
text, encodingCharset, Arrays.toString(encoded), encoded.length, decodingCharset, decoded);
}
}
실행 결과
== 영문 ASCII 인코딩 ==
A -> [US-ASCII] 인코딩 -> [65] 1byte -> [US-ASCII] 디코딩 -> A
A -> [US-ASCII] 인코딩 -> [65] 1byte -> [ISO-8859-1] 디코딩 -> A
A -> [US-ASCII] 인코딩 -> [65] 1byte -> [EUC-KR] 디코딩 -> A
A -> [US-ASCII] 인코딩 -> [65] 1byte -> [x-windows-949] 디코딩 -> A
A -> [US-ASCII] 인코딩 -> [65] 1byte -> [UTF-8] 디코딩 -> A
A -> [US-ASCII] 인코딩 -> [65] 1byte -> [UTF-16BE] 디코딩 -> �
== 한글 인코딩 - 기본 ==
가 -> [US-ASCII] 인코딩 -> [63] 1byte -> [US-ASCII] 디코딩 -> ?
가 -> [ISO-8859-1] 인코딩 -> [63] 1byte -> [ISO-8859-1] 디코딩 -> ?
가 -> [EUC-KR] 인코딩 -> [-80, -95] 2byte -> [EUC-KR] 디코딩 -> 가
가 -> [x-windows-949] 인코딩 -> [-80, -95] 2byte -> [x-windows-949] 디코딩 -> 가
가 -> [UTF-8] 인코딩 -> [-22, -80, -128] 3byte -> [UTF-8] 디코딩 -> 가
가 -> [UTF-16BE] 인코딩 -> [-84, 0] 2byte -> [UTF-16BE] 디코딩 -> 가
== 한글 인코딩 - 복잡한 문자 ==
쀍 -> [EUC-KR] 인코딩 -> [63] 1byte -> [EUC-KR] 디코딩 -> ?
쀍 -> [x-windows-949] 인코딩 -> [-105, -51] 2byte -> [x-windows-949] 디코딩 -> 쀍
쀍 -> [UTF-8] 인코딩 -> [-20, -128, -115] 3byte -> [UTF-8] 디코딩 -> 쀍
쀍 -> [UTF-16BE] 인코딩 -> [-64, 13] 2byte -> [UTF-16BE] 디코딩 -> 쀍
== 한글 인코딩 - 디코딩이 다른 경우 ==
가 -> [EUC-KR] 인코딩 -> [-80, -95] 2byte -> [x-windows-949] 디코딩 -> 가
쀏 -> [x-windows-949] 인코딩 -> [-105, -49] 2byte -> [EUC-KR] 디코딩 -> ��
가 -> [EUC-KR] 인코딩 -> [-80, -95] 2byte -> [UTF-8] 디코딩 -> ��
가 -> [x-windows-949] 인코딩 -> [-80, -95] 2byte -> [UTF-8] 디코딩 -> ��
가 -> [UTF-8] 인코딩 -> [-22, -80, -128] 3byte -> [x-windows-949] 디코딩 -> 媛�
== 영문 인코딩 - 디코딩이 다른 경우 ==
A -> [EUC-KR] 인코딩 -> [65] 1byte -> [UTF-8] 디코딩 -> A
A -> [x-windows-949] 인코딩 -> [65] 1byte -> [UTF-8] 디코딩 -> A
A -> [UTF-8] 인코딩 -> [65] 1byte -> [x-windows-949] 디코딩 -> A
A -> [UTF-8] 인코딩 -> [65] 1byte -> [UTF-16BE] 디코딩 -> �
영문 'A'를 인코딩하면 1byte를 사용하고 숫자 65가 된다.
숫자 65를 디코딩하면 UTF-16을 제외하고 모두 디코딩이 가능하다.
UTF-16의 경우 ASCII와 호환되지 않기 때문에 나머지 문자 집합으로는 디코딩이 불가능하다. 그래서 '�' 이렇게 생긴 특수문자가 출력된다.
한글 '가'는 ASCII, ISO-8859-1로 인코딩 할 수 없다. 이 둘은 한글을 지원하지 않는 문자 집합이다.
EUC-KR, MS949, UTF-8, UTF-16은 한글 인코딩 디코딩이 잘 수행된다. 이들은 한글을 지원하는 문자 집합이다.
한글 '가'는 EUC-KR, MS949의 경우 모두 같은 값을 반환한다. 즉, 이 둘은 서로 호환된다.
그러나, EUC-KR은 자주 사용하는 한글 2350개로만 표현할 수 있다. 따라서 '뷁'과 같은 문자는 문자 집합에 없으므로 인코딩이 불가능하다.
MS949, UTF-8, UTF-16은 모두 한글을 표현할 수 있고, 굉장히 넓은 범위의 한글을 지원하기 때문에 '뷁'과 같은 문자도 인코딩이 가능하다.
정리를 하자면
ASCII 영문 인코딩: UTF-16을 제외하고 모두 호환
사실상 표준인 UTF-8을 사용하면 된다.
한글이 깨지는 가장 큰 이유
EUC-KR(MS949)와 UTF-8은 서로 호환되지 않는다.
한글이 깨지는 대부분의 문제는 UTF-8로 인코딩한 한글을 EUC-KR(MS949)로 디코딩하거나, 그 반대의 경우일 때 발생한다.
EUC-KR(MS949) 또는 UTF-8로 인코딩한 한글을 ISO-8859-1로 디코딩할 때
이게 이제 IntelliJ IDEA에서 많이 발생하는 경우다. 꼭 보면 기본 파일 인코딩 집합이 ISO-8859-1 이거로 되어 있다.
addFilterBefore()는 원하는 필터를 지정한 필터보다 더 앞 순서로 등록하는 것이다. 이 코드를 예로 들면, 내가 만든 StopWatchFilter를 등록할건데 이 필터는 Spring Security에서 등록된 필터 중 가장 첫번째 순서로 알려져 있는 WebAsyncManagerIntegrationFilter보다 더 앞으로 등록해서 가장 앞에 필터를 등록한다.
addFilterAfter(), addFilterAt(), addFilter()메서드도 있다. addFilterAfter()는 지정한 필터 바로 다음에 등록하는 것이다. addFilter()는 순서와 상관없이 그냥 필터를 등록하는 가장 단순한 방법이다. 그럼 addFilterAt()은 뭘까?
addFilterAt()은 등록하고자 하는 필터를 가지고 지정한 필터를 대체하는 것이다. 예를 들어 다음코드를 보자.
이렇게 작성했다면, MyCustomFilter를 등록할건데 그 필터가 UsernamePasswordAuthenticationFilter의 위치에 등록되는 것이고 UsernamePasswordAuthenticationFilter 필터를 대체한다. 그러니까 더 이상 기존의 UsernamePasswordAuthenticationFilter는 동작하지 않는것이다.
이렇게 여러 방법으로 필터를 등록할 수 있다. 그리고 커스텀 필터 만드는 게 이렇게 쉽다.
어떠한 인증도 하지 않은 요청이 들어오면 로그인 페이지로 리다이렉트 되는지를 확인하는 테스트이다.
다음은 인증을 특정 유저로 했을 때 정상적으로 동작하는지를 확인하는 테스트이다. 이 테스트가 중요하다.
@Test
@WithUserDetails(
value = "user",
userDetailsServiceBeanName = "userServiceImpl", // UserDetailsService 구현한 구현체의 빈 이름
setupBefore = TestExecutionEvent.TEST_EXECUTION //언제 유저가 세팅되는지를 지정 (TEST_EXECUTION -> 이 테스트가 실행되기 바로 직전에 유저를 세팅)
)
void getNotes_authenticated() throws Exception {
mockMvc.perform(get("/note"))
.andExpect(status().isOk());
}
여기서, @WithUserDetails() 애노테이션으로, UserDetails 타입의 유저를 mockMvc로 요청할 때 사용한다.
그러니까, UserDetailsService를 구현한 구현체의 빈 이름을 알려주면, 해당 빈을 찾아서 그 빈이 구현한 loadUserByUsername()을 사용한다. 그때 유저이름을 value값으로 지정한 `user`로 사용한다.
쉽게 말해, `/note`로 요청을 날릴 때 SecurityContext안에 Authentication으로 이 유저를 집어넣는다는 의미가 된다. 그래서 실제로 알려준 빈을 통해 loadUserByUsername('user')를 호출해서 찾아진 유저를 SecurityContext안에 넣는다.
참고로, @WithMockUser, @WithAnonymousUser라는 애노테이션도 있는데, @WithMockUser는 반환하는 타입이 org.springframework.security.core.userdetails.User라서 만약, 서비스 내에서 UserDetails를 구현한 본인만의 User를 사용한다면 이 애노테이션을 사용할 순 없다. 그리고 @WithAnonymousUser는 말 그대로 Authentication 정보로 `anonymousUser`가 들어간 상태로 테스트가 진행된다.
또한, @WithSecurityContext 애노테이션도 있는데, 이 애노테이션은 Authentication을 가짜로 만드는게 아니라, 아예 진짜 SecurityContext를 만드는 애노테이션이다.