티스토리 뷰

(1편) https://sedangdang.tistory.com/279?category=1020314 - 요청 파라미터에 넣을 csv 데이터 가공하기

 

* 개인 토이 프로젝트에 적용하는 과정을 남긴 글이라 그대로 따라하기에 어려움이 있을 수 있습니다

 

<사용하는 기술 스택>

- JAVA + Spring + JPA + MySQL

 

2편. 가공된 csv 데이터를 자바에서 읽어서 DB 테이블에 저장하기!

# 1. csv 파일 읽기

 

스프링 부트 | 상대 경로 현재 디렉토리(./)는 어디를 뜻하는 것일까?

개인적으로 만들어보고 있는 자바 스프링 프로젝트에서 외부 리소스 파일을 읽어야 할 일이 있어 찾아보는데 정말 내가 잘못된 개념을 알고 있었던 것 같아 적용하는데 골머리를 앓았고, 또 그

sedangdang.tistory.com

(해당 글 참고)

 

가공된 csv 파일 데이터를 외부 디렉토리에 놓고 자바가 이를 읽어서 이용하는 식으로 만들어보자.

저장 경로는 현재 작업 디렉토리 기준 storage/init/regionList.csv와 같다.

 

그런데 저장 디렉토리의 위치나 이름은 실행 환경에 따라 제각각일 수 있으니 이것은 소스코드에 하드코딩 하는 것이 아니라 profile 설정 파일에 지정해놓고 그걸 가져와서 사용하는 방식으로 만들어보자.

# application.properties (개발 환경)
resources.location=resources.location=C:/Users/HOME/Desktop/Java_study/Capstone/smartnotice/storage

# application-real.properties (운영 환경)
resources.location=home/ec2-user/app/smartnotice_storage

설정파일에다가 절대경로를 박아넣고, 그 뒤에 저장소 내에 해당 파일의 위치를 이어붙이는 식으로 만들어보려고 한다.

근데 이게 좋은 방식인지는 잘 모르겠다. 다른사람들은 어떻게 하는지 궁금하다.

 

그래서 아래와 같이 코드를 짜고 로그를 찍어보면?

...
@Value("${resources.location}")
private String resourceLocation;
...

@PostMapping("/region")
public ResponseEntity<String> resetRegionList() {
     ...
    String fileLocation = resourceLocation + "/init/regionList.csv"; // 설정파일에 설정된 경로 뒤에 붙인다
    Path path = Paths.get(fileLocation);
    URI uri = path.toUri();
    ...
}

 

 

String 파일 경로를 Path 클래스로 1차 변환 하고 -> 그 다음에 URI 클래스로 2차 변환하는 과정을 거친다.

왜 굳이 Path를 거쳐서 만드는걸까? 

 

저 Path 클래스라는 녀석이 java.io.File 클래스의 개선된 버전인데 저 Path 클래스로 .toAbsuolutePath() 를 이용해 절대경로를 얻는다던지, 파일 이름을 가져온다던지 굉장히 다양한 일들을 할 수 있다.

여기서 .toUri() 라는 메서드는 Path의 경로를 URI로 변환시켜준다.

(Path 클래스 자체가 File 클래스의 개선 버전이기에 프로토콜으로 file:/// 을 붙여주는 것이다.)


여기서 또 URI란 무엇인가? URL은 아는데.. 해서 또 포스팅한 글을 참고하자

https://sedangdang.tistory.com/282

 

URL과 URI, URN의 차이점에 대해

URI(Uniform Resource Identifier): 리소스를 식별할 수 있게 해주는 통합 자원 식별자 URL(Uniform Resource Locator): 리소스의 위치를 가리키는 것. - URI가 URL의 상위 개념이다. URL은 어떤 프로토콜을..

sedangdang.tistory.com

* 저 변환된 URI 경로도 URL으로 볼 수 있겠다


저 URI 경로를 이용해서 파일을 읽어올 수 있다. 파일을 읽는 방법도 되게 여러가지 방법들이 있는데, 스프링에서 제공하는 대표적인 방법 중 하나인 UrlResource 를 이용하면 되겠다. 

(https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/io/UrlResource.html)

BufferedReader br = br = new BufferedReader(new InputStreamReader(
                                            new UrlResource(uri).getInputStream()));

 

그래서 만들어지는 전체 코드는 아래와 같다.

    @PostMapping("/region")
    public ResponseEntity<String> resetRegionList() {
        ...
        String fileLocation = resourceLocation + "/init/regionList.csv"; // 설정파일에 설정된 경로 뒤에 붙인다
        Path path = Paths.get(fileLocation);
        URI uri = path.toUri();
    
        try {
            BufferedReader br = new BufferedReader(new InputStreamReader(
                    new UrlResource(uri).getInputStream()));
            String line = br.readLine(); // head 떼기

            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            try {
                br.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        return ResponseEntity.ok("초기화에 성공했습니다");
    }

 

위 코드를 실행해보면 csv 파일을 한줄씩 읽어서 잘 출력하는 것을 확인할 수 있다.

try-catch 문이 꽤 지저분하게 들어가 있다. 나도 어디선가 복붙해온거라..

뭔가 좀 더 깔끔하게 만들어볼 수 없을까하다가 생각난 것이 try-with-resources 문이다. 

 

try (BufferedReader br = new BufferedReader(new InputStreamReader(
                new UrlResource(uri).getInputStream()))
) {
        String line = br.readLine(); // head 떼기
        while ((line = br.readLine()) != null) {
            System.out.println(line);
        }      
   } catch (IOException e) {
        e.printStackTrace();
   }

이를 사용해보면 꽤나 간결해진다. close() 를 따로 해주지 않아도 되기 때문에 굳이 또 finally 안에 try-catch를 쑤셔넣어서  지저분하게 close를 해 줄 필요가 없어진다. 사용하고 난 자원을 자동으로 반납해 주는 구문이 try-with-resources 문이다.

 

** 알아서 close() 해주는 객체는 AutoCloseable 인터페이스를 구현한 객체만 가능하다

- 참고: https://codechacha.com/ko/java-try-with-resources/

 

 

# 2. 객체로 만들어 테이블에 저장하기

csv 파일에 저장된 지역 데이터를 Region 객체로 변환하고 DB에 저장하자

@Getter
@Entity
@NoArgsConstructor
public class Region {

    @Id @Column(name = "region_id")
    private Long id; // 지역 순번

    @Column(name = "region_parent")
    private String parentRegion; // 시, 도

    @Column(name = "region_child")
    private String childRegion; // 시, 군, 구

    private int nx; // x좌표

    private int ny; // y좌표

    @Embedded
    private Weather weather; // 지역 날씨 정보

    // 날씨 정보 제외하고 지역 생성
    public Region(Long id, String parentRegion, String childRegion, int nx, int ny) {
        this.id = id;
        this.parentRegion = parentRegion;
        this.childRegion = childRegion;
        this.nx = nx;
        this.ny = ny;
    }

    // 날씨 갱신
    public void updateRegionWeather(Weather weather) {
        this.weather = weather;
    }
    
    @Override
    public String toString() {
        return parentRegion + " " + childRegion;
    }
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Embeddable
public class Weather {

    private Double temp; // 온도

    private Double rainAmount; // 강수량

    private Double humid; // 습도

    private String lastUpdateTime; // 마지막 갱신 시각 (시간 단위)
}
@Slf4j
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ApiController { 

    @Value("${resources.location}")
    private String resourceLocation;
    
    private final EntityManager em;
    ...
    
    @PostMapping("/region")
    @Transactional
    public ResponseEntity<String> resetRegionList() {
        String fileLocation = resourceLocation + "/init/regionList.csv"; // 설정파일에 설정된 경로 뒤에 붙인다
        Path path = Paths.get(fileLocation);
        URI uri = path.toUri();

        try (BufferedReader br = new BufferedReader(new InputStreamReader(
                new UrlResource(uri).getInputStream()))
        ) {
            String line = br.readLine(); // head 떼기
            while ((line = br.readLine()) != null) {
                String[] splits = line.split(",");
                em.persist(new Region(Long.parseLong(splits[0]), splits[1], splits[2],
                        Integer.parseInt(splits[3]), Integer.parseInt(splits[4])));
            }
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("뭔가 오류가 생겼는데요");
        }
        return ResponseEntity.ok("초기화에 성공했습니다");
    }
    ...
}

 

 

그럼 깔끔하게 붙는다!

 

그리고 추가로 Weather 객체도 아예 따로 테이블로 만든 다음에 Region 테이블이랑 1대1로 연결시키는 것이 더 저장공간을 아낄 수 있지 않은가 싶기도 한데 귀찮아서... 그냥 하려고 한다.

 

그 예전에 JPA 강의에서 엔티티일 필요가 없는 것을 불필요하게 엔티티로 만들지 마라 라는 얘기가 있었던 것 같은데.. 아마 데이터를 지속해서 추적할 필요가 있다면 (= id값이 따로 있다면) 엔티티로 만드는 것이 좋다고 기억한다. 하도 안하니 진짜 다까먹는다.. 

 

 

# 2. 쿼리 파라미터를 이어붙이고 API 요청 보내기

내 스프링부트 서버에서 기상청 API 서버로 http를 이용해 API 요청을 날려야 한다. 어떻게 할까..?

사실 이미 공식적으로 예시 코드가 만들어진 것이 있다. 그걸 그냥 갖다쓰면 된다.

 

 

기상청_단기예보 ((구)_동네예보) 조회서비스

초단기실황, 초단기예보, 단기((구)동네)예보, 예보버전 정보를 조회하는 서비스입니다. 초단기실황정보는 예보 구역에 대한 대표 AWS 관측값을, 초단기예보는 예보시점부터 6시간까지의 예보를,

www.data.go.kr

 

쿼리 파라미터는 어떻게 만드는거냐면 특별한 메서드가 있는게 아니고 그냥 StringBuilder를 가지고 뒤에 문자열 이어붙이는 것 뿐이다 ㅋㅋㅋㅋ

 

@Slf4j
@RestController
@RequestMapping("/api/weather")
@RequiredArgsConstructor
public class WeatherApiController {

    private final EntityManager em;

    @Value("${weatherApi.serviceKey}")
    private String serviceKey;

    @GetMapping
    @Transactional
    public ResponseEntity<WeatherResponseDTO> getRegionWeather(@RequestParam Long regionId) {

        // 1. 날씨 정보를 요청한 지역 조회
        Region region = em.find(Region.class, regionId);
        StringBuilder urlBuilder =  new StringBuilder("http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst");

        // 2. 요청 시각 조회
        LocalDateTime now = LocalDateTime.now();
        String yyyyMMdd = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        int hour = now.getHour();
        int min = now.getMinute();
        if(min <= 30) { // 해당 시각 발표 전에는 자료가 없음 - 이전시각을 기준으로 해야함
            hour -= 1;
        }
        String hourStr = hour + "00"; // 정시 기준
        String nx = Integer.toString(region.getNx());
        String ny = Integer.toString(region.getNy());
        String currentChangeTime = now.format(DateTimeFormatter.ofPattern("yy.MM.dd ")) + hour;

        // 기준 시각 조회 자료가 이미 존재하고 있다면 API 요청 없이 기존 자료 그대로 넘김
        Weather prevWeather = region.getWeather();
        if(prevWeather != null && prevWeather.getLastUpdateTime() != null) {
            if(prevWeather.getLastUpdateTime().equals(currentChangeTime)) {
                log.info("기존 자료를 재사용합니다");
                WeatherResponseDTO dto = WeatherResponseDTO.builder()
                        .weather(prevWeather)
                        .message("OK").build();
                return ResponseEntity.ok(dto);
            }
        }

        log.info("API 요청 발송 >>> 지역: {}, 연월일: {}, 시각: {}", region, yyyyMMdd, hourStr);

        try {
            urlBuilder.append("?" + URLEncoder.encode("serviceKey", "UTF-8") + serviceKey);
            urlBuilder.append("&" + URLEncoder.encode("pageNo","UTF-8") + "=" + URLEncoder.encode("1", "UTF-8")); /*페이지번호*/
            urlBuilder.append("&" + URLEncoder.encode("numOfRows","UTF-8") + "=" + URLEncoder.encode("1000", "UTF-8")); /*한 페이지 결과 수*/
            urlBuilder.append("&" + URLEncoder.encode("dataType","UTF-8") + "=" + URLEncoder.encode("JSON", "UTF-8")); /*요청자료형식(XML/JSON) Default: XML*/
            urlBuilder.append("&" + URLEncoder.encode("base_date","UTF-8") + "=" + URLEncoder.encode(yyyyMMdd, "UTF-8")); /*‘21년 6월 28일 발표*/
            urlBuilder.append("&" + URLEncoder.encode("base_time","UTF-8") + "=" + URLEncoder.encode(hourStr, "UTF-8")); /*06시 발표(정시단위) */
            urlBuilder.append("&" + URLEncoder.encode("nx","UTF-8") + "=" + URLEncoder.encode(nx, "UTF-8")); /*예보지점의 X 좌표값*/
            urlBuilder.append("&" + URLEncoder.encode("ny","UTF-8") + "=" + URLEncoder.encode(ny, "UTF-8")); /*예보지점의 Y 좌표값*/

            URL url = new URL(urlBuilder.toString());
            log.info("request url: {}", url);

            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setRequestProperty("Content-type", "application/json");

            BufferedReader rd;
            if(conn.getResponseCode() >= 200 && conn.getResponseCode() <= 300) {
                rd = new BufferedReader(new InputStreamReader(conn.getInputStream()));
            } else {
                rd = new BufferedReader(new InputStreamReader(conn.getErrorStream()));
            }
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = rd.readLine()) != null) {
                sb.append(line);
            }
            rd.close();
            conn.disconnect();
            String data = sb.toString();

            //// 응답 수신 완료 ////
            //// 응답 결과를 JSON 파싱 ////

            Double temp = null;
            Double humid = null;
            Double rainAmount = null;

            JSONObject jObject = new JSONObject(data);
            JSONObject response = jObject.getJSONObject("response");
            JSONObject body = response.getJSONObject("body");
            JSONObject items = body.getJSONObject("items");
            JSONArray jArray = items.getJSONArray("item");

            for(int i = 0; i < jArray.length(); i++) {
                JSONObject obj = jArray.getJSONObject(i);
                String category = obj.getString("category");
                double obsrValue = obj.getDouble("obsrValue");

                switch (category) {
                    case "T1H":
                        temp = obsrValue;
                        break;
                    case "RN1":
                        rainAmount = obsrValue;
                        break;
                    case "REH":
                        humid = obsrValue;
                        break;
                }
            }

            Weather weather = new Weather(temp, rainAmount, humid, currentChangeTime);
            region.updateRegionWeather(weather); // DB 업데이트
            WeatherResponseDTO dto = WeatherResponseDTO.builder()
                    .weather(weather)
                    .message("OK").build();
            return ResponseEntity.ok(dto);

        } catch (IOException e) {
            WeatherResponseDTO dto = WeatherResponseDTO.builder()
                    .weather(null)
                    .message("날씨 정보를 불러오는 중 오류가 발생했습니다").build();
            return ResponseEntity.ok(dto);
        }
    }
}

switch 문에서 T1H, REH와 같은 내용은 공식 문서를 참고해야 알 수 있다. 1시간 강수량이었나 어쩌나

 

 

(+) JSON 파싱은 json 파싱 라이브러리를 별도로 사용하면 쉽게 사용할 수 있다. 

implementation 'org.json:json:20220320'

(사용 방법 참고 - https://codechacha.com/ko/java-parse-json/ )

 

 

아무튼 이제 포스트맨을 이용하거나 웹사이트를 이용해 요청을 날려보면 DB 테이블에 날씨정보가 업데이트 되는것을 확인할 수 있다.

 

 

 

데이터가 반영된 모습

 

이거를 더 나아가서 프론트 단까지 넘겨주면 아래와도 같이 된다.

 

        $.ajax({
            type: "GET",
            url: "/api/weather?regionId=" + regionId,
            dataType: "json",
            success: function (result) {
                $("#weatherSyncIcon").removeClass("bx-spin");

                var weather = result.weather;

                if(weather == null) {
                    $("#weatherInfoText").text("날씨를 불러오는 중 오류가 발생했습니다.");
                } else {
                    var lastUpdateTime = weather.lastUpdateTime;
                    var temp = weather.temp;
                    var humid = weather.humid;
                    var rainAmount = weather.rainAmount;
                    $("#weatherInfoText").text(" 온도: " + temp + "℃, 습도: " + humid
                        + "%, 강수량: " + rainAmount + "mm (기준 시점: " + lastUpdateTime + "시)");
                }
            },
            error: function (xhr) {
                alert(xhr.responseText);
                $("#weatherSyncIcon").removeClass("bx-spin");
            }
        });

 

아주 뿌듯하다 ㅎㅎ

 

 

* 의외로 빈번하게 API 요청을 했을 때 "게이트웨이 내부 서비스 오류", "HTTP 오류"와 같이 상대 서버 측에서 에러를 뿜는 경우가 많다.  내 잘못인가 싶은데 검색해보니 이런 일이 꽤 자주 있다.

 

 

자세한 사항은 공식 홈페이지에 첨부되어있는 설명문서를 읽어보는 것이 정확하다. 나도 잘 모른다.

 


 

아무튼 이렇게 오픈API를 이용해서 날씨정보를 불러오는 과정을 진행해보았다.

 

글이 굉장히 길어져서 포스팅 하기가 귀찮은 부분이 많았는데 그래도 막상 남기고 보니 기억에 잘 남을 것 같아 도움이 많이 된 것 같다.

특히 스프링에서 파일 경로랑 파일 읽기 이거는 참 많이 배웠다.. 역시 해봐야 는다는 것이 정말 맞는 말인 것 같다.

 

앞으로도 다양한 경험을 많이 해봐야겠다는 것을 많이 느꼈다. 갈길이 멀다!!

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함