API 통신으로 zip 파일을 받게 된다면? (Java, IO Stream 이해)

dtrtetg·2021년 5월 20일
3
post-thumbnail

개요

Open Dart API 링크

위 API는 재무제표, 기업 공시 등 증권 관련 정보를 얻을 수 있는 서비스 입니다.

개인 프로젝트를 만드는 중 재미있는 내용이 있어서 정리해보고자 합니다.

상황 이해

  1. 우리나라 주식 시장에 상장 되어 있는 기업 목록들이 필요했다.
  2. API를 찾아보니 기업들의 고유 번호를 압축파일로 제공하고 있었다.
    압축파일 안에는 약 8만개의 기업 고유 번호가 XML 형태로 되어있었다.
    (약 52만줄의 데이터...ㄷㄷ)
  3. 서버에서 API 통신을 하여 이 데이터를 사용하기 쉽게 가공해야 한다.
  4. ZipInputStream을 이해하기 전에 자바의 IO Stream을 이해하여야 한다.

요약

  • Api 통신
  • Zip 파일 압축 풀기

InputStream, OutputStream 이해

택배로 비유를 해서 쉽게 설명을 해보겠습니다. 위 사진처럼 물류 창고에서 집으로 물건을 옮기려면 택배 기사가 배송을 해주어야 합니다. 고객이 직접 택배를 창고에서 가져가지 않습니다. 이런 특징들을 통해서 Stream을 설명해보려 합니다.

우선 Stream은 택배 기사를 의미합니다. 물건을 주고받을 때 택배 기사가 옮겨주기 때문이지요.

InputStream

이제 그 택배 기사가 창고에 있는 물건을 싣고 배달을 해서 고객의 집 앞에 택배를 갖다줍니다. 이 과정은 집의 관점에서 보면 집으로 물건이 들어오는 것이므로 입력 입니다.

그래서 이 과정을 집의 관점에서는 InputStream이 됩니다.

OutputStream

그런데 물건이 마음에 안들어서 반품을 하거나, 혹은 친구에게 보낼 물건이 있어서 택배를 보내려고 합니다. 그럼 택배 기사는 집에 있는 물건을 가지고 다른 곳으로 갑니다. 이건 출력이고 OutputStream의 의미입니다.

기준에 따라 다른 Stream

하지만 제가 '관점'이라는 표현을 쓴 것에 주목해야 합니다. 집 관점에서 반품을 할 때는 OutputStream이 맞지만 창고 입장에선 물건이 들어오기 때문에 InputStream이라고 할 수 있습니다.

그래서 내가 구현하고자 하는 것이 집인지 창고인지를 잘 파악해야 합니다. 그 기준에 따라서 입출력이 다르게 해석이 되기 때문이죠.

압축파일 다운로드

TDD Code

Test Code

@Test
@DisplayName("zip 파일 다운로드")
public void downloadCorpCodeZipFile() throws InterruptedException, IOException {

    // Given
    RestTemplate restTemplate = new RestTemplate();
    UriComponents uriComponents = UriComponentsBuilder
            .fromHttpUrl(opendartProperties.getUrl())
            .pathSegment("corpCode.xml")
            .queryParam("crtfc_key", opendartProperties.getKey())
            .build();

    // When
    Path file = restTemplate.execute(uriComponents.toUriString(), HttpMethod.GET, null, response -> {

        Path zipFile = Files.createTempFile("opendart-", ".zip");
        Files.write(zipFile, response.getBody().readAllBytes());

        return zipFile;
    });

    // Then
    assertNotNull(file);
    assertEquals("opendart", file.getFileName().toString().substring(0, 8));

    // 테스트 후 삭제
    Files.delete(file);
}

OpendartProperties

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@ToString
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "opendart")
public class OpendartProperties {
    private String key;
    private String url;
}

Application.properties

opendart.key=${env.OPENDART_KEY}
opendart.url=https://opendart.fss.or.kr/api

코드 설명

Api Key

API에 사용되는 Key 값은 노출되면 안되기 때문에 환경변수에 넣어 사용했습니다.

환경 변수 활용하기

  1. 환경변수 등록
    자바 설치하면서 다들 한 번씩 해봤던 것 기억나시죠? 저는 OPENDART_KEY 이름으로 키를 등록했습니다.
  2. application.properties에 추가
    • 환경변수를 편집하면 IDE를 재시작 해주어야 합니다! (2시간 동안 헤맸음;;)
    • opendart.key=${env.OPENDART_KEY} 저는 이렇게 추가했어요.
  3. ConfigurationProperties 클래스 생성, 위에 OpendartProperties 클래스 코드 참고해주세요.
    • @ConfigurationProperties 어노테이션을 통해 외부 설정파일을 사용할 수 있게 해줍니다. prefix 값은 application.properties에서 사용할 값 입니다.
  4. 검증하기
@Autowired
OpendartProperties opendartProperties;

@Test
@DisplayName("프로퍼티에서 환경변수 불러오기")
public void OpendartPropertiesLoadTest() {

    // Given & When
    String key = opendartProperties.getKey();

    // Then
    assertNotNull(key);
    assertEquals(System.getenv("OPENDART_KEY"), key);
}

테스트 코드에선 @Autowired 어노테이션 붙여야해요.

UriComponents

URL은 String으로 전부 적으면
https://opendart.fss.or.kr/apicorpCode.xml?crtfc_key=API_KEY_00000000000000000 이런 식으로 길어집니다. 그래서 UriComponents를 사용해서 가독성있게 uri를 만들 수 있도록 구성했습니다.

  • fromHttpUrl : 기본 바탕이 되는 url을 입력해줍니다.
  • pathSegment : url 뒤에 '/'를 붙이고 그 뒤에 내용을 추가해줍니다. 지금은 요청하는 api 이름이 들어가는 자리입니다. 추후 이 부분도 상수를 활용하면 좋을 것 같습니다.
  • queryParam : 쿼리스트링으로 추가할 내용을 입력해줍니다. key, value 순서로 입력해주면 됩니다.

RestTemplate

Spring 에서 제공하는 Http 요청을 보낼 수 있게 해주는 클래스 입니다. 여러 사용법이 있지만 저는 응답으로 받는 데이터를 압축파일로 처리할 수 있게 코딩했습니다.

UriComponents로 구성한 uri로 목적지를 설정하고 Get 요청을 보냅니다. 그 후 응답을 처리하기 위해서 람다식으로 응답 부분을 처리했습니다.

Path

File 과 비슷한 클래스이지만 File 클래스를 사용하면 테스트 후 파일이 삭제되지 않아서 이 클래스를 사용했습니다. (물론 제가 몰라서 삭제를 못했을 가능성이 큽니다..ㅎ)

아무튼 임시 파일을 하나 만들고 제가 알아보기 쉽게 하기 위해 'opendart-' 라는 접두어를 붙이고 압축파일인 것을 미리 알고 있으니 .zip 확장자도 붙였습니다.

임시파일은

그리고 요청에 대한 응답을 getBody() 메서드로 꺼내보면 압축파일이 바이너리 데이터로 이루어져있습니다. 그래서 readAllBytes() 메서드로 읽어서 방금 만든 임시파일에 내용을 넣어주었습니다.

여기서 입출력에 관련된 메서드 중 read 라는 단어가 앞에 붙으면 InputStream과 관련된 작업을 처리하는 것입니다. 반대는 write 단어가 붙으니 참고로 알아두면 좋습니다.

테스트 검증

어떻게 검증할까 하다가 일단 Null이 아닌지 확인을 하고 압축 파일을 까볼 수 없으니 이정도로 만족할 까 하다가 아까 붙여준 접두어가 잘 붙었는지도 확인했습니다.

파일을 삭제하는 코드를 주석처리 하여 테스트 코드를 실행해보니 사진처럼 압축 파일을 잘 다운로드 받은 것을 알 수 있습니다.

압축 풀기

TDD Code

Test Code

@Test
@DisplayName("zip 파일 압축 파일 내 CORPCODE.xml 파일 추출")
public void getCorpCodeXmlFile() throws IOException {

    // Given
    Path zipFile = Opendart.downloadCorpCodeZip();
    byte[] buf = Files.readAllBytes(zipFile);
    ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(buf));
    ZipEntry zipEntry = null;

    String testDir = "C:\\Users\\dragontiger\\Desktop\\test\\";
    String fileName = "CORPCODE.xml";

    // 이미 존재하는 파일이 있으면 삭제
    Path path = Path.of(testDir + fileName);
    Files.deleteIfExists(path);

    // When
    while ((zipEntry = zipInputStream.getNextEntry()) != null) {
        Files.copy(zipInputStream, Paths.get(testDir + zipEntry.getName()));
    }
    zipInputStream.closeEntry();
    zipInputStream.close();

    // Then
    String[] fileNameArr = new File(testDir).list();
    assertNotNull(fileNameArr);
    assertEquals(fileName, fileNameArr[0]);

    Files.delete(zipFile);
}

코드 설명

downloadCorpCodeZip()

위에서 구현했던 테스트코드를 기능화 한 것 입니다. 똑같은 기능을 하고 압축파일을 반환합니다.

ZipInputStream

압축 파일로 저장 후 다시 그 파일을 읽어들여서 압축을 푸는 과정에 쓰이는 스트림 입니다. 아까 택배로 비유들었던 것으로 보면 컴퓨터가 물류 창고, 집이 자바 프로그램으로 비유 할 수 있겠네요.

컴퓨터에 저장된 파일이 자바 프로그램 안으로 들어오는 것이므로 InputStream을 사용하면 되겠습니다.

ZipEntry는 압축된 요소 하나를 뜻합니다. ZipInputStream에서 nextEntry() 메서드를 사용하면 ZipEntry를 얻을 수 있습니다. 더 이상 요소가 없으면 null이 반환됩니다.

ZipEntry를 통해 파일 명을 알 수 있고 ZipInputStream을 이용해서 압축된 파일을 추출해줍니다.

사용 후엔 꼭 close()를 사용해서 더 이상 사용되지 않는 스트림이라는 것을 알려주세요. 그래야 메모리 관리에 효율적입니다. (택배 기사님 퇴근하는 것이랑 똑같겠네요 ㅎㅎ)

마무리

지금까지 API 응답으로 zip 파일을 다운 받고, Stream을 통해서 압축을 풀기까지 했습니다. 이제 이 데이터를 파싱하는 일만 남았죠.

아마 다음 포스팅으로 하게 될 수 있지만 결론부터 말하면 xml 데이터를 파싱하기 위해서 압축파일을 저장하고 그 파일을 다시 읽어 압축을 푼 파일을 저장하고 그 파일을 다시 읽어서 xml 데이터로 파싱하는 복잡한 과정을 거칠 필요가 없습니다.

다운로드 받은 압축 파일에서 압축 푼 데이터를 바로 파싱하면 됩니다! 바로 코드를 보면 쉽게 이해가 될거에요.

Document document = restTemplate.execute(uriComponents.toUriString(), HttpMethod.GET, null, response -> {
    InputStream inputStream = new ByteArrayInputStream(response.getBody().readAllBytes());
    ZipInputStream zipInputStream = new ZipInputStream(inputStream);
    Document tmpDocument = null;

    try {
         tmpDocument = DocumentBuilderFactory.newDefaultInstance().newDocumentBuilder().parse(zipInputStream);
    } catch (Exception e) {
        e.printStackTrace();
    }

    return tmpDocument;
});

결국 내가 필요한 데이터는 xml 데이터 이므로 중간 과정을 건너뛰어도 상관 없게 된거죠. 이래서 의식의 흐름대로 개발하면 안되고 항상 내가 무엇을 개발하려고 하는지 제대로 알아야 할 필요가 있겠습니다...ㅋ


전체 코드는 Github 에서 볼 수 있습니다.

profile
안녕하세요. 백엔드 엔지니어 입니다.

3개의 댓글

comment-user-thumbnail
2022년 2월 5일

다른 부분은 괜찮은데 response.getBody().readAllBytes() 부분의 readAllBytes 에서 cannot find symbol 떨어지며 안되는데 혹시 확인해야 할 부분이나 버전체크 할 부분이 있을까요?

답글 달기
comment-user-thumbnail
2023년 2월 28일

최종 코드로 테스트하고 있는데 아래 오류가 발생합니다. 혹시 해결 방법이 있으신가요?
[Fatal Error] :1:1: 예기치 않은 파일의 끝입니다.

1개의 답글