서론

 

Excel Upload 기능이 필요하여 많이쓰는 POI 라이브러리를 살펴보았으나, 2가지 아쉬운점이 있었습니다.

 

1. 비즈니스 로직과 POI 라이브러리 코드의 강결합
2. DOM과 SAX 방식은 코드 작성 방법이 달라 둘 다 쓰는데 있어 유지보수의 어려움

 

 

DOM 방식

try {
    Workbook workbook = WorkbookFactory.create(file.getInputStream());
    Sheet sheet = workbook.getSheetAt(0);
    for(int i = 0 ; i < sheet.getPhysicalNumberOfRows() ; i++){
        final Row row = sheet.getRow(i);
        for(int j = 0; j < row.getPhysicalNumberOfCells(); j++){
            final Cell cell = row.getCell(j);
            //Business Code
        }
    }
} catch (IOException e) {
    e.printStackTrace();
}

 

SAX 방식

try {
    OPCPackage pkg = OPCPackage.open(file.getInputStream());
    XSSFReader r = new XSSFReader(pkg);
    SharedStringsTable sst = r.getSharedStringsTable();
    StylesTable styles = r.getStylesTable();
    XMLReader parser = XMLHelper.newXMLReader();


    SAXSheetHandler sheetHandler = //사용자가 정의한 SAXSheetHandler(? extends DefaultHandler)
    ContentHandler handler = new XSSFSheetXMLHandler(styles, sst, sheetHandler, false);
    SAXRowHandler rowHandler = new SAXRowHandler();


    parser.setContentHandler(handler);
    try (InputStream sheet = r.getSheetsData().next()) {
        parser.parse(new InputSource(sheet));
    }
}
catch (Exception e) {
     e.printStackTrace();
}

 

이러한 문제를 어떻게 해결할 수 있을까 고민하던 와중 우아한 형제들 Excel 기술 블로그를 보고 영감을 얻어 Excel 업로드 라이브러리를 개발하기로 했습니다. 이번 포스팅은 개인 프로젝트로 진행한 라이브러리 설계 과정과 적용 기술 및 개발 당시 어려움을 겪은 내용을 다루겠습니다.

 

라이브러리 사용법 및 공식 문서는 GithubWiki 페이지를 참고하시기 바랍니다.


개발 과정

 

먼저 개발에 앞서 필요 기능을 리스트업 했습니다.

 

1. DOMSAX 방법에 대한 추상화된 API를 제공해야한다.

2. Streaming 방식과 Collection 방식을 제공해야한다.

3. 사용자 코드에서 POI 코드가 직접적으로 의존되지 않아야한다.

4. 학습비용이 낮아야한다.

5. 다국어 처리를 지원해야한다.

6. Validation 기능을 제공해야한다.

 

 


 

1. DOM 과 SAX 방식에 대한 공통 API 구현

 

POI에서 제공하는 DOM과 SAX 방식은 구현 방법이 완전히 다릅니다. 그 이유는 제공하는 API도 다를 뿐더러 DOM 방식은 Pull 방식, SAX 방식은 Push 방식으로 Parsing 결과를 제공하기 때문입니다.

 

따라서, 먼저 이러한 두 가지 방법에 대해서 공통으로 처리할 수 있는 API를 설계하고 이를 Interface 제공하도록 구상하였습니다.

 

 

 

 

위와 같이 interface를 정의하면 사용자는 구현의 Detail은 알 필요없이 API 호출만으로 SAX 방식 혹은 DOM 방식으로 결과를 얻을 수 있습니다.

 

 

 

두번째는 Excel 파일에서 데이터를 Parsing하기 위해서는 Sheet에 대한 처리, 각각의 Row에 대한 처리가 필요합니다. 따라서, 이전과 마찬가지로 Row와 Sheet에 대한 각각의 inteface를 정의한 다음 각각의  Reader는 interface에 의존함으로써, 공통화된 기능을 제공할 수 있도록 설계했습니다.

 

 

 

 

public class ReaderFactory {
    private final ExcelMetaModelMappingContext context;
    
    public ReaderFactory(ExcelMetaModelMappingContext context) {
        this.context = context;
    }
    
    public <T> Reader<T> createInstance(ReaderType type, Class<T> tClass)  {
        final boolean isCached = context.hasMetaModel(tClass);
        if (type == ReaderType.WORKBOOK) {
            return isCached ? new WorkBookReader<>(tClass, context.getMetaModel(tClass)) : new WorkBookReader<>(tClass);
        }
        return isCached ? new SAXReader<>(tClass, context.getMetaModel(tClass)) : new SAXReader<>(tClass);
    }

    public  <T> Reader<T> createInstance(Class<T> tClass){
        final ExcelBody entity = tClass.getAnnotation(ExcelBody.class);
        return createInstance(entity.type(), tClass);
    }
}

 

여기에, Factory 클래스를 추가하여 사용자가 Enum 값으로 SAX 혹은 DOM(WorkBook) 방식 중 하나를 지정하면, 그에 해당하는 Excel Reader를 생성하도록 추가하였습니다. 지금까지 설명한 내용을 도식화하면 위 그림과 같습니다.

 


2.  Annotation 기반 메타 정보 작성

 

Excel로 읽는 각 Row 데이터는 결국 특정 Entity로 변환되어 DB에 저장되거나 비즈니스 로직에서 사용될 것입니다. 이러한 Entity를 POJO스럽게 유지하면서도 라이브러리에서 필요한 다양한 메타 정보를 기록할 수 있는 방법 중 하나는 @Annotation 활용입니다. Spring 환경에서 개발하면, 다양한 Annotation을 접하게 되는데, 라이브러리를 개발함에 있어서도 이러한 Annotation을 사용하여 Entity 클래스내에 라이브러리 코드가 직접 침투되지 않도록 설계하였습니다.

 

또한, Annotation을 사용함에 있어 JPA와 유사한 스타일을 적용하면, 학습곡선을 많이 낮출 수 있다고 생각하여 비슷하게 디자인했습니다.

 

@ExcelBody(dataRowPos = 3, 
           type = ReaderType.SAX,
           headerRowRange = @RowRange(start = 1, end = 2),
           messageSource = PersonMessageConverter.class)
@ExcelBody(dataRowPos = 2)
@ExcelMetaCachePut
@ExcelColumnOverrides({
        @ExcelColumnOverride(headerName = "생성일", index = 8, column = @ExcelColumn(headerName = "생성일자")),
        @ExcelColumnOverride(headerName = "수정일", index = 10, column = @ExcelColumn(headerName = "수정일자"))
})
public class Person extends BaseAuditEntity{
    @ExcelColumn(headerName = "이름")
    @NotNull
    private String name;
    
    @Merge(headerName = "전화번호")
    @ExcelColumnOverrides(@ExcelColumnOverride(headerName = "집전화번호", index = 5, column = @ExcelColumn(headerName = "휴대전화번호", index = 4)))
    private Phone phone;
    
    @ExcelEmbedded
    private Address address;
    
    @ExcelColumn(headerName = "생성일자")
    @DateTimeFormat(pattern = "yyyyMMdd")
    private LocalDate createdAt;
    
    @ExcelColumn(headerName = "성별")
    @ExcelConvert(converter = GenderConverter.class)
    private Gender gender;
}

 

결과적으로 위와 같이 Entity내 라이브러리 코드 작성 없이 메타 Annotation을 작성하게되면, 라이브러리 코드내에서 해당 Annotation 정보들을 참조하여 Entity 생성 및 데이터를 주입할 수 있도록 하였습니다.

 

(사용법은 Excel-Parser Wiki 페이지를 참고하시기 바랍니다)

 

 


3. Reflection 활용

 

사용자 코드에서 무엇을(What) 처리 해야할지 명시하고 어떻게(How) 처리해야할지는 기술하지 않았습니다. 즉 원하는 바만 선언하였으니, 라이브러리내에서 메타 정보를 읽어들여 사용자가 원하는대로 처리하고 반환 해야합니다.

 

Java에서는 Runtime 시점에 Reflection을 통해서 Instance 및 Class의 내부 정보를 알 수 있는 방법을 제공합니다. 따라서 이를 활용해서 라이브러리 내부에서 Annotation 분석 → 데이터 Parsing Entity 생성 데이터 주입 → 데이터 Validation 검증 과정 순서대로 처리할 수 있도록 구상하였습니다.

 

 

 

위 4가지 단계에서 데이터 Parsing은 SAX Reader, WorkBook Reader가 담당하는 것을 이전 내용을 통해 확인했습니다. 

따라서, Annotation 분석과, Entity 생성을 위해 이를 담당할 Class를 추가로 생성하였습니다.

 

ExcelEntityParser와 EntityInstantiator는 Reflection을 활용하여, Entity 내부를 탐색하는 과정을 담당합니다. Parser는 이 과정에서 Entity에 작성된 Annotation의 유효성 검증 및 헤더 정보 등을 취합하는 역할을 담당하고, Instantiator는 Entity를 생성하고, Parser에서 취합된 헤더 정보를 토대로 데이터를 주입하는 역할을 담당합니다.

 

public class ExcelEntityParser implements EntityParser {
    ...(중략)...
    private void doParse() {
        visited.add(tClass);
        findAllFields(tClass);
        final int annotatedFieldHeight = extractHeaderNames();

        calcHeaderRange(annotatedFieldHeight);
        validateHeaderRange();
        calcDataRowRange();
        validateOverlappedRange();
        extractOrder();
        validateOrder();
        validateHeaderNames();
    }
    
    ...(중략)...
    private void findAllFields(final Class<?> tClass) {
        ReflectionUtils.doWithFields(tClass, field -> {
            final Class<?> clazz = field.getType();

            if(field.isAnnotationPresent(ExcelConvert.class)){
                final Class<?> converterType = field.getAnnotation(ExcelConvert.class).converter();
                if(!converterType.getSuperclass().isAssignableFrom(ExcelColumnConverter.class)){
                    throw new InvalidHeaderException(String.format("Only ExcelColumnConverter is allowded. Entity : %s Converter: %s",this.tClass.getName(), converterType.getName()));
                }
            }
            else if(instantiatorSource.isSupportedDateType(clazz) && !field.isAnnotationPresent(DateTimeFormat.class)){
                throw new InvalidHeaderException(String.format("Date Type must be placed @DateTimeFormat Annotation. Entity : %s Field : %s ", this.tClass.getName(), clazz.getName()));
            }
            else if(!instantiatorSource.isSupportedInjectionClass(clazz) && visited.contains(clazz)){
                throw new UnsatisfiedDependencyException(String.format("Unsatisfied dependency expressed between class '%s' and '%s'", tClass.getName(), clazz.getName()));
            }

            if (instantiatorSource.isSupportedInjectionClass(clazz)) {
                declaredFields.add(field);
            } else {
                visited.add(clazz);
                findAllFields(clazz);
                visited.remove(clazz);
            }
        });
    }
    ...(중략)...
}

 

public class EntityInstantiator<T> {
    ...(중략)...
    public <R> EntityInjectionResult<T> createInstance(Class<? extends T> clazz, List<String> excelHeaderNames, ExcelMetaModel excelMetaModel, RowHandler<R> rowHandler) {
        resourceCleanUp();
        final T object = BeanUtils.instantiateClass(clazz);

        ReflectionUtils.doWithFields(clazz, f -> {
            if (!excelMetaModel.isPartialParseOperation()) {
                instantiateFullInjectionObject(object, excelHeaderNames, excelMetaModel, f, rowHandler);
            } else if (excelMetaModel.getInstantiatorSource().isCandidate(f)) {
                instantiatePartialInjectionObject(object, excelHeaderNames, excelMetaModel, f);
            }
        });

        if (excelMetaModel.isPartialParseOperation()) {
            setupInstance(excelHeaderNames, excelMetaModel.getInstantiatorSource(), rowHandler);
        }

        return new EntityInjectionResult<>(object, List.copyOf(exceptions));
    }
    
    ...(중략)...
    private <U> void setupInstance(final List<? extends String> headers, EntitySource entitySource, final RowHandler<U> rowHandler) {
        for (int i = 0; i < instances.size(); i++) {
            if (Objects.isNull(instances.get(i))) continue;

            Field field = instances.get(i).field;
            Class<?> type = field.getType();
            field.setAccessible(true);
            String value = rowHandler.getValue(i);

            try {
                final Object instance = instances.get(i).instance;
                if (!StringUtils.isEmpty(value)) {
                    inject(entitySource, field, type, value, instance);
                }
                validate(instance, headers.get(i), value, field.getName()).ifPresent(exceptions::add);
            } catch (IllegalAccessException | ParseException e) {
                addException(headers, field, value, e.getLocalizedMessage());
            }

        }
    }    
    ...(중략)...
}    

 

 

 

Entity Parser와 Instantiator까지 적용되면, 라이브러리로 Excel Parsing 요청시, 위 흐름대로 처리되는 것을 이해할 수 있습니다.

 


삽질의 시작

 

이전 내용을 토대로 기본적인 구현을 마친 이후 테스트를 해보자 몇가지 추가 고민이 생겼습니다. 그리고 이것은 이후 시작되는 삽질의 첫삽을 푼 순간이었습니다.

 

 

고민거리

 

  1. Entity에 지정된 Annotation 유효성 검증을 런타임에 수행하는데, Spring Boot 기동시점인 로드 타임에 검증하는 것이 더 좋지 않을까?
  2. Maven Central에 배포해보자!!!

 

삽질 1. 대상 Entity 클래스 Scanning

 

 

Spring Boot 기동 시점에 검증을 하려면, Excel Parser 라이브러리의 대상 Entity를 모두 찾을 수 있어야 합니다. 따라서, Spring에서 Bean Scanning 하는 코드 및 관련 클래스를 사용해야겠다고 생각했지만 검색 능력의 부족으로 인해 찾는데 많은 어려움을 겪었습니다. 많은 시행착오 끝에 ClassPathScanningCandidateComponentProvider 클래스가 해당 기능을 제공하는 것을 확인할 수 있었습니다.

 

ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false);
provider.findCandidateComponents("base 패키지명");

 


 

삽질 2. Default base 패키지명은 어떻게 알 수 있을까?

 

 

ClassPathScanningCandidateComponentProvider 클래스를 통해 base 패키지명을 String 타입으로 전달하면, 하위 패키지내 클래스를 탐색하는 기능을 제공해줌을 알 수 있습니다.

 

여기서 한가지 의문이 들었습니다.

 

'Spring Data JPA에서는 @EnableJpaRepositories Annotation을 통해 basePackages를 입력하지 않아도 Repository Bean을 만들 수 있었는데, 어떤 원리로 그런것일까? '

 

이것을 알기위해 구글링을 해봤지만, 어떠한 keyword로 검색해야할지 몰라 정확한 정보를 찾을 수 없었습니다. 

(대부분 @EnableJpaRepositories 설정 방법이나 basePackage를 지정하는 방법 관련된 검색결과가 대다수였습니다.)

 

결국, 선택한 방법은 코드내 EnableJpaRepositories 부터 시작해서 관련된 클래스 Debugger를 걸어놓고 코드를 따라 거슬러 오르는 방법이었습니다.

 

EnableJpaRepositores 검색결과

 

 

 

추적끝에 찾은 결과는 위와 같습니다. @EnableJpaRepositories 어노테이션을 Configuration 클래스에 선언하면, JpaRepositoriesRegistrar 클래스 정보가 같이 Import 됩니다. 이때, JpaRepositoryConfigExtension 클래스가 Bean으로 등록됩니다. 그리고 RepositoryConfigurationDelegate에게 Bean 탐색을 위임합니다.

 

 

이때, basePackages를 설정하게 되는데, 사용자가 지정한 @EnableRepositories Package 정보를 가져와서 지정합니다.

 

 

JpaRepositoriesAutoConfiguration은 JpaRepository 관련 자동설정을 하는데, JpaRepositoryConfigExtension 클래스가 Bean으로 등록되어있으면, 관련 자동설정을 하지 않습니다. @EnableJpaRepositories Annotation을 사용자가 지정했다면, 이전에 설명했듯이, JpaRepositoryConfigExtension가 Bean으로 등록되었기 때문에 자동설정을 하지 않습니다.

 

 

 

반면 @EnableJpaRepositories Annotation이 존재하지 않는다면, 마찬가지로 RepositoryConfigurationDelegate에게 Bean 탐색을 위임합니다. 이때 사용되는 basePackges는 AutoConfigurationPackages.get 메소드를 통해 얻을 수 있습니다. 그리고 해당 메소드가 바로 Spring Boot에서 사용되는 기본 basePackges 정보임을 알 수 있었습니다.

 

 


 

삽질 3. JPA는 되는데 난 안돼!!!

 

 

Spring Data JPA에서 사용되는 자동설정 Idea를 토대로 개발중인 라이브러리에 적용하기로 했습니다. 

@EnableExcelEntityScan Annotation과 AutoConfiguration 클래스를 만들어서 사용자가 Annotation을 지정하여 basePackage를 지정하지 않으면 AutoConfiguration의 설정을 따르도록 했습니다.

 

하지만 아무리 AutoConfigurationPackages.get 메소드를 호출해도 Bean 정보가 없다는 Exception이 발생하였습니다.

 

처음에는 AutoConfigurationPackages.get가 아니라 혹시 다른 메소드가 이를 대신하나 싶어서 샅샅히 찾아봤지만 코드상에서는 찾을 수 없었습니다.

 

그렇게 한참을 삽질하다 문득 spring.factories에 EnableAutoConfiguration 설정을 하지 않았음을 알게 되었고, 설마 이것때문에? 라는 생각으로 관련 AutoConfiguration 클래스를 등록시켰습니다.

 

 

그 결과, 설정 이후에 정상적으로 basePackage 정보를 가져오는 것을 확인하고 많이 부족함을 재차 느꼈습니다.

 


삽질 4. Gradle기반 Spring Boot Starter 만들기

 

Spring Boot Starter 관련하여, Maven 기반으로 Starter를 작성하는 방법에 대해서는 다수 있지만, Gradle로 만드는 방법은 찾기 어려웠습니다. 다만 Spring Boot Starter 개념은 아래 링크에 참고된 블로그를 통해서 학습할 수 있었습니다. 한참의 삽질끝에 완성할 수 있었습니다.

 

 

참고 블로그 

 - nevercaution.github.io/spring-boot-starter-custom/

 


삽질 5. Maven Central 배포하기

 

 

운이 좋게 저보다 앞서 고생하시고 그 기록을 남겨주신 siyoon210님 블로그를 통해서 다른 과정과 비교했을 때 큰 문제 없이 업로드할 수 있었습니다.

 


마치며

 

숲을 제대로 모른 상태에서 나무만 보면서 만들다보니 삽질이 많았습니다. 하지만 그런 시행착오를 겪으면서 배워서 그런지 학습한 내용이 보다 오랫동안 기억에 남을 것같습니다. 공식 문서에 사용법에 대해서 작성했으나 나중에 기회가된다면 튜토리얼 포스팅을 작성해볼까 합니다. 관련된 자료는 아래 링크를 참고하시기 바랍니다.

 

마지막으로 해당 라이브러리에 대한 코드기여는 언제나 환영입니다.

 

GitHub : Excel-Parser

Documentation : Excel-Parser Wiki

서론

 

이번 포스팅에서는 JPA에 Cache 적용방법에 대해서 다루어보겠습니다. 먼저 Cache 선정 기준 및 패턴에 대한 소개 및 적용 방법을 설명합니다. Cache로는 Ehcache3을 적용하며, Spring Actuator를 통해서 캐시 Metric 변화도 함께 살펴보겠습니다.

 


 

1. Cache 적용 기준

 

캐시란 간단하게 말해서 Key와 Value로 이루어진 Map이라고 볼 수 있습니다.

하지만 일반 Map과는 다르게 만료 시간을 통해 freshness 조절 및 캐시 제거 등을 통해서 공간을 조절할 수 있는 특징이 있습니다.

 

그렇다면 캐시 적용을 위해 고려해야할 척도는 무엇이 있을까요?

 

1. 얼마나 자주 사용하는가?

 

출처 : https://ko.wikipedia.org/wiki/%EA%B8%B4_%EA%BC%AC%EB%A6%AC

 

위 그림은 파레토 법칙을 표현합니다. 즉 시스템 리소스 20%가 전체 전체 시간의 80% 정도를 소요함을 의미합니다. 따라서 캐시 대상을 선정할 때에는 캐시 오브젝트가 얼마나 자주 사용하는지, 적용시 전체적인 성능을 대폭 개선할 수 있는지 등을 따져야합니다.

 

 

2. HitRatio

 

HitRatio는 캐시에 대하여 자원 요청과 비례하여 얼마나 캐시 정보를 획득했는지를 나타내며, 계산 식은 다음과 같습니다.

 

HitRatio = hits / (hits + misses) * 100

 

캐시공간은 한정된 공간이기 때문에, 만료시간을 설정하여 캐시 유지시간을 설정할 수 있습니다. misses가 높다는 것은 캐시공간의 여유가 없어 이미 캐시에서 밀려났거나, 혹은 자주 사용하지 않는 정보를 캐시하여 만료시간이 지난 오브젝트를 획득하고자할 때 발생할 수 있습니다. 따라서 캐시를 설정할 때는 캐시 공간의 크기 및 만료 시간을 고려해야합니다.

 


 

2. Cache 패턴

 

이번에는 캐시에 적용되는 패턴에 대해서 알아보도록 하겠습니다.

 

 

1. No Caching

 

 

 

말 그대로 캐시없이 Application에서 직접 DB로 요청하는 방식을 말합니다. 별도 캐시한 내역이 없으므로 매번 DB와의 통신이 필요하며, 부하가 유발되는 SQL이 지속 수행되면 DB I/O에 영향을 줍니다.

 

 

2. Cache-aside

 

 

 

Application 기동시 캐시에는 아무 데이터가 없으며, Application이 요청시에 Cache Miss가 발생하면, DB로부터 데이터를 읽어와 Cache에 적재합니다. 이후에 동일한 요청을 반복하면, 캐시에 데이터가 존재하므로 DB 조회 없이 바로 데이터를 전달받을 수 있습니다.

 

해당 패턴은 Application이 캐시 적재와 관련된 일을 처리하므로, Cache Miss가 발생했을 때 응답시간이 저하될 수 있습니다.

 

3. Cache-through

 

캐시에 데이터가 없는 상황에서 Miss가 발생했을 때, Application이 아닌 캐시제공자가 데이터를 처리한 다음 Application에게 데이터를 전달하는 방법입니다. 즉 기존에는 동기화의 책임이 Application에 있었다면, 해당 패턴은 캐시 제공자에게 책임이 위임됩니다.

 

Cache-through 패턴은 다음과 같이 세분화할 수 있습니다.

 

Read-through

 

 

데이터 읽기 요청시, 캐시 제공자가 DB와의 연계를 통해 데이터를 적재하고 이를 반환합니다.

 

Write-through

 

 

데이터 쓰기 요청시, Application은 캐시에만 적용을 요청하면, 캐시 제공자가 DB에 데이터를 저장하고, Application에게 응답하는 방식입니다. 모든 작업은 동기로 진행됩니다.

 

Write-behind

 

 

데이터 쓰기 요청시, Application은 데이터를 캐시에만 반영한 다음 요청을 종료합니다. 이후 일정 시간을 간격으로 비동기적으로 캐시에서 DB로 데이터를 저장요청합니다. 이 방식은 Application의 쓰기 요청 성능을 높일 수 있으나 만약 캐시에 DB에 저장하기 전에 다운된다면, 데이터 유실이 발생합니다.

 

 


 

3. EhCache

 

Ehcache는 Java에서 주로 사용하는 캐시 관련 오픈소스이며, Application에 Embedded되어 간편하게 사용할 수 있는 특징을 지니고 있습니다. EhCache3에서는 JSR-107에서 요구하는 표준을 준수하여 만들어졌기 때문에 2 버전과 3 버전 설정 방법이 다릅니다. 

 

Ehcache에서는 이전에 설명한 캐시 패턴을 모두 적용할 수 있습니다. 그 중 Cache-through 전략은 CacheLoaderWriter 인터페이스 구현을 통해서 적용할 수 있으나 해당 내용에 대해서는 다루지 않겠습니다.

 

자세한 설명은 공식 홈페이지를 참고바랍니다.

 

 

 

출처 : https://www.ehcache.org/documentation/3.2/caching-concepts.html

 

 

공식 메뉴얼에 따르면, 캐시 중요도에 따라 세군데 영역으로 나뉘어 저장할 수 있습니다. 먼저 Heap Tier는 GC가 관여할 수 있는 JVM의 Heap영역을 말합니다. 반면, Off Heap은 GC에서 접근이 불가능하며 OS가 관리하는 영역입니다. 해당 영역에 데이터를 저장하려면, -XX:MaxDirectMemorySize 옵션 설정을 통해 충분한 메모리를 확보해야합니다. 마지막 영역은 Disk 영역으로 해당 설명은 Skip 하겠습니다.

 

그럼 지금부터 지금까지 JPA 포스팅하면서 다룬 예제를 확장하여 EhCache를 적용하겠습니다. 예제 프로그램은 Cache-Aside 패턴을 통해 구현하며, 그외 나머지 패턴은 다루지 않겠습니다.

 

1. EhCache3 적용

 

build.gradle

...(중략)...
dependencies {
    ...(중략)...
    
    //cache
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation group: 'javax.cache', name: 'cache-api', version: '1.1.1'
    implementation group: 'org.hibernate', name: 'hibernate-jcache', version: '5.4.19.Final'
    implementation group: 'org.ehcache', name: 'ehcache', version: '3.8.1'
}
...(중략)...

 

 

application.yaml

spring:
  jpa:
    properties:
      javax:
        persistence:
          sharedcache:
            mode: ENABLE_SELECTIVE
      hibernate:
        cache:
          use_second_level_cache: true
          region:
            factory_class: org.hibernate.cache.jcache.internal.JCacheRegionFactory
        temp:
          use_jdbc_metadata_defaults: false
        format_sql: true
        show_sql: true
        use_sql_comments: true
    hibernate:
      ddl-auto: none
    database-platform: org.hibernate.dialect.Oracle10gDialect

 

JPA와 관련된 캐시 설정을 합니다. 설정 내용 중 캐시와 관련된 옵션을 살펴보겠습니다.

 

먼저 SharedCache는 캐시모드를 설정할 수 있는 옵션으로 enable_selective는 @Cacheable이 설정된 엔티티에만 캐시를 적용함을 의미합니다. 만약 모든 엔티티에 적용하려면 all 옵션을 줄 수 있습니다.

 

 

 

 

use_second_level_cache는 2차 캐시 활성화 여부를 지정합니다. JPA에서 1차 캐시는 PersistentContext를 의미하며, 각 세션레벨에서 트랜잭션이 진행되는 동안에 캐시됩니다. 반면 2차 캐시는 SessionFactory 레벨에서의 캐시를 의미하며 모든 세션에게 공유되는 공간입니다. 해당 옵션을 통해서 2차 캐시 설정 여부를 지정합니다.

 

factory_class는 캐시를 구현한 Provider 정보를 지정합니다. Ehcache3는 JSR-107 표준을 준수하여 개발되었기 때문에 JCacheRegionFactory를 지정합니다.

 

 

2. Configuration 설정

 

@Configuration
public class CachingConfig {
    public static final String DB_CACHE = "db_cache";

    private final javax.cache.configuration.Configuration<Object, Object> jcacheConfiguration;

    public CachingConfig() {
        this.jcacheConfiguration = Eh107Configuration.fromEhcacheCacheConfiguration(CacheConfigurationBuilder.newCacheConfigurationBuilder(Object.class, Object.class,
                ResourcePoolsBuilder.newResourcePoolsBuilder()
                        .heap(10000, EntryUnit.ENTRIES))
                .withSizeOfMaxObjectSize(1000, MemoryUnit.B)
                .withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(Duration.ofSeconds(300)))
                .withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofSeconds(600))));
    }

    @Bean
    public HibernatePropertiesCustomizer hibernatePropertiesCustomizer(javax.cache.CacheManager cacheManager) {
        return hibernateProperties -> hibernateProperties.put(ConfigSettings.CACHE_MANAGER, cacheManager);
    }

    @Bean
    public JCacheManagerCustomizer cacheManagerCustomizer() {
        return cm -> {
            cm.createCache(DB_CACHE, jcacheConfiguration);
        };
    }
}

 

 

Cache Config 클래스를 작성합니다. 먼저 생성자를 통해 캐시의 기본 설정을 구성했습니다. 위 구성은 테스트를 위해 임의로 지정하였으며, 커스터마이징하여 작성 가능합니다.

 

지정된 옵션 설명은 다음과 같습니다.

총 10000개의 Entity를 저장할 수 있으며, 각 오브젝트 사이즈는 1000 Byte를 넘지 않도록 제한하였습니다. Object는 최초 캐시에 입력후 600초 동안 저장되며, 만약 마지막으로 캐시 요청이후에 300초동안 재요청이 없을 경우 만료되도록 지정하였습니다.

 

자세한 설정 방법은 공식 문서를 참고 바랍니다.

 

@Entity
@Table
@Getter
@Cacheable
@org.hibernate.annotations.Cache(region = CachingConfig.DB_CACHE, usage = CacheConcurrencyStrategy.READ_ONLY)
public class Customer {
    @Id
    @GeneratedValue
    @Column(name = "id")
    private Long customerId;
    @Column(name = "name")
    private String customerName;
}

 

SharedCache 모드를 enable_selective로 지정하였으므로, @Cacheable 어노테이션을 추가하여 해당 엔티티를 캐시할 수 있도록 설정하였습니다. 캐시 제공자내에는 여러 캐시가 존재할 수 있으며, 캐시마다 이름이 부여되어있으므로 region영역에는 캐시내에서 참조할 캐시이름을 지정합니다.

 

usage는 캐시와 관련된 동시성 전략을 지정할 수 있습니다. 지정할 수 있는 옵션으로는 NONE, READ_ONLY, NONSTRICT_READ_WRITE, READ_WRITE, TRANSACTIONAL 총 5가지 입니다. 예제 프로그램에서는 읽기 전용으로만 지정하기 위해서 READ_ONLY 옵션을 부여했습니다.

 

 

@Service
@Slf4j
public class CustomerService {
   ...(중략)...

    public CustomerDTO getCustomer(Long id) {
        log.info("getCustomer from db ");
        Customer customer = repository.findById(id).orElseThrow(IllegalAccessError::new);
        return CustomerDTO.of(customer);
    }
}

 

@RestController
@RequiredArgsConstructor
@RequestMapping("/customers")
public class CustomerController {
    ...(중략)...

    @GetMapping("/{id}")
    public CustomerDTO getCustomer(@PathVariable Long id) {
        return service.getCustomer(id);
    }
}

 

테스트를 위해 Service 및 Controller에 관련 메소드를 생성합니다.

 

 

2020-08-05 23:09:30.493  INFO 1872 --- [nio-8080-exec-1] c.e.paging_demo.service.CustomerService  : getCustomer from db 
Hibernate: 
    select
        customer0_.id as id1_0_0_,
        customer0_.name as name2_0_0_ 
    from
        customer customer0_ 
    where
        customer0_.id=?
2020-08-05 23:09:40.278  INFO 1872 --- [nio-8080-exec-2] c.e.paging_demo.service.CustomerService  : getCustomer from db 
2020-08-05 23:09:51.598  INFO 1872 --- [nio-8080-exec-3] c.e.paging_demo.service.CustomerService  : getCustomer from db 
2020-08-05 23:09:52.384  INFO 1872 --- [nio-8080-exec-4] c.e.paging_demo.service.CustomerService  : getCustomer from db 

 

실행 후 로그 출력 결과입니다. 최초 요청시에는 SQL 수행결과가 로그에 기록되었지만, 그 이후에는 SQL이 수행되지 않고 2차 캐시에서 정상적으로 가져온 것을 확인할 수 있습니다.

 

 

3. DTO 레벨 캐시 추가 설정

 

이번에는 테스트 목적으로 Entitiy 뿐만 아니라 DTO에도 캐시를 적용해보겠습니다. 테스트를 위해 두 대상은 다른 캐시영역에 저장되며, 두 캐시영역간 설정에 차이를 두겠습니다.

 

@Configuration
public class CachingConfig {
    public static final String DB_CACHE = "db_cache";
    public static final String USER_CACHE = "user_cache";

    ...(중략)...

    @Bean
    public JCacheManagerCustomizer cacheManagerCustomizer() {
        return cm -> {
            cm.createCache(DB_CACHE, jcacheConfiguration);
            cm.createCache(USER_CACHE, Eh107Configuration.fromEhcacheCacheConfiguration(CacheConfigurationBuilder.newCacheConfigurationBuilder(Long.class, CustomerDTO.class,
                    ResourcePoolsBuilder.newResourcePoolsBuilder()
                            .heap(10000, EntryUnit.ENTRIES))
                    .withSizeOfMaxObjectSize(1000, MemoryUnit.B)
                    .withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(Duration.ofSeconds(10)))
                    .withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofSeconds(20)))));
        };
    }
}

 

새로운 캐시를 추가하기 위해서 위와같이 Configuration 클래스를 수정했습니다. 신규로 추가하는 USER_CACHE는 만료시간을 훨씬 짧게하여 최장 20초간만 캐시에 저장되도록 설정하였습니다.

 

@Service
@Slf4j
public class CustomerService {
    ...(중략)...

    @Cacheable(value = CachingConfig.USER_CACHE, key ="#id")
    public CustomerDTO getCustomer(Long id) {
        log.info("getCustomer from db ");
        Customer customer = repository.findById(id).orElseThrow(IllegalAccessError::new);
        return CustomerDTO.of(customer);
    }
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/customers")
@Slf4j
public class CustomerController {
    ...(중략)...

    @GetMapping("/{id}")
    public CustomerDTO getCustomer(@PathVariable Long id) {
        log.info("Controller 영역");
        return service.getCustomer(id);
    }
}

 

Service 클래스에 @Cacheable 어노테이션을 추가하여 캐시를 지정합니다. 이때 key는 파라미터로 전달받은 id를 지정하며, SPEL을 사용할 수 있습니다.

 

Controller 클래스에는 별다른 로직 추가 없이 로그만 남기도록 수정했습니다. 이제 어플리케이션을 재기동 후 결과를 보겠습니다.

 

2020-08-05 23:18:53.012  INFO 13244 --- [nio-8080-exec-1] c.e.p.controller.CustomerController      : Controller 영역
2020-08-05 23:18:53.055  INFO 13244 --- [nio-8080-exec-1] c.e.paging_demo.service.CustomerService  : getCustomer from db 
Hibernate: 
    select
        customer0_.id as id1_0_0_,
        customer0_.name as name2_0_0_ 
    from
        customer customer0_ 
    where
        customer0_.id=?
2020-08-05 23:18:57.093  INFO 13244 --- [nio-8080-exec-2] c.e.p.controller.CustomerController      : Controller 영역
2020-08-05 23:18:57.821  INFO 13244 --- [nio-8080-exec-3] c.e.p.controller.CustomerController      : Controller 영역
2020-08-05 23:19:03.127  INFO 13244 --- [nio-8080-exec-4] c.e.p.controller.CustomerController      : Controller 영역
2020-08-05 23:19:29.327  INFO 13244 --- [nio-8080-exec-6] c.e.p.controller.CustomerController      : Controller 영역
2020-08-05 23:19:29.329  INFO 13244 --- [nio-8080-exec-6] c.e.paging_demo.service.CustomerService  : getCustomer from db 

 

최초 기동 후에는 모든 캐시에 정보가 없으므로 Controller -> Service -> DB 순으로 호출되어 데이터가 캐싱되었음을 확인할 수 있습니다.

 

이후에는 Service Layer의 DTO 또한 캐시되었으므로 지속 호출시에 Controller 로그는 출력되나 Service 레벨 메소드는 호출되지 않습니다. 

 

20초가 지난 이후에는 Service Layer에 지정된 DTO 캐시가 만료되므로 Entity에 접근하나 해당 캐시는 아직 유효하므로 별도 DB 통신 없이 캐시에서 데이터를 반환하는 것을 확인할 수 있습니다.

 

지금까지 캐시 적재에 대해서만 살펴봤는데, 만약 수정, 삭제등으로 인해 캐시를 삭제해야한다면 @CacheEvict 어노테이션을 통해 삭제할 수 있습니다. 

 


 

4. Actuator 적용

 

Spring Actuator 프로젝트를 사용하게되면, 모니터링에 필요한 유용한 Metric 뿐만 아니라 HealthCheck 및 Dump 생성 그리고 Reload 등이 가능합니다. Spring Actuator를 통해서 Cache와 관련된 Metric을 활용해 Prometheus & Grafana 대시보드로 시각화 또한 가능합니다.

 

이번에는 Actuator 적용 후 URL 호출을 통해 Metric을 확인하는 방법에 대해서 살펴보겠습니다.

 

1. Actuator 설정

 

build.gradle

...(중략)...
dependencies {
    ...(중략)...
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}    

 

먼저 actuator 관련 의존성을 추가합니다.

 

 

application.yaml

management:
  endpoints:
    health:
      show-details: always
    web:
      exposure:
        include: "*"
  metrics:
    cache:
      instrument: true

 

해당 설정을 통해 actuator 기능을 활성화합니다.

 

2. 적용 확인

 

 

프로그램 기동 후 ip:port/actuator URL을 호출하면 위와 같이 상세 정보를 볼 수 있는 URL이 제공됩니다. 

 

 

 

Cache 관련 정보는 metrics 하위에 위 표시된 영역으로 정보를 확인할 수 있습니다.

 

 

 

위 그림은 gets과 관련된 metric 정보이며, 총 7번의 get 요청이 있었음을 확인할 수 있습니다. 그 밖에 다른 정보들도 제공되는 URL을 통해 정보 확인이 가능합니다.

 


마치며

 

이번 포스팅에서는 JPA 캐시 적용에 대해서 알아봤습니다. 일반적으로 코드성 데이터나 공지사항과 같이 수정/삭제가 거의 없고 자주 사용되는 데이터에 대해서 캐시를 적용하면, 좋은 효과를 볼 수 있습니다. 하지만 무턱대고 적용했다가는 오히려 성능 저하가 발생될 수 있으니 전략 수립이 필요합니다.

 

이번 포스팅에서는 각 Application 내부에서만 유효한 Local Cache에 대해서 다루었습니다. EhCache가 Clustering을 지원하지만, Application Scale Out에 영향을 주기때문에 개인적으로는 가급적 Local Cache로써 사용하고 캐시 무효화 시간을 짧게 가지는 것이 좋다고 생각합니다. 만약 캐시간의 동기화가 필요하다면 Clustering을 고려하거나 Redis와 같은 Third 캐시를 추가로 두는 것을 고려해볼 수 있습니다.

'JAVA > JPA' 카테고리의 다른 글

3. JPA Hateoas 적용하기  (8) 2020.08.01
2. JPA Sort 조건 개선  (2) 2020.07.30
1. JPA 페이징 쿼리 개선  (0) 2020.07.29

서론

 

이번 포스팅에서 왜 Hateoas를 적용해야하는지에 대해서 다루지 않습니다. 이와 관련된 자세한 이응준님의 그런 REST API로 괜찮은가 발표 세션을 꼭 보시기를 권장드리며, 해당 내용을 알고 계시다는 전제하에 포스팅을 작성하겠습니다.

 

이번에 다룰 내용은 Rest API 작성을 위해 필요한 Hateoas를 기존에 생성한 Page 객체에 적용하는 방법과 PagedResourceAssembler를 Customizing하는 방법을 알아보겠습니다.


1. 기존 Page 객체 이슈

 

 

위 그림은 이전 포스팅에서 예제 프로그램 API 호출 결과입니다. 해당 API는 원하는 요청에 대한 결과를 확인할 수 있습니다. 하지만 데이터를 통해서 어떠한 행위들이 가능한지 혹은 메시지가 정확히 어떠한 의미를 포함하고 있는지는 API 결과를 통해 알 수 없습니다.

 

다시말해 해당 API 결과를 보고서는 customer_id를 통해 어떠한 API를 추가적으로 호출할 수 있는지 어떤 상태 변화가 가능한지 알 수 없습니다.

 

그렇다면 Hateoas가 적용된 API의 모습은 어떨까요?

 

[
  {
    "id": 1,
    "node_id": "MDU6SXNzdWUx",
    "url": "https://api.github.com/repos/octocat/Hello-World/issues/1347",
    "repository_url": "https://api.github.com/repos/octocat/Hello-World",
    "labels_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/labels{/name}",
    "comments_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments",
    "events_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/events",
    "html_url": "https://github.com/octocat/Hello-World/issues/1347",
    "number": 1347,
    "state": "open",
    "title": "Found a bug",
    "body": "I'm having a problem with this.",
    "user": {
      "login": "octocat",
      "id": 1,
      "node_id": "MDQ6VXNlcjE=",
      "avatar_url": "https://github.com/images/error/octocat_happy.gif",
      "gravatar_id": "",
      "url": "https://api.github.com/users/octocat",
      "html_url": "https://github.com/octocat",
      "followers_url": "https://api.github.com/users/octocat/followers",
      "following_url": "https://api.github.com/users/octocat/following{/other_user}",
      "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
      "organizations_url": "https://api.github.com/users/octocat/orgs",
      "repos_url": "https://api.github.com/users/octocat/repos",
      "events_url": "https://api.github.com/users/octocat/events{/privacy}",
      "received_events_url": "https://api.github.com/users/octocat/received_events",
      "type": "User",
      "site_admin": false
    },
    "labels": [
      {
        "id": 208045946,
        "node_id": "MDU6TGFiZWwyMDgwNDU5NDY=",
        "url": "https://api.github.com/repos/octocat/Hello-World/labels/bug",
        "name": "bug",
        "description": "Something isn't working",
        "color": "f29513",
        "default": true
      }
    ],
    "assignee": {
      "login": "octocat",
      "id": 1,
      "node_id": "MDQ6VXNlcjE=",
      "avatar_url": "https://github.com/images/error/octocat_happy.gif",
      "gravatar_id": "",
      "url": "https://api.github.com/users/octocat",
      "html_url": "https://github.com/octocat",
      "followers_url": "https://api.github.com/users/octocat/followers",
      "following_url": "https://api.github.com/users/octocat/following{/other_user}",
      "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
      "organizations_url": "https://api.github.com/users/octocat/orgs",
      "repos_url": "https://api.github.com/users/octocat/repos",
      "events_url": "https://api.github.com/users/octocat/events{/privacy}",
      "received_events_url": "https://api.github.com/users/octocat/received_events",
      "type": "User",
      "site_admin": false
    },
    "assignees": [
      {
        "login": "octocat",
        "id": 1,
        "node_id": "MDQ6VXNlcjE=",
        "avatar_url": "https://github.com/images/error/octocat_happy.gif",
        "gravatar_id": "",
        "url": "https://api.github.com/users/octocat",
        "html_url": "https://github.com/octocat",
        "followers_url": "https://api.github.com/users/octocat/followers",
        "following_url": "https://api.github.com/users/octocat/following{/other_user}",
        "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
        "organizations_url": "https://api.github.com/users/octocat/orgs",
        "repos_url": "https://api.github.com/users/octocat/repos",
        "events_url": "https://api.github.com/users/octocat/events{/privacy}",
        "received_events_url": "https://api.github.com/users/octocat/received_events",
        "type": "User",
        "site_admin": false
      }
    ],
    "milestone": {
      "url": "https://api.github.com/repos/octocat/Hello-World/milestones/1",
      "html_url": "https://github.com/octocat/Hello-World/milestones/v1.0",
      "labels_url": "https://api.github.com/repos/octocat/Hello-World/milestones/1/labels",
      "id": 1002604,
      "node_id": "MDk6TWlsZXN0b25lMTAwMjYwNA==",
      "number": 1,
      "state": "open",
      "title": "v1.0",
      "description": "Tracking milestone for version 1.0",
      "creator": {
        "login": "octocat",
        "id": 1,
        "node_id": "MDQ6VXNlcjE=",
        "avatar_url": "https://github.com/images/error/octocat_happy.gif",
        "gravatar_id": "",
        "url": "https://api.github.com/users/octocat",
        "html_url": "https://github.com/octocat",
        "followers_url": "https://api.github.com/users/octocat/followers",
        "following_url": "https://api.github.com/users/octocat/following{/other_user}",
        "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
        "organizations_url": "https://api.github.com/users/octocat/orgs",
        "repos_url": "https://api.github.com/users/octocat/repos",
        "events_url": "https://api.github.com/users/octocat/events{/privacy}",
        "received_events_url": "https://api.github.com/users/octocat/received_events",
        "type": "User",
        "site_admin": false
      },
      "open_issues": 4,
      "closed_issues": 8,
      "created_at": "2011-04-10T20:09:31Z",
      "updated_at": "2014-03-03T18:58:10Z",
      "closed_at": "2013-02-12T13:22:01Z",
      "due_on": "2012-10-09T23:39:01Z"
    },
    "locked": true,
    "active_lock_reason": "too heated",
    "comments": 0,
    "pull_request": {
      "url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347",
      "html_url": "https://github.com/octocat/Hello-World/pull/1347",
      "diff_url": "https://github.com/octocat/Hello-World/pull/1347.diff",
      "patch_url": "https://github.com/octocat/Hello-World/pull/1347.patch"
    },
    "closed_at": null,
    "created_at": "2011-04-22T13:33:48Z",
    "updated_at": "2011-04-22T13:33:48Z",
    "repository": {
      "id": 1296269,
      "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5",
      "name": "Hello-World",
      "full_name": "octocat/Hello-World",
      "owner": {
        "login": "octocat",
        "id": 1,
        "node_id": "MDQ6VXNlcjE=",
        "avatar_url": "https://github.com/images/error/octocat_happy.gif",
        "gravatar_id": "",
        "url": "https://api.github.com/users/octocat",
        "html_url": "https://github.com/octocat",
        "followers_url": "https://api.github.com/users/octocat/followers",
        "following_url": "https://api.github.com/users/octocat/following{/other_user}",
        "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
        "organizations_url": "https://api.github.com/users/octocat/orgs",
        "repos_url": "https://api.github.com/users/octocat/repos",
        "events_url": "https://api.github.com/users/octocat/events{/privacy}",
        "received_events_url": "https://api.github.com/users/octocat/received_events",
        "type": "User",
        "site_admin": false
      },
      "private": false,
      "html_url": "https://github.com/octocat/Hello-World",
      "description": "This your first repo!",
      "fork": false,
      "url": "https://api.github.com/repos/octocat/Hello-World",
      "archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}",
      "assignees_url": "http://api.github.com/repos/octocat/Hello-World/assignees{/user}",
      "blobs_url": "http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}",
      "branches_url": "http://api.github.com/repos/octocat/Hello-World/branches{/branch}",
      "collaborators_url": "http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}",
      "comments_url": "http://api.github.com/repos/octocat/Hello-World/comments{/number}",
      "commits_url": "http://api.github.com/repos/octocat/Hello-World/commits{/sha}",
      "compare_url": "http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}",
      "contents_url": "http://api.github.com/repos/octocat/Hello-World/contents/{+path}",
      "contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors",
      "deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments",
      "downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads",
      "events_url": "http://api.github.com/repos/octocat/Hello-World/events",
      "forks_url": "http://api.github.com/repos/octocat/Hello-World/forks",
      "git_commits_url": "http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}",
      "git_refs_url": "http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}",
      "git_tags_url": "http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}",
      "git_url": "git:github.com/octocat/Hello-World.git",
      "issue_comment_url": "http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}",
      "issue_events_url": "http://api.github.com/repos/octocat/Hello-World/issues/events{/number}",
      "issues_url": "http://api.github.com/repos/octocat/Hello-World/issues{/number}",
      "keys_url": "http://api.github.com/repos/octocat/Hello-World/keys{/key_id}",
      "labels_url": "http://api.github.com/repos/octocat/Hello-World/labels{/name}",
      "languages_url": "http://api.github.com/repos/octocat/Hello-World/languages",
      "merges_url": "http://api.github.com/repos/octocat/Hello-World/merges",
      "milestones_url": "http://api.github.com/repos/octocat/Hello-World/milestones{/number}",
      "notifications_url": "http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}",
      "pulls_url": "http://api.github.com/repos/octocat/Hello-World/pulls{/number}",
      "releases_url": "http://api.github.com/repos/octocat/Hello-World/releases{/id}",
      "ssh_url": "git@github.com:octocat/Hello-World.git",
      "stargazers_url": "http://api.github.com/repos/octocat/Hello-World/stargazers",
      "statuses_url": "http://api.github.com/repos/octocat/Hello-World/statuses/{sha}",
      "subscribers_url": "http://api.github.com/repos/octocat/Hello-World/subscribers",
      "subscription_url": "http://api.github.com/repos/octocat/Hello-World/subscription",
      "tags_url": "http://api.github.com/repos/octocat/Hello-World/tags",
      "teams_url": "http://api.github.com/repos/octocat/Hello-World/teams",
      "trees_url": "http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}",
      "clone_url": "https://github.com/octocat/Hello-World.git",
      "mirror_url": "git:git.example.com/octocat/Hello-World",
      "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks",
      "svn_url": "https://svn.github.com/octocat/Hello-World",
      "homepage": "https://github.com",
      "language": null,
      "forks_count": 9,
      "stargazers_count": 80,
      "watchers_count": 80,
      "size": 108,
      "default_branch": "master",
      "open_issues_count": 0,
      "is_template": true,
      "topics": [
        "octocat",
        "atom",
        "electron",
        "api"
      ],
      "has_issues": true,
      "has_projects": true,
      "has_wiki": true,
      "has_pages": false,
      "has_downloads": true,
      "archived": false,
      "disabled": false,
      "visibility": "public",
      "pushed_at": "2011-01-26T19:06:43Z",
      "created_at": "2011-01-26T19:01:12Z",
      "updated_at": "2011-01-26T19:14:43Z",
      "permissions": {
        "admin": false,
        "push": false,
        "pull": true
      },
      "allow_rebase_merge": true,
      "template_repository": null,
      "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O",
      "allow_squash_merge": true,
      "delete_branch_on_merge": true,
      "allow_merge_commit": true,
      "subscribers_count": 42,
      "network_count": 0
    }
  }
]

 

위 API는 Github의 Issue API 결과입니다. 주요 데이터에 대하여 자신의 URL과 다음 상태 변이에 대한 URL 링크를 같이 제공하여 Hateoas를 준수하고 있습니다. 따라서 Client는 서버로부터 받은 링크를 통해 상태를 변화할 뿐 Client 소스에 하드코딩되어 있을 필요가 없습니다.

 

Spring에서는 Hateoas 기능을 손쉽게 적용가능하기 위한 Spring Hateoas 프로젝트가 있습니다. 해당 프로젝트를 적용하여, 기존 프로그램 예제를 확장해보도록 하겠습니다.

 


 

2. Hateoas 적용

 

build.gradle

 

(...중략...)
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-hateoas'
    (...중략...)
}

 

위와같이 dependency에 hateoas를 추가합니다.

 

 

2-1. Controller

 

@RestController
@RequiredArgsConstructor
public class CustomerController {
    private final CustomerService service;
    private final PagedResourcesAssembler<CustomerDTO> assembler;

    @GetMapping("/customers")
    public ResponseEntity<PagedModel<EntityModel<CustomerDTO>>> getCustomer(PageableDTO pageableDTO){
        Page<CustomerDTO> customers = service.getCustomer(pageableDTO);
        PagedModel<EntityModel<CustomerDTO>> entityModels = assembler.toModel(customers);
        return ResponseEntity.ok(entityModels);
    }
}

 

Controller에서 Page 모델을 기본적으로 제공되는 PagedResourcesAssembler를 통해 PagedModel 객체로 변환하는 코드를 추가하였습니다.

 

 

2-2. 실행 결과

 

 

 

PagedResourcedAssembler 적용 후 결과를 보면, self 링크를 포함하여 다음 상태 전이를 위한 API가 기본적으로 제공되는 것을 확인할 수 있습니다.

 

하지만 위 API에서는 각각의 customerDTO 각 원소에 대한 상태 변이 URL은 제공되지 않았습니다. 이를 추가해보겠습니다.

 


 

3. PagedModelUtil

 

public class LinkResource<T> extends EntityModel<T> {
    public static <T> EntityModel<T> of(WebMvcLinkBuilder builder, T model, Function<T,?> resourceFunc){
        return EntityModel.of(model, getSelfLink(builder, model, resourceFunc));
    }

    private static<T> Link getSelfLink(WebMvcLinkBuilder builder, T data, Function<T, ?> resourceFunc){
        WebMvcLinkBuilder slash = builder.slash(resourceFunc.apply(data));
        return slash.withSelfRel();
    }
}

 

EntityModel을 생성할 때, Link를 주입하도록 LinkResource 클래스를 생성합니다.

 

public class PagedModelUtil {
    public static <T> PagedModel<EntityModel<T>> getEntityModels(PagedResourcesAssembler<T> assembler,
                                                                 Page<T> page,
                                                                 Class<?> clazz,
                                                                 Function<T, ?> selfLinkFunc ){
        WebMvcLinkBuilder webMvcLinkBuilder = linkTo(clazz);
        return assembler.toModel(page, model -> LinkResource.of(webMvcLinkBuilder, model, selfLinkFunc::apply));
    }

    public static <T> PagedModel<EntityModel<T>> getEntityModels(PagedResourcesAssembler<T> assembler,
                                                                 Page<T> page,
                                                                 WebMvcLinkBuilder builder,
                                                                 Function<T, ?> selfLinkFunc ){
        return assembler.toModel(page, model -> LinkResource.of(builder, model, selfLinkFunc::apply));
    }
}

 

LinkResource를 기반으로 전체 Page 모델을 순회하면서 Link를 주입하고, 이를 PagedModel 객체로 컨버팅하는 Util 클래스를 생성합니다.

 

@RestController
@RequiredArgsConstructor
public class CustomerController {
    private final CustomerService service;
    private final PagedResourcesAssembler<CustomerDTO> assembler;

    @GetMapping("/customers")
    public ResponseEntity<PagedModel<EntityModel<CustomerDTO>>> getCustomer(PageableDTO pageableDTO){
        Page<CustomerDTO> customers = service.getCustomer(pageableDTO);

        PagedModel<EntityModel<CustomerDTO>> entityModels = PagedModelUtil.getEntityModels(assembler, customers,
                linkTo(methodOn(this.getClass()).getCustomer(null)),
                CustomerDTO::getCustomerId);
        return ResponseEntity.ok(entityModels);
    }
}

 

Controller 클래스에서는 해당 Util 클래스 함수 호출을 통해 PagedModel로 변경하도록 코드를 변경헀습니다.

여기서 두번째 및 세번째 인자가 각 DTO별로 호출할 메소드 및 ID 값에 해당합니다.

 

위 예제에서는 getCustomer 메소드를 두번째 인자로 전달했기 때문에 @GetMapping 어노테이션으로 전달한 customers 가 URL에 추가가 될 것입니다. 또한, customers URL 뒤에올 ID 값으로 customerDTO에 속한 id 값을 넘겨줌으로써 URL이 완성될 것입니다.

 

 

따라서, 위 코드를 통해서 각 DTO 별로 추가되는 URL은 http://localhost:8080/customers/{id}가 될 것입니다.

 

 

@RestController
@RequiredArgsConstructor
public class CustomerController {
    private final CustomerService service;
    private final PagedResourcesAssembler<CustomerDTO> assembler;

    @GetMapping("/customers")
    public ResponseEntity<PagedModel<EntityModel<CustomerDTO>>> getCustomer(PageableDTO pageableDTO){
        Page<CustomerDTO> customers = service.getCustomer(pageableDTO);

        PagedModel<EntityModel<CustomerDTO>> entityModels = PagedModelUtil.getEntityModels(assembler, customers,
                linkTo(methodOn(this.getClass()).getCustomer(null)),
                v-> v.getCustomerName() + "/" + v.getCustomerId());
        return ResponseEntity.ok(entityModels);
    }
}

 

만약 URL을 변경하고 싶다면, 위와같이 두번째 인자 및 세번째 Function을 새로 정의하여 변경 가능합니다.

 

 


4. PagedResourceAssembler 커스터마이징

 

 

지금까지 Hateoas 적용과 각 DTO 별로 Self 링크를 적용하는 방법에 대해서 살펴봤습니다. 기본적으로 제공되는 PagedResourceAssembler에는 첫페이지, 이전 페이지, 현재 페이지, 다음 페이지 및 마지막 페이지 정보가 담겨있습니다.

 

 

 

 

 

이는 해당 API가 게시판에 노출되는 API라면 위와 같은 모습에 사용될 수 있는 Link 정보를 담고 있습니다.

 

 

 

만약 사용자가 원하는 게시판의 형태가 위와 같다면, 기본 제공되는 PagedResourcesAssembler는 이를 표현할 수 없습니다. 따라서 Customizing이 필요합니다.

 

4-1. CustomPagedResourceAssembler

 

public class CustomPagedResourceAssembler<T> extends PagedResourcesAssembler<T> {
    private final HateoasPageableHandlerMethodArgumentResolver pageableResolver;
    private final Optional<UriComponents> baseUri;
    private final int PAGE_SIZE;
    private final int DEFAULT_PAGE_SIZE = 10;

    public CustomPagedResourceAssembler(HateoasPageableHandlerMethodArgumentResolver pageableResolver, @Nullable  Integer pageSize) {
        super(pageableResolver,null);
        this.pageableResolver = pageableResolver;
        baseUri = Optional.empty();
        this.PAGE_SIZE = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
    }

    @Override
    public PagedModel<EntityModel<T>> toModel(Page<T> entity) {
        return toModel(entity, EntityModel::of);
    }

    @Override
    public <R extends RepresentationModel<?>> PagedModel<R> toModel(Page<T> page, RepresentationModelAssembler<T, R> assembler) {
        Assert.notNull(page, "Page must not be null!");
        Assert.notNull(assembler, "ResourceAssembler must not be null!");

        List<R> resources = new ArrayList<>(page.getNumberOfElements());

        for (T element : page) {
            resources.add(assembler.toModel(element));
        }

        PagedModel<R> resource = createPagedModel(resources, asPageMetadata(page), page);
        return addPaginationLinks(resource, page, Optional.empty());
    }

    private PagedModel.PageMetadata asPageMetadata(Page<?> page) {

        Assert.notNull(page, "Page must not be null!");
        int number = page.getNumber();
        return new PagedModel.PageMetadata(page.getSize(), number, page.getTotalElements(), page.getTotalPages());
    }

    private <R> PagedModel<R> addPaginationLinks(PagedModel<R> resources, Page<?> page, Optional<Link> link) {

        UriTemplate base = getUriTemplate(link);
        boolean isNavigable = page.hasPrevious() || page.hasNext();

        int currIndex = page.getNumber();
        int lastIndex = page.getTotalPages() == 0 ? 0 : page.getTotalPages() - 1;
        int baseIndex = (currIndex / this.PAGE_SIZE) * this.PAGE_SIZE;
        int prevIndex = baseIndex - this.PAGE_SIZE;
        int nextIndex = baseIndex + this.PAGE_SIZE;
        int size = page.getSize();
        Sort sort = page.getSort();

        if(lastIndex < currIndex)
            throw new IndexOutOfBoundsException("Page total size must not be less than page size");

        if (isNavigable) {
            resources.add(createLink(base, PageRequest.of(0, size, sort), IanaLinkRelations.FIRST));
        }

        Link selfLink = link.map(it -> it.withSelfRel()).orElseGet(() -> createLink(base, page.getPageable(), IanaLinkRelations.SELF));

        resources.add(selfLink);

        if (prevIndex >= 0) {
            resources.add(createLink(base,
                    PageRequest.of(prevIndex, size, sort),
                    IanaLinkRelations.PREV));
        }

        for(int i = 0; i < this.PAGE_SIZE; i++){
            if(baseIndex + i <= lastIndex){
                resources.add(createLink(base, PageRequest.of(baseIndex + i, size, sort), LinkRelation.of("pagination")));
            }
        }

        if(nextIndex < lastIndex){
            resources.add(createLink(base, PageRequest.of(nextIndex, size, sort), IanaLinkRelations.NEXT));
        }

        if (isNavigable) {
            resources.add(createLink(base, PageRequest.of(lastIndex, size, sort), IanaLinkRelations.LAST));
        }

        return resources;
    }

    private UriTemplate getUriTemplate(Optional<Link> baseLink) {
        return UriTemplate.of(baseLink.map(Link::getHref).orElseGet(this::baseUriOrCurrentRequest));
    }

    private String baseUriOrCurrentRequest() {
        return baseUri.map(Object::toString).orElseGet(CustomPagedResourceAssembler::currentRequest);
    }

    private static String currentRequest() {
        return ServletUriComponentsBuilder.fromCurrentRequest().build().toString();
    }

    private Link createLink(UriTemplate base, Pageable pageable, LinkRelation relation) {
        UriComponentsBuilder builder = fromUri(base.expand());
        pageableResolver.enhance(builder, getMethodParameter(), pageable);
        return Link.of(UriTemplate.of(builder.build().toString()), relation);
    }
}

 

위 코드는 기존 PagedResourcesAssembler를 상속받아 구현하였습니다. 가장 핵심이 되는 부분은 addPaginationLinks 메소드이며, 해당 메소드를 통해 페이지의 인덱스 정보를 pagination 링크아래에 배치하도록 표현했습니다.

 

@Configuration
public class AppConfig implements WebMvcConfigurer {

    ...(중략)...

    @Bean
    public CustomPagedResourceAssembler<?> customPagedResourceAssembler(){
        return new CustomPagedResourceAssembler<>(new HateoasPageableHandlerMethodArgumentResolver(),10);
    }
}

 

이후 해당 클래스를 Bean으로 등록시켜 줍니다. 생성자의 두번째 파라미터는 Page 사이즈를 결정하며, 해당 값의 변경을 통해 노출되는 URL 크기가 달라집니다.

 

4-2 Controller 

 

@RestController
@RequiredArgsConstructor
public class CustomerController {
    private final CustomerService service;
    private final CustomPagedResourceAssembler<CustomerDTO> assembler;

    @GetMapping("/customers")
    public ResponseEntity<PagedModel<EntityModel<CustomerDTO>>> getCustomer(PageableDTO pageableDTO){
        Page<CustomerDTO> customers = service.getCustomer(pageableDTO);

        PagedModel<EntityModel<CustomerDTO>> entityModels = PagedModelUtil.getEntityModels(assembler, customers,
                linkTo(methodOn(this.getClass()).getCustomer(null)),
                CustomerDTO::getCustomerId);
        return ResponseEntity.ok(entityModels);
    }
}

 

Bean으로 등록한 CustomPagedResourceAssembler로 주입받도록 코드를 수정했습니다.

 

4-3 결과

 

 

애플리케이션 구동 후 해당 URL을 호출하면, 정상적으로 페이지 URL이 표시되는 것을 확인할 수 있습니다.

 


마치며

 

Page 객체에 Hateoas를 적용하는 방법에 대해서 살펴봤습니다. Rest API 설계를 위해서는 Hateoas 적용뿐만 아니라 Self-descriptive-messages를 적용하기 위해 IANA 등록 혹은 Profile 링크를 추가해야합니다.

 

Profile 링크를 적용하기 위해서 RestDocs 혹은 Swagger 등을 활용할 수 있으니 참고 바랍니다.

 

 

'JAVA > JPA' 카테고리의 다른 글

4. JPA Cache 적용하기  (1) 2020.08.05
2. JPA Sort 조건 개선  (2) 2020.07.30
1. JPA 페이징 쿼리 개선  (0) 2020.07.29

서론

 

이전 포스팅에서 다룬 예제를 확장하여 진행하겠습니다. 이번 포스팅에서는 JPA Sort에 대해서 다루도록 하겠습니다.

 


 

문제상황

 

 

 

 

http://localhost:8080/customers?page=3&size=10&sort=customerId,asc

 

예제 프로그램 수행후 다음과 같은 URL을 호출하면 page, size, sort 파라미터가 Pageable 객체로 컨버팅 되어 정상적으로 프로그램이 페이징과 정렬이 수행되는 것을 확인할 수 있습니다.

 

http://localhost:8080/customers?page=3&size=10&sort=customerId,asc&sort=customerName,desc

 

만약 여러 Column의 정렬을 수행하고 싶다면, 위와 같이 여러개의 sort 파라미터를 추가하면 정상 수행되는 것을 확인할 수 있습니다.

 

하지만 위 sort 조건은 @Entity 클래스에 정의한 필드명과 정확히 동일해야합니다. 만약 예제와 같이 동일 컬럼에 대하여 리턴 값은 SnakeCase인 customer_id 일경우 Client 로직에서는 전달받은 값 이외에 @Entitiy 클래스 필드명을 관리해야합니다. 만약 Entitiy에 정의되어있지 않은 필드명을 전달하게된다면, 에러가 발생합니다.

 

Client 로직에서 도메인 객체 필드명에 의존하는 것은 바람직해 보이지 않습니다. 따라서 Enum과 DTO를 활용해서 Sort 조건이 도메인 객체와 직접연관되지 않도록 설정해봅시다.

 


 

Pageable 구현

 

기존에는 파라미터 객체를 Pageable로 Controller 파라미터로 주입받았습니다. 이때 도메인 객체 필드명을 Sort 인자에 정확히 명시해야하는 문제가 있었습니다. 따라서 방법을 바꾸어 별도 PageableDTO에 파라미터 인자를 주입받고 이를 처리하겠습니다.

 


1. Pair 클래스

 

@Getter
@Builder(access = AccessLevel.PRIVATE)
@ToString
public class Pair<T, U> {
    private T first;
    private U second;

    public static <T, U> Pair of(T first, U second) {
        return Pair.builder()
                .first(first)
                .second(second)
                .build();
    }
}

 

먼저 컬럼명과 정렬 상태를 담을 Pair 클래스를 생성합니다.

 


 

2. Sort 상태를 표현하는 Enum 구현

 

public enum SortOption {
    ASC,
    DESC
}

 


 

3. PageableDTO

 

@Getter
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class PageableDTO {
    private Integer page;
    private Integer size;
    private Integer totalElements;
    private List<Pair<String, SortOption>> sorts;
}

 

Page 관련 정보와 Sort 정보를 담을 DTO를 선언합니다.


 

4.  HandlerMethodArgumentResolver 클래스 구현

 

public class PageableArgumentResolver implements HandlerMethodArgumentResolver {
    private final String PAGE = "page";
    private final String SIZE = "size";
    private final String TOTAL_ELEMENTS = "total_elements";

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().equals(PageableDTO.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        List<Pair<String, SortOption>> sorts = new ArrayList<>();
        final var sortArr = webRequest.getParameterMap().get("sort");

        if(sortArr != null){
            for(var v : sortArr){
                String[] keywords = v.split(",");
                sorts.add(Pair.of(keywords[0], (keywords.length < 2 || keywords[1].equals("asc")) ? SortOption.ASC : SortOption.DESC));
            }
        }

        return PageableDTO.builder()
                .page(getValue(webRequest.getParameter(PAGE)))
                .size(getValue(webRequest.getParameter(SIZE)))
                .totalElements(getValue(webRequest.getParameter(TOTAL_ELEMENTS)))
                .sorts(sorts)
                .build();
    }

    private Integer getValue(String param) {
        return param != null ? Integer.parseInt(param) : null;
    }
}

 

@Configuration
public class AppConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new PageableArgumentResolver());
    }
}

 

위 클래스는 Custom HandlerMethodArgumentResolver입니다. 별도로 이를 구현한 이유는 PageableDTO에 정렬 정보를 담기 위해서는 배열형태로 넘어오는 Sort 정보를 @ModelAttribute를 통해 주입받을 수 없기 때문입니다. 

 

따라서, 별도 HandlerMethodArgumentResolver를 구현하여 파라미터 값에서 관심있는 값들을 가져와 가공하여 DTO를 생성받아 주입할 수 있도록 구현하여 기존 resolver 목록에 추가했습니다.

 

만약 HandlerMethodArgumentResolver를 사용하고 싶지 않다면, MultiValueMap을 통해 주입받은 후 별도 Factory 메소드를 사용해 DTO를 코드상에서 변경시키는 것도 한 방법입니다.

 

 


 

5. 도메인 컬럼 매핑 Layer 구현

 

public class MetaModelUtil {
    public static String getColumn(Path<?> path){
        return path.getMetadata().getName();
    }
}

 

public enum CustomerMetaType {
    CUSTOMER_ID(getColumn(customer.customerId)),
    CUSTOMER_NAME(getColumn(customer.customerName));

    private String name;
    CustomerMetaType(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

 

Client에서 존재하는 SnakeCase의 컬럼명과 실제 @Entity 클래스 컬럼명을 매핑하기 위하여 Enum을 사용했습니다. @Entity로 부터 생성되는 컬럼명을 얻어오기 위해서는 QueryDsl의 Q클래스를 활용하거나 JPA Meta모델을 활용하는 방법이 있습니다.

 

대개 JPA를 쓰면서 QueryDsl을 같이 쓰기 때문에 별도 JPA Meta모델을 추출하기 보다는 QueryDsl의 MetaModel을 활용하여 매핑하였습니다.

 


 

6. DomainSpec 클래스 구현

 

6-1 Sort 전략 클래스 생성

 

public interface SortStrategy<T extends Enum<T>> {
    Sort.Order getSortOrder(T domainKey, SortOption order);
}
public class CustomerSortStrategy implements SortStrategy<CustomerMetaType> {
    @Override
    public Sort.Order getSortOrder(CustomerMetaType domainKey, SortOption order) {
        Sort.Order sortOrder = null;
        switch (domainKey){
            case CUSTOMER_ID:
            case CUSTOMER_NAME:
                final var column = domainKey.getName();
                sortOrder = (order == SortOption.ASC) ? Sort.Order.asc(column) : Sort.Order.desc(column);
        }
        return sortOrder;
    }
}

 

엔티티별로 정렬을 구현할 전략을 설정할 수 있는 객체를 생성합니다. 위 switch 구문에서 CUSTOMER_ID를 제거하면 Client로부터 넘어오는 customer_id는 해당되지 않으므로 Null이 반환되고 에러가 발생하게 될 것입니다. 따라서 Strategy에 지정한 구문여부에 따라서 정렬 대상을 한정할 수 있습니다.

 

 

 

6-2. DomainSpec 클래스 생성

 


public class DomainSpec<T extends Enum<T>, U> {
    private final Class<T> clazz;
    @Getter @Setter
    private int defaultPage = 0;
    @Getter @Setter
    private int defaultSize = 20;
    private SortStrategy<T> sortStrategy;

    public DomainSpec(Class<T> clazz, SortStrategy<T> sortStrategy) {
        this.clazz = clazz;
        this.sortStrategy = sortStrategy;
    }

    public DomainSpec(Class<T> clazz, SortStrategy<T> sortStrategy, int defaultPage, int defaultSize) {
        this.clazz = clazz;
        this.defaultPage = defaultPage;
        this.defaultSize = defaultSize;
        this.sortStrategy = sortStrategy;
    }

    public void changeSortStrategy(SortStrategy<T> sortStrategy) {
        this.sortStrategy = sortStrategy;
    }

    public Pageable getPageable(PageableDTO dto) {
        Integer page = dto.getPage();
        Integer size = dto.getSize();
        Pageable pageable;

        if (dto.getSorts().size() < 1) {
            pageable = PageRequest.of(page == null ? defaultPage : page, size == null ? defaultSize : size);
        } else {
            List<Sort.Order> sorts = getSortOrders(dto.getSorts());
            pageable = PageRequest.of(page == null ? defaultPage : page, size == null ? defaultSize : size, Sort.by(sorts));
        }

        return pageable;
    }

    private List<Sort.Order> getSortOrders(List<Pair<String, SortOption>> sorts) {
        Assert.notNull(this.sortStrategy,"There is no sort strategy");

        List<Sort.Order> orders = new ArrayList<>();
        for (var o : sorts) {
            T column;
            try {
                column = Enum.valueOf(this.clazz, o.getFirst().toUpperCase());
            } catch (IllegalArgumentException e) {
                throw new IllegalArgumentException("Sort option error");
            }

            final Sort.Order order = sortStrategy.getSortOrder(column, o.getSecond());
            Assert.notNull(order, "sort option error");

            orders.add(order);
        }
        return orders;
    }
}

 

정렬을 포함한 Pageable 객체를 얻어오기 위한 클래스입니다. SortStrategy를 통해 전달받은 구현체에 정렬 방법을 위임합니다.

 


 

7. Service 구현

 

@Service
public class CustomerService {
    private final CustomerRepository repository;
    private DomainSpec<CustomerMetaType, Customer> spec;

    public CustomerService(CustomerRepository repository) {
        this.repository = repository;
        this.spec = new DomainSpec<>(CustomerMetaType.class, new CustomerSortStrategy());
    }

    public Page<CustomerDTO> getCustomer(PageableDTO pageableDTO) {
        final var pageable = spec.getPageable(pageableDTO);

        final var customers = repository.findAll(pageable, pageableDTO.getTotalElements());
        return customers.map(CustomerDTO::of);
    }
}

 

정렬 조건을 포함한 Pageable 객체를 CustomerSpec에서 가져올 수 있도록 구성하였습니다.

 


 

8. Controller 구현

 

@RestController
@RequiredArgsConstructor
public class CustomerController {
    private final CustomerService service;

    @GetMapping("/customers")
    public ResponseEntity<Page<CustomerDTO>> getCustomer(PageableDTO pageableDTO){
        Page<CustomerDTO> customers = service.getCustomer(pageableDTO);
        return ResponseEntity.ok(customers);
    }
}

 

Pageable 객체를 바로 전달받는 것이 아니라, HandlerMethodArgumentResolver를 통해 PageableDTO를 주입받아 이를 서비스 객체로 넘기도록 코드를 수정하였습니다.

 

 


 

9. 실행 결과

 

 

프로그램을 실행시키면, 기존과는 다르게 customer_name과 같이 도메인 엔티티 컬럼명에 종속적이지 않고 컬럼명을 지정할 수 있습니다. 또한, 컬럼 정렬 대상을 제한하여 보다 무분별한 정렬이 발생되지 않도록 강제할 수 있습니다. 

 

 


 

마치며

 

이번 포스팅에서는 JPA에서 정렬 조건을 제한하는 방법과 엔티티 컬럼명에 의존하지 않도록 매핑 Layer를 두는 방법에 대해서 알아봤습니다.

 

위 구조를 조금 확장하면, Specification이나 BooleanBuilder 등을 활용하여 SQL Where 조건절에 추가하는 도메인 코드 로직을 구성할 수 있습니다.

 

hellozin님 블로그님 포스팅에 이와 관련된 내용이 있으니 참고하면 좋을 것 같습니다.

 

JPA Specification으로 쿼리 조건 처리하기

해당 코드는 Github에서 확인할 수 있습니다. Spring Data에서 Specification은 DB 쿼리의 조건을 Spec으로 작성해 Repository method에 적용하거나 몇가지 Spec을 조합해서 사용할 수 있게 도와줍니다. 간단한 예

velog.io

 

'JAVA > JPA' 카테고리의 다른 글

4. JPA Cache 적용하기  (1) 2020.08.05
3. JPA Hateoas 적용하기  (8) 2020.08.01
1. JPA 페이징 쿼리 개선  (0) 2020.07.29

서론

 

Pageable 객체를 사용하면 JPA에서 손쉽게 페이징 처리를 할 수 있습니다. 하지만 기본적으로 제공되는 findAll 메소드를 통해 페이징 처리를 요청하면, 매 요청시마다 전체 갯수를 구하기 위한 Count SQL이 수행되는 것을 볼 수 있습니다.

 

조회 조건이 달라지지 않는 이상 매번 동일한 결과 건수가 보장될텐데, 매번 Count SQL이 수행되는 것은 꽤나 비효율 적입니다. 더불어 Count SQL을 수행하면, 페이징되는 데이터 뿐만 아니라 전체 데이터를 DBMS 메모리에 로딩해서 집계 해야하기 때문에 반복 수행시 성능 이슈가 발생할 수 있습니다. 

 
물론 한번 메모리에 데이터가 로딩되면 그 이후에는 Count SQL 속도가 처음보다는 빠릅니다. 하지만 RDBMS(Oracle 기준)는 DMA(Direct Memorry Access)로 데이터에 즉시 접근할 수 없고 Latch를 획득해야하는 과정이 수반되기 때문에 여전히 Overhead가 존재합니다.

이번 포스팅에서는 CustomRepository 구현을 통해 전체 개수를 알고 있는 상황이라면, Count SQL을 요청하지 않도록 findAll 메소드를 구현하겠습니다.


1. Entity

 

@Entity
@Table
@Getter
public class Customer {
    @Id
    @GeneratedValue
    @Column(name = "id")
    private Long customerId;
    @Column(name = "name")
    private String customerName;
}

 

예제를 위해 엔티티는 위와같이 간단히 설정하였습니다. 조회를 위해 사전에 해당 테이블에 100건의 데이터 입력 하였다고 가정하고 진행하겠습니다.

 

 

2. 반환 DTO

 

@Builder(access = AccessLevel.PRIVATE)
@Getter
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
public class CustomerDTO {
    private Long customerId;
    private String customerName;

    public static CustomerDTO of(Customer customer){
        return CustomerDTO.builder()
                .customerId(customer.getCustomerId())
                .customerName(customer.getCustomerName())
                .build();
    }
}

 

반환되는 DTO는 위와같이 구현했습니다. 

 

 

3. Repository 구현

 

@NoRepositoryBean
public interface BaseRepository<T, ID extends Serializable> extends JpaRepository<T,ID> {
    Page<T> findAll(Pageable pageable, Integer totalElements);
    Page<T> findAll(Specification<T> spec, Pageable pageable, Integer totalElements);
    Page<T> findAll(Specification<T> spec, Pageable pageable, Integer totalElements, Sort sort);
}

 

CustomRepository 구현을 위해 JpaRepository를 상속받는 새로운 Repository를 생성합니다. 

여기서 totalElements는 조건에 해당되는 전체 레코드 갯수로 Client가 전체 레코드 갯수를 파라미터로 전달하게 되면, 해당 값을 Page 객체에 삽입 후 Count SQL을 수행하지 않습니다. 반면 값이 전달되지 않으면 Count SQL을 수행합니다.

 

따라서, Count SQL 수행 여부는 파라미터로 전달되는 totalElements의 유무에 따라 결정됩니다.

 

public class BaseRepositoryImpl<T,ID extends Serializable> extends SimpleJpaRepository<T, ID> 
        implements BaseRepository<T,ID> {

    public BaseRepositoryImpl(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
        super(entityInformation, entityManager);
    }

    @Override
    public Page<T> findAll(Pageable pageable, Integer totalElements) {
        return findAll(null, pageable, totalElements, Sort.unsorted());
    }

    @Override
    public Page<T> findAll(Specification<T> spec, Pageable pageable, Integer totalElements) {
       return findAll(spec, pageable, totalElements, Sort.unsorted());
    }

    @Override
    public Page<T> findAll(Specification<T> spec, Pageable pageable, Integer totalElements, Sort sort) {
        TypedQuery<T> query = getQuery(spec, sort);

        int pageNumber = pageable.getPageNumber();
        int pageSize = pageable.getPageSize();

        if(totalElements == null){
            return findAll(spec, pageable);
        }

        if(pageNumber < 0){
            throw new IllegalArgumentException("page number must not be less than zero");
        }

        if(pageSize < 1){
            throw new IllegalArgumentException("pageSize must not be less than one");
        }

        if(totalElements < pageNumber * pageSize){
            throw new IllegalArgumentException("totalElements must not be less than pageNumber * pageSize");
        }

        int offset = (int) pageable.getOffset();
        query.setFirstResult(offset);
        query.setMaxResults(pageable.getPageSize());

        return  new PageImpl<>(query.getResultList(), PageRequest.of(pageNumber, pageSize), totalElements);
    }
}

 

위 코드는 실제 Repository 구현 코드입니다. 전달받은 파라미터에 대하여 유효성 검증 후 로직에 따라 처리합니다.

만약 totalElements 값이 null이 아니라면 마지막 줄과 같이 Page 객체에 전달받은 값을 그대로 넣습니다.

 

 

public interface CustomerRepository extends BaseRepository<Customer, Long> {}

 

실제 사용하는 Repository에는 JpaRepository가 아닌 CustomRepository를 상속받도록 지정합니다.

 

@SpringBootApplication
@EnableJpaRepositories(repositoryBaseClass = BaseRepositoryImpl.class)
public class PagingDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(PagingDemoApplication.class, args);
    }
}

 

마지막으로 등록한 Repository를 사용하기 위해서는 Configuration 클래스에 @EnableJpaRepositories 어노테이션으로 CustomRepository를 등록합니다.

 

 

Service

 

@Service
@RequiredArgsConstructor
public class CustomerService {
    private final CustomerRepository repository;

    public Page<CustomerDTO> getCustomer(Pageable pageable, Integer totalElements) {
        Page<Customer> customers = repository.findAll(pageable, totalElements);
        return customers.map(CustomerDTO::of);
    }
}

 

CustomRepository의 메소드를 호출하며, 리턴된 페이지객체에 대해서 DTO로 변환하는 로직을 추가하였습니다.

 

 

Controller

 

@RestController
@RequiredArgsConstructor
public class CustomerController {
    private final CustomerService service;

    @GetMapping("/customers")
    public ResponseEntity<Page<CustomerDTO>> getCustomer(Pageable pageable,
                                                         @RequestParam(required = false, name = "total_elements")Integer totalElements){
        Page<CustomerDTO> customers = service.getCustomer(pageable, totalElements);
        return ResponseEntity.ok(customers);
    }
}

 

파라미터로 Pageable 객체이외에 totalElements를 전달받아 Service로 전달하는 역할을 수행합니다.

 


결과

 

1. totalElements 인자를 입력하지 않았을 경우

 

 

 

 

위와 같이 Count SQL이 수행되는 것을 확인할 수 있습니다.

 

 

2. totalElements 입력 결과

 

 

 

 

페이징 처리 SQL 수행 후 리턴받은 Page 객체에 전달받은 totalElements 값이 추가되었음을 확인할 수 있습니다.


마치며

 

이번 포스팅에서 JPA 페이징 수행시 Count SQL을 제거하는 방법에 대해서 살펴보았습니다. 기존에는 매번 Count SQL을 수행하여 Index가 벗어났는지 등을 Server에서 확인하였다면, 지금은 totalElements에 대한 책임이 Client 측에 일부 있습니다. 따라서 조회 조건이 달라진다면, totalElements 값을 전달하지 않는 처리가 Client에서 구현되어야 합니다.

 

약간의 복잡함은 있지만 비효율적인 Count SQL을 반복수행하는 것을 줄임으로 성능 향상을 꾀할 수 있습니다.

 

다음 포스팅에서는 Sort와 조회조건처리 관련하여 다루도록 하겠습니다.

'JAVA > JPA' 카테고리의 다른 글

4. JPA Cache 적용하기  (1) 2020.08.05
3. JPA Hateoas 적용하기  (8) 2020.08.01
2. JPA Sort 조건 개선  (2) 2020.07.30

지금까지 Generic 기초 및 동작 원리에 대해서 살펴봤습니다.

 

Generic을 활용한 Type erasure 동작 방법을 정리하면 다음과 같습니다.

 

  1. 경계가 표시된 Type은 경계로 컴파일시에 치환된다
  2. Unbounded Wildcard(?) 타입은 Object 타입으로 치환된다
  3. 타입 안정성 보장을 위해서 캐스팅 연산을 수행한다.
  4. Generic Type에 대한 다형성 지원을 위해서 Bridge 메소드를 추가한다.

 

개인적으로 Generic이 도입은 Java를 사용함에 있어 많은 편의성을 가져다 줬지만, 이에 못지않은 불편함 또한 주었다고 생각합니다. 즉 Type erasure로 인하여 하위 호환성을 유지할 순 있었지만, 한참이 지난 지금도 런타임시 타입을 알지 못하는 불편함으로 인해 개발자가 코드 작성시에 주의를 기울여야합니다.

 

이번 포스팅에는 Type erasure에 의해서 런타임시 표현되는 Unknown Type 불일치로 인해 주의해야할 점을 다루도록 하겠습니다.


Reifiable Type

 

Java에서 Runtime시에 완전하게 오브젝트 정보를 표현할 수 있는 타입을 가르켜 Reifiable 하다고 합니다.

즉 Compile 단계에서 Type erasure에 의해서 지워지지 않는 타입 정보를 말합니다.

Reifiable 가능한 타입 정보는 다음과 같습니다.

 

  1. 원시 타입(int, double, float, byte 등등)
  2. Number, Integer와 같은 일반 클래스와 인터페이스 타입
  3. Unbounded Wildcard가 포함된 Parameterized Type(List<?>, ArrayList<?> 등)
  4. Raw Type(List, ArrayList, Map 등)

 

Unbounded Wildcard는 애초에 타입 정보를 전혀 명시하지 않았기 때문에, 컴파일시에 Type erasure 한다고 해도 잃을 정보가 없습니다. 따라서 Reifiable 하다고 볼 수 있으며, 컴파일시점에 Object로 치환됩니다. 

 

그외 나머지는 Generic을 사용하지 않았기 때문에 타입 정보가 그대로 남습니다.


반면 아래 타입의 경우에는 Reifiable 하지 않습니다.

 

  1. Generic Type(T)
  2. Parameterized Type(List<Number, ArrayList<String> 등)
  3. 경계가 포함된 Parameterized Type(List<? extends Number>, List<? super String> 등)

 

Reifiable 하지 않은 경우에 대해서 차근차근 살펴보겠습니다.

우선 Generic Type은 Type erasure에 의하여 삭제되는 것은 이전의 포스팅들을 통해서 이해되셨을 것으로 생각합니다.

 

Parameterized Type의 경우에는 Type erasure에 의하여 컴파일시에 Raw Type으로 변경됩니다. 

가령 List<String>, List<Integer>, List<List<String>>의 타입정보는 컴파일 시에 타입 안정성 검증 용도로 사용될 뿐 컴파일이 완료되면 Raw Type인 List로 치환됩니다. 따라서 Reifiable 하지 않습니다. 

 

경계가 포함된 Parameterized Type 또한, 컴파일 시 타입 안정성 검증 용도로만 사용되므로 동일합니다.

 

그렇다면 위와같은 특성으로 인하여 주의해야할 점이 무엇이 있을까요?


Instance Test

 

public class Main {
    public static void main(String[] args) {
        Integer  a = 3;
        System.out.println(isIntegerType(a));
    }
    public static boolean isIntegerType(Object b){
        return b instanceof Integer;
    }
}

 

전달받은 타입이 Integer인지 확인하는 메소드를 구현한다고 가정합시다. 가장 심플한 방법은 instanceof 문법을 사용해서 Integer 임을 확인할 수 있습니다.

 

그럼 해당 메소드를 Generic 메소드로 변경해도 문제 없을까요?

 

public class Main {
    public static void main(String[] args) {
        Integer  a = 3;
        System.out.println(isIntegerType(a));
    }
    public static <T> boolean isIntegerType(T b){
        return b instanceof Integer;
    }
}

 

위 코드를 수행하면 문제없이 동작합니다. 그 이유는 Integer 타입은 런타임시에도 Integer 타입이기 때문입니다.

 

 

public class Main {
    public static void main(String[] args) {
        List<Integer> ints = Arrays.asList(1,2,3);
        System.out.println(isIntegerList(ints));
    }
    public static <T> boolean isIntegerList(T b){
        return b instanceof List<Integer>; //Compile 에러 발생
    }
}

 

이번에는 List<Integer> 타입임을 확인하는 메소드를 생성한다고 가정해봅시다. 위와같이 만들었을 때 정상적으로 동작할까요?

 

우리는 런타임시에 List의 Type Argument가 Integer임을 확인하고 싶으나 컴파일 이후에는 Raw Type으로 변경됩니다. 따라서 List<Integer> 임을 확인할 수 없기 때문에 애초에 컴파일 에러가 발생합니다.

 

public class Main {
    public static void main(String[] args) {
        List<Integer> ints = Arrays.asList(1,2,3);
        System.out.println(isList(ints));
    }
    public static <T> boolean isList(T b){
        return b instanceof List;
    }
}

 

물론 Raw Type 자체는 Reifiable 하기 때문에, 위와 같이 Raw type으로 명시하면 컴파일 에러도 발생하지 않고 정상적으로 동작합니다. 하지만 여전히 해당 List의 Element 타입이 Integer임은 알 수 없습니다.

 

public class Main {
    public static void main(String[] args) {
        List<Integer> ints = Arrays.asList(1,2,3);
        System.out.println(isList(ints));
    }
    public static <T> boolean isList(T b){
        return b instanceof List<?>;
    }
}

 

가급적이면 Raw type을 사용하는 것은 지양하는 것이 좋으므로 Reifiable한 Wildcard를 사용하여 동일한 구문을 표현할 수 있습니다.

 

일반적으로 instanceof 문법을 사용한 코드는 code smell로 표현하여 잘 사용하지는 않지만, 만약 사용한다 할지라도 반드시 Reifiable한 타입에만 사용할 수 있다는점을 유의합시다.


캐스팅

 

public class Main {
    public static void main(String[] args) {
        List objs = Arrays.asList(1, 2, 3);
        List<Integer> ints = (List<Integer>)objs;
    }
}

 

위와 같이 Raw Type의 List에 Integer 타입 원소만 있다고 가정할 때, 이를 List<Integer> 타입으로 캐스팅할 수 있을까요?

 

실제로 어플리케이션으로 실행하면, 정상적으로 수행되는 것으로 보입니다.

 

 

하지만 자세히 보면, cast 관련 Warning이 발생된 것을 확인할 수 있습니다.

그 이유는 마찬가지로 컴파일이 완료된 이후에는 타입정보가 없어지는데, List<Integer> 타입으로의 안전한 캐스팅을 보장할 수 없기 때문입니다.

 

그렇다면 Instance Test에서는 에러를 발생시켰는데, 캐스팅에서는 Warning을 발생시킨 것일까요?

다음 예제코드를 보며, 그 이유를 알아보도록 하겠습니다.

 

public static List<Integer> getAsIntegerList(List<?> objs){
    objs.forEach(o->{
        if(!(o instanceof Integer))
            throw new ClassCastException();
    });
    return (List<Integer>)objs;
}

 

List 오브젝트를 전달받아, 해당 List안에 있는 원소가 모두 Integer 일경우에는 List<Integer>로 캐스팅하여 반환하는 메소드를 구현한다고 가정해봅시다.

 

위 코드에는 이미 List 원소를 탐색하면서 Integer가 아닌 원소가 하나라도 존재한다면 ClassCastException이 발생합니다.

 

즉 List<Integer>로 캐스팅하는 코드에 도달한다면 이미 objs 리스트에는 모든 원소가 Integer 임이 반드시 보장됩니다.

따라서 java에서는 개발자가 의도적으로 위 코드와 같은 캐스팅을 수행할 수 있을 수도 있기때문에 에러가 아닌 Warning을 발생시킵니다.

 

따라서, 만약 위와같이 의도적으로 Non-Reifiable 타입에 대해서 캐스팅을 수행했다면 @SuppressWarnings("unchecked") 어노테이션을 붙여서 컴파일 시점에 Warning이 발생하지 않도록 처리해야합니다.


Exception

 

public class MyException<T> extends Exception{}


try {
   ....
}catch (MyException<Integer> e){
   ....   
}

 

Throwable을 상속받는다면 Generic 클래스를 생성할 수 없습니다. 또한 catch 구문에서 Parameterized Type을 사용할 수 없습니다. 이는 이전의 설명과 마찬가지로 런타임 시점에 타입확인이 불가하기 때문입니다.

'JAVA > Generic' 카테고리의 다른 글

6. Generic 메소드  (0) 2020.03.10
5. Wildcard  (0) 2020.03.08
4. Wildcard with super(하위 경계)  (0) 2020.03.06
3. Wildcard with extends(상위 경계)  (2) 2020.03.06
2. Subtyping, Substitution 원리  (0) 2020.03.05

Integer 값이 들어있는 List 원소중 가장 큰 값을 리턴하는 함수를 만든다고 가정해봅시다.

 

public static int max(Collection<Integer> cols){
    return cols.stream().max((o1, o2) -> o1.compareTo(o2)).get();
}

 

대략 위와같이 구현할 수 있습니다. 그럼 Integer 타입이아닌 비교가 가능한 모든 클래스 타입이 사용될 수 있도록 하려면 어떻게 해야할까요?

 

그동안 학습한 내용을 적용하면서 이번 포스팅 주제를 다루어보겠습니다.

 

Step 1. Generic 사용

public static <T> T max(Collection<T> cols){
    return cols.stream().max((o1, o2) -> o1.compareTo(o2)).get(); //Compile 에러 발생
}

 

Get and Put 원리 고민없이 작성한다면 위와같이 작성할 수 있습니다. 여기서 첫번째 등장하는 <T>는 T 타입의 경계를 지정합니다. 이는 첫번째 포스팅에서 클래스 멤버 변수 타입의 경계 설정과 동일한 개념입니다.

 

하지만 이를 실행하면 두 원소의 대소 비교부분인 compareTo 메소드에서 컴파일 에러가 발생하게됩니다.

과연 무엇이 문제일까요?

 

첫번째 등장하는 <T> 구문은 T 타입의 경계를 지정한다고 했습니다.

위 코드는 Generic 타입이 존재한다는 것만 지정할 뿐, 해당 타입에 대한 경계를 지정하지 않았습니다.

이는 어떠한 타입이 지정되어도 상관없다는 의미입니다. 문제는 여기서 발생합니다.

 

compareTo 메소드를 사용하기 위해서는 해당 클래스에 Comparable 인터페이스가 구현되어있어야 합니다.

하지만 T 타입에는 어떠한 타입도 허용가능하므로 문제가 발생할 수 있습니다. 따라서 허용되지 않습니다.

 

이와 같은 문제를 해결하기 위해서는 Comparable 인터페이스가 구현된 타입만 지정되도록 경계를 정의해야합니다. 

 

Step2. 타입 경계 적용

 public static <T extends Comparable<T>> T max(Collection<T> cols){
    return cols.stream().max((o1, o2) -> o1.compareTo(o2)).get();
 }

 

메소드 레벨에서 타입 경계를 Comparable로 지정하면, Comparable Subtype 만 지정될 수 있습니다. 따라서 메소드 내부에서 compareTo 사용이 가능합니다.

 

Step3. Get and Put 원리 적용

public static <T extends Comparable<? super T>> T max(Collection<? extends T> cols){
    return cols.stream().max((o1, o2) -> o1.compareTo(o2)).get();
}

 

Get and Put 원리를 적용하면 위와같이 변경 가능합니다. 그 이유는 Collection 내부에서 바깥으로 T의 Subtype 값을 꺼내며, Comparable 인터페이스 compareTo 메소드안으로 값을 넣기 때문입니다.

 

Step4. Method Reference 적용

public static <T extends Comparable<? super T>> T max(Collection<? extends T> cols){
    return cols.stream().max(T::compareTo).get();
}

 

추가로 Generic 타입에 메소드 레퍼런스를 적용하면 위와같이 작성할 수 있습니다.


Bridge 메소드

 

public interface Comparable{
	public int compareTo(Object o);
}    

 

이번에는 Bridge 메소드에 대해서 알아보겠습니다. Java 1.4 버전 이하에서는 Generic 지원이 없었기 때문에 Comparable 인터페이스 구조는 위와 같습니다. 이때 파라미터는 모든 타입을 허용해야하므로 Object 타입인 것을 확인할 수 있습니다.

 

class Integer implements Comparable{
    private final int value;
    public Integer(int value) { this.value = value; }
    public int compareTo(Integer o){
        return (this.value < o.value) ? -1 : (value == o.value ? 0 : 1 );
    }
    @Override
    public int compareTo(Object o) {
        return compareTo((Integer)o);
    }
}

 

이를 구현해야하는 Integer, Double 등의 클래스에서는 올바른 비교 처리를 위해 Object를 자기 자신의 클래스 타입으로 치환하여 처리하도록 부가적인 메소드 구현이 필요했습니다.

 

public interface Comparable<T>{
	public int compareTo(T o);
}    
class Integer implements Comparable<Integer>{
    private final int value;
    public Integer(int value) { this.value = value; }
    @Override
    public int compareTo(Integer o){
        return (this.value < o.value) ? -1 : (value == o.value ? 0 : 1 );
    }
}

 

Generic 등장 이후 compareTo 메소드에 Object가 아닌 명확한 타입 지정이 가능해졌습니다.

따라서 이를 구현하는 클래스에서도 Object 타입이 아닌 자신이 지정한 타입으로의 메소드 구현이 가능해졌습니다.

그 결과 Bridge 메소드 없이 1개의 메소드만 구현하면 됩니다.

 

하지만 여기서 한가지 의문이 발생합니다.

 

'Java는 하위 호환성을 중요시 여기는 언어인데, 위와 예시와 같이 Generic 도입에 따라서 메소드 수가 줄어들게 되면, 이는 Integer 클래스 구조가 변경되므로 하위 호환성을 유지할 수 있을까?'

 

 

실제 구조가 변경되었는지 확인을 위해 Reflection을 활용해서 런타임시 compareTo 메소드 정보를 출력해보겠습니다.

 

public class Main {
    public static void main(String[] args) {
        Stream.of(Integer.class.getDeclaredMethods())
                .filter(m -> "compareTo".equals(m.getName()))
                .map(Method::toGenericString)
                .forEach(System.out::println);
    }
}
public int java.lang.Integer.compareTo(java.lang.Object)
public int java.lang.Integer.compareTo(java.lang.Integer)

 

출력 결과 2개의 메소드가 존재함을 확인할 수 있습니다.

이를 통해 알 수 있는 사실은 Generic 타입 파라미터가 포함된 메소드를 오버로딩 할경우 Bridge 메소드를 자동으로 추가하여 레거시 코드와 호환성을 유지한다는 점입니다.

'JAVA > Generic' 카테고리의 다른 글

7. Reification  (0) 2020.03.10
5. Wildcard  (0) 2020.03.08
4. Wildcard with super(하위 경계)  (0) 2020.03.06
3. Wildcard with extends(상위 경계)  (2) 2020.03.06
2. Subtyping, Substitution 원리  (0) 2020.03.05

Unbounded Wildcard

 

Collections 클래스에는 reverse API가 있습니다. 해당 API는 주어진 Collection내 원소들을 역순으로 뒤집어주는 기능을 수행합니다.

 

public class Collections {
    ....
    public static void reverse(List<?> list){}
    ....
}

 

위 파라미터 변수를 살펴보면 wildcard가 사용되었지만 그 어떠한 경계도 지정되지 않은 형태로 사용되었습니다.

이는 말 그대로 타입 변수가 어떠한 것이든 상관이 없음을 의미합니다.

 

자세히 보면 List<Object> 와 의미가 비슷하다고 생각할 수 있습니다. 

하지만 List<?>와 List<Object>는 다릅니다.

 

public class Main{
   public void static main(int argc, String[] argv){
       List<Object> ints = new ArrayList<>();
       ints.add(1);
       ints.add(2);
       List<?> objs;
   }
}

 

List<Object>에는 Object 의 Subtype이 할당될 수 있지만, List<?>에는 오직 null 만이 할당될 수 있습니다. null만 할당이 가능하다는 점에서 List<? extends Object>와 같습니다.

 

Unbounded Wildcard 의미는 전달되는 변수의 타입이 무엇인지 정확히는 모르나 타입은 존재하며, 관심대상은 파라미터 타입이 아닌 객체가 제공하는 기능입니다.

 

즉 위 예시의 경우에는 관심대상은 List 인터페이스가 제공하는 기능이지 그 안에서 제공되는 타입들이 제공되는 기능은 관심대상은 아닙니다.

 

 

Unbounded Wildcard 내용을 정리하면 다음과 같습니다.

  • Object 클래스에 의하여 제공되는 기본적인 기능을 사용할 경우
  • 타입 파라미터에 의존적이지 않은 Generic Class에서 제공되는 기능을 사용하는 경우
  • 매개 변수로 전달되는 실제 타입은 관심 대상이 아니다.

Wildcard Capture

 

Get and Put 원리를 적용하여 reverse API를 직접 구현해봅시다. 메소드 수행 동작상에서 List의 원소가 제공하는 기능은 관심대상이 아니기에 다음과 같이 Spec을 정의할 수 있습니다.

 

public static void reverse(List<?> list)

 

메소드를 구현하면 대략 다음과 같습니다.

 

public static void reverse(List<?> list){
    List<Object> tmp = new ArrayList<>(list);
    for(int i =0;i < tmp.size();i++){
        list.set(i, tmp.get(i));
    }
}
Error:(12, 32) java: incompatible types: java.lang.Object cannot be converted to capture#1 of ?

 

위 메소드에서 tmp 리스트의 타입 인자가 Object인 이유는 전달받는 타입 정보를 모르기 때문에 모든 타입을 수용할 수 있도록 Object로 지정하였습니다.

 

하지만 메소드를 수행하면 에러가 발생하는 것을 볼 수 있습니다.

무엇이 문제였을까요?

 

public interface List<?> extends Collection<?>{
    ...
    ? get(int index);
    ? set(int index, ? element)
    ...
}

 

상위 경계 포스팅에서 다루었던 내용을 잠시 복습해보겠습니다.

 

Wildcard로 타입 인자가 지정되었을 때 타입에 대한 정보를 모르기때문에, 위 인터페이스에서 set 메소드를 호출할때마다 이를 지칭하기 위해 capture# of ?와 같은 형태로 컴파일러가 임의의 타입 변수를 할당합니다. 이를 Wildcard Capture라고 합니다.

 

문제는 알지 못하는 임의의 타입 T를 가르키는 capture# of ? 변수는 컴파일러가 내부적으로만 사용하고 타입 제약조건을 공식화하는데 사용하지 않는다는 점입니다. 

 

따라서 Object 타입의 값을 알 수 없는 List에 집어넣으려고 하기 때문에 에러가 발생합니다.

 

public static void reverse(List<T> list){
    List<T> tmp = new ArrayList<>(list);
    for(int i =0;i < tmp.size();i++){
        list.set(i, tmp.get(i));
    }
}

 

가장 심플한 방법은 reverse API를 위와 같이 타입 파라미터를 명시하는 방법입니다.

하지만, 관심대상이 아닌 T 타입을 컴파일러 제약때문에 API에 넣는다는 것은 그다지 좋은 디자인이 아닙니다.

따라서 API 디자인 규칙을 잘 준수하면서도 기능 동작을 구현하기위해 일종의 컴파일러 트릭을 사용합니다.

 

 

위 코드는 Helper 메소드를 사용하여 문제를 해결하였습니다. 즉 reverse 메소드를 호출할 때는 Wildcard Capture 정보를 타입 제약 조건으로 사용되지 않았습니다. 하지만 내부적으로 reverseHelper 메소드를 호출할때, 파라미터 타입에 T 타입을 명시하였기 때문에, capture# of ?와 같은 정보를 타입으로써 지정할 수 있습니다. 

 

이후 동일 타입내에서 값을 추출하고 넣음을 보장할 수 있기때문에 값 할당이 허용됩니다.


Wildcard 제한

 

public class Main {
    public static void main(String[] args)  {
        List<?> list = new ArrayList<?>(); //Compile 에러 발생
    }
}

 

 

Wildcard를 사용하면, 이를 기반으로 인스턴스 생성이 불가합니다.

만약, 타입 자동 추론을 이용하여 위 그림과 같이 list를 생성 할지라도, add 수행시 알 수 없는 타입이기 때문에 null외에 값 허용이 안됩니다.

 

이러한 제한을 둔 배경에는 Wildcard는 구체적인 타입 파라미터가 지정된 객체를 가르키는 것이기 때문에, 타입을 알 수 없는 Wildcard가 지정된 인스턴스 생성은 바람직하지 않기 때문입니다.

 

하지만 Wildcard가 쓰여있다고 해서 모든 객체가 생성 불가능한 것은 아닙니다.

Nested 형태로 Wildcard가 명시된 구조에서는 생성 가능합니다.

 

public class Main {
    public static void main(String[] args)  {
        List<List<?>> lists = new ArrayList<List<?>>();
        List<Integer> ints = Arrays.asList(1, 2, 3);
        List<Double> dbls = Arrays.asList(1.0, 2.0);
        lists.add(ints);
        lists.add(dbls);
    }
}

 

다만, 위에서도 볼 수 있듯이 Nested 형태 Wildcard가 포함된 인스턴스 또한 다른 List의 타입 파라미터를 가르키는 용도로 사용될 뿐 Wildcard 자체가 타입으로써 사용되지는 않습니다.

 

'JAVA > Generic' 카테고리의 다른 글

7. Reification  (0) 2020.03.10
6. Generic 메소드  (0) 2020.03.10
4. Wildcard with super(하위 경계)  (0) 2020.03.06
3. Wildcard with extends(상위 경계)  (2) 2020.03.06
2. Subtyping, Substitution 원리  (0) 2020.03.05

+ Recent posts