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
반응형

1. 의존성 추가

dependencies {
   runtimeOnly 'mysql:mysql-connector-java'
}

 

2. application properties

#Database
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.profiles.active=local
spring.datasource.url= jdbc:mysql://db-d618t-kr.vpc-pub-cdb.ntruss.com:3306/pay_calculator_test?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=nomu9032
spring.datasource.password=jmk1994**
spring.datasource.hikari.connection-timeout= 3000
spring.datasource.hikari.validation-timeout= 3000
spring.datasource.hikari.minimum-idle= 5
spring.datasource.hikari.max-lifetime= 240000
spring.datasource.hikari.maximum-pool-size= 20

# Hibernate setting
spring.jpa.database= mysql
spring.jpa.database-platform= org.hibernate.dialect.MySQL5InnoDBDialect
spring.jpa.hibernate.ddl-auto=validate
728x90
반응형
728x90
반응형

1. 버킷 생성

https://typo.tistory.com/entry/Nodejs-Multer-S3-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-AWS-S3-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B5%AC%ED%98%84-1

 

Node.js | Multer 를 이용한 AWS S3 파일 업로드 구현(1)

1. aws에 접속하여 IAM 사용자를 생성한다 2. 권한으로 AmazonS3FullAccess를 할당해준다. 3. 해당 Access key ID, Secret access key를 알고 있어야 한다. 4. aws s3 화면에 접속해서 버킷을 만들어준다. 5...

typo.tistory.com

 

2. build.gradle 의존성 추가

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

3. application.properties 추가

중요한 내용을 담기 때문에 .gitignore에 설정 파일을 추가해준다.

 

4. S3Config 

backend/aws/S3Config

package mobile.backend.aws;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.accessKey}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secretKey}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3 amazonS3Client() {
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

        return AmazonS3ClientBuilder
                .standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();
    }
}

 

5. WebConfig

Config 파일을 한 곳에서 관리한다면 Class를 추가해준다.

package mobile.backend;

...

@Configuration
@Import({AopConfig.class, RedisConfig.class, S3Config.class})
public class WebConfig implements WebMvcConfigurer {

...

 

6. S3Uploader

backend/aws/S3Uploader

package mobile.backend.aws;

import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.PutObjectRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Optional;

@Slf4j
@RequiredArgsConstructor    // final 멤버변수가 있으면 생성자 항목에 포함시킴
@Service
public class S3Uploader {
    private final AmazonS3Client amazonS3Client;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    // MultipartFile을 전달받아 File로 전환한 후 S3에 업로드
    public String upload(MultipartFile multipartFile, String dirName) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));
        return upload(uploadFile, dirName);
    }


    private String upload(File uploadFile, String dirName) {
        String fileName = dirName + "/" + uploadFile.getName();
        String uploadImageUrl = putS3(uploadFile, fileName);

        removeNewFile(uploadFile);  // 로컬에 생성된 File 삭제 (MultipartFile -> File 전환 하며 로컬에 파일 생성됨)

        return uploadImageUrl;      // 업로드된 파일의 S3 URL 주소 반환
    }

    // 1. 로컬에 파일생성
    private Optional<File> convert(MultipartFile file) throws  IOException {
        File convertFile = new File(file.getOriginalFilename());
        if(convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }

    // 2. S3에 파일업로드
    private String putS3(File uploadFile, String fileName) {
        amazonS3Client.putObject(
                new PutObjectRequest(bucket, fileName, uploadFile)
                        .withCannedAcl(CannedAccessControlList.PublicRead)	// PublicRead 권한으로 업로드 됨
        );
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    // 3. 로컬에 생성된 파일삭제
    private void removeNewFile(File targetFile) {
        if(targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        }else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    // s3 파일 삭제
    public void delete(String fileName) {
        log.info("File Delete : " + fileName);
        amazonS3Client.deleteObject(bucket, fileName);
    }
}

 

7. Controller

클라이언트(React) 에서 보낸 데이터입니다.

const config = {
    headers: {
        "content-type": "multipart/form-data",
    },
};

const formData = new FormData();

formData.append("file_object", fileObject);

await axios.post('/java/file/upload', formData, config)

 

backend/module/file/FileController

...

    @PostMapping("/upload")
    public CommonResponse<String> upload(
            @RequestParam("file_object") MultipartFile fileObject
    ) throws IOException {
        log.info("multipartFile={}", fileObject);
        return new CommonResponse<String>(
                true,
                service.upload(
                        fileObject
                ));
    }
    
...

 

8. Service

backend/module/file/FileService

...

	@Autowired
    private S3Uploader s3Uploader;

...

public String upload( MultipartFile fileObject ) throws IOException {

        if(fileObject.isEmpty()) {
        }
        else {
            String storedFileName = s3Uploader.upload(fileObject,"images");
            log.info("fileName={}", storedFileName);
        }
        return "ok";
    }

 

9. main 함수 파일에 다음을 추가해준다.

@SpringBootApplication
public class BackendApplication {

	static {
		System.setProperty("com.amazonaws.sdk.disableEc2Metadata", "true");
	}

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

}

 

서버를 키고 파일 저장을 시도해보면 아래와 같이 성공한 것을 볼 수 있다.

728x90
반응형
728x90
반응형

아래 옵션으로 편리하게 request 데이터를 확인할 수 있다.

 logging.level.org.apache.coyote.http11=debug

 

package hello.upload.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;
@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {

    @Value("${file.dir}")
    private String fileDir;

    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }

    @PostMapping("/upload")
    public String saveFile(@RequestParam String itemName,
                           @RequestParam MultipartFile file, HttpServletRequest
                                   request) throws IOException {
        log.info("request={}", request);
        log.info("itemName={}", itemName);
        log.info("multipartFile={}", file);
        if (!file.isEmpty()) {
            String fullPath = fileDir + file.getOriginalFilename();
            log.info("파일 저장 fullPath={}", fullPath);
            file.transferTo(new File(fullPath));
        }
        return "upload-form";
    }

}
728x90
반응형
728x90
반응형

1. 의존성 추가

// jwt
	implementation 'io.jsonwebtoken:jjwt:0.9.1'

 

2. JwtTokenManager 

backend/auth/JwtTokenManager

package web.backend.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtTokenManager {

    private static final String secret = "secretKey!!!";

    // 토큰 유효 기간
    public static final long JWT_TOKEN_VALIDITY = 60 * 60 * 24 * 1000L; //하루

    /**
     *  토큰 생성
     */
    public String generateToken(String id, Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)  // 정보 저장
                .setId(id)
                .setIssuedAt(new Date(System.currentTimeMillis()))  // 토큰 발행 시간 정보
                .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY)) // set Expire Time
                .signWith(SignatureAlgorithm.HS512, secret)// 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();
    }

    /**
     *  토큰 id 반환
     */
    public String getTokenIdFromToken(String token) {
        return getClaimFromToken(token, Claims::getId);
    }

    /**
     *  토큰이 만료되었는지 Boolean 반환
     */
    public Boolean isTokenExpired(String token) {
        final Date expiration = getClaimFromToken(token, Claims::getExpiration);
        return expiration.before(new Date());
    }

    /**
     *  토큰 자체에 대한 정보 추출(id, expire 등)
     */
    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token).getBody();// JWT payload 에 저장되는 정보단위
        return claimsResolver.apply(claims);
    }

    /**
     *  토큰 안의 모든 정보 추출
     */
    public Claims getClaimsFromToken(String token) {
        final Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token).getBody();// JWT payload 에 저장되는 정보단위
        return claims;
    }

}

 

3. Test

test/java/web/backend/auth/JwtTest

package web.backend.auth;

import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.HashMap;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.*;

@Slf4j
public class JwtTest {

    @Autowired
    JwtTokenManager jwtTokenProvider = new JwtTokenManager();

    Map<String, Object> map = new HashMap<>();

    @BeforeEach
    public void before() {
        map.put("userId","teepo");
    }

    @Test
    public void test() {

        // 토큰 생성
        String tokenString = jwtTokenProvider.generateToken("1", map);
        log.info("tokenString={}",tokenString);
        assertNotNull(tokenString);

        // 토큰 Id
        String tokenId = jwtTokenProvider.getTokenIdFromToken(tokenString);
        log.info("tokenId={}", tokenId);
        assertEquals("1",tokenId);

        // 토큰이 만료되었는지
        Boolean tokenExpired = jwtTokenProvider.isTokenExpired(tokenString);
        log.info("tokenExpired={}",tokenExpired);
        assertEquals(false, tokenExpired);

        // 토큰 안의 모든 정보
        Claims tokenClaims = jwtTokenProvider.getClaimsFromToken(tokenString);
        log.info("tokenClaims={}", tokenClaims);

        // before 메소드에서 생성한 데이터 조회
        String value = tokenClaims.get("userId").toString();
        log.info("userId={}",value);
        assertEquals("teepo", value);

    }
}

 

4. 로그 확인

 


 

이번엔 클라이언트한테 요청을 받고, 쿠키 안에 Token을 넣은 뒤 확인해보자. 코드는 전 포스트에서 이어진다.

 

1. Controller

backend/module/user/UserController

    @PostMapping("/test")
    public CommonResponse<String> jwtGenerateTest(HttpServletResponse response, @RequestBody User user) {
        return new CommonResponse<String>(true, userService.jwtTest(response,user));
    }

 

2. Service

backend/module/user/UserService

    JwtTokenManager jwtTokenManager = new JwtTokenManager();

    public String jwtTest(HttpServletResponse response ,User user) {

        Map<String, Object> tokenMap = new HashMap<>();

        tokenMap.put("userId", user.getUserId());

        String tokenString = jwtTokenManager.generateToken("token1", tokenMap);

        log.info("tokenValue={}",jwtTokenManager.getClaimsFromToken(tokenString));


        Cookie cookie = new Cookie("token1",tokenString);
        cookie.setMaxAge(86400000); // 하루

        response.addCookie(cookie);

        return "ok";
    }

 

3. Postman

하단에 Cookies 버튼을 누르면 확인할 수 있다.

728x90
반응형

+ Recent posts