회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
AWS 이용 중이라면 최대 700만 원 지원받으세요
국내 유명 IT 기업은 한국을 넘어 세계를 무대로 할 정도로 뛰어난 기술과 아이디어를 자랑합니다. 이들은 기업 블로그를 통해 이러한 정보를 공개하고 있습니다. 요즘IT는 각 기업의 특색 있고 유익한 콘텐츠를 소개하는 시리즈를 준비했습니다. 이들은 어떻게 사고하고, 어떤 방식으로 일하는 걸까요?
회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!
확인
국내 유명 IT 기업은 한국을 넘어 세계를 무대로 할 정도로 뛰어난 기술과 아이디어를 자랑합니다. 이들은 기업 블로그를 통해 이러한 정보를 공개하고 있습니다. 요즘IT는 각 기업의 특색 있고 유익한 콘텐츠를 소개하는 시리즈를 준비했습니다. 이들은 어떻게 사고하고, 어떤 방식으로 일하는 걸까요?
이번 글에서는 성형수술 및 피부 시술 정보 제공 플랫폼 ‘강남언니’가 병원 관리 소프트웨어 KOS(이하 KOS)를 개발하면서, 새로 도입한 인터페이스 정의 언어를 이용한 인터페이스 관리 전략을 소개합니다.
B2B SaaS 애플리케이션 개발을 시작하면서 KOS팀은 인터페이스 정의 언어(이하 IDL, Interface Definition Language)를 도입하게 되었습니다. IDL은 소프트웨어의 인터페이스를 정의하는 명세 언어를 말하고, 특정한 언어에 국한되지 않는 방법으로 인터페이스를 정의하여 애플리케이션들의 언어가 다르더라도 동일한 인터페이스로 통신할 수 있도록 합니다.
여러 종류의 IDL 중, KOS팀은 Google Protocol buffers(a.k.a. Protobuf)를 선택했습니다. KOS팀이 Protobuf를 선택한 이유는 다양한 언어로 Generate 할 수 있다는 특징이 있고, 무엇보다 인터페이스 작성이 매우 쉽습니다. 그리고 추후 gRPC라는 강력한 경량 통신을 사용할 수 있기에 도입했습니다. KOS팀은 IDL을 도입함으로써, 제품 개발 프로세스에 있던 비효율을 해결할 수 있었습니다.
IDL을 도입하기 전의 제품 개발 프로세스입니다.
위 프로세스의 4, 5번 과정에서 비효율적인 문제들이 발생하곤 했는데요. 자세하게 어떤 비효율적인 문제가 있었고, IDL을 통해 어떻게 해결했는지에 대해 이야기해보겠습니다.
IDL을 도입하기 이전 관리되던 API 문서의 일부입니다. 위에서 언급한 과정대로 백엔드 개발자가 노션 문서에 통신하는데 필요한 통신 규약들에 대해 정의합니다. 그 뒤, 변경사항이 생긴 이후에는 변경을 인지한 개발자가 문서를 수정을 하게 되는데요. 이때 해당 API 문서는 현재 구현된 코드가 제대로 반영된 상태인지 알기 어렵고, 응답 필드가 많으면 많을수록 어떤 불일치가 있는지 찾아내기 어렵습니다. 이렇듯 시스템이 커질수록 API의 수는 계속 늘어날 것이고, 모든 통신 규약을 노션과 같은 외부 문서로 유지보수하는 데는 꽤 많은 노력을 필요로 하게 됩니다.
백엔드 개발자가 인터페이스를 주도하여 설계할 때, 데이터 무결성 및 시스템 성능 최적화와 같은 시스템적인 부분이 가장 먼저 고려되는 경우가 많습니다. 이는 기술적 측면에 있어 분명 필수적인 고려 사항이지만 성능에 초점이 맞춰지다 보니 오버 스펙을 가진 인터페이스가 정의될 여지가 생기며, 이로 인해 프론트엔드 개발자와 이를 통합하기 위해 지속적인 합의를 하게 됩니다. 해당 과정에서 커뮤니케이션 오버헤드가 발생하고 운영 비용이 높아질 수 있다는 문제가 있습니다. 이제 이러한 비효율적인 문제를 해결하기 위해 어떠한 변경들이 있었는지 설명드리겠습니다.
프론트엔드 개발자가 API를 설계하고 백엔드 개발자는 설계된 해당 인터페이스가 제품 구현을 반영할 수 있는지 리뷰하고 변경을 요청합니다.
백엔드 주도로 인터페이스를 설계하면서 직면한 문제를 대응하기 위해 설계 프로세스를 변경했습니다. 이제 UI와 UX 디자인의 복잡성에 대해 더 잘 알고 있는 프론트엔드 개발자가 인터페이스 설계를 주도하여 최종 엔드 유저의 요구 사항과 기대에 부합하는 방식으로 인터페이스를 설계합니다.
또한, 이 변경된 프로세스를 통해 백엔드와 프론트엔드 개발자 간 지식 전파도 자연스럽게 이어집니다. 예를 들어, 사용자 경험에 미숙할 수 있는 백엔드 개발자와 시스템 관점에서 생각하기 어려울 수 있는 프론트엔드 개발자 간의 지식 전파가 상호 리뷰를 통해 자연스럽게 만들어지는 효과를 거둘 수 있었습니다. 결론적으로, 프론트엔드 주도로 인터페이스를 설계하는 방식의 전환은 단순한 개발 프로세스의 전환뿐만 아니라 사용자 경험을 우선시하는 문화적 전환이라고 볼 수 있습니다.
인터페이스로 정의된 Protobuf 코드를 서버와 클라이언트에서 정의하여 동일한 통신 계약 모델을 사용합니다. 아래에서 실제 Protobuf로 정의된 Message를 이용하여 백엔드와 프론트엔드에서 어떻게 관리되는지 알아보겠습니다.
e.g. 예약 API
message ReservationOption {
string optionId = 1;
string optionTitle = 2;
}
message ReserveCommand {
string visitorId = 1;
repeated ReservationOption options = 2;
optional string memo = 3;
string startDateTimeUtc = 4;
string endDateTimeUtc = 5;
}
message ReserveCommandResponse {
string reservationId = 1;
}
service ReservationController {
rpc reserve(ReserveCommand) returns (ReserveCommandResponse) {
option (google.api.http) = {
post: "/tenants/{tenantId}/schedule/reservation/v1/commands/reserve"
body: "*"
};
}
}
(병원에서 고객 예약을 생성하는 인터페이스의 일부입니다.)
위처럼 Protobuf로 정의된 메시지를 protoc로 컴파일을 하면, 아래와 같이 코드가 생성됩니다.
참고
TypeScript Code로 변환된 인터페이스
export interface ReserveCommand {
visitorId: string;
options: ReservationOption[];
memo?: string | undefined;
startDateTimeUtc: string;
endDateTimeUtc: string;
}
export interface ReserveCommandResponse {
reservationId: string;
}
export const ReserveCommand = {
encode(message: ReserveCommand, ...) {
if (message.name !== "") {
//...
}
},
decode(input) {
//...
},
fromJSON(object: any): ReserveCommand {
return {
visitorId: isSet(object.visitorId) ? String(object.visitorId) : "",
//...
}
}
toJSON(message: ReserveCommand): unknown {
//...
}
}
export class ReservationControllerClientImpl implements ReservationController {
private readonly rpc: Rpc;
private readonly service: string;
constructor(rpc: Rpc, opts?: { service?: string }) {
//...
}
reserve(request: ReserveCommand): Promise<ReserveCommandResponse> {
const data = ReserveCommand.encode(request).finish();
const promise = this.rpc.request(this.service, "reserve", data);
return promise.then((data) => ReserveCommandResponse.decode(new _m0.Reader(data)));
}
}
이제 프론트엔드 개발자는 IDL에 정의된 Interface를 Submodule로부터 Import 하여 코드에 반영합니다. 물론, 백엔드 개발자도 동일하게 해당 Interface를 Import 하여 반영합니다.
이제 IDL에 명시한 예약 생성의 코드가 각 애플리케이션 언어에 맞게 생성되었으니, 실제 클라이언트와 서버 간 위 코드를 어떻게 적용하여 사용하는지 의사 코드(pseudo-code)로 살펴보겠습니다.
Client Code
// TypeScript로 Generate된 Code를 Import합니다.
import {
ReserveCommand,
ReserveCommandResponse,
ReservationController
} from '@/idl/gen/pb-typescript/reservation';
// 프론트에서 API 호출할 때도, IDL에 정의한 Controller부를 가져와서 선언해줍니다.
const ReservationApi: ReservationController = {
reserve(command: ReserveCommand): Promise<ReserveCommandResponse> {
return axios.post(
'/tenants/:currentTenantId/reservation/v1/commands/reserve',
ReserveCommand.toJSON(command)
).then(ReserveCommandResponse.fromJSON);
},
//...
};
Server Code
//Java로 generate된 code를 Import합니다.
import protos.reservation.ReserveCommand;
import protos.reservation.ReserveCommandResponse;
//Server에서도 IDL에 정의한 Controller에 맞춰서 선언해줍니다.
@PostMapping("/tenants/{tenantId}/reservation/v1/commands/reserve")
public ReserveCommandResponse reserve(
@RequestBody ReserveCommand command
) {
return reserveCommandExecutor.execute(command);
}
위 클라이언트와 서버의 Import 부를 보면 알 수 있듯, 이제 클라이언트와 서버는 항상 동일한 통신 계약 모델을 사용하게 됩니다. 이로써 문제 파트에서 정의한 유지보수 이슈에 대해서 많은 부분을 소화할 수 있게 되었습니다.
KOS팀은 현재 MSA 구조로 많은 서비스를 운영하고 있습니다. 각 서비스 간 느슨한 결합을 위해, 이벤트 드리븐 아키텍처를 적절하게 사용하여 만들어지고 있는데요. 이때 많은 도메인 서비스들에서 발행/ 소비되는 이벤트의 계약 모델, 그리고 Server to Server로 이루어진 통신 규약들에 대해서도 문서로만 관리된다면, 하나의 서비스에서 변경이 이루어졌을 때 어디서부터 불일치가 발생했는지 찾는데 많은 리소스가 들게 됩니다.
따라서 각 서비스들 간에서도 위의 문제 섹션에 명시한 동일한 문제를 겪을 수 있게 됩니다. 그러나 각 서비스에서 IDL로 정의된 동일한 인터페이스를 사용한다면, 여러 도메인 서비스에서 발행/ 소비되는 이벤트 계약 모델과 서버 간 통신 규약들에 대해서도 동일한 인터페이스를 가져갈 수 있다는 해결 방법이 생깁니다.
이제 읽으시면서 궁금증이 들었을만한 부분들을 Self QnA를 통해 알아보면서 마무리 지어보겠습니다.
KOS팀은 IDL을 Git에 독립 리포지토리로 만들어서 정의한 뒤, 사용하는 애플리케이션에서 Git Submodule을 이용하여 각 애플리케이션 리포지토리에 불러와 사용하고 있습니다. 또한, 각 애플리케이션에서 Generate 된 코드를 Submodule을 통해 관리하는 이유는 Protobuf가 Protoc, Plugin 버전에 따라 생성된 결과물이 다를 수 있기 때문에 모든 파일을 각 애플리케이션 별 독립된 리포지토리에서 관리하고 있습니다.
위는 전체적인 프로세스를 나타낸 도식표입니다. KOS팀은 Code Review 단계에서 인터페이스를 상호 합의를 하고 있는데요. 아래와 같은 방식으로 진행됩니다.
작업에 관련된 프론트엔드와 백엔드 개발자들이 리뷰를 통해 UI/ UX를 반영할 수 있는 데이터 구조인지, 혹은 이 설계가 도메인 바운더리를 위배하고 있진 않는지, 그리고 최종적으로 시스템적으로 구현하는데 문제가 없는 인터페이스인지 확인하는 절차를 가집니다. 이제 PR을 병합한 뒤, Github Actions의 워크 플로우가 트리거 되어 GitHub Bot이 각 애플리케이션의 언어들로 Generate 시켜 메인 브랜치에 커밋하는 방식으로 관리되고 있습니다.
message ConsultationContract {
Counselor counselor = 1;
optional string consultationMemo = 2;
}
message Counselor {
string id = 1;
string name = 2;
}
import com.healingpaper.solution.reservation.ConsultationEntity;
import protos.reservation.ConsultationContract;
private ConsultationContract convertToContract(
ConsultationEntity consultation
) {
return ConsultationContract.newBuilder()
//counselor는 required한 속성으로, NPE가 발생하지 않습니다.
.setCounselor(consultation.getCounselor())
//만약 consultationMemo의 값이 Null이였다면, NullPointException이 발생하게 됩니다.
.setConsultationMemo(consultation.getConsultationMemo())
.build();
// ==================================================
//따라서, NPE 방지를 위해 상담 메모를 넣어줄 때, Optional Handling 로직을 추가해야합니다.
return ConsultationContract.newBuilder()
.setCounselor(consultation.getCounselor())
.setConsultationMemo(
Optional.ofNullable(consultation.getConsultationMemo)
.orElse("")
)
.build();
}
Swagger는 현재 많은 기업들이 사용하고 있는 안정성 있고 다양한 기능들이 내포된 유용한 도구로 알려져 있습니다. 그러나 KOS팀에서 채택하지 않은 결정적인 이유는 백엔드 개발자에게 종속된 API Document가 될 수 있기 때문이었는데요. 인터페이스 문서의 주체가 백엔드 개발자에게 강하게 종속되어 있는 경우 어떤 문제들이 발생할 지에 대해 나열해 보면 다음과 같습니다.
지금까지 강남언니 KOS팀에서 도입 후 모두가 만족해하고 있는 IDL의 사용 전략에 대해 소개드렸습니다. IDL을 이용하여 클라이언트와 서버 간 통신 규약을 코드로 명시하고 형상 관리를 통해 변경 기록을 관리할 수 있다는 특징 하나만으로도 충분히 매력적이나, 이를 모든 애플리케이션에서 동일한 통신 계약 모델을 강력하게 강제하여 사용할 수 있다는 특징이 저는 가장 강력한 무기라고 생각이 듭니다.
혹시 비슷한 문제 상황을 겪고 있는 팀이라면, 이번 글을 통해 조금이나마 해결에 도움이 되셨으면 좋겠습니다. 마지막으로 KOS팀에서는 이 외에도 빠르고 효율적인 개발을 위해 또 다른 여러 도구와 방식들을 채택하고 있습니다.
<원문>
[SaaS] 프론트엔드 개발자가 API를 설계하는 이유
요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.