1. 사용자 정의 이벤트가 필요할 때
버튼 클릭, 마우스 이동, 텍스트 변경 같은 표준 Java 이벤트는 빙산의 일각에 불과합니다. 실제 애플리케이션에서는 이러한 틀에 들어가지 않는 상황이 많이 발생합니다. 예를 들어 프로그램이 인터넷에서 데이터를 로드하고 로딩이 완료되면 애플리케이션의 다른 부분에 알려야 할 수 있습니다. 게임에서는 플레이어가 새로운 업적을 획득할 수 있고, 이에 대해 UI와 로깅 시스템 등 여러 컴포넌트에 동시에 알려야 합니다. 비즈니스 애플리케이션에서는 주문 상태 변경이 회계, 창고, 사용자에게 동시에 알림을 트리거해야 합니다.
이런 경우에는 사용자 정의 이벤트와 리스너를 만드는 것이 유용합니다. 컴포넌트 간 상호작용의 고유한 시나리오를 표현할 수 있고, 코드를 더 유연하고 구조적으로 만들 수 있습니다.
사용자 정의 이벤트의 구조
Java에서 자체 이벤트를 만들려면 보통 세 가지를 구현합니다:
- 이벤트 클래스 — 보통 java.util.EventObject의 하위 클래스로 만듭니다. 이벤트에 대한 정보(소스, 이벤트와 연관된 데이터 등)를 저장합니다.
- 리스너 인터페이스 — 예를 들어 MyEventListener처럼 java.util.EventListener를 확장하고 이벤트 처리 메서드를 정의합니다.
- 구독/해제 메커니즘 — 이벤트 소스에 add...Listener/remove...Listener 메서드를 제공하고, 이벤트 발생 시 리스너를 호출하는(fire...) 동작을 구현합니다.
이제 파일이나 네트워크에서 데이터를 로드하고, 로딩 완료를 다른 곳에 알려야 하는 애플리케이션 예제로 단계별로 살펴보겠습니다.
이벤트 클래스
로딩 완료 정보를 담는 이벤트 클래스를 만들어 봅시다.
import java.util.EventObject;
// 이벤트 클래스: EventObject를 상속합니다
public class DataLoadedEvent extends EventObject {
private final String data; // 이벤트에 대한 추가 정보
public DataLoadedEvent(Object source, String data) {
super(source); // source: 이벤트를 발생시킨 객체
this.data = data;
}
public String getData() {
return data;
}
}
여기서 source는 이벤트의 소스 객체(예: 데이터 로더)이고, data는 로드된 데이터 문자열입니다(파일 경로, JSON, 결과 등일 수 있음).
리스너 인터페이스
리스너 인터페이스를 정의합니다. 보통 EventListener(타입 지정을 위한 마커 인터페이스)를 확장합니다.
import java.util.EventListener;
// 해당 이벤트의 리스너 인터페이스
public interface DataLoadedListener extends EventListener {
void dataLoaded(DataLoadedEvent event);
}
dataLoaded 메서드는 이벤트가 발생했을 때 호출됩니다.
이벤트 소스
리스너 목록을 보관하고, 이를 등록/삭제하며, 이벤트 발생 시 통지해 줄 주체가 필요합니다.
import java.util.ArrayList;
import java.util.List;
public class DataLoader {
private final List<DataLoadedListener> listeners = new ArrayList<>();
// 리스너 등록
public void addDataLoadedListener(DataLoadedListener listener) {
listeners.add(listener);
}
// 리스너 제거
public void removeDataLoadedListener(DataLoadedListener listener) {
listeners.remove(listener);
}
// 이벤트를 발생시키는 메서드(예: 데이터 로드 완료 후)
private void fireDataLoaded(String data) {
DataLoadedEvent event = new DataLoadedEvent(this, data);
// 모든 리스너에게 알림
for (DataLoadedListener listener : listeners) {
listener.dataLoaded(event);
}
}
// 데이터를 "로드"하는 예시 메서드
public void loadData() {
// 로드를 시뮬레이션합니다(예: 파일 또는 네트워크)
String loadedData = "이것은 로드된 데이터입니다!";
System.out.println("데이터가 로드되었습니다: " + loadedData);
// 모든 리스너에게 알립니다
fireDataLoaded(loadedData);
}
}
실제 환경에서는 loadData() 메서드가 비동기적으로 동작하고, 파일을 읽거나 서버에 요청하는 등 더 복잡할 수 있지만, 여기서는 단순히 로드를 시뮬레이션합니다.
2. 사용자 정의 이벤트 사용
데이터 로드 완료를 알고 싶어 하는 컴포넌트가 있다고 가정해 봅시다.
public class DataLoadedHandler implements DataLoadedListener {
@Override
public void dataLoaded(DataLoadedEvent event) {
System.out.println("핸들러가 이벤트를 받았습니다: " + event.getData());
}
}
이제 애플리케이션의 메인 클래스에서 모두 연결해 보겠습니다:
public class Main {
public static void main(String[] args) {
DataLoader loader = new DataLoader();
DataLoadedHandler handler = new DataLoadedHandler();
// 리스너를 등록합니다
loader.addDataLoadedListener(handler);
// 데이터 로드를 시작합니다
loader.loadData();
}
}
무엇이 일어날까요?
- DataLoader가 데이터를 로드합니다(시뮬레이션).
- 로드 후 fireDataLoaded를 호출하고, 이 메서드는 이벤트 객체를 만들어 모든 리스너에게 알립니다.
- 우리의 핸들러(DataLoadedHandler)가 이벤트를 받아 메시지를 출력합니다.
예시 출력:
데이터가 로드되었습니다: 이것은 로드된 데이터입니다!
핸들러가 이벤트를 받았습니다: 이것은 로드된 데이터입니다!
익명 클래스와 람다 표현식 사용
리스너마다 별도의 클래스를 만들지 않기 위해, 종종 익명 클래스나 람다 표현식(Java 8+)을 사용합니다:
public class Main {
public static void main(String[] args) {
DataLoader loader = new DataLoader();
// 람다 표현식으로 리스너 등록
loader.addDataLoadedListener(event ->
System.out.println("람다 핸들러: " + event.getData())
);
loader.loadData();
}
}
리스너 등록과 제거
리스너는 추가/제거할 수 있습니다. 이는 메모리 관리와 누수 방지에 중요합니다(특히 리스너가 "무거운" 객체이거나 더 이상 필요하지 않은 경우).
DataLoadedHandler handler = new DataLoadedHandler();
loader.addDataLoadedListener(handler);
// 나중에, 더 이상 핸들러가 필요 없으면:
loader.removeDataLoadedListener(handler);
리스너를 제거하지 않고 이벤트 소스가 오래 살아 있다면, 리스너는 목록에 남아 가비지 컬렉터로 수거되지 않을 수 있습니다 — 이는 메모리 누수로 이어질 수 있습니다.
3. 실습: 미니 예제 — 카운터
학습용 애플리케이션에서 확장할 수 있는 간단한 예제를 만들어 봅시다. 값이 증가할 때마다 새로운 값을 리스너에게 알려 주는 카운터 클래스를 가정합니다.
이벤트 클래스
import java.util.EventObject;
public class CounterChangedEvent extends EventObject {
private final int newValue;
public CounterChangedEvent(Object source, int newValue) {
super(source);
this.newValue = newValue;
}
public int getNewValue() {
return newValue;
}
}
리스너 인터페이스
import java.util.EventListener;
public interface CounterChangedListener extends EventListener {
void counterChanged(CounterChangedEvent event);
}
카운터 클래스
import java.util.ArrayList;
import java.util.List;
public class Counter {
private int value = 0;
private final List<CounterChangedListener> listeners = new ArrayList<>();
public void addCounterChangedListener(CounterChangedListener listener) {
listeners.add(listener);
}
public void removeCounterChangedListener(CounterChangedListener listener) {
listeners.remove(listener);
}
public void increment() {
value++;
fireCounterChanged();
}
private void fireCounterChanged() {
CounterChangedEvent event = new CounterChangedEvent(this, value);
for (CounterChangedListener listener : listeners) {
listener.counterChanged(event);
}
}
}
사용법
public class Main {
public static void main(String[] args) {
Counter counter = new Counter();
// 람다로 리스너를 등록합니다
counter.addCounterChangedListener(event ->
System.out.println("카운터가 변경되었습니다: " + event.getNewValue())
);
counter.increment(); // 카운터가 변경되었습니다: 1
counter.increment(); // 카운터가 변경되었습니다: 2
}
}
4. 사용자 정의 이벤트를 만들고 처리할 때 흔한 실수
오류 №1: 리스너 알림 메서드 호출을 잊음. 이벤트는 생성되지만 리스너에게 알리는 메서드(fireDataLoaded, fireCounterChanged)가 호출되지 않습니다. 그 결과 리스너는 아무 반응이 없습니다.
오류 №2: 리스너 처리기에서 발생한 예외. 리스너 중 하나가 예외를 던지면 나머지가 알림을 받지 못할 수 있습니다. 한 “문제” 리스너가 다른 리스너를 방해하지 않도록, 리스너 호출을 try–catch로 감싸는 것이 좋은 실천입니다.
오류 №3: 리스너를 제거하지 않음. 더 이상 필요 없는 리스너가 제거되지 않으면 계속 이벤트를 받으며 메모리에서 제거되지 않습니다. 소스의 수명이 길고 리스너가 많을수록 메모리 누수로 이어질 수 있습니다.
오류 №4: 순회 중 리스너 목록 수정. 이벤트 처리 중에 누군가 리스너를 추가/삭제하면 ConcurrentModificationException이 발생할 수 있습니다. 안전한 접근법은 먼저 목록을 별도의 배열로 복사한 뒤, 복사본을 순회하는 것입니다.
오류 №5: 처리기에서 오래 걸리는 작업 수행. 처리기가 파일 로드, 네트워크 대기처럼 오래 걸리는 일을 하면 UI가 멈출 수 있습니다. 무거운 작업은 별도의 스레드로 옮기거나 비동기 메커니즘을 사용하세요.
GO TO FULL VERSION