"이 테이블 스키마 바꾸면 어떤 API가 영향받아?"
실무에서 이런 질문을 받았다. 기존 Top-Down 분석(URL→SQL)으로는 답할 수 없었다. 테이블에서 코드를 역추적하는 Bottom-Up 분석을 구현한 이야기.
3줄 요약
- Top-Down(URL→SQL)과 Bottom-Up(테이블→코드)은 상호보완적이다
- 역방향 인덱싱(
Map<테이블명, List<접근정보>>)으로 O(1) 조회 가능하게 구현했다 - GUI에서 드릴다운 UX는 CardLayout + 브레드크럼 패턴으로 해결한다
1. 들어가며
기술 1편부터 지금까지 Top-Down 분석을 구현했다:
Top-Down 분석:
URL → Controller → Service → DAO → SQL
"이 API는 어떤 테이블을 조회하나?"라는 질문에 완벽하게 답할 수 있었다.
그런데 실무에서 반대 방향 질문을 받았다.
실무 피드백
"DBMS 이관 프로젝트를 하는데, 특정 테이블에 CRUD하는 곳이 어디 있는지 찾는 기능이 필요해요."
시나리오:
- DBMS 이관: Oracle → PostgreSQL로 이관할 때, 양방향 동기화 대상 테이블 선정
- 스키마 변경:
USER테이블에 컬럼 추가하면, 어떤 API가 영향받나? - 장애 분석:
ORDER테이블 데이터가 이상해. 어떤 코드가 INSERT 하지?
모두 테이블 → 코드 방향의 분석이 필요했다.
Top-Down vs Bottom-Up
| 관점 | Top-Down | Bottom-Up |
|---|---|---|
| 방향 | URL → SQL | 테이블 → 코드 |
| 질문 | "이 API는 뭘 하나?" | "이 테이블은 어디서 쓰이나?" |
| 시작점 | API URL | 테이블명 |
| 용도 | API 영향도 분석 | 테이블 영향도 분석 |
비유: 전화번호부
| 분석 방향 | 비유 |
|---|---|
| Top-Down | 이름으로 전화번호 찾기 |
| Bottom-Up | 전화번호로 주인 찾기 |
둘 다 필요하다. 그래서 테이블 영향도 탭을 추가하기로 했다.
2. 역방향 인덱싱 설계
기존 데이터 구조
Top-Down 분석 결과는 트리 구조다:
FlowResult
└── List<FlowNode> (각 API 엔드포인트)
└── children: List<FlowNode> (호출된 메서드들)
└── sqlInfo: SqlInfo (SQL 정보)
└── tables: List<String> (접근 테이블들)
이 구조에서 "USER 테이블에 접근하는 API는?"을 찾으려면:
// O(n×m): 모든 API의 모든 노드를 탐색해야 함
for (FlowNode flow : result.getFlows()) { // n개 API
for (FlowNode node : getAllNodes(flow)) { // m개 노드
if (node.getSqlInfo().getTables().contains("USER")) {
// 찾음!
}
}
}
문제: O(n×m) 복잡도. 매번 전체 탐색은 비효율적이다.
역방향 인덱스 구축
// 역방향 인덱스: 테이블명 → 접근 정보 목록
Map<String, TableImpact> tableIndex = new HashMap<>();
// 테이블명으로 O(1) 조회 가능!
TableImpact impact = tableIndex.get("USER");
List<TableAccess> accesses = impact.getAccesses();
// USER 테이블에 접근하는 모든 정보가 바로 나옴
비유: 책의 색인(Index)
| 방식 | 비유 | 시간 |
|---|---|---|
| 매번 탐색 | 책 전체를 읽으며 키워드 찾기 | O(n) |
| 역방향 인덱스 | 뒤쪽 색인에서 페이지 번호 확인 | O(1) |
구현: buildTableIndex
public Map<String, TableImpact> buildTableIndex(FlowResult result) {
Map<String, TableImpact> tableIndex = new HashMap<>();
for (FlowNode flow : result.getFlows()) {
String url = flow.getUrlMapping();
String httpMethod = flow.getHttpMethod();
// 트리 구조를 재귀 탐색하며 테이블 접근 정보 수집
collectTableAccesses(flow, url, httpMethod, tableIndex);
}
return tableIndex;
}
private void collectTableAccesses(FlowNode node, String url, String httpMethod,
Map<String, TableImpact> tableIndex) {
// DAO 노드이고 SQL 정보가 있으면 테이블 접근 기록
if (node.getClassType() == ClassType.DAO && node.hasSqlInfo()) {
SqlInfo sqlInfo = node.getSqlInfo();
List<String> tables = sqlInfo.getTables();
for (String tableName : tables) {
// 테이블별로 접근 정보 수집
TableImpact impact = tableIndex.computeIfAbsent(
tableName.toUpperCase(), TableImpact::new);
impact.addAccess(new TableAccess(
url, // /api/user
httpMethod, // GET
node.getClassName(), // UserDao
node.getMethodName(), // selectUser
sqlInfo.getType(), // SELECT
sqlInfo.getSqlId(), // user.selectUser
sqlInfo.getFileName(), // user-mapper.xml
sqlInfo.getQuery() // SELECT * FROM USER...
));
}
}
// 자식 노드 재귀 처리
for (FlowNode child : node.getChildren()) {
collectTableAccesses(child, url, httpMethod, tableIndex);
}
}
핵심: computeIfAbsent
// 이 한 줄이 핵심
TableImpact impact = tableIndex.computeIfAbsent(tableName, TableImpact::new);
- 키가 없으면 새 객체 생성해서 넣음
- 키가 있으면 기존 객체 반환
if (map.get(key) == null) { map.put(key, new Value()); }패턴을 한 줄로
데이터 클래스
public static class TableImpact {
private final String tableName;
private final List<TableAccess> accesses = new ArrayList<>();
public String getTableName() { return tableName; }
public List<TableAccess> getAccesses() { return accesses; }
public void addAccess(TableAccess access) { accesses.add(access); }
// CRUD 타입별 접근 횟수
public Map<SqlInfo.SqlType, Long> getCrudCounts() {
Map<SqlInfo.SqlType, Long> counts = new HashMap<>();
for (TableAccess access : accesses) {
SqlInfo.SqlType type = access.getSqlType();
counts.put(type, counts.getOrDefault(type, 0L) + 1);
}
return counts;
}
}
public static class TableAccess {
private final String url; // /api/user
private final String httpMethod; // GET
private final String className; // UserDao
private final String methodName; // selectUser
private final SqlInfo.SqlType sqlType; // SELECT
private final String sqlId; // user.selectUser
private final String xmlFileName; // user-mapper.xml
private final String query; // SELECT * FROM USER...
}
3. GUI 구현: CardLayout과 브레드크럼
화면 구조
┌──────────────────────────────────────────────────────────────┐
│ [호출 흐름 탭] [테이블 영향도 탭] │
├──────────────────────────────────────────────────────────────┤
│ │
│ 테이블 목록 │ 테이블 상세 (CardLayout) │
│ ┌───────────┐ │ ┌─────────────────────────────────────┐ │
│ │ == 전체 == │ │ │ USER > 쿼리 │ │ ← 브레드크럼
│ │ USER │ │ ├─────────────────────────────────────┤ │
│ │ ORDER │ │ │ Card 1: 접근 정보 테이블 │ │
│ │ PRODUCT │ │ │ ┌────┬─────────┬─────────┬────────┐ │ │
│ │ │ │ │ │CRUD│ URL │ XML파일 │ SQL ID │ │ │
│ │ │ │ │ ├────┼─────────┼─────────┼────────┤ │ │
│ └───────────┘ │ │ │ S │ /api/.. │ user.xml│ select │ │ │
│ │ │ └────┴─────────┴─────────┴────────┘ │ │
│ │ ├─────────────────────────────────────┤ │
│ │ │ Card 2: 쿼리 상세 뷰 │ │
│ │ │ SELECT * FROM USER WHERE id = #{id} │ │
│ │ └─────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
CardLayout: 같은 공간에 다른 화면
public class TableImpactPanel extends JPanel {
private static final String CARD_ACCESS_TABLE = "accessTable";
private static final String CARD_QUERY_DETAIL = "queryDetail";
private JPanel cardPanel;
private CardLayout cardLayout;
private void layoutComponents() {
cardLayout = new CardLayout();
cardPanel = new JPanel(cardLayout);
// Card 1: 접근 정보 테이블
JPanel accessTablePanel = createAccessTablePanel();
cardPanel.add(accessTablePanel, CARD_ACCESS_TABLE);
// Card 2: 쿼리 상세 뷰
JPanel queryDetailPanel = createQueryDetailPanel();
cardPanel.add(queryDetailPanel, CARD_QUERY_DETAIL);
add(cardPanel, BorderLayout.CENTER);
}
// 카드 전환
private void showQueryDetailView() {
cardLayout.show(cardPanel, CARD_QUERY_DETAIL);
}
private void showAccessTableView() {
cardLayout.show(cardPanel, CARD_ACCESS_TABLE);
}
}
CardLayout 장점:
- 같은 공간에 여러 화면을 겹쳐놓고 필요한 것만 표시
- 탭 전환보다 자연스러운 드릴다운 UX
- Swing 표준 레이아웃 (별도 라이브러리 불필요)
브레드크럼 패턴
브레드크럼 (Breadcrumb):
USER > 쿼리 전체
│ └── 현재 위치
└── 클릭하면 뒤로 이동
// 브레드크럼 컴포넌트
private JLabel breadcrumbTableLabel; // 테이블명 (클릭 가능)
private JLabel breadcrumbSeparator; // " > "
private JLabel breadcrumbQueryLabel; // 쿼리 (현재 위치)
private void updateBreadcrumb(String tableName, String queryInfo) {
breadcrumbTableLabel.setText(tableName);
if (queryInfo != null) {
breadcrumbSeparator.setVisible(true);
breadcrumbQueryLabel.setVisible(true);
breadcrumbQueryLabel.setText(queryInfo);
} else {
breadcrumbSeparator.setVisible(false);
breadcrumbQueryLabel.setVisible(false);
}
}
// 브레드크럼 테이블명 클릭 → 접근 정보 테이블로 돌아가기
breadcrumbTableLabel.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (breadcrumbSeparator.isVisible()) {
showAccessTableView();
}
}
@Override
public void mouseEntered(MouseEvent e) {
if (breadcrumbSeparator.isVisible()) {
// 클릭 가능 표시 (색상 변경)
breadcrumbTableLabel.setForeground(COLOR_LINK);
}
}
@Override
public void mouseExited(MouseEvent e) {
breadcrumbTableLabel.setForeground(COLOR_HEADER);
}
});
마우스 뒤로가기 버튼 지원
// 마우스 확장 버튼: 4 = 뒤로가기(XBUTTON1), 5 = 앞으로가기(XBUTTON2)
MouseAdapter mouseBackButtonAdapter = new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
// 버튼 4 = 마우스 뒤로가기 버튼
if (e.getButton() == 4) {
if (breadcrumbSeparator.isVisible()) {
showAccessTableView();
}
}
}
};
// 패널 전체에 리스너 추가
addMouseListener(mouseBackButtonAdapter);
cardPanel.addMouseListener(mouseBackButtonAdapter);
accessTable.addMouseListener(mouseBackButtonAdapter);
queryTextArea.addMouseListener(mouseBackButtonAdapter);
왜 버튼 4인가?
| 버튼 | 값 | 설명 |
|---|---|---|
| 왼쪽 | 1 | BUTTON1 |
| 오른쪽 | 3 | BUTTON3 |
| 가운데 (휠) | 2 | BUTTON2 |
| 뒤로가기 | 4 | XBUTTON1 (마우스 옆면) |
| 앞으로가기 | 5 | XBUTTON2 |
웹 브라우저처럼 마우스 옆 버튼으로 뒤로 가기가 된다!
4. JTable 실시간 검색
요구사항
테이블 접근 정보가 수백 개일 수 있다. 원하는 URL이나 SQL ID를 빠르게 찾으려면 실시간 검색이 필요했다.
RowFilter 구현
private JTable accessTable;
private DefaultTableModel accessTableModel;
private TableRowSorter<DefaultTableModel> accessTableSorter;
private JTextField accessSearchField;
private void initializeComponents() {
// 테이블 모델
String[] columns = {"CRUD", "URL", "XML 파일", "SQL ID"};
accessTableModel = new DefaultTableModel(columns, 0);
accessTable = new JTable(accessTableModel);
// 정렬 및 필터링 기능
accessTableSorter = new TableRowSorter<>(accessTableModel);
accessTable.setRowSorter(accessTableSorter);
}
private void setupEventHandlers() {
// 검색 필드 실시간 필터링
accessSearchField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) { filterAccessTable(); }
@Override
public void removeUpdate(DocumentEvent e) { filterAccessTable(); }
@Override
public void changedUpdate(DocumentEvent e) { filterAccessTable(); }
});
}
private void filterAccessTable() {
String searchText = accessSearchField.getText().trim().toLowerCase();
if (searchText.isEmpty()) {
accessTableSorter.setRowFilter(null); // 필터 해제
} else {
// 모든 컬럼에서 검색 (대소문자 무시)
String regex = "(?i)" + Pattern.quote(searchText);
accessTableSorter.setRowFilter(RowFilter.regexFilter(regex));
}
}
핵심: RowFilter.regexFilter()
// (?i) = 대소문자 무시
// Pattern.quote() = 특수문자 이스케이프
RowFilter.regexFilter("(?i)" + Pattern.quote(searchText));
"user"입력 → USER, User, user 모두 매칭"/api"입력 → 슬래시도 정상 검색 (quote로 이스케이프)
5. 트러블슈팅
Issue #025: CRUD 필터 실시간 적용 불가
증상:
- 엔드포인트 검색: 타이핑할 때마다 실시간 필터링 ✅
- CRUD 체크박스: 변경 후 "분석 시작" 버튼을 다시 눌러야 반영 ❌
원인:
CRUD 필터가 분석 파라미터로 전달되어 재분석이 필요한 구조였다.
// 문제의 구조
analyzeButton.addActionListener(e -> {
List<SqlType> crudFilter = getSelectedCrudTypes();
FlowResult result = analyzer.analyze(path, crudFilter); // 재분석!
});
해결: 원본 보존 + 필터 재적용
// 원본 결과 보존
private FlowResult originalResult;
private FlowResult filteredResult;
// CRUD 체크박스 변경 시
crudCheckbox.addActionListener(e -> {
// 원본에서 필터링만 다시 적용 (재분석 X)
filteredResult = analyzer.filterByCrudType(originalResult, getSelectedCrudTypes());
displayResult(filteredResult);
});
재귀 필터링 구현:
public FlowResult filterByCrudType(FlowResult result, List<SqlType> types) {
if (types == null || types.isEmpty() || types.size() == 4) {
return result; // 전체 선택 = 필터 없음
}
FlowResult filtered = new FlowResult();
for (FlowNode flow : result.getFlows()) {
FlowNode filteredFlow = filterNode(flow, types);
if (filteredFlow != null) {
filtered.addFlow(filteredFlow);
}
}
return filtered;
}
private FlowNode filterNode(FlowNode node, List<SqlType> types) {
// DAO 노드면서 SQL 타입이 필터에 없으면 제외
if (node.hasSqlInfo()) {
SqlType type = node.getSqlInfo().getType();
if (!types.contains(type)) {
return null;
}
}
// 자식 노드 재귀 필터링
FlowNode filtered = node.shallowCopy();
for (FlowNode child : node.getChildren()) {
FlowNode filteredChild = filterNode(child, types);
if (filteredChild != null) {
filtered.addChild(filteredChild);
}
}
// 자식이 모두 필터링된 비-DAO 노드도 유지
// (Controller→Service 경로를 보존하기 위해)
return filtered;
}
Issue #026: 세션 저장/복원 미동작
증상:
- 테이블 영향도 탭에서 테이블 선택 후 앱 종료 → 재시작 시 선택 상태 복원 안 됨
원인:saveSession()이 분석 완료 후에만 호출됨. 탭 전환, 테이블 선택 시에는 저장되지 않음.
해결:
// 창 닫기 이벤트에서 전체 상태 저장
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
sessionManager.saveSession(
projectPath,
currentResult,
urlFilter,
outputStyle,
selectedTabIndex, // 추가
selectedEndpoint, // 추가
selectedTable, // 추가
tableDetailViewActive, // 추가
selectedQueryRowIndex // 추가
);
System.exit(0);
}
});
Issue #027: 스크롤 복원 타이밍 문제
증상:
- 세션 복원 시 엔드포인트 선택은 되지만, 결과 패널이 해당 위치로 스크롤되지 않음
원인:scrollToEndpoint()가 UI 렌더링 완료 전에 호출됨.
해결: invokeLater 중첩
private void restoreScrollPosition(String selectedEndpoint) {
// 첫 번째 invokeLater: 현재 이벤트 큐 처리 후
SwingUtilities.invokeLater(() -> {
// 두 번째 invokeLater: 렌더링 완료 후
SwingUtilities.invokeLater(() -> {
scrollToEndpoint(selectedEndpoint);
});
});
}
왜 두 번 감싸나?
이벤트 큐:
[세션 복원] → [UI 업데이트] → [레이아웃 계산] → [스크롤]
└── 여기서 해야 함!
invokeLater는 현재 이벤트가 끝난 후 실행을 예약한다. 두 번 감싸면 모든 렌더링이 끝난 후 실행된다.
Issue #028: "전체" 선택 시 빈 화면
증상:
- 분석 완료 후 테이블 영향도 탭에서 "전체"가 선택되어 있지만 상세화면이 비어있음
원인: 메서드 호출 순서 문제
// 잘못된 순서
updateTableList(result); // displayTableAccesses() 호출
tableImpactPanel.updateData(result); // tableIndex 설정
// displayTableAccesses()가 호출될 때 tableIndex가 아직 null!
해결:
// 올바른 순서
tableImpactPanel.updateData(result); // 먼저 tableIndex 설정
updateTableList(result); // 그 다음 화면 업데이트
Issue #029: SQL 필터 변경 시 선택 상태 초기화
증상:
- 테이블 선택 후 상세화면을 보고 있는 중
- SQL 필터(SELECT/INSERT...) 변경 시 선택이 "전체"로 리셋됨
원인:
필터 변경 시 테이블 목록을 새로 생성하면서 기존 선택 상태를 저장하지 않음.
해결: 상태 저장/복원
private void applyFiltersAndRefresh() {
// 1. 현재 상태 저장
String currentSelectedTable = getSelectedTableName();
boolean wasDetailViewActive = tableImpactPanel.isQueryDetailViewActive();
int selectedQueryRow = tableImpactPanel.getSelectedQueryRowIndex();
// 2. 필터 적용 및 새로고침
filteredResult = analyzer.filterByCrudType(originalResult, getSelectedCrudTypes());
updateTableList(filteredResult);
// 3. 상태 복원
if (currentSelectedTable != null) {
selectTableByName(currentSelectedTable);
if (wasDetailViewActive) {
tableImpactPanel.restoreQueryView(selectedQueryRow);
}
}
}
6. 마치며
요약
| 기능 | 구현 방식 |
|---|---|
| 역방향 조회 | Map<테이블명, TableImpact> 인덱스 |
| 드릴다운 UI | CardLayout + 브레드크럼 |
| 실시간 검색 | JTable RowFilter |
| 뒤로가기 | 마우스 XBUTTON1 (버튼 4) |
| CRUD 필터 | 원본 보존 + 재귀 필터링 |
삽질에서 배운 점
- 양방향 분석: Top-Down과 Bottom-Up은 상호보완적. 둘 다 있어야 실무에서 유용하다.
- 인덱싱 = 시간 vs 공간 트레이드오프: 매번 O(n)으로 탐색하는 대신, O(n) 한 번으로 인덱스를 만들고 O(1)로 조회.
- CardLayout: 같은 공간에 여러 화면이 필요할 때 Swing 표준 솔루션. 드릴다운 UX에 적합.
- 브레드크럼 패턴: 현재 위치 표시 + 뒤로 가기. 계층 구조 탐색의 표준 UI 패턴.
- invokeLater 타이밍: Swing UI 업데이트는 비동기. 렌더링 완료 후 작업이 필요하면 invokeLater를 중첩.
- 상태 저장/복원: 사용자 액션(필터 변경 등)으로 화면이 초기화되면 안 된다. 현재 상태를 저장하고 복원하는 패턴 필수.
시리즈
- 기술 1편: JavaParser로 Java 코드 분석하기
- 기술 2편: 호출 흐름을 어떻게 따라가나? - 재귀와 백트래킹
- 기술 3편: DAO에서 SQL까지 - XML 파싱으로 연결하기
- 기술 4편: 정적 분석의 한계 - 해결할 수 없는 것들
- 기술 5편: 분석 결과를 어떻게 보여줄까? - CLI 출력 구현기
- 기술 6편: 분석 결과를 Excel로 정리하기 - Apache POI 활용기
- 기술 7편: Swing으로 모던한 GUI 만들기 - CLI를 넘어서
- 기술 8편: jpackage로 배포하기 - 폐쇄망에서 Java 없이 실행하기
- 기술 9편: Gson으로 세션 영속성 구현하기 - 앱을 닫아도 분석 결과가 살아있다
- 기술 10편: CRUD 분석과 테이블 영향도 - Bottom-Up 분석의 힘 (현재 글)
'토스 러너스하이 2기 > 기술' 카테고리의 다른 글
| Gson으로 세션 영속성 구현하기 - 앱을 닫아도 분석 결과가 살아있다 (0) | 2026.01.17 |
|---|---|
| jpackage로 배포하기 - 폐쇄망에서 Java 없이 실행하기 (0) | 2026.01.12 |
| Swing으로 모던한 GUI 만들기 - CLI를 넘어서 (1) | 2026.01.12 |
| 분석 결과를 Excel로 정리하기 - Apache POI 활용기 (0) | 2026.01.10 |
| 분석 결과를 어떻게 보여줄까? - CLI 출력 구현기 (1) | 2026.01.07 |