자바 프로그래밍

자바 스윙 세번째 - JTable을 이용한 복잡한 데이터 리스트

plas 2020. 10. 17. 09:38

JList는 간단한 데이터를 리스트로 관리하는데 유용하다. 그러나 필드가 여러 개 있는 복잡한 데이터라면 리스트 형태로는 필요한 데이터를 모두 보여줄 수 없다. 그러므로 JTable을 이용해야 한다. 이 클래스는 사용법도 상당히 복잡하지만, 데이터를 다루는 부분과 guI 부분을 분리하여 설계해야 하므로 프로그램의 모듈화 설계가 매우 중요하다. 이 프로그램은 GUI의 테이블 기능을 재사용 가능하도록 모듈화하여 작성되었다.

입력파일로 주어진 song 데이터에 대해 JTable을 포함하는 실행 화면은 다음과 같다.

실행화면 예시 song.txt
1 빅뱅 뱅뱅뱅 2015
2 빙뱅 WELIKE2PARTY 2015
3 아이유 마음 2015
4 Zion.T 꺼내먹어요 2015
5 빅뱅 LOSER 2015
6 백아연 이럴거면그러지말지 2015
7 빅뱅 BAEBAE 2015
8 샤이니 view 2015
9 윤미래 너의얘길들어줄께 2015
10 로꼬 우연히봄 2015

프로그램 시작

프로그램을 시작하기 위해서는 데이터를 읽어들이는 엔진 부분과 GUI 부분을 구동해야 한다. 데이터의 입력과 관리를 담당하는 엔진 부분은 구체 클래스인 SongMgr에서 담당한다. GUI 부분을 시작시키는 코드는 GUIMain.startGUI()이다. startGUI의 매개변수로 engine을 보냈다.

public class MyMain {
	void mymain() {
		SongMgr engine = new SongMgr();
		engine.readAll("song.txt");
		GUIMain.startGUI(engine);
		//engine.printAll();
	}
	public static void main(String[] args) {
		MyMain a = new MyMain();
		a.mymain();
	}
}

SongMgr는 파일에서 데이터를 입력하여 리스트로 관리하고 추가/삭제/수정/검색의 기능을 제공하는 클래스다. 이 프로그램에서는 데이터를 다루는 레이어를 데이터 엔진이라고 부르고 그것을 DataEngineInterface로 정의하여 사용한다.

GUIMain 클래스는 데이터를 다룰 엔진 부분을 DataEngineInterface를 통해 접근한다. 이것을 startGUI의 매개변수로 전달한다. 그리고 스레드를 통해 GUI 부분을 시작하도록 호출한다.

public class GUIMain {
    static DataEngineInterface engine;
    public static void startGUI(DataEngineInterface en) {
    	engine = en;
        javax.swing.SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                createAndShowGUI();
            }
        });
    }

createAndShowGUI() 메소드는 메인프레임을 생성하고 컨텐트팬에 이 프로그램의 화면구성을 담당하는 TableSelectionDemo 객체를 생성하여 추가하는 일을 담당한다. 여기서는 설명은 생략한다. 

화면 구성

이 프로그램의 윈도우 전체를 나타낼 JPanel을 다음 클래스에서 담당한다. 이 패널은 상단(입력창과 검색 버튼), 중간(JTable), 하단(편집창과 버튼들)로 구성된다. 이 프로그램은 화면 구성부를 두 개의 클래스로 나누어 작성하였다. 중간 부분은 TableController 클래스가 담당하고 하단은 BottomPane 클래스가 담당한다. 상단은 이 클래스 안에서 담당한다.

public class TableSelectionDemo extends JPanel {
    private static final long serialVersionUID = 1L;
    static TableController tableContoller;
    static BottomPane bottom;
    public TableSelectionDemo() {
    	super(new BorderLayout());
    }
    void addComponentsToPane() {
    	tableContoller = new TableController();
     	tableContoller.init();
    	JScrollPane center = new JScrollPane(tableContoller.table);   	
    	add(center, BorderLayout.CENTER);
    	
    	bottom = new BottomPane();
    	bottom.init(GUIMain.engine.getColumnCount());
        add(bottom, BorderLayout.PAGE_END);
        
    	setupTopPane();
    }

먼저 가운데 JTable 부분을 먼저 살펴보자. tableContoller.init()은 JTable을 생성하여 초기화하는데, 중간 창은 JScrollPane을 이용하여 만들었고 그 안에 JTable을 넣었다.(JScrollPane은 가변 크기를 가지는 컴포넌트를 포함하여 그에 맞게 스크롤을 보여주는 컨테이너다.

중간 패널 -  TableController 클래스

테이블 부분이 코드가 복잡하고 길어지므로 JTable을 사용하기 위해서 필요한 일을 담당하는 TableController 클래스를 따로 만들었다. 이 클래스의 선언부는 다음과 같다. 필드로 JTable과 DefaultTableModel을 가지고 테이블의 이벤트 핸들러를 구현한다. 테이블모델은 JTable에 들어갈 데이터를 가지는데, 데이터는 테이블 헤더가 될 배열과 테이블에 들어갈 데이터 부분(여러 개의 행)이 있다.

    void init() {
        dataMgr = GUIMain.engine;    	
    	tableModel = new DefaultTableModel(dataMgr.getColumnNames(), 0);
    	loadData("");
    	
    	table = new JTable(tableModel);
        ListSelectionModel rowSM = table.getSelectionModel();
        rowSM.addListSelectionListener(this);
        table.setPreferredScrollableViewportSize(new Dimension(500, 220));
        table.setFillsViewportHeight(true);
        table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); 
    }

init() 메소드는 이 클래스 생성 후 처음에 한번만 불려진다. 여기서는 테이블모델을 생성하고 엔진으로부터 데이터를 로드하는 일을 한다(loadData("")). 엔진은 GUIMain에서 시작할 때 생성한 SongMgr 클래스 객체가 될 것이다. 그리고 이 모델을 이용하여 JTable 컴포넌트를 생성하고 SelectionListener를 등록하는 등의 일을 한다. 여기서 중요한 부분이 테이블 모델의 생성과 데이터 로딩 부분이다. 

dataMgr의 getColumnNames() 메소드는 테이블을 만들 때 컬럼의 수와 헤더로 쓸 스트링 배열을 받는다. 여기서는 추상 인터페이스인 DataEngineInterface를 사용하고 있으며, 실제 구체 클래스인 SongMgr 클래스에서 해당 메소드는 다음과 같은 스트링 배열을 돌려준다. 이것이 위 테이블의 헤더로 사용되었음을 볼 수 있다.

	private static final String[] labels = {"랭킹", "이름", "제목", "년도", "가사"};
	@Override
	public int getColumnCount() {
		return labels.length;
	}
	// 테이블의 열 제목을 스트링 배열로 돌려줌
	public String[] getColumnNames() {		
		return labels;
	}

loadData() 메소드는 다음과 같이 구성된다. 여기서 List<?>가 사용되는 이유는 아래 UIData와 DataEngineInterface 부분에서 설명한다.

    void loadData(String kwd) {
    	List<?> result = dataMgr.search(kwd);
    	tableModel.setRowCount(0);
    	for (Object m : result)
    		tableModel.addRow(((UIData)m).getUiTexts());
    }

loadData 메소드는 테이블 초기화에서도 사용되고 중간에 키워드 검색을 위해서도 사용할 수 있다. 그래서 키워드를 매개변수로 받아 엔진에서 검색한 결과를 이용하여 테이블모델에 추가한다. dataMgr.search()는 키워드가 ""이면 전체 검색, 아니면 키워드를 검색하게 된다. 이 때 GUI를 위한 인터페이스로 UIData를 만들고 getUITexts() 메소드를 추가했다. 이것은 실제 구체 클래스인 Song에서 Manageable 이외에 UIData 인터페이스도 구현해야 한다. 즉 그 Song 객체의 데이터를 각 열에 들어갈 스트링 모양으로 바꾸어 배열을 반환한다. UIData 인터페이스는 이외에 UI 쪽에서 텍스트를 Song 객체에 설정할 수 있는 set 메소드를 가지는데 이에 대해서는 밑에서 다시 살펴본다.

	@Override
	public String[] getUiTexts() {   // Song
		return new String[] {""+id, name, title, ""+year, lyric};
	}

테이블모델은 이러한 스트링 배열을 객체로 저장하게 된다. 디폴트테이블모델은 스트링배열을 객체로 가진다. 

다음은 테이블에서 사용자가 어떤 행을 클릭하였을 때 발생하는 이벤트에 대한 이벤트 핸들러를 살펴보자.

    // 선택된 행이 변경되면 그 내용을 편집창으로 보냄
    public void valueChanged(ListSelectionEvent e) {
        ListSelectionModel lsm = (ListSelectionModel)e.getSource();
        if (!lsm.isSelectionEmpty()) {
        	selectedIndex = lsm.getMinSelectionIndex();
        	String[] rowTexts = new String[tableModel.getColumnCount()];
        	for (int i = 0; i < rowTexts.length; i++)
        		rowTexts[i] = (String)tableModel.getValueAt(selectedIndex, i);
        	TableSelectionDemo.bottom.moveSelectedToEdits(rowTexts);
        }
    }

클릭할 때 일어나는 일은 그 행의 각 셀 텍스트를 하단 패널의 편집창으로 옮기는 것이다. 이 프로그램에서는 일단 클릭하면 그 데이터를 하단의 편집창으로 옮긴 후 거기서 수정/추가/삭제가 일어날 수 있다. 그것을 위해 for 루프에서 테이블모델로부터 선택된 행의 스트링을 받아 rowTexts 배열에 저장한 후 그것을 bottom 패널 객체에게 넘겨서 하단의 편집창에 추가하도록 한다. 이 때 텍스트 배열의 길이와 하단 편집창 개수는 동일하다.

하단의 편집창과 버튼 처리

하단 패널도 새로운 클래스 BottomPane으로 분리했다. 여기에는 편집창 5개(테이블 열의 개수와 같다)가 있고 수정, 추가, 삭제 버튼이 있다. 먼저 편집창의 역할은 사용자가 테이블에서 클릭한 행의 데이터를 사용자가 수정하기 위한 것이다. 테이블에서 행을 선택하면 그 데이터가 편집창에 내려온다. 일단 편집창에 내려온 데이터는 편집할 수 있고, 사용자는 그 데이터로 수정, 추가, 삭제를 요청할 수 있게 된다.

class BottomPane extends JPanel implements ActionListener {
	JTextField edits[];
	int columnCount;

	void init(int columnCount) {
		this.columnCount = columnCount;
		edits = new JTextField[columnCount];

추가, 수정, 삭제 연산은 dataMgr가 가진 객체리스트에도 반영하고 화면 상의 테이블에도 반영해야 한다. 이렇게 데이터 부분과 GUI 부분이 일치하도록 유지하는 것이 중요하다. 항상 데이터 부분을 먼저 수정한 후 GUI에 반영해야 한다. 추가 버튼의 경우를 예로 들어 어떤 일이 일어나는지 차례로 따라가 보자.

  • 추가 버튼이 눌러지면 BottomPane.actionPerformed가 호출됨
  • 편집창의 텍스트를 스트링 배열에 모아 editTexts에 저장
  • "추가" 에 해당하는 부분이 실행되고 테이블패널의 tableController.addRow(editTexts)를 호출함
  • tableController에서 먼저 dataMgr의 addRow를 호출한다.
  • editTexts로 새로운 Song 객체를 생성한 후 그것을 Manager의 mList에 추가한다.
  • Song 객체 생성과 추가가 성공하면 tableModel에 해당 행을 추가한다.

하단 패널의 추가 버튼이 눌려지면 actionPerformed에서 추가 버튼에 해당하는 부분이 실행된다.

	public void actionPerformed(ActionEvent e) {  // BottomPane
		String[] editTexts = getEditTexts();
		clearEdits();
		TableController tablePane = TableSelectionDemo.tablePane;
		switch (e.getActionCommand()) {
		case "추가":
			tablePane.addRow(editTexts);
			break;

테이블팬의 addRow를 호출하기 위해 editTexts를 넘겨준다. 이것은 편집창에 사용자가 입력한 스트링의 배열로 새로운 Song 객체를 만들기 위한 데이터다. getEditTexts()를 통해 편집창의 현재의 값을 스트링 배열로 만들어 전달해 준다. 그러면 dataMgr는 이 값으로 새로운 객체를 만들어 데이터매니저의 리스트에 추가한다. dataMgr.addNewItem 메소드는 새로운 Song 객체를 생성하고 그것을 editTexts 값으로 초기화할 것이다. 그리고 그 새로운 객체를 ArrayList에 추가한다.

    void addRow(String[] editTexts) {   // TableController
		try {
			dataMgr.addNewItem(editTexts);
		} catch (Exception ex) {  // 추가 중 오류 발생
			ex.printStackTrace();
			JOptionPane.showMessageDialog(null, "추가 데이터 오류");
			return;
		}
		tableModel.addRow(editTexts);
    }

성공적으로 데이터매니저에 추가된 경우는 테이블 뷰에도 해당 행을 하나 추가하면 된다. 추가는 테이블모델에게 행을 추가하라고 addRow를 불러주면 된다.

객체를 생성할 때 만약 editTexts가 가진 값이 타입이 맞지 않다면? 예를 들어 년도를 나타내는 네 번째 항목은 int여야 되는데, 숫자가 아닌 글자가 들어있다면? 그 경우 생성자에서 parseInt를 시도할 때 NumberFormatException이 발생할 것이다. 그러면 우리는 사용자가 편집창에 잘못된 값을 넣었음을 알 수 있다. (물론 그 전에 데이터가 숫자만 들어가게 하거나 숫자가 아니면 오류를 일으키고 바로 고치게 하면 더 좋을 것이다) 현재로서는 추가가 실패하게 되고 그것을 사용자에게 알려주기 위해 메시지 박스를 띄워 준다.

객체의 생성과 리스트에 추가는 dataMgr가 가리키는 SongMgr의 객체가 수행한다. 

	public void addNewItem(String[] editTexts) {   // SongMgr
		// TODO Auto-generated method stub
		Song s = new Song();
		s.set(editTexts);
		mgr.mList.add(s);
	}

상단의 검색 기능

상단은 검색 키워드 입력창과 검색 버튼을 가진다. 화면 구성을 위한 부분은 다음과 같다.

    void setupTopPane() {   // TableSelectionDemo 
    	JPanel topPane = new JPanel();
        JTextField kwdTextField = new JTextField("", 20);
        topPane.add(kwdTextField, BorderLayout.LINE_START);
        JButton search = new JButton("검색");
        topPane.add(search, BorderLayout.LINE_END);
        ...
        add(topPane, BorderLayout.PAGE_START);
    }

검색의 결과는 테이블 부분에 보여지게 된다. 이러한 동작을 위해 검색 버튼의 핸들러 메소드가 필요하다.

        search.addActionListener(new ActionListener() {
        	public void actionPerformed(ActionEvent e) {
        		if (e.getActionCommand().equals("검색")) {
        			tablePane.loadData(kwdTextField.getText());
            	}
           }
        });

여기서 검색의 처리는 다음의 한줄로 해결된다.

tablePane.loadData(kwdTextField.getText());

TableController의 loadData는 전달된 스트링의 검색 결과를 리스트로 받아 테이블에 보여주는 역할을 한다. 테이블을 초기화할 때 loadData("")를 통해 모든 Song 객체를 불러왔으나 여기서는 해당 키워드에 검색되는 객체의 리스트만 테이블에 추가하게 된다. 검색 결과를 추가하기 전에 먼저 테이블에 있는 요소를 모두 삭제하기 위해 tableModel.setRowCount(0);를 실행한다.

다음 zip 파일을 풀어서 모든 디렉토리를 이클립스의 새로 만든 프로젝트의 src 디렉토리에 드래그드랍으로 넣어주면 된다.

TableDemo-src.zip
0.01MB

Facade 패키지의 역할 - GUI 부분과 엔진 부분의 중간 인터페이스

GUI에서 엔진의 관리자 기능과 요소 클래스를 접근해야 하는 경우가 생길 것이다. 이 때 구체 클래스에 의존할 수 없으므로 추상 인터페이스를 만들어야 하는데, 이들 인터페이스를 포함한 패키지를 파사드라고 이름 붙였다. 파사드란 실제 내부는 보이지 않고 노출된 인터페이스만 보고 사용하도록 만들어진 레이어를 말한다. 여기에는 다음의 두 인터페이스가 포함된다.

  • UIData - GUI 레이어에서 요소 클래스를 접근하기 위한 인터페이스
  • DataEngineInterface - GUI 레이어에서 엔진을 접근하기 위한 인터페이스

요소 클래스를 위한 UIData 인터페이스

이 프로그램은 Song 데이터를 다루지만 GUI 부분은 어떤 데이터든지 사용할 수 있게 작성되었다. OCP 원칙에 의해 변경가능한 부분은 개방적으로 무엇이든 적용될 수 있게 작성한다. 이 때 사용할 요소 데이터가 되기 위한 조건을 UIData 인터페이스가 정의한다. 이것은 GUI에서 그 요소를 테이블에 보여주기 위한 getUITexts() 메소드와 GUI에서 설정한 데이터의 필드값을 객체에 전달하기 위한 set 메소드를 포함한다.

package facade;
public interface UIData {
    void set(Object[] uitexts);
    String[] getUiTexts();
}

그러면 구체클래스인 Song 클래스는 Manageable 이외에도 이것도 구현해서 기능을 제공해야 한다. 그러면 getUiTexts는 테이블모델에서 해당 객체의 데이터를 테이블 뷰에 행으로 추가하기 위해 사용된다. 그리고 set 메소드는 추가나 수정할 때 GUI의 편집창에 있는 텍스트 데이터를 Song에게 전달하는데 사용된다.

그러면 구체 클래스인 Song에서는 이 인터페이스를 구현해야 한다.

public class Song implements Manageable, UIData {
	....
	@Override
	public String[] getUiTexts() {
		return new String[] {""+id, name, title, ""+year, lyric};
	}
	public void set(Object[] row) {
		id = Integer.parseInt((String)row[0]);
		name = (String)row[1];
		title = (String)row[2];
		year = Integer.parseInt((String)row[3]);
	}
	...

데이터 관리 기능 인터페이스 - DataEngineInterface

이 프로그램에서는 DIP 설계를 위해 SongMgr(콘솔 부분)와 GUI 부분을 연결하는 DataEngineInterface가 핵심적인 역할을 한다. 파일에서 입력한 데이터를 리스트로 관리하고 입력, 출력, 검색 등의 기능을 담당하는 부분은 데이터 엔진이라고 이름 붙였다. 그러면 이 인터페이스는 GUI에서 필요한 입력, 검색, 추가, 삭제, 변경 등의 기능을 위한 메소드를 정의하고 있다.

public interface DataEngineInterface {
	// 이 매니저가 관리하는 데이터를 테이블에 보여주기 위해 
	// 열제목의 개수와 배열을 반환. 필요한 열의 개수만큼 배열이 반환됨
	int getColumnCount();
	String[] getColumnNames();
	// 파일에서 Manager로 데이터를 모두 읽어들임
	void readAll(String filename);
	// 키워드에 매치되는 것을 모두 찾아 리스트로 반환
	List<Manageable> search(String kwd);
	// UI 테이블의 행에 있는 데이터를 스트링 배열로 받아와서 새로운 객체 추가
	void addNewItem(String[] uiTexts);
	// UI 테이블의 행에 있는 데이터를 스트링 배열로 받아와서 해당 객체 수정
	void update(String[] uiTexts);
	// UI 테이블의 행의 첫번째 데이터를 키로 받아와 해당 객체를 찾아 삭제
	void remove(String kwd);
}

이 프로그램에서 구체 클래스인 SongMgr는 Manager 클래스를 이용해 요소 데이터를 관리하는 기능을 가지고 추가로 DataEngineInterface에서 필요한 메소드를 제공한다. 예를 들어 readAll 메소드는 다음과 같이 Manager의 readAll을 호출해 주는 역할을 한다.

public class SongMgr implements DataEngineInterface {
    ....
    private Manager mgr = new Manager();
    @Override
	public void readAll(String filename) {
		mgr.readAll("song.txt", new Factory() {
			public Manageable create() {
				return new Song();
			}
		});
	}

또한 search 메소드는 다음과 같이 추가되었다. Manager의 findAll 기능을 이용하고 있다.  

	@Override
	public List<Manageable> search(String kwd) {    // SongMgr
		if (kwd == null)
			return mgr.mList;
		return mgr.findAll(kwd);
	}

Manager의 findAll 기능을 이용하고 있다.  Manager와 Manageable을 이용하는 예제는 앞의 포스트에서 설명되었다.

	public List<Manageable> findAll(String kwd) {   // Manager
		List<Manageable> result = new ArrayList<>();
		for (Manageable m : mList) {
			if (m.matches(kwd))
				result.add(m);
		}
		return result;
	}

그런데 이러한 search 기능을 이용할 때 GUI 쪽에서 한 가지 문제가 생길 수 있다. 즉 GUI 쪽에서 굳이 Manageable에 의존할 필요가 없다는 점이다. ISP 원칙에 의하면 GUI는 UIData에만 의존해야지 Managealbe에 의존해서는 안된다. (TableDemo를 사용하려는 쪽에서 Manageable이 아닌 다른 구조를 가질 수 있다.) 그래서 넘겨받은 리스트를 <?>를 이용하여 무슨 타입이든 가능하게 정의했다. 또한 for 루프에서도 Object 타입을 이용하여 반복하고 있다. 그런데, 이 경우 우리는 검색 결과에 들어있는 요소 타입이 UIData를 구현하였으리라고 가정할 수 있다. (TableDemo를 쓰려면 요소 타입이 UIData를 구현해야 함) 그러므로 Object를 (UIData)로 다운캐스팅하여 getUiTexts를 호출하고 있다.

    void loadData(String kwd) {
    	List<?> result = dataMgr.search(kwd);
    	tableModel.setRowCount(0);
    	for (Object m : result)
    		tableModel.addRow(((UIData)m).getUiTexts());
    }

테이블 셀 수정 금지

먼저 테이블의 셀 수정을 금지하는 방법을 살펴본다. 기본적으로 JTable은 각 셀의 데이터를 수정할 수 있게 허용하는데, 이 프로그램에서는 셀의 값을 그 자리에서 수정하지 못하게 하려고 한다. 테이블에서 각 셀의 값을 수정하지 못하게 하기 위해 다음과 같이 테이블모델 생성부에 메소드를 오버라이드할 수 있다. DefaultTableModel은 일반 클래스지만, 무명클래스 형태로 그것의 메소드를 오버라이드할 수도 있다.

    	tableModel = new DefaultTableModel(dataMgr.getColumnNames(), 0){  
    		 public boolean isCellEditable(int row, int column){ //셀 수정 못하게 하는 부분
    			    return false;
    		 }
    	};

이것도 일종의 DIP라고 할 수 있다. 우리가 라이브러리 모듈의 기능에 대해 설정을 변경하고자 할 때는 이러한 메소드를 재정의하므로써 제어할 수 있게 된다.