주요글: 도커 시작하기

이전 글(스프링 웹플럭스(WebFlux) SSE(Server Sent Event) 구현 1)에서 작성한 SSE 예제는 현실적이지 않다. 서로 다른 브라우저에서 /stocks/123 으로 연결하면 서로 다른 값이 표시된다. 실제라면 둘 다 같은 값을 출력해야 한다. 그래서 이 글에서는 조금 더 현실적인 예를 만들어보려 한다. 여기서 만들 예는 사용자 ID별로 데이터를 전송하는 기능이다.

여기서 구현할 클래스는 세 개다.

  • UserChannel : 사용자 ID별로 메시지를 보내기 위한 채널이다. Flux를 이용해서 메시지를 전송한다.
  • UserChannels : 사용자 ID에 대한 UserChannel을 관리한다.
  • UserChannelApi : SSE를 사용자 ID 별로 메시지를 전송하는 기능을 제공한다.

UserChannel 클래스

UserChannel 클래스는 다음과 같다.

public class UserChannel {
    private Flux<String> flux;
    private FluxSink<String> sink;
    private UnicastProcessor<String> processor;

    public UserChannel() {
        processor = UnicastProcessor.create();
        this.sink = processor.sink();
        this.flux = processor.share();
    }

    public void send(String message) {
        sink.next(message);
    }

    public Flux<String> toFlux() {
        return flux;
    }
}

하나의 데이터를 여러 클라이언트에 전송하기 위해 UnicastProcessor와 Flux#share()를 사용해서 Flux를 생성했다. 이 코드는 데이터를 전송하기 위해 UnicastProcessor#sink()로 구한 FluxSink를 사용한다.

send() 메서드는 FluxSink#next() 메서드를 이용해서 데이터를 Flux에 데이터를 보낸다.

UserChannels 클래스

UserChannels 클래스는 UserChannel을 맵으로 관리한다.

public class UserChannels {
    private ConcurrentHashMap<Long, UserChannel> map = new ConcurrentHashMap<>();

    public UserChannel connect(Long userId) {
        return map.computeIfAbsent(userId, key -> new UserChannel());
    }

    public void send(Long userId, String message) {
        UserChannel userChannel = map.get(userId);
        if (userChannel != null) {
            userChannel.send(message);
        }
    }
}

UserChannelApi 클래스

UserChannelApi가 제공하는 API는 두 개다.

  • GET /channels/users/{userId}/messages : 특정 사용자의 메시지를 수신하기 위한 SSE 구현
  • POST /channels/users/{userId}/messages : 특정 사용자에 메시지를 보내기 위한 API

다음은 두 API의 구현이다.

@RestController
public class UserChannelApi {
    private UserChannels channels = new UserChannels();

    @GetMapping("/channels/users/{userId}/messages")
    public Flux<ServerSentEvent<String>> connect(
            @PathVariable("userId") Long userId) {
        return channels.connect(userId) // UserChannel 리턴
            .toFlux() // Flux 리턴
            .map(str -> ServerSentEvent.builder(str).build());
    }

    @PostMapping(path = "/channels/users/{userId}/messages", 
                 consumes = MediaType.TEXT_PLAIN_VALUE)
    public void send(@PathVariable("userId") Long userId, 
                     @RequestBody String message) {
        channels.send(userId, message);
    }
}

connect() 메서드는 클라이언트를 특정 사용자 채널에 연결하고 채널의 메시지를 ServerSentEvent로 변환해서 클라이언트에 전송한다.

send() 메서드는 특정 사용자 채널에 메시지를 전송한다. @PostMapping 애노테이션의 consumes 값이 text/plain이므로 HTTP 요청 몸체로 받은 값을 문자열로 처리한다.

실행

구현을 했으니 동작을 확인할 차례다. 서버를 구동하고 크롬과 같이 SSE를 지원하는 브라우저를 두 개 띄운다. 그리고 Talend API Tester나 Postman 등 send() API를 호출하기 위한 도구를 준비한다. 참고로 서버 포트는 18080으로 변경했다.

첫 번째 브라우저에서 http://localhost:18080/channels/users/1/messages 에 접속한다. 그리고 POST로 데이터를 두 개 전송한다. 그러면 첫 번째 브라우저에 POST로 전송한 두 문자열이 표시되는 것을 확인할 수 있다.

두 번째 브라우저에서 접속한 뒤에 다시 POST로 메시지를 몇 개 더 전송한다. 아래 그림과 같이 첫 번째 브라우저와 두 번째 브라우저가 동일 데이터를 수신한 것을 확인할 수 있다.

 

Server Sent Event, 줄여서 SSE는 웹 서버에서 웹 브라우저로 이벤트를 푸시하고 싶을 때 유용하게 사용할 수 있다. 스프링 웹플럭스를 사용하면 간단하게 SSE를 구현할 수 있다. 이 글에서는 간단한 예를 이용해서 스프링 웹플럭스로 SSE를 어떻게 구현하는지 살펴보자.

[참고] 스프링 리액터에 대한 내용은 스프링 리액터 Reactor 기초 글 목록 글을 참고한다.

프로젝트 생성

먼저 스프링 부트 프로젝트를 생성한다. 이 글에서는 메이븐 프로젝트를 예로 사용했으며 pom.xml 파일은 다음과 같다. 웹플럭스 사용을 위해 spring-boot-starter-webflux를 추가했다.

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>sse-demo</groupId>
    <artifactId>sse-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>sse-demo</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>13</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

SSE 서버 구현

SSE 서버 구현은 간단하다. 요청 처리 메서드에서 ServerSentEvent를 제공하는 Flux를 리턴하기만 하면 된다. 다음은 구현 예이다.

@RestController
public class SimpleSSEApi {

    @GetMapping("/stocks/{code}")
    public Flux<ServerSentEvent<Stock>> stocks(@PathVariable("code") String code) {
        return Flux.interval(Duration.ofSeconds(1))
                .map(t -> Stock.builder()
                        .code(code)
                        .value(randomValue())
                        .build())
                .map(stock -> ServerSentEvent.builder(stock).build());
    }

    private int randomValue() {
        return ThreadLocalRandom.current().nextInt(1000) + 10000;
    }
}

이 코드에서 stocks()가 리턴하는 Flux는 1초 간격으로 ServerSentEvent를 제공한다. ServerSentEvent.builder() 메서드는 클라이언트에 전송할 이벤트 데이터를 받는다. 위 코드에서는 임의의 value 값을 갖는 Stock 객체를 이벤트 데이터로 사용했다.

자바 스크립트 구현

자바 스크립트는 EventSource를 이용해서 서버가 보내는 이벤트를 수신할 수 있다. 먼저 첫 화면 요청을 처리할 컨트롤러를 만든다. 이 컨트롤러는 '/' 요청이 오면 뷰로 index를 사용한다.

@Controller
public class IndexController {
    @GetMapping("/")
    public String index() {
        return "index";
    }
}

다음은 index 뷰로 사용할 index.html 파일이다. src/main/resxources/templates 폴더에 위치한 thymeleaf 파일이다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="utf-8">
    <title>단순 SSE</title>
</head>
<body>
    <div id="stockValue">
    </div>

    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"></script>
    <script type="text/javascript">
        var source = null;
        function start() {
            source = new EventSource("/stocks/1234");
            console.log("create EventSource");
            source.onmessage = function(ev) {
                console.log("on message: ", ev.data);
                $("#stockValue").text(ev.data);
            };
            source.onerror = function(err) {
                console.log("on err: ", err);
                stop();
            };
        }
        function stop() {
            if (source != null) {
                source.close();
                console.log("close EventSource");
                source = null;
            }
        }

        $(document).ready(function(){
            start();
        });
        $(window).on("unload", function () {
            stop();
        });
    </script>
</body>

</html>

EventSource는 SSE를 위한 표준 API이다. EventSource 객체를 생성할 때 SSE를 받을 서버 경로를 입력한다. 위 코드에서는 /stocks/1234를 경로로 지정했다.

EventSource#onmessage는 서버가 이벤트를 전송할 때 불린다. onmessage에 전달한 이벤트 처리 함수는 서버가 이벤트를 전송할 때마다 인자로 이벤트를 받는다. 서버가 전송한 데이터는 이벤트 객체의 data 속성에 담긴다. 예제에서는 서버가 전송한 데이터를 stockValue 영역에 표시하게 구현했다.

EventSource#onerror는 에러가 발생했을 때 불린다. 예제에서는 에러가 발생하면 EventSource#close()를 이용해서 연결을 끊도록 했다.

실행 결과

mvnw spring-boot:run 명령어로 서버를 실행한 뒤에 http://localhost:8080으로 접속해보자. 그러면 1초마다 stockValue 영역의 값이 바뀌는 것을 볼 수 있다.

서버의 로그 레벨을 debug로 변경하면 1초 간격으로 메시지를 생성하는 로그를 볼 수 있다.

2019-12-06 08:10:22.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@7769d30b]
2019-12-06 08:10:23.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@5efe1c65]
2019-12-06 08:10:24.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@5eb98269]
2019-12-06 08:10:25.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@67b3812f]
2019-12-06 08:10:26.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@5e7be759]
2019-12-06 08:10:27.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@70c8d5c7]
2019-12-06 08:10:28.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@6270bb7d]

 

정리

이 글에서는 간단하게 스프링 웹 플럭스로 SSE를 구현하는 방법을 살펴봤는데 다음 글(스프링 웹플럭스(WebFlux) SSE(Server Sent Event) 구현 2)에서는 조금 더 현실적인 예제를 만들어보자.

참고자료

 

MySQL이나 MariaDB에서 inet_aton/inet_ntoa 함수를 사용하면 문자열로 된 IP 주소를 정수로 저장할 수 있어 저장 용량을 줄이는데 도움이 된다. JPA를 사용하면 AttributeConverter를 사용해서 유사한 변환을 처리할 수 있다.

inet_aton과 inet_ntoa 구현

먼저 다음은 문자열과 정수 간 변환을 처리하는 코드이다.

object Inets {
    const val p3_256 = 256L * 256L * 256L
    const val p2_256 = 256L * 256L

    fun aton(ip: String?): Long? {
        if (ip == null) return null
        val vals: List<Int> = ip.split(".").filter { it.isNotEmpty() }.map { Integer.parseInt(it) }
        if (vals.isEmpty()) return null
        if (vals.size == 1) return vals[0].toLong()
        if (vals.size == 2) return vals[0] * p3_256 + vals[1]
        if (vals.size == 3) return vals[0] * p3_256 + vals[1] * p2_256 + vals[2]
        else return vals[0] * p3_256 + vals[1] * p2_256 + vals[2] * 256L + vals[3]
    }

    fun ntoa(num: Long?): String? {
        if (num == null) return null

        val d = num % 256
        val c = num / 256 % 256
        val b = num / (p2_256) % 256
        val a = num / (p3_256) % 256
        return "$a.$b.$c.$d"
    }
}

MySQL의 inet_aton()에 맞춰 구현했다. 예를 들어 inet_aton()은 "1.1"을 "1.0.0.1"과 동일한 값으로 변환한다. 또 "1.1.1"은 "1.1.0.1"과 같게 변환하고 "1"은 "0.0.0.1"과 같게 변환한다. 이 규칙에 맞게 Inets.aton()을 구현했다.

JPA 컨버터

다음은 Inets를 이용해서 구현한 JPA 컨버터이다.

import javax.persistence.AttributeConverter
import javax.persistence.Converter

@Converter
class InetConverter : AttributeConverter<String, Long> {
    override fun convertToDatabaseColumn(ip: String?): Long? {
        return Inets.aton(ip)
    }

    override fun convertToEntityAttribute(num: Long?): String? {
        return Inets.ntoa(num)
    }

}

사용

다음은 InetConverter를 사용한 코드 예이다.

@Convert(converter = InetConverter::class)
@Column(name = "reg_ip")
val ip: String?

 

스프링 데이터 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 애노테이션을 사용해서 값을 정렬했다.



+ Recent posts