728x90
반응형
FROM gradle:8-jdk17-alpine as builder
WORKDIR /build

# 그래들 파일이 변경되었을 때만 새롭게 의존패키지 다운로드 받게함.
COPY build.gradle settings.gradle /build/
RUN gradle build -x test --parallel --continue > /dev/null 2>&1 || true

# 빌더 이미지에서 애플리케이션 빌드
COPY . /build
RUN gradle build -x test --parallel

# APP
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# 빌더 이미지에서 jar 파일만 복사
COPY --from=builder /build/build/libs/my-app-*-SNAPSHOT.jar .

EXPOSE 8080

CMD java -jar ./my-app-*-SNAPSHOT.jar

이렇게 설정해주면 도커 이미지의 크기를 많이 줄일 수 있다.

 

참고 

Gradle을 사용할 때 도커 빌드를 빠르게 하는 방법 - Soo Story (findstar.pe.kr)

728x90
반응형
728x90
반응형

build.gradle 에 Next.js 를 추가하는 방법은 다음과 같다.

// npm install
task appNpmInstall(type: Exec) {
    workingDir "$projectDir/projectname"
    inputs.dir  "$projectDir/projectname"
    if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains('windows')) {
        commandLine "npm.cmd", "install"
    } else {
        commandLine "npm", "install"
    }
}

//npm build
task npmBuild(type: Exec) {
    dependsOn("appNpmInstall")
    workingDir "$projectDir/projectname"
    inputs.dir "$projectDir/projectname"
    if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains('windows')) {
        commandLine "npm.cmd", "run", "build"
    } else {
        commandLine "npm", "run", "build"
    }
}
// build 경로를 webpack으로 미리 설정 하였으므로, build 결과 이동 관련 Task 제외
compileJava.dependsOn("npmBuild")
728x90
반응형
728x90
반응형

여러개의 쓰레드를 만들고, 각각의 쓰레드에 스케쥴링을 한다고 할 때 다음과 같이 관리할 수 있다.

@Component
public class SchedulerServiceImpl implements SchedulerService {
    private final Logger LOGGER = LoggerFactory.getLogger(this.getClass());
    private static final Map<String, ThreadPoolTaskScheduler> scheduledMap = new HashMap<>();
    private String cron = "*/10 * * * * *";

    @Override
    public void startScheduler(Sample sampleType) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.initialize();
        // scheduler setting
        scheduler.schedule(getRunnable(sampleType), getTrigger());
        scheduledMap.put(sampleType.getSampleType(), scheduler);
    }

    @Override
    public void setCron(String cron) { this.cron = cron; }

    @Override
    public void stopScheduler(Sample sampleType) { scheduledMap.get(sampleType.getSampleType()).shutdown(); }

    private Runnable getRunnable(Sample sampleType) {
        // do something
        Runnable scheduleExecService;
        switch (sampleType){
            case APPLE:
                scheduleExecService = new ScheduleExecAppleServiceImpl();
                break;
            case SAMSUNG:
                scheduleExecService = new ScheduleExecSamsungServiceImpl();
                break;
            default:
                scheduleExecService = new ScheduleExecSamsungServiceImpl();
                break;
        }
        return scheduleExecService;
    }

    private Trigger getTrigger() {
        // cronSetting
        return new CronTrigger(cron);
    }
}

 

클래스를 따로 만들지 않고 다음과 같이 구현할 수도 있다.

    private static final Map<Long, ThreadPoolTaskScheduler> scheduledMap = new HashMap<>();
    
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.initialize();
        scheduler.schedule(new Runnable() {
            @Override
            public void run() {
               service.write(dto);
            }
        }, new CronTrigger("* * * * * *"));

 

쓰레드가 생성될 때에 맞춰 스케줄러를 생성해주고, 쓰레드가 종료될 때 같이 종료해주면 된다.

스케줄러들은 맵으로 관리한다.

 

스케줄러의 ThreadPool을 따로 관리하고 싶으면 다음을 추가하면 된다.

@Configuration
class SchedulerConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();

        threadPoolTaskScheduler.setPoolSize(5);
        threadPoolTaskScheduler.setThreadGroupName("scheduler thread pool");
        threadPoolTaskScheduler.setThreadNamePrefix("scheduler-thread-");
        threadPoolTaskScheduler.initialize();

        taskRegistrar.setTaskScheduler(threadPoolTaskScheduler);
    }
}
728x90
반응형
728x90
반응형

1. 해당 Thread를 시작할 때 getId() 메소드로 Thread id를 데이터베이스에 담아둔다.

 

2. 아래 코드로 Id로 찾아서 종료해준다.

Set<Thread> setOfThread = Thread.getAllStackTraces().keySet();
            for(Thread thread : setOfThread){
                if(thread.getId() == 종료하는쓰레드ID()){
                    thread.interrupt();
                }
            }
728x90
반응형
728x90
반응형

이번엔 Consumer 서버를 만들어보고 Producer 서버에서 생성한 토픽을 구독하여 읽어보기로 하자.

 

1. application.properties

server.port=8081
spring.kafka.consumer.bootstrap-servers=localhost:9092
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeSerializer
spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeSerializer

logging.level.org.apache.kafka=ERROR
spring.kafka.bootstrap-servers=localhost:9092
  • auto-offset-reset: 가장 이른 것은 소비자가 가장 이른 이벤트부터 읽는다는 것을 의미
  • key-deserializer 및 value-deserializer는 메시지를 보내기 위해 Kafka 생산자가 보낸 키와 값을 역직렬화하는 역할

2. Main

Main에서 @KafkaListener를 사용하여 아래처럼 구현한다.

@SpringBootApplication
@Slf4j
public class ConsumerApplication {

	public static void main(String[] args) {
		SpringApplication.run(ConsumerApplication.class, args);
	}

	@Bean
	public NewTopic topic() {
		return TopicBuilder.name("one-topic")
			.partitions(10)
			.replicas(1)
			.build();
	}

	@KafkaListener(id = "myGroup", topics = "one-topic")
	public void listen(String in) {
		log.info("message={}",in);
	}
}

 

3. postmain으로 producer 서버에서 요청을 보낸 후 consumer 서버에서 확인한다.

 

 

 

- 예제

@Service
@Slf4j
public class service {

    /**
     * 일반 리스너
     */

    @KafkaListener(topics = "test", groupId = "test-group-00")
    public void recordListener(ConsumerRecord<String, String> record) {
        log.info(record.toString());
        // 기본적인 리스너선언 방식으로, poll()이 호출되어 가져온 레코드들을 차례대로 개별 레코드의 메시지 값을 파라미터로 받게 된다.
        // 파라미터로 컨슈머 레코드를 받기 때문에 메시지 키, 메시지 값에 대한 처리를 이 메서드 안에서 수행하면 된다.
    }

    @KafkaListener(topics = "test", groupId = "test-group-01")
    public void singleTopicListener(String messageValue) {
        log.info(messageValue);
        // 메시지 값을 파라미터로 받는 리스너
    }

    @KafkaListener(topics = "test", groupId = "test-group-02", properties = {"max.poll.interval.ms:60000", "auto.offset.reset:earliest"})
    public void singleTopicWithPropertiesListener(String messageValue) {
        log.info(messageValue);
        // 별도의 프로퍼티 옵션값을 선언해주고 싶을 때 사용한다.
    }

    @KafkaListener(topics = "test", groupId = "test-group-03", concurrency = "3")
    public void concurrentTopicListener(String messageValue) {
        log.info(messageValue);
        // 2개 이상의 카프카 컨슈머 스레드를 실행하고 싶을 때 concurrency 옵션을 활용할 수 있다.
        // concurrency값 만큼 컨슈머 스레드를 생성하여 병렬처리 한다.
    }

    @KafkaListener(topicPartitions = {
        @TopicPartition(topic = "test01", partitions = {"0", "1"}),
        @TopicPartition(topic = "test02", partitionOffsets = @PartitionOffset(partition = "0", initialOffset = "3")),
    })
    public void listenSpecificPartition(ConsumerRecord<String, String> record) {
        log.info(record.toString());
        // 특정 토픽의 특정 파티션만 구독하고 때 `topicPartitions` 파라미터를 사용한다.
        // `PartitionOffset` 어노테치션을 활용하면 특정 파티션의 특정 오프셋까지 지정할 수 있다.
        // 이 경우에는 그룹 아이디에 관계없이 항상 설정한 오프셋의 데이터부터 가져온다.
    }

    /**
     * 배치 리스너
     */

    @KafkaListener(topics = "test", groupId = "test-group-00")
    public void batchListener(ConsumerRecords<String, String> records) {
        records.forEach(record -> log.info(record.toString()));
        // 컨슈머 레코드의 묶음(ConsumerRecords)을 파라미터로 받는다.
        // 카프카 클라이언트 라이브러리에서 poll() 메서드로 리턴받은 ConsumerRecords를 리턴받아 사용하는 방식과 같다.
    }

    @KafkaListener(topics = "test", groupId = "test-group-01")
    public void singleTopicListener(List<String> list) {
        list.forEach(recordValue -> log.info(recordValue));
        // 메시지 값을 List형태로 받는다.
    }

    @KafkaListener(topics = "test", groupId = "test-group-02", concurrency = "3")
    public void concurrentTopicListener(ConsumerRecords<String, String> records) {
        records.forEach(record -> log.info(record.toString()));
        // 2개 이상의 컨슈머 스레드로 배치 리스너를 운영할 경우에 concurrency 옵션을 함께 선언하여 사용하면 된다.
    }
}

 

- Custom Container Factory

@Configuration
public class ListenerContainerConfiguration {
    
    @Bean
    public KafkaListenerContainerFactory<ConcurrentMessageListenerContainer<String, String>> customContainerFactory() {
        
        Map<String, Object> props = new HashMap<>();
        props.put(Consumerconfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:8080");
        props.put(Consumerconfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(Consumerconfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        
        DefaultKafkaConsumerFactory cf = new DefaultKafkaConsumerFactory<>(props);
        
        ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();  //  리스너 컨테이너를 만들기 위해 사용
        factory.getContainerProperties().setConsumerRebalanceListener(new ConsumerAwareRebalanceListener() {
            // 리밸런스 리스너를 선언하기 위해 setConsumerRebalanceListener 메서드를 호출한다.
            
            @Override
            public void onPartitionsRevokedBeforeCommit(Consumer<?, ?> consumer, Collection<TopicPartition> partitions) {
                // 커밋이 되기 전 리밸런스가 발생했을 때 호출되는 메서드
            }
            
            @Override
            public void onPartitionsRevokedAfterCommit(Consumer<?, ?> consumer, Collection<TopicPartition> partitions) {
                // 커밋이 일어난 이후 리밸런스가 발생했을 때 호출되는 메서드
            }
            
            @Override
            public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
                // 리밸런싱이 끝나서 파티션 소유권이 할당되고 나면 호출되는 메서드
            }
            
            @Override
            public void onPartitionsLost(Collection<TopicPartition> partitions) {
                
            }
            
        });
        
        factory.setBatchListener(false);
        factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD);
        factory.setConsumerFactory(cf);
        
        return factory;
    }
    
}

 

customContainerFactory를 사용해준다.

@KafkaListener(topics = "test", groupId = "test-group", containerFactory = "customContainerFactory")
public void customListener(String data) {
    log.info(data);
    // customContainerFactory 옵션을 커스텀 컨테이너 팩토리로 설정하여 사용한다.
}

 

- 참고 spring kafka 사용법 | D-log (leejaedoo.github.io)

728x90
반응형
728x90
반응형

1. application.properties

spring.kafka.producer.bootstrap-servers=localhost:9092
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer

logging.level.org.apache.kafka=ERROR
spring.kafka.bootstrap-servers=localhost:9092

위와 같이 application.properties를 설정해준다. 자세한 설정은 아래를 참고하자.


Kafka Publisher Configuration

  • bootstrap.servers
    • 연결할 서버 정보. e.g. : host1:port1,host2:port2와 같이 여러개를 나열할 수 있음
    • 초기 커넥션 연결시에 사용하기 때문에, 모든 서버 리스트를 포함할 필요는 없음. (실제 메시지 전송시에는 새로운 커넥션을 맺은 다음에 전송하기 때문)
  • key.serializer, value.serializer
    • 메시지를 serialize 할 때 사용할 클래스를 지정
    • ByteArraySerializer, StringSerializer 등등 Serializer를 implements한 클래스들이 있음
  • partitioner.class
    • 어떤 파티션에 메시지를 전송할지 결정하는 클래스임
    • 기본값은 DefaultPartitioner이며 메시지 키의 해시값을 기반으로 전송할 파티션을 결정함
  • acks
    • 프로듀서가 전송한 메시지를 카프카가 잘 받은 걸로 처리할 기준을 말함
    • 0, 1, all 값으로 세팅할 수 있으며 각각 메시지 손실률과 전송 속도에 대해 차이가 있음
    • 설정값 비교
      설정값 손실률 속도 설명
      acks = 0 높음 빠름 프로듀서는 서버의 확인을 기다리지 않고
      메시지 전송이 끝나면 성공으로 간주합니다.
      acks = 1 보통 보통 카프카의 leader가 메시지를 잘 받았는지만 확인합니다.
      acks = all 낮음 느림 카프카의 leader와 follower까지 모두 받았는지를 확인합니다.
    • 기본값은 acks=1 
  • buffer.memory
    • 프로듀서가 서버로 전송 대기중인 레코드를 버퍼링하는데 사용할 수 있는 메모리 양
    • 레코드가 서버에 전달될 수 있는 것보다더 빨리 전송되면 max.block.ms동안 레코드를 보내지 않음
    • 기본값은 33554432, 약 33MB임
  • retries
    • 프로듀서가 에러가 났을때 다시 시도할 횟수를 말함
    • 0보다 큰 숫자로 설정하면 그 숫자만큼 오류 발생시에 재시도 함
  • max.request.size
    • 요청의 최대 바이트 크기를 말합니다. 대용량 요청을 보내지 않도록 제한할 수 있음
    • 카프카 서버에도 별도로 설정할 수 있으므로 서로 값이 다를 수 있음
  • connections.max.idle.ms
    • 지정한 시간 이후에는 idle 상태의 연결을 닫음
  • max.block.ms
    • 버퍼가 가득 찼거나 메타데이터를 사용할 수 없을 때 차단할 시간을 정할 수 있음
  • request.timeout.ms
    • 클라이언트가 요청 응답을 기다리는 최대 시간을 정할 수 있음
    • 정해진 시간 전에 응답을 받지 못하면 다시 요청을 보내거나 재시도 횟수를 넘어서면 요청이 실패
  • retry.backoff.ms
    • 실패한 요청에 대해 프로듀서가 재시도하기 전에 대기할 시간
  • producer.type
    • 메시지를 동기(sync), 비동기(async)로 보낼지 선택할 수 있음
    • 비동기를 사용하는 경우 메시지를 일정 시간동안 쌓은 후 전송하므로 처리 효율을 향상시킬 수 있음

- 참고 Spring Boot에서 Apache Kafka 사용 ... 1/2 :: Modern Architecture Stories (tistory.com)

 

2. Controller

간단한 Controller를 만들자.

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/publish")
public class controller {

    private final KafkaTemplate<String, String> kafkaTemplate;

    @GetMapping
    public String publish() {

        kafkaTemplate.send("one-topic", "abc");
        return "success";
    }

}

 

3. postman으로 요청을 보내본다.

 

4. kafka 컨테이너에 접속해서 리스트를 보자.

정상적으로 토픽이 생성된 것을 볼 수 있다.

 

5. 토픽 내용을 확인한다.

# kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic one-topic --from-beginning

 

6. 토픽을 상세히 본다.

# kafka-topics.sh --bootstrap-server localhost:9092 --topic one-topic --describe

 

7. Custom KafkaTemplete

다른 템플릿을 사용하고 싶으면 다음과 같이 Config를 추가하여 의존성을 주입해주면 된다.

@Configuration
@RequiredArgsConstructor
public class KafkaProducerConfig {
    
    private final KafkaProperties kafkaProperties;

    @Bean
    public ProducerFactory<String, String> producerFactory() {
        Map<String, Object> configProps = new HashMap<>();
        configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaProperties.getBootstrapServers());
        configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        configProps.put(ProducerConfig.ACKS_CONFIG, "all");
        return new DefaultKafkaProducerFactory<>(configProps);
    }

    @Bean
    public KafkaTemplate<String, String> kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }

}

 

 

728x90
반응형
728x90
반응형

1. Docker 및 Docker compose 설치

 

Docker | 시작하기 :: 티포의개발일지 (tistory.com)

 

 

Docker | 시작하기

1. Docker 란? Docker란 컨테이너를 생성하고 관리하기 위한 도구이다. 여기서 컨테이너란 표준화된 소프트웨어 유닛을 말한다. 기본적으로 해당 코드를 실행하는데 필요한 종속성과 도구가 포함된

typo.tistory.com

 

 

Docker | Docker-Compose :: 티포의개발일지 (tistory.com)

 

Docker | Docker-Compose

1. Docker Compose 란? 'docker build'와 'docker run' 명령을 대체할 수 있는 도구 Dockerfile을 대체하지 않는다. 함께 작동한다. 이미지나 컨테이너를 대체하지 않는다. 다수의 호스트에서 다중 컨테이너를 관

typo.tistory.com

 

 

Docker | Docker-Compose

1. Docker Compose 란? 'docker build'와 'docker run' 명령을 대체할 수 있는 도구 Dockerfile을 대체하지 않는다. 함께 작동한다. 이미지나 컨테이너를 대체하지 않는다. 다수의 호스트에서 다중 컨테이너를 관

typo.tistory.com

 

2. Kafka 설치

 

docker-compose.yaml 파일을 생성한다.

version: '2'
services:
  zookeeper:
    image: wurstmeister/zookeeper
    container_name: zookeeper
    ports:
      - "2181:2181"
  kafka:
    image: wurstmeister/kafka
    container_name: kafka
    ports:
      - "9092:9092"
    environment:
      KAFKA_ADVERTISED_HOST_NAME: 127.0.0.1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

 

- 설명

version: '2' //docker-compose 버전 지정
services: //docker-compose의 경우 docker 컨테이너로 수행될 서비스들은 services 하위에 기술
  zookeeper: //서비스 이름. service 하위에 작성하면 해당 이름으로 동작
    image: wurstmeister/zookeeper //도커 이미지
    container_name: zookeeper
    ports: //외부포트:컨테이너내부포트
      - "2181:2181" 
  kafka: 
    image: wurstmeister/kafka
    container_name: kafka
    ports: //외부포트:컨테이너내부포트
      - "9092:9092"
    environment://kafka 브로터를 위한 환경 변수 지정
      KAFKA_ADVERTISED_HOST_NAME: 127.0.0.1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 //kafka가 zookeeper에 커넥션하기 위한 대상을 지정
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

 

아래 명령어를 실행한다.

$ docker-compose up -d

 

Docker Desktop으로 확인해본다.

 

3. Kafka 테스트

 

생성된 Kafka Container에 접속한다.

$ docker container exec -it kafka bash

 

토픽을 생성한다. 

# kafka-topics.sh --create --topic test-topic --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1

 

프로듀서를 실행한다.

// # bin/kafka-console-producer.sh --broker-list localhost:9092 --topic test-topic
# kafka-console-producer.sh --broker-list localhost:9092 --topic pro-topic

 

 

컨슈머를 실행한다.

# kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test-topic --from-beginning

 

토픽 목록을 조회한다.

# kafka-topics.sh --list --bootstrap-server localhost:9092

토픽을 삭제한다.

# kafka-topics.sh --delete --topic test-topic --bootstrap-server localhost:9092

 


Zookeeper를 사용하지 않고 Docker Contrainer 쓰는 법

  kafka:
    image: bitnami/kafka:3.4
    container_name: kafka
    ports:
      - "9092:9092"
    environment:
      - ALLOW_PLAINTEXT_LISTENER=yes
      - KAFKA_BROKER_ID=1
      - KAFKA_CFG_PROCESS_ROLES=broker,controller
      - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER
      - KAFKA_CFG_LISTENERS=CONTROLLER://:9093,PLAINTEXT://:9092
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      - KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR=1
      - KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true
      - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@kafka:9093
      - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092
      - KAFKA_CFG_DELETE_TOPIC_ENABLE=true
      - KAFKA_CFG_BROKER_ID=1
      - KAFKA_CFG_NODE_ID=1
      - KAFKA_ENABLE_KRAFT=yes
      - TZ=Asia/Seoul
728x90
반응형
728x90
반응형

테스트 코드를 열심히 작성했고, 이제 얼마나 잘 작성했는지 보기위해 Jacoco를 써보기로 했다.

 

1. Build gradle

build gradle에 아래 내용을 추가해주자.

 

plugins {
	id 'jacoco'
}

jacocoTestReport {
    reports {
        html.enabled true
        xml.enabled false
        csv.enabled true

        html.destination file("jacoco/jacocoHtml")
        xml.destination file("jacoco/jacoco.xml")
    }

    def Qdomains = []
    for(qPattern in "**/QA" .. "**/QZ"){
        Qdomains.add(qPattern+"*")
    }

    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it,
                    exclude: [] + Qdomains)
        }))
    }

    finalizedBy 'jacocoTestCoverageVerification'
}

jacocoTestCoverageVerification {
    def Qdomains = []
    for (qPattern in "*.QA".."*.QZ") {  // qPattern = "*.QA","*.QB","*.QC", ... "*.QZ"
        Qdomains.add(qPattern + "*")
    }
    violationRules {
        rule {
            // 'element'가 없으면 프로젝트의 전체 파일을 합친 값을 기준으로 한다.
            limit {
                // 'counter'를 지정하지 않으면 default는 'INSTRUCTION'
                // 'value'를 지정하지 않으면 default는 'COVEREDRATIO'
                minimum = 0.30
            }
        }

        rule {
            // 룰을 간단히 켜고 끌 수 있다.
            enabled = true

            // 룰을 체크할 단위는 클래스 단위
            element = 'CLASS'

            // 브랜치 커버리지를 최소한 90% 만족시켜야 한다.
            limit {
                counter = 'BRANCH'
                value = 'COVEREDRATIO'
                minimum = 0.90
            }

            // 라인 커버리지를 최소한 80% 만족시켜야 한다.
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }

            // 빈 줄을 제외한 코드의 라인수를 최대 200라인으로 제한한다.
            limit {
                counter = 'LINE'
                value = 'TOTALCOUNT'
                maximum = 200
            }

            // 커버리지 체크를 제외할 클래스들
            excludes = [
//                    '*.test.*',
            ] + Qdomains
        }
    }
}

 

2. gitignore

### jacoco ###
jacoco/

 

3. Test

테스트 코드를 동작해본다.

 

4. index.html

위 사진 경로에 생성된 index.html 파일을 열어본다.

참고

Gradle 프로젝트에 JaCoCo 설정하기 | 우아한형제들 기술블로그 (woowahan.com)

 

Gradle 프로젝트에 JaCoCo 설정하기 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요. 상품시스템팀에서 서버 개발(..새발)을 하고 있는 연철입니다. 프로젝트 세팅 중에 찾아보고 삽질했던 내용들이 도움이 될까 하여 남깁니다. JaCoCo는 Java 코드의 커버리지

techblog.woowahan.com

 

728x90
반응형
728x90
반응형

Spring에서 API 문서를 만들 때 Swegger, Restdocs를 쓴다. 가장 큰 차이는 Restdocs는 테스트코드가 필수라는 점이다.

TDD가 처음엔 귀찮은 작업일 순 있지만 향후 유지보수를 위해선 오히려 나를 편하게 해주는 작업이기 때문에 테스트코드 기반의 RestDocs를 사용하기로 했다.

 

RestDocs 공식 사이트

Spring REST Docs

 

1. 환경설정

Spring Boot를 이용할 땐 RestDocs를 가져오면 알아서 build gradle에 추가해준다. 

아닐 경우엔 구글에 검색하면 최신 버전에 맞게끔 설정하는 내용이 많을테니 검색해보자.

 

아래는 추가되어야 할 것들 ( 버전에 따라 다를 수도 있습니다. )

plugins {
    id "org.asciidoctor.jvm.convert" version "3.3.2"//RestDoc
}

ext {
    set('snippetsDir', file("build/generated-snippets"))
}

configurations {
    asciidoctorExt
}

asciidoctor {//RestDoc
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'
    dependsOn test
}

dependencies {
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' //RestDoc
    
}

ext {
    snippetsDir = file('build/generated-snippets')//RestDoc
}

test {
    outputs.dir snippetsDir
    useJUnitPlatform()
}


// asccidoctor 작업 이후 생성된 HTML 파일을 static/docs 로 copy
task copyDocument(type: Copy) {
    dependsOn asciidoctor
    from file("build/docs/asciidoc")
    into file("src/main/resources/static/docs")
}

// 참고사항 //
// 공식 문서에서는 위의 ascidoctor.doFirst부터 아래 내용은 없고 이와 같은 내용만 있습니다.
// 이렇게 하면 jar로 만들어 질때 옮겨지는 것으로 IDE로 돌릴 때는 build 폴더에서만 확인이 가능합니다.
// 위 방법을 사용하면 IDE에서도 static으로 옮겨진 것을 확인할 수 있습니다.
// 위에 방법을 사용하든 아래 방법을 사용하든 편한 선택지를 사용하시면 됩니다.
//bootJar {//RestDoc
//    dependsOn asciidoctor
//    from("${asciidoctor.outputDir}/html5") {
//        into 'static/docs'
//    }
//}

tasks.named('test') {
    outputs.dir snippetsDir
    useJUnitPlatform()
}

tasks.named('asciidoctor') {
    inputs.dir snippetsDir
    dependsOn test
}

 

2. 테스트 코드 작성하기

아래 코드가 일반적인 mockMvc를 사용한 테스트 코드이다.

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

@SpringBootTest
@Slf4j
@AutoConfigureRestDocs
@AutoConfigureMockMvc
class EmsConfigControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @Transactional
    void test() throws Exception {
        this.mockMvc.perform(MockMvcRequestBuilders.get("/user"))
            .andExpect(status().isOk());
            .andDo(
                document("user-get")
            )
    }
}

 

만약 Spring Sequrity RBAC를 사용 중이라면 authentication 을 위해 다음 메서드를 추가해주자.

ROLE이 "ROLE_ADMIN" 이라면 "ADMIN" 을 추가하면 된다. 여러 개를 콤마로 사용할 수도 있다.

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;

    void test() throws Exception {
        this.mockMvc.perform(MockMvcRequestBuilders.get("/user").with(user("admin").password("password").roles("ADMIN","USER")))
            .andExpect(status().isOk());
            .andDo(
                document("user-get")
            )
    }

 

먼저 Roles에 따라서 api에 접근이 잘 되는지 확인해보자.

 

3. Request Response Document Description

Request와 Response를 명세해주기 위한 메서드를 사용해준다. 필요에 따라서 작성해주면 된다.

		.andDo( // rest docs 문서 작성 시작
                        document("member-get", // 문서 조각 디렉토리 명
                                pathParameters( // path 파라미터 정보 입력
                                        parameterWithName("id").description("Member ID") 
                                ),
                                responseFields( // response 필드 정보 입력
                                        fieldWithPath("id").description("ID"),
                                        fieldWithPath("name").description("name"),
                                        fieldWithPath("email").description("email")
                                )
                        )
                )

pathParameter 나 requestFields는 다음과 같이 할 수 있다.

 

- Path Parameters 

spring에서는 Path value를 사용할 때 MockMvcRequestBuilders 보다 RestDocumentationRequestBuilders를 선호한다.

import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;

        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/user/{id}",1L).with(user("admin").roles("CONFIG_READ")))
            .andExpect(status().isOk())
            .andDo(
                document("findUserByIndexId",
                    pathParameters(parameterWithName("id").description("User Id")),

 

- Query Parameters

Spring Rest Doc 3.0에 Query Parameter를 적용할 수 있는 방법이 추가되었다

this.mockMvc.perform(get("/users?page=2&per_page=100")) 
		.andExpect(status().isOk()).andDo(document("users", queryParameters(
				parameterWithName("page").description("The page to retrieve"), 
				parameterWithName("per_page").description("Entries per page") 
		)));

 

- RequestFields

requestFields(fieldWithPath("id").description("EmsConfig Id")),

 

- Post Method

Post Method를 사용할 땐 다음과 같이 쓰자. ContentType과 content를 이용해 map을 String으로 넣어주면 된다.

Map<String, Object> map = new HashMap<>();
map.put("name", "홍길동");
map.put("phone", "010-5424-6542");
map.put("address", address);

ObjectMapper objectMapper = new ObjectMapper();


        this.mockMvc.perform(MockMvcRequestBuilders.post("/ems_config")
                .with(user("admin").roles("CONFIG_READ", "CONFIG_WRITE"))
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(map))

 

type을 지정해주고 배열이나 Json Object를 아래와 같이 사용할 수도 있다.

fieldWithPath("result").type(JsonFieldType.ARRAY).description("result"), // 배열
fieldWithPath("result.id").type(JsonFieldType.NUMBER).description("User Id"), // JSON 요소
fieldWithPath("result[].item").type(JsonFieldType.STRING).description("User Items"), // 배열 안의 JSON요소

 

만약 숨기고 싶은 필드가 있다면 아래와 같이 하자.

fieldWithPath("id").description("사용자 id").ignored()

 

이제 빌드를 해보면 기본적으로 다음과 같은 조각들이 default로 생성된다.

  • curl-request.adoc
  • http-request.adoc
  • httpie-request.adoc
  • http-response.adoc
  • request body
  • response body

 

테스트 코드에 따라 추가적인 조각이 생성될 수 있다.

  • response-fields.adoc
  • request-parameters.adoc
  • request-parts.adoc
  • path-parameters.adoc
  • request-parts.adoc

이제 우린 이 조각들을 가져다 쓰면서 문서를 작성 할 것이다.

4. index.adoc

adoc 파일을 쉽게 작성하기 위해 아래 플러그인을 설치해준다.

 

src/docs/index.adoc 파일을 만들고 다음과 같이 작성해보자.

= REST Docs
backtony.github.io(부제)
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left 
:toclevels: 2
:sectlinks:

[[User-API]]
== User API

[[User-단일-조회]]
=== User 단일 조회
operation::user-get[]

 

plugin을 통해 확인해보면 잘 나오는 것을 볼 수 있다.

 

 

operation을 사용할 때 

operation::user-get[]

위와 같은 방법으로 빈 배열을 할당하면 모든 것들이 보이지만,

operation::user-get[snippets='response-fields']

이렇게 하면 원하는 스니펫만을 보여줄 수도 있다.

 

또한 문서를 더 깔끔하게 작성하려면 아래 사이트를 참고해보자.

Asciidoc 기본 사용법 (narusas.github.io)

 

Asciidoc 기본 사용법

Asciidoc의 기본 문법을 설명한다

narusas.github.io

 

5. index.html

문서를 html로 보는 방법은 몇 가지가 있는데,  위에서 설치했던 plugin으로 볼 수 있다 

html버튼을 누르면 브라우저가 뜨고 파일이 생성되는 것을 볼 수 있다.

 


23.06.28 추가 내용

6. 리팩토링

테스트 코드의 andDo(document()) 부분의 중복을 삭제하는 리팩토링을 진행해보자.

먼저 디렉토리 이름을 따로 지정해주지 않아도 되도록 아래 Configuration을 선언해준다.

utils/RestDocsConfig.java

@TestConfiguration
public class RestDocsConfig {

    @Bean
    public RestDocumentationResultHandler write() {
        return MockMvcRestDocumentation.document(
            "{class-name}/{method-name}",
            Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
            Preprocessors.preprocessResponse(Preprocessors.prettyPrint())
        );
    }

    public static final Attribute field(
        final String key,
        final String value) {
        return new Attribute(key, value);
    }
}

 

테스트 코드에서 선언되는 값들의 중복을 제거하기 위해 다음을 만든다.

Utils/ControllerTest.java

@Disabled
@WebMvcTest({
        MemberController.class,
        CommonDocController.class
})
public abstract class ControllerTest {

    @Autowired protected ObjectMapper objectMapper;

    @Autowired protected MockMvc mockMvc;

    @MockBean protected MemberRepository memberRepository;

    protected String createJson(Object dto) throws JsonProcessingException {
        return objectMapper.writeValueAsString(dto);
    }
}

 

이젠 andDo(document()) 부분을 삭제하는 코드를 만들어준다.

이 코드는 모든 테스트에서 extends 될 것이다.

@SpringBootTest
@AutoConfigureRestDocs
@AutoConfigureMockMvc
@Disabled
@Import(RestDocsConfig.class)
@ExtendWith(RestDocumentationExtension.class)
public class RestDocsTestSupport {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    protected JwtUtil jwtUtil;

    @Autowired
    protected RestDocumentationResultHandler restDocs;

    @BeforeEach
    void setUp(final WebApplicationContext context,
        final RestDocumentationContextProvider provider) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .apply(
                MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
            .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함 
            .alwaysDo(restDocs) // pretty 패턴과 문서 디렉토리 명 정해준것 적용
            .addFilters(new CharacterEncodingFilter("UTF-8", true)) // 한글 깨짐 방지
            .build();
    }
}

 

여기서 만약 Spring Security를 적용하면 다음과같이 써야한다.

@BeforeEach
    void setUp(final WebApplicationContext context,
        final RestDocumentationContextProvider provider) throws ServletException {
        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();
        delegateProxyFilter.init(
            new MockFilterConfig(context.getServletContext(), BeanIds.SPRING_SECURITY_FILTER_CHAIN));
        
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .apply(
                MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
            .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함 
            .alwaysDo(restDocs) // pretty 패턴과 문서 디렉토리 명 정해준것 적용
            .addFilters(
                new CharacterEncodingFilter("UTF-8", true),
                delegateProxyFilter
            )
            .build();
    }

 

아래는 예시 코드이다.

class MemberControllerTest extends RestDocsTestSupport {

    @Test
    public void member_page_test() throws Exception {
        Member member = new Member("backtony@gmail.com", 27, MemberStatus.NORMAL);
        PageImpl<Member> memberPage = new PageImpl<>(List.of(member), PageRequest.of(0, 10), 1);
        given(memberRepository.findAll(ArgumentMatchers.any(Pageable.class))).willReturn(memberPage);

        mockMvc.perform(
                get("/api/members")
                        .param("size", "10")
                        .param("page", "0")
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(
                        restDocs.document(
                                requestParameters(
                                        parameterWithName("size").optional().description("size"), // 필수여부 false
                                        parameterWithName("page").optional().description("page") // 필수여부 false
                                )
                        )
                )
        ;
    }
    

    @Test
    public void member_create() throws Exception {
        MemberSignUpRequest dto = MemberSignUpRequest.builder()
                .name("name")
                .email("hhh@naver.com")
                .status(MemberStatus.BAN)
                .build();
        mockMvc.perform(
                post("/api/members")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(dto))
        )
                .andExpect(status().isOk())
                .andDo(
                        restDocs.document(
                                requestFields(
                                        // 앞서 작성한 RestDocsConfig의 field 메서드로 constraints를 명시
                                        fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하")),
                                        fieldWithPath("email").description("email").attributes(field("constraints", "길이 30 이하")),
                                        fieldWithPath("status").description("Code Member Status 참조")
                                )
                        )
                )
        ;
    }
}

 

7. Request, Response Fields 커스터마이징

/test/resources/org/springframework/restdocs/templates/asciidoctor/ 경로에 snippet 파일을 추가하면 default 표를 만들 수 있다.

/default-request-fields.snippet

|===
|필드|타입|필수값|설명|제한

{{#fields}}
|{{path}}
|{{type}}
|{{^optional}}true{{/optional}}
|{{description}}
|{{#constraint}}{{constraint}}{{/constraint}}

{{/fields}}
|===

constraints 같은 경우는 다음과 같이 추가할 수 있다.

fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하"))

 

/default-response-fields.snippet

|===
|필드|타입|설명

{{#fields}}
|{{path}}
|{{type}}
|{{description}}

{{/fields}}
|===

/default-path-parameters.snippet, /default-query-parameters.snippet

|===
|파라미터|설명

{{#parameters}}
|{{name}}
|{{description}}

{{/parameters}}
|===

 

8. Enum 코드 문서화

Enum 타입을 사용하기 위해선 로직에 사용하던 enum을 다음 interface에 implement 해주어야 한다.

Enumtype.java

public interface RestDocsEnumType {

    String getName();

    String getDescription();

}

 

아래는 예시 코드.

MemberStatus.java

@AllArgsConstructor
public enum MemberStatus implements EnumType {
    LOCK("일시 정지"),
    NORMAL("정상"),
    BAN("영구 정지");

    private final String description;

    @Override
    public String getDescription() {
        return this.description;
    }

    @Override
    public String getName() {
        return this.name();
    }
}

 

위는 참고했던 사이트에서 만든 파일들이고,

내가 직접 만들었던 파일들이다.

 

Enum에 관련한 snippet인 custom-response-fields.snippet을 만들어준다.

|===
|필드|타입|설명

{{#fields}}
|{{path}}
|{{type}}
|{{description}}

{{/fields}}
|===

 

위 패키지 경로대로 하나씩 클래스를 만들어준다.

 

utils/CustomResponseFieldsSnippet

public class CustomResponseFieldsSnippet extends AbstractFieldsSnippet {

    public CustomResponseFieldsSnippet(String type,
        PayloadSubsectionExtractor<?> subsectionExtractor,
        List<FieldDescriptor> descriptors, Map<String, Object> attributes,
        boolean ignoreUndocumentedFields) {
        super(type, descriptors, attributes, ignoreUndocumentedFields,
            subsectionExtractor);
    }

    @Override
    protected MediaType getContentType(Operation operation) {
        return operation.getResponse().getHeaders().getContentType();
    }

    @Override
    protected byte[] getContent(Operation operation) throws IOException {
        return operation.getResponse().getContent();
    }
}

 

utils/ApiResponseDto

@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {

    private T data;

    private ApiResponseDto(T data){
        this.data=data;
    }

    public static <T> ApiResponseDto<T> of(T data) {
        return new ApiResponseDto<>(data);
    }
}

 

utils/consts/EnumDocs

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnumDocs {
    // 문서화하고 싶은 모든 enum값을 명시
    Map<String,String> Sex;
    Map<String,String> memberStatus;
}

 

utils/consts/CommonDocController

@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {

    private T data;

    private ApiResponseDto(T data){
        this.data=data;
    }

    public static <T> ApiResponseDto<T> of(T data) {
        return new ApiResponseDto<>(data);
    }
}

 

utils/consts/CommonDocControllerTest

// restdocs의 get 이 아님을 주의!!
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;


class CommonDocControllerTest extends RestDocsTestSupport {

    @Test
    public void enums() throws Exception {
        // 요청
        ResultActions result = this.mockMvc.perform(
                get("/test/enums")
                        .contentType(MediaType.APPLICATION_JSON)
        );

        // 결과값
        MvcResult mvcResult = result.andReturn();

        // 데이터 파싱
        EnumDocs enumDocs = getData(mvcResult);

        // 문서화 진행
        result.andExpect(status().isOk())
                .andDo(restDocs.document(
                        customResponseFields("custom-response", beneathPath("data.memberStatus").withSubsectionId("memberStatus"), // (1)
                                attributes(key("title").value("memberStatus")),
                                enumConvertFieldDescriptor((enumDocs.getMemberStatus()))
                        ),
                        customResponseFields("custom-response", beneathPath("data.sex").withSubsectionId("sex"), 
                                attributes(key("title").value("sex")),
                                enumConvertFieldDescriptor((enumDocs.getSex()))
                        )
                ));
    }

    // 커스텀 템플릿 사용을 위한 함수
    public static CustomResponseFieldsSnippet customResponseFields
                                (String type,
                                 PayloadSubsectionExtractor<?> subsectionExtractor,
                                 Map<String, Object> attributes, FieldDescriptor... descriptors) {
        return new CustomResponseFieldsSnippet(type, subsectionExtractor, Arrays.asList(descriptors), attributes
                , true);
    }

    // Map으로 넘어온 enumValue를 fieldWithPath로 변경하여 리턴
    private static FieldDescriptor[] enumConvertFieldDescriptor(Map<String, String> enumValues) {
        return enumValues.entrySet().stream()
                .map(x -> fieldWithPath(x.getKey()).description(x.getValue()))
                .toArray(FieldDescriptor[]::new);
    }

    // mvc result 데이터 파싱
    private EnumDocs getData(MvcResult result) throws IOException {
        ApiResponseDto<EnumDocs> apiResponseDto = objectMapper
                                                .readValue(result.getResponse().getContentAsByteArray(),
                                                new TypeReference<ApiResponseDto<EnumDocs>>() {}
                                                );
        return apiResponseDto.getData();
    }
}

 

이렇게 만들고 빌드를 하면 아래와 같은 adoc 파일이 생기는데

 

Enum을 클릭해서 새 창을 띄우도록 하려면 아래와 같이 사용할 수 있다.

queryParameters(
                        parameterWithName("memberStatus").description(
                            "link:#enum-memberStatus[memberStatus,window=\"_blank\"]"),

 

 

 

 

 

 

 

 

728x90
반응형
728x90
반응형
setState({
      ...state,
      [e.target.id]: { ...state[e.target.id], [e.target.name]: e.target.value },
    });

위와 같은 방식으로 사용하고자 할 때, typescript에서 에러가 발생할 경우가 있다.

그럴 땐아래 코드를 인터페이스에 추가해주자.

 

interface abc  {
    [prop: string]: any;
}
728x90
반응형

+ Recent posts