개발자취

Java Mail API의 SharedFileInputStream 오류(로 추정되는 것) 수정해보기

SeongOnion 2023. 7. 23. 11:37
728x90

SharedFileInputStream?

A SharedFileInputStream is a BufferedInputStream that buffers data from the file and supports the mark and reset methods. It also supports the newStream method that allows you to create other streams that represent subsets of the file. A RandomAccessFile object is used to access the file data. Note that when the SharedFileInputStream is closed, all streams created with the newStream method are also closed. This allows the creator of the SharedFileInputStream object to control access to the underlying file and ensure that it is closed when needed, to avoid leaking file descriptors. Note also that this behavior contradicts the requirements of SharedInputStream and may change in a future release.

 

SharedFileInputStream은 Java Mail API에서 지원하는 InputStream의 한 종류이며 SharedInputStream 인터페이스의 구현체이다.

 

하나의 파일에서 데이터를 읽어와 여러 군데에서 사용해야 하는 상황에서, 각 사용처가 모두 접근할 수 있는 RandomAccessFile 객체를 하나 띄워놓고 해당 파일에 대해 저마다의 새 InputStream을 생성해 데이터에 접근할 수 있도록 돕는다.

 

해당 객체의 구체적인 작동 방식은 다음과 같다.

  1. 초기 생성 시 init() 메서드를 호출해 특정 파일에 접근 가능한 RandomAccessFile 을 만들어 멤버 변수로 선언하고 해당 SharedFileInputStream은 master가 된다.
  2. newStream(long start, long end) 메서드를 통해 해당 파일의 특정 부분에 접근할 수 있는 slave SharedFileInputStream을 생성한다.
  3. slave는 master가 생성한 RandomAccessFile에 대한 참조값(정확히는 RandomAccessFile을 감싼 SharedFile 클래스)을 갖고 접근할 수 있다.
  4. master인 SharedFileInputStreamclose() 되면 RandomAccessFile 또한 close() 되므로 slave인 SharedFileInputStream은 더 이상 RandomAccessFile에 접근할 수 없다.

위 작동 방식 및 구조를 간략하게 그림으로 표현하면 다음과 같다.

실제로 Java Mail API의 메일 메시지의 표준격인 MimeMessage에서도 SharedInputStream를 별도로 처리해주고 있다.

public class MimeMessage extends Message implements MimePart {
    protected void parse(InputStream is) throws MessagingException {

      if (!(is instanceof ByteArrayInputStream) &&
          !(is instanceof BufferedInputStream) &&
          !(is instanceof SharedInputStream))
          is = new BufferedInputStream(is);

      headers = createInternetHeaders(is);

      // inputStream이 SharedInputStream인 경우 newStream() 으로 contentStream을 별도 세팅한다.
      if (is instanceof SharedInputStream) {
          SharedInputStream sis = (SharedInputStream)is;
          contentStream = sis.newStream(sis.getPosition(), -1);
      } else {
          try {
          content = ASCIIUtility.getBytes(is);
          } catch (IOException ioex) {
          throw new MessagingException("IOException", ioex);
          }
      }

      modified = false;
      }
}

해당 클래스는 파일에 접근하고자 할 때마다 사용처들이 직접 Byte 값들을 복사해 보관하는 것이 아닌 InputStream을 생성해주므로 메모리에 대한 부담을 많이 낮출 수 있고, 실제로 OOM 이슈로 굉장히 고생했던 프로젝트에 적용해 테스트 했을 때도 크나큰 메모리적 이점이 있음을 확인했다.

 

문제 상황

하지만 생각보다 해당 클래스의 적용이 쉽지 않았다.

 

SharedFileInputStream을 통해 생성한 MimeMessage에서 계속해서 IOException: Stream Closed 오류가 발생했다....

해당 오류도 매번 발생하는 것이 아닌 간헐적으로 발생했고, 내 PC에선 발생했다가 동료 분 PC에선 발생하지 않고.. 발생 원인을 찾기가 여간 쉽지 않았다.

몇 시간 혹은 며칠간의 삽질 끝에 해당 오류가 GC(가비지 컬렉션)와 밀접한 연관이 있음을 확인했다..!

// 성공
@Test
void master_with_reference_test() throws Exception {
    File sampleFile = getSampleFile();

    // master를 변수로 할당
    SharedFileInputStream masterSharedFileIs = new SharedFileInputStream(sampleFile);
    InputStream slaveSharedFileIs = masterSharedFileIs.newStream(0, -1);

    System.gc();

    assertDoesNotThrow(() -> slaveSharedFileIs.read());
}

// 실패
@Test
void master_with_no_reference_test() throws Exception {
    File sampleFile = getSampleFile();

    // master 변수 할당 없이 바로 slave 생성
    InputStream slaveSharedFileIs = new SharedFileInputStream(sampleFile).newStream(0, -1);

    System.gc();

    assertDoesNotThrow(() -> slaveSharedFileIs.read());
}

위 두 개의 테스트 코드를 보자.

 

첫 번째의 코드는 최초 생성한 master SharedFileInputStream을 변수로 할당한 후, slave SharedFileInputStream을 생성한 경우이다.

 

그리고 두 번째 코드는 master SharedFileInputStream에 대한 변수 할당 없이 바로 slave SharedFileInputStream을 생성한 경우이다.

 

로직상 다른 부분이라곤 1도 없지만 첫 번째 테케는 통과하고 두 번째 테케는 Stream Closed 오류가 발생한다.

 

그 이유는 GC가 돌면서 참조가 없는 master SharedFileInputStream을 청소해버리기 때문이다!

 

GC가 master SharedFileInputStreamfinalize()하며 RandomAccessFileclose() 해버리기 때문에, 비록 slave SharedFileInputStream에 대한 참조가 남아있다고 하더라도 파일에 접근할 수 없게 되는 것이다.

SharedFileInputStream의 javadoc을 참고해보면, RandomAccessFilestatic 클래스인 SharedFile로 한 번 감싸서 GC에 해당 클래스가 타겟팅 되지 않도록 유도한 것으로 보이지만,

 

SharedFileInputStream 클래스 자체가 GC에 잡혀 finalize() -> close() 메서드를 호출해버리면 해당 방법은 크게 유의미하지 않을 것 같다.

 

해결 방법

이 문제를 해결하기 위한 핵심 포인트는 master인 SharedFileInputStream이 GC 타겟에 잡히지 않도록 하는 것이다.

 

이는 즉, 모든 slave SharedFileInputStream의 참조가 사라지기 전까진 master SharedFileInputStream 인스턴스에 대한 참조가 사라지지 않도록 하는 것이다.

 

이를 위해 각 slave SharedFileInputStream이 master SharedFileInputStream을 참조하도록 하는 방법을 고안했다.

기존코드

public class SharedFileInputStream extends BufferedInputStream
                implements SharedInputStream {

    ...

    /**
     * True if this is a top level stream created directly by "new".
     * False if this is a derived stream created by newStream.
     */
    private boolean master = true;

    ...
 }

기존 코드에는 해당 SharedFileInputStream이 master 인지에 대한 여부만 boolean 타입으로 갖고 있었다.

 

기본값은 true이고, slave를 만들 때는 해당 값에 false를 할당한다.

 

다음은 위에서 언급했던 slave를 생성하는 newStream() 메서드이다.

    /**
     * Return a new InputStream representing a subset of the data
     * from this InputStream, starting at <code>start</code> (inclusive)
     * up to <code>end</code> (exclusive).  <code>start</code> must be
     * non-negative.  If <code>end</code> is -1, the new stream ends
     * at the same place as this stream.  The returned InputStream
     * will also implement the SharedInputStream interface.
     *
     * @param    start    the starting position
     * @param    end    the ending position + 1
     * @return        the new stream
     */
    @Override
    public synchronized InputStream newStream(long start, long end) {
    if (in == null)
        throw new RuntimeException("Stream closed");
    if (start < 0)
        throw new IllegalArgumentException("start < 0");
    if (end == -1)
        end = datalen;
    return new SharedFileInputStream(sf,
            this.start + start, end - start, bufsize);
    }

newStreamprivate constructor를 호출해 새 객체를 생성 후 반환하는데, 해당 코드는 아래와 같다.

    /**
     * Used internally by the <code>newStream</code> method.
     */
    private SharedFileInputStream(SharedFile sf, long start, long len,
                int bufsize) {
    super(null);
    this.master = false;
    this.sf = sf;
    this.in = sf.open();
    this.start = start;
    this.bufpos = start;
    this.datalen = len;
    this.bufsize = bufsize;
    buf = new byte[bufsize];
    }

주석에서도 알 수 있듯, 해당 생성자는 오로지 newStream() 메서드에서만 호출되고 따라서 slave를 만들 때만 사용되는 생성자이다.

따라서 this.master 값은 false로 세팅된다.

 

커스터마이징 (수정 후)

이제 생각했던대로 slave를 만들 때 master에 대한 참조를 갖도록 변경해보자.

 

먼저, 해당 SharedFileInputStream의 master 여부 뿐 아니라, master SharedFileInputStream에 대한 참조값을 멤버 변수로 추가한다.

public class SharedFileInputStream extends BufferedInputStream
                implements SharedInputStream {

    ...

    /**
     * True if this is a top level stream created directly by "new".
     * False if this is a derived stream created by newStream.
     */
    private boolean master = true;

    // master에 대한 참조값
    private SharedFileInputStream masterInputStream = null;

    ...
 }​

해당 SharedFileInputStream이 master인 경우, 기본값을 null로 설정한다.

 

이제 newStream 메서드를 수정해보자.

    /**
     * Return a new InputStream representing a subset of the data
     * from this InputStream, starting at <code>start</code> (inclusive)
     * up to <code>end</code> (exclusive).  <code>start</code> must be
     * non-negative.  If <code>end</code> is -1, the new stream ends
     * at the same place as this stream.  The returned InputStream
     * will also implement the SharedInputStream interface.
     *
     * @param    start    the starting position
     * @param    end    the ending position + 1
     * @return        the new stream
     */
    @Override
    public synchronized InputStream newStream(long start, long end) {
    if (in == null)
        throw new RuntimeException("Stream closed");
    if (start < 0)
        throw new IllegalArgumentException("start < 0");
    if (end == -1)
        end = datalen;

    SharedFileInputStream masterIs;

    if (this.master)
        masterIs = this;
    else
        masterIs = this.masterInputStream;

    return new SharedFileInputStream(masterIs, sf,
            this.start + start, end - start, bufsize);
    }

slave를 생성할 때 해당 SharedFileInputStream의 master를 넘겨주는 것이 목표이므로, 만약 newStream을 호출하는 현재 인스턴스가 master면 자기 자신을, 그렇지 않으면 자신이 생성되며 넘겨받은 masterInputStream을 넘겨준다.

 

이에 맞게 SharedFileInputStream의 생성자도 수정해준다.

/**
     * Used internally by the <code>newStream</code> method.
     */
    private SharedFileInputStream(SharedFileInputStream masterInputStream, SharedFile sf, long start, long len,
                                  int bufsize) {
        super(null);
        this.masterInputStream = masterInputStream;
        this.master = false;
        this.sf = sf;
        this.in = sf.open();
        this.start = start;
        this.bufpos = start;
        this.datalen = len;
        this.bufsize = bufsize;
        buf = new byte[bufsize];
    }

간단하다. 자신의 masterInputStream 변수에 넘겨받은 값을 할당해주기만 하면 된다.

 

이제 수정은 모두 끝났으니 한 번 돌려본다.

 

먼저 디버거로 slave에 master SharedFileInputStream에 대한 참조값이 잘 설정되었는지부터 확인해보자.

성공이다. slave SharedFileInputStream에 master SharedFileInputStream의 주소값인 2693번이 제대로 설정됨을 확인할 수 있다.

 

테스트 코드 실행 시에도 기존에 실패했던 것까지 모두 성공하였다..!

 

메모리 leak은 없는가?

이론 상 master는 결국 모든 slave의 참조가 해제되면 자신의 참조도 해제되므로 자연스레 master가 GC에 잡히는 시점은 모든 slave의 참조가 해제되는 시점이라고 예상했다.

 

하지만 그래도 최소한의 로깅 정도만 찍어서 참조 해제 후 GC를 돌렸을 때 InputStream이 잘 close() 되는지 확인해보고자 했다.

@Override
public void close() throws IOException {
    System.out.println("Closing SharedFileInputStream. is master: " + this.master);
    ...
}

다행히 예상한대로 salve가 close() 되고나서 master도 곧이어 close() 되는 것을 확인할 수 있었다..!

 

마치며

해당 수정사항은 아직은 프로젝트의 개발환경 정도에만 반영하여 테스트 및 모니터링을 진행하고 있다. (다행히 아직까진 아무 문제 없었다)

 

사실 GC에 대한 개념만 알았지, 코드 단에서 이를 직접 체감할 일이 드문데 이번 기회를 통해서 큰 수확을 얻어가는 것 같다.

 

위 수정내용은 Java Mail API에도 이슈 및 Pull Request로 등록해놓은 상태이다.

 

https://github.com/jakartaee/mail-api/pull/695

 

Retain reference to master SharedFileInputStream by SeongEon-Jo · Pull Request #695 · jakartaee/mail-api

resolves #694 Added reference to master SharedFileInputStream to slaves. By doing this, I guess it can retain reference to master SharedFileInputStream in order not to be junked by garbage collecti...

github.com

 

SharedFileInputStream 클래스가 2006년 5월 JavaMail 1.4 버전부터 추가되어서 이후엔 이렇다 할 업데이트가 없었다는 면에서 내 PR이 적절히 검토되거나 반영될지는 확신이 없다.

 

하지만 반영 여부를 떠나서 내가 나름대로 수정한 코드에 나도 몰랐던 문제가 있거나 혹은 더 나은 해결방안이 존재한다는 피드백 정도만 받아도 큰 도움이 될 것 같다.