POC 프로젝트가 맡겨졌다. 병원 환자 모니터와 연동할 웹개발인데, 컨셉만 증명하는 프로젝트라 간단할 것 같았던 일이 사실은 고생길의 시작이라는 것을 몰랐다. 라즈베리 파이에서 qt를 통해 gateway서버에 쏘면 gateway 는 원격 웹 서버에 그 정보들을 HL7 protocol로 변환하여 전송하다. 웹 서버는 연동하는 프론트 서버에게 그 정보들을 실시간으로 뿌려준다.
P.M(라즈베리파이) 기기는 총 16개가 중앙서버(C.S)와 연결될 것이다. 그림만 이렇게 설계하였을 뿐,(이 그림도 내가 만들었다. 사실상 기획서가 없는 상태에서 시작했다..) 그 외에 어떤 설계도 없었다. 그래서 각자 알아서 만드는 과정에서 많은 장애가 있었다. 일단 요구된 기능은 크게 한 6가지 정도로 추려볼 수 있는데,
웹 화면에서 환자 검색 기능, QT에서도 환자 검색가능(둘은 별도의 기능)
기기 하나당 하나의 환자정보 일정시간동안 측정데이터를 날리는데, 그 데이터들을 DB에 저장
측정데이터를 웹 그래프 화면에 표시, QT에서 보내는 그래프 파형과 일치해야됨
환자에 대한 파일을 CentralStation Server로 전송업로드 가능
GW가 살았는지 죽었는지 응답가능
세션 시작시 세션 정보를 저장
보고나니 정말 생각보다 별 거 없다고 생각이 들지만, 구현하는 과정이 만만치 않았다.. 만드는 과정에서 협업은 3명이서 하였다. 프론트쪽 서버를 만드는 프론트개발자 한 분, qt쪽을 만드는 개발자, 나는 gw와 cs( 중앙)서버를 만드는 역할을 담당했다. 프론트쪽 개발에 내가 많이 참여하게 되었다.
먼저 QT와 통신할 gateWay 서버를 만들기로 했다. (springboot에 java11 환경) 이 소프트웨어는 qt와 함께 라즈베리파이에 심고, qt와 통신하며 그 결과값을 중앙 centralStation 서버로 쏴주는 역할을 하는 gateWay로서 통신과정에서 TCP layer에서만 빠른 속도로 실시간으로 바이트 단위 전달을 하기 위해 rest 통신 같은 것을 제거하고 소켓으로 통신한다. 그래서 내장톰캣을 껐다.
gateWay는 nio socket으로 central station과 qt에 접속한다. 일단 nio socket을 써본다는 것 자체가 곤혹이었다. nio socket은 커녕 일반 socket 관련 프로그램도 학부생 시절에 한 번 만들어 본 게 다이다.. 더군다나 qt 쪽에서 보내는 Data들을 신뢰할 수 없는 상황이니 일일히 예외 테스트 케이스를 만들어서 버퍼로 읽어줘야 됐다. 생각한 것보다 더 로우레벨에 가까운 일이다.
비동기 쪽 로직도 골치상황이었다. QT와 연동할 socket과 CS와 연동할 socket 2개를 동시에 열어줘야했기 때문에, 모든 흐름은 비동기처리를 고려해야했다. 그나마 예전에 swing으로 game을 만들면서 multi-thread에 대한 개념이 있어서 다행이지.. 아니었다면 많이 헤멨을 상황이었다. 아무튼 고생고생하며 QT에서 보내는 데이터를 하나의 제이슨 단위로 읽어서 처리하는 과정을 완료했다.
여기까지는 좋았다. 헷갈리긴 하였지만, 크게 어려운 부분도 없었고, 첫번째 난항은 QT에서 전송해준 jsonData를 다시 HL7 protocol로 변환해서 C.S 서버에게 전달해주는 과정이었다. 생전 처음 보는 프로토콜이었다. 병원 프로토콜이라는데, 이 프로토콜을 익히는데만 하더라도, 꽤나 고생했다. 대충 아래 같이 변환해줘야 된다.
처음 설계됐던 대로 C.S와 GW은 HL7 Protocol을 사용해서 통신해야 한다. 그냥 편하게 Json으로 할 수 없나요? 물어보니 규격을 따라야 된다고 대답하더라.. 까라면 까라지 정신으로 변환하였다. socket으로 변환된 hl7 protocol을 사용해서, c.s 서버에 전달하는 흐름까지 완성한 후.. c.s 만들기에 착수했다.
c.s 서버를 만드는 것도 골칫덩어리다. 일단 붙어있는 DB는 2개여야 되는데, 하나는 사내 DB로 mysql를 쓸 거고, 다른 하나는 EMR로 병원 DB 가 연결해있어야 된다. 각자 테스트 용으로 oracle 스키마와 mysql 스키마를 만들고, ERD를 만들었다. oracle 관련 로직은 실제 병원에서는 프로시저를 호출할 거라, 실제와 비슷한 환경으로 프로시저를 만들어서 호출하는 식으로 했다. 사실 이 상황에서 JPA를 쓰고 싶었지만, 회사에서는 Mybatis를 권유했다.
프론트 개발은 Next.js를 활용하여 개발하였다. 사실은 지금 프로젝트가 서버사이드랜더링을 할 필요가 없어서, 딱히 Next를 쓸 필요 없이 순수한 React.js를 활용하여도 됐지만, 그냥 다른 회사의 프론트 작업물들이 Next.js 위에 굴러갔기 때문에 비슷하게 갈려고 선택했다. 프론트에서 측정데이터를 받는 과정에서는 처음에 웹소켓을 고려했다가, SSE Protocol를 사용하기로 결정했다. 측정데이터를 제외한 Rest 요청같은 경우 axios + redux saga+ redux를 통해서 해결하기로 했다.
HL7 protocol로 변환시 실제로 전송해야되는 정보 중에서 HL7 protocol에 포함되지 않는 정보들도 있다. 그럴때면 임시방편으로 규격을 내가 만들어서 추가해줘야 했다. hl7 protocol를 이해하는 데만 하더라도 꽤나 골머리를 앓았고, 이렇게 원래 규격에 포함이 되지 않을 때는 구색맞추기 식으로 진행하였다. 새삼 JSON이 얼마나 편한 통신규격인지 깨달을 수 있는 과정이었다..
서버를 여러개 띄워두고 테스트를 해야되니, 정신이 없었다. 기본적으로 띄워야 되는 서버만 하더라도, GW, C.S, FrontEnd Server 그리고 QT를 여기서 내가 돌릴 수 없으니, SocketTest라는 애뮬레이터 프로그램을 다운받아서 대체했다. 4개를 띄워놓고 혼자서 테스트를 하는데, 테스트하는데 얘를 먹었다.
DB를 여러개 연결하는 과정도 시간이 걸렸다. 이전에는 springBoot rest API를 만들면서, DB를 하나만 써봤는데, DB를 2개 이상 연결하려니, DB 객체를 Bean으로 등록하는 과정에서 중복이슈가 터졌다. Bean을 만드는 과정에서 싱글톤으로 등록하는데, 여러개의 SqlSessionFactoryBean을 등록하려다 보니, bean의 이름이 어노테이션만 쓰면 자동으로 소문자로 시작해서 등록해주는데, 이름 충돌이 걸린 것이다. 처음에는, BeanNameGenerator
를 상속받아서 이름 짓기 전략을 패키지 명 풀경로까지 지정할려고 했는데, 더 번거로울 것 같아서.. @Primary & @Qualifier 를 활용했다.
@Primary & @Qualifier 를 사용하는데 있어서 @RequiredArgsConstructor 가 안 먹히는 이슈는 lombok.config 파일을 만드는 것으로 해결했다.
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
<!-- log4jdbc -->
<dependency>
<groupId>org.bgee.log4jdbc-log4j2</groupId>
<artifactId>log4jdbc-log4j2-jdbc4.1</artifactId>
<version>1.16</version>
</dependency>
<dependency>
<groupId>com.oracle.ojdbc</groupId>
<artifactId>orai18n</artifactId>
<version>19.3.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- MariaDB는 다른 식으로 연결.. -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<!-- 스프링 부트에서 Log4j2를 사용하기위해선 내부로깅에서 쓰이는 의존성을 제외해주어야 합니다. 기본적으로 Spring은 Slf4j라는 로깅 프레임워크를 사용합니다. 구현체를 손쉽게 교체할 수 있도록 도와주는 프레임 워크입니다. Slf4j는 인터페이스고 내부 구현체로 logback을 가지고 있는데, Log4j2를 사용하기 위해 exclude 해야 합니다. -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc8</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc8</artifactId>
<scope>runtime</scope>
</dependency> //안 먹힘
//@Slf4j
//@ExtendWith(SpringExtension.class)
// @Transactional //실제 DB에 값이 들어가지 않도록.
//@SpringBootTest
//public class IntegTest {
//
// @Qualifier("MysqlPatientMapper")
// @Autowired
// private PatientMapper patientMapper;
//
// @Qualifier("MysqlMeasureDataMapper")
// @Autowired
// private MeasureDataMapper measureDataMapper;
//
//
// @Qualifier("OracleMeasureDataMapper")
// @Autowired
// private net.lunalabs.central.mapper.oracle.MeasureDataMapper oracleMeasureDataMapper;
//
// @Autowired
// private net.lunalabs.central.mapper.oracle.PatientMapper oraclePatientMapper;
//
// @Autowired
// private GlobalVar globalVar;
1. CentralStation에서 FTP Server를 만드는 데 있어서, 파일 경로를 설정해주는 데 이슈가 있었다. 테스트 배포서버의 OS는 우분투인데 현재 내 윈도우 OS 경로의 FILePath를 지정해주면 OS간 경로 특수문자가 달라서 파싱이 여의치 않았다.. 이 부분은 GW 쪽 FTP protocol를 사용해서 파일 전송하는 부분에서 코드를 수정해줘야 했다.
```java
String pattern = Pattern.quote(System.getProperty("file.separator"));
logger.debug("filePath: " + filePath);
File fileTest = new File(filePath);
String fileName = fileTest.getName();
public static String parsingFilepath(String string) {
if (string == null || string.length() == 0) {
return "";
}
StringBuffer sb = new StringBuffer();
char c = 0;
int i;
int len = string.length();
for (i = 0; i < len; i += 1) {
c = string.charAt(i);
switch (c) {
case '\\':
log.debug("...." + c);
sb.append("\\" +File.separatorChar);
break;
default:
sb.append(c);
}
}
return sb.toString();
}
String parseFilepath = Common.parsingFilepath(strMessage);
logger.debug("escapeFile: " + parseFilepath);
obj = (JSONObject)parser.parse(parseFilepath);
String filename = String.valueOf(obj.get("filename")); //여기서 문제.
logger.debug("ftp uploder로 넘겨주는 filename: " + filename);
유독 특수문자 관련하여 파싱 부분에서 신경쓸 필요가 많았다.. 이런 로우레벨에 가까운 작업은 되도록 피하고 싶은 경험이었다..
socket 채널에서 읽는 메서드는 비동기 처리해야 여러개의 gw가 달라붙을 수 있었다. 이거는 항상 헷갈리는 점이다.
SSE 프로토콜을 이 프로젝트에서 처음 써봤다. GW에서 던진 DATA를 C.S가 받아서 FrontServer로 계속 전달해서 그래프에 반영해줘야 되는데, 이 부분에서 처음 0.1초 단위로 프론트 쪽에서 ajax polling을 하는 식으로 구현할려고 하다가, 실시간 데이터 반영에 어울리지 않다고 판단, WebSocket을 쓸까 고민하다가, 측정데이터 같은 경우 굳이 양방향 통신이 필요없다고 판단. SSE를 쓰기로 결정했다. 이 프로토콜을 통해서 데이터를 받는 쪽은 프론트 개발자도 잘 모르는 영역이라 내가 대신 작업해줬다.. 처음 써보는 프로토콜이라 많이 헤멨다..
프론트에서 전송받은 DATA를 그래프로 변환시키는데도 얘를 엄청 먹었다. 이게 realtime으로 계속 그려져야 되니, 처음에는 apex chart를 통해서 만들었는데, 배열에 x축과 y축을 집어넣는 방식이었다. 그런데 배열에 계속 값이 쌓이니, 어느순간 쌓이면 앞의 인덱스들을 지워주는 로직을 작성해야 한다. 이 부분도 프론트개발자가 잘 몰라서 내가 대신 만들어줬다.....
나머지 REST로 구현한 API 요청은 리덕스와 리덕스 사가를 통해서 구현하기로 했는데 이 부분도 프론트 개발자가 잘 몰라서 내가 대신 구현해주었다..
이제 진짜 완성되었다고 쳤는데, 그래프가 버벅거리는 이슈가 발생했다. 알고보니 데이터를 업데이트 해주는 함수에서 useEffect를 custom한 hooks를 사용했는데, 같은 데이터가 들어올 때는 함수가 실행되지 않는 걸 확인할 수 있었다. 이 이슈도 내가 파악하고 해결해주었다..
그렇게 함에도 그래프가 버벅거리는 이슈가 안 고쳐졌다. 혹시나 벡엔드 쪽 이슈 인가봐, nio socket에서 버퍼를 읽어오는 과정을 무한루프로 대체했다. 기존에는 버퍼를 쏘고 읽고 socket을 끊어버리고, 다시 연결하는 과정으로 만들었는데, 그런 부분들을 다 대체했다.
무한루프로 대체한 후 라즈베리파이에 심은 G.W가 C.S에서 쏘아준 Data를 버퍼로 다 못 읽어오는 현상이 발생했다. 희안하게도 pc에서 test 하면 잘 되는데, 라즈베리로 옮겨놓으면 버퍼 용량이 제한되는 현상이 발생했다. 에러 원인을 찾는데 꽤 긴 시간이 흐른 뒤, 버퍼 사이즈 자체를 줄이고, 무한루프로 읽어오는 것으로 대체함으로서 이슈를 해결했다.
이렇게까지 고쳐놓았음에도 불구하고 그래프가 버벅거리는 이슈가 해결되지 않았다. 테스트 결과, 프론트쪽에서는 데이터는 잘 받아옴을 확인했고 그래프를 그리는 쪽에서, 브라우저가 CPU 사양이 과도하게 잡아먹는 것을 확인할 수 있었다. 결국 graph 라이브러리를 교체하기로 결정했다.
위의 교체는 프론트개발자에게 맡겼는데 도통 진도가 나가지 않더라.. 결국 내가 canvas 태그를 활용한 chart.js와 chartjs-plugin-streaming 로 그래프를 교체햇다. 이 라이브러리는 x축에 realtime 옵션을 적용시켜서 지나간 데이터들은 자동으로 삭제되도록 만들어져있어 최적화에 용이하다고 판단했다. 이 과정에서 GPU를 활용한 WebGL 라이브러리로 교체를 시도해봤지만, 기존 chart 라이브러리와 달리 적용방식이 완전히 달라서 포기했다.. 교체 과정에서 Dom 에 직접 접근해야만 되는데 useRef 활용법을 몰라서 고생 좀 했다.
그래프를 교체하고 나니, 그래프 그려지는 것이 한 결 나아졌다. 하지만 스케줄러를 통해서, GW 메서드를 돌려서 실제 환경과 비슷하게 테스트를 해보니, Data 자체가 받아오는 쪽이 조금씩 delay 가 누적되는 현상을 발견했다.. 원래 delay는 발생하는 것이 당연한데, 누적이 된다는 것이 이상했다. SSE만 따로 단위테스트를 했을 시, 누적현상은 발생하지 않았다. 시간을 소모한 끝에 DB 쿼리의 문제라는 것을 확인했다.
문제는 관계형 DB 쿼리가 select를 제외한 경우 병렬처리가 안 된다는 이슈였다. 기존의 측정데이터 insert 로직은 대충 다음과 같다.
QT에서 MS100 코드 전송시 => Gateway는 전송받은 JSON 데이타를 HL7 Protocol로 파싱 후 CentralStation에 전송 => CentralStation은 받은 측정데이터를 사내DB에 저장 => 병원DB에도 저장 => 측정데이터에서 보낸 pid를 기준으로 사내DB에서 환자정보를 가져옴 => 측정데이터와 환자정보를 JSON 문자열로 조합해 Front 서버에 SSE 프로토콜을 이용해(기기ID별로 발행주소) 전송 => 성공적으로 완료되면 HL7 Protocol data를 생성해 GateWay에 응답 => GateWay는 응답받았으면, 성공했다는 JSON 메시지를 QT에 응답
즉 0.1초마다 qt에서 측정데이터를 쏘면, 그때마다 c.s 가 그 측정데이터를 파싱해서, insert 7번*2 update, select를 하는데, 트랜잭션이 과도하게 발생한다는 점이다. 쿼리를 실행하는 메서드 자체는 비동기적으로 실행하여도 안에 IO 작업자체는 DB 내에 트랜잭션 단위로 lock이 걸려있어서 점점 더 느려지는 것이었다. 이 부분은 쿼리를 실행하는 메서드 자체를 비동기로 따로 빼놓은 후에, 전역 thread safe 한 hashmap을 하나 만들어서 측정데이터를 계속 add 해준다음 1분단위로 Bulk Insert 후 hashmap을 비워주는 형식으로 트랜잭션 수를 최소화했다.. 그리고 select 하는 부분의 쿼리메서드는 Caching을 활용해서 반복작업을 최소화하였다.
이제 드디어 그래프가 부드럽게 돌아가기 시작했다. 이 부분에서 또 한 가지 에로사항은 현재 개발환경에서 QT를 돌려볼 수 가 없어서, 내가 GW 측에서 가짜 데이터를 쏘아내는 스케줄러 함수들을 16개를 만들었는데, GW 하나가 16개를 돌렸을 시, GW의 threadpool이 감당하지 못하는 숫자의 thread가 쌓여서 예외가 터지는 상황이 발생했다. 이 이슈는 그쪽에서 QT 애뮬레이터를 exe 파일로 만들어줬고, i9 급 pc 2대에서 각각 8개, 6개, 나머지 pc에서2 대를 돌린다음 6개 돌린 pc 브라우저에서 그래프를 확인하니 부드럽게 돌아가는 것을 확인했다.
추가로 next.js frontServer를 배포할 때, 혹시 몰라 multicore 를 사용할 수 있도록, pm2 cluster mode로 배포하도록 설정했다. 이 부분도 내가 담당해서 진행했다.
추가적인 이슈는 그쪽 QT에서 보이는 그래프 파형과 프론트서버에서 브라우저로 그리는 그래프 파형이 일치하도록 내가 x축 값을 그쪽 QT에서 보내는 startTime과 endTime을 파싱해서 실기간으로 집어넣었는데.. 장비마다 시각 설정이 다르다고 한다더라.. 특정 기기에서는 인터넷 연결이 안 돼서 RTC가 안 된다고 하드라.. 그래서 x축을 프론트단에서 계산해서 집어넣었다. (현재시각 함수 사용- 밀리세컨드 반영)
이렇게 고생고생하며 완성했는데, 갑자기 배포한 180번 서버가 죽어버리는 현상이 발생했다. 알고나니 테스트 과정에서, 로그가 쌓이는데, 그게 파일에 계속 저장되어서 server가 뻑 가버리는 현상이었다. 이 부분은 배포과정에서.. log들을 null 로 처리해주도록 설정해서 이슈를 해결했다.
QT를 만드는 쪽 서버에서 우리가 배포한 C.S 서버 도메인 주소로 소켓 연결을 못하는 이슈가 발생했다. 이 부분은 TCP 포워딩을 따로 작업을 해줘야 처리가 가능했다. 그리고, FTP protocol 접속 문제도 따로 포워딩 작업을 진행했어야 했다. 이 부분은 대표님이 담당하여 처리했다. 나는 네트워크 쪽 관련은 잘 모른다.. 소켓으로 원격서버 http 주소로 접속할려면 tcp 포워딩 해야됨..
이거 말고도 자잘한 이슈들이 많았다. 자잘한 이슈들은 2차에서 해결하고, 이쯤에서 마무리짓도록 했다. DB 쪽은 그쪽에서 인터넷 접속이 안 되는 이슈가 발생해서, DB를 접근할 수 있는 서버에 TESTDB를 따로 설치하도록 DB 설치 가이드와 query문을 작성해서 보내달라고 했다. 나는 직접 설치하기보다 Docker가 깔려있다면 Docker로 띄워놓는 것을 추천해서 그 방식으로 가이드를 보내줬다.. 흠.. 또 다른 이슈가 분명 발생하겠지만.. 일단은 이쯤에서 마무리 되는 것 같다.