본문 바로가기
공부기록/자바

윈도우 알림 개발 with JAVA (feat. System Tray & StompSocket)

by 책읽는 개발자 ami 2024. 3. 14.
728x90
반응형

윈도우 알림 흐름

1. 윈도우 알림 개발의 필요성

요새는 앱 푸시/웹 푸시 등 알림 서비스가 보편화되어 있다.

그래서 그런지 윈도우 알림은 자주 쓰이진 않는 느낌?

하지만 나의 경우엔 아래 상황에서 사용자가 알림을 받을 수 있는 방법이 필요했다.

1. 웹 서비스이기 때문에 앱 없음.

2. 사용자가 웹에 항상 접속해 있지 않음.

3. 개발 언어는 JAVA로(왜? 내가 JAVA 개발자니까...)

물론 문자이메일 같은 방법도 있겠지만, 윈도우 알림을 사용해 보고 싶어서 만들게 되었다.

 

2. 서버/클라이언트 스펙

** 서버 **

- Spring Boot 기반 어플리케이션

- Rest API로 사용자 메시지 수신

- stomp websocket 으로 사용자 메시지 websocket으로 전달

 

** 클라이언트 **

- Spring Boot 기반 어플리케이션(jar로 배포 후 exe로 변환하여 응용프로그램으로 사용 가능)

- 이클립스 JavaFX Plugin 설치 및 프로젝트에 JavaFX 추가(윈도우 알림 기능 사용 가능)

- stomp websocket 으로 사용자 메시지 websocket으로 수신

 

3. 테스트 방법 및 흐름도

테스트 흐름도

4. 결과

5. 구현

필요한 코드들.. 길이에 비해 내용은 심플합니다ㅎㅎ

최대한 설명을 많이 적긴 했는데... 중요한 건 구조에 대한 이해인 것 같네요.

[1] 서버

Spring Boot 프로젝트를 하나 생성해 주세요!

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.5.2</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>kr.example</groupId>
	<artifactId>websocket</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<packaging>war</packaging>
	<name>websocket</name>
	<description>WebSocket Test Server</description>
	<properties>
		<java.version>8</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
    	<!-- websocket -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>		
		<!-- stomp -->
		<dependency>
		    <groupId>org.webjars</groupId>
		    <artifactId>stomp-websocket</artifactId>
		    <version>2.3.4</version>
		</dependency>
        <!-- Jackson JSON Mapper -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
    </dependencies>
	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>				
			</plugin>
		</plugins>
	</build>
</project>

WebSocketApplication.java

@SpringBootApplication
public class WebSocketApplication extends ServletInitializer {
	
	@Override
	protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
		return super.configure(application);
	}
	
	public static void main(String[] args) {
		SpringApplication.run(WebSocketApplication.class, args);
	}
}

ServletInitializer.java

public class ServletInitializer extends SpringBootServletInitializer {
	@Override
	protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
		return application.sources(WebSocketApplication.class);
	}
}

WebSocketConfig.java

@Configuration
@EnableWebSocketMessageBroker //웹소켓 사용할 수 있게 하는 어노테이션(메시지 브로커, 메시지 핸들링 등 주요 기능 제공)
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	
    @Override
    public boolean configureMessageConverters(List<MessageConverter> messageConverters) {
        messageConverters.add(new StringMessageConverter());
        //messageConverters.add(new MappingJackson2MessageConverter());
        return WebSocketMessageBrokerConfigurer.super.configureMessageConverters(messageConverters);
    }
	
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/all","/specific");
        config.setApplicationDestinationPrefixes("/app");
    }
	
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
         registry.addEndpoint("/ws").setAllowedOrigins("*");
    }
}

소스 설명

configureMessageConverters : 메시지 컨버터 관련 설정하는 함수. 여기선 문자열 메시지를 처리할 수 있도록 해두었다. 주석처리한 부분을 풀면 json 형식도 사용 가능하다.

configureMessageBroker: 메시지 브로커 관련 설정하는 함수(메시지 브로커란? 클라이언트와 서버 간 메시지 전송을 관리하는 역할을 함)

    enableSimpleBroker: 여기서 작성한 걸 '토픽'이라고 부른다. (/all, /specific) 해당 토픽을 통해 메시지를 브로드캐스팅할 수 있게 해 준다.

    setApplicationDestinationPrefixes: 여기서 클라이언트가 메시지를 서버로 전달할 때 사용하는 프리픽스를 정의한다.

registerStompEndpoints: 엔드포인트 등록하는 함수. 여기서 CORS를 허용하는 규칙을 작성할 수 있다.

 

WebSocketRestController.java

@RestController
public class WebSocketRestController {
	private SimpMessagingTemplate template; //메시지 전송을 간편하게 할 수 있는 템플릿.
	
	private ObjectMapper mapper = new ObjectMapper(); //객체를 JSON문자열 형태로 변환하기 위해 사용.

	@Autowired
	public WebSocketRestController(SimpMessagingTemplate template) {
		this.template = template;
	}
	
	@PostMapping("/send/all")
	@SendTo("/all/messages")
	public void send(@RequestBody Map<String, String> params) {		
		String message = params.get("message");
		String sender = params.get("sender");
		Map<String, String> payload = new HashMap<String, String>();
		payload.put("message", message);
		payload.put("title", sender + "님의 메시지!");
		
		String jsonString = "";
		try {
			jsonString = mapper.writeValueAsString(payload);
		} catch (JsonProcessingException e) {
			e.printStackTrace();
		}
		
		this.template.convertAndSend("/all/messages", jsonString);
	}
}

소스 설명

@SendTo("/all/messages") 어노테이션: 해당 메서드가 완료되면 "/all/messages" 토픽으로 메시지를 전송해야 함을 나타낸다.

해당 메소드에서 메시지를 가공하여 JSON문자열 형태로 변환한 뒤, SimpleMessagingTemplate을 사용하여 메시지를 브로드캐스팅 한다.

[2] 클라이언트

마찬가지로 스프링 프로젝트를 하나 만들어주세요.

JavaFx를 사용하기 위해선 이클립스 마켓플레이스에서 JavaFx 설치가 필요합니다.

 

그리고 생성한 스프링 부트 프로젝트에 javafx를 추가해야되는데 이게 좀 트리키합니다...

저는 일단 아래 링크를 참조해서 추가했습니다.

https://luminitworld.tistory.com/44

 

Eclipse 환경에서 JavaFX 사용하기 (JDK11 버전 이후)

javafx는 자바언어로 컨포넌트들을 보다 편하게 위치시키고 디자인할 수 있는 그래픽 미디어 패키지입니다. 자바 응용 프로그램을 개발하기 위한 JDK는 JDK11부터 javafx가 별개로 떨어져서 javafx 플

luminitworld.tistory.com

위 링크에선 javafx 프로젝트를 생성해서 했는데, 여기선 spring boot 프로젝트 생성 후 위 설정을 적용하면 됩니다.

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.5.2</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>kr.example</groupId>
	<artifactId>websocket</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<packaging>jar</packaging>
	<name>websocket</name>
	<description>WebSocket Test Client</description>
	<properties>
		<java.version>8</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
		    <groupId>org.apache.tomcat.embed</groupId>
		    <artifactId>tomcat-embed-jasper</artifactId>
		</dependency>
        <!-- Jackson JSON Mapper -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        
		<!-- websocket -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>
		
		<!-- stomp -->
		<dependency>
		    <groupId>org.webjars</groupId>
		    <artifactId>stomp-websocket</artifactId>
		    <version>2.3.4</version>
		</dependency>
		
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

WebSocketApplication.java

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class WebSocketApplication {	
	public static void main(String[] args) {
		SpringApplication.run(WebSocketApplication.class, args);
	}
	//CommandLineRunner 애플리케이션이 시작된 후 특정 코드를 실행하도록 하는 인터페이스.
	@Bean
    public CommandLineRunner run(SystemTrayService systemTrayService) {
        return args -> {
            systemTrayService.initSystemTray(); //프로그램이 시작되면 해당 메소드를 호출한다.
        };
    }
}

SystemTrayService.java

import java.awt.AWTException;
import java.awt.Desktop;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.SystemTray;
import java.awt.Toolkit;
import java.awt.TrayIcon;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;

import org.springframework.stereotype.Service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;

@Service
public class SystemTrayService {
	
	private TrayIcon trayIcon;
	
	private ObjectMapper mapper = new ObjectMapper();
	
    //시스템 트레이를 초기화하는 메서드(프로그램 시작 시 한 번만 호출)
	public void initSystemTray() throws AWTException {
		
		System.setProperty("java.awt.headless", "false");
		
		if(SystemTray.isSupported()) {
                    SystemTray tray = SystemTray.getSystemTray();
                    String toolTip = "New Alarm";
                    URL imageUrl = this.getClass().getClassLoader().getResource("static/images/banana.png");
                    Image image = Toolkit.getDefaultToolkit().createImage(imageUrl);
                    trayIcon = new TrayIcon(image, toolTip);
                    trayIcon.setImageAutoSize(true);
                    tray.add(trayIcon);
		
			try {
                            PopupMenu popup = new PopupMenu();

                            MenuItem menuItem1 = new MenuItem("홈");
                            MenuItem menuItem3 = new MenuItem("종료");
                            menuItem1.addActionListener(new ActionListener() {
                                @Override
                                public void actionPerformed(ActionEvent e) {
                                    try {
                                        Desktop.getDesktop().browse(new URI("http://localhost:6060/home/main"));
                                    } catch (IOException e1) {
                                        e1.printStackTrace();
                                    } catch (URISyntaxException e1) {
                                        e1.printStackTrace();
                                    }
                                }
                            });

                            menuItem3.addActionListener(new ActionListener() {
                                @Override
                                public void actionPerformed(ActionEvent e) {
                                    tray.remove(trayIcon);
                                    System.exit(0);
                                }
                            });

                            popup.add(menuItem1);
                            popup.add(menuItem3);

                            trayIcon.setPopupMenu(popup);
		        
			} catch (Exception e) {
				e.printStackTrace();
			} 
		}
	}
	
	public void displayMessage(Object payload)  {
                Map<String, String> value = new HashMap<String, String>();
                try {
                    value = mapper.readValue(payload.toString(), HashMap.class);
                } catch (JsonMappingException e) {
                    e.printStackTrace();
                } catch (JsonProcessingException e) {
                    e.printStackTrace();
                }
                String title = value.get("title");
                String message = value.get("message");
                trayIcon.displayMessage(title, message, TrayIcon.MessageType.INFO);
	}	
}

소스 설명

System.setProperty("java.awt.headless", "false"); // AWT는 그래픽 사용자 인터페이스를 생성하고 관리하는 Java 일부다. 그러나 일부 환경에선 그래픽 사용자 인터페이스를 지원하지 않기 때문에, 이를 대비하여 Java는 헤드리스 모드를 지원한다. (헤드리스 모드에선 그래픽을 표시하지 않고, 그래픽 관련 작업을 수행하지 않음) 

system tray

1. 이미지와 툴팁을 사용하여 TrayIcon을 생성

2. PoupMenu에 아이템 추가(아이콘을 우측 클릭 했을 때 나오는 메뉴)

    [1] 홈 - 웹 브라우저를 열어 특정 url로 이동

    [2] 종료 - 애플리케이션 종료

displayMessage - 알림 메시지를 표시하는 메서드 (MyStompSessionHandler.java에서 호출할 함수)

payload는 JSON형식의 문자열로 전달된다. 따라서 JSON 형식으로 파싱해주어야 한다.

TrayIcon.displayMeessage()를 사용하여 윈도우 알림을 표시한다. (위 결과 이미지 참조)

 

ClientWebSocketStompConfig.java

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.converter.StringMessageConverter;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompSessionHandler;
import org.springframework.web.socket.client.WebSocketClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
//STOMP 웹 소켓 클라이언트단 설정하는 파일
@Configuration
public class ClientWebSocketStompConfig {
	@Bean
    public WebSocketStompClient WebSocketStompClient(WebSocketStompClient webSocketClient,
                                                     StompSessionHandler stompSessionHandler) {

		webSocketClient.setMessageConverter(new StringMessageConverter());	//문자열 형식으로 메시지를 받기 위한 설정.
		
        Object[] urlVariables = {};
        String url = "ws://localhost:6662/ws";
        //url에 웹 소켓 서버 URL을 지정하여 연결하는
        webSocketClient.connect(url, stompSessionHandler, urlVariables);

        return webSocketClient;
    }
	
    @Bean
    public WebSocketStompClient webSocketClient() {
    	//Spring FrameWork에서 기본적으로 제공되는 WebSocketStompClient 객체를 사용
        WebSocketClient webSocketClient = new StandardWebSocketClient();
        return new WebSocketStompClient(webSocketClient);
    }
    
    @Bean
    public StompSessionHandler stompSessionHandler() {
    	//커스텀 세션 핸들러를 사용
        return new MyStompSessionHandler();
    }
}

 

MyStompSessionHandler.java

import java.lang.reflect.Type;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;


public class MyStompSessionHandler extends StompSessionHandlerAdapter {
	
	@Autowired
	private SystemTrayService trayService;
	
	@Override
	public Type getPayloadType(StompHeaders headers) {
		return super.getPayloadType(headers); //메시지 페이로드 유형 결정. 여기선 기본 구현체 사용
	}
	@Override
	public void handleException(StompSession session, StompCommand command, StompHeaders headers, byte[] payload,
			Throwable exception) {
		super.handleException(session, command, headers, payload, exception); //예외 발생시 호출되는 부분
	}
	@Override
	public void handleTransportError(StompSession session, Throwable exception) {	
		super.handleTransportError(session, exception); //연결에 문제 생겼을 때 호출되는 부분
	}
	@Override
    public void afterConnected(StompSession session, StompHeaders connectedHeaders) { //웹소켓이 성공적으로 연결되었을 때
        session.subscribe("/all/messages", this);
    }
	@Override
    public void handleFrame(StompHeaders headers, Object payload) { //수신된 메시지 처리
        trayService.displayMessage(payload);
    }	
}

소스 설명

handleFrame 메시지 수신 시 해당 함수가 호출된다. 해당 예제에서는 trayService.displayMessage(payload);를 통해 시스템 트레이에 메시지를 보여주는 함수를 호출한다.

6. 실행

클라이언트 프로젝트\target 하위 폴더에 jar 파일이 생겼나요?

그렇다면 exe로 변환하여 사용자가 프로그램을 쉽게 사용할 수 있어야 하지 않겠습니까?

저는 아래 페이지를 참조하여 변환해 쉽게 성공했습니다.

프로그램을 별도로 설치해야 하긴 하지만 가볍고 쉽습니다!

https://haenny.tistory.com/270

 

[Launch4j] JAR 파일로 응용프로그램(exe파일) 만들기

[Launch4j] JAR 파일로 응용프로그램(exe파일) 만들기 1. Launch4j 다운로드 및 설치 Launch4j - Cross-platform Java executable wrapper Cross-platform Java executable wrapper Launch4j is a cross-platform tool for wrapping Java applications d

haenny.tistory.com

 

7. 결론

열심히 만들어서 테스트까지 완료하였으나 실제로 사용하기 위해선 추가해야될 부분이 많습니다..

제일 중요한 부분은 보안!! 보안을 위해선 클라이언트 프로그램에도 Spring Security를 적용해야 합니다.

시큐리티를 적용하려면 사용자 로그인이  필요하고, 로그인을 하려면 gui도 필요하겠죠?

또한 웹소켓 연결, 수신 등 예외처리를 전혀 구현해두지 않았기 때문에 해당 부분도 보완이 필요할 거구요..ㅎㅎ

일이 점점 커지는 것 같아 이건 그냥 개인 프로젝트로 남겨두기로 했습니다:)

언젠간 구현할 나를 위해.. 화이팅~

728x90
반응형