본 글은 "MSA 이해와 구현"이라는 사내 강의 수강 후, 여러 MSA 관련 글을 참고하여 작성한 글입니다.
1. MSA란?
MSA는 Micro Services Architecture의 약자로 이름에서 유추할 수 있듯이 하나의 서비스를 Micro 단위로 쪼개서 관리하는 아키텍처를 의미합니다.
커머스 서비스를 예로 들면, 커머스라는 도메인 아래 하나의 서비스로 개발되고 운영되고 있었던 모놀리스(Monolith)형태가 아닌 주문, 정상, 회원, 상품 등 커머스 도메인 아래 여러 서비스로 추가 분리 및 개발하여 분리한 각 서비스를 각각의 서버에 배포하여 관리하는 MicroServices형태가 있을 수 있습니다.
위 사진은 가트너에서 제시한 표준 Micro Services Architecture 입니다. MSA로의 구현 및 전환을 시도하는 많은 기업들에서 위 사진에서 제시된 표준 아키텍처를 따르고 있다고 합니다. 해당 아키텍처에 대해서는 다음글에서 다뤄보도록 하겠습니다.
2. Why? MSA
한눈에 보기에도 복잡한 이 MSA를 저희가 왜 써야할까요? MSA를 쓰는 궁극적인 이유는 Loose Coupling, 느슨한 결합 특성때문이라고 볼 수 있습니다. 즉, 하나의 통합된 형태로 관리되던 서비스(ex. 커머스)를 여러 나눌 수 있는 최소한의 단위인 micro 단위의 서비스(ex. 주문, 정산, 회원 등)로 나눔으로써 각 서비스 간의 영향을 최소화할 수 있고(낮은 의존성), 이로 인해 모놀리스의 High Cohesion, 높은 응집력 특성으로 야기되는 다양한 문제(ex. 기능 단위 장애의 전체 확산 등)를 보완할 수 있게됩니다.
MSA의 이러한 Loose Coupling 특성은 다양한 기대효과를 낳을 수 있습니다.
장점
장애의 확산 차단
특정 Micro 서비스에서 장애가 발생하면, 전체 서비스로 확산되지 않습니다.
기존 모놀리스 구조에서는 특정 기능의 장애가 발생하면 전체 서비스가 마비되는 일이 흔했는데, MSA에서는 Micro단위로 서비스를 세분화하여 각 기능을 독립시켰기 때문에 하나의 Micro 서비스가 타 서비스에 영향을 주지 않아 장애의 전체 확산을 막을 수 있습니다.
각 서비스의 개별 배포
각 Micro 서비스별로 개별 배포가 가능합니다.
기존 모놀리스 구조에서는 특정 기능에 대한 변경사항이 적용되려면, 서비스가 하나의 서버로 관리되었기 때문에 전체 서비스 배포가 불가피하였습니다. MSA에서는 이러한 서비스를 여러 Micro 서비스로 나누어 별도의 서버로 각각 배포하는 구조이기 때문에 각 Micro 서비스별로 개별 배포가 가능합니다.
신속한 기능 수정 및 요구사항 반영
긴급하게 특정 기능에 대한 요구사항이 발생했을 때, 신속하게 반영할 수 있습니다.
위에서 언급했던 것처럼 MSA는 Micro 서비스 단위로 개별 서버에 배포되기 때문에, 서버의 크기가 비교적 작습니다. 이로 인해 배포 시간이 줄어들고 기능 수정이 신속하게 진행될 수 있습니다.
용이한 유지보수
용이한 유지보수가 가능합니다.
특정 서비스에 대한 장애나 이상징후 발생 시, 해당하는 서비스만 점검하면되기 때문에 대처할 영역이 좁아지므로 유지보수가 용이합니다.
Loose Coupling과는 별개로 다양한 기술 수용 등과 같은 장점이 존재합니다.
하지만 MSA의 Loose Coupling, 느슨한 결합 특성으로 인한 단점도 존재합니다.
단점
높은 복잡성
MSA를 활용하면 복잡성이 커집니다.
기존 모놀리스 기반 시스템은 하나의 서비스를 하나의 UI, 하나의 서버, 하나의 DB로 개발하고 관리해왔기 때문에, 타 모듈간 연동도 간단한 함수 호출로 이루어졌고, 데이터 동기화에 관한 부분도 단일 트랜잭션 처리로 간단하게 관리될 수 있었습니다.
하지만 MSA로 구현이 되는 서비스는 Micro 단위의 서비스마다 별개의 서버와 DB로 관리되고 있고, 이로 인해 타 모듈간 연동에 Rest나 gRPC 등과 같은 기술이 활용되어야 하고 데이터 동기화에 있어서도 분산 트랜잭션 환경의 메세지큐 기술이 도입되어 활용되어야하기 때문에, 개발 및 운영에 대한 복잡성이 커지게 되었습니다.
통신 비용 증가
MSA를 활용하면 통신비용 또한 증가됩니다.
여기서 말하는 통신비용은 latency에 따른 통신효율을 의미합니다. 위에서 언급한 것처럼 모놀리스 방식에서는 단순 메소드/함수 호출로 끝나는 연동이 MSA에서는 Rest나 gRPC와 같은 통신 기술이 활용되어야 합니다. 이로 인해 서비스 성능상의 비효율이 발생할 수 있습니다.
데이터 동기화의 어려움
MSA 기반 환경에서는 데이터 동기화를 위한 다양한 기술이 도입되어야 합니다.
서비스의 데이터가 하나의 DB로 관리되는 것이 아닌 Micro 단위의 서비스로 분리되어 각각 별도의 DB로 관리되는 형태이기 때문에, 데이터 일관성 또한 MSA환경에서 주요하게 다뤄야할 이슈라고 볼 수 있습니다. 이를 위한 기술도입 사례로 kafka 메세지큐 기술 도입 사례가 있는데, 특정 Micro 서비스에서 데이터 변경이 발생하여 kafka 메세지 큐에 이벤트를 발행하면, kafka의 해당 이벤트를 구독하고 있는 Micro 서비스들이 이에 맞게 데이터 변경을 진행하는 방식이 있을 수 있습니다.
분산 트랜잭션 처리 필요
먼저 분산 트랜잭션 환경이란? 2개 이상의 네트워크 시스템간 트랜잭션 환경이라고 보면 됩니다. 모놀리스 환경에서 데이터베이스의 특성 ACID(원자성, 일관성, 고립성, 지속성)를 보장하기 위해 단일 서버/DB내 트랜잭션 처리를 하는 것처럼 MSA 환경의 여러 서버/DB 간에 ACID를 보장하기 위해 단일 트랜잭션이 처리되는 것처럼 여러 기술과 패턴이 도입되어 트랜잭션이 처리되어야 합니다.
이를 위한 대표적인 패턴으로 Saga패턴이 있습니다.
Monolith vs MSA
위와 같은 단점들이 있기 때문에 무조건적으로 모놀리스 방식이 아닌 MSA가 활용되어야 한다고 말할 수 없습니다. MSA를 활용해야 하는지에 대해서는 구현해야 하는 도메인이나 비즈니스 환경, 사용기술, 해당 서비스를 관리해야하는 조직 등 특성에 따라 선택되어야 합니다.
모놀리스
단일 코드베이스
쉬운 디버깅
쉬운 배포
상대적으로 좋은 성능
시스템 업데이트 시, 전체 시스템 배포 필요
코드 재사용의 어려움 (다른 시스템 모듈에서의 재사용)
변경 필요사항에 대한 유연한 대처 어려움
다양한 기술 사용에 제약
스케일링이 어려움
MSA
업데이트가 필요한 모듈만 배포 가능
코드 재사용이 쉬움 (다른 시스템에서의 코드 재사용)
변경 필요사항에 대한 유연한 대처 가능
특정 서비스의 장애로부터 독립 가능
다양한 기술 적용 가능
스케일링이 쉬움
개발난이도 증가
상대적으로 복잡한 배포
네트워크 지연으로 인해 상대적으로 떨어지는 성능
데이터 부정합에 취약
3. Cloud Native
MSA와 함께 많이 언급되는 개념인 Cloud Native에 대해 간략히 짚고 넘어가겠습니다.
Cloud Native란? 클라우드 환경에 맞게 서비스를 구축하여 클라우드의 이점을 최대한 활용한 환경이라고 보면 됩니다. CNCF(Cloud Native Computing Foundation)에서 정의한 내용에서는 Cloud Native를 클라우드 네이티브 기술은 조직이 퍼블릭, 프라이빗, 그리고 하이브리드 클라우드와 같은 현대적이고 동적인 환경에서 확장 가능한 애플리케이션을 개발하고 실행할 수 있게 해준다. 컨테이너, 서비스 메쉬, 마이크로서비스, 불변(Immutable) 인프라, 그리고 선언형(Declarative) API가 이러한 접근 방식의 예시들이다. 이 기술은 회복성, 관리 편의성, 가시성을 갖춘 느슨하게 결합된 시스템을 가능하게 한다. 견고한 자동화 기능을 함께 사용하면 엔지니어는 영향이 큰 변경을 최소한의 노력으로 자주, 예측 가능하게 수행할 수 있다. 라고 정의하고 있습니다. 쉽게 말해서 Cloud 환경의 컨테이너, 서비스메쉬 등과 같은 기술들을 활용하면 관리편의성, 느슨하게 결합된 시스템 등과 같은 효과를 볼 수 있는 것이라고 보면 됩니다.
Cloud Native는 MSA로 구현되는 서비스에 적용되어야 하는 환경이라서 해당 개념을 더 자세하게 알고가면 좋을 것 같습니다.
아래 나열한 기술들은 Cloud Native의 4가지 핵심기술입니다.
CI/CD : Continuous Integration and Continuous Delivery(또는 Deployment), 각자가 작업한 코드를 하나의 결과물로 통합하여 테스트 빌드 지속적 수행(CI), 빌드된 결과물을 최종 배포하여 최종 사용자가 이용가능하도록 지속적 수행(CD), 이러한 과정을 지속적 자동 수행
DevOps : Development and Operation, 소프트웨어의 개발과 운영의 스피드와 품질을 향상하기 위한 새로운 조직 문화이자 접근방식. 개발계획 수립부터 비즈니스 타당성 검토, 개발, Build, Release, 서버 쪽에 Deploy , Operation, Monitoring까지 일련의 과정을 뫼비우스 띠처럼 반복
MicroServices : 하나의 서비스를 Micro 단위로 쪼개서 관리하는 아키텍처
Containers : 애플리케이션 구동을 위해 필요한 라이브러리나 구성파일들을 이미지라는 패키지 형태로 묶어서 구동시키는 환경. 해당 기술을 활용하면 서비스별 독립적인 구동이 가능하고, 효율적인 배포 및 Autoscale 가능
각 기술에 대한 설명을 간단하게 커멘트 남겼지만, 각 기술 하나하나 다룰내용이 많은 기술들입니다. 해당 기술들은 앞으로 작성될 글에서 더 자세하게 다뤄보겠습니다.
이어서
본 글에서 다양한 키워드가 소개되었습니다. MSA, 모놀리스, Rest, gRPC, kafka, Saga, Cloud Native... 해당 키워드는 이어지는 글에서 MSA에 대해 상세히 다뤄보며 더 알아보겠습니다.
동작 파라미터화는 말그대로 특정 동작을 파라미터로 입력할 수 있는 기능인데, 이 기능을 활용하면 특정 동작을 정의한 코드를 다른 메서드의 인수로써 넘겨줄 수 있습니다.
why? 왜 사용해야 하는가?
이전글에서 해당 동작 파라미터화 기능이 Java8의 핵심기능인 스트림 API의 토대가 되었다고 언급했었습니다. 스트림 API를 활용하기 위해 사용한다고도 말할 수 있지만, 좀 더 근본적인 사용이유는 "자주 바뀌는 요구사항에 효과적으로 대응"할 수 있기 때문입니다.
자세한건 예시를 보면서 알아보겠습니다.
예시
요구사항 1. 녹색사과 필터링
농장 재고목록 Application에서 활용되는 사과 리스트중 녹색사과만 필터링하는 기능을 추가한다는 요구사항이 있다고 가정해보겠습니다. 해당 요구사항의 기능은 아래 코드로 구현할 수 있습니다.
public static List<Apple> filterGreenApples(List<Apple> inventory) {
List<Apple> result = new ArrayList<>()
for (Apple apple : inventory) {
if ( GREEN.equals(apple.getColor()) ) {
result.add(apple);
}
}
return result;
}
요구사항 2. 녹색/빨간사과 필터링
농부가 변심하여 빨간 사과도 필터링하고 싶다고 요구사항을 변경하였습니다. 위 코드를 Copy/Paste하여 if문의 조건만 RED로 바꿔 메서드를 추가할 수 있겠지만, 추후에 다양한 색으로 요구사항이 또 들어올 경우에 유연한 대처가 불가능해질 수 있습니다. 또한, 동일한 기능의 코드가 반복될 수 있죠.
이를 보완하기 위해 아래 코드처럼 색이라는 변수를 받을 수 있도록 메서드에 색 파라미터를 추가해줍니다.
public static List<Apple> filterAppleByColor(List<Apple> inventory, Color color) {
List<Apple> result = new ArrayList<>()
for (Apple apple : inventory) {
if ( apple.getColor().equals(color) ) {
result.add(apple);
}
}
return result;
}
농부가 또 변심하여 색 이외에도 가벼운 사과와 무거운 사과로 구분할 수 있도록 해달라고 할 수 있습니다.
public static List<Apple> filterAppleByWeight(List<Apple> inventory, int weight) {
List<Apple> result = new ArrayList<>()
for (Apple apple : inventory) {
if ( apple.getWeight() > weight ) {
result.add(apple);
}
}
return result;
}
위 코드로도 무게 기준으로 필터링할 수 있도록 기능을 구현할 수 있지만, 해당 무게 필터링 코드는 색 필터링 코드와 대부분 중복됩니다. 이는 소프트웨어 공학의 DRY (Don't Repeat Yourself), 같은 것을 반복하지 말자는 원칙을 어기는 것입니다.
public static List<Apple> filterApple(List<Apple> inventory, Color color, int weight, boolean flag) {
List<Apple> result = new ArrayList<>()
for (Apple apple : inventory) {
if ((flag && apple.getColor().equals(color))
|| (!flag && apple.getWeight() > weight)) {
result.add(apple);
}
}
return result;
}
위 코드로 flag에 따라 필터링 대상을 결정하고, 필터링 대상에 맞게 필터링을 수행할 수 있지만, 요구사항 추가에 유연하게 대응할 수 없고, flag를 다양화해서 어찌어찌 대응한다해도 코드의 복잡성이 굉장히 크게 증가할 것입니다.
이를 대체하는 방안으로 동작 파라미터화기능이 사용될 수 있습니다.
동작 파라미터화 기능의 활용
예시 보완
위 예시에서 동작 파라미터화 기능을 사용하기 위해 아래와 같은 사전준비 작업이 필요합니다.
필터링 메서드의 참/거짓을 반환하는 인터페이스(프레디케이트) 정의
참/거짓을 반환하는 인터페이스(프레디케이트)의 구현체 정의 (ex. 150이상 무게 사과 판단, 초록사과 판단 등)
필터링 메서드의 동작 파라미터화
아래는 프레디케이트 정의 코드입니다.
public interface ApplePredicate {
boolean test (Apple apple);
}
아래는 프레디케이트의 구현체 코드입니다.
// 150이상 무게 사과 판단
public class AppleHeavyWeightPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return apple.getWeight() > 150;
}
}
// 초록사과 판단
public class AppleGreenColorPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return GREEN.equals(apple.getColor());
}
}
위 처럼 프레디케이트 인터페이스를 정의하고 구현체를 작성하는 패턴을 전략 디자인 패턴이라고 합니다. 전략 디자인 패턴은 전략(알고리즘)을 캡슐화하는 알고리즘 패밀리(프레디케이트 인터페이스)를 정의해둔 다음에 런타임에 상황에 따라 알고리즘을 선택하는 기법입니다. (ApplePredicate : 알고리즘 패밀리, AppleHeavyWeightPredicate/AppleGreenColorPredicate : 전략)
해당 패턴을 활용한다면 filter 메서드에서 파라미터로 받은 동작에 맞게 필터링을 수행할 수 있습니다. 아래 코드는 전략 디자인 패턴 기반의 동작을 파라미터로 받을 수 있도록 수정한 filter 메서드입니다.
public static List<Apple> filterApple(List<Apple> inventory, ApplePredicate p) {
List<Apple> result = new ArrayList<>()
for (Apple apple : inventory) {
if ( p.test(apple) ) {
result.add(apple);
}
}
return result;
}
위 코드처럼 프레디케이트(인터페이스)를 파라미터로 지정한 다음에 다양한 구현체를 파라미터로 받아 다양하게 필터링을 수행할 수 있도록 하였습니다. 즉, 우리가 전달한 ApplePredicate 객체에 의해 filterApples 메서드의 동작이 결정됩니다.
익명클래스
위와 같은 방법도 충분히 좋지만, 동작을 파라미터화하기 위해 인터페이스를 정의하고 여러 클래스를 정의하는 과정이 상당히 번거롭게 느껴집니다. 이를 간소화하기 위해 익명클래스를 활용하는 방법이 있습니다.
익명클래스를 이용하면 클래스 선언과 인스턴스화를 동시에 할 수 있어 별도로 여러 클래스를 정의하는 과정이 필요하지 않습니다. 아래 코드는 익명클래스를활용하기 이전 filter메소드를 실행하는 코드입니다.
List<Apple> heavyApples = filterApples(inventory, new AppleHeavyWeightPredicate());
List<Apple> greenApples = filterApples(inventory, new AppleGreenColorPredicate());
위 코드에서는 사전에 정의된 클래스를 파라미터로 입력하여 실행하고 있습니다. 아래 코드는 익명클래스를 활용하여 filte메소드를 실행하는 코드입니다.
List<Apple> redApples = filterApples(inventory, new ApplePredicate() {
public boolean test(Apple a) {
return RED.equals(a.getColor());
}
});
익명클래스를 활용하여 별도 클래스를 선언하고 작성하는 과정을 간소화하였지만, 여전히 코드는 불필요하게 공간을 많이 차지하고 있고, 프로그래머가 여전히 익숙하게 느껴질 수 있는 형태의 코드가 아닙니다.
이러한 코드의 장황함은 코드를 구현하고 유지보수하는 데 많은 시간을 소요하게끔 할 수 있습니다.
람다 표현식
위의 익명클래스의 단점을 보완하기 위해 람다 표현식이 사용될 수 있습니다.
List<Apple> result = filterApples(inventory, (Apple apple) -> RED.equals(apple.getColor()));
위 코드를 통해 익명클래스에서의 복잡성을 많이 줄일 수 있습니다. 람다표현식의 자세한 부분은 👉🏻다음글에서 다룰 것입니다.
리스트 형식의 추상화
아래 코드처럼 프레디케이트에 제너릭을 활용한다면, 사과 뿐만 아니라 바나나, 오렌지 등으로 대상까지 다양화할 수 있습니다.
public interface Predicate<T> {
boolean test(T t);
}
public static <T> List<T> filter(List<T> list, Predicate<T> p) {
List<T> result = new ArrayList<>();
for (T e : list) {
if (p.test(e)) {
result.add(e);
}
}
return result;
}
실전 예제
실전에서 사용하고 있는 예제로 Comparator, Runnable 등이 있고, 간단히 다뤄보겠습니다.
Comparator로 정렬하기
Java8에서 Collection에는 sort 메서드가 포함되어 있습니다. 해당 sort메서드의 정렬방식을 Comparator를 활용하여 정의할 수 있는데요.
아래 코드처럼 Comparator를 활용하여 정렬 기준을 정의한 후에 sort 메서드에 파라미터로 입력하면 됩니다.
// java.util.Comparator
public interface Comparator<T> {
int compare(T o1, T o2);
}
익명클래스
inventory.sort(new Comparator<Apple>() {
public int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
});
람다표현식
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
Runnable로 코드 블록 실행하기
자바 스레드를 이용하면 코드를 병렬로 실행할 수 있습니다. Runnable은 스레드에 실행할 동작을 전달할 때, 사용되는 인터페이스입니다.
// java.lang.Runnable
public interface Runnable {
void run();
}
익명 클래스
Thread t = new Thread(new Runnable() {
public void run() {
System.out.println("Hello World!");
}
});
람다 표현식
Thread t = new Thread(() -> System.out.println("Hello World!"));
Java 8이 등장하기 이전의 Java프로그램은 대부분 코어 중 하나만을 사용하였고, 나머지 코어는 유휴 idle 상태로 두었습니다. 유휴 idle 상태인 나머지 코어를 활용하기 위해서는 멀티 스레드 기법을 활용하면 되었지만, 관리하기 어렵고 많은 문제가 발생할 우려가 크다는 단점이 있었습니다.
이를 보완하기 위해 Java 8에서는 아래와 같이 새로운 기능이 추가되었습니다.
동작 파라미터화
메서드 참조
람다
스트림 API
기타 기능들 (디폴트 메서드, Optional 등)
동작 파라미터화기능을 추가하여 특정 메서드의 파라미터로 메서드도 입력할 수 있도록 하였습니다. 간단한 메서드의 경우에는 별도의 메서드 구현 없이도 입력 값에 바로 메서드 코드를 입력할 수 있게끔 람다 기능도 추가되었습니다. 그리고 이러한 동작 파라미터화 기능이 Java 8의 핵심 기능인 스트림 API의 토대가 되었습니다.
결론적으로 Java 8에서 위 기능들이 추가되어 보다 안정적인 멀티코어 기반의 Java 프로그램을 구현하기 위한 환경이 조성되었다고 볼 수 있습니다. 이제 아래 메뉴에서 새롭게 추가된 기능으로 어떤 것들이 있는지? 왜 해당 기능들을 추가하면 보다 안정적인 프로그램 구현이 가능한지? 알아보겠습니다.
새로운 기능
동작 파라미터화
동작 파라미터화는 말그대로 특정 동작을 파라미터로 입력할 수 있는 기능을 지칭합니다. 즉, 메서드(코드)를 다른 메서드의 인수로 넘겨줄 수 있는 기능이라고 보면 됩니다.
예시
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
public boolean accept(File file) {
return file.isHidden();
}
});
메서드 참조
메서드 참조는 특정 메소드의 파라미터로 입력될 메소드를 지칭하기 위한 기능입니다.
위에서 언급한 동작 파라미터화 기능의 예시 코드를 보면, 가독성 측면에서 꽤나 복잡하다고 느껴집니다. 단번에 해석하기도 쉽지 않고 isHidden이라는 메서드를 활용하기 위해서 굳이 별도 메서드를 추가적으로 만들어서 파라미터로 입력하고 있습니다.
이러한 비효율을 없애기 위해 Java 8에서는 메서드 참조기능을 제공하고 있습니다.
As-Is
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
public boolean accept(File file) {
return file.isHidden();
}
});
To-Be
File[] hiddenFiles = new File(".").listFiles(File::isHidden);
위의 To-Be 코드처럼 별도 메소드 구현 없이 필요한 메서드를 참조하여 파라미터로 입력할 수 있습니다. 클래스명에 ::기호를 붙이고, 그 뒤에 참조할 메서드명을 붙여서 메서드 참조를 활용할 수 있습니다.
람다
람다기능은 메서드를 다른 메서드의 파라미터로 입력할 때, 메서드를 별도로 구현하지 않고 바로 코드를 파라미터로 입력하도록 할 수 있는 기능입니다. 먼저 프레디케이트에 대해서 언급하고 넘어가겠습니다.
프레디케이트
프레디케이트는 인수로 특정 값을 받아서 true/false로 반환하는 함수를 가리키는 말입니다.
Function<Apple, Boolean> == Predicate<Apple>형태로 볼 수 있고, 입력할 Object type을 제네릭 형태로 입력하게 됩니다.
Predicate를 활용하는 방식이 좀더 표준적인 방식이고, 구현은 아래 코드와 같이 진행할 수 있습니다.
public interface Predicate<T> {
boolean test(T t);
}
static List<Apple> filterApples(Predicate<Apple> p) {
List<Apple> result = new ArrayList<>();
Apple input = new Apple();
if (p.test(input)) {
result.add(apple);
}
return result;
}
위와 같이 특정 Object를 입력으로 받아 특정 기준에 부합하는지에 대한 판단을 Predicate활용을 통해 진행할 수 있습니다. 그리고 아래와 같이 Predicate 파라미터의 메서드를 호출할 수 있습니다.
람다기능은 앞서 언급한 것처럼 메서드를 다른 메서드의 파라미터로 입력할 때, 메서드를 별도로 구현하지 않고 바로 코드를 파라미터로 입력하도록 할 수 있는 기능입니다.
위 프레디케이트 메서드 호출을 위해 사용되는 isHeavyApple메서드는 매우 간단한 코드입니다. 람다 기능을 활용한다면, 위 프레디케이트 메서드 호출 코드를 아래 코드와 같이 별도 메서드 구현없이 간단하게 표현할 수 있습니다.
filterApples( (Apple a) -> a.getWeight() > 150 );
()안에 람다 함수의 인수로 입력할 변수를 선언해주고, ->로 연결된 선언부에서 구현할 코드를 작성해주면 됩니다.
Stream API
Stream API는 Java 8이 등장하면서 동작 파라미터화기능을 토대로 출시된 API입니다. Stream API를 활용한다면, Java 8이전에 길고 복잡한 코드로 처리했던 로직을 데이터베이스 질의 언어에서 표현식을 처리하는 것처럼 간결하게 코드를 작성하여 처리할 수 있습니다.
Java 8 이전 예시코드
Map<Currency, List<Transaction>> transactionByCurrencies = new HashMap<>();
for (Transaction transaction : transactions) {
if (transaction.getPrice() > 1000) {
Currency currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionByCurrencies.get(currency);
if (transactionsForCurrency == null) {
transactionsForCurrency = new ArrayList<>();
transactionByCurrencies.put(currency, transactionsForCurrency);
}
transactionsForCurrency.add(transaction);
}
}
위 코드에서 볼 수 있는 것처럼 Stream API를 활용하면 코드의 가독성 측면에서 이점이 있습니다. 뿐만 아니라 Stream API는 아래 문제 해결에 주안점을 두었습니다.
모호함과 반복적인 코드 문제
멀티코어 활용 어려움
이제 두 가지 문제의 관점에서 Stream API를 알아보겠습니다.
모호함과 반복적인 코드 문제 해결
기존의 컬렉션에서는 데이터를 처리할 때 아래와 같이 반복되는 패턴이 많았습니다.
주어진 조건으로 데이터를 필터링 (ex. 특정 무게를 기준으로 사과 선별)
데이터 추출 (ex. 특정 사과의 무게필드 추출)
데이터 그룹화 (ex. 숫자 리스트를 홀수와 짝수로 그룹화)
등등...
Stream API를 활용한다면, 위 반복되는 패턴을 제공되는 Stream 라이브러리를 활용하여 구현할 수 있습니다. 가령 데이터 필터링 패턴은 Stream의 Filter 라이브러리를 활용하여 구현할 수 있죠.
위처럼 패턴을 라이브러리화 시킬 수 있었던 배경은 Stream API에서는 내부반복을 활용하는 것이었습니다. 외부반복은 기존에 사용하던 방식처럼 For-loop를 활용하는 방식이고, 내부반복은 Stream 라이브러리 자체에서 진행되는 반복을 의미한다고 볼 수 있습니다. 그렇기 때문에 내부반복이 진행될 때에는 어떻게 돌아가는지 개발자가 직접 확인할 수 없죠.
멀티코어 활용 어려움 해결
Java 8 등장 이전에는 멀티스레딩 구현이 쉽지 않았습니다. 각각의 스레드에서 공유된 데이터에 접근하게 되면 동시성 문제가 발생할 수도 있는데, 각 스레드들을 잘 제어하지 못하면 데이터가 원치않은 방식으로 바뀔 수도 있습니다.
Stream API의 핵심적인 목표는 이러한 멀티스레딩 기능을 쉽게 구현하는 것입니다. 아래 Parallel 코드를 활용하면 쉽게 병렬처리를 구현할 수 있습니다.
Java 8 이전에는 멀티스레딩 기능을 활용하기 위한 데이터를 나누는 등의 작업을 모두 개발자가 진행해줘야 했지만, Stream API의 parallelStream을 활용하면 Stream API 내부적으로 Java 7에서 추가된 포크-조인 프레임워크를 활용하여 멀티스레딩을 위한 작업이 진행됩니다. 아래 사진은 포크-조인 프레임워크에서 멀티스레딩 작업이 진행되는 과정을 도식화한 그림입니다.
그래서 Java 8에서는 데이터베이스 질의 언어에서 표현식을 처리하는 것처럼( 고수준 언어로 원하는 동작을 구현했을 때, 최적의 저수준 실행방법을 선택하는 방식 ) 병렬 연산을 지원하는 스트림이라는 새로운 API를 제공합니다. 이러한 스트림을 이용하면 에러를 자주 일으키고 이용 비용이 비싼 Synchronized를 사용하지 않아도 됩니다.
동시성 문제
멀티스레딩 기능을 활용한다면, 당연히 동시성 문제도 피해갈 수 없습니다. 이를 해결하기 위한 기존에 활용하던 첫번째 방법은 Synchronized를 활용하는 것입니다.
아래 코드와 같이 Synchronized Collection을 활용한다면, 하나의 스레드가 해당 Collection을 점유하는 시점에 Lock을 걸어 Thread Safe하게 작동될 수 있도록 합니다.
하지만, 스레드가 점유를 풀 때 Lock-Unlock 작업이 반복되기 때문에 성능이 떨어집니다. (멀티코어 CPU의 각 코어는 별도의 캐시를 포함하고 있는데, 락을 사용하면 캐시의 동기화를 위해 속도가 느린 캐시 일관성 프로토콜 인터코어 통신이 이루어집니다.) 그래서 이를 보완하기 위한 방법으로 Stream 라이브러리의 Collect 메서드를 호출하는 것입니다.
👉이전글에서 예선에 대한 내용을 확인할 수 있다. 9월13일 본선OT 이후로 KT AI해커톤 본선이 시작됐다.
본선
본선 과제는 KT DX플랫폼(RPADU, APPDU)과 GPT, Bard와 같은 외부망의 생성형AI 모델에 proxy로 접근할 수 있게 해주는 gen.AI플랫폼을 이용하여 업무에 활용할 수 있는 서비스를 개발하는 것이였다. 즉, RPADU를 활용하여 데이터를 수집하고, APPDU를 활용하여 backend서버를 개발하고 View를 제공해야하는데, 데이터 분석이나 학습에 활용할 AI모델은 gen.AI플랫폼을 활용하라는 것이다.
그리고 본선이 진행되는 10/19(목) 이전까지 사전 개발기간이 주어진다. 본선이 진행되는 10/19~20 1박2일 동안은 실무평가, 임원평가 총 두 번의 평가가 진행되며, 해당 평가에서 좋은 성적을 거두어야 수상을 할 수 있게 된다.
사전학습
나는 과제발굴에 앞서 LLM이 뭔지 파악할 필요가 있다고 생각했다. LLM은 Large Language Model의 약자로 사용자 질의에 따른 응답을 사람처럼 해주는 모델이다. 많이들 써봤을 ChatGPT 그 자체이다. ChatGPT와 같은 LLM모델을 우리의 비즈니스에 맞게 응답해줄 수 있도록 할 수 있다. 예를들어, OSS가 뭐야?라고 ChatGPT에 질문을 했을 때, Open Source Software라는 대답을 준다. 여기서 ChatGPT를 우리 비즈니스(OSS시스템)에 맞게 학습을 시키면, Operating Support System이라는 답변을 줄 수 있도록 할 수 있다.
ChatGPT와 같은 LLM모델을 학습시키는 방법은 대표적으로 fine-tuning(파인튜닝)기법이 있는데, LLM모델에 추가 데이터를 학습시켜 우리 비즈니스에 특화된 모델을 만드는 것이다. 하지만 이 방법은 들어가는 비용대비 얻을 수 있는 효과는 미미하다. 그래서 대규모의 플랫폼을 만드는 것이 아니라면 추천하지 않는 방법이다.
대안으로 Embedding(임베딩)기술을 응용한 Retrieval-Augmented Generation(RAG)방식이 있다. RAG방식은 먼저 임베딩 과정을 거쳐 벡터로 만들어진 우리 비즈니스 데이터를 별도 저장소에 저장한다. 그리고 사용자가 질의를 하면 해당 질의와 유사한 비즈니스 데이터를 별도 저장소에서 가져오고, 가져온 비즈니스 데이터를 사용자 질의와 합쳐서 프롬프트 형태로 구성한 후에 LLM모델에 질의를 날린다. 이 방식이 RAG방식이다.
이 때, 활용되는 Embedding(임베딩)은 자연어를 모델이 이해할 수 있는 벡터형태로 변환하는 기술인데, RAG방식에서 사용자 질의와 유사한 데이터를 찾을 때에도 벡터 간의 유사도를 계산하기 때문에 임베딩 기술이 유용하게 쓰인다.
과제선정부터 어려웠다. 과제 선정을 위해 팀내 Confluence플랫폼을 활용하여 아이디어를 수렴했다. 팀장님께서 본선부터는 팀내 다른 사원들도 같이 간접참여할 수 있는 형태면 좋을 것 같다고 말씀하셔서, 해커톤 팀원들 뿐만 아니라 다른 사원들의 아이디어도 같이 수렴했다.
여러 아이디어가 많이 나왔지만... 우리가 잘할 수 있고, 정책이나 데이터 활용상 제약이 없는 OSS Assistant 아이디어로 선정했다.
OSS Assistant는 현장작업자의 VOC를 줄이고 빠른 정보검색 및 신규인력의 빠른 적응을 위한 OSS도메인 특화 챗봇이다. (역시 LLM모델엔 챗봇이 가장 만만하다..)
대표적인 두 가지 기능으로 여러데이터를 기반으로 응답을 주는 RAG기반 챗봇기능과 사용자 질의에 따라 OSS의 오더라는 것을 작업자가 직접 처리할 수 있게 하는 기능을 구현하기로 했다. 그리고 나는 RAG기반 챗봇기능 구현을 담당하였다.
구현과정
초기설계
내가 담당한 RAG기반 챗봇기능을 구현하기 위해 설계를 진행했다. 초기설계는 챗봇의 서버로 활용될 APPDU서버가 다음 작업을 모두 수행하는 것으로 설계했다.
KMS(사내 Jira Confluence플랫폼) 데이터 로딩 : KMS라는 사내 Jira Confluence플랫폼에는 업무에 필요한 SOP나 가이드 자료 등을 업로드하여 공유할 수 있는 플랫폼이다. 해당 플랫폼의 데이터를 챗봇에 활용하기 위해 KMS서버에 데이터를 요청해서 받아야한다.
RPADU(사내 RPA tool)의 파싱데이터 수신 : AI해커톤 본선의 평가요소 중 RPADU라는 사내 RPA tool 활용 항목이 있다. 이를 충족시키기 위해 우리 시스템의 고객들에게 매월 공유하고 있는 ppt형태의 사용자매뉴얼 데이터를 RPADU를 활용하여 파싱할 수 있도록 RPADU개발을 진행했다. 해당 툴은 코딩이 아닌 GUI로 개발할 수 있도록 만들어진 프로그램이고, C#기반의 프로그램이다.
데이터전처리 및 임베딩 수행 : 챗봇에 활용될 데이터의 품질을 높이기 위해서는 적절한 전처리 작업이 필요하다. 그리고 전처리가 완료된 데이터들을 임베딩하여 LLM모델이 이해할 수 있는 벡터형태로 변환해야 한다.
Vector DB 임베딩 벡터 저장 : Vector Database는 임베딩된 벡터를 저장하고 조회하는데 특화된 데이터베이스이다. 벡터에 메타데이터 형태로 관련 정보를 매핑하여 저장할 수 있고, 사용자 질의와 유사한 정보를 보다 쉽게 조회할 수 있다. 임베딩된 벡터를 해당 Vector DB에 저장하는 작업이 필요하다.
환경세팅
개발을 위한 개발환경을 세팅하고 APPDU서버환경을 세팅하는 작업을 진행했다. APPDU서버환경은 서버간 방화벽 작업을 진행해야 했다. 그 중, KMS서버-APPDU서버간 방화벽 작업은 각 서버 담당자 문의 결과, 지원하지 않는 연동으로 방화벽해제가 어렵다는 회신을 받았다. KMS데이터를 활용하지 않는 방향까지 고려를 했지만, 해당 애로사항을 해커톤 팀내 공유하고 회의를 진행한 결과 KMS데이터를 활용할 새로운 아이디어가 고안될 수 있었다.
2차 설계
초기설계에서 KMS데이터는 내 로컬PC에서 로드하여 APPDU서버로 전달하는 방향으로 2차 설계를 확정지었다. KMS담당자로서 KMS데이터 학습을 요청받거나 필요할 때, 해당 데이터들을 검수하고 학습에 적절한 데이터라고 판단되면 사전에 개발한 KMS데이터 로딩 프로그램을 실행시켜 APPDU서버에 KMS데이터를 전달할 것이다.
개발
KMS Loader
학습이 진행될 KMS데이터는 oss2chatbot라벨이 붙은 Confluence 페이지이다. 해당 라벨은 설정하기 나름이다. 그리고 KMS데이터는 Langchain의 ConfluenceLoader라이브러리를 활용하여 가져온다.
from langchain.document_loaders import ConfluenceLoader
from flask import current_app, jsonify
import requests
class KmsService:
def __init__(self, config):
self.config = config
def load_kms_sop(self):
# confluence loader 객체 생성
loader = ConfluenceLoader(
url=current_app.config['KMS_URL'],
username=current_app.config['KMS_USERNAME'],
api_key=current_app.config['KMS_PASSWORD'],
)
# kms데이터 로드 (라벨 설정)
kms_data = loader.load(label="oss2chatbot")
kms_documents = []
# 제목 + 내용
for data in kms_data:
temp = data.metadata['title'] + " : " + data.page_content
kms_documents.append(temp)
request_data = {'data': kms_documents}
response = requests.post(APPDU_SERVER_URL, json=request_data)
return response
위 코드는 특정 라벨이 붙은 Confluence 페이지를 가져와서 각 페이지의 데이터마다 제목과 내용을 병합해주고 APPDU서버로 데이터를 전송하는 코드이다.
Manual Parsing RPA
Manual PPT 데이터를 파싱하는 RPA tool을 활용하면 위 사진에서 보이는 것처럼 코드가 아닌 GUI형태로 프로그램을 개발할 수 있다. 프로그램 소스 전체가 사진에 표현되어 있진 않지만, flow를 간략히 설명하면,
파싱할 PPT파일이 있는 디렉토리에서 파일 list를 읽는다.
각 파일 list를 for loop 모듈을 활용하여 읽는다.
각 파일에서 파싱된 데이터들을 활용하여 json 형태로 문자열을 만들어준다. (APPDU서버로 HTTP POST요청을 통해 body에 json형태로 실어보내주기 위함)
만들어진 json형태의 문자열에서 Encoding 오류나는 문자를 찾아 수정해준다. (if 모듈 하드코딩)
json형태로 만들어진 문자열을 body에 실어 APPDU서버로 HTTP POST 요청을 보낸다.
각각의 chunk로 분리된 데이터들은 openai.Embedding api를 활용하여 임베딩이 진행된다. 임베딩 작업을 통해 벡터로 변환된 데이터들은 메타 데이터로 원본 데이터와 id값, file name 등을 매핑하여 ChromaDB라는 Vector DB에 저장된다.
하지만 Vector DB로 ChromaDB를 활용하는데 어려움이 있었다. 사용중인 Python 3.8 환경에서 버전문제가 발생하였다. Sqlite3 버전이 낮아서 문제였고, 버전을 올려야 했다. 버전 올리는 것 또한 local의 window환경과 실서버의 linux환경에서의 방법이 상이했다. window 환경에서는 Sqlite3의 dll파일만 교체해주면 되었지만, linux환경에서는 pysqlite3-binary를 설치해야 했다.
그래서 사외망 환경에서 whl파일을 사내망으로 가져와서 프로젝트 root경로에 놓고, Dockerfile에 pip install 명령어를 추가하여 가져온 whl파일을 설치하도록 했다. 그랬더니 비로소 서버환경에서의 ChromaDB가 설치되었다. 그 후, pysqlite3-binary 라이브러리를 사내 라이브러리 저장소인 nexus라는 저장소에 저장하도록 요청하여 별도 Dockerfile에 명령어를 입력하지 않아도 되도록 했다.
프롬프트 엔지니어링
from flask import current_app, jsonify
from .file_manager import get_dict_for_collection, get_embedding
from views.order.order_analysis import *
import openai, copy
# 프롬프트 템플릿 생성
def make_template(documents, filename):
result_documents = ""
for doc in documents:
result_documents = result_documents + " " + doc
template = """You are the best assistant that describes many knowledges in Korean.
당신은 문의에 대한 방법을 제공하는 것이 목표입니다.
인사를 할 경우에 '안녕하세요. 무엇을 도와드릴까요?라고 답변해주세요.
그리고 답변 마지막에 '자세한 내용은 {filename}을 참고해주세요.'라는 메세지를 남겨주세요.
아래 내용을 참고하여 답변해주세요. <내용>{documents}<내용끝>
1. 위 내용 전부를 읽기 쉽도록 단계별로 재작성 해주세요.
2. 불필요한 개행문자는 제거해주세요.
3. 답변은 1000자 이내로 작성해주세요.""".format(documents=result_documents, filename=filename)
return template
# Function Call용 템플릿 생성
def make_order_template():
template = """You are the best assistant that describes many knowledges in Korean."""
return template
# 챗봇 main 함수
def basic_rag_chat_completion(messages, rag):
# function call을 위한 메세지 백업
func_messages = copy.deepcopy(messages['messages'])
# openai config
openai.api_type = current_app.config['AZURE_OPENAI_API_TYPE']
openai.api_base = current_app.config('AZURE_OPENAI_API_BASE']
openai.api_version = current_app.config('AZURE_OPENAI_API_VERSION']
openai.api_key = current_app.config('AZURE_OPENAI_API_KEY']
# 사용자 질의 임베딩 및 Chroma DB에서 유사 documents 조회
user_input = messages['messages'][-1]['content']
user_embedding = get_embedding(user_input)
chroma_response = rag.query(
query_embeddings=user_embedding,
n_results=3
)
# system message 세팅
template_message = make_template(chroma_response['documents'][0], chroma_response['metadatas'][0][0]['source'])
system_message = {"role": "system", "content": template_message}
messages['messages'].insert(0, system_message)
# 질의
response = openai.ChatCompletion.create(
engine="gpt-4",
messages=messages['messages'],
functions=get_functions(),
temperature=0.0, # 모델의 정확성, 0과 1사이로 조절하여 창의성을 제어
max_tokens=1000, # 모델이 생성할 수 있는 최대 토큰 수 (일반적으로 1개 토큰은 약 4글자)
top_p=0.95, # 무작위성과 독창성 조절
frequency_penalty=0, # 모델이 예측을 반복하는 경향을 제어. 이미 생성된 단어의 확률을 낮춤
presence_penalty=0, # 새로운 예측을 만들도록 유도. 이미 예측된 텍스트에 단어가 나타난 경우, 해당 단어의 확률을 낮춤
stop=None # 모델의 응답 정지 옵션
)
res_message = response["choices"][0]["message"]
# function call 응답일 경우,
if res_message.get("function_call"):
res_message.update({'content': None})
available_functions = {
"order_analysis": order_analysis,
"remove_ponr": remove_ponr,
"get_order_task_list": get_order_task_list,
"close_task": close_task
}
# function 실행
function_name = res_message["function_call"]["name"]
function_to_call = available_functions[function_name]
function_args = json.loads(res_message["function_call"]["arguments"])
function_response = function_to_call(
function_args
)
# system message 세팅 (order template 활용)
order_template_message = make_order_template()
order_system_message = {"role": "system", "content": order_template_message}
# 사전 백업해뒀던 function call용 메세지에 system 메세지 및 응답 세팅
func_messages.append(order_system_message)
func_messages.append(res_message)
# function 실행 결과 추가
func_messages.append(
{
"role": "function",
"name": function_name,
"content": function_response
}
)
# 함수 실행결과를 바탕으로 챗봇 응답 생성
res = openai.ChatCompletion.create(
engine="gpt-4",
messages = func_messages,
temperature=0.0,
max_tokens=1000,
top_p=0.95,
frequency_penalty=0,
presence_penalty=0,
stop=None
)
assistant_turn = res.choices[0].message
return jsonify(assistant_turn)
if (res_message['content'] == "안녕하세요. 무엇을 도와드릴까요?"):
return jsonify({
"content": "안녕하세요. 무엇을 도와드릴까요?",
"role": "assistant"
})
# 질의에 맞는 document가 없을 경우 (할루시네이션 방지)
if (chroma_response['distances'][0][0] >= 0.17):
return jsonify({
"content": "찾으시는 정보가 없어 답변드리기 어렵습니다.",
"role": "assistant"
})
return jsonify(res_message)
위는 사용자 질의에 따른 응답을 주는 코드이다. Function Call의 핵심 부분은 내가 구현한 부분이 아니므로 뺐다. 기본적인 flow는 아래와 같다.
사용자 질의가 들어오면 basic_rag_chat_completion main 함수가 실행된다.
사용자 질의를 임베딩하고 임베딩 데이터를 기반으로 ChromaDB에서 유사한 document를 가져온다.
가져온 document를 system message에 프롬프트 형태로 세팅해준다. (system message가 뭔지는 아래 참고)
openai api에 만들어진 message를 던져 질의한다.
해당 질의가 function call호출을 위한 질의라면, if문으로 들어가 맞는 function을 실행시킨다.
system message로 function call용 template를 세팅해준다.
function 결과를 message에 세팅한다.
만들어진 message로 한번더 질의하여 받은 응답을 반환한다.
function call 질의가 아니라면, if문을 skip 한다.
질의가 인사말이라면 인사말로 응답한다.
질의에 맞는 document가 없다면, 답변할 수 없는 내용이라고 답변한다.
chroma db 조회 결과 중, distances 정보가 있는데 해당 정보는 사용자 질의와 반환된 document의 연관성이 얼마나 밀접한지를 나타내는 정보이다. distance가 1에 가까울수록 연관성이 없는 document이고, 시행착오 끝에 0.17 수치를 기준으로 실제 적절한 문서를 가져오는지 판단되는 것 같아 위와 같이 코드를 작성하였다.
위 작업으로 할루시네이션을 어느정도 방지했지만, 인사말이 들어올 경우 ChromaDB에 관련 Document가 없어 distance가 높게 나오는 바람에 인사에 대한 응답으로 찾을 수 없는 정보가 나온다는 답변이 나오게 되었다. 이를 방지하기 위해 프롬프트에 인사말이 나올 경우, 특정 메세지의 인사말로 응답해달라는 문구를 추가하고 특정 메세지의 인사말이 응답으로 나왔을 경우, 인사로 답변할 수 있도록 하단에 코드를 작성했다.
그리고 rag chat 부분과 function call 부분을 합치는 과정에서 질의에 활용되는 message에 동일한 템플릿에 세팅되다보니 function call의 동작이 제대로 되지 않아서 rag용과 function call용 template을 구분했다.
뿐만 아니라 function call 파라미터로 난수가 입력되는 등 여러 시행착오가 있었지만, function call 부분은 내 관할이 아니므로 pass,,,
또한, 내가 구현한 부분은 아니지만, 이전 답변에 대한 history를 프론트에서 받게끔 해서 이전 답변을 고려한 답변이 될 수 있도록 하였고, 문서 형식에 상관없이 UI를 통해 문서를 업로드하면 해당 문서의 내용을 파싱하여 임베딩, Vector DB저장까지 될 수 있도록 구현되었다.
프롬프트는 아래 조건이 녹아들어갈 수 있도록 구성했다.
질문을 시작하기 전에 대답하는 ChatGPT에 역할부여
질문하는 사용자의 구체적인 목표를 제시
사용자가 원하는 구체적인 대답방법을 제시
얻고 싶은 결과 형식을 명확하게 제시
제한된 대답의 길이를 제시 그리고 추가적으로 위에 언급한 것처럼 인사말에 대한 응답이 인사말이 될 수 있도록하고 대답에 참고한 문서의 정보(file name)를 전달할 수 있도록 두 가지 문구를 추가했다.
그리고 질의를 할 때, message는 role과 content라는 두 가지 속성을 가지는데, content에는 실제 내용이 들어가고, role에는 system과 user, assistant, function 등과 같이 role에 관한 값이 들어간다. 해당 role에 따라 content 처리 방법이 달라진다. 아래는 role에 따라 주로 어떤 내용이 들어가는지 매핑한 내용이다.
user : 사용자 질의
system : 프롬프트 템플릿 + document
assistant : 이전 답변 내용 (history)
function : function call에 의한 함수 실행 결과
시연 및 발표
시연과 발표준비도 만만치 않았다... (문서작업이 가장 어려운 개발자들...)
시연영상 촬영을 위한 시나리오를 짜기전에 코드를 합쳤는데, 위에서 언급했던 여러가지 문제가 발생해서 해결하느라 시간 다 썼다. 그리고 부랴부랴 시나리오 정해서 영상촬영까지 끝낸 후 마감시각 정각에 파일들을 제출할 수 있었다. 해커톤 팀의 과장님이 발표를 담당하셨는데, PPT 제작에도 많은 힘을 쓰신 것 같았다.
그리고 한가지 과제가 또 있었다... 시연과 코드리뷰가 Jupyter 상에서 진행된다고 하여 APP형태의 코드들을 모두 Jupyter 환경으로 옮기는 데도 힘이 들었다. 환경이 다르다 보니... 라이브러리 설치가 안되는 부분도 많았고 해커톤 팀원 셋이서 정말 애썼다. 그리고 코드리뷰 시간이 다 되어가서 겨우 Jupyter 환경으로 모두 옮길 수 있었다. (과장님 발표하는 동안에도 계속 환경 옮기고 있었음;;)
과장님 발표는 성공적이었다. 평가하시는 분들과 같이 보시는 분들의 반응이 좋았다. 과장님께 직접 말씀드리기는 어렵지만... 영업사원 같았다. 우리가 구현한 시스템이 어디에 쓰이고 어떤 기대효과가 있고, 어떤 기술이 쓰였는지 어필을 잘 하셨다. 발표시간이 다 되어 뒷 부분 발표를 못하게 되었는데도, 심사자분께서 뒷 내용이 궁금하다고 하시어 뒷 내용도 마저 발표할 수 있었다. 그리고 우리가 구현한 시스템에 대한 자신감도 엿보였다. 발표를 할 때에는 내 제품에 대한 확신과 자신감이 있어야 청중의 공감을 살 수 있다는 것을 몸소 깨닫게 되었다.
그리고 코드리뷰 시간의 리뷰어 분들의 반응도 좋았다. RPA쪽을 구현한 나는 따로 RPA코드를 설명드리고 있었고, 다행히 RPA활용 점수를 받을 수 있었다. 그리고 바로 옆에서 우리의 APP코드 리뷰를 진행하셨는데, 리뷰어 분께서 제공한 샘플보다 코드를 더 잘 짠 것 같다는 평가도 받을 수 있었다. 그리고 프롬프트 엔지니어링에 신경쓴 티도 난다고 하시는 등 호평이 이어졌다. 본선에 대한 부푼 기대감을 안고 리뷰장을 나왔다. 그리고 22시, 모든 발표가 마무리 되었다.
이제 앞서 받은 실무평가를 통해 20팀중 단 7팀만이 임원평가를 받을 수 있는 자격이 주어지고 대상, 최우수상, 우수상, 장려상 입상을 할 수 있다. 해당 결과는 다음날 발표되지만, 우리는 입상에 대한 기대감이 부푼 상태였다. 이제 임원평가를 위한 발표를 준비해야 했다. 물론 실무평가 때 활용한 발표를 그대로 활용할 수 있지만, 타겟이 임원이므로 기술적인 내용보다는 개발하게 된 동기나 기대효과가 발표의 주를 이룰 수 있도록 수정하는 것이 좋다고 판단되었다. 그리고 이전에 촬영한 시연영상은 임원평가 때 보여지기 때문에 영상의 퀄리티를 높이기 위해 시나리오를 재작성해서 재촬영했다. 그리고 우리는 새벽 2시경에 숙소에 들어갈 수 있었다.
다음 날 10시, 발표가 진행되는 대강당에 집결했다. 예상했던대로 임원평가 대상자로 우리팀이 선발되었다. 장려상까지는 확보한 것이었다. 과장님의 발표가 진행되고, 어제보다 침착해진 어조로 우리가 구현한 시스템의 기대효과를 어필하셨다. 임원평가 때의 반응도 좋아보였다. 발표장에는 우리회사 사장님이 IT부문장 자격으로 참관하고 계셨다. 실물로 처음 뵙는 분인데, 악수까지 청해주셔서 영광이었다. (사장님 fan이된 ssul푼다) 우리팀의 발표를 보신 이후에도 comment를 남겨주셨는데, 발표에 나온 내용은 본부장과도 카톡으로 얘기를 나누고 있던 내용이었다... J과장과 사원들이 자기계발에 관심이 많은 것을 알고있다... 등 호평도 많이 주셨다. 이러다가 대상까지 받는 것 아닌지 김칫국을 원샷했다.
결과
결과는 우수상이었다. 사실 우수상도 감사하지만, 최소 최우수상이라고 생각했던 우리팀은 조금 아쉬워했다. 기대를 많이해서 그런 것 같다. 그래도 우수상이라도 받을 수 있어서 좋았다.
해커톤에 참여하여 어떤 서비스를 만드는 일 자체도 재밌었는데, 상을 수상하여 더욱 보람된 9~10월이 될 수 있었다. 끝!