스프링 데이터 JPA 기능 중에서 Pageable과 Page를 사용하면 쉽게 페이징 처리를 할 수 있어 편리하다. 하지만 특정 행부터 일정 개수의 데이터를 조회하고 싶은 경우에는 Pageable과 Page가 적합하지 않다(예를 들어 21번째 행부터 21개의 데이터를 읽어오고 싶은 경우). 특정 행부터 일정 개수의 데이터를 조회할 수 있는 기능을 모든 리포지토리에 적용할 필요가 생겼는데 이를 위해 다음 작업을 진행했다.

  • Rangeable 클래스 추가 : 조회할 범위 값 저장(Pageable 대체).
  • RangeableExecutor 인터페이스 : Rangeable 타입을 사용하는 조회 메서드 정의.
  • RangeableRepository 인터페이스 : 스프링 데이터 JPA Repository 인터페이스와 RangeableExecutor 인터페이스를 상속.
  • RangeableRepositoryImpl 클래스 : 스프링 데이터 JPA의 기본 구현체를 확장. RangeableRepository 인터페이스의 구현을 제공.

스프링 데이터 JPA에서 모든 리포지토리에 동일 기능을 추가하는 방법은 스프링 데이터 JPA 레퍼런스를 참고한다.

예제 코드 : https://github.com/madvirus/spring-data-jpa-rangeable

 

madvirus/spring-data-jpa-rangeable

init. Contribute to madvirus/spring-data-jpa-rangeable development by creating an account on GitHub.

github.com

Rangeable 클래스

import org.springframework.data.domain.Sort;

public class Rangeable {
    private int start;
    private int limit;
    private Sort sort;

    public Rangeable(int start, int limit, Sort sort) {
        this.start = start;
        this.limit = limit;
        this.sort = sort;
    }

    public int getStart() {
        return start;
    }

    public int getLimit() {
        return limit;
    }

    public Sort getSort() {
        return sort;
    }
}

* start : 시작행, limit : 개수, sort : 정렬

RangeableExecutor 인터페이스

import org.springframework.data.jpa.domain.Specification;

import java.util.List;

public interface RangeableExecutor<T> {
    List<T> getRange(Specification<T> spec, Rangeable rangeable);
}

RangeableRepository 인터페이스

import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.Repository;

import java.io.Serializable;

@NoRepositoryBean
public interface RangeableRepository<T, ID extends Serializable>
        extends Repository<T, ID>, RangeableExecutor<T> {
}

RangeableRepositoryImpl 클래스

import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.support.JpaEntityInformation;
import org.springframework.data.jpa.repository.support.SimpleJpaRepository;

import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
import java.io.Serializable;
import java.util.List;

public class RangeableRepositoryImpl<T, ID extends Serializable>
        extends SimpleJpaRepository<T, ID>
        implements RangeableRepository<T, ID> {

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

    @Override
    public List<T> getRange(Specification<T> spec, Rangeable rangeable) {
        TypedQuery<T> query = getQuery(
                spec, getDomainClass(), rangeable.getSort());

        query.setFirstResult(rangeable.getStart());
        query.setMaxResults(rangeable.getLimit());

        return query.getResultList();
    }
}

* 기본 구현체인 SimpleJpaRepository 클래스를 확장해서 getRange() 구현

@EnableJpaRepositories로 기본 구현 지정

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@Configuration
@EnableJpaRepositories(repositoryBaseClass = RangeableRepositoryImpl.class)
public class SpringJpaConfiguration {
}

리포지토리에서 RangeableExecutor 인터페이스 사용

import org.springframework.data.repository.Repository;
import rangeable.jpa.RangeableExecutor;

public interface CommentRepository 
        extends Repository<Comment, Long>, RangeableExecutor<Comment> {
}

Rangeable로 일정 범위 조회

List<Comment> comments = repository.getRange(
        someSpec,
        new Rangeable(10, 5, Sort.by("id").descending()));

 

최근에 사용하는 프로필이 dev, prod, local, test 4개가 존재하는 스프링 부트 어플리케이션을 개발하고 있다. 로컬에서 'mvn spring-boot:run' 명령어를 실행하면 local 프로필을 사용해서 부트 앱을 실행하고 싶었다. src/main/resources 폴더에 application-prod.properties, application-dev.properties, application-local.properties 파일이 함께 존재해서 src/main/resources의 application.properties 파일에 spring.profiles.active=local 설정을 줄 수 없었다.


'mvn spring-boot:run -Dspring-boot.run.profiles=local'와 같이 로컬에서 실행할 때 마다 프로필을 지정하려니까 귀찮았다. 그래서 프로필을 선택하지 않은 경우 기본으로 local 프로필을 활성화하는 설정을 추가했다.


먼저 EnvironmentPostProcessor 인터페이스를 구현한 클래스를 작성한다.


public class ProfileResolverEnvironmentPostProcessor implements EnvironmentPostProcessor {


    @Override

    public void postProcessEnvironment(ConfigurableEnvironment environment, 

                                                   SpringApplication application) {

        boolean isSomeProfileActive = 

                environment.acceptsProfiles(Profiles.of("prod", "dev", "test", "local"));


        if (!isSomeProfileActive) {

            environment.addActiveProfile("local");

            Resource path = new ClassPathResource("application-local.properties");

            if (path.exists()) {

                try {

                    environment.getPropertySources().addLast(

                            new PropertiesPropertySourceLoader().load("application-local", path).get(0));

                } catch (IOException e) {

                    throw new IllegalStateException(e);

                }

            }

        } else {

            log.info("Some of [prod, dev, test, local] is active: " + environment.getActiveProfiles());

        }

    }

}


이 코드는 ConfigurableEnvironment#acceptsProfiles() 메서드를 이용해서 "prod", "dev", "test", "local" 프로필 중 하나라도 활성화되어 있는지 검사한다. 활성화되어 있지 않으면 활성 프로필을 "local"을 추가하고, 사용할 프로퍼티 소스로 "application-local" 프로퍼티 파일을 추가한다.


다음 할 일은 META-INF/spring.factories 파일에 다음 설정을 추가하는 것이다.


org.springframework.boot.env.EnvironmentPostProcessor=\

demo.ProfileResolverEnvironmentPostProcessor


특정 프로필을 선택하지 않고 부트 어플리케이션을 실행하면 local 프로필이 활성화되는 것을 확인할 수 있다.

스프링 스케줄러를 이용해서 cron 설정을 런타임에 변경하는 방법을 살펴본다.


1. TaskScheduler 설정


먼저 TaskScheduler를 설정한다.


@Configuration

public class SchedulingConfiguration {


    @Bean

    public ThreadPoolTaskScheduler schedulerExecutor() {

        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();

        taskScheduler.setPoolSize(4);

        taskScheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

        return taskScheduler;

    }


}


스프링 부트를 사용한다면 부트가 알아서 TaskScheduler를 만들어준다.


2. cron을 사용해서 작업을 스케줄링하는 코드 작성


다음은 cron을 이용해서 스케줄링하는 코드를 작성한다. 예제는 다음과 같다.


@Service

public class SchedulerService {

    private TaskScheduler scheduler;

    private String cron = "*/2 * * * * *";

    private ScheduledFuture<?> future;


    public SchedulerService(TaskScheduler scheduler) {

        this.scheduler = scheduler;

    }


    public void start() {

        ScheduledFuture<?> future = this.scheduler.schedule(() -> {

                    System.out.println("run at " + LocalDateTime.now());

                },

                new CronTrigger(cron));

        this.future = future;

    }


    public void changeCron(String cron) {

        if (future != null) future.cancel(true);

        this.future = null;

        this.cron = cron;

        this.start();

    }

}


scheduler.schedule()은 스케줄링을 취소할 수 있는 ScheduledFuture를 리턴한다. 이 ScheduledFuture를 이용해서 스케줄을 변경할 때 이전 스케줄을 취소하고 새 스케줄을 등록하면 된다. 위 코드에서 changeCron() 메서드는 앞서 생성한 스케줄이 있다면 future.cancel()을 이용해서 스케줄을 취소한다.


3. 스케줄 런타임 변경 확인


테스트 코드를 이용해서 실제 스케줄이 런타임에 바뀌는지 확인해보자.


@RunWith(SpringRunner.class)

@SpringBootTest

public class SchedulerServiceTest {

    @Autowired

    private SchedulerService schedulerService;


    @Test

    public void changeCron() throws InterruptedException {

        schedulerService.start();

        Thread.sleep(10000);

        schedulerService.changeCron("*/3 * * * * *");

        Thread.sleep(20000);

    }

}


SchedulerService의 최초 cron 설정은 "2/* * * * * *"이므로 매 2초마다 작업을 실행한다. 위 코드는 스케줄링을 시작한 뒤에 10초간 쉬고 그 다음에 매 3초마다 작업을 실행하도록 cron 설정을 변경한다. 그리고 20초 동안 쉰다. 실행 결과는 다음과 같다.


run at 2018-12-20T23:03:02.003

run at 2018-12-20T23:03:04.002

run at 2018-12-20T23:03:06.001

run at 2018-12-20T23:03:08.001

run at 2018-12-20T23:03:10.002

run at 2018-12-20T23:03:12.002

run at 2018-12-20T23:03:15.002

run at 2018-12-20T23:03:18.003

run at 2018-12-20T23:03:21.001

run at 2018-12-20T23:03:24.001

run at 2018-12-20T23:03:27.002

run at 2018-12-20T23:03:30.001


위 결과를 보면 2초 마다 실행하다가 changeCron()을 실행한 뒤부터는 3초 마다 실행하는 것을 확인할 수 있다.


예제 코드는 https://github.com/madvirus/spring-scheduler-cron-change 에서 확인할 수 있다.

스프링 부트 2.0에서 엑셀 다운로드 기능을 구현하는 방법을 정리했다.


pom.xml 설정


https://start.spring.io/ 사이트에서 스프링 부트 2.0.x 버전을 선택해서 프로젝트를 생성한다. Dependencies로는 Web과 Thymeleaf를 선택한다. 생성한 프로젝트 pom.xml 파일에 엑셀 생성을 위해 poi 의존을 추가한다.


<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0"

         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 

              http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>


    <groupId>madvirus</groupId>

    <artifactId>excel-download</artifactId>

    <version>0.0.1-SNAPSHOT</version>

    <packaging>jar</packaging>


    <name>excel-download</name>

    <description>Demo project for Spring Boot</description>


    <parent>

        <groupId>org.springframework.boot</groupId>

        <artifactId>spring-boot-starter-parent</artifactId>

        <version>2.0.1.RELEASE</version>

        <relativePath/> <!-- lookup parent from repository -->

    </parent>


    <properties>

        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

        <java.version>1.8</java.version>

    </properties>


    <dependencies>

        <dependency>

            <groupId>org.springframework.boot</groupId>

            <artifactId>spring-boot-starter-web</artifactId>

        </dependency>

        <dependency>

            <groupId>org.springframework.boot</groupId>

            <artifactId>spring-boot-starter-thymeleaf</artifactId>

        </dependency>


        <dependency>

            <groupId>org.apache.poi</groupId>

            <artifactId>poi</artifactId>

            <version>3.17</version>

        </dependency>


        <dependency>

            <groupId>org.springframework.boot</groupId>

            <artifactId>spring-boot-devtools</artifactId>

            <scope>runtime</scope>

        </dependency>

        <dependency>

            <groupId>org.springframework.boot</groupId>

            <artifactId>spring-boot-starter-test</artifactId>

            <scope>test</scope>

        </dependency>

    </dependencies>


    <build>

        <plugins>

            <plugin>

                <groupId>org.springframework.boot</groupId>

                <artifactId>spring-boot-maven-plugin</artifactId>

            </plugin>

        </plugins>

    </build>



</project>


엑셀 다운로드 위한 application.properties 파일 설정


확장자나 파라미터를 이용해서 엑셀 다운로드를 처리할 수 있도록 application.propertie 파일에 다음 설정을 추가한다.


spring.mvc.contentnegotiation.favor-parameter=true

spring.mvc.contentnegotiation.favor-path-extension=true

spring.mvc.contentnegotiation.media-types.xls=application/vnd.ms-excel


스프링 부트는 기본적으로 ContentNegotiationViewResolver를 사용하는데 각 프로퍼티는 다음을 설정한다.

  • favor-parameter: 이 값이 true면 ContentNegotiationViewResolver가 format 파라미터로 지정한 미디어 타입을 사용하도록 설정
  • favor-path-extension: 이 값이 true면 ContentNegotiationViewResolver가 확장자로 지정한 미디어 타입을 사용하도록 설정
  • media-types.타입: 타입에 해당하는 컨텐츠 타입을 지정

예를 들어 위 설정을 사용하면 다음 요청을 엑셀 타입(application/vnd.ms-excel) 요청으로 인지하고, 엑셀 미디어 타입에 해당하는 응답을 처리할 수 있는 뷰를 사용해서 응답을 생성한다.

  • stat.xls (확장자가 xls)
  • stat?format=xls (format 파라미터가 xls)

예제 컨트롤러


다음 코드는 일반 뷰와 엑셀 다운로드를 처리하는 컨트롤러 코드이다.


@Controller

public class StatController {

    private void populateModel(Model model) {

        List<StatRow> rows = Arrays.asList(

                new StatRow("고객1", 1000, 1500),

                new StatRow("고객2", 2000, 2500),

                new StatRow("고객3", 3000, 3500)

        );

        model.addAttribute("rows", rows);

    }


    @GetMapping("/stat")

    public String get(Model model) {

        populateModel(model);

        return "stat";

    }


    @GetMapping("/stat.xls")

    public String getExcelByExt(Model model) {

        populateModel(model);

        return "statXls";

    }


    @GetMapping(path = "/stat", params = "format=xls")

    public String getExcelByParam(Model model) {

        populateModel(model);

        return "statXls";

    }

}


get() 메서드는 일반 뷰를 사용해서 응답을 생성한다. getExcelByExt() 메서드는 확장자가 xls인 요청 경로를 처리하므로 "statXls"에 대응하는 뷰 중에서 엑셀 타입을 응답으로 생성할 수 있는 뷰를 선택한다. 비슷하게 getExcelByParam() 역시 format 파라미터가 xls인 요청을 처리하므로 엑셀 타입을 생성할 수 있는 뷰를 선택한다.


엑셀 생성을 위한 뷰 클래스


엑셀 다운로드를 위한 뷰 클래스는 다음과 같이 구현한다. 빈 객체 이름으로 "statxls"를 사용했는데 이 이름은 앞서 컨트롤러에서 리턴한 뷰 이름과 같다.


package exceldownload;


import org.apache.poi.ss.usermodel.*;

import org.springframework.stereotype.Component;

import org.springframework.web.servlet.view.document.AbstractXlsView;


import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpServletResponse;

import java.util.List;

import java.util.Map;


@Component("statXls")

public class StatXlsView extends AbstractXlsView {

    @Override

    protected void buildExcelDocument(

            Map<String, Object> model, Workbook workbook,

            HttpServletRequest request, HttpServletResponse response) throws Exception {

        response.setHeader("Content-Disposition", "attachment; filename=\"stat.xls\"");


        List<StatRow> stats = (List<StatRow>) model.get("rows");


        CellStyle numberCellStyle = workbook.createCellStyle();

        DataFormat numberDataFormat = workbook.createDataFormat();

        numberCellStyle.setDataFormat(numberDataFormat.getFormat("#,##0"));


        Sheet sheet = workbook.createSheet("mobilestat");

        for (int i = 0 ; i < stats.size() ; i++) {

            StatRow stat = stats.get(i);

            Row row = sheet.createRow(i);


            Cell cell0 = row.createCell(0);

            cell0.setCellValue(stat.getName());


            Cell cell1 = row.createCell(1);

            cell1.setCellType(CellType.NUMERIC);

            cell1.setCellValue(stat.getValue1());

            cell1.setCellStyle(numberCellStyle);


            Cell cell2 = row.createCell(2);

            cell2.setCellType(CellType.NUMERIC);

            cell2.setCellValue(stat.getValue2());

            cell2.setCellStyle(numberCellStyle);

        }

    }

}



타임리프 뷰 구현


타임리프트를 이용한 뷰 구현 파일인 stat.html은 다음과 같아 간단하게 구현했다. 엑셀 다운로드를 위한 링크를 추가했다.


<!DOCTYPE HTML>

<html xmlns:th="http://www.thymeleaf.org">

<head>

    <meta charset="utf-8" />

    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

    <title>통계</title>

</head>

<body>


<a href="stat.xls">엑셀다운, 확장자(stat.xls)</a> |

<a href="stat?format=xls">엑셀다운, 파라미터(stat?format=xls)</a>

<table border="1">

    <thead>

    <tr>

        <th>이름</th>

        <th>값1</th>

        <th>값2</th>

    </tr>

    </thead>

    <tbody>

    <tr th:each="row : ${rows}">

        <td th:text="${row.name}"></td>

        <td th:text="${#numbers.formatInteger(row.value1, 1, 'COMMA')}"></td>

        <td th:text="${#numbers.formatInteger(row.value2, 1, 'COMMA')}"></td>

    </tr>

    </tbody>

</table>


</body>

</html>


예제 실행


완전한 예제 프로젝트는 https://github.com/madvirus/excel-download 리포지토리에서 구할 수 있다. 명령 프롬프트에서 "mvnw spring-boot:run" 명령어를 부트 어플리케이션을 실행한 뒤에 http://localhost:8080/stat 주소에 연결해보자. 다음 결과를 볼 수 있다.



엑셀 다운로드 링크를 클릭해보자. 두 링크 중 아무거나 클릭하면 엑셀 파일을 다운로드 한다.



실제 다운로드한 파일을 열어보자. 아래와 같이 엑셀 파일이 올바르게 생성된 것을 확인할 수 있다.



스프링 부트(spring boot)를 사용한다면 타임리프(thymeleaf)의 식 객체(expression object)를 쉽게 확장할 수 있다. 먼저 식 객체를 생성해주는 타임리프 IExpressionObjectDialect를 구현한다. 이 클래스를 스프링 빈으로 등록해야 한다. 아래 예는 @Component를 붙여 컴포넌트 스캔 대상으로 설정했다.


import java.util.Collections;

import java.util.Set;


import org.springframework.stereotype.Component;

import org.thymeleaf.context.IExpressionContext;

import org.thymeleaf.dialect.AbstractDialect;

import org.thymeleaf.dialect.IExpressionObjectDialect;

import org.thymeleaf.expression.IExpressionObjectFactory;


@Component

public class MyFormatDialect extends AbstractDialect implements IExpressionObjectDialect {


    protected ScgFormatDialect() {

        super("myFormat");

    }


    @Override

    public IExpressionObjectFactory getExpressionObjectFactory() {

        return new IExpressionObjectFactory() {


            @Override

            public Set<String> getAllExpressionObjectNames() {

                return Collections.singleton("scgFormat");

            }


            @Override

            public Object buildObject(IExpressionContext context, String expressionObjectName) {

                return new MyFormat();

            }


            @Override

            public boolean isCacheable(String expressionObjectName) {

                return true;

            }

        };

    }


}


생성자에서는 식 객체의 이름을 "myFormat"으로 지정한다.

getExpressionObjectFactory() 메서드는 IExpressionObjectFactory 객체를 리턴한다. 이 객체의 buildObject() 메서드가 생성하는 객체가 식 객체가 된다. 이 객체는 타임리프 식에서 사용할 메서드를 제공한다. 다음은 식 객체로 사용할 클래스의 구현 예이다.


public class MyFormat {


    public String date(String date) {

        if (!StringUtils.hasText(date))

            return null;

        if (date.length() == 8) {

            return date.substring(0, 4) + "-" + date.substring(4, 6) + "-" + date.substring(6, 8);

        } else {

            return date;

        }

    }


    public String contractNum(String contractNum) {

        if (!StringUtils.hasText(contractNum))

            return null;

        if (contractNum.length() > 5) {

            return contractNum.substring(0, 5) + "-" + contractNum.substring(5);

        } else {

            return contractNum;

        }

    }


    public String phone(String phone) {

        if (!StringUtils.hasText(phone))

            return null;

        if (phone.length() == 11) {

            return phone.substring(0, 3) + "-" + phone.substring(3, 7) + "-" + phone.substring(7);

        } else if (phone.length() == 10) {

            return phone.substring(0, 3) + "-" + phone.substring(3, 6) + "-" + phone.substring(6);

        } else {

            return phone;

        }

    }

}


이제 커스텀 식 객체를 타임리프 식에서 사용하면 된다.


<td th:text="${#myFormat.phone(item.handphone)}"></td>

<td th:text="${#myFormat.contractNum(item.useContractNum)}"></td>




스프링 부트 2.0은 기본 사용하는 커넥션풀을 HikariCP로 교체했다. HikariCP와 관련된 커넥션 풀 속성은 다음과 같다. 


 속성

 설명

기본 값

 connectionTimeout

풀에서 커넥션을 구할 때 대기 시간을 밀리초 단위로 지정한다. 대기 시간 안에 구하지 못하면 익셉션이 발생한다.


허용 가능한 최소 값은 250이다. 

30000

(30초)

 validationTimeout

커넥션이 유효한지 검사할 때 대기 시간을 지정한다. 이 값은 connectionTimeout보다 작아야 한다.


허용 가능한 최소 값은 250이다. 

5000
(5초)

 idleTimeout

커넥션이 풀에서 유휴 상태로 남을 수 있는 최대 시간을 밀리초 단위로 지정한다. 이 값은 minimumIdle이 maximumPoolSize보다 작을 때만 적용된다.


타임아웃이 지난 커넥션을 제거하기까지 최대 30초, 평균 15초 차이 날 수 있다.


이 타임아웃 전에는 유휴 커넥션을 제거하지 않는다. 풀의 커넥션 개수가 minimumIdle이 되면 유휴 여부에 상관없이 커넥션을 풀에서 제거하지 않는다.


이 값이 0이면 유휴 커넥션을 풀에서 제거하지 않는다.


허용 가능한 최소 값은 10000(10초)이다.

600000

(10분)

 maxLifetime

커넥션의 최대 유지 시간을 밀리초 단위로 설정한다. 이 시간이 지난 커넥션 중에서 사용중인 커넥션은 종료된 이후에 풀에서 제거한다.


갑자기 풀에서 많은 커넥션이 제거되는 것을 피하기 위해 negative attenuation(감쇠)를 적용해 점진적으로 제거한다.

 

이 값을 설정할 것을 권장한다. DB나 인프라스트럭처에서 제한한 커넥션 제한 시간 보다 최소한 30초는 짧아야 한다..


이 값이 0이면 풀에서 제거하지 않지만 idleTimeout은 적용된다.

1800000
(30분)
 maximumPoolSize

유휴 상태와 사용중인 커넥션을 포함해서 풀이 허용하는 최대 커넥션 개수를 설정한다. 이 값은 데이터베이스에 대한 실제 커넥션의 최대 개수를 결정한다.


풀이 이 크기에 도달하고 유휴 커넥션이 없을 때 connectionTimeout이 지날 때까지 getConnection() 호출은 블록킹된다.

10

 minimumIdle

풀에 유지할 유휴 커넥션의 최소 개수를 설정한다.

maximumPoolSize와 동일
 connectionTestQuery

커넥션이 유효한지 검사할 때 사용할 쿼리를 지정한다. 드라이버가 JDBC4를 지원하면 이 프로퍼티를 설정하지 말자. 이 프로퍼티를 설정하지 않으면 JDBC4의 Conneciton.isValid()를 사용해서 유효한지 검사를 수행한다.

없음

 leakDetectionThreshold

커넥션이 누수 가능성이 있다는 로그 메시지를 출력하기 전에 커넥션이 풀에서 벗어날 수 있는 시간을 밀리초로 설정한다. 0은 누수 발견을 하지 않는다. 허용하는 최소 값은 2000(2초)이다.


0


스프링 부트가 자동 제공하는 DataSource를 사용한다면 속성 앞에 "spring.datasource.hikari."를 접두어로 붙이면 된다. 다음은 속성의 사용 예이다. 


spring.datasource.hikari.connectionTimeout=5000

spring.datasource.hikari.validationTimeout=1000

spring.datasource.hikari.maxPoolSize=30


스프링 부트 날짜 타입을 JSON으로 응답할 때 별도 설정을 하지 않으면 부트 버전에 따라 응답 형식이 다르다. 먼저 간다한 테스트를 위해 다음과 같이 세 개의 날짜 형식을 갖는 Now 클래스를 사용하자.


import java.time.LocalDateTime;

import java.time.OffsetDateTime;

import java.util.Date;


public class Now {

    private LocalDateTime localTime;

    private OffsetDateTime offsetTime;

    private Date date;


    public Now() {

        localTime = LocalDateTime.now();

        offsetTime = OffsetDateTime.now();

        date = new Date();

    }


    ...getter 생략

}



Now를 생성해서 JSON으로 응답하는 컨트롤러를 다음과 같이 작성했다.


@RestController

public class SampleController {

    @GetMapping("/now")

    public Now time() {

        return new Now();

    }

}


이 글에서 사용한 코드는 https://github.com/madvirus/boot-jackson 리포지토리에서 참고할 수 있다.


부트 2.0에서 기본 JSON 시간 타입 포맷팅


부트 2.0으로 테스트하면 응답 결과가 다음과 같다. 각 타입을 ISO-8601 형식으로 출력하고 있다.


{

    "localTime": "2018-03-01T17:03:50.445428",

    "offsetTime": "2018-03-01T17:03:50.445428+09:00",

    "date": "2018-03-01T08:03:50.445+0000"

}


시간대 정보가 있는 OffsetDateTime 타입은 "+09:00"이 뒤에 붙어 있다. 반면에 Date 타입은 UTC 기준 시간을 사용했다. 또 다른 차이는 OffsetDateTime과 Date의 시간대 표시가 다르다는 것이다. OffsetDateTime 타입은 "+09:00"와 같이 콜론이 포함되어 있고 Date 타입은 "+0000"과 같이 콜론이 없다. LocalDateTime의 경우 오프셋 정보가 없으므로 시간대 부분이 없다.


부트 1.5에서 기본 JSON 시간 타입 포맷팅


부트 1.5에서 테스하면 응답 결과가 다음과 같다. 아주 난리다!


{

"localTime": {

"month": "MARCH",

"dayOfWeek": "THURSDAY",

"dayOfYear": 67,

"nano": 644321400,

"year": 2018,

"monthValue": 3,

"dayOfMonth": 1,

"hour": 17,

"minute": 28,

"second": 2,

"chronology": {

"id": "ISO",

"calendarType": "iso8601"

}

},

"offsetTime": {

"offset": {

"totalSeconds": 32400,

"id": "+09:00",

"rules": {

"fixedOffset": true,

"transitions": [],

"transitionRules": []

}

},

"month": "MARCH",

"dayOfWeek": "THURSDAY",

"dayOfYear": 67,

"nano": 644321400,

"year": 2018,

"monthValue": 3,

"dayOfMonth": 1,

"hour": 17,

"minute": 28,

"second": 2

},

"date": 1519894518644

}


부트 1.5에서 jackson-datatype-jsr310 모듈 추가


부트 1.5에 아래와 같이 jackson-datatype-jsr310 모듈을 추가해보자. 이 모듈은 LocalDateTime이나 OffsetDateTime과 같이 자바 8의 시간 타입을 지원하는 모듈이다.


<dependency>

    <groupId>com.fasterxml.jackson.datatype</groupId>

    <artifactId>jackson-datatype-jsr310</artifactId>

</dependency>


이 모듈을 추가한 뒤에 JSON 생성 결과를 보면 다음과 같다.


{

"localTime": [

2018,

3,

8,

21,

16,

30,

166225000

],

"offsetTime": 1520511390.166383,

"date": 1520511390166

}



부트 1.5 Date 포맷: WRITE_DATES_AS_TIMESTAMPS 비활성, StdFormat 사용


부트 1.5에서 아래 프로퍼티를 application.properties 파일에 추가하면 Date 타입을 ISO-8601 포맷을 사용해서 변환한다.


spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false


다음 프로퍼티를 설정해도 결과가 같다.


spring.jackson.date-format=com.fasterxml.jackson.databind.util.StdDateFormat


실제 출력 결과는 다음과 같다. UTC 기준으로 출력하고 있다.


"date": "2018-03-08T08:51:53.972+0000"



부트 1.5, 2.0: Jackson2ObjectMapperBuilderCustomizer로 포맷팅 설정


부트는 Jackson2ObjectMapperBuilderCustomizer 인터페이스를 제공하는데 이 인터페이스를 구현한 클래스를 빈으로 등록하면 변환 포맷을 설정할 수 있다. 다음 코드는 사용 예이다.


import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;

import com.fasterxml.jackson.datatype.jsr310.ser.OffsetDateTimeSerializer;

import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;

import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;


import java.time.LocalDateTime;

import java.time.OffsetDateTime;

import java.time.format.DateTimeFormatter;


@SpringBootApplication

public class Boot15JacksonApplication implements Jackson2ObjectMapperBuilderCustomizer {


    public static void main(String[] args) {

        SpringApplication.run(Boot15JacksonApplication.class, args);

    }


    // Jackson2ObjectMapperBuilderCustomizer 인터페이스 메서드

    @Override

    public void customize(Jackson2ObjectMapperBuilder builder) {

        // LocalDateTime은 오프셋 정보가 없으므로 패턴에 시간대에 해당하는 Z가 없다.

        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");

        LocalDateTimeSerializer localSerializer = new LocalDateTimeSerializer(formatter);


        DateTimeFormatter formatter2 = 

                DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ");

        CustomOffsetDateTimeSerializer offsetSerializer = 

                new CustomOffsetDateTimeSerializer(formatter2);


        builder

                .simpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")

                .serializerByType(LocalDateTime.class, localSerializer)

                .serializerByType(OffsetDateTime.class, offsetSerializer)

        ;

    }


    public class CustomOffsetDateTimeSerializer extends OffsetDateTimeSerializer {

        public CustomOffsetDateTimeSerializer(DateTimeFormatter formatter) {

            super(OffsetDateTimeSerializer.INSTANCE, false, formatter);

        }

    }


}


Jackson2ObjectMapperBuilder의 다음 메서드를 이용해서 시간 타입 변환 포맷을 설정했다. 
  • simpleDateFormat: Date 타입을 위한 변환 포맷 설정
  • serializerByType: 타입을 위한 Jackson의 Serializer 설정
serializerByType 메서드를 이용해서 LocalDateTime과 OffsetDateTime을 위한 Serializer를 설정했다. LocalDateTime을 위한 Serializer로는 Jackson이 제공하는 LocalDateTimeSerializer 클래스를 사용했다. 

OffsetDateTime은 약간 수고가 더 필요하다. Jackson이 제공하는 OffsetDateTimeSerializer를 상속해서 구현한 Serializer를 사용했다. 

위 설정을 추가한 뒤 결과는 다음과 같다.

{
"localTime": "2018-03-08T21:26:46",
"offsetTime": "2018-03-08T21:26:46+0900",
"date": "2018-03-08T21:26:46+0900"
}




Bean Validation 2.0(JSR-380)에는 검증과 관련해서 간지러운 점을 긁어주는 애노테이션이 몇 개 추가되었다. 예를 들어 2.0에 추가된 @Email 애노테이션을 사용하면 이메일 형식을 검사하기 위해 정규 표현식을 사용하지 않아도 되고, @Positive 애노테이션을 사용하면 값이 양수인지 검사할 수 있다.


스프링 5에서 Bean Validation 2.0 설정


스프링 5 버전에서 Bean Validation 2.0을 사용하려면 다음과 같이 2.0 API와 관련 프로바이더를 의존에 추가해주면 된다. hibernate-validator 6.0 의존을 추가하면 validationa-api 2.0 의존도 함께 추가되므로 validation-api 의존을 생략해도 된다.


<!-- validation-api는 생략 가능 -->

<dependency>

    <groupId>javax.validation</groupId>

    <artifactId>validation-api</artifactId>

    <version>2.0.1.Final</version>

</dependency>


<dependency>

    <groupId>org.hibernate.validator</groupId>

    <artifactId>hibernate-validator</artifactId>

    <version>6.0.7.Final</version>

</dependency>


스프링 관련 설정


@EnableWebMvc 애노테이션을 사용하면 된다. 이 애노테이션을 사용하면 Bean Validation 애노테이션에 대한 검증 기능을 제공하는 OptionalValidatorFactoryBean를 글로벌 Validator로 등록한다.


@Configuration

@EnableWebMvc

public class SpringConfig implements WebMvcConfigurer {

    ...

}


애노테이션 사용 예


다음은 Bean Validation 애노테이션을 사용한 예이다.


import org.springframework.format.annotation.DateTimeFormat;


import javax.validation.constraints.Email;

import javax.validation.constraints.NotBlank;

import javax.validation.constraints.NotEmpty;

import javax.validation.constraints.PastOrPresent;

import java.time.LocalDate;


public class FormData {

    @NotBlank

    @Email

    private String email;


    @NotBlank

    private String name;


    @NotEmpty

    private String password;


    @DateTimeFormat(pattern = "yyyyMMdd")

    @PastOrPresent

    private LocalDate birthday;


컨트롤러는 다음과 같이 @Valid를 이용해서 검증을 적용할 수 있다.


@PostMapping

public String submit(@ModelAttribute @Valid FormData formData, Errors errors) {

    if (errors.hasErrors()) return "form";

    return "submit";

}


실제 예제 코드는 https://github.com/madvirus/spring5-bv2 리포지토리에서 확인할 수 있다. 리포지토리에서 코드를 clone하고 아래 순서대로 실행한다.

  • git clone https://github.com/madvirus/spring5-bv2.git
  • cd spring5-bv2
  • cd sp5-bv2
  • mvnw jetty:run

이 예제는 JSP를 뷰 기술로 사용했다. 관련 폼을 보여주는 JSP 코드는 다음과 같다.


<%@ page contentType="text/html; charset=utf-8" %>

<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>

<!DOCTYPE html>

<html>

<head>

    <title>가입</title>

</head>

<body>

    <h2>가입</h2>

    <form:form modelAttribute="formData">

    <p>

        <label>이메일:<br>

        <form:input path="email" />

        <form:errors path="email"/>

        </label>

    </p>

    <p>

        <label>이름:<br>

        <form:input path="name" />

        <form:errors path="name"/>

        </label>

    </p>

    <p>

        <label>암호:<br>

        <form:password path="password" />

        <form:errors path="password"/>

        </label>

    </p>

    <p>

        <label>생일:<br>

        <form:input path="birthday" />

        <form:errors path="birthday" />

        </label>

    </p>

    <input type="submit" value="가입">

    </form:form>

</body>

</html>


브라우저에서 http://localhost:8080/register로 연결한 뒤 폼에 아무 값도 입력하지 않고 [가입] 버튼을 누르면 검증 결과를 확인할 수 있다. 실제 결과는 다음과 같다.



에러 메시지를 위한 MessageSource를 설정하지 않았다. 위 결과에서 이메일, 이름, 암호 필드에 출력한 메시지는 hibernate-validator가 제공하는 기본 에러 메시지이다.


스프링 부트 2 사용


스프링 부트 2 버전은 기본적으로 Bean Validation 2.0을 사용하므로 추가 설정이 필요없다. 그냥 스프링 부트 2 프로젝트를 만들어 사용하면 된다. 관련 예제 코드는 같은 리포지토리의 boot2-bv2 폴더에 있다. 아래 순서대로 실행한 뒤 브라우저에서 http://localhost:8080/register 주소로 연결해서 확인할 수 있다.

  • git clone https://github.com/madvirus/spring5-bv2.git
  • cd spring5-bv2
  • cd boot2-bv2
  • mvnw spring-boot:run

부트 예제는 Thymeleaf를 뷰로 사용했다. 폼을 보여주는 Themeleaf 템플릿 코드는 다음과 같다.


<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<head>

    <meta charset="UTF-8" />

    <title>가입</title>

</head>

<body>

<h2>가입</h2>

<form th:object="${formData}" method="POST">

    <p>

        <label>이메일:<br>

            <input type="text" th:field="*{email}" />

            <span th:each="err : ${#fields.errors('email')}" th:text="${err}" />

        </label>

    </p>

    <p>

        <label>이름:<br>

            <input type="text" th:field="*{name}" />

            <span th:each="err : ${#fields.errors('name')}" th:text="${err}" />

        </label>

    </p>

    <p>

        <label>암호:<br>

            <input type="password" th:field="*{password}" />

            <span th:each="err : ${#fields.errors('password')}" th:text="${err}" />

        </label>

    </p>

    <p>

        <label>생일:<br>

            <input type="text" th:field="*{birthday}" />

            <span th:each="err : ${#fields.errors('birthday')}" th:text="${err}" />

        </label>

    </p>

    <input type="submit" value="가입">

</form>

</body>

</html>


실행 결과는 앞서 본 그림과 동일하다.


Bean Validation 2.0 제공 애노테이션


Bean Validation 2.0이 제공하는 애노테이션 목록은 다음과 같다. 2.0에 추가된 애노테이션에는 (2)를 표시했다. 아래 표의 지원 타입에서 CharSequence는 문자열 관련 인터페이스로 이 인터페이스를 구현한 대표적인 클래스가 String이다.


애노테이션 

주요 속성 

설명 

 지원 타입

@AssertTrue

@AssertFalse

 

값이 true인지 또는 false인지 검사한다. null은 유효하다고 판단한다. 

boolean
Boolean

@DecimalMax

@DecimalMin

String value

- 최댓값 또는 최솟값


boolean inclusive

- 지정값 포함 여부

- 기본 값 true

지정한 값보다 작거나 같은지 또는 크거나 같은지 검사한다.

inclusive가 false면 value로 지정한 값은 포함하지 않는다.

null은 유효하다고 판단한다.

BigDecimal

BigInteger

CharSequence

byte, short, int, long 및 각 래퍼 타입

@Max

@Min

long value

지정한 값보다 작거나 같은지 또는 크거나 같은지 검사한다.

null은 유효하다고 판단한다.

BigDecimal
BigInteger
byte, short, int, long 및 관련 래퍼 타입

@Digits

int integer

- 허용 가능한 정수 자릿수


int fraction

- 허용 가능한 소수점 이하 자릿수

자릿수가 지정한 크기를 넘지 않는지 검사한다.

null은 유효하다고 판단한다.

BigDecimal

BigInteger

CharSequence

byte, short, int, long 및 관련 래퍼 타입

@Size

int min

- 최소 크기

- 기본 값 0


int max

- 최대 크기

- 기본 값 

길이나 크기가 지정한 값 범위에 있는지 검사한다.

null은 유효하다고 판단한다.

CharSequence

Collection

Map

배열

@Null

@NotNull

 

값이 null인지 또는 null이 아닌지 검사한다. 

 

@Pattern

String regexp

- 정규표현식 

값이 정규표현식에 일치하는지 검사한다. 

null은 유효하다고 판단한다.

CharSequence

@NotEmpty (2)

 

문자열나 배열의 경우 null이 아니고 길이가 0이 아닌지 검사한다. 콜렉션의 경우 null이 아니고 크기가 0이 아닌지 검사한다.

CharSequence

Collection

Map

배열

@NotBlank (2)


null이 아니고 최소한 한 개 이상의 공백아닌 문자를 포함하는지 검사한다.

CharSequence

@Positive (2)

@PositiveOrZero (2)

 

양수인지 검사한다.

OrZero가 붙은 것은 0 또는 양수인지 검사한다.

null은 유효하다고 판단한다.

BigDecimal
BigInteger
byte, short, int, long 및 관련 래퍼 타입

@Negative (2)

@NegativeOrZero (2)

 

음수인지 검사한다.
OrZero가 붙은 것은 0 또는 음수인지 검사한다. 

null은 유효하다고 판단한다.

BigDecimal
BigInteger
byte, short, int, long 및 관련 래퍼 타입

@Email (2)

 

이메일 주소가 유효한지 검사한다. 
null은 유효하다고 판단한다.
CharSequence 

@Future (2)

@FutureOrPresent (2)

 

해당 시간이 미래 시간인지 검사한다.

OrPresent가 붙은 것은 현재 또는 미래 시간인지 검사한다.

null은 유효하다고 판단한다.

시간 관련 타입

@Past (2)

@PastOrPresent (2)

 

해당 시간이 과거 시간인지 검사한다.

OrPresent가 붙은 것은 현재 또는 과거 시간인지 검사한다.
null은 유효하다고 판단한다.

시간 관련 타입 

* 애노테이션이 속한 패키지: javax.validation.constraints

* 시간 관련 타입: Date, Calendar, Instant, LocalDate, LocalDateTime, MonthDay, OffsetDateTime, OffsetTime, Year, YearMonth, ZonedDateTime 등



관련 링크

  • 예제 코드: https://github.com/madvirus/spring5-bv2
  • Bean Validation: http://beanvalidation.org/


  1. 자바덕 2018.02.28 16:27

    안녕하세요. DDD 마스터 최범균님..
    https://github.com/madvirus/spring5-bv2 링크 들어가면 404 나옵니당..
    저만 그런가요! 확인 부탁드립니다.
    항상 좋은 글 감사합니다. (--)(__)

HTTP 요청 헤더를 이용해서 접속한 사용자 정보를 받기로 했다. 받아야 할 정보가 두 개여서 스프링 컨트롤러에서 다음과 같은 코드를 사용하게 되었다.


@RestController

public class HandoverApi {


    private HandoverService handoverService;


    @PostMapping("/api/handover")

    public HandoverPk postHandover(

            @RequestHeader(name = "employeeId", required = false) String employeeIdHeader,

            @RequestParam(name = "employeeId", required = false) String employeeIdParam,

            @RequestHeader(name = "cellphone", required = false) String cellphoneHeader,

            @RequestParam(name = "cellphone", required = false) String cellphoneParam,

            @Valid HandoverRequest req) throws ServletRequestBindingException {

        if (StringUtils.isEmpty(employeeIdHeader) && StringUtils.isEmpty(employeeIdParam)) {

            throw new ServletRequestBindingException("Missing employeeId");

        }

        if (StringUtils.isEmpty(cellphoneHeader) && StringUtils.isEmpty(cellphoneParam)) {

            throw new ServletRequestBindingException("Missing cellphoneParam");

        }

        String employeeId = employeeIdParam != null ? employeeIdParam : employeeIdHeader;

        String cellphone = cellphoneParam != null ? cellphoneParam : cellphoneHeader;

        req.setEmployeeId(employeeId);

        req.setCellphone(cellphone);


        return handoverService.handoverDigWork(req);

    }


employeeId와 cellphone 값을 요청 헤더나 요청 파라미터로 받을 수 있도록 했다. 헤더와 파라미터를 함께 사용할 수 있게 해서 코드가 다소 장황해졌다. 문제는 이런 코드가 계속해서 중복해서 출현하게 되었다는 것이다. employeeId와 cellphone 값이 필요한 API에서 이런 장황하면서 보기 싫은 코드를 중복해서 사용하게 되었다.


이런 중복을 없애기 위해 스프링의 커스텀 HandlerMethodArgumentResolver를 사용했다. 원하는 코드 모양은 다음과 같다.


@RestController

public class HandoverApi {


    @PostMapping("/api/handover")

    public HandoverPk postHandover(

            @MobileUser(check = MobileUserCheck.ALL) MobileUserInfo mobUserInfo,

            @Valid DigWorkHandoverRequest req) {

        req.setEmployeeId(mobUserInfo.getEmployeeId());

        req.setCellphone(mobUserInfo.getCellphone());


        return handoverService.handoverDigWork(req);

    }


@MobileUser 애노테이션이 붙은 MobileUserInfo 타입 파라미터에 자동으로 employeeId와 cellphone 값이 담기도록 스프링을 확장하는 것을 목표로 했다.


파라미터에 사용할 MobileUserInfo와 @MobileUser


먼저 employeeId와 cellphone을 담을 데이터 클래스를 하나 만들었다.


@Getter

@AllArgsConstructor

@ToString

public class MobileUserInfo {

    private String employeeId;

    private String cellphone;

    

    public void checkEmployeeId() throws ServletRequestBindingException {

        if (StringUtils.isEmpty(employeeId))

            throw new ServletRequestBindingException("Missing employeeId");

    }

    

    public void checkCellphone() throws ServletRequestBindingException {

        if (StringUtils.isEmpty(cellphone))

            throw new ServletRequestBindingException("Missing cellphone");

    }


    public void checkAll() throws ServletRequestBindingException {

        checkEmployeeId();

        checkCellphone();

    }

    

}


@MobileUser 애노테이션은 다음과 같이 정의했다.


@Target(value = { ElementType.PARAMETER })

@Retention(value = RetentionPolicy.RUNTIME)

public @interface MobileUser {


    MobileUserCheck check() default MobileUserCheck.EMPLOYEE_ID;


}


check 속성은 값을 어디까지 검사할지 여부를 지정하기 위한 용도로 사용한다. 이 예제의 경우 employeeId만 필요한 경우가 있고, cellphone만 필요한 경우가 있고, 둘 다 필요한 경우도 있다. 이를 필요에 따라 검사 대상을 지정할 수 있도록 check 속성을 추가했다. MobileUserCheck 열거 타입은 검사할 대상을 포함한다.


public enum MobileUserCheck {

    ALL, EMPLOYEE_ID, CELLPHONE, NONE

}


커스텀 HandlerMethodArgumentResolver 구현


스프링이 컨트롤러 메서드의 인자로 MobileUserInfo 타입 객체를 받을 수 있으려면 HandlerMethodArgumentResolver의 구현체를 알맞게 제공해야 한다. 예제를 위한 구현체는 다음과 같다.


public class MobileUserInfoResolver implements HandlerMethodArgumentResolver {

    @Override

    public boolean supportsParameter(MethodParameter parameter) {

        MobileUser mobUserAnnot = parameter.getParameterAnnotation(MobileUser.class);

        return mobUserAnnot != null && 

                 MobileUserInfo.class.isAssignableFrom(parameter.getParameterType());

    }


    @Override

    public Object resolveArgument(MethodParameter parameter, 

            ModelAndViewContainer mavContainer,

            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) 

            throws Exception {

        String employeeIdHeader = webRequest.getHeader("employeeId");

        String employeeIdParam = webRequest.getParameter("employeeId");

        String cellphoneHeader = webRequest.getHeader("cellphone");

        String cellphoneParam = webRequest.getParameter("cellphone");


        String employeeId = employeeIdParam != null ? employeeIdParam : employeeIdHeader;

        String cellphone = cellphoneParam != null ? cellphoneParam : cellphoneHeader;


        MobileUser mobUserAnnot = parameter.getParameterAnnotation(MobileUser.class);

        MobileUserInfo mobileUserInfo = new MobileUserInfo(employeeId, cellphone);

        switch (mobUserAnnot.check()) {

        case ALL:

            mobileUserInfo.checkAll();

            break;

        case EMPLOYEE_ID:

            mobileUserInfo.checkEmployeeId();

            break;

        case CELLPHONE:

            mobileUserInfo.checkCellphone();

            break;

        default:

            break;

        }

        return mobileUserInfo;

    }

}


supportsParameter() 메서드는 컨트롤러 메서드의 특정 파라미터를 지원하는지 여부를 리턴한다. 이 예제의 경우 파라미터에 MobileUser 애노테이션이 붙이 있고 파라미터 타입이 MobileUserInfo인 경우 true를 리턴하도록 구현했다.


resolveArgument() 메서드는 파라미터에 전달할 객체를 생성한다. 이 예에서는 employeeId와 cellphone 값을 요청 헤더나 요청 파라미터에서 읽어와 MobileUserInfo를 생성하고, @MobileUser 애노테이션의 check 값에 따라 값 검사를 수행한 뒤에, 검사에 통과하면 MobileUserInfo를 리턴한다.


WebMvcConfigurer로 설정하기


마지막으로 준비할 작업은 커스텀 HandlerMethodArgumentResolver를 사용하도록 스프링 MVC를 설정하는 것이다. @EnableWebMvc나 스프링 부트를 사용한다면 다음과 같이 WebMvcConfigurer 구현 클래스를 사용해서 커스텀 HandlerMethodArgumentResolver를 등록하면 된다.


@Configuration

public class WebMvcCustomConfiguration extends WebMvcConfigurerAdapter {


    @Override

    public void addArgumentResolvers(

            List<HandlerMethodArgumentResolver> argumentResolvers) {

        argumentResolvers.add(new MobileUserInfoResolver());

    }

    

}



커스텀 인자 사용


이제 컨트롤러 메서드의 파라미터로 커스텀 인자 타입을 사용하면 된다.


@PostMapping("/api/handover")

public HandoverPk postHandover(

        @MobileUser(check = MobileUserCheck.ALL) MobileUserInfo mobUserInfo,

        @Valid DigWorkHandoverRequest req) {

    req.setEmployeeId(mobUserInfo.getEmployeeId());

    req.setCellphone(mobUserInfo.getCellphone());


    return handoverService.handoverDigWork(req);

}


@GetMapping(value = "/api/checks")

public yCheckData getCheckData(@MobileUser MobileUserInfo userInfo) {

    CheckData checks = checkService.getChecks(userInfo.getEmployeeId());

    return checks;

}


Spring Data Jpa의 Speicfication을 애용하는 편인데, 이 Specification을 사용해서 조건을 조합하다보면 다음과 같은 코드를 종종 작성하게 된다. (관련 내용은 http://javacan.tistory.com/entry/SpringDataJPA-Specifcation-Usage 참고)


Specifications<Check> specs = Specifications.where(

    CheckSpecs.yearQuarter(searchRequest.getYear(), searchRequest.getQuarter()));


if (searchRequest.hasTeamCd())

    specs = specs.and(CheckSpecs.teamCd(searchRequest.getTeamCd()));

if (searchRequest.hasPlanDate())

    specs = specs.and(CheckSpecs.planDate(searchRequest.getPlanDate()));


List<Check> checks = checkRepository.findAll(specs);


if 절과 각 Spec을 and로 엮는 코드가 실수하기 좋게 되어 있다. 이를 보완하고자 SpecBuilder라는 보조 클래스를 하나 만들었다. 이 클래스를 사용하면 위 코드를 다음과 같이 변경할 수 있다.


Specification<Check> spec = SpecBuilder.builder(Check.class)

        .and(CheckSpecs.yearQuarter(searchRequest.getYear(), searchRequest.getQuarter()))

        .whenHasText(searchRequest.getTeamCd(), str -> CheckSpec.teamCd(str))

        .whenHasText(searchRequest.getPlanDate(), CheckSpec::planDate)

        .toSpec();


.List<Check> checks = checkRepository.findAll(specs);


단순히 and로 조합하는 경우, if를 사용할 때보다 코드를 보기가 더 좋아졌다.


SpecBuilder의 완전한 코드는 다음과 같다.


public class SpecBuilder {


    public static <T> SpecSupplier<T> builder(Class<T> type) {

        return new SpecSupplier<T>();

    }


    public static class Builder<T> {

        private List<Specification<T>> specs = new ArrayList<>();


        public Builder<T> and(Specification<T> spec) {

            specs.add(spec);

            return this;

        }


        public Builder<T> whenHasText(String str, 

                 Function<String, Specification<T>> specSupplier) {

            if (StringUtils.hasText(str)) {

                specs.add(specSupplier.apply(str));

            }

            return this;

        }


        public Builder<T> when(String str, 

                 Function<String, Specification<T>> specSupplier) {

            specs.add(specSupplier.apply(str));

            return this;

        }

        

        public Builder<T> whenHasTextThenBetween(String from, String to,

                BiFunction<String, String, Specification<T>> specSupplier) {

            if (StringUtils.hasText(from) && StringUtils.hasText(to)) {

                specs.add(specSupplier.apply(from, to));

            }

            return this;

        }


        public Builder<T> whenIsTrue(Boolean cond,

                Supplier<Specification<T>> specSupplier) {

            if (cond != null && cond.booleanValue()) {

                specs.add(specSupplier.get());

            }

            return this;

        }


        public Specification<T> toSpec() {

            if (specs.isEmpty())

                return Specifications.where(null);

            else if (specs.size() == 1)

                return specs.get(0);

            else {

                return specs.stream().reduce(

                        Specifications.where(null),

                        (specs, spec) -> specs.and(spec),

                        (specs1, specs2) -> specs1.and(specs2));

            }

        }

    }

}


최근 진행하는 프로젝트는 DB 관련 기술로 JPA를 사용하고 있는데, 연동 대상은 레거시 DB이다. 레기서 DB답게 복합키를 갖는 테이블이 다수 존재한다. 아래는 한 예이다.



이 테이블은 특정 업무의 점검 결과를 저장할 때 사용하는데(간결함을 위해 이름을 변경하고 칼럼수도 줄였다), CHECK_H는 점검 결과에 대한 마스터 테이블이고, CHECK_D는 각 세부 점검 항목의 결과를 저장하는 테이블이다. CHECK_H와 CHECK_D는 1:N의 관계를 갖는다.


그림에서 보는 것처럼 CHECK_H의 PK는 네 개의 칼럼을 구성되어 있으며, CHECK_D는 이 네 칼럼을 참조한다.


검사 항목은 20여개 정도 되는데, 각 항목을 그룹으로 나눠서 표현한다. 각 항목이 속한 그룹을 저장하는 칼럼이 GROUP이고 검사 항목을 저장하는 칼럼이 ITEM이며, 그 결과를 저장한 칼럼이 RSLT이다. 화면에 결과를 표시할 때에는 GROUP과 ITEM을 오름차순으로 정렬해서 출력한다.


CHECK_H와 CHECK_D는 개념적으로 하나의 검사 결과를 의미하므로, 이 둘과 매핑되는 모델은 하나의 애그리거트에 포함된다. 매핑할 애그리거트를 다음과 같이 만들었다.



CHECK_D는 별도 라이프사이클이 없고 CHECK_H에 종속되어 있으므로 CHECK_D에 매핑되는 Detail을 밸류로 표현했고, Check를 애그리거트의 루트로 했다. 복합키를 사용하므로 복합키에 해당하는 식별자 클래스인 CheckId도 따로 만들었다.


Detail 클래스와 CHECK_D 테이블의 매핑 설정


Detail은 밸류이므로 CHECK_D의 주요키와 매핑할 필요가 없으므로 밸류가 가져야 할 속성만 정의했다.


@Embeddable

public class Detail {

    private String group;

    private String item;

    private String rslt;


    ...

}


Check 클래스와 CHECK_H 테이블의 매핑 설정


복합키를 위한 CheckId는 다음과 같다.


@Embeddable

public class CheckId implements Serializable {

    

    @Column(name = "JOIN_NUM")

    private String joinNum;


    @Column(name = "PATH_FLAG")

    private String pathFlag;


    @Column(name = "JOIN_YMD")

    private String joinYmd;


    @Column(name = "RSLT_FLAG")

    private String rsltFlag;

}


다음은 Check 클래스 설정이다.


@Entity

@Table(name = "CHECK_H")

public class Check {

    @EmbeddedId

    private CheckId id;


    @Column(name = "FROM_TIME")

    private String fromTime;


    @Column(name = "TO_TIME")

    private String toTime;


    @ElementCollection(fetch = FetchType.EAGER)

    @CollectionTable(name = "CHECK_D", joinColumns = {

            @JoinColumn(name = "JOIN_NUM", referencedColumnName = "JOIN_NUM"),

            @JoinColumn(name = "PATH_FLAG", referencedColumnName = "PATH_FLAG"),

            @JoinColumn(name = "JOIN_YMD", referencedColumnName = "JOIN_YMD"),

            @JoinColumn(name = "RSLT_FLAG", referencedColumnName = "RSLT_FLAG") })

    @org.hibernate.annotations.OrderBy(clause = "GROUP asc, ITEM asc")

    private Set<CheckDetail> details = new LinkedHashSet<>();



@CollectionTable의 joinColumns 속성을 사용해서 CHECK_D에서 CHECK_H를 참조할 때 사용하는 조인 칼럼을 지정했다. Check가 필요한 기능에서 CheckDetail도 함께 사용하기에 @ElementCollection의 fetch 속성을 EAGER로 설정했다. 화면에서 GROUP과 ITEM을 오름차순 기준으로 정렬해서 보여주기 때문에, 하이버네이트의 @OrderBy 애노테이션을 사용해서 값을 정렬했다.



현재 참여하고 있는 프로젝트는 레거시 시스템과 관련이 있다. CHAR 타입 칼럼의 기본값으로 공백문자(' ')를 지정한 테이블이 많아서 기존 코드를 보면 TRIM 처리를 하는 쿼리가 많이 있다. 현재 프로젝트는 JPA를 사용하고 있는데, 조회 결과에서 불필요한 공백을 제거할 필요가 생겼다. 이를 위해 EntityListener와 커스텀 애노테이션을 사용했다.


@Trim 커스텀 애노테이션


DB에서 읽어온 값을 trim해야 하는 대상을 설정하기 위해 @Trim 애노테이션을 추가했다.


@Target({ ElementType.FIELD })

@Retention(RUNTIME)

public @interface Trim {

}


JPA 엔티티 리스너(entity listener) 메서드 구현


엔티티 단위로 @Trim 애노테이션이 붙은 필드에 대해 trim 처리를 수행하기 위해 JPA 엔티티 리스너 메서드를 구현했다. 엔티티 리스너 메서드는 크게 별도 클래스로 구현하거나 엔티티 메서드로 구현하는 두 가지 방법이 존재하는데 범용적으로 적용하기 위해 별도 클래스로 구현했다. 구현 클래스는 아래와 같다.


import javax.persistence.PostLoad;


public class TrimEntityListener {


    @PostLoad

    public void postLoad(Object entity) {

        Field[] fields = entity.getClass().getDeclaredFields();

        for (Field field : fields) {

            if (field.isAnnotationPresent(Trim.class)) {

                field.setAccessible(true);

                try {

                    Object value = field.get(entity);

                    if (value != null && value instanceof String) {

                        String s = (String) value;

                        field.set(entity, s.trim());

                    }

                } catch (Exception ex) {

                }

            }

        }

    }

}


@PostLoad 애노테이션은 엔티티를 로딩한 후에 호출할 메서드를 설정한다. @PostLoad 외에도 엔티티 라이프사이클에 맞춘 @PrePersist, @PostPersist, @PreUpdate, @PostUpdate 등의 애노테이션이 존재한다.


postLoad() 메서드의 entity 파라미터를 로딩한 엔티티 객체이다. postLoad() 메서드는 엔티티 객체의 필드 중에서 @Trim 애노테이션이 존재하는 필드를 찾아서 값에 대해 trim 처리를 한다.


엔티티 리스너 클래스 적용


이제 엔티티 리스너 클래스를 사용하도록 설정만 하면 된다. 전체 엔티티를 대상으로 엔티티 리스너 클래스를 적용하고 싶다면 JPA 설정 파일(스프링 부트를 사용한다면 META-INF/orm.xml 파일)의 <entity-listener> 태그에 엔티티 리스너 클래스를 설정하면 된다.


<?xml version="1.0" encoding="UTF-8"?>


<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"

  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm

                    http://xmlns.jcp.org/xml/ns/persistence/orm_2_1.xsd"

  version="2.1">


  <persistence-unit-metadata>

    <persistence-unit-defaults>

      <entity-listeners>

        <entity-listener class="jpa.TrimEntityListener" />

      </entity-listeners>

    </persistence-unit-defaults>

  </persistence-unit-metadata>

  

</entity-mappings>


엔티티별로 엔티티 리스너 클래스를 적용할 수도 있다. @EntityListener 애노테이션을 사용해서 각 엔티티 클래스에 사용할 엔티티 리스너를 지정하면 된다.


import javax.persistence.EntityListeners;


@Entity

@EntityListeners({TrimEntityListener.class})

public class Division {


    @Trim

    private String location;

    ...

}


TrimEntityListener는 @PostLoad 메서드를 정의하고 있으므로, 엔티티를 로딩할 때마다 @PostLoad 메서드를 사용해서 @Trim이 붙은 필드 값을 trim 처리할 수 있다.

스프링부터 다중 데이터소스 설정 방법은 간단하다. 먼저 application.properties 파일에 "구분.datasource"로 시작하는 데이터소스 설정을 추가한다. 다음은 예이다.



# datasource 1

spring.datasource.driver-class-name=oracle.jdbc.OracleDriver

spring.datasource.url=jdbc:oracle:thin:@10.xx.xx.xx:SID

spring.datasource.username=dbuser

spring.datasource.password=dbpw


spring.datasource.tomcat.initialSize=5

spring.datasource.tomcat.maxActive=40

spring.datasource.tomcat.maxIdle=40

spring.datasource.tomcat.minIdle=5

spring.datasource.tomcat.maxWait=5

spring.datasource.tomcat.validationQuery=select 1+1 from dual

spring.datasource.tomcat.testWhileIdle=true

spring.datasource.tomcat.timeBetweenEvictionRunsMillis=60000


# datasource 2

sms.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver

sms.datasource.url=jdbc:sqlserver://10.xx.xx.xx:1433;databaseName=SMS

sms.datasource.username=dbuser1

sms.datasource.password=dbpw1


sms.datasource.tomcat.initialSize=1

sms.datasource.tomcat.maxActive=20

sms.datasource.tomcat.maxIdle=20

sms.datasource.tomcat.minIdle=1

sms.datasource.tomcat.maxWait=1

sms.datasource.tomcat.validationQuery=select 1

sms.datasource.tomcat.testWhileIdle=true

sms.datasource.tomcat.timeBetweenEvictionRunsMillis=60000


이 코드는 spring.datasource로 시작하는 데이터소스와 sms.datasource로 시작하는 데이터소스를 설정했다. "구분.datasource.tomcat"으로 시작하는 설정은 톰캣 DBCP 설정이다.


스프링부트는 별도 설정을 하지 않으면 spring.datasource로 시작하는 설정만 데이터소스로 사용하므로, 다중 데이터소스를 사용하려면 별도 설정을 추가해야 한다. 다음은 다중 데이터소스를 위한 자바 설정 예이다.


@Configuration

public class DataSourceConfiguration {


    @Bean

    @ConfigurationProperties(prefix = "spring.datasource")

    public DataSourceProperties dataSourceProp() {

        return new DataSourceProperties();

    }


    @Primary

    @Bean(name = "dataSource")

    @ConfigurationProperties(prefix = "spring.datasource.tomcat")

    @Qualifier("primary")

    public DataSource dataSource() {

        return dataSourceProp().initializeDataSourceBuilder().build();

    }


    @Primary

    @Bean(name = "transactionManager")

    @Qualifier("primary")

    public PlatformTransactionManager transactionManager() {

        return new DataSourceTransactionManager(dataSource());

    }


    @Bean

    @ConfigurationProperties(prefix = "sms.datasource")

    public DataSourceProperties smsDataSourceProp() {

        return new DataSourceProperties();

    }


    @Bean(name = "smsDataSource")

    @ConfigurationProperties(prefix = "sms.datasource.tomcat")

    @Qualifier("sms")

    public DataSource smsDataSource() {

        return smsDataSourceProp().initializeDataSourceBuilder().build();

    }


    @Bean(name = "smsTransactionManager")

    @Qualifier("sms")

    public PlatformTransactionManager smsTransactionManager() {

        return new DataSourceTransactionManager(smsDataSource());

    }



각 데이터소스 설정별로 세 개의 빈을 설정했다.

  • 설정 프로퍼티를 담은 DataSourceProperties 빈
    • @ConfigurationProperties를 사용해서 접두어를 지정(예, smsDataSourceProp() 빈 설정은 @ConfigurationProperties의 prefix 값으로 sms.datasource 사용)
  • DataSourceProperties 빈을 이용해서 DataSource 빈 생성
    • DataSourceProperties의 initializeDataSourceBuilder() 메서드를 이용해서 생성해야 tomcat 설정이 적용
  • 각 DataSource 마다 트랜잭션관리자 생성
    • 글로벌 트랜잭션을 사용해야 하면 JTA 트랜잭션 설정


스프링의 @EnableCache 설정과 @Cacheable 설정을 사용하면 매우 쉽게 레디시를 캐시로 사용할 수 있어서 편리하다. 한 가지 불편한 점이 있다면 캐시키를 JDK 직렬화를 사용해서 저장한다는 것이다. 그러다보니 상상한 레디스 키가 cache:1:2:3" 문자열이어도(캐시 이름 cache, 키 "1:2:3") 실제 레디스에 들어간 키는 "cache:타입데이터1:2:3" 이런 모양이 된다. 이는 레디스에 직접 연결해서 데이터를 확인할 때 불편함을 준다.


JDK 직렬화 대신 문자열로 키를 생성하는 KeySerializer를 사용하면 이 단점을 보완할 수 있다. 이를 하려면 직접 RedisTemplate을 생성해주면 된다. 다음은 코드 구현 예이다.


import java.net.UnknownHostException;


import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.context.annotation.Primary;

import org.springframework.data.redis.connection.RedisConnectionFactory;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.data.redis.serializer.StringRedisSerializer;


@Configuration

public class RedisTemplateConfiguration {


    @Bean

    @Primary

    public RedisTemplate<Object, Object> redisTemplate(

            RedisConnectionFactory redisConnectionFactory)

            throws UnknownHostException {

        RedisTemplate<Object, Object> template = new RedisTemplate<Object, Object>();

        template.setConnectionFactory(redisConnectionFactory);

        template.setKeySerializer(new StringRedisSerializer());

        return template;

    }

}


StringRedisSerializer는 키 값을 직렬화할 때 String.getBytes()를 사용하므로 JDK 직렬화를 사용할 때처럼 불필요한 타입정보가 붙지 않는다.

+ Recent posts