01 · 주제 선택
마켓과 동호회 중 하나를 골라 시작합니다.
아래 내용은 수업용 설계 초안입니다. 두 서비스를 한 번에 완성하는 수업이 아니라, 팀이 선택한 한 주제로 UX와 설계를 맞추고 개발을 시작합니다.
마켓은 사진·가격 또는 나눔 여부를 보고 물건을 고른 뒤 찜·채팅 요청을 보내고 거래 상태를 확인하는 서비스입니다. 동호회 관리는 소개를 보고 가입 신청·승인을 거쳐 공지·모임을 확인하고 참석을 기록하는 서비스입니다.
이번 주에는 목표 화면과 PRD·ERD·API 초안을 함께 정리합니다. 그런 다음 마켓이면 게시글 등록·목록·상세, 동호회면 소개·가입 신청·승인처럼 하나의 흐름을 먼저 연결합니다. 나머지 요청 기능도 전체 계획에 남기고 다음 작업으로 나눕니다.
실제 회사의 불편·권한·운영 규칙을 확인한 자료는 아닙니다. 팀이 어느 문제를 더 자주 겪는지, 누가 처음 사용해 볼 수 있는지 확인해 주제를 고릅니다. 화면·가격·이름·날짜·횟수는 모두 예시입니다.
02 · 마켓 UX
물건을 찾은 뒤 연락하고 거래 상태를 확인합니다.
사용자가 제공한 아래 구상도를 기준으로 사진·가격/나눔·찜·채팅 요청·거래 상태를 전체 범위로 둡니다.
게시자는 사진과 제목·내용을 올리고 판매인지 나눔인지 고릅니다. 판매이면 가격, 나눔이면 무료라는 표시를 명확히 합니다. 목록에서 본 상태와 상세 화면에서 본 상태가 다르면 이미 끝난 거래에 연락할 수 있으므로 같은 기록을 보여줘야 합니다.
관심 직원은 찜 목록에 저장하거나 채팅 요청을 보냅니다. 이번 초안의 채팅 요청은 게시자에게 연락 요청을 남기고 게시자가 확인하는 기능입니다. 실시간 메시지 송수신·결제·배송까지 포함한 서비스로 범위를 늘리지 않습니다.
거래 상태는 판매중 → 예약중 → 거래완료입니다. 예약이 취소되면 판매중으로 돌아오는 경로도 둡니다. 채팅 요청을 보내는 것과 실제 예약을 확정하는 것은 다르므로 판매자가 상태를 바꿉니다. 팀이 정할 추가 기준은 예약 대상·시간·장소를 어디까지 기록할지입니다.
03 · 동호회 UX
가입·공지·모임과 연간 참석 기록을 연결합니다.
소개, 가입 신청·승인, 공지, 일정, 참석/불참, 멤버별 연간 참석 횟수가 요청된 기능입니다.
가입 전 직원은 동호회 소개를 보고 신청합니다. 운영자는 자기 동호회의 가입을 승인하고 공지와 모임 일정을 등록합니다. 승인된 멤버는 공지·일정을 보고 자신의 참석·불참 의사를 응답하는 초안으로 시작합니다.
모임 전 참석 응답은 계획이고 실제 참석과 다를 수 있습니다. 연간 실제 참석 횟수를 제공하려면 모임 후 운영자가 참석을 확인하는 기록이 필요합니다. 이 기준은 수업용 제안이므로 회사가 원한 집계가 ‘참석 의사’인지 ‘실제 참석’인지 확인 후 확정합니다.
연간 횟수는 같은 멤버의 같은 완료 모임을 중복 없이 셉니다. 미래·취소 모임은 실제 횟수에 포함하지 않습니다. 원본에 있는 참석률은 추가 참고 정보이며, 우선 요청된 연간 참석 횟수를 구현합니다. 운영자와 멤버가 볼 수 있는 통계 범위도 팀이 정합니다.
04 · 상태와 권한
버튼 이름만이 아니라 상태가 바뀌는 조건을 정합니다.
마켓의 연락 요청과 예약 확정, 동호회의 참석 의사와 실제 참석을 분리해 봅니다.
마켓에서 두 직원이 동시에 채팅 요청을 보내더라도 둘 다 자동 예약될 수는 없습니다. 첫 버전에서는 요청을 기록하고 판매자가 예약을 확인합니다. 상태를 바꾸려는 사람이 게시자인지도 서버가 확인합니다. 찜·채팅 요청을 여러 번 눌러도 같은 기록이 중복 생성되지 않게 합니다.
동호회에서는 다른 동호회 운영자가 승인하거나 일반 멤버가 남의 실제 참석을 바꾸지 못하게 합니다. 참석 의사와 실제 참석 기록은 같은 칸을 덮어쓰지 않습니다. 기록 수정 시 연간 횟수도 원래 기록과 맞게 다시 계산합니다.
저장이 실패하면 작성 내용을 유지하고 실패한 행동을 안내합니다. 화면 색이나 성공 문구를 먼저 바꾸고 실제 저장은 나중에 확인하는 방식으로 진행하지 않습니다. 운영 규칙은 수업의 제안과 팀이 확인한 사실을 구분해 문서에 남깁니다.
05 · 도구와 구성
화면은 Vite + React, 업무 처리는 Go에서 시작합니다.
라이브러리·프레임워크·개발 도구는 서로 다른 역할입니다. 도구 이름보다 사용자가 하려는 행동을 먼저 정합니다.
React는 입력창·목록·상태 표시 등 화면을 만드는 라이브러리입니다. Next.js는 페이지·서버 기능과 구성 규칙을 준비한 프레임워크이고, Vite(비트)는 개발 화면 실행과 배포 파일 준비를 돕는 도구입니다. 프레임워크의 기능 중 필요한 것을 선택해서 쓸 수 있습니다.
이번에는 React가 마켓·동호회 화면을, Go가 로그인·권한·입력 확인과 저장을 맡습니다. Next.js도 사내 서비스에 사용할 수 있습니다. 이번 구성에서는 화면 이동·데이터 로딩 방식을 별도로 정해야 하므로 준비된 프레임워크와 비교할 때 이 부담도 봅니다.
DB는 SQLite와 PostgreSQL 중 배포 조건·파일 보존·백업·동시 쓰기 작업을 보고 결정합니다. 개발자가 여섯 명이라는 이유만으로 정하지 않습니다. 이번 요청 기능은 별도 AI 모델 호출 없이 구현할 수 있습니다. AI 코딩 도구는 문서·구현·검토를 돕는 데 씁니다.
06 · S3와 DB
사진 원본과 게시글 기록은 저장 역할이 다릅니다.
S3는 Amazon Simple Storage Service의 줄임말로 AWS가 제공하는 객체 저장 서비스입니다. 사진·문서 같은 파일을 보관하는 곳으로 이해하면 됩니다.
사무용 의자 게시글에는 사진 원본과 제목·가격·거래 상태가 있습니다. 이 예시에서는 사진 파일은 S3에 보관하고, 게시글 내용과 그 사진을 찾을 정보는 DB에 기록합니다. 사진을 별도로 보관해도 어느 게시글의 사진인지 연결할 수 있어야 합니다.
DB는 “판매중인 물건만 보기”, “내가 찜한 물건 찾기”처럼 기록을 연결하고 조건으로 조회하는 역할을 합니다. S3에는 파일을 넣고 고유한 이름으로 다시 찾습니다. S3의 버킷은 파일을 모아 두는 저장 단위, 객체 키는 그 안에서 파일을 구별하는 이름입니다. 그림의 images/chair.jpg는 예시 이름이며 코드 문법을 외울 필요는 없습니다.
마켓에 필요한 것은 사진을 계속 보관할 장소입니다. 여기서는 S3를 예로 들며 AWS만 사용할 수 있다는 뜻은 아닙니다. 로컬 개발에서는 서버의 영구 폴더로 시작할 수도 있지만, 배포 때도 파일이 남는지와 백업·접근 권한을 별도로 정해야 합니다. SQLite도 파일 데이터를 저장할 수 있으므로 “DB에는 사진을 절대 넣을 수 없다”고 설명하지 않습니다.
S3 객체는 기본적으로 비공개입니다. 사내 사진을 보여줄 때도 서버가 접근 권한을 확인합니다. 기간이 제한된 보기 주소를 만들어 사용할 수 있으며 그 임시 주소를 영구적인 사진 식별 정보로 DB에 저장하지 않습니다. 이번 수업은 개념과 설계까지이며 AWS 계정 생성·유료 저장소 연결을 실행하지 않습니다.
photo-storage-notes.md · 구현할 때 참고
S3: 사진 원본 images/chair.jpg
DB stored_files: 파일 번호 12, 소유자 7, 저장소 s3, 객체 키 images/chair.jpg
DB post_photos: 게시글 42, 파일 번호 12
DB posts: 게시글 42, 제목 사무용 의자, 가격 15000, 상태 available
화면: 서버의 /api/files/12로 사진을 요청
서버: 로그인·사진 접근 권한 확인 후 파일 제공 또는 짧게 유효한 보기 주소 발급
임시 보기 주소가 만료되어도 DB의 객체 키와 파일 연결 정보는 그대로 유지
파일 업로드와 게시글 저장 중간에 실패할 경우 미연결 파일 처리도 구현 때 정한다. 07 · 마켓 PRD
마켓의 전체 기능과 첫 구현을 구분합니다.
PRD는 누가 어떤 행동을 할 수 있고 무엇을 확인하면 완료인지 적는 문서입니다.
아래 초안은 요청된 기능을 모두 담습니다. 첫 수업에서 등록·조회부터 만든다고 찜·채팅 요청·거래 상태를 요구에서 빼는 것은 아닙니다. 후속 작업의 시작 조건도 함께 적습니다.
market-prd.md · 수업용 완성 초안
# 사내 나눔·중고 마켓 · PRD 초안
수업용 제안. 실제 회사의 규칙은 확인 후 확정한다.
사용자: 직원(게시자 또는 관심 직원).
목표 UX: 사진·가격/나눔·상태를 보고 물건을 찾고 게시자에게 연락한다.
전체 기능:
- 사진·제목·설명·판매/나눔 구분·가격을 포함한 게시글 등록
- 목록·상세 조회, 찜하기/취소, 채팅 요청
- 판매중 → 예약중 → 거래완료, 예약 취소 시 판매중 복귀
역할:
- 직원은 게시글을 조회하고 자기 계정으로 찜·채팅 요청한다.
- 게시글 수정·거래 상태 변경은 게시자만 한다.
- 채팅 요청을 보냈다는 이유로 예약 상태를 자동 변경하지 않는다.
입력 규칙 초안:
- 판매는 0보다 큰 가격, 나눔은 가격 0으로 저장한다.
- 사진은 등록한 파일을 서버가 확인한 뒤 연결한다.
- 같은 직원의 같은 게시글 찜·채팅 요청은 중복 생성하지 않는다.
첫 구현: 사진 포함 등록 → 목록 → 상세 → 새로고침 후 재조회.
후속 구현: 찜·채팅 요청·거래 상태 관리. 전체 요구에서 삭제하지 않는다.
채팅 요청은 연락 요청 기록과 게시자의 확인까지다.
실시간 메시지 송수신·결제·배송은 이번 범위에 포함하지 않는다.
완료 확인:
- 사진·가격/나눔·상태가 목록과 상세에 일치한다.
- 찜을 두 번 눌러도 중복 레코드가 생기지 않는다.
- 채팅 요청이 저장되고 게시자가 확인할 수 있다.
- 다른 직원은 내 게시글의 상태를 변경할 수 없다.
- 저장 오류이면 성공 안내 대신 입력 내용을 유지한다. 08 · 마켓 ERD
게시글·사진·찜·채팅 요청을 작성자와 연결합니다.
ERD는 저장할 기록과 관계를 보여줍니다. PK는 기록 번호, FK는 다른 기록을 가리키는 번호입니다.
직원 7번이 올린 게시글 42번을 직원 9번이 찜하고 채팅 요청했다고 가정합니다. 게시글의 작성자와 관심 직원을 한 칸에 넣지 않고 각각 연결합니다. 찜·요청이 생겨도 거래 상태는 판매자가 확인할 때 바뀝니다.
market-erd.md · 관계와 예시 데이터
# 사내 마켓 · 기록과 관계 예시
PK는 각 기록 번호, FK는 다른 기록을 가리키는 번호다.
users(id PK, name)
posts(id PK, seller_id FK→users, title, description,
kind: sale|giveaway, price, status: available|reserved|completed)
stored_files(id PK, owner_id FK→users, storage_provider, bucket, object_key)
post_photos(id PK, post_id FK→posts, stored_file_id FK→stored_files, order_no)
favorites(user_id FK→users, post_id FK→posts)
chat_requests(id PK, post_id FK→posts, requester_id FK→users, created_at)
status_events(id PK, post_id FK→posts, from_status, to_status, actor_id FK→users, created_at)
직원 1명 → 여러 게시글 / 게시글 1개 → 여러 사진·찜·채팅 요청·상태 변경 기록
favorites는 (user_id, post_id)를 중복 없이 보관한다.
chat_requests도 (post_id, requester_id)를 중복 없이 보관한다.
예시:
users: id=7 김직원 / id=9 박직원
posts: id=42, seller_id=7, title=사무용 의자,
kind=sale, price=15000, status=available
stored_files: id=12, owner_id=7, storage_provider=s3,
bucket=수업용 비공개 버킷, object_key=images/chair.jpg
post_photos: id=61, post_id=42, stored_file_id=12, order_no=1
favorites: user_id=9, post_id=42
chat_requests: id=101, post_id=42, requester_id=9
나눔 게시글 예: kind=giveaway, price=0.
채팅 요청이 생겨도 posts.status는 여전히 available이다.
판매자가 예약을 확인하면 status를 reserved로 바꾸고 status_events를 함께 저장한다. 09 · 마켓 API
등록·찜·채팅 요청·거래 상태의 응답을 맞춥니다.
API 명세는 화면과 서버가 어떤 입력과 결과를 주고받을지 정리합니다. 아래는 구현할 약속이며 실제 실행한 응답이 아닙니다.
사진 등록을 준비한 뒤 게시글에 연결하고, 저장된 번호로 목록·상세를 다시 읽습니다. 작성자·관심 직원의 번호는 브라우저에서 마음대로 지정하지 않고 서버가 로그인 정보로 확인합니다. 채팅 요청의 저장과 게시자의 확인 경로까지 함께 설계합니다.
market-api.md · 요청·응답 예시
# 사내 마켓 · API 명세 예시
구현할 약속이며 실제 실행 응답이 아니다. JSON 필드와 권한은 Go에서 확인한다.
모든 동작은 로그인한 직원 계정으로 처리한다. 작성자 ID는 서버가 결정한다.
POST /api/uploads
사진 파일 업로드 → 201 {"fileId":12,"url":"/api/files/12"}
파일 종류·크기·소유자 검사는 서버에서 한다. 허용 기준은 팀이 정한다.
S3를 쓰는 예시: 서버가 파일 업로드를 확인하고 DB에 객체 키·소유자를 기록한 뒤 fileId를 반환.
사진 원본이 아니라 객체 키·파일 번호를 게시글과 연결한다.
GET /api/files/12 → 서버가 로그인·접근 권한 확인 후 파일 또는 만료되는 보기 주소 제공.
업로드 직후 미연결 사진은 소유자만 접근하고, 게시글에 연결된 뒤에는 해당 게시글의 열람 권한을 따른다.
API url은 우리 서버의 안정된 파일 경로이며 공개 S3 주소나 영구적인 임시 링크라는 뜻이 아니다.
POST /api/posts
보냄:
{"title":"사무용 의자","description":"직접 전달 가능합니다","kind":"sale","price":15000,"photoIds":[12]}
201:
{"id":42,"status":"available"}
필수 제목·사진 누락, 판매/나눔 가격 규칙 불일치이면 400.
사진은 현재 직원이 등록한 파일만 연결한다.
GET /api/posts → 200 게시글 목록
GET /api/posts/42 → 200
{"id":42,"sellerId":7,"title":"사무용 의자","kind":"sale","price":15000,"status":"available","photos":["/api/files/12"],"isFavorited":false}
PATCH /api/posts/42
게시자만 제목·내용·판매/나눔·가격·사진을 수정. 등록과 같은 입력 검증.
보냄: {"title":"사무용 의자 나눔","kind":"giveaway","price":0}
200 {"id":42,"kind":"giveaway","price":0,"status":"available"}
거래 상태 변경은 아래 status 경로로 분리한다.
PUT /api/posts/42/favorite → 200 {"favorited":true}
DELETE /api/posts/42/favorite → 200 {"favorited":false}
같은 요청을 반복해도 찜 기록은 중복되지 않는다.
POST /api/posts/42/chat-requests
보냄: {}
201 {"id":101,"postId":42,"status":"requested"}
이미 요청했으면 기존 요청 번호를 200으로 돌려준다.
게시자 본인의 글 또는 거래완료 글에는 요청을 만들지 않는다(400).
GET /api/posts/42/chat-requests → 게시자만 요청 목록 조회.
PATCH /api/posts/42/status
보냄: {"status":"reserved"}
200 {"id":42,"status":"reserved"}
게시자만 변경. available→reserved→completed,
예약 취소 reserved→available을 허용하는 초안.
허용하지 않은 변경은 400. 변경과 상태 이력은 함께 저장한다.
로그인 없음 401 / 남의 글 변경·연락 요청 목록 조회 403 / 없는 게시글 404.
저장 실패 500과 {"error":{"code":"save_failed"}}.
실패 시 작성 내용을 유지하고 성공 안내를 하지 않는다. 10 · 동호회 PRD
운영자와 멤버의 행동을 따로 정합니다.
가입·공지·일정·참석 응답·연간 횟수를 전체 범위로 유지하고 소개·신청·승인부터 연결합니다.
예시 운영 규칙은 해당 동호회 안에서만 적용합니다. 소개를 어디까지 공개할지, 멤버별 횟수를 본인과 운영자만 볼지, 실제 참석을 누가 기록할지는 팀이 확인할 질문입니다.
club-prd.md · 수업용 완성 초안
# 동호회 관리 · PRD 초안
수업용 제안. 공개 범위·운영 권한·집계 기준은 팀이 확인 후 확정한다.
사용자: 가입 전 직원, 승인된 멤버, 해당 동호회 운영자.
목표 UX: 소개를 보고 가입한 뒤 공지·일정을 확인하고 참석 기록을 찾는다.
전체 기능:
- 동호회 소개, 가입 신청과 운영자의 승인
- 동호회별 공지 등록
- 모임 일정 등록, 멤버의 참석/불참 응답
- 멤버별 연간 실제 참석 횟수 조회
권한 초안:
- 직원은 소개를 보고 본인 계정으로 가입 신청한다.
- 운영자는 자기 동호회의 신청 승인·공지·일정·실제 참석 확인을 담당한다.
- 승인 멤버는 해당 동호회의 공지·일정을 보고 본인의 참석/불참을 응답한다.
- 일반 멤버는 다른 사람의 승인·실제 참석 기록을 바꾸지 않는다.
데이터 규칙 초안:
- 같은 사람의 같은 동호회 가입 신청은 중복되지 않는다.
- 참석 의사 응답과 실제 참석 확인 기록을 분리한다.
- 같은 멤버·모임의 실제 참석은 한 번만 센다.
- 완료된 모임의 실제 참석=true인 기록을 해당 연도로 집계한다.
- 취소·미래 모임이나 단순 참석 응답은 실제 참석 횟수에 넣지 않는다.
첫 구현: 소개 조회 → 가입 신청 → 운영자 승인 → 본인 가입 상태 재조회.
후속 구현: 공지·모임·응답·실제 참석·연간 조회. 전체 요구에서 삭제하지 않는다.
완료 확인: 승인 전후 표시와 접근 범위가 바뀌고, 다른 동호회 운영 권한이 섞이지 않는다.
참석 응답만으로 연간 횟수가 올라가지 않고 실제 참석을 기록·수정하면 집계도 바뀐다. 11 · 동호회 ERD
가입 상태·참석 의사·실제 참석은 다른 기록입니다.
한 직원이 여러 동호회에 가입하고 한 동호회가 여러 모임을 열 수 있습니다. 가입·모임·참석 기록으로 관계를 연결합니다.
동호회 5번의 모임 81번에 직원 7번이 참석하겠다고 응답한 뒤 실제 참석을 확인했다고 가정합니다. 의사 응답만 있으면 실제 횟수는 아직 0회이고, 완료 모임의 실제 참석 기록이 있으면 1회입니다. 이미지의 다른 횟수는 별개의 가상 예시입니다.
club-erd.md · 관계와 예시 데이터
# 동호회 · 기록과 관계 예시
users(id PK, name)
clubs(id PK, name, description, manager_id FK→users)
memberships(id PK, club_id FK→clubs, user_id FK→users,
status: pending|approved)
notices(id PK, club_id FK→clubs, author_id FK→users, title, body)
events(id PK, club_id FK→clubs, title, starts_at, ends_at,
status: scheduled|completed|cancelled)
rsvps(event_id FK→events, user_id FK→users, response: attending|declined)
attendance_records(event_id FK→events, user_id FK→users,
attended, checked_by FK→users)
직원 여러 명 ↔ 여러 동호회: memberships로 연결.
동호회 1개 → 여러 공지·모임 / 모임 1개 → 여러 의사 응답·실제 참석 기록.
memberships는 (club_id,user_id), rsvps와 attendance_records는 (event_id,user_id)가 중복되지 않음.
예시:
users: id=7 김직원 / id=9 이운영자
clubs: id=5, name=등산 동호회, manager_id=9
memberships: id=21, club_id=5, user_id=7, status=approved
notices: id=51, club_id=5, title=가을 모임 안내
events: id=81, club_id=5, starts_at=2026-10-25T09:00:00+09:00, ends_at=2026-10-25T12:00:00+09:00, status=completed
rsvps: event_id=81, user_id=7, response=attending
attendance_records: event_id=81, user_id=7, attended=true, checked_by=9
위 예시 모임만 있으면 2026년 참석 횟수는 1회다.
단순 rsvp=attending만 있고 실제 참석 기록이 없으면 실제 횟수는 0회다.
연간 횟수는 중복으로 저장하는 누적 숫자보다 원래 참석 기록으로 계산한다.
수업용 연도 기준은 Asia/Seoul에서 모임 시작일이 속한 연도다. 12 · 동호회 API
가입 승인과 연간 집계도 같은 모임·멤버를 가리켜야 합니다.
Go가 로그인·동호회 소속·운영 권한을 확인합니다. 일반 멤버는 자기 참석 의사만 바꾸고 실제 참석은 운영자가 기록하는 초안입니다.
모임 전 참석 응답 API와 모임 후 실제 참석 API를 나눕니다. 횟수 조회는 원래 실제 참석 기록을 집계하므로 기록을 정정하면 결과도 바뀝니다. 예시에서는 모임 시작일을 한국 시간으로 해석한 연도를 사용합니다.
club-api.md · 요청·응답 예시
# 동호회 관리 · API 명세 예시
구현할 약속이며 실제 실행 응답이 아니다. 로그인과 해당 동호회의 권한을 Go에서 확인한다.
GET /api/clubs/5 → 200
{"id":5,"name":"등산 동호회","description":"함께 걷는 모임","membershipStatus":null}
POST /api/clubs/5/join-requests
보냄: {}
201 {"membershipId":21,"status":"pending"}
신청한 직원은 서버 로그인 정보에서 결정. 이미 신청한 경우 기존 상태를 200으로 반환.
GET /api/clubs/5/memberships?status=pending
해당 동호회 운영자만 대기 신청 목록 조회.
200 [{"membershipId":21,"userId":7,"name":"김직원","status":"pending"}]
GET /api/clubs/5/members
운영자만 승인 멤버 목록을 조회해 실제 참석·연간 조회 대상을 선택.
200 [{"membershipId":21,"userId":7,"name":"김직원","status":"approved"}]
PATCH /api/clubs/5/memberships/21
보냄: {"status":"approved"}
해당 동호회 운영자만 승인.
200 {"membershipId":21,"clubId":5,"status":"approved"}
POST /api/clubs/5/notices
보냄: {"title":"가을 모임 안내","body":"공지 내용"}
운영자만 등록 → 201 {"id":51}
GET /api/clubs/5/notices → 승인 멤버·운영자에게 200 공지 목록.
POST /api/clubs/5/events
보냄: {"title":"정기 등산","startsAt":"2026-11-15T09:00:00+09:00","endsAt":"2026-11-15T12:00:00+09:00"}
운영자만 등록 → 201 {"id":82,"status":"scheduled"}
GET /api/clubs/5/events → 승인 멤버·운영자에게 200 일정 목록.
PUT /api/events/82/rsvp
보냄: {"response":"attending"} 또는 {"response":"declined"}
해당 동호회의 승인 멤버 본인 응답만 변경.
200 {"eventId":82,"response":"attending"}
참석 의사만 저장하며 실제 참석 횟수는 올리지 않는다.
PATCH /api/events/81/status
보냄: {"status":"completed"}
운영자가 모임 종료 후 완료로 변경(endsAt이 현재보다 미래면 거절).
PUT /api/events/81/attendance/7
보냄: {"attended":true}
운영자만 해당 멤버의 실제 참석을 기록·수정.
모임 완료 여부·멤버 소속을 검사 → 200 {"eventId":81,"userId":7,"attended":true}
GET /api/clubs/5/members/7/attendance?year=2026
본인 또는 운영자만 조회.
200 {"year":2026,"attendedCount":1}
완료된 모임의 실제 참석 기록을 모임 시작일의 연도(수업 초안 Asia/Seoul)로 계산.
다른 승인 멤버는 본인 화면에서 자기 횟수를 확인한다.
로그인 없음 401 / 다른 동호회 운영·다른 사람 기록 변경 403 / 없는 기록 404.
날짜·응답 값·소속 오류 400 / 저장 실패 500과 error.code=save_failed.
실패 시 입력·기존 응답을 유지하고 성공으로 표시하지 않는다. 13 · 함께 개발 시작
한 주제를 골라 저장한 결과를 다시 읽습니다.
강사도 교육생과 같은 시점에 시작합니다. 본 강의는 방향을, 복습의 문서는 실제 구현을 도울 참고를 제공합니다.
- 01
주제와 목표 화면 선택
왜 하나요? 처음 사용해 볼 직원과 행동을 정합니다.
할 일 마켓 또는 동호회를 고르고 사용자·불편·목표 UX·화면을 맞춥니다.
정상이라면 선택한 이유와 첫 사용 순서를 설명할 수 있습니다.
- 02
PRD·ERD·API 초안 확인
왜 하나요? 같은 데이터와 상태를 사용합니다.
할 일 마켓은 거래 상태, 동호회는 가입·응답·실제 참석 기준을 확인합니다.
정상이라면 누가 어떤 기록을 바꾸는지 세 문서가 일치합니다.
- 03
첫 흐름 구현·재조회
왜 하나요? 화면 성공 표시와 실제 저장을 구분합니다.
할 일 Vite + React·Go·선택 DB로 첫 흐름을 만들고 새로고침·다른 계정으로 확인합니다.
정상이라면 등록 글 또는 가입 상태가 유지되고 권한 없는 변경은 차단됩니다.
- 04
후속 기능 나누기
왜 하나요? 전체 요구를 빠뜨리지 않고 이어갑니다.
할 일 찜·채팅 요청·상태 또는 공지·일정·참석·연간 조회의 시작 조건과 담당을 적습니다.
정상이라면 완료·미완료·시안 상태를 구분한 다음 작업 목록이 남습니다.
마지막 확인
w09-project-design.md
선택한 주제의 결정·실행 결과를 기록하세요. 뒤에는 두 후보의 완성 초안을 함께 넣었습니다.
# 9주차 · 사내 마켓 또는 동호회 관리
선택 주제 / 선택 이유: [내용]
확인한 사용자 불편 / 수업용 가정: [각각]
목표 UX / 역할별 화면 / 첫 사용 순서: [내용]
첫 구현 / 후속 기능과 담당: [내용]
상태·권한·중복·실패 규칙: [내용]
DB 선택 / 보존·백업 이유: [내용]
사진 원본 저장 위치 / DB의 파일 연결 정보: [내용]
사진 업로드·게시글 저장 실패 시 안내: [내용]
프로젝트 위치 / README 실행 순서: [내용]
등록·재조회·다른 계정 확인 결과: [내용]
완료 / 미완료 / 시안 여부: [각각]
## 마켓 PRD
# 사내 나눔·중고 마켓 · PRD 초안
수업용 제안. 실제 회사의 규칙은 확인 후 확정한다.
사용자: 직원(게시자 또는 관심 직원).
목표 UX: 사진·가격/나눔·상태를 보고 물건을 찾고 게시자에게 연락한다.
전체 기능:
- 사진·제목·설명·판매/나눔 구분·가격을 포함한 게시글 등록
- 목록·상세 조회, 찜하기/취소, 채팅 요청
- 판매중 → 예약중 → 거래완료, 예약 취소 시 판매중 복귀
역할:
- 직원은 게시글을 조회하고 자기 계정으로 찜·채팅 요청한다.
- 게시글 수정·거래 상태 변경은 게시자만 한다.
- 채팅 요청을 보냈다는 이유로 예약 상태를 자동 변경하지 않는다.
입력 규칙 초안:
- 판매는 0보다 큰 가격, 나눔은 가격 0으로 저장한다.
- 사진은 등록한 파일을 서버가 확인한 뒤 연결한다.
- 같은 직원의 같은 게시글 찜·채팅 요청은 중복 생성하지 않는다.
첫 구현: 사진 포함 등록 → 목록 → 상세 → 새로고침 후 재조회.
후속 구현: 찜·채팅 요청·거래 상태 관리. 전체 요구에서 삭제하지 않는다.
채팅 요청은 연락 요청 기록과 게시자의 확인까지다.
실시간 메시지 송수신·결제·배송은 이번 범위에 포함하지 않는다.
완료 확인:
- 사진·가격/나눔·상태가 목록과 상세에 일치한다.
- 찜을 두 번 눌러도 중복 레코드가 생기지 않는다.
- 채팅 요청이 저장되고 게시자가 확인할 수 있다.
- 다른 직원은 내 게시글의 상태를 변경할 수 없다.
- 저장 오류이면 성공 안내 대신 입력 내용을 유지한다.
## 마켓 ERD
# 사내 마켓 · 기록과 관계 예시
PK는 각 기록 번호, FK는 다른 기록을 가리키는 번호다.
users(id PK, name)
posts(id PK, seller_id FK→users, title, description,
kind: sale|giveaway, price, status: available|reserved|completed)
stored_files(id PK, owner_id FK→users, storage_provider, bucket, object_key)
post_photos(id PK, post_id FK→posts, stored_file_id FK→stored_files, order_no)
favorites(user_id FK→users, post_id FK→posts)
chat_requests(id PK, post_id FK→posts, requester_id FK→users, created_at)
status_events(id PK, post_id FK→posts, from_status, to_status, actor_id FK→users, created_at)
직원 1명 → 여러 게시글 / 게시글 1개 → 여러 사진·찜·채팅 요청·상태 변경 기록
favorites는 (user_id, post_id)를 중복 없이 보관한다.
chat_requests도 (post_id, requester_id)를 중복 없이 보관한다.
예시:
users: id=7 김직원 / id=9 박직원
posts: id=42, seller_id=7, title=사무용 의자,
kind=sale, price=15000, status=available
stored_files: id=12, owner_id=7, storage_provider=s3,
bucket=수업용 비공개 버킷, object_key=images/chair.jpg
post_photos: id=61, post_id=42, stored_file_id=12, order_no=1
favorites: user_id=9, post_id=42
chat_requests: id=101, post_id=42, requester_id=9
나눔 게시글 예: kind=giveaway, price=0.
채팅 요청이 생겨도 posts.status는 여전히 available이다.
판매자가 예약을 확인하면 status를 reserved로 바꾸고 status_events를 함께 저장한다.
## 마켓 API
# 사내 마켓 · API 명세 예시
구현할 약속이며 실제 실행 응답이 아니다. JSON 필드와 권한은 Go에서 확인한다.
모든 동작은 로그인한 직원 계정으로 처리한다. 작성자 ID는 서버가 결정한다.
POST /api/uploads
사진 파일 업로드 → 201 {"fileId":12,"url":"/api/files/12"}
파일 종류·크기·소유자 검사는 서버에서 한다. 허용 기준은 팀이 정한다.
S3를 쓰는 예시: 서버가 파일 업로드를 확인하고 DB에 객체 키·소유자를 기록한 뒤 fileId를 반환.
사진 원본이 아니라 객체 키·파일 번호를 게시글과 연결한다.
GET /api/files/12 → 서버가 로그인·접근 권한 확인 후 파일 또는 만료되는 보기 주소 제공.
업로드 직후 미연결 사진은 소유자만 접근하고, 게시글에 연결된 뒤에는 해당 게시글의 열람 권한을 따른다.
API url은 우리 서버의 안정된 파일 경로이며 공개 S3 주소나 영구적인 임시 링크라는 뜻이 아니다.
POST /api/posts
보냄:
{"title":"사무용 의자","description":"직접 전달 가능합니다","kind":"sale","price":15000,"photoIds":[12]}
201:
{"id":42,"status":"available"}
필수 제목·사진 누락, 판매/나눔 가격 규칙 불일치이면 400.
사진은 현재 직원이 등록한 파일만 연결한다.
GET /api/posts → 200 게시글 목록
GET /api/posts/42 → 200
{"id":42,"sellerId":7,"title":"사무용 의자","kind":"sale","price":15000,"status":"available","photos":["/api/files/12"],"isFavorited":false}
PATCH /api/posts/42
게시자만 제목·내용·판매/나눔·가격·사진을 수정. 등록과 같은 입력 검증.
보냄: {"title":"사무용 의자 나눔","kind":"giveaway","price":0}
200 {"id":42,"kind":"giveaway","price":0,"status":"available"}
거래 상태 변경은 아래 status 경로로 분리한다.
PUT /api/posts/42/favorite → 200 {"favorited":true}
DELETE /api/posts/42/favorite → 200 {"favorited":false}
같은 요청을 반복해도 찜 기록은 중복되지 않는다.
POST /api/posts/42/chat-requests
보냄: {}
201 {"id":101,"postId":42,"status":"requested"}
이미 요청했으면 기존 요청 번호를 200으로 돌려준다.
게시자 본인의 글 또는 거래완료 글에는 요청을 만들지 않는다(400).
GET /api/posts/42/chat-requests → 게시자만 요청 목록 조회.
PATCH /api/posts/42/status
보냄: {"status":"reserved"}
200 {"id":42,"status":"reserved"}
게시자만 변경. available→reserved→completed,
예약 취소 reserved→available을 허용하는 초안.
허용하지 않은 변경은 400. 변경과 상태 이력은 함께 저장한다.
로그인 없음 401 / 남의 글 변경·연락 요청 목록 조회 403 / 없는 게시글 404.
저장 실패 500과 {"error":{"code":"save_failed"}}.
실패 시 작성 내용을 유지하고 성공 안내를 하지 않는다.
## 동호회 PRD
# 동호회 관리 · PRD 초안
수업용 제안. 공개 범위·운영 권한·집계 기준은 팀이 확인 후 확정한다.
사용자: 가입 전 직원, 승인된 멤버, 해당 동호회 운영자.
목표 UX: 소개를 보고 가입한 뒤 공지·일정을 확인하고 참석 기록을 찾는다.
전체 기능:
- 동호회 소개, 가입 신청과 운영자의 승인
- 동호회별 공지 등록
- 모임 일정 등록, 멤버의 참석/불참 응답
- 멤버별 연간 실제 참석 횟수 조회
권한 초안:
- 직원은 소개를 보고 본인 계정으로 가입 신청한다.
- 운영자는 자기 동호회의 신청 승인·공지·일정·실제 참석 확인을 담당한다.
- 승인 멤버는 해당 동호회의 공지·일정을 보고 본인의 참석/불참을 응답한다.
- 일반 멤버는 다른 사람의 승인·실제 참석 기록을 바꾸지 않는다.
데이터 규칙 초안:
- 같은 사람의 같은 동호회 가입 신청은 중복되지 않는다.
- 참석 의사 응답과 실제 참석 확인 기록을 분리한다.
- 같은 멤버·모임의 실제 참석은 한 번만 센다.
- 완료된 모임의 실제 참석=true인 기록을 해당 연도로 집계한다.
- 취소·미래 모임이나 단순 참석 응답은 실제 참석 횟수에 넣지 않는다.
첫 구현: 소개 조회 → 가입 신청 → 운영자 승인 → 본인 가입 상태 재조회.
후속 구현: 공지·모임·응답·실제 참석·연간 조회. 전체 요구에서 삭제하지 않는다.
완료 확인: 승인 전후 표시와 접근 범위가 바뀌고, 다른 동호회 운영 권한이 섞이지 않는다.
참석 응답만으로 연간 횟수가 올라가지 않고 실제 참석을 기록·수정하면 집계도 바뀐다.
## 동호회 ERD
# 동호회 · 기록과 관계 예시
users(id PK, name)
clubs(id PK, name, description, manager_id FK→users)
memberships(id PK, club_id FK→clubs, user_id FK→users,
status: pending|approved)
notices(id PK, club_id FK→clubs, author_id FK→users, title, body)
events(id PK, club_id FK→clubs, title, starts_at, ends_at,
status: scheduled|completed|cancelled)
rsvps(event_id FK→events, user_id FK→users, response: attending|declined)
attendance_records(event_id FK→events, user_id FK→users,
attended, checked_by FK→users)
직원 여러 명 ↔ 여러 동호회: memberships로 연결.
동호회 1개 → 여러 공지·모임 / 모임 1개 → 여러 의사 응답·실제 참석 기록.
memberships는 (club_id,user_id), rsvps와 attendance_records는 (event_id,user_id)가 중복되지 않음.
예시:
users: id=7 김직원 / id=9 이운영자
clubs: id=5, name=등산 동호회, manager_id=9
memberships: id=21, club_id=5, user_id=7, status=approved
notices: id=51, club_id=5, title=가을 모임 안내
events: id=81, club_id=5, starts_at=2026-10-25T09:00:00+09:00, ends_at=2026-10-25T12:00:00+09:00, status=completed
rsvps: event_id=81, user_id=7, response=attending
attendance_records: event_id=81, user_id=7, attended=true, checked_by=9
위 예시 모임만 있으면 2026년 참석 횟수는 1회다.
단순 rsvp=attending만 있고 실제 참석 기록이 없으면 실제 횟수는 0회다.
연간 횟수는 중복으로 저장하는 누적 숫자보다 원래 참석 기록으로 계산한다.
수업용 연도 기준은 Asia/Seoul에서 모임 시작일이 속한 연도다.
## 동호회 API
# 동호회 관리 · API 명세 예시
구현할 약속이며 실제 실행 응답이 아니다. 로그인과 해당 동호회의 권한을 Go에서 확인한다.
GET /api/clubs/5 → 200
{"id":5,"name":"등산 동호회","description":"함께 걷는 모임","membershipStatus":null}
POST /api/clubs/5/join-requests
보냄: {}
201 {"membershipId":21,"status":"pending"}
신청한 직원은 서버 로그인 정보에서 결정. 이미 신청한 경우 기존 상태를 200으로 반환.
GET /api/clubs/5/memberships?status=pending
해당 동호회 운영자만 대기 신청 목록 조회.
200 [{"membershipId":21,"userId":7,"name":"김직원","status":"pending"}]
GET /api/clubs/5/members
운영자만 승인 멤버 목록을 조회해 실제 참석·연간 조회 대상을 선택.
200 [{"membershipId":21,"userId":7,"name":"김직원","status":"approved"}]
PATCH /api/clubs/5/memberships/21
보냄: {"status":"approved"}
해당 동호회 운영자만 승인.
200 {"membershipId":21,"clubId":5,"status":"approved"}
POST /api/clubs/5/notices
보냄: {"title":"가을 모임 안내","body":"공지 내용"}
운영자만 등록 → 201 {"id":51}
GET /api/clubs/5/notices → 승인 멤버·운영자에게 200 공지 목록.
POST /api/clubs/5/events
보냄: {"title":"정기 등산","startsAt":"2026-11-15T09:00:00+09:00","endsAt":"2026-11-15T12:00:00+09:00"}
운영자만 등록 → 201 {"id":82,"status":"scheduled"}
GET /api/clubs/5/events → 승인 멤버·운영자에게 200 일정 목록.
PUT /api/events/82/rsvp
보냄: {"response":"attending"} 또는 {"response":"declined"}
해당 동호회의 승인 멤버 본인 응답만 변경.
200 {"eventId":82,"response":"attending"}
참석 의사만 저장하며 실제 참석 횟수는 올리지 않는다.
PATCH /api/events/81/status
보냄: {"status":"completed"}
운영자가 모임 종료 후 완료로 변경(endsAt이 현재보다 미래면 거절).
PUT /api/events/81/attendance/7
보냄: {"attended":true}
운영자만 해당 멤버의 실제 참석을 기록·수정.
모임 완료 여부·멤버 소속을 검사 → 200 {"eventId":81,"userId":7,"attended":true}
GET /api/clubs/5/members/7/attendance?year=2026
본인 또는 운영자만 조회.
200 {"year":2026,"attendedCount":1}
완료된 모임의 실제 참석 기록을 모임 시작일의 연도(수업 초안 Asia/Seoul)로 계산.
다른 승인 멤버는 본인 화면에서 자기 횟수를 확인한다.
로그인 없음 401 / 다른 동호회 운영·다른 사람 기록 변경 403 / 없는 기록 404.
날짜·응답 값·소속 오류 400 / 저장 실패 500과 error.code=save_failed.
실패 시 입력·기존 응답을 유지하고 성공으로 표시하지 않는다. 완료 체크
이 6가지가 보이면 오늘의 목표를 달성한 것입니다
- 두 후보 중 하나를 골랐고 요청된 전체 기능을 후속 계획에 남겼습니다.
- 역할별 목표 화면과 UX·입력 오류·상태 안내를 정했습니다.
- 마켓의 연락과 예약, 동호회의 의사 응답과 실제 참석을 구분했습니다.
- PRD·ERD·API가 같은 사용자·기록·상태·권한을 가리킵니다.
- 저장·재조회·중복 요청·다른 계정의 접근을 확인한 범위대로 기록했습니다.
- 사진 원본을 보관할 곳과 DB에서 사진을 연결할 정보를 나눠 정했습니다.
다음 단계
다음에는 다른 사람이 실제로 사용하며 확인합니다
선택한 서비스의 후속 기능을 이어 붙이고, 만든 사람의 설명 없이도 실행·이용할 수 있는지 확인합니다.
막힐 때
자주 나오는 질문
두 서비스를 모두 이번 수업에 완성하나요?
한 주제를 선택해 설계와 첫 흐름을 시작합니다. 두 후보의 전체 예시는 제공하지만 90~100분에 전체 기능 완성을 전제하지 않습니다.
채팅 요청이면 실시간 채팅도 만들어야 하나요?
우선은 게시자에게 연락 요청을 남기고 게시자가 확인하는 범위입니다. 실제 메시지를 주고받는 기능이 필요하면 별도로 요구와 범위를 정합니다.
연간 참석 횟수는 참석 버튼을 누른 횟수인가요?
초안은 완료된 모임의 실제 참석 기록을 셉니다. 참석 의사와 실제 참석은 다를 수 있으므로 회사에서 원하는 집계 기준을 먼저 확인합니다.
AI 모델 API 키가 필요한가요?
이번 필수 기능에는 특정 AI 모델 호출을 넣지 않습니다. AI 코딩 도구로 설계·구현을 도울 수 있지만 자동 분류 기능이나 별도 모델 API 키를 준비할 필요는 없습니다.
이미지 속 화면이 이미 구현됐나요?
사용자 제공 구상도와 AI 생성 시안입니다. 실제 저장·가입 승인·집계가 동작하는지는 수업에서 구현한 뒤 별도로 확인합니다.