해커톤 프로젝트 - WebClient 관련 내용 정리

Chooooo·2023년 8월 31일
0

TIL

목록 보기
5/28
post-thumbnail

😎 서울시 공공서비스 Open API 연결

😀 WebClientController

@RestController
@RequiredArgsConstructor
public class WebClientController {


    private final WebClientService service;

    /**
     * 서울시 문화행사 공공서비스예약 정보 Open API JSON 연동 성공.
     */
    @GetMapping("/test")
    public WebClientDTO returnString(@RequestParam Integer startIndex, @RequestParam Integer endIndex) {
        WebClientDTO res = service.get(startIndex, endIndex);
        return res;
    }

    @GetMapping("/test2")
    public String returnString2(@RequestParam Integer startIndex, @RequestParam Integer endIndex) {
        String res = service.get2(startIndex, endIndex);
        return res;
    }

    /**
     * 서울시 공공서비스 예약 (종합) 정보
     */
    @GetMapping("/test3")
    public WebClientDTO returnPublicServiceReservationAPI(@RequestParam Integer startIndex, @RequestParam Integer endIndex) {
        WebClientDTO res = service.getPublicServiceReservation(startIndex, endIndex);
        return res;
    }

    @GetMapping("/test4")
    public String get1365Data(@RequestParam("noticeEndde") Integer num) {  //프로그램 등록 정보
        System.out.println("num : " + num);
        String res = service.get1365Data(num);
        return res;
    }

    @GetMapping(value = "/test5", produces = MediaType.APPLICATION_XML_VALUE)
    public Volunteer getData(@RequestParam("noticeEndde") Integer num) {
        Volunteer res = service.returnData(num);
        return res;
    }
}

😀 WebClientService

⚽ 전체 코드

@Service
@Transactional
@Slf4j
public class WebClientService {

    private final String BASE_URL = "http://openAPI.seoul.go.kr:8088";
    private final String BASE_URL_1365 = "http://openapi.1365.go.kr/openapi/service";

    private final String BASE_URL_JOB = "http://apis.data.go.kr/B552474/SenuriService";
    private final WebClient webClient;

    /**
     * 객체지향적으로 Bean으로 등록해서 받아와서 사용
     * 등록은 xml전용으로 해놓고 가져와서 json으로 사용해야 할 경우 변경
     */
    public WebClientService(WebClient webClient) {
        this.webClient = webClient;
    }

    @Value("${api.hyunsoo.key}")
    private String api_key;

    @Value("${api.key.1365}")
    private String api_key_1365;

    @Value("${api.key.data.portal}")
    private String apiKeyDataPortal;



    public WebClientDTO get(Integer startIndex, Integer endIndex) {
        DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(BASE_URL);
        factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY);
        //이렇게 URI의 빌드 설정을 완료하면 WebClient의 인스턴스를 생성할 때 해당 UriBuilder로 uri를 만든다고 설정하면 된다.
        // 기본 세팅 진행
        WebClient webClient = WebClient.builder()
                .uriBuilderFactory(factory)
                .baseUrl(BASE_URL)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

        //필요한 값들 요청.
        WebClientDTO response = webClient.get()
                .uri(uriBuilder -> uriBuilder.path("{api_key}/json/ListPublicReservationCulture/{startIndex}/{endIndex}")
                        .build(api_key, startIndex, endIndex))
                .retrieve()
                .bodyToMono(WebClientDTO.class)
                .block();

        /**
         * 결과 확인 log
         log.info("반환되는 값 : {}", response.toString());
         */
        return response;
    }

    public String get2(Integer startIndex, Integer endIndex) {
        DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(BASE_URL);
        factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY);
        //이렇게 URI의 빌드 설정을 완료하면 WebClient의 인스턴스를 생성할 때 해당 UriBuilder로 uri를 만든다고 설정하면 된다.
        // 기본 세팅 진행
        WebClient webClient = WebClient.builder()
                .uriBuilderFactory(factory)
                .baseUrl(BASE_URL)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

        //필요한 값들 요청.
        String response = webClient.get()
                .uri(uriBuilder -> uriBuilder.path("{api_key}/json/ListPublicReservationCulture/{startIndex}/{endIndex}")
                        .build(api_key, startIndex, endIndex))
                .retrieve()
                .bodyToMono(String.class)
                .block();


        /**
         * 결과 확인 log
         log.info("반환되는 값 : {}", response.toString());
         */
        return response;
    }

    public WebClientDTO getPublicServiceReservation(Integer startIndex, Integer endIndex) {
        DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(BASE_URL);
        factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY);

        //모든 값 요청.

        WebClientDTO res = webClient.mutate() // mutate() => Return a builder to create a new WebClient whose settings are replicated from the current WebClient.
                .uriBuilderFactory(factory)
                .build()
                .get().uri(uriBuilder ->
                        uriBuilder.path("{api_key}/json/tvYeyakCOllect/{startIndex}/{endIndex}")
                                .build(api_key, startIndex, endIndex))
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .retrieve()
                .bodyToMono(WebClientDTO.class)
                .block();

        /**
         * 결과 확인 log
         log.info("반환되는 res : {}", res.toString());
         */

        return res;

    }

    public String get1365Data(Integer num) {
        //xml open api를 사용하기 위한 기본 세팅.
        /**
         * open api를 xml 형식으로 받아오기 위해 기본 세팅.
         */
        WebClient webClient = WebClient.builder()
                .baseUrl(BASE_URL_1365)
                .exchangeStrategies(ExchangeStrategies.builder()
                        .codecs(clientCodecConfigurer ->
                                clientCodecConfigurer.defaultCodecs()
                                        .jaxb2Encoder(new Jaxb2XmlEncoder())
                        ).codecs(clientCodecConfigurer ->
                                clientCodecConfigurer.defaultCodecs()
                                        .jaxb2Decoder(new Jaxb2XmlDecoder())
                        )
                        .build()
                )
                .build();
        /**
         * 내가 원하는 형식으로 반환 성공 !
         */
        String res = webClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/rest")
                        .path("/VolunteerPartcptnService")
                        .path("getVltrSearchWordList")
                        .queryParam("serviceKey", api_key_1365)
                        .queryParam("noticeEndde", num)
                        .build())
                .accept(MediaType.valueOf(MediaType.APPLICATION_XML_VALUE))
                .retrieve()
                .bodyToMono(String.class)
                .block();


        /**
         * String format을 활용한 xml 활용

        String url = String.format("http://openapi.1365.go.kr/openapi/service/rest/VolunteerPartcptnService/getVltrSearchWordList?serviceKey=%s&noticeEndde=%d", api_key_1365, num);
        String res = webClient.get()
                .uri(url)
                .accept(MediaType.valueOf(MediaType.APPLICATION_XML_VALUE))
                .retrieve()
                .bodyToMono(String.class)
                .block();
         */

        /**
         * 로그 확인
         log.info("num : {}", num);
         log.info("response : {}", res);
         */

        return res;
    }

    public Volunteer returnData(Integer num) {
        /**
         * open api를 xml 형식으로 받아오기 위해 기본 세팅.
         */
        WebClient webClient = WebClient.builder()
                .baseUrl(BASE_URL_1365)
                .exchangeStrategies(ExchangeStrategies.builder()
                        .codecs(clientCodecConfigurer ->
                                clientCodecConfigurer.defaultCodecs()
                                        .jaxb2Encoder(new Jaxb2XmlEncoder())
                        ).codecs(clientCodecConfigurer ->
                                clientCodecConfigurer.defaultCodecs()
                                        .jaxb2Decoder(new Jaxb2XmlDecoder())
                        )
                        .build()
                )
                .build();
        /**
         * 내가 원하는 형식으로 반환 성공 !
         */
        Volunteer res = webClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/rest")
                        .path("/VolunteerPartcptnService")
                        .path("getVltrSearchWordList")
                        .queryParam("serviceKey", api_key_1365)
                        .queryParam("noticeEndde", num)
                        .build())
                .accept(MediaType.valueOf(MediaType.APPLICATION_XML_VALUE))
                .retrieve()
                .bodyToMono(Volunteer.class)
                .block();
        /**
         * 로그 확인
         log.info("res : {}", res.toString());
         */
        return res;
    }

    public String employmentSupport(Integer startIndex, Integer endIndex) {
        DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(BASE_URL);
        factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY);
        //이렇게 URI의 빌드 설정을 완료하면 WebClient의 인스턴스를 생성할 때 해당 UriBuilder로 uri를 만든다고 설정하면 된다.
        // 기본 세팅 진행
        WebClient webClient = WebClient.builder()
                .uriBuilderFactory(factory)
                .baseUrl(BASE_URL)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

        //필요한 값들 요청.
        String response = webClient.get()
                .uri(uriBuilder -> uriBuilder.path("{api_key}/json/tbViewProgram/{startIndex}/{endIndex}")
                        .build(api_key, startIndex, endIndex))
                .retrieve()
                .bodyToMono(String.class)
                .block();


        /**
         * 결과 확인 log
         log.info("반환되는 값 : {}", response.toString());
         */
        return response;
    }

    public EmploymentJsonDto returnEmploymentDto(Integer startIndex, Integer endIndex) {
        DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(BASE_URL);
        factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY);

        EmploymentJsonDto res = webClient.mutate() // mutate() => Return a builder to create a new WebClient whose settings are replicated from the current WebClient.
                .uriBuilderFactory(factory)
                .build()
                .get().uri(uriBuilder ->
                        uriBuilder.path("{api_key}/json/tbViewProgram/{startIndex}/{endIndex}")
                                .build(api_key, startIndex, endIndex))
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .retrieve()
                .bodyToMono(EmploymentJsonDto.class)
                .block();

        return res;

    }

    /**
     * 100세누리 구인정보 목록 검색 --> 나중에 시간날 때 코드 고쳐보자.
     */
    public SenuriServiceRawResponse CallSenuriListService(int numOfRows, int pageNo) {
        final String uri = String.format("http://apis.data.go.kr/B552474/SenuriService/getJobList?ServiceKey=%s&numOfRows=%d&pageNo=%d",
                apiKeyDataPortal,numOfRows, pageNo);
        System.out.println("uri = " + uri);
        try {
            return webClient.get()
                    .uri(new URI(uri))
                    .accept(MediaType.APPLICATION_XML)
                    .retrieve()
                    .bodyToMono(SenuriServiceRawResponse.class)
                    .blockOptional().orElseThrow(() -> new RuntimeException("[CallSenuriService] Error."));

        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }

    }

    /**
     * String으로 제대로 되는지 테스트
     */
    public String CallSenuriListServiceString(int numOfRows, int pageNo) {
        final String uri = String.format("http://apis.data.go.kr/B552474/SenuriService/getJobList?ServiceKey=%s&numOfRows=%d&pageNo=%d",
                apiKeyDataPortal,numOfRows, pageNo);
        System.out.println("uri = " + uri);
        try {
            return webClient.get()
                    .uri(new URI(uri))
                    .accept(MediaType.APPLICATION_XML)
                    .retrieve()
                    .bodyToMono(String.class)
                    .blockOptional().orElseThrow(() -> new RuntimeException("[CallSenuriService] Error."));

        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 100세누리 구인정보 상세 정보 조회
     */
    public SenuriServiceDetailRawResponse CallSenuriDetailService(String id) {
        final String uri = String.format("https://apis.data.go.kr/B552474/SenuriService/getJobInfo?&id=%s&serviceKey=%s",
                id, apiKeyDataPortal);

        try {
            return webClient.get()
                    .uri(new URI(uri))
                    .accept(MediaType.APPLICATION_XML)
                    .retrieve()
                    .bodyToMono(SenuriServiceDetailRawResponse.class)
                    .blockOptional().orElseThrow(() -> new RuntimeException("[CallSenuriServiceDetail] Error."));

        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }
    }


}

🖤 먼저 WebClientDTO

  • 해당 Open API로부터 데이터를 가져오기 위해 맵핑을 해서 가져와야 하기 때문에 DTO 설정해줬다.
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WebClientDTO {

    @JsonProperty("tvYeyakCOllect")
    private PublicServiceReservation publicServiceReservation;

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    private static class PublicServiceReservation {
        @JsonProperty("list_total_count")
        private String listTotalCount;

        @JsonProperty("RESULT")
        private Result result;

        @JsonProperty("row")
        private List<Row> rows;
    }
    //키 값 제대로 보고 파싱해야함.!!!! -> 코드 정리

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    private static class Result{
        @JsonProperty("CODE")
        private String code;
        @JsonProperty("MESSAGE")
        private String message;
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    private static class Row{

        @JsonProperty("DIV")
        private String div;

        @JsonProperty("SERVICE")
        private String service;

        @JsonProperty("GUBUN")
        private String gubun;

        @JsonProperty("SVCID")
        private String serviceId;

        @JsonProperty("MAXCLASSNM")
        private String maxClassNM;

        @JsonProperty("MINCLASSNM")
        private String minClassNM;

        @JsonProperty("SVCSTATNM")
        private String svcStatNM;

        @JsonProperty("SVCNM")
        private String svcNM;

        @JsonProperty("PAYATNM")
        private String payAtNM;

        @JsonProperty("PLACENM")
        private String placeNM;

        @JsonProperty("USETGTINFO")
        private String useTgtInfo;

        @JsonProperty("SVCURL")
        private String svcUrl;

        @JsonProperty("X")
        private String x;

        @JsonProperty("Y")
        private String y;

        @JsonProperty("SVCOPNBGNDT")
        private String svcOpnBgnDt;

        @JsonProperty("SVCOPNENDDT")
        private String svcOpnEndDt;

        @JsonProperty("RCPTBGNDT")
        private String rcptbgndt;

        @JsonProperty("RCPTENDDT")
        private String rcptenddt;

        @JsonProperty("AREANM")
        private String areaNM;

        @JsonProperty("IMGURL")
        private String imgUrl;

        @JsonProperty("DTLCONT")
        private String dtlCont;

        @JsonProperty("TELNO")
        private String telNo;

        @JsonProperty("V_MIN")
        private String vMin;

        @JsonProperty("V_MAX")
        private String vMax;

        @JsonProperty("REVSTDDAYNM")
        private String revStdDayNM;

        @JsonProperty("REVSTDDAY")
        private String RevStdDay;
    }

    public int fetchListTotalCount() {
        return Integer.parseInt(this.publicServiceReservation.listTotalCount);
    }

    public List<PublicServiceReservationDto> toDto() {
        return this.publicServiceReservation.rows.stream()
                .map(r -> PublicServiceReservationDto.of(
                        r.div, r.service, r.gubun, r.serviceId, r.maxClassNM, r.minClassNM, r.svcStatNM, r.svcNM, r.payAtNM, r.placeNM, r.useTgtInfo, r.svcUrl,
                        r.x, r.y, r.svcOpnBgnDt, r.svcOpnEndDt, r.rcptbgndt, r.rcptenddt, r.areaNM, r.imgUrl, r.dtlCont, r.telNo, r.vMin, r.vMax, r.revStdDayNM, r.RevStdDay
                )).collect(Collectors.toList());
    }

}
  • json으로 요청 보내기 때문에 @JsonProperty를 통해 데이터를 이름 그대로 맵핑하면 된다 !

🖤 개수 정해서 Open API 호출해서 데이터 가져오기 - json

public WebClientDTO get(Integer startIndex, Integer endIndex) {
        DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(BASE_URL);
        factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY);
        //이렇게 URI의 빌드 설정을 완료하면 WebClient의 인스턴스를 생성할 때 해당 UriBuilder로 uri를 만든다고 설정하면 된다.
        // 기본 세팅 진행
        WebClient webClient = WebClient.builder()
                .uriBuilderFactory(factory)
                .baseUrl(BASE_URL)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();

        //필요한 값들 요청.
        WebClientDTO response = webClient.get()
                .uri(uriBuilder -> uriBuilder.path("{api_key}/json/ListPublicReservationCulture/{startIndex}/{endIndex}")
                        .build(api_key, startIndex, endIndex))
                .retrieve()
                .bodyToMono(WebClientDTO.class)
                .block();

        /**
         * 결과 확인 log
         log.info("반환되는 값 : {}", response.toString());
         */
        return response;
    }
  • startIndex ~ endIndex까지의 데이터를 가져올 것.

🎈 .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) :

  • Open API 서버로부터 json 형식의 데이터로 응답을 받아오겠다는 뜻.
.uri(uriBuilder -> uriBuilder.path("{api_key}/json/ListPublicReservationCulture/{startIndex}/{endIndex}")
                        .build(api_key, startIndex, endIndex))
  • 위 부분에서 uri의 path 부분에 변수를 사용할 경우 최종 build()에서 차례대로 변수들을 넣어주면 된다.

🖤 xml로 요청 데이터 응답 받아오기

public String get1365Data(Integer num) {
        //xml open api를 사용하기 위한 기본 세팅.
        /**
         * open api를 xml 형식으로 받아오기 위해 기본 세팅.
         */
        WebClient webClient = WebClient.builder()
                .baseUrl(BASE_URL_1365)
                .exchangeStrategies(ExchangeStrategies.builder()
                        .codecs(clientCodecConfigurer ->
                                clientCodecConfigurer.defaultCodecs()
                                        .jaxb2Encoder(new Jaxb2XmlEncoder())
                        ).codecs(clientCodecConfigurer ->
                                clientCodecConfigurer.defaultCodecs()
                                        .jaxb2Decoder(new Jaxb2XmlDecoder())
                        )
                        .build()
                )
                .build();
        /**
         * 내가 원하는 형식으로 반환 성공 !
         */
        String res = webClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/rest")
                        .path("/VolunteerPartcptnService")
                        .path("getVltrSearchWordList")
                        .queryParam("serviceKey", api_key_1365)
                        .queryParam("noticeEndde", num)
                        .build())
                .accept(MediaType.valueOf(MediaType.APPLICATION_XML_VALUE))
                .retrieve()
                .bodyToMono(String.class)
                .block();


        /**
         * String format을 활용한 xml 활용

        String url = String.format("http://openapi.1365.go.kr/openapi/service/rest/VolunteerPartcptnService/getVltrSearchWordList?serviceKey=%s&noticeEndde=%d", api_key_1365, num);
        String res = webClient.get()
                .uri(url)
                .accept(MediaType.valueOf(MediaType.APPLICATION_XML_VALUE))
                .retrieve()
                .bodyToMono(String.class)
                .block();
         */

        /**
         * 로그 확인
         log.info("num : {}", num);
         log.info("response : {}", res);
         */

        return res;
    }

🎈 위 부분처럼 builder()를 통해 xml 형식으로 받아오기 위해 기본 세팅이 필요하다. jaxb2Encoder, jaxb2Decoder를 통해 xml 설정을 해줘야함.

🎈 그리고 WebClient 인스턴스를 사용하여 Open API에 요청 보낼 때 .accept() 메서드에 xml_value를 넣어주면 된다.

🖤 스프링 빈에 WebClient를 등록할 경우

💨 WebClientConfig

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                .codecs(clientCodecConfigurer -> {
                    clientCodecConfigurer.defaultCodecs().jaxb2Encoder(new Jaxb2XmlEncoder());
                    clientCodecConfigurer.defaultCodecs().jaxb2Decoder(new Jaxb2XmlDecoder());
                    clientCodecConfigurer.defaultCodecs().maxInMemorySize(-1);
                })
                .build();

        return WebClient.builder()
                .exchangeStrategies(exchangeStrategies)
                .build();
    }

}
  • 따로 config 패키지에 넣어서 빈에 등록시키고 사용하면 된다.
  • 기본 등록은 xml 형식으로 저장해두고 필요할 때 바꾸면 된다 !

스프링 빈 등록한 것 사용

따로 private final로 설정해둔 인스턴스를 통해 json인 경우에 mutate()를 통해 설정만 변경해주면 된다 !

public EmploymentJsonDto returnEmploymentDto(Integer startIndex, Integer endIndex) {
        DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(BASE_URL);
        factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY);

        EmploymentJsonDto res = webClient.mutate() // mutate() => Return a builder to create a new WebClient whose settings are replicated from the current WebClient.
                .uriBuilderFactory(factory)
                .build()
                .get().uri(uriBuilder ->
                        uriBuilder.path("{api_key}/json/tbViewProgram/{startIndex}/{endIndex}")
                                .build(api_key, startIndex, endIndex))
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .retrieve()
                .bodyToMono(EmploymentJsonDto.class)
                .block();

        return res;

    }
  • 스프링 빈에 등록해서 사용할 경우, 기존의 기본 설정 말고 다른 설정을 해야할 경우, mutate()를 통해 다시 설정하면 된다.

🎈 mutate() : WebClient의 현재 인스턴스를 복제하여 수정 가능한 빌더를 반환하는 메서드이다. 이 메서드를 사용하면 기존의 WebClient 인스턴스에서 일부 설정을 변경하거나 확장할 수 있다.

  • 일반적으로 mutate() 메서드는 WebClient의 기존 설정을 그대로 가져가면서 특정 부분을 수정하고자 할 때 유용!!! 예를 들어, 기존의 WebClient 설정을 유지하면서 새로운 필터를 추가하거나, 또는 특정한 URI 템플릿을 사용하기 위해 mutate() 메서드를 사용할 수 있다.

예외처리 진행할 경우

public SenuriServiceRawResponse CallSenuriService(int numOfRows, int pageNo) {
        final String uri = String.format("http://apis.data.go.kr/B552474/SenuriService/getJobList?numOfRows=%d&pageNo=%d&ServiceKey=%s",
                numOfRows, pageNo, apiKeyDataPortal);
        System.out.println("uri = " + uri);
        try {
            return webClient.get()
                    .uri(new URI(uri))
                    .accept(MediaType.APPLICATION_XML)
                    .retrieve()
                    .bodyToMono(SenuriServiceRawResponse.class)
                    .blockOptional().orElseThrow(() -> new RuntimeException("[CallSenuriService] Error."));

        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }
    }
  • 예외 처리 진행할 경우 Optional로 빼서 따로 예외를 처리하면 된다.
profile
back-end, 지속 성장 가능한 개발자를 향하여

0개의 댓글