▸JAVA/기본 문법

콜백(Callback) 패턴을 사용한 비동기 방식의 원리와 사용법

코데방 2020. 2. 7.
728x90

[ 콜백 (Callback) ]

  • 피호출자(Callee)가 호출자(Caller)를 다시 호출하는 것
  • 비동기적(Asynchronous) 처리를 하기 위한 디자인 패턴의 종류

자바에서 콜백 패턴을 다루는 방법에 대해 정리해 보았습니다. 간단해 보이는데 개념이 정확히 잡혀있지 않으면 꽤나 헷갈리는 패턴인 것 같습니다.

 


 

먼저 아래의 예시 상황을 한번 만들어보겠습니다. 

 

  1. 클래스A는 채팅(하는척) 기능 담당하는 클래스이다. 
  2. 채팅내용으로 "채팅을 하고 있습니다." 라는 문자열을 0.5초에 한번씩 화면에 출력한다.
  3. 채팅 문자열이 10개가 쌓이면 네트워크로 대용량 파일을 전송(하는척)한다.
  4. 클래스B는 파일 전송(하는척) 기능을 담당하는 클래스이다.
  5. 1~10까지의 숫자를 0.5초에 한번씩 전송(하는척)한다. 
  6. 전송률이 40%, 80%일 때 채팅화면에 "파일 전송중입니다(xx%)" 라는 문구를 출력한다.
  7. 전송이 완료되면 파일전송이 종료되고 "파일 전송이 완료되었습니다~!" 문구를 출력한다. 

위의 로직을 수행하기 위한 방법을 한 번 알아보겠습니다. 실제 파일 전송이나 채팅은 아니고 코드 간소화를 위해 반복문으로 모두 처리하도록 하겠습니다.


 

먼저 클래스A는 채팅을 진행합니다. 파일 전송이 발생하더라도 채팅이 끊어지면(블로킹 되면) 안되기 때문에 파일 전송 작업을 담당하는 클래스B는 별도 스레드로 동작시킵니다. 채팅 문구가 10개가 되면 파일 전송을 위해 클래스B를 구현한 스레드가 시작됩니다. 여기까지는 별 문제가 없습니다.

 

문제는 파일 전송 진행사항과 끝났을 때 종료문구를 화면에 출력해야 한다는 점입니다. 물론 콘솔 환경에서는 그냥 어느 스레드에서 출력을 해도 같은 콘솔에 출력되지만 실제 UI 환경이라고 생각하고 클래스B는 출력 기능이 없다고 가정하겠습니다. 클래스B에서도 같은 화면에 출력하는 기능을 만드려고 한다면 어떻게든 만들 수는 있겠지만 코드가 매우 복잡해지고 가독성이 떨어지는 등 많은 문제가 있는 상황 설정이라고 보시면 됩니다.

 

 


 

[ 동기(Synchronous) 방식을 통한 구현 ]

 

이 문제를 해결하기 위해 두 가지 방법이 있습니다. 하나는 클래스A(스레드1)에서 클래스B(스레드2)의 파일 전송 상태를 지속적으로 체크하는 것입니다. B의 상태값을 계속 저장하고, A는 그 상태를 계속 확인하다가 40%, 80%, 종료가 될 때마다 해당 알림을 출력해주면 됩니다. 이렇게 한 스레드가 다른 스레드의 상태를 지속적으로 확인하는 것을 동기적 방식이라고 합니다. 

 

 

 

package hs;

// 클래스A - 채팅을 담당할 클래스
public class A {

	public static void main(String[] args) {

		// 파일 전송 상황을 저장할 객체 생성
		StateB state = new StateB();
		// 파일 전송을 담당할 스레드 생성
		Thread classB = new Thread(new B(state));
		
		for (int i = 0; i < 100; i++) {
	
			System.out.println("채팅중입니다.");
			
			// 10째 문구가 출력되면 파일 전송 시작
			if (i == 9) {
				classB.start();
			}
			
			// 4일 경우
			if (state.four) {
				System.out.println("--파일 전송 중입니다(40%)");
				state.four = false; // 한번만 출력하기 위해 다시 false

			// 8일 경우
			} else if (state.eight) {
				System.out.println("--파일 전송 중입니다(80%)");
				state.eight = false;
			
			// 종료되었을 경우
			} else if (state.finish) {
				System.out.println("--파일 전송이 완료되었습니다~!");
				state.finish = false;
			}
			
			try {
				Thread.sleep(500);
			} catch(Exception e) {
				e.printStackTrace();
			}
		}		
	}
}

// 클래스B의 진행상황을 저장할 클래스
class StateB {
	
	boolean four = false;   // 40% 달설 여부
	boolean eight = false;  // 80% 달성 여부
	boolean finish = false; // 종료 여부
	
}


// 클래스B - 파일 전송을 담당할 클래스
class B implements Runnable {

	StateB state;

	// 생성자
	B(StateB state) {
		this.state = state;
	}
	
	
	@Override
	public void run() {

		for (int i = 1; i <= 10; i++) {

			// 40% 체크
			if (i == 4) {
				state.four = true;
				
			// 80% 체크
			} else if (i == 8) {
				state.eight = true;
			}
			
			
			try {
				Thread.sleep(500);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}

		// 로직이 끝나면 true로 변경
		state.finish = true;
	}
}

 


 

[ 콜백 패턴을 이용한 비동기(Asynchronous) 방식 ]

 

위와 같이 A 스레드에서 계속 B 스레드의 상태를 체크해야 한다면 A 스레드의 로직이 실행되는 내내 다른 스레드의 상태를 확인하는 작업이 필요합니다. B 스레드의 작업이 언제 끝날지 모르는 만큼 반복문으로 계속 돌려서 확인하거나 반복문이 끝나고 다른 로직으로 넘어가더라도 계속 확인해주는 로직을 넣어줘야 합니다. 괜한 연산 자원을 소모하는데다가 코드 또한 낭비가 심해질 수 있습니다.

 

이러한 문제점을 해결하는 구조가 콜백 패턴을 통한 비동기 방식입니다. A는 B를 실행시키기만 하고 아예 신경을 꺼버리는 것이죠. B가 완료되면 알아서 A에게 알려주고 진행사항 및 결과를 출력해주는 것입니다.

 

현재 클래스 B에는 출력 기능이 없으므로 B가 진행사항을 출력하기 위해서는 클래스 A의 메소드를 호출하는 방법밖에 없습니다. 따라서 A가 호출한 B가, 필요 시 다시 A의 메소드를 호출(실행)하기 때문에 "콜백(Callback)" 이라고 부릅니다.

 

 

 


 

그렇다면 이제 어떻게 B가 A의 메소드를 호출하는지에 대한 문제만 남습니다. 자바스크립트 같은 경우는 아예 매개변수에 함수 포인터를 넣어서 호출할 수 있다고 하지만 자바에는 그런 기능이 없습니다. 따라서 직접 작성해줘야 합니다. 물론 이 패턴을 도와주는 별도 기본 클래스가 있긴 하지만 직접 만드는 것도 어렵진 않으니 직접 만들어보겠습니다.

 

콜백 패턴의 핵심은 "현재 실행되고 있는 인스턴스 안에 존재하는 함수의 전달 및 실행"입니다. 깊이 생각하지 않으면 얼핏, new를 사용해 함수가 있는 객체를 생성해서 실행하면 되지 않는가 하는 생각이 들 수도 있습니다. 하지만 아래와 같은 경우를 보겠습니다.

 

package hs;

public class Example {

	public static void main(String[] args) {
		
		// 생성될 때 창을 하나 띄우는 클래스 인스턴스
		App a1 = new App();
		App a2 = new App();
		
		// 창에 문자열을 출력하는 메소드
		a1.printStr("출력을 해봅니다.");
		a2.printStr("출력을 해봅니다.");
	}
}

 

만약 위의 경우와 같이 인스턴스를 두 개 생성해서 메소드를 써준다면 서로 다른 창에 메세지를 출력하게 됩니다. 같은 창에 다른 메세지를 출력하려면 a1의 인스턴스에서 printStr() 메소드를 실행해야 합니다. 따라서 B의 스레드에서 클래스 A의 인스턴스를 별도로 생성한 뒤 출력 메소드를 실행해준다면, 현재 다른 스레드에서 이미 실행중인 ClassA와는 관계없이 별도로 동작하게 됩니다. 다른 창이 하나 더 떠서 그곳에 출력되는 것과 같습니다. 의도한 것이 아니라면 해당 방식은 잘못된 방식이 됩니다.

 

또한 new를 사용해서 다른 클래스에서 해당 기능을 사용할 수 있더라도 이런 방식으로는 클래스 간 강한 결합으로 엮이게 됩니다. 예를 들어 클래스 B에서 클래스 A의 인스턴스를 생성해 사용한다면 클래스A가 변경되면 클래스B의 로직 또한 변경해줘야 할 수 있습니다. 각기 다른 모듈이나 다른 기능을 가진 클래스들이 강한 결합으로 묶이는 것은 그리 좋은 구조가 아닙니다. 

 

자바의 메모리 구조에 대해서는 아래 링크글을 참조하시면 됩니다. 

 

2019/12/09 - [JAVA/기본상식] - Java의 메모리 구조_기본 구조[1/3]

 


 

이제까지의 설명을 요약하자면, "콜백 패턴이란 클래스B에서 마치 클래스A에 코딩되어 있는 것과 같은 방식으로 클래스A의 메소드를 실행시켜주는 방식" 이라고 볼 수 있습니다.

 

구현에 대한 해답은 결국 "객체"의 특성에 있습니다. 아까 위에서 B 스레드의 상태값을 저장한 stateB의 객체를 각 스레드가 공유해서 사용한 것과 같이, 함수가 들어있는 객체를 생성해서 ClassB에게 넘겨준 뒤 ClassB가 필요할 때 해당 객체의 함수를 실행시켜주도록 하면 됩니다. 이 객체는 ClassA 스레드에서 이미 생성하고 사용중인 객체이므로 ClassB로 넘겨주더라도 그 본질은 변하지 않습니다. 8가지 원시타입을 제외한 모든 변수는 참조변수(객체)이며 메모리 주소를 가리키고 있기 때문에 객체를 매개변수로 주고 받는 행위는 실제 값이 들어 있는 주소(포인터)에 대한 정보입니다.

 

일반적인 콜백 패턴에서는 내부 인터페이스로 이를 해결합니다. 따로 인터페이스나 클래스를 만들지 않고도 가시성 좋게 코드를 구현할 수 있어서인 것 같습니다. classA에 내부 인터페이스를 하나 만든 뒤 인스턴스를 생성하면서 오버라이딩해줍니다. 그리고 이렇게 생성된 인스턴스 객체를 classB에게 전달한 뒤, ClassB가 해당 객체의 메소드를 사용하게 되면 결국 classA의 메소드를 실행하는 결과가 됩니다. 객체의 원리만 잘 생각해보면 의외로 간단한 문제입니다. 함수 포인터라는 개념만 없을 뿐, 객체(인스턴스) 자체가 결국 함수 포인터가 될 수 있습니다.

 

아래와 같은 구조로 생각하시면 됩니다. 굳이 다른 클래스가 아니더라도 다른 메소드에게 매개변수로 넘겨줘서 실행시킬 수도 있습니다.

 

 


 

굳이 이정도 로직에서는 저렇게 오버라이딩하고 귀찮게 작성할 필요도 없습니다. 그냥 내부 클래스에다가 메소드를 고정으로 만들어두고 해당 클래스의 인스턴스를 넘겨준 뒤 메소드를 실행시키면 됩니다. 위와 같이 인터페이스로 작성을 많이 하는 이유는 아래와 같이 코드를 유동적으로 사용하기 위함입니다. A가 B에게 일을 시킬 때는 종료할 때 "abc"를 출력하게 하고 다시 A가 C에게 일을 시킬 때는 종료할 때 "def"를 출력하게 한다면 아래와 같이 오버라이딩을 다르게 해서 넘겨줄 수 있습니다.

 

인터페이스도 외부에 따로 생성할 수 있지만 한 클래스에서만 콜백용으로 사용하는 인터페이스라면 내부에 생성하는게 관리하기도 좋고 깔끔한 편입니다. 이것저것 해보면 알겠지만 필드 값 등의 클래스 내부 정보의 사용도 인터페이스 사용이 별도 클래스를 생성하는 것보다 더 쉽습니다. 또한 지금처럼 출력만 하는게 아니라 B의 작업 결과와 상태값을 A에게 전달해서 그 결과값으로 다른 작업을 수행할 수 있도록 하는 등, 여러 가지 면에서 인터페이스를 사용하고 오버라이딩하는 것이 더 편리한 경우가 많습니다.

 

 

 


 

이제 예시 코드와 결과를 보겠습니다. B에서는 어떤 클래스도 new로 생성해주지 않았고 직접 출력하는 메소드를 사용하지 않아도 A가 실행되는 것에 잘 껴서 실행됩니다. 

 

생성자로 callback 객체를 넘겨주는 방법도 있고 setter를 이용해 B에게 객체를 넘겨주는 방법도 있습니다. 두 번째 방법은 B에서 콜백 메소드를 실행하려고하는 시점에 아직 callback 객체가 넘어오지 않은 상태라면 실행하지 않도록 해줄 수 있습니다. A는 콜백을 받을 수 있는 상황이 되면 setter로 callback 객체를 넘겨주는 것이고 반대로 B는 아직 callback 객체가 없다면 A가 콜백을 받고싶어하지 않는 것으로 간주해 호출하지 않는 방식입니다. 이건 간단하니까 그냥 코드에서는 제외하겠습니다. 중요한건 콜백 패턴의 원리니까요.

 

 

package hs;

// 클래스A - 채팅을 담당할 클래스
public class A {

	// 콜백 사용을 위한 인터페이스 정의 (외부에 작성해도 무방)
	// 경우에 따라 메소드의 내부 클래스로 만들어서 활용해도 됨
	interface Callback {

		void printState(int n);
	}

	public static void main(String[] args) throws Exception {

		// B에서 호출할 콜백 메소드 오버라이딩
		Callback callback = new Callback() {

			@Override
			public void printState(int n) {

				if (n > 0) {
					System.out.printf("--파일 전송중입니다(%d%%)--\n", n*10);

				} else {
					System.out.println("--파일 전송이 완료되었습니다~!");

				}
			}
		};

		// 클래스 B를 실행할 스레드 생성 (callback 객체를 생성자로 넘겨줌)
		// setter로 객체를 넘겨주는 구조가 더 좋음 (콜백 받을 수 있는 상태일 때 실행해줌)
		Thread b1 = new Thread(new B(callback));

		// 채팅 시작
		for (int i = 0; i < 100; i++) {

			System.out.println("채팅중입니다.");

			// 열번째 메세지 출력 후 파일 전송 시작
			if (i == 9) {
				b1.start();
			}

			Thread.sleep(500); // 짧게 쓰기 위해 예외는 throws 처리
		}
	}
}

// 클래스B - 파일 전송을 담당할 클래스
class B implements Runnable {

	A.Callback callback;

	// 생성자로 오버라이딩 된 callback 객체를 받음
	B(A.Callback callback) {
		this.callback = callback;
	}

	@Override
	public void run() {

		// 파일 전송 진행
		for (int i = 1; i <= 10; i++) {

			// 4, 8일 경우 콜백 메소드를 실행해줌
			if (i == 4 || i == 8) {
				callback.printState(i);
			}

			try {
				Thread.sleep(500);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}

		// 파일 전송이 끝나면 -1 값을 넣어서 콜백 메소드를 실행
		callback.printState(-1);
	}
}
728x90

댓글

💲 추천 글