모든 사례

카페 운영 시스템

사장이 자리를 비워도 근무표, 출퇴근, 급여, 재고, 세금이 돌아가게 만든 카페 운영 앱

기간
2026년 4월부터
역할
고객사 프로젝트, 서버와 웹, 앱 전체 (1인)
커밋
242개

쓴 기술 Django 5.1, django-tenants, DRF, Celery, PostgreSQL, Next.js 16, Expo(React Native), Turborepo, Cloudflare

카페 운영 앱 홈 화면. 4월 근무표 달력에 직원별 오픈, 미들, 마감 근무가 색으로 표시되고 아래에 이번 달 매출이 있다.
앱 홈. 근무표와 이번 달 매출. 데모 데이터

문제

고객사는 매장 하나가 건물 두 곳에 나뉜 카페예요. 아르바이트 직원 4명이 하루 4~5시간씩 주 4~5일 일해서, 주 16~25시간으로 주휴수당 대상이에요. 사장님은 자리를 비워도 직원 관리, 재고 관리, 세금 관리가 체계적으로 돌아가길 원했어요. 창고 두 곳에 품목이 50개쯤 있었지만 재고를 관리할 방법이 아예 없었고, 세금은 간이과세자로 세무사 없이 직접 신고하고 있었어요. 매출 데이터는 POS에서 엑셀로만 내보낼 수 있었고요.

사장님이 꼽은 가장 큰 고민은 직원 관리와 채용, 들쑥날쑥한 음료 품질, 잦은 휴무 신청이었어요.

“사장이 부재 시에도 직원관리, 재고관리, 세금관리를 체계적으로”
첫 요구사항 정리 문서

목표

  • 직원이 쓰기 쉽게 모바일을 먼저 만들고, 웹과 iOS, Android를 모두 지원한다.
  • 오픈, 미들, 마감, 생산 네 가지 근무를 짜고, 출퇴근은 매장에서만 찍게 한다.
  • 주휴수당과 간이과세 부가세를 자동으로 계산한다.
  • 창고 두 곳의 재고를 레시피와 연결해 관리한다.

풀어 간 과정

처음 시도한 방법, 막힌 지점, 방향을 바꾼 이유를 시간 순서로 적었어요.

  1. 2026년 4월

    Supabase로 시작해 하루 만에 Django로

    첫 요구사항은 직원, 재고, 세금에 걸친 25개 항목이었어요. Supabase로 시작했지만 하루 써 보니 속도가 느리고 데이터를 관리하기 불편해서, 끝낸 작업을 버리고 Django 관리자 화면과 서버 쪽 업무 로직으로 옮겼어요. 사흘 뒤에는 다른 카페도 쓸 수 있게 매장마다 DB 스키마를 나눴어요.

  2. 2026년 4~9월

    출퇴근 확인을 매장에 맞추기

    처음부터 와이파이 이름으로 확인하고, 읽을 수 없으면 IP로 확인하게 계획했어요. 써 보니 앱에서 와이파이 이름을 안정적으로 읽을 수 없어서, 사장님이 허용할 네트워크를 등록하면 앱이 IP를 자동으로 잡게 바꿨어요. 확인에 실패한 직원은 매니저가 대신 등록하고, 9월부터는 직원이 자기 기록을 직접 고칠 수 있되 허용 범위는 사장님이 세 가지 중에서 골라요.

  3. 2026년 5~9월

    쓰면서 늘어난 범위

    운영하면서 레시피(5월), 매장 홈페이지(8월), 선물 세트 예약 주문(9월)이 차례로 붙었어요.

지금의 구조

사장님이 하던 판단을 규칙으로 옮기고, 직원은 앱 하나로 일하게 했어요.

  1. 1

    카페마다 PostgreSQL 스키마를 따로 두고, 로그인 토큰에 담긴 매장 정보를 요청마다 검증해요.

  2. 2

    출근은 매장 와이파이 이름이 맞으면 통과하고, 읽을 수 없는 환경에서는 등록된 IP로 확인해요.

  3. 3

    주 15시간 이상 일하면 주휴수당을 계산하고, 간이과세 부가가치율을 적용해 세금을 추정해요.

  4. 4

    레시피를 실행하면 g과 mL를 품목 밀도로 환산해 재고를 빼요. 같은 요청이 두 번 와도 한 번만 빠지게 요청 키와 행 잠금을 걸었어요.

  5. 5

    직원과 사장님은 Expo 앱과 Next.js 웹을 함께 써요.

카페 운영 앱 스케줄 관리 화면. 날짜별로 직원 이름, 근무 종류, 시간이 카드로 나열돼 있다.
스케줄 관리. 날짜별 근무와 시간. 데모 데이터

핵심 결정

Supabase에서 Django로 옮기고, 매장은 스키마로 나누기

이유
처음 쓰던 Supabase는 속도와 데이터 관리가 불편했어요. 옮기는 김에 다른 카페도 쓸 수 있게, 매장 분리를 행 단위가 아니라 스키마와 URL 경로로 정했어요.
다른 선택지
Supabase 유지, 또는 한 스키마 안에서 행 단위로 나누기
감수한 것
매장마다 마이그레이션을 따로 돌려야 해요.

와이파이 확인에 IP 대체 경로 두기

이유
iOS에서 와이파이 이름을 읽으려면 별도 권한이 필요하고, 웹에서는 아예 읽을 수 없어요. 그래서 이름을 못 읽으면 등록된 IP로 확인해요.
다른 선택지
와이파이 이름으로만 확인
감수한 것
IP 확인은 정확도가 낮아서, 매니저 대리 등록과 기록 보정 승인으로 보완했어요.

어려웠던 문제

겪었거나 배포 전에 막은 문제를 어떻게 알아챘고, 무엇을 의심해 원인을 찾았는지, 다시 일어나지 않게 무엇을 바꿨는지 기록했어요.

출퇴근 버튼을 동시에 누르면 기록이 두 번 남던 문제

같은 직원이 출근 버튼을 동시에 두 번 누르면 같은 시각의 기록이 두 개 생겨, 급여가 두 번 계산될 수 있는 구조였어요. 기록 보정을 승인할 때 그사이 찍은 퇴근을 옛 값으로 덮거나, 퇴근 없이 열린 기록이 나중에 수십 시간짜리 근무로 닫히는 문제도 함께 있었어요. 기능을 넣고 7분 뒤, AI 코드 리뷰(Codex)가 잡아낸 내용을 반영해 고쳤어요.

왜 어려웠나

코드가 이미 행 잠금(select_for_update)을 쓰고 있어서 잠그고 있다고 보였어요. 하지만 아직 없는 행은 잠글 수 없어요. 테스트는 행 잠금이 동작하지 않는 SQLite에서 돌았고, 마지막 안전망인 유니크 제약도 없었어요.

추적 과정

  1. 1. 출근 API는 중복을 어떻게 막을까?

    열린 기록이 있는지 확인한 뒤 새로 만드는데, 트랜잭션도 잠금도 없었어요.

  2. 2. 보정 경로의 겹침 검사는 안전할까?

    그 사람의 기존 기록만 잠갔어요. 겹칠 기록이 아직 없으면 잠기는 행이 0개예요.

  3. 3. DB가 마지막에 막아 줄까?

    출퇴근 모델에 유니크 제약이 없었어요.

  4. 4. 그럼 무엇을 잠가야 할까?

    아직 없는 기록 대신, 그 직원의 계정 행을 잠가 요청의 순서를 세우기로 했어요.

원인

출퇴근과 보정 경로가 '확인한 뒤 생성'을 잠금 없이 했고, 잠금이 있던 곳도 아직 없는 행을 대상으로 해서 실제로는 아무것도 잠그지 않았어요.

요청 A요청 B
  1. 1열린 기록이 있나? 없음
  2. 2열린 기록이 있나? 없음
  3. 3출근 기록 생성
  4. 4출근 기록 생성, 같은 시각 기록 두 개
  5. 5(고친 뒤) 직원 계정 행 잠금잠금을 기다렸다가 다시 확인하고 거부

해결

출근, 퇴근, 보정 요청, 승인을 모두 트랜잭션으로 감싸고 먼저 그 직원의 계정 행을 잠가요. 잠금을 얻은 뒤 열린 기록과 겹침을 다시 확인해요. 승인할 때는 7일 이내, 미래 시각 금지, 16시간 이하 규칙을 다시 검사하고, 요청 뒤 기록이 바뀌었으면 거부해요.

고치기 전

clock_in():
    if exists(open record of user): reject
    create(record)                      # 트랜잭션도 잠금도 없음
check_overlap():
    lock(records of user)               # 겹칠 기록이 없으면 0행 잠금

고친 뒤

@atomic  clock_in / clock_out / submit / approve:
    lock(user row)                      # 아직 없는 기록 대신 사람을 잠금
    re-check open record and overlap
    approve: re-check 7일 이내, 미래 금지, 16시간 이하
             요청 이후 기록이 바뀌었으면 거부
고객사 코드라서 동작을 그대로 옮긴 단순화한 의사 코드로 적었어요

검증

  • 테스트 4개 (요청 뒤 바뀐 기록은 승인 거부, 최근 출근만 열린 기록으로 인정, 16시간 넘게 열린 기록은 퇴근 거부, 승인 때 7일 규칙 재검사)
  • 동시 요청 자체를 재현하는 테스트는 없어요. 테스트 DB인 SQLite에서는 행 잠금이 동작하지 않아요

재발 방지

  • 모든 출퇴근 쓰기가 같은 잠금 순서를 거치게 했어요.
  • 퇴근 없이 16시간 넘게 열린 기록은 퇴근으로 닫지 않아요.

남긴 원칙

아직 없는 기록은 잠글 수 없으니, 그 사람의 행을 잠가요.

밤을 넘기는 근무와 하루 두 번 근무가 엉키던 문제

자정을 넘기는 근무는 지각 판정이 틀렸고, 하루에 근무가 두 번이면 오전 출근 기록이 저녁 근무의 출근으로 잡혔어요.

왜 어려웠나

'한 사람의 근무와 출근 기록은 하루에 하나'라는 가정이 화면 코드 곳곳에 깔려 있었어요. 낮 근무 하나만 보면 아무 문제가 없었어요.

추적 과정

  1. 1. 근무에 맞는 출근 기록을 어떻게 찾을까?

    사람별로 하루에 하나만 찾고 있었어요.

  2. 2. 시각은 어떻게 비교할까?

    자정부터 몇 분인지로 비교해서, 끝이 시작보다 이른 밤 근무는 앞뒤가 뒤집혔어요.

  3. 3. 어떤 기록을 불러올까?

    오늘 출근 기록만 불러와서, 전날 밤에 시작한 근무를 놓쳤어요.

원인

근무와 출근 기록을 날짜 하나에 묶어서 다뤘어요. 근무를 시간 구간으로 보지 않았어요.

해결

근무 구간을 만들 때 끝이 시작보다 같거나 이르면 끝에 24시간을 더해요. 출근 기록은 근무 시작 2시간 전부터 끝 전까지의 구간 안에 있는 것을 그 근무의 출근으로 봐요. 웹과 모바일에 같은 규칙을 넣었어요.

핵심 코드

shiftWindow(shift):
    start, end = shift.start, shift.end
    if end <= start: end += 24h          # 자정을 넘기는 근무
    return [start, end)

recordForShift(shift, records):
    window = [shift.start - 2h, shift.end)
    return first record whose clock_in is in window
고객사 코드라서 동작을 그대로 옮긴 단순화한 의사 코드로 적었어요

검증

  • 테스트 6개 (밤 근무 지각 판정, 오전 기록이 저녁 근무로 잡히지 않음 등)

재발 방지

  • 웹과 모바일이 같은 매칭 규칙을 쓰게 맞췄어요.

남긴 원칙

근무는 날짜가 아니라 시간 구간으로 다뤄요.

결과

플랫폼
웹, iOS, Android
백엔드 테스트
테스트 함수 325개
  • 근무표, 출퇴근, 급여, 재고, 레시피, 세금, 예약 주문, 홈페이지를 한 시스템에서 관리해요.
  • 다른 카페도 같은 시스템을 쓸 수 있도록 매장마다 데이터를 나눠 두었어요.

다음 사례

입시 컨설팅 CRM

성수기에는 여러 신청자를 동시에 매칭해야 해서, 같은 시간에 컨설턴트가 두 번 잡힐 위험이 컸어요.