'Programming/Spring Boot'에 해당하는 글 8건

데이터베이스에서 replication 은 데이터베이스간의 데이터 복제를 의미한다. 하나의 데이터베이스에서 읽기와 쓰기 작업을 동시에 진행할 경우 병목 현상이 발생할 확률이 높다. 이를 방지하기 위해 흔히 Master(Write) 와 Slave(Read) 로 나누어, Master 에서 변경된 데이터를 Slave 로 복제하는 것을 replication 이라고 한다.

 

Java 어플리케이션에서 분기 처리하는 방법은 커넥션 풀(DataSource) 을 Master 와 Slave 로 나누어 주고, JDBC 의 Connection.setReadOnly 메소드나 Spring 의 @Transactional 를 사용하는 것이다.

 

 

Spring Boot 에서의 read / write 분기 처리

 

1. DB 설정 (application.yml)

 

spring:
  datasource:
    master:
      hikari:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://dev.cluster-abc.rds.amazonaws.com:3306/kps
        read-only: false
        username: username
        password: password

    slave:
      hikari:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://dev.cluster-ro-abc.rds.amazonaws.com:3306/kps
        read-only: true
        username: username
        password: password

 

 

2. DataSource Bean 등록

 

@Configuration
public class DataSourceConfiguration {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.master.hikari")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }
}

 

 

3. AbstractRoutingDataSource 구현

: Read-Write 가 동시에 필요하거나 복제 지연으로의 이슈를 막기 위해 AbstractRoutingDataSource 구현.

 

public class routingDataSourceImpl extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) ? "slave" : "master";
    }
}

 

 

4. Lazy 설정

: LazyConnectionDataSourceProxy 설정으로 실제 쿼리 직전에 connection 을 생성하여 불필요한 커넥션 점유 방지.

 

@Configuration(proxyBeanMethods = false)
public class DataSourceConfiguration {

    @Primary
    @Bean
    public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }

    @Bean
    public DataSource routingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource,
                                        @Qualifier("slaveDataSource") DataSource slaveDataSource) {
        RoutingDataSourceImpl routingDataSourceImpl = new RoutingDataSourceImpl();
        Map<Object, Object> targetDataSource = new HashMap<>();
        targetDataSource.put("master", masterDataSource);
        targetDataSource.put("slave", slaveDataSource);
        routingDataSourceImpl.setTargetDataSources(targetDataSource);
        routingDataSourceImpl.setDefaultTargetDataSource(masterDataSource);
        return routingDataSourceImpl;
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.master.hikari")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }
}

 

 

5. @Transactional 명시

 

@Service
public class ExampleService {

    ...

    @Transactional(readOnly = true)
    public List<ExampleEntity> getList() {
        return exampleRepository.getList();
    }

    @Transactional  // default : readOnly = false
    public Integer insertExample() {
        return exampleRepository.insertExample();
    }
}

 

 

6. 테스트

 

위의 코드를 예로 들면 updateExample() 를 실행하여 데이터 삽입을 확인하고, 

getList() 를 실행했을 때 slave dataSource 가 구성되며 삽입한 데이터가 출력되면 성공.

 

...
HikariPool-1 - configuration:
HikariPool-1 - Starting...
...
HikariPool-2 - configuration:
HikariPool-2 - Starting...
...

 

 


WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

트랙백  0 , 댓글  0개가 달렸습니다.
secret

Spring Boot REST API 프로젝트에 API 문서 자동화 툴로 뭘 써야 하나...

 

 

Swagger vs Spring rest docs

 

  • Swagger - 적용 난이도 쉬움, 이쁜 UI, API 테스트 기능, 코드 기반
  • Spring rest docs - 적용 난이도 보통, 단순 UI, 테스트 기반

 

장단점이 분명하지만, 내부용으로 후딱 만들어 쓸 것이라면 Swagger. 외부에 제공해야 한다면 정갈해 보이는 Spring rest docs 이 아닐까 싶다.

 

 

문서화를 위한 gradle 구성

 

  • Step 1. 코드 테스트 -> 에러가 발생하지 않으면 해당 api 내용(snippets: asciidoc 파일) 생성.
  • Step 2. Test 로부터 생성된 snippets 을 import 하도록 api 문서 템플릿(asciidoc 파일) 을 생성하고 빌드하여 API 문서(html) 완성.
  • Step 3. 생성된 html 파일을 외부에 노출하도록 static 디렉토리에 복제.

 

plugins {
    id 'org.springframework.boot' version '2.5.6'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
    // gradle 7 이상은 org.asciidoctor.convert 대신 org.asciidoctor.jvm.convert 사용
    id 'org.asciidoctor.jvm.convert' version '3.3.2'  // Step 1
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

bootJar {  // Step 3
    dependsOn asciidoctor
    copy {
        from "${asciidoctor.outputDir}"
        into "${sourceSets.main.output.resourcesDir}/static/docs"
    }
}

asciidoctor {  // Step 2
    sourceDir 'src/main/asciidoc'
    attributes \
        'snippets': file('build/generated-snippets')
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  // Step 1
}

test {
    useJUnitPlatform()
}

 

이 build.gradle 파일은 가장 기본적인 세팅이므로 추후 익숙해지면 task 를 통합하거나 build 경로를 수정하여 커스터마이징 해서 편하게 쓰면 되겠다. 예를 들면...

 

asciidoctor {
    dependsOn test
    sourceDir 'src/main/asciidoc'
    outputDir "${sourceSets.main.output.resourcesDir}/static/docs"
    attributes \
        'snippets': file('build/generated-snippets')
}

 

3개의 task (단위 Test - html 생성 - html 복사) 를 하나로 통합한 예...

 

아무튼 build.gradle 파일을 수정했으면 JUnit 와 MockMvc 를 사용하여 Test 를 작성한다. (model, controller 생략)
MockMvc, REST Assured 중 서버 구동없이 컨트롤러 단위 테스트가 쉬운 MockMvc 를 사용한다.

 

@WebMvcTest(TestController.class)
@AutoConfigureRestDocs(outputDir = "build/generated-snippets")
public class TestControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void test() throws Exception {

        mockMvc.perform(RestDocumentationRequestBuilders.get("/book/{id}",1).accept(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andDo(document("book",
                        pathParameters(
                                parameterWithName("id").description("book unique id")
                        ),
                        responseFields(
                                fieldWithPath("id").description("book unique id"),
                                fieldWithPath("title").description("title")
                        )
                ))
                .andExpect(jsonPath("$.id", is(notNullValue())))
                .andExpect(jsonPath("$.title", is(notNullValue())));
    }
}

 

* 아래 에러 발생시:
urlTemplate not found. If you are using MockMvc did you use RestDocumentationRequestBuilders to build the request?

> pathParameters 을 사용할 때는 MockMvcBuilders.get 대신 RestDocumentationRequestBuilders.get 사용

 

Test 코드에서 document(identifier, ...) 에 기반하여 snippets 의 내용을 구성하게 되며, andExpect 에서 에러가 발생하지 않으면 outputDir 에 snippets 들이 생성된다.

 

curl-request.adoc
http-request.adoc
http-response.adoc
...

 

정상적으로 생성됐다면 이 snippets 들을 불러올 템플릿 asciidoc 파일을 생성한다. (/src/main/asciidoc/)

 

= Document Title
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toc-title: API 명세서
:toclevels: 4

[[api]]

== Book Api
api 에 관련된 설명을 이곳에 적습니다..

include::{snippets}/book/curl-request.adoc[]
include::{snippets}/book/http-request.adoc[]
include::{snippets}/book/path-parameters.adoc[]
include::{snippets}/book/http-response.adoc[]
include::{snippets}/book/response-fields.adoc[]

 

* asciidoc 파일 작성시 참고

https://docs.asciidoctor.org/asciidoc/latest/

https://narusas.github.io/2018/03/21/Asciidoc-basic.html

 

 

Asciidoc 기본 사용법

Asciidoc의 기본 문법을 설명한다

narusas.github.io

 

AsciiDoc Language Documentation :: Asciidoctor Docs

AsciiDoc is a lightweight and semantic markup language primarily designed for writing technical documentation. The language can be used to produce a variety of presentation-rich output formats, all from content encoded in a concise, human-readable, plain t

docs.asciidoctor.org

 

Spring REST Docs는 기본적으로 Asciidoctor 를 사용하지만, Markdown 을 사용할 수도 있다.

간략하게 이런식으로 작성하고 bootJar 를 실행하면 /build/docs/asciidoc/book.html 파일이 생성된 것을 확인할 수 있다.

 

spring-rest-docs

 

bootRun 을 실행하면 해당 파일이 /build/resources/main/static/docs 에 복사되며 http://localhost/docs/book.html 에서 확인 가능.

 

 


WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

트랙백  0 , 댓글  0개가 달렸습니다.
secret

Spring MVC 에서는 기본 error 페이지를 제공하지 않아 SimpleMappingExceptionResolver 를 사용해야 하지만, Spring Boot 는 기본적으로 BasicErrorController 를 설정하여 모든 error 를 처리하는 /error 매핑을 제공하며, 서블릿 컨테이너에 전역 error 페이지로 등록된다. 예로, Thymeleaf 를 사용할 경우 /error 와 매핑되는 페이지는 src/main/resources/templates/error.html 가 될 것이다.


시스템 클라이언트의 경우, HTTP 상태, error, 메시지를 담은 JSON 형식으로 응답한다. 브라우저 클라이언트의 경우, 위와 동일한 데이터를 HTML 형식으로 렌더링한 /error 페이지로 응답할 것이며, 존재하지 않을 경우 Spring boot 에서 제공하는 "whitelabel" error 페이지로 응답한다. whitelabel 에는 exception 정보가 명시되므로 다른 error 페이지로 대체하는 것이 좋다. (properties 파일에서 설정이 가능하다. server.error.whitelabel.enabled=false)


error 를 처리할 뷰를 추가하여 커스터마이징 할 수 있는데, @ExceptionHandler 와 @ControllerAdvice 같은 일반적인 Spring MVC 기능을 사용할 수 있다.

@ExceptionHandler 는 단일 컨트롤러 등에서, @ControllerAdvice 는 전역에서 exception 처리를 한다.



@ResponseStatus 과 HTTP 상태 코드 사용


일반적으로 웹 요청을 Spring boot 에서 처리할 때 정의되지 않은 exception 이 발생하면 서버는 HTTP 500 응답을 반환한다. @ResponseStatus 어노테이션을 메소드나 exception 클래스에 사용하면 지정한 상태 코드와 메시지를 반환할 수 있다. 아래는 404 exception 클래스를 만들고, 컨트롤러에 전달된 주문번호(id)가 존재하지 않으면 해당 exception 처리를 하는 예이다.


@ResponseStatus(value=HttpStatus.NOT_FOUND, reason="No such Order")  // 404
public class OrderNotFoundException extends RuntimeException {
    // Process Exception
}
 
@RequestMapping(value="/orders/{id}", method=GET)
public String showOrder(@PathVariable("id"long id, Model model) {
    Order order = orderRepository.findOrderById(id);
 
    // Call Exception
    if (order == nullthrow new OrderNotFoundException(id);
 
    model.addAttribute(order);
    return "orderDetail";
}
cs


이렇게 반환되는 에러 정보는 콘솔이나 /error 에 매핑되는 페이지에서 확인할 수 있으며, 없다면 whitelabel 페이지에서 확인할 수 있다.

RESTful 요청의 경우 다음과 같이 error 정보를 json 형식의 응답으로 보여준다.


{
    "timestamp""2018-11-08T05:22:05.440+0000",
    "status"404,
    "error""Not Found",
    "message""No such page",
    "path""/orders"
}
cs



@ExceptionHandler 사용


@RequestMapping 어노테이션을 가진 메소드에서 exception 을 처리하기 위해 동일한 컨트롤러에 @ExceptionHandler 메소드를 추가하여 사용할 수 있다. @ExceptionHandler 을 사용하면, @ResponseStatus 어노테이션이 없는 exception 도 다룰 수 있고, 별로도 작성한 error 페이지로 사용자를 리디렉션 시킬 수 있다. @ExceptionHandler 는 HttpServletRequest, HttpSession, WebRequest, Locale, InputStream, OutputStream, Empty Model, ... 등의 여러가지 파라미터 type 을 가질 수 있다. 또한 ModelAndView, Model, Map, View, String, @ResponseBody, HttpEntiry<?>, ResponseEntity<?>, void, ... 등의 type 을 반환할 수 있다.


다음은 DataIntegrityViolationException 라는 미리 작성한 exception 클래스를 호출시켜 핸들러에서 처리하는 예제이다.


@Controller
public class ExceptionHandlingController {
    ...
    @RequestMapping("/dataIntegrityViolation")
    String throwDataIntegrityViolationException() throws SQLException {
        throw new DataIntegrityViolationException("Duplicate id");
    }
 
    @ResponseStatus(value = HttpStatus.CONFLICT, reason = "Data integrity violation"// 409
    @ExceptionHandler(DataIntegrityViolationException.class)
    public void conflict() {
        // Nothing to do
    }
}
cs



@ControllerAdvice 사용


@ControllerAdvice 를 사용하면 개별 컨트롤러뿐만 아니라 전체 어플리케이션에서 exception 처리를 할 수 있다.

모든 exception 의 기본 핸들러의 코드는 다음과 같다.


@ControllerAdvice
class GlobalDefaultExceptionHandler {
    public static final String DEFAULT_ERROR_VIEW = "error";
 
    @ExceptionHandler(value = Exception.class)
    public ModelAndView defaultErrorHandler(HttpServletRequest req, Exception e) throws Exception {
        // exception 에 @ResponseStatus 를 가진 경우 프레임워크가 처리하도록 한다.
        if (AnnotationUtils.findAnnotation (e.getClass(), ResponseStatus.class!= null)
            throw e;
 
        ModelAndView mav = new ModelAndView();
        mav.addObject("exception", e);
        mav.addObject("url", req.getRequestURL());
        mav.setViewName(DEFAULT_ERROR_VIEW);
        return mav;
    }
}
cs




WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

트랙백  0 , 댓글  0개가 달렸습니다.
secret

@Value 대신에 속성들의 타입을 명시하여 아래와 같이 @ConfigurationProperties 를 선언할 수 있다.

아래는 @ConfigurationProperties 로부터 사용자 메타데이터 파일을 쉽게 생성할 수 있도록 spring-boot-configuration-processor 종속성을 가져와서 properties 작성, getter/setter 정의, getter 를 사용하는 순으로 properties 의 속성값을 가져오는 예이다.


- properties

acme.enabled=false
acme.remote-address=192.168.1.234
...
acme.security.username=abc
acme.security.password=efg
...
cs


- build.gradle

dependencies {
   compile "org.springframework.boot:spring-boot-configuration-processor"
}
cs


- AcmeProperties.java

package com.example;
 
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
 
import org.springframework.boot.context.properties.ConfigurationProperties;
 
@Component
@ConfigurationProperties(prefix="acme")
public class AcmeProperties {
 
    private boolean enabled;
    private InetAddress remoteAddress;
    private final Security security = new Security();
    public boolean isEnabled() { ... }
    public void setEnabled(boolean enabled) { ... }
    public InetAddress getRemoteAddress() { ... }
    public void setRemoteAddress(InetAddress remoteAddress) { ... }
    public Security getSecurity() { ... }
 
    public static class Security {
        private String username;
        private String password;
        private List<String> roles = new ArrayList<>(Collections.singleton("USER"));
        public String getUsername() { ... }
        public void setUsername(String username) { ... }
        public String getPassword() { ... }
        public void setPassword(String password) { ... }
        public List<String> getRoles() { ... }
        public void setRoles(List<String> roles) { ... }
    }
}
cs


- MyService

@Service
public class MyService {
 
    private final AcmeProperties properties;
 
    @Autowired
    public MyService(AcmeProperties properties) {
        this.properties = properties;
    }
 
     //...
 
    @PostConstruct
    public void openConnection() {
        Server server = new Server(this.properties.getRemoteAddress());
        // ...
    }
}
cs


위 예에서 getter와 setter는 Spring MVC 와 마찬가지로 표준 Java Beans 프로퍼티 디스크립터를 통해 바인딩되기 때문에 일반적으로 필수이지만 setter 는 생략될 수도 있다. Project Lombok 을 사용하여 getter 와 setter 를 자동으로 추가하기도 한다.


Spring Boot 는 properties 정보를 @ConfigurationProperties Bean 에 바인딩할 때 다소 완화된 규칙을 사용하므로 properties 속성 이름과 Bean 속성 이름이 정확히 일치하지 않아도 된다. 일반적으로 properties 속성 이름의 단어 사이에 camel/Kebab case 를 사용하거나 언더바(_)로 이어주곤 하는데 이러한 이름들을 모두 동일하게 바인딩 시켜준다. @Value 는 엄격한 규칙 때문에 속성 이름이 일치하지 않으면 값을 불러올 수 없다.


  • acme.my-project.person.first-name (Kebab case)
  • acme.myProject.person.firstName (camel case)
  • acme.my_project.person.first_name (underscore)



@Validated


@ConfigurationProperties 클래스에 @Validated 어노테이션을 사용하여 유효성 검사를 시도할 수 있다.

해당 Properties 를 사용할 @Bean 메서드에 @Validated 를 사용하여 검증을 트리거 할 수도 있다.


@ConfigurationProperties(prefix="acme")
@Validated
public class AcmeProperties {
 
    @NotNull
    private InetAddress remoteAddress;
 
    @Valid
    private final Security security = new Security();
 
    // ... getters and setters
 
    public static class Security {
 
        @NotEmpty
        public String username;
 
        // ... getters and setters
    }
}
cs




WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

트랙백  0 , 댓글  0개가 달렸습니다.
secret

Spring Boot 사용시 특정 속성들을 별도로 만들어(외부화) 다른 환경에서도 동일하게 코딩을 할 수 있다.

일반적으로 공통적으로 사용되는 속성들을 정의하거나, 개발 환경(prod/dev) 등을 구분하고자 할 때 주로 사용된다.

그 방법으로 properties 파일이나, YAML 파일, 환경변수 및 명령행 인수 등을 사용할 수 있으며, 

워낙 다양한 방법들이 있다보니 중복되거나 하는 속성 값들에는 PropertySource 우선 순위가 작용한다.


  1. ~/.spring-boot-devtools.properties (devtools가 활성화시)
  2. @TestPropertySource (테스트시)
  3. @SpringBootTest (테스트시)
  4. Command line 파라미터
  5. SPRING_APPLICATION_JSON 속성 (환경 변수 또는 시스템 속성에 포함 된 인라인 JSON).
  6. ServletConfig 초기화 파라미터
  7. ServletContext 초기화 파라미터
  8. java:comp/env JNDI 속성
  9. System.getProperties() (Java System 등록 정보)
  10. OS 환경 변수
  11. RandomValuePropertySource (random.* 속성으로 랜덤값을 삽입할 때 유용)
  12. jar 외부의 프로필 별 속성(application-{profile}.properties 및 YAML 변형)
  13. jar 내부의 프로필 별 속성(application-{profile}.properties 및 YAML 변형)
  14. jar 외부의 속성 ( application.properties 및 YAML 변형)
  15. jar 내부의 속성 ( application.properties 및 YAML 변형)
  16. @Configuration 클래스의 @PropertySource
  17. SpringApplication.setDefaultProperties()


많기도 하여라. 나름이겠지만 사실상 사용하는건 @SpringBootTestapplication-{profile}.properties, @PropertySource 정도가 아닐까.

일반적으로 다음과 같은 방식으로 properties 파일을 작성하여 속성을 정의한다.


spring.profiles.active=dev,hsqldb

my.secret=${random.value}
my.number=${random.int}
my.bignumber=${random.long}
my.uuid=${random.uuid}
my.number.less.than.ten=${random.int(10)}
my.number.in.range=${random.int[1024,65536]}

app.name=MyApp
app.description=${app.name} is a Spring Boot application
cs


Spring Profiles 는 어플리케이션 구성의 일부를 분리하여 특정 환경에서만 사용할 수 있게 한다. 

@Component 나 @Configuration 은 @Profile 으로 표시되어 로드될 상황을 분리할 수 있다.


@Configuration
@Profile("production")
public class ProductionConfiguration {
    // ...
}
cs


나는 주로 YAML 보다는 properties 를 사용하므로 YAML 에 관련된 내용은 생략하겠다. 

properties 와 동일한 정의가 가능하지만, @PropertySource 로 값을 가져올 수 없다는 차이점 정도?


이렇게 설정된 속성들은 @Value("${property}") 어노테이션을 사용하여 값을 가져올 수 있지만, 여러 속성을 작성하거나 계층적으로 그룹화 된 여러 property 들을 관리할 때는 @Value("${property}") 보다는 @ConfigurationProperties 로 설정하여 바인딩 하는 것이 좋다.




WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

트랙백  0 , 댓글  0개가 달렸습니다.
secret