Building Darolink: From Prototype to Service
다로링크를 실제 서비스로 만들기까지 · 앱, 서버, 지도, 데이터 설계 회고
- Haram Lee
- 2026-07-14
- projects / Dairuri
지난 글에서는 다로링크의 프로토타입을 다시 현장에 들고 가서, 유저 테스팅을 반복하며 기능을 어떻게 고쳤는지를 기록했다. 그 글 끝에서 실제 기능 구현 과정이나 기술적인 디테일은 분량이 꽤 될 것 같아서 다음 글에서 따로 다루겠다고 적었다. 이번 글이 바로 그 다음 글이다.
사실 이번 글을 쓰면서 조금 고민이 됐다. 개발자 중심으로 코드 얘기를 하면 너무 딱딱해지고, 그렇다고 안 하면 우리가 한 학기 동안 뭘 만들었는지 절반은 빼놓고 말하게 되어버리는 셈이니까! 그래서 개발자 입장에서의 구현 당위성을 중심으로 설명하고자 기획했다.
내가 다로링크를 구현하면서 느낀 점은 기술은 기술 자체로 머물 수 없다는 것이고, 또 어디서 어떻게 쓰이느냐는 엔지니어의 몫이라는 것이다. 팀의 목적이 단순히 프로토타입이나 작동하는 목업이 아니라 실제 사용할 수 있을 정도로 보안이나 기능에 집중한 만큼 이 글에서 더 자세히 기록하고 싶었다.
전체 서비스 아키텍처

프론트엔드 → 백엔드 → 데이터베이스 순으로, 사용자가 앱 화면을 누르는 순간부터 그 요청이 서버를 거쳐 DB에 쌓이기까지를 따라가며 다로링크의 전체 구조를 정리해보았다. Expo 기반 React Native + TypeScript 앱을 중심으로, AWS EC2에 배포된 Node.js + TypeScript API 서버, DB는 Amazon RDS PostgreSQL, 캐시는 Amazon ElastiCache Redis를 사용했다. 지도는 안드로이드에서 Naver Maps 네이티브 SDK를, 웹에서는 Naver Web Dynamic Map을 사용하였다.
프론트엔드: 화면부터 앱까지
개발팀 내부 회의를 거쳐 Expo 기반 React Native + TypeScript로 안드로이드 앱을 만들기로 결정했다. Expo의 react-native-web 덕분에 같은 코드베이스로 웹 프로토타입까지 함께 만들 수 있었다. 지역 생활 서비스다 보니 모바일에서 바로 확인하고 사용할 수 있는 게 중요했고, 지도·모집글·채팅·버스 정보처럼 이동 중에 확인할 일이 많은 기능이라 앱을 택하는 것이 자연스러운 선택이었다고 생각한다. 비록 앱은 한 번도 만들어 본 적 없었지만 그래서 이러한 선택이 더 의미있었고, 또 돌이켜보면 정말 재미있었던 것 같다.
화면에서 제일 신경을 쓰게 된 것은 라이드 셰어링 흐름이었다. 모집글을 직접 올리는 흐름과, 다른 사람이 올린 모집글에 참여하는 흐름을 나눴고 참여하는 쪽은 도착지 기준으로 모집글을 필터링해서 볼 수 있게 했다.

유형 선택 / 이동 모집&일손 등록 최종 확인
이동과 일손은 입력받는 정보가 전혀 다르지만, 작성 흐름의 마지막에서는 하나의 createPost 호출로 통일된다.
function buildCreatePostInput(type: RecruitmentType): Partial<Post> {
if (type === "work") {
// ...일손 입력값 정리...
return {
type: "job",
profileMode: "resource",
title: workTitle.trim(),
placeName: workArea.trim(),
wageType: "hourly",
wageAmount: parseCurrencyNumber(workPay),
// ...
};
}
// 이동 입력값 정리
return {
type: "carpool",
title: rideTitle.trim(),
departure,
destination,
seats: parseCurrencyNumber(rideCapacity),
// ...
};
}
await createPost(buildCreatePostInput(selectedType));서버가 완성되기 전에 화면부터 만들어야 했는데, 임시 데이터를 코드에 직접 하드코딩하는 대신 화면과 API 호출 로직을 분리해두었다. mock API와 live API를 같은 인터페이스로 만들어두고 환경에 따라 전환하는 구조라, 서버가 붙기 전에도 화면 개발을 진행할 수 있었고 테스트에서도 같은 mock을 그대로 쓰는 구조로 설계하였다.
export function resolveApiMode(env: ApiModeEnv = process.env): ApiMode {
if (env.NODE_ENV === "test") {
return "mock";
}
if (env.EXPO_PUBLIC_DARORI_USE_MOCK_API === "true") {
if (env.NODE_ENV === "production") {
throw new Error("mock API mode is not allowed in production builds");
}
return "mock";
}
return "live";
}백엔드: 요청 하나가 서버를 지나가는 길
서버는 Node.js + TypeScript로 만들어서 AWS EC2에 배포했고, HTTPS로 서비스한다. 빠른 피드백과 접근 용이성을 위해 팀원들이 보는 화면은 웹으로도 배포하였다. 따라서 Android APK는 EC2의 API 서버를 직접 호출하고, 웹 프로토타입은 Vercel의 서버리스 함수를 호출하는데, 두 진입점이 같은 핸들러 모듈을 공유하도록 설계하여 팀원들과 실시간으로 공유할 수 있도록 하였다. 인증, 모집글, 지원/승인, 채팅, 신고, 버스 목격 기록, 장소 검색, 레이트리밋을 처리하는 로직을 HTTP 서버 코드와 분리해두었더니, 추후 EC2에도 서버리스에도 같은 코드를 그대로 얹을 수 있었다.
내부적으로는 역할을 나눠서 구성했다. 인증을 검증하는 모듈, 레이트리밋을 검사하는 모듈, 실제 쿼리를 실행하는 모듈을 분리해서, 요청이 들어오면 라우트별로 필요한 검증만 거치고 비즈니스 로직으로 넘어가게 했다. 공개 조회는 인증도 레이트리밋도 없이 바로 처리하고, 글을 쓰거나 지원하는 것처럼 상태를 바꾸는 요청은 인증과 레이트리밋을 모두 거친다.
인증 쪽은 처음엔 아이디/비밀번호 정도만 생각했다가, 실제 사용자가 쓸 서비스라면 전화번호 인증이 필요하다는 의견이 나와서 다시 기능을 추가했다. SMS로 인증 문자를 보내고, 인증 코드는 그대로 저장하지 않고 해시 처리해서 저장한다. 인증이 끝나면 회원가입/로그인을 거쳐 세션 토큰이 발급되고, 이후 요청은 이 토큰으로 검증한다.
const PHONE_VERIFICATION_TTL_MINUTES = 10;
const PHONE_VERIFICATION_MAX_ATTEMPTS = 5;
// 인증 코드 원문은 SMS로만 나가고, DB에는 해시만 남는다
await getPostgresPool().query(
`
insert into phone_verifications (id, phone, code_hash, expires_at)
values ($1, $2, $3, $4)
`,
[id, phone, createPhoneVerificationCodeHash(phone, code), expiresAt.toISOString()],
);
await deliverPhoneVerificationCode(phone, code);장소 검색도 서버가 프록시한다. 클라이언트가 지도 API를 직접 호출하지 않고, 서버가 Naver Geocoding과 Local Search 외부 API를 대신 호출해 결과를 조합해서 반환한다. 검색 API 키를 클라이언트 번들에 넣지 않기 위한 결정이 가장 컸고, 나중에 검색 결과를 서버에서 보정하거나 캐싱할 여지를 남겨두려는 이유도 있었다.
데이터베이스: 관계로 풀어낸 서비스 구조
데이터는 Amazon RDS PostgreSQL에 두고, 조회가 잦은 일부만 Amazon ElastiCache Redis로 분리했다. DB 테이블은 크게 네 그룹으로 나뉜다.

1) 사용자·인증(users, auth_sessions, phone_verifications)
2) 모집·지원(posts, post_likes, applications, vehicles)
3) 채팅·신고·평가(chat_rooms, chat_messages, reports, manner_ratings)
4) 버스 아카이빙(bus_routes, bus_route_stops, bus_stops, bus_sightings)
사용자-모집-지원-채팅으로 이어지는 흐름은 FK로 연결해서 데이터 무결성을 보장하고, 버스 쪽은 노선·정류장·목격 기록을 따로 나눠서 나중에 노선이 늘어나도 구조를 그대로 유지할 수 있게 했다.
라이드 셰어링을 처음 기획할 때는 모집글 테이블이랑 인재 풀 테이블을 따로 만들 생각이었다. 그런데 구현하면서 보니 둘 다 누군가 글을 올리고, 다른 사람이 반응하고, 연결된다는 흐름은 동일했다. 그래서 posts 테이블 하나에 이동 모집글이랑 일손 프로필을 같이 넣는 방식을 택했다. 대신 ‘라이드셰어랑 글인데 출발지가 없다’거나 ‘인재 풀 모집 글인데 급여가 없다’ 같은 상태는 DB 제약 조건을 두었다.
create table if not exists posts (
id text primary key,
type post_type not null, -- 'carpool' | 'job'
title text not null,
author_id text not null references users(id) on delete restrict,
-- 이동 글용 컬럼: departure, destination, seats ...
-- 일손 글용 컬럼: place_name, wage_amount, available_tasks ...
-- 유형별 필수값은 제약 조건으로 강제한다
constraint job_required_fields check (
type <> 'job' or (place_name is not null and wage_amount is not null)
),
constraint carpool_required_fields check (
type <> 'carpool' or (departure is not null and destination is not null)
)
);마음에 드는 모집글에 지원하면 작성자가 승인하거나 거절하고, 승인되는 순간 채팅방이 자동으로 만들어진다. 신고(reports)는 채팅방과 연결되는 구조라, 대화 중에 문제가 생기면 그 맥락 그대로 신고로 이어질 수 있다. 채팅이 끝나면 상대에 대한 매너 평가(manner_ratings)를 남길 수 있다. 운전자는 vehicles에 차량 정보를 등록할 수 있다.
행복버스 아카이빙: 목격 기록을 정보로 바꾸는 일
행복버스 아카이빙은 프론트엔드·백엔드·DB가 어떻게 맞물리는지 잘 보여주는 기능이라 따로 짚어본다. 데이터가 흘러가는 흐름을 네 단계로 나눠서 설계했다.

1) 기록
사용자가 버스를 보고 버튼을 탭 한 번 누르면 위치랑 시간이 자동으로 기록된다.
2) 정류장 매칭
좌표를 기준으로 가장 가까운 정류장을 추론한다(inferRouteAndStop)
어느 정류장인지 직접 고르게 하면 입력 과정이 길어지니, 우선은 좌표 추론으로 처리했다. 다만 반경 밖이면 스냅하지 않고 기록을 거부하고, 거리가 같은 정류장이 여럿이면(환승 지점처럼) 노선 코드순으로 결정론적으로 고르도록 로직을 설계하였다. 같은 입력이면 항상 같은 결과가 나와야 테스트할 수 있기 때문이다.
/**
* 제보 좌표에서 가장 가까운 (노선, 정류장) 쌍을 고른다. 순수 함수.
* - 거리가 같으면 노선 코드순 → 정류장 id순으로 결정론적으로 선택
* - 반경(radiusMeters) 밖 정류장은 제외 — 멀리 있는 제보를
* 아무 정류장에나 스냅하지 않고 null을 반환한다
*/
export function inferRouteAndStop(reporter, routes, routeStops, stops, radiusMeters) {
let best = null;
for (const link of routeStops) {
const stop = stopsById.get(link.stopId);
const distance = haversine(reporter, stop);
if (distance > radiusMeters) continue;
if (best === null || distance < best.distanceMeters) {
best = { route, stop, distanceMeters: distance };
}
// 거리가 같으면 노선 코드 → 정류장 id 순으로 tie-break
}
return best;
}3) 저장 및 익명화
PostgreSQL에 저장하되, 제보자 정보는 다른 사용자에게 노출되지 않게 비식별 처리한다. 원본 reporter_id는 DB에만 남고, 클라이언트에는 salt를 붙인 SHA-256 해시의 앞 6자리만 기록된다. 같은 제보자는 같은 라벨로 보여서 반복 제보를 알아볼 수 있지만, 라벨에서 사람을 역추적할 수는 없다. 탈퇴한 계정의 기록은 지우지 않고 “deleted” 라벨로 남기도록 하였다.
export function reporterLabel(
reporterId: string | null,
salt: string = REPORTER_LABEL_SALT,
): string {
if (!reporterId) {
return "deleted"; // 탈퇴한 계정의 기록
}
return createHash("sha256")
.update(`${reporterId}::${salt}`)
.digest("hex")
.slice(0, 6);
}4) 공유
Redis 캐시를 통해 정류장별 최신 목격 정보를 제공한다. 사용자가 지도를 열 때마다 조회할 데이터라 매번 PostgreSQL을 직접 조회하지 않도록 만들었다. Redis가 죽어 있어도 서비스가 멈추지 않도록 캐시는 best-effort로 두고 놓치면 SQL 값으로 폴백한다.

개별 탑승 제보를 정류장 단위의 익명 데이터로 정규화해서, 다음 사용자가 재사용할 수 있는 정보로 바꾸는 게 이 흐름의 핵심이었다.
개발하면서 남은 아쉬움
다로링크는 최종 발표 시점에 웹 프로토타입이랑 안드로이드 APK로 구현됐지만, 완성된 서비스라고 하기엔 아직 남은 게 많다.
세션 만료 UX나 토큰 갱신·폐기 정책 같은 운영 수준의 세션 관리, 프로필 이미지 업로드처럼 실 서비스라면 당연히 있어야 할 기본기가 아직 다듬어지지 않았다.
정류장 매칭 정확도. 좌표 기반 추론이 GPS 오차나 정류장이 몰려있는 구간에서는 정확도가 떨어진다.
라이드 셰어링의 신뢰 장치. 신청·승인·채팅·매너 평가까지 기본 구조는 갖췄지만, 매너 평가를 실제 신뢰 정책으로 어떻게 노출하고 악용을 막을지는 더 고민이 필요하다.
마치며
프론트엔드, 백엔드, 데이터베이스를 분리해서 살펴봤지만 결국 다 같은 결정에서 파생된 것들이고, 어떻게 하면 서비스를 간결하게 구현할 수 있을지 고민한 결과이다. 화면 몇 개, 테이블 몇 개로 보이지만 그 뒤에는 항상 이런 선택이 있었다. 그간 서비스를 구현하면서는 이렇게까지 복잡한 구조를 설계해보지 않았고, 또 프론트엔드와 백엔드, 또 DB까지 유기적으로 구성해볼 일이 많지 않았던 것 같다.
정말 귀중한 경험이고, 또 로컬비즈니스에 대해서 개발자로서 회고해볼 수 있는 좋은 경험을 할 수 있어서 뿌듯했다. 앞으로도 어떤 엔지니어가 되고 싶은지 많이 숙고해야겠다고 느낀다…
끝!

테크포임팩트 캠퍼스 크리에이터 활동의 일환으로 제작된 콘텐츠입니다.