카프카, 데이터 플랫폼의 최강자(정리) + Spring Cloud Stream 연동

hyeokjin·2022년 6월 17일
0
post-thumbnail

[카프카란?]

Apache Kafka(아파치 카프카)는

  • 대용량의 실시간 로그처리에 특화된 아키텍처 설계를 통하여 기존 메시징 시스템보다 우수한 TPS를 보여주고 있다.

Kafka의 broker는 topic을 기준으로 메시지를 관리한다.
Producer는 특정 topic의 메시지를 생성한 뒤 해당 메시지를 broker에 전달한다.
Broker가 전달받은 메시지를 topic별로 분류하여 쌓아놓으면, 해당 topic을 구독하는 consumer들이 메시지를 가져가서 처리하게 된다.

Kafka는 확장성(scale-out)과 고가용성(high availability)을 위하여 broker들이 클러스터로 구성되어 동작하도록 설계되어있다.
심지어 broker가 1개 밖에 없을 때에도 클러스터로써 동작한다.

클러스터 내의 broker에 대한 분산 처리는 Apache ZooKeeper가 담당한다.

Producer가 broker에게 다수의 메시지를 전송할 때 각 메시지를 개별적으로 전송해야하는 기존 메시징 시스템과는 달리,
다수의 메시지를 batch형태로 broker에게 한 번에 전달할 수 있어 TCP/IP 라운드트립 횟수를 줄일수 있다.
메시지를 기본적으로 메모리에 저장하는 기존 메시징 시스템과는 달리 메시지를 파일 시스템에 저장한다.
Kafka에서는 메시지를 파일 시스템에 저장하기 때문에 메시지를 많이 쌓아두어도 성능이 크게 감소하지 않는다.
또한 많은 메시지를 쌓아둘 수 있기 때문에, 실시간 처리뿐만 아니라 주기적인 batch작업에 사용할 데이터를 쌓아두는 용도로도 사용할 수 있다.

Kafka의 partition은 consumer group당 오로지 하나의 consumer의 접근만을 허용하며,
해당 consumer를 partition owner라고 부른다. 따라서 동일한 consumer group에 속하는 consumer끼리는 동일한 partition에 접근할 수 없다.

Kafka는 메모리에 별도의 캐시를 구현하지 않고 OS의 페이지 캐시에 이를 모두 위임한다.
OS가 알아서 서버의 유휴 메모리를 페이지 캐시로 활용하여 앞으로 필요할 것으로 예상되는 메시지들을 미리 읽어들여(readahead) 디스크 읽기 성능을 향상 시킨다.

Kafka의 메시지는 하드디스크로부터 순차적으로 읽혀지기 때문에 하드디스크의 랜덤 읽기 성능에 대한 단점을 보완함과 동시에
OS 페이지 캐시를 효과적으로 활용할 수 있다.
일반적으로 파일 시스템에 저장된 데이터를 네트워크로 전송할 땐 커널모드와 유저모드 간의 데이터 복사가 발생하게 된다.
Zero-copy 기법을 사용하면 위에서 언급한 커널모드와 유저모드 간의 불필요한 데이터 복사를 피할 수 있다

카프카는 모니터링, 실시산분석, 분산어플리케이션 등 많은 활용범위가 있다.
카프카 스트림즈 API등 이용하여 실시간 분석 수행 예제
파이프예제(IDE 툴에서) 프로그램 만들기(토폴로지 생성, 입력스트림, 출력스트림을 만들고 토픽을 통해 데이터 확인)
행 분리 예제(공백 분리)
단어빈도수 세기예제
KSQL를 통한 실시간데이터, 배치데이터 모두 sql을 이용한 계산된결과를 조회하여(스트림생성하여 넣어준다) 데이터전달등
많은 내용이 있지만 이번시간에는 근본적인 카프카의 내용에 살펴보겠다.

[데이터 플랫폼 최강자 카프카]

주키퍼는 카프카의 노드 관리를 해주고, 토픽의 offset 정보등을 저장하기 위해 필요하다
토픽은 카프카에 저장되는 데이터를 구분하기 위해서 사용하고 offset은 처리된 커밋되는 저장위치라고 생각하면 된다
보통 주키퍼 3대, 카프카 3대 클래스터로 구성되어있다
주키퍼 클래스터 루트경로 하위 z노드를 설정하여 여러대의 카프카 서버에 접근하여 상태를 관리할수 있다.(카프카 서버는 브로커라고도 한다.)
브로커에서는 프로듀서, 컨슈머가 존재하며 프로듀서 토픽 형태로 메시지를 전달한다.
또 프로듀서에서 토픽안에는 파티션을 설정하여 여러개의 메시지로 나누어 전송한다.
파티션이 하나일때는 순서대로 메시지가 전달되지만, 여러개의 파티션일경우 순서없이 메시지를 보낸다.
파티션을 늘리는건 간단하나, 줄이는건 하지못하여 자신의 어플리케이션 환경을 잘 살피고 신중하게 해야한다.

파티션 수에 따른 메시지 순서에 알아보자.
카프카는 각각의 파티션에 대해서만 순서를 보장한다.

ex)
파티션2개에, 메시지가 a b c 가 있다고하면,
파티션1: a c
파티션2: b

파티션1: b
파티션2: a c

이렇게 들어올수 있으나

파티션1: c
파티션2: a b

이런식은 불가능 하다.

브로커 프로듀서에서는 또 ack 설정 기능이 있다.
ack는 프로듀서 에서 메시지를 보내면 브로커에서는 이게 정상적으로 적재되었다는 신호를 보내게 되는것을 말한다
Replicas을 여러개 둔 상태에서 브로커의 acks 를 1로 지정했다면 브로커에서는 마스터에만 메시지가 정상 저장되면 ack를 보내게 되는 반면,
ack를 all 로 지정했다면 모든 Replicas에 메시지가 Replication 되어야만 ack 를 보내게 된다.
이는 즉, 메시지 손실우려가 있지만 처리속도를 최대치로 하거나, 처리속도가 다소 느려질지라도 손실없는 메시지 전송을 하느냐의 차이다.
자신의 구축 환경이 고성능을 중요하는지 고가용성이 중요한지에 따라 달라질 수 있다

프로듀서의 정보를 보면 앞서 말한 partition(토픽을 몇개로 나눌지를 의미) 와 replication-factor가 있다 (그 외 leader, ISR 등은 생략했다.)
replication-factor는 데이터(메시지)를 복제할 브로커 수를 뜻하는데
즉, replication-factor는 설정된 수 만큼, 한대의 카프카 서버 다운이되면 레플리케이션에 설정된 카프카 서버 다른한대가 구동될 수 있는 수를 의미한다.

예를들어 서버 1,2,3 총 3 대가 있다. replication-factor=3이면 그 중 리더를 선출한다. 리더에서 실제 메시지를 만들고 팔로워에는 만든 데이터가 복제된다고 보면된다.
브로커1(리더), 브로커2(팔로워), 브로커3(팔로워) 에서 브로커1이 어떤사유로 작동중지가되면 브로커2,3 중 팔로워가 리더로 바뀌고 서버가 구동되어 장애를 피할수 있다.

근데 또 여기서 자세히들어가면... 고성능, 고가용성을 필요에 따라 선택할 수 있는데, 브로커1 리더 고장시, 브로커2가 바로 리더로 바뀐다고했을때,
전송되는 메시지가 끊키지않기 때문에 메시지 손실이 없어 가용성측면에서는 좋다 하지만 리더를 선출하고 재구성하는 작업이 있으므로 메세지 전달이 느려 성능은 느려진다
반대로 메시지 전송을 브로커1에서의 서버가 다시 정상가동 할 때 까지 기다리면 리더 재구성은 안해도 되므로 성능은 빠르지만, 복구될때까지 전송되는 메시지가 누락이 있을 수 있어 가용성은 안좋을수 있다

또 레플리케이션 3대를 지정하더라도 ISR 설정을하면 설정한 서버 내에서 리더, 팔로워를 선출한다. 만약 카프카 서버 3대를 구동한다면 replication-factor는 서버 수 대로 맞추면 될거 같다.

프로듀서에서 메시지를 보내면 컨슈머에서 메시지를 확인한다.
프로듀서가 보내는게아닌, 컨슈머에서 브로커의 토픽을 가져다 쓴다. 컨슈머 각각이 파티션 메시지를 가져오고 컨슈머그룹을 만들어 컨슈머를 지정하여 토픽의 모든 메시지를 확인 할 수 있다.
그리고 각각의 서버에서 여러개의 컨슈머가 동작하도록 하기위해 노드의 concurrency를 늘려서 토픽의 파티션을 모두 활용 할 수 있도록 컨슈머의 수를 함께 늘려주면된다.

컨슈머에도 사실 알아야할것이 많다..
컨슈머 그룹에 컨슈머를 새로추가 하거나 ,장애복구 재기동시 리밸런싱이 일어나는데 offset을 참조한다.
offset은 브로커에 쌓여있는 연속된 데이터 중 컨슈머가 읽어갈 위치를 의미하는 것이다.

컨슈머에서 메시지를 처리한 뒤에 브로커로 정상적으로 처리되었다는 신호를 보내게 되는게 이를 offset commit 이라한다.
offset commit 은 기본적으로 자동커밋인데 이 경우 일정주기(default 5초)마다 poll() 한 마지막 offset을 commit 한다.
즉, 메시지 처리 여부와 무관하게 offset commit 이 먼저 수행될 수 있다.

만약 컨슈머에서 메시지 처리 도중 리밸런스가 발생할 경우 메시지는 전부 유실되게 된다. (메시지 처리 도중 앞서 offset 커밋이 먼저 수행되어 이후 데이터를 가져오려고 하니 리밸런스시 그 전 데이터가 유실됨)
poll() 한 메시지가 모두 처리 되더라도 다음번 자동커밋 주기 전에 리밸런스가 발생한다면 그 사이만큼 메시지가 중복으로 처리되게 된다.(메시지 처리는 되었으나 커밋은 아직 안한상태라 리밸런스시 전 데이터부터 가져옴)

헷갈리니 잘 살펴봐야한다..

참고) 메시지 중복을 피할수 있는 방법을 찾아보니
spring.kafka.consumer.enable-auto-commit = false
spring.kafka.listener.ack-mode = RECORD
등 설정을통해 중복을 최소화 할 수 있다고는 한다..

RECORD: 수신기에 의해 각 레코드가 처리된 후 offiset을 commit 한다.
BATCH : 다음 폴링 전에 직전까지 처리된 메시지 offiset을 commit 한다.
TIME: AckTime 마다 직전까지 처리된 메시지 offset을 commit 한다.
(AckTime 는 밀리세컨드초로 지정하며 기본값은 5초이다)
COUNT : AckCount 마다 직전까지 처리된 메시지 offset을 commit 한다.
(AckCount 기본값은 1이다)
COUNT_TIME : COUNT 또는 TIME 중 하나의 조건이 충족할 경우 메시지 offset을 commit 한다.
MANUAL : BATCH 방식과 비슷한데 다른 점은 Ack 처리를 컨슈머에서 직접 해줘야 한다.
MANUAL_IMMEDIATE : Ack 처리를 컨슈머에서 직접 해줘야 하고 즉시 offiset이 commit 된다.

이제 개념은 거의 살펴본거 같으니 리눅스 CLI 환경에서 주요 명령어들을 살펴보자

[topic]

토픽생성
./kafka-topics.sh --zookeeper localhost:2181 --topic <topic_name> --create --partitions <값> --replication-factor <값>

토픽리스트 조회
./kafka-topics.sh --zookeeper localhost:2181 --list

파티션, 리더, 복제본 및 ISR의 수와 함께 토픽에 대한 전체 설명을 제공
./kafka-topics.sh --zookeeper localhost:2181 --describe --topic <topic_name>

토픽삭제
./kafka-topics.sh --zookeeper localhost:2181 --topic<topic_name> --delete

[topic 데이터 보내기]

토픽으로 메시지생성 (커맨드를 이용하여 토픽으로 메시지를 보낼 수 있으며 각각의 라인이 하나의 메세지가 된다.)
./kafka-console-producer.sh --broker-list localhost:9092 --topic <topic_name>

토픽에 대한 키를 지정하여 메시지 전송, 여기서 key는 특정 파티션이고 value는 생산자가 토픽에 쓰는 메시지이다.
./kafka-console-producer.sh --broker-list localhost: 9092 --topic <topic_name> --property parse.key= true --property key.separator=,

입력: key, value
입력: 다른 키, 다른 값

참고: 'server.properties' 파일을 열어 'num.partitions=1'와 같은 PartitionCount 수정 및 ReplicationFactor 수정가능

[topic 데이터 받기]

토픽에대한 메시지확인 (커맨드를 이용해 메시지 가져오기)
./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic <topic_name>

특정 토픽에대한 전체메시지 확인(처음부터 즉, 컨슈머가 비활성화된 시간부터 모든 메시지를 읽을 수 있도록 Kafka 주제에 지시)
./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic <topic_name> --from-beginning

컨슈머 토픽 그룹지정
./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic <topic_name> --group <group_name>

(만약, 토픽1에 메시지가 one, two, three, four가 있고, 파티션이 2개면, 토픽1의 컨슈머에 메시지가 one, two 다시 조회하면 three, four가 있다. 컨슈머 그룹으로 묶어서 저장하는 전체 메시지를 보기 위함이다.)

컨슈머 그룹에서 처음부터 컨슈머의 메시지를 읽는 것을 알 수 있다.
동일한 명령이 한 번 더 실행되면 출력이 표시되지 않는다.
Apache Kafka에서 오프셋이 커밋되기 때문이다.
따라서 소비자 그룹이 작성될 때까지 모든 메시지를 읽은 다음에는 새 메시지만 읽는다.(컨슈머는 한번 메시지를 소비하면 끝)
('--from-beginning' 명령어는 처음부터 끝까지 group_name 그룹에대한 메시지 확인.)
./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic <topic_name> --group <group_name> --from-beginning

토픽에대한 키를 지정하여 지정된 키로 데이터를 읽을 수 있다.
./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic <topic_name> --from-beginning --property print.key=true --property key.seperator=,

[컨슈머 그룹]

이 명령은 모든 그룹을 나열하고, 그룹을 설명하고, 컨슈머 정보를 삭제하거나, 컨슈머 그룹 오프셋을 재설정하는 전체 문서를 제공한다.

컨슈머그룹리스트 조회
./kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list

컨슈머그룹상세조회
./kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group_name>

[오프셋 재설정]

kafka-consumer-groups은 오프셋을 재설정하는 옵션을 제공한다. 키워드(--reset-offsets)
오프셋 값을 재설정한다는 것은 사용자가 메시지를 다시 읽고 싶은 지점을 정의하는 것을 의미한다.
한 번에 하나의 컨슈머 그룹만 지원하며 그룹에 대한 활성 인스턴스가 없어야 한다.

오프셋을 재설정하는 동안 사용자는 세 가지 인수를 선택해야 한다.
1. 실행 옵션(An execution option)
2. 사양 재설정(Reset Specifications)
3. 범위(Scope)

두 가지 실행 옵션을 사용할 수 있다.
'--dry-run': 기본 실행 옵션으로, 이 옵션은 재설정해야 하는 오프셋을 계획하는 데 사용된다.
'--execute': 이 옵션은 오프셋 값을 업데이트하는 데 사용된다.

다음과 같은 재설정 사양을 사용할 수 있다.
'--to-datetime': datetime 에서 오프셋을 기준으로 오프셋을 재설정합니다. 사용된 형식은 	'YYYY-MM-DDTHH:mm:SS.sss'이다.
'--to-earliest': 오프셋을 가장 이른 오프셋으로 재설정한다.
'--to-latest': 오프셋을 최신 오프셋으로 재설정한다.
'--shift-by': 현재 오프셋 값을 'n'만큼 이동하여 오프셋을 재설정한다. 'n'의 값은 양수 또는 음수일 수 있다.
'--from-file': 오프셋을 CSV 파일에 정의된 값으로 재설정한다.
'--to-current': 오프셋을 현재 오프셋으로 재설정한다.

정의할 수 있는 두 가지 범위가 있다.
'--all-topics': 그룹 내에서 사용 가능한 모든 토픽에 대한 오프셋 값을 재설정한다.
'--topic': 지정된 토픽에 대해서만 오프셋 값을 재설정한다. 사용자는 오프셋 값을 재설정하기 위해 토픽 이름을 지정해야 한다.

ex)
'--to-earliest ' 명령 사용
오프셋은 0으로 새로운 오프셋으로 재설정된다. 이는 오프셋 값을 0으로 재설정한 ' -to-earliest ' 명령을 사용해서이다.
./kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group <group_name> --reset-offsets --to-earliest --excute --topic <topic_name>


' --shift-by ' 명령 사용
오프셋 값은 '0'에서 '+2'로 이동한다.
./kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group <group_name> --reset-offsets --shift-by 2 --excute --topic <topic_name>

기본개념을 접했으니,

Spring Cloud Stream 환경에서의 kafka Streams API를 살펴보고 카프카를 연동해보자!

[Spring Cloud Stream]

스프링 클라우드 스트림(spring-cloud-stream) 프로젝트는 스프링 기반으로 작성된 서비스에 매우 간단하게 메시징 기능을 추가할 수 있게 해준다.
몇 개의 어노테이션만 추가하면 서비스가 메시지 발행자(publisher) 혹은 소비자(consumer)로 동작할 수 있다.
또한 스프링 클라우드 스트림은 RabbitMQ, 아파치 카프카, 구글 PubSub 등 여러 메시징 플랫폼을 지원한다.

여기서 아파치 카프카(kafka)를 통한 메시징 구현을 해보자

먼저 알아야할 개념부터 정리해보자.

spring cloud stram application : 미들웨어에 직접 붙지않고 바인더 구현체를 중간에 두고 통신한다.

Binder : 외부 메시징 시스템과의 통합을 담당하는 구성 요소이다.
Binding(input/output) : 외부 메시징 시스템과 응용 프로그램 간의 브리지 (대상 바인더에서 생성 한 메시지 생성자 및 소비자)
Middleware : RabbitMQ, Kafka와 같은 메시지 시스템.

DwmsStream : 바인딩 채널
@input : 채널에서 메세지를 받아서 값을 출력
@output : 채널로 메세지를 보내는 코드

@StreamListener(DwmsStream.INPUT)
: 카프카에서 데이터를 받아옴(consumer)

@Payload
: 데이터를 담고 있는 파라미터

스프링 클라우드 스트림에서 바인딩 가능한 채널타입은
MessageChannel(outbound)
SubscribableChannel(inbound)

MessageBuilder withPayload
MessageBuilder 라는 것을 사용해서 편리하게 메시지를 생성할 수 있다.
두 가지 팩토리 메소드를 제공하는데,
하나는 기존 메시지로부터 메시지를 생성하기 위한 것이고
다른 하나는 페이로드 오브젝트로 부터 메시지를 생성하기 위한 것이다.
기존 메시지로부터 메시지를 생성할 경우 헤더 정보와 페이로드 정보는 새로운 메시지로 복사된다는 것을 기억하자.

개념을 알았으니 활용을 해보자!

[Spring Cloud Stream 2]

application.yml에서
주키퍼 및 카프카 서버를 연결시키고 파티션 및 레플리케이션팩터 등 설정구성을 한다
그리고 메시지를 보낼 토픽생성을 하고 필요한 나머지 설정도 완료한다.

원리로만 보면 spring cloud 프레임워크를 통해 브로커의 프로듀서 역할을 하여
해당 토픽에 맵핑된 메시지를 보내는 역할이다.

서버 기동시 토픽 및 컨슈머 그룹등이 'server.property'에 설정된 경로로 자동생성이 된다.

application.yml

// 브로커 및 주키퍼 구성 및 zNode 설정, 레플리케이션팩터 설정및 파티션 설정 트랜잭션 설정등
spring.cloud.stream.kafka.binder.brokers: 30.30.30.20:9092,30.30.30.21:9092,30.30.30.22:9092
spring.cloud.stream.kafka.binder.zkNodes: 30.30.30.20:2181,30.30.30.21:2181,30.30.30.22:2181/zNode
spring.cloud.stream.kafka.binder.replicationFactor: 3
spring.cloud.stream.kafka.binder.minPartitionCount: 2
spring.cloud.stream.kafka.binder.transaction.transaction-id-prefix: tx

// 스트림 데이터와 맵핑할 토픽이름(json타입으로 보낼예정)
spring.cloud.stream.bindings.<channelName>.destination: <topic_name>
spring.cloud.stream.bindings.<channelName>.contentType: application/json
// 스트림 데이터와 맵핑할 컨슈머 그룹이름
spring.cloud.stream.bindings.<channelName>.group: <group_name>

// 프로듀서 설정	
spring.kafka.producer.transaction-id-prefix: tx
spring.kafka.producer.retries: 10
spring.kafka.producer.acks: all

// 컨슈머 설정
spring.kafka.listener.concurrency: 5

[channelName 으로 메세지 보내기]

< channelName>은 'req_message'
<topic_name>은 'topicData'라고 이름을 정하고
어떻게 보내는지 살펴보자.

StreamConfig.java

	// 먼저 TestStreams 클래스를 바인딩 시킨다.
	@Configuration
	@EnableBinding(TestStreams.class)
	public class StreamsConfig {
		
	}

TestStream.java

	// 인터페이스 스트림에 이름을 맵핑시킨다.
	public interface TestStream {
		String OUT_MESSAGE = "req_message";
		String IN_MESSAGE = "res_message";
	}
	// 보낼때
	@Output(OUT_MESSAGE)
	MessageChannel outboundReqMessage();

	// 받을때
    @Input(IN_MESSAGE)
    SubscribableChannel inboundResMessage();

TestServiceImpl.java

// MessageChannel에 스트림을 통해 만든 변수로 받아서, withPayload 메서드를 이용해 데이터를 담아서 send 시킨다.
// 데이터는 JSON 형태로 보내도록 설정한다.
MessageChannel messageChannel = TestStream.outboundReqMessage();

RequestData data = new RequestData("test", "test2", "tes3");

boolean bSend = messageChannel.send(MessageBuilder.withPayload(data)
				.setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON)
				.build());

-> 해당 로직이 수행되면 req_message 이름으로 맵핑된 topicData(<topic_name>)가 브로커 프로듀서로써 토픽 메시지를 날린다.

CLI환경에서 아래의 명령어를 확인해보자.

./kafka-console-consumer.sh --bootstrap-server 30.30.30.20:9092,30.30.30.21:9092,30.30.30.22:9092 --topic topicData

{"test", "test2", "test3"} 이 출력될 것 이다.

그렇다면 컨슈머에서 어떻게 데이터를 받아서 사용할까?

topicData 토픽으로 메시지를 보내면 다른 프로젝트에서 topicData로 설정된 토픽의 메시지를 가져온다.
물론 타 프로젝트의 경우도 동일한 환경의 application.yml 셋팅이 이루워져야한다.

(아래의 코드는 타 프로젝트에 설정된거라고 보자)

application.yml에서 'topicData' 토픽에 대한 < channelName> 이 res_message 라고 해보자

StreamConfig.java

	// 먼저 TestStreams 클래스를 바인딩 시킨다.
	@Configuration
	@EnableBinding(TestStreams.class)
	public class StreamsConfig {
		
	}

TestStream.java

	// 인터페이스 스트림에 이름을 맵핑시킨다.
	public interface TestStream {
		String IN_MESSAGE = "res_message";
	}

	// 받을때
    @Input(IN_MESSAGE)
    SubscribableChannel inboundResMessage();

Listenner.java

	// @StreamListener를 통해 @Payload 를 선언하고 데이터를 받아 올 수 있다.
	@Component
	public class Listenner {
		@StreamListener(TestStream.IN_MESSAGE)
		public void handleResTopicData(@Payload RequestData data) throws Exception {

			log.info("Received RequestData : {}", data);
			// {"test", "test2", "test3"}
		}
	}

이런식으로 분산어플리케이션 환경에서도 데이터를 주고 받을 수 있다.
잘 활용하면 많은곳에 쓰일 수 있을 것이다.

참고하기 좋은 사이트

https://cloud.spring.io/spring-cloud-stream-binder-kafka/spring-cloud-stream-binder-kafka.html#_kafka_binder_properties

https://coding-start.tistory.com/139

https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html

profile
노옵스를향해

0개의 댓글