분석 결과를 Excel로 정리하기 - Apache POI 활용기

2026. 1. 10. 23:09·토스 러너스하이 2기/기술

분석은 됐는데, 산출물은 어떻게 만들지?
산출물 작성할 때 참고할 수 있는 레퍼런스가 필요했다. Apache POI로 Excel을 생성하면서 배운 것들.


3줄 요약

  • Excel 출력은 "산출물 작성 시 복사/붙여넣기, 필터링으로 참고할 레퍼런스"다
  • 계층 구조(트리)는 Excel 필터링에 불편하다 → 평면 테이블로 변환
  • 파라미터는 Controller + SQL 합집합으로 표시하는 게 실용적이다

1. 들어가며

이전 글에서 CLI 출력을 구현했다. 터미널에서 분석 결과를 바로 확인할 수 있게 됐다.

CLI만으로는 부족하다

[GET] /api/user/list
├── [Controller] UserController.getList()
│   └── [Service] UserService.findAll()
│       └── [DAO] UserDAO.selectList()
│           → SQL: User_SQL.xml / selectUserList

개발자 혼자 빠르게 확인하기엔 충분하다. 그런데 산출물을 작성해야 할 때는?


산출물 작성, 매번 수작업

공공 SI 프로젝트에서는 프로그램 목록 같은 산출물을 작성해야 한다. 내가 개발한 기능에 대해 이런 정보들이 필요했다:

  • Controller, Service, ServiceImpl, DAO, XML 파일명
  • 각 레이어의 메소드명
  • 사용된 테이블명
  • SQL ID

매번 IDE에서 파일 열어보고, 메소드 찾아보고, XML에서 테이블 확인하고... 수작업의 연속이었다.

"이거 자동으로 정리해주는 게 있으면 좋겠다"


새 프로젝트, CRUD 분석이 필요하다

프로젝트 주제를 정할 때쯤, 새로 투입될 프로젝트에 대해 상사분께 전화로 사전 설명을 들었다.

"구 DBMS에서 신 DBMS로 이관하는 프로젝트야. 양방향 동기화가 필요해서, 구 시스템에서 어떤 테이블에 CRUD가 일어나는지 분석해야 할 거야."

이 말을 듣고 생각했다:

  • 어떤 API가 어떤 테이블에 INSERT/UPDATE/DELETE 하는지 파악
  • → 동기화 대상 테이블 선정
  • → 영향도 분석

호출 흐름 분석 + CRUD 정보가 함께 있으면 이런 분석에 바로 활용할 수 있겠다.


왜 Excel인가?

산출물마다 형식이 다르다. 모든 산출물에 딱 맞는 포맷을 만들 수는 없다.

그래서 생각한 게 "참고용 레퍼런스":

  • 필요한 정보를 필터링해서 찾고
  • 복사/붙여넣기로 산출물에 옮기고
  • 필요하면 편집해서 사용
포맷 필터링 편집 복사/붙여넣기 결론
PDF ❌ ❌ △ 보기만 가능
HTML △ ❌ △ 웹 서버 필요
CSV ❌ ✅ ✅ 스타일링 불가
Excel ✅ ✅ ✅ 채택

Excel은 자동 필터 + 편집 + 복사/붙여넣기가 모두 가능하다. 공공기관에서 누구나 열 수 있는 표준 포맷이기도 하다.


비유: 수작업 조사 vs 정리된 레퍼런스

구분 기존 방식 Excel 레퍼런스
비유 매번 도서관에서 책 찾기 정리된 색인집
파일명 확인 IDE에서 직접 열어봄 필터로 검색
테이블 확인 XML 파일 뒤져봄 한눈에 목록 확인
산출물 작성 하나씩 타이핑 복사/붙여넣기

결국 "매번 수작업 조사"를 "정리된 레퍼런스 참고"로 바꾸는 것이 Excel 출력의 목표다.


2. Apache POI란?

한 줄 정의

Apache POI = Java에서 Microsoft Office 파일을 읽고 쓰는 라이브러리

  • POI: "Poor Obfuscation Implementation"의 약자 (MS Office 포맷이 복잡해서 붙은 이름)
  • Excel(.xlsx), Word(.docx), PowerPoint(.pptx) 모두 지원
  • Apache 재단에서 관리하는 오픈소스

왜 Apache POI를 선택했나?

라이브러리 특징 선택 이유
Apache POI Java 표준, xlsx 지원, 풍부한 스타일링 ✅ 채택
JExcel 간단하지만 xls만 지원 (구버전) xlsx 필요
EasyExcel 대용량 특화, 중국 알리바바 오버스펙

Apache POI는 가장 널리 쓰이고, 문서가 풍부하다. 처음 배우기에 적합했다.


의존성 추가

// build.gradle
dependencies {
    implementation 'org.apache.poi:poi-ooxml:5.2.5'
}

poi-ooxml을 추가하면 .xlsx 형식을 다룰 수 있다.


3. Apache POI 기본 구조

계층 구조

Workbook (엑셀 파일)
├── Sheet (시트)
│   ├── Row (행)
│   │   ├── Cell (셀)
│   │   ├── Cell
│   │   └── Cell
│   └── Row
└── Sheet

Excel 파일 구조가 그대로 클래스로 매핑된다.


기본 사용법

// 1. Workbook 생성 (엑셀 파일)
Workbook workbook = new XSSFWorkbook();  // .xlsx 형식

// 2. Sheet 생성
Sheet sheet = workbook.createSheet("시트이름");

// 3. Row 생성 (0부터 시작)
Row row = sheet.createRow(0);

// 4. Cell 생성 (0부터 시작)
Cell cell = row.createCell(0);
cell.setCellValue("Hello, Excel!");

// 5. 파일 저장
try (FileOutputStream fos = new FileOutputStream("output.xlsx")) {
    workbook.write(fos);
}

핵심: Workbook → Sheet → Row → Cell 순서로 생성.


스타일 적용

// CellStyle 생성
CellStyle headerStyle = workbook.createCellStyle();

// 배경색
headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);

// 테두리
headerStyle.setBorderBottom(BorderStyle.THIN);
headerStyle.setBorderTop(BorderStyle.THIN);

// 폰트
Font font = workbook.createFont();
font.setBold(true);
headerStyle.setFont(font);

// 셀에 스타일 적용
cell.setCellStyle(headerStyle);

주의: CellStyle은 Workbook 단위로 생성해야 한다. 셀마다 새로 만들면 메모리 낭비.


4. 시트 구성 전략

개발자가 언제 이 Excel을 쓸까?

산출물 작성하면서 이런 상황이 생긴다:

  • "이 API가 어떤 Service, DAO 거치지?" → 호출 흐름 확인
  • "이 테이블 쓰는 SQL이 뭐였지?" → SQL 목록에서 검색
  • "분석 범위가 어디까지지?" → 전체 현황 파악

한 시트에 다 넣으면? 정보 과부하. 용도별로 시트를 분리했다.


3개 시트 구성

시트 언제 쓰나 내용
요약 분석 범위/현황 파악할 때 프로젝트 정보, 통계 (API 수, SQL 수)
호출 흐름 산출물에 메소드/파일명 쓸 때 Controller → Service → DAO 흐름
SQL 목록 테이블/SQL 정보 찾을 때 SQL ID, 타입, 테이블, 쿼리
public void export(FlowResult result, Path outputPath) {
    try (Workbook workbook = new XSSFWorkbook()) {
        createSummarySheet(workbook, result);    // 시트 1
        createCallFlowSheet(workbook, result);   // 시트 2
        createSqlListSheet(workbook, result);    // 시트 3

        // 파일 저장
        try (FileOutputStream fos = new FileOutputStream(outputPath.toFile())) {
            workbook.write(fos);
        }
    }
}

요약 시트

┌─────────────────────────────────────────┐
│  Code Flow Tracer - 분석 결과           │
├─────────────────────────────────────────┤
│  프로젝트 경로: C:\project\sample       │
│  분석 일시: 2025-12-20 14:30:00         │
│  URL 필터: /api/**                      │
├─────────────────────────────────────────┤
│  분석 통계                              │
│  ├── 전체 API: 15개                     │
│  ├── Controller: 3개                    │
│  ├── Service: 5개                       │
│  └── SQL: 12개                          │
└─────────────────────────────────────────┘

분석 범위와 전체 현황을 한눈에 파악할 수 있다.


5. 계층 구조 → 평면 테이블

문제: 트리 구조는 필터링이 어렵다

CLI에서는 트리 구조가 직관적이다:

[GET] /api/user/list
├── UserController.getList()
│   └── UserService.findAll()
│       └── UserDAO.selectList()

이걸 그대로 Excel에 넣으면?

API 호출 흐름
/api/user/list UserController.getList() → UserService.findAll() → UserDAO.selectList()

문제점:

  • 한 셀에 전부 들어가서 필터링 불가
  • "UserDAO를 쓰는 API 찾기" → 불가능

해결: 평면 테이블로 변환

API Controller Service DAO SQL
/api/user/list UserController.getList UserService.findAll UserDAO.selectList selectUserList
/api/user/detail UserController.getDetail UserService.findById UserDAO.selectById selectUserById

레이어별로 컬럼 분리하면:

  • "UserDAO" 필터 → 관련 API 모두 검색 가능
  • 각 레이어별 통계 산출 가능

평면화 로직

/**
 * 트리 구조를 평면 테이블로 변환
 */
private List<FlatFlowRow> flattenFlowTree(FlowNode root) {
    List<FlatFlowRow> rows = new ArrayList<>();
    flattenRecursive(root, new FlatFlowRow(), rows);
    return rows;
}

private void flattenRecursive(FlowNode node, FlatFlowRow currentRow, List<FlatFlowRow> rows) {
    // 현재 노드 타입에 따라 컬럼 채우기
    switch (node.getLayerType()) {
        case CONTROLLER:
            currentRow.controller = node.getClassName() + "." + node.getMethodName();
            break;
        case SERVICE:
            currentRow.service = node.getClassName() + "." + node.getMethodName();
            break;
        case DAO:
            currentRow.dao = node.getClassName() + "." + node.getMethodName();
            break;
        case SQL:
            currentRow.sql = node.getSqlInfo().getSqlId();
            rows.add(currentRow.copy());  // 리프 노드에서 행 추가
            return;
    }

    // 자식 노드 재귀 탐색
    for (FlowNode child : node.getChildren()) {
        flattenRecursive(child, currentRow, rows);
    }
}

핵심: SQL(리프 노드)에 도달할 때마다 한 행을 생성.


비유: 족보 vs 명단

트리 구조 (족보) 평면 테이블 (명단)
계층이 명확 검색/필터링 용이
관계 파악 쉬움 통계 산출 쉬움
콘솔 출력에 적합 Excel에 적합

족보를 보면 가문의 계보가 보인다. 하지만 "김씨가 몇 명인지"는 세어봐야 안다.
명단을 보면 한 줄에 한 명. 필터 걸면 바로 김씨만 추출.

용도에 따라 같은 데이터도 다른 형태로 보여줘야 한다.


6. 레이어별 색상 구분

왜 색상이 필요한가?

| Controller | Service | DAO | SQL |
|------------|---------|-----|-----|
| UserController.getList | UserService.findAll | UserDAO.selectList | selectUser |
| UserController.getList | UserService.findAll | UserDAO.selectDept | selectDept |
| UserController.getDetail | UserService.findById | UserDAO.selectById | selectById |

300행이 넘어가면? 눈이 아프다. 시각적 구분이 필요하다.


레이어별 색상 정의

// 색상 상수 정의
private static final short COLOR_CONTROLLER = IndexedColors.LIGHT_GREEN.getIndex();
private static final short COLOR_SERVICE = IndexedColors.LIGHT_CORNFLOWER_BLUE.getIndex();
private static final short COLOR_DAO = IndexedColors.LAVENDER.getIndex();
레이어 색상 의미
Controller 연녹색 진입점 (HTTP 요청)
Service 연파랑 비즈니스 로직
DAO 연보라 데이터 접근

CLI에서 ANSI 색상으로 구분한 것과 동일한 컨셉.


다중 구현체 경고 색상

정적 분석의 한계에서 다뤘듯이, 인터페이스에 구현체가 여러 개면 어떤 게 실행되는지 모른다.

// 다중 구현체 경고 스타일 (연한 살구색)
XSSFColor peachColor = new XSSFColor(new byte[]{(byte)255, (byte)240, (byte)224}, null);
warningStyle.setFillForegroundColor(peachColor);

해당 행을 연한 살구색으로 강조하고, 비고 컬럼에 "외 UserServiceV2, V3" 표시.


7. 파라미터 표시 문제

문제 상황

/user/detail.do
├── Controller.getUser(userId, gubun)
│   ├── if(gubun==1) → DAO.select1() → SQL: #userId#
│   └── if(gubun==2) → DAO.select2() → SQL: #deptId#

Excel에 파라미터를 표시해야 한다. 어떤 파라미터를 보여줄까?

시도 1: Controller 파라미터만

모든 행에 "userId, gubun" 표시 → SQL별 차이 없음 ❌

시도 2: SQL 파라미터만

행1: "userId", 행2: "deptId" → 분기 파라미터(gubun) 누락 ❌

해결: Controller + SQL 합집합

private String mergeParameters(Set<String> controllerParams, List<String> sqlParams) {
    Set<String> merged = new LinkedHashSet<>();
    merged.addAll(controllerParams);  // Controller 파라미터 먼저
    merged.addAll(sqlParams);         // SQL 파라미터 추가 (중복 제거)
    return merged.isEmpty() ? "-" : String.join(", ", merged);
}

결과:

행1: userId, gubun ← Controller(userId, gubun) + SQL(userId)
행2: userId, gubun, deptId ← Controller(userId, gubun) + SQL(deptId)

왜 합집합인가?

  • Controller 파라미터: API가 받는 입력값
  • SQL 파라미터: 실제로 쿼리에 사용되는 값
  • 둘 다 보여줘야 전체 데이터 흐름이 보인다

SQL 파라미터 추출

iBatis와 MyBatis는 파라미터 표기가 다르다:

-- iBatis: #paramName#
SELECT * FROM USER WHERE USER_ID = #userId#

-- MyBatis: #{paramName}
SELECT * FROM USER WHERE USER_ID = #{userId}

정규식으로 추출:

// iBatis 패턴
private static final Pattern IBATIS_PARAM = Pattern.compile("#([a-zA-Z_][a-zA-Z0-9_]*)#");

// MyBatis 패턴
private static final Pattern MYBATIS_PARAM = Pattern.compile("#\\{([a-zA-Z_][a-zA-Z0-9_.]*)\\}");

private void extractParametersFromQuery(String query) {
    Set<String> params = new HashSet<>();

    Matcher ibatisMatcher = IBATIS_PARAM.matcher(query);
    while (ibatisMatcher.find()) {
        params.add(ibatisMatcher.group(1));
    }

    Matcher mybatisMatcher = MYBATIS_PARAM.matcher(query);
    while (mybatisMatcher.find()) {
        params.add(mybatisMatcher.group(1));
    }

    sqlParameters.addAll(params);
}

8. 동적 SQL 원본 출력

문제: 동적 SQL 태그가 사라진다

iBatis XML:

<select id="selectUser">
    SELECT * FROM USER
    <dynamic prepend="WHERE">
        <isNotEmpty property="userId">
            AND USER_ID = #userId#
        </isNotEmpty>
    </dynamic>
</select>

기존 출력:

SELECT * FROM USER AND USER_ID = #userId#

<dynamic>, <isNotEmpty> 태그가 사라졌다! 조건 분기 정보를 알 수 없다.


해결: XML 원본 형태 유지

/**
 * XML 요소의 내용을 원본 형태로 추출 (동적 SQL 태그 포함)
 */
private String getElementContentWithTags(Element element) {
    StringBuilder sb = new StringBuilder();

    for (Content content : element.getContent()) {
        if (content instanceof Text) {
            sb.append(((Text) content).getText());
        } else if (content instanceof Element) {
            Element child = (Element) content;
            sb.append("<").append(child.getName());
            // 속성 출력
            for (Attribute attr : child.getAttributes()) {
                sb.append(" ").append(attr.getName())
                  .append("=\"").append(attr.getValue()).append("\"");
            }
            sb.append(">");
            sb.append(getElementContentWithTags(child));  // 재귀
            sb.append("</").append(child.getName()).append(">");
        }
    }

    return sb.toString();
}

결과:

SELECT * FROM USER
<dynamic prepend="WHERE">
    <isNotEmpty property="userId">
        AND USER_ID = #userId#
    </isNotEmpty>
</dynamic>

동적 SQL 태그가 그대로 보인다. 어떤 조건에서 어떤 쿼리가 실행되는지 파악 가능.


9. 자동 필터와 컬럼 너비

자동 필터 설정

// 헤더 행에 자동 필터 설정
sheet.setAutoFilter(new CellRangeAddress(0, 0, 0, lastColumn));

Excel에서 각 컬럼 헤더에 드롭다운 화살표가 생긴다. 클릭하면 필터링 가능.


컬럼 너비 자동 조정

// 모든 컬럼 너비 자동 조정
for (int i = 0; i <= lastColumn; i++) {
    sheet.autoSizeColumn(i);
}

// SQL 컬럼은 최대 너비 제한 (너무 길면 보기 불편)
int sqlColumnIndex = 4;
if (sheet.getColumnWidth(sqlColumnIndex) > 20000) {
    sheet.setColumnWidth(sqlColumnIndex, 20000);
}

주의: autoSizeColumn()은 모든 행을 스캔하므로 대용량 데이터에서는 느릴 수 있다.


10. 프로젝트에서 활용

실제 출력 결과

요약 시트:

┌────────────────────────────────────────────┐
│ Code Flow Tracer - 분석 결과               │
├────────────────────────────────────────────┤
│ 분석 대상: C:\project\legacy-system        │
│ 분석 일시: 2025-12-20 14:30:00             │
│ URL 필터: /api/user/**                     │
├────────────────────────────────────────────┤
│ 통계                                       │
│ - 분석된 API: 12개                         │
│ - Controller 메서드: 4개                   │
│ - Service 메서드: 8개                      │
│ - DAO 메서드: 15개                         │
│ - SQL 쿼리: 18개                           │
└────────────────────────────────────────────┘

 

 

호출 흐름 시트:

 

SQL 목록 시트:

 


11. 마치며

이 글에서 배운 것

주제 핵심
Apache POI Workbook → Sheet → Row → Cell 계층 구조
시트 구성 역할별 분리 (요약/호출흐름/SQL)
평면 테이블 트리 구조를 레이어별 컬럼으로 변환
색상 구분 IndexedColors, XSSFColor로 가독성 향상
파라미터 Controller + SQL 파라미터 합집합

삽질 포인트

  1. CellStyle을 셀마다 생성 → 메모리 폭발. Workbook 단위로 재사용!
  2. autoSizeColumn() 과다 사용 → 대용량에서 느림. 필요한 컬럼만!
  3. 트리 그대로 Excel에 → 필터링 불가. 평면 테이블로 변환!

다음 글 예고

CLI와 Excel로 결과를 볼 수 있게 됐다. 그런데...

"터미널 열고 명령어 치는 게 불편해요"
"더블클릭으로 실행할 수는 없나요?"

 

비개발자도 쉽게 사용하려면 GUI가 필요하다고 생각했다.

'토스 러너스하이 2기 > 기술' 카테고리의 다른 글

jpackage로 배포하기 - 폐쇄망에서 Java 없이 실행하기  (0) 2026.01.12
Swing으로 모던한 GUI 만들기 - CLI를 넘어서  (1) 2026.01.12
분석 결과를 어떻게 보여줄까? - CLI 출력 구현기  (1) 2026.01.07
정적 분석의 한계 - 해결할 수 없는 것들  (0) 2026.01.04
DAO에서 SQL까지 - XML 파싱으로 연결하기  (0) 2025.12.29
'토스 러너스하이 2기/기술' 카테고리의 다른 글
  • jpackage로 배포하기 - 폐쇄망에서 Java 없이 실행하기
  • Swing으로 모던한 GUI 만들기 - CLI를 넘어서
  • 분석 결과를 어떻게 보여줄까? - CLI 출력 구현기
  • 정적 분석의 한계 - 해결할 수 없는 것들
KBroJ9210
KBroJ9210
  • KBroJ9210
    개발일기
    KBroJ9210
  • 전체
    오늘
    어제
    • 분류 전체보기 (25)
      • 토스 러너스하이 2기 (11)
        • 회고 (1)
        • 기술 (10)
      • Loopers (9)
        • 테크니컬 라이팅 (6)
        • WIL(What I Learned) (3)
      • 두리두리넋두리 (5)
        • 개발일기 (5)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
KBroJ9210
분석 결과를 Excel로 정리하기 - Apache POI 활용기
상단으로
목차

    티스토리툴바