분석은 됐는데, 산출물은 어떻게 만들지?
산출물 작성할 때 참고할 수 있는 레퍼런스가 필요했다. 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인가?
산출물마다 형식이 다르다. 모든 산출물에 딱 맞는 포맷을 만들 수는 없다.
그래서 생각한 게 "참고용 레퍼런스":
- 필요한 정보를 필터링해서 찾고
- 복사/붙여넣기로 산출물에 옮기고
- 필요하면 편집해서 사용
| 포맷 | 필터링 | 편집 | 복사/붙여넣기 | 결론 |
|---|---|---|---|---|
| ❌ | ❌ | △ | 보기만 가능 | |
| 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 파라미터 합집합 |
삽질 포인트
- CellStyle을 셀마다 생성 → 메모리 폭발. Workbook 단위로 재사용!
- autoSizeColumn() 과다 사용 → 대용량에서 느림. 필요한 컬럼만!
- 트리 그대로 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 |