La foret rouge
Published on

Scavenger에 Export 기능 추가하기

Authors
  • avatar
    Name
    신주용

Scavenger

Scavenger1는 실행 되지 않거나 더이상 사용되지 않는 코드인 데드 코드를 정적 분석이 아니라 애플리케이션이 실제로 실행되는 런타임에 분석할 수 있는 도구입니다.

코드에는 클래스나 메소드, 다른 패키지 import 등 다양한 경우가 있지만 Scavenger는 이 중 호출되지 않는 메소드를 탐색하는 역할을 하는 도구입니다. 안 입는 옷을 방 바닥에 던져두면 엄마에게 등짝 스매시를 맞지만 지금 안 쓰이는 코드를 그냥 둔다고 해서 당장 큰 문제로 이어지지는 않습니다. 하지만 이런 코드가 하나 둘씩 쌓이기만 하고 지우지 않는다면 팀에 새로 합류하는 개발자가 이해하기 어려울 수도 있고, 코드의 길이가 늘어나니 빌드 등에 걸리는 시간도 길어지게 됩니다2.

특히 Scavenger에서 제가 관심이 갔던 이유는 Scavenger가 자바 소스 코드를 분석하고 바이트코드 수정 기술을 사용해 런타임에 기능을 추가할 수 있기 때문입니다. 저는 Soot3이라는 도구를 이용해 컴파일 타임에 로그를 추가하고 이를 활용하여 추가적인 분석을 하고 있었는데, 실행 시 jar 파일만 추가하면 되는 javaagent 방식을 고민하고 있어서 참고하고 싶었습니다. 또, collector, frontend까지 다 제공되어 결과를 GUI로 볼 수 있다는 점도 좋아보였습니다. 그리고 개발중인 파이썬 에이전트도 있다고 해서 여러모로 써봐야겠다고 생각했습니다. 발표 마지막이 너무 인상적이었던 것도 이유 중 하나입니다.

실행

Scavenger를 실행하기 위해서는 데이터 수집을 담당하는 Collector, 웹 UI와 API를 제공하는 API를 먼저 실행해야 합니다. Jar 파일로 제공하고 있기 때문에 java -jar scavenger-collector.jar와 같이 어렵지 않게 실행할 수 있습니다. 그 다음 우리가 분석하려고 하는 대상 프로그램을 실행할 때 Agent jar 파일과 설정파일을 java -javaagent:scavenger-agent.jar -Dscavenger.configuration=scavenger.conf myApp와 같이 포함해서 실행하면 Scavenger의 데이터 수집이 시작됩니다. 자세한 설치 방법과 환경 변수는 Scavenger 레포지토리의 README를 참고해주세요. 실행 결과는 다음과 같습니다.

Scavenger 실행 사진

이렇게 어떤 클래스에 몇 개의 메소드가 있고, 그 중 몇 퍼센트가 사용됐는지, 어떤 메소드가 언제 마지막으로 호출됐는지와 같은 정보를 얻을 수 있습니다.

그런데 저는 이렇게 호출된 메소드 목록을 이용해 추가적인 분석을 수행하고 싶었는데, 당시 Scavenger에서는 결과를 내보낼 수 있는 기능이 없었습니다. 웹사이트의 결과를 일일이 복사 + 붙여넣기를 하기에는 메소드 개수가 너무 많았고, Collector의 DB에 쿼리해서 데이터를 바로 뽑아쓸 수도 있었지만, 아무래도 웹사이트에서 결과물을 보면서 바로 내보내기 버튼을 클릭하여 다운로드를 받는 것이 더 편할 것 같았습니다.

그래서 버튼과 api를 구현한 첫 PR을 올렸습니다. 이 PR을 올릴 떄 신경썼던 부분은 내보내기 기능과 관련해서

  • 웹에서 보던 것과 유사하고, Pandas에서 가져다 쓰기 쉬우려면 CSV(Comma-Seperated Values) 형식을 사용하고자 했습니다.
  • 하지만, 자바의 메소드 Signature를 보면 com.example.handleLocals(int,long,java.util.HashMap)와 같이 파라미터가 여러 개일 때 ',(쉼표)'로 구분되므로 열(데이터)을 쉼표로 구분하는 CSV 파일로 내보낼 시 충돌하여(어디까지가 하나의 데이터인지를 컴퓨터는 구분할 수 없으므로) 열이 엉망이 될 수 있습니다.
  • 그래서 형식은 CSV와 동일하나 열 구분자로 '탭(\t)'을 사용하는 TSV(Tab-Seperated Values) 형식을 사용했습니다.
Scavenger add Export feature PR

Reviews

이후 감사하게도 구체적인 코드 리뷰를 많이 받았습니다. 그 중 기억에 남는 리뷰 몇 개를 돌아보자면 다음과 같습니다.

시간 변수 타입 문제

일반적으로 자바에서는 시간을 얻기 위해 System.currentTimeMills() 함수를 자주 사용합니다. 그러면 숫자만 잔뜩 있는 Unix timestamp 형태의 시간값을 반환해 줍니다. 하지만 저는 제가(사람이) 보기 위해 파일로 내보내기를 하려는 것이므로 lastSeenAtMills와 같은 변수의 타입을 String으로 설정해서 PR을 올렸습니다.

ExportSnapshotMethodDto.kt
data class ExportMethodInvokationDto(
  // ...
  val lastSeenAtMillis: String,
  val invokedAtMillis: String,
)

이에 대해 시간 변수를 Instance 타입으로 바꾸는게 좋을 것 같다는 제안을 받았습니다. 그래서 수정을 하기 위해 다른 파일을 참고했는데 다른 파일에서는 시간 변수가 long 타입으로 선언이 되어있었습니다. 그래서 제가 제대로 이해한 것이 맞는지 확인하고자 댓글을 남겼고 "long 타입은 legacy 코드로부터 온 것"이라고 그 원인과 함께 왜 Instance 타입으로 변경하는 것을 제안했는지도 알려주셨습니다. 그래서 수정은 다음과 같이 진행했습니다.

  • 프로세스 간 데이터 전송을 위한 객체인 DTO에서는 시간을 Instance 타입으로 설정하여 출력이 아닌 다른 용도로 활용하기에도 쉽도록 했고,

    ExportSnapshotMethodDto.kt
    import java.time.Instant
    
    data class ExportSnapshotMethodDto(
      val filterInvokedAtMillis: Instant?,
      // ...
      val lastInvokedAtMillis: Instant?
    ) {
      companion object {
        fun from(entity: ExportSnapshotMethodEntity): ExportSnapshotMethodDto {
          return ExportSnapshotMethodDto(
            filterInvokedAtMillis = entity.filterInvokedAtMillis?.let { Instant.ofEpochMilli(it) },
            // ...
    
  • kotlin-csv를 이용해 출력하기 직전에 toString()을 사용해 문자열 형식으로 변환하여 출력합니다.

    ExportSnapshotMethodDto.kt
    @Service
    class ExportSnapshotMethodService(
      var exportSnapshotMethodRepository: ExportSnapshotMethodRepository
    ) {
      fun writeDtoToTsv(stream: OutputStream, customerId: Long, snapshotId: Long) {
        // ...
        try {
          data.forEach { entity: ExportSnapshotMethodEntity ->
          val dto = ExportSnapshotMethodDto.from(entity)
          rows.add(
            listOf(
              dto.filterInvokedAtMillis?.toString().orEmpty(),
              // ...
    

Iterator 사용

메소드 목록을 tsv 파일로 내보낼 때 처음에는 제가 익숙한 forEach 구문을 사용했고, rows 배열을 만들어 한 번에 파일로 쓰도록 구현했습니다.

ExportSnapshotMethodService.kt
fun writeDtoToTsv(stream: OutputStream, customerId: Long, snapshotId: Long) {
  // ...
  try {
    rows = mutableListOf(
      listOf(
        "filterInvokedAtMillis",
        // headers ...
      )
    )

    data.forEach { entity: ExportSnapshotMethodEntity ->
      val dto = ExportSnapshotMethodDto.from(entity)
      rows.add(
        listOf(
          dto.filterInvokedAtMillis?.toString().orEmpty(),
          // values ...
        )
      )
    }

    csvWriter {
      delimiter = '\t'
    }.writeAll(
      rows = rows,
      ops = stream
    )
  }

그리고 이를 Iterator를 사용하는 방법으로 변경하는 것을 제안받았습니다. (다른 코드에도 비슷한 형태로 Scavenger 팀의 코드 컨벤션인 것 같았습니다. 이를 계기로 함수형 프로그래밍에 대해서도 더 찾아보고 싶었습니다.)

exportSnapshotMethodRepository.findSnapshotMethodExport(customerId, snapshotId)
    .map { ExportSnapshotMethodDto.from(it).toCsv() }

이를 구현하는 과정에서 kotlin-csv의 README와 테스트 케이스를 살펴봤는데, 제가 처음 했던 것처럼 writeAll이나 writeRows 함수가 아니라 각 row에 대해 적용하려면 I/O stream을 open()함수로 열고 블록 내부에서 작업해야 할 것 같았습니다. 그래서

  • Iterator가 반복하면서 DTO 객체를 string list로 변환할 수 있도록 DTO 부분에 toList() 메소드를 구현했고,
    data class ExportSnapshotMethodDto(
      // params
    ) {
      companion object {
        // ...
        fun toList(): List<String> {
          return listOf(
              this.filterInvokedAtMillis?.toString().orEmpty(),
              this.packages,
              this.status,
              this.excludeAbstract?.toString().orEmpty(),
              this.parent,
              this.signature,
              this.type,
              this.usedCount.toString(),
              this.unusedCount.toString(),
              this.lastInvokedAtMillis?.toString().orEmpty()
          )
        }
      }
    // ...
    
  • csvWriter 객체를 초기화하고 open()하는 것은 동일하게 적용하는 대신 findAllExportSnapshotNode()의 결과로 받아오는 List<ExportSnapshotMethodDto>에 대해 map 함수를 사용할 수 있도록 했습니다.
     fun writeSnapshotToTsv(stream: OutputStream, customerId: Long, snapshotId: Long) {
      try {
        csvWriter {
          delimiter = '\t'
        }.open(stream) {
          writeRow(
            listOf(
              "filterInvokedAtMillis",
              // keys ...
            )
          )
          snapshotNodeDao.findAllExportSnapshotNode(
            customerId = customerId,
            snapshotId = snapshotId
          ).map { writeRow(ExportSnapshotMethodDto.from(it).toList()) }
        }
      } // ...
    

이렇게 구현하고 예시와 다르게 구현한 이유를 커밋 메시지에 추가했는데 👍 받았습니다. :)

API path

이 PR을 올릴 때 처음에 제 생각에는 export 기능은 snapshot 뿐만 아니라 다른 정보에도 사용 가능하지 않을까 싶어서 exportSnapshot 클래스로 만들었습니다. 그런데 리뷰 중에 export 메소드를 snapshot 클래스 아래로 옮기자고 하셨고 그렇게 수정했습니다. 해당 커밋에서는 리뷰 해주신 대로 메소드 위치만 옮겼고 잊고 있다가 API 주소가 갑자기 생각이 났습니다. 그래서 메소드 위치와 통일하기 위해 API 주소도 /export/snapshot/{snapshotId}에서 /snapshot/{snapshotId}/export로 변경하는 커밋을 올렸고, very good이라 해주셔서 기분 좋았습니다 ㅎㅎ

그 외에도 저는 Scavenger 개발 팀이 아닌 외부인으로서 오픈소스에 기여를 했기 때문에 다른 파일을 최대한 참고해서 변수명이나 코드 스타일을 맞췄으나 팀이 추구하는 방향과는 다른 부분이 없을 수 없었습니다. 이런 사소한 부분에 대해서도 리뷰를 꼼꼼하게 해주셔서 감사했습니다.

그리고 제가 JavaScript에 익숙하지 않아서 frontend 부분을 조금만 바꿀 때도 매 번 새로 빌드하는게 너무 오래 걸리는 것 같은데 혹시 팀원분들 개발하실 땐 어떻게 했는지를 여쭤봤는데 이 부분도 친절하게 알려주셨습니다. 덕분에 vite를 처음 알게 되었는데 빌드 속도가 정말 빨라서 다음에 개인 프로젝트로 frontend를 할 일이 있으면 꼭 써봐야겠다 싶었습니다.

마무리

youtube: demo

Scavenger 팀원 분들께서 리뷰를 구체적으로 해주셔서 수정을 빠르게 할 수 있었고, 이유를 여쭤봤을 때도 잘 알려주셔서 이유를 이해하고 고칠 수 있었던 것이 좋았습니다.

Scavenger는 간단한 설치로 프로그램의 Dead Code를 확인할 수 있는 좋은 툴이어서 앞으로도 계속 쓰게 될 것 같습니다. 사용하다가 또 추가하고 싶은 기능이 생겼을 때 또 기여할 수 있으면 좋겠습니다.

Footnotes

  1. "naver/scavenger." github.com. https://github.com/naver/scavenger (accessed May. 21, 2023).

  2. 김태연, 권오준. "런타임 데드 코드 분석 도구 Scavenger - 당신의 코드는 생각보다 많이 죽어있다." deview.kr. https://deview.kr/data/deview/session/attach/[225]런타임+데드코드+분석+Scavenger+-+당신의+코드는+생각보다+많이+죽어있다..pdf (accessed May. 21, 2023).

  3. R. Vallée-Rai, P. Co, E. Gagnon, L. Hendren, P. Lam, and V. Sundaresan, "Soot - a java bytecode optimization framework," IBM Press, Nov. 1999, p. 13. [Online]. Available: https://dl.acm.org/doi/10.5555/781995.782008.