이번 글에서는 RAG가 무엇인지, 그리고 이걸 Java와 Spring을 활용한 콜봇 서비스에 어떻게 적용할 수 있는지 정리해보겠습니다.

1. RAG란?

RAG는 Retrieval-Augmented Generation, 즉 "검색 기반 생성"이라는 의미입니다.

기존의 챗봇은 GPT 같은 대규모 언어 모델(LLM)을 그대로 사용해 질문에 답변하지만, RAG는 여기에 하나를 더 얹습니다.

바로, 외부 지식을 먼저 검색하고 이를 바탕으로 답변을 생성하는 구조입니다.

 

예를 들어 누군가가 “콜봇 개발에서 가장 어려운 점은 뭔가요?”라고 묻는다면, GPT는 일반적인 상식을 바탕으로 답할 수도 있지만, RAG는 먼저 관련 문서를 찾아보고 그 내용을 기반으로 응답을 생성합니다.

RAG의 구조는 보통 다음과 같은 세 단계로 구성됩니다:

  1. Query Encoder: 사용자의 질문을 벡터(임베딩)로 변환
  2. Retriever: 이 벡터를 기반으로 벡터 DB에서 관련 문서를 검색
  3. Generator: 검색된 문서 + 원 질문을 가지고 GPT가 답변 생성

이 덕분에 최신 정보나 도메인 특화 지식을 반영한 답변이 가능해집니다.

 

2. 이걸 콜봇에 어떻게 적용할 수 있을까?

콜봇은 기본적으로 사용자의 음성 질문을 텍스트로 변환(STT) 하고, 여기에 답변한 내용을 다시 음성으로(TTS) 바꿔주는 시스템입니다. 여기에 RAG를 붙이면, 단순 규칙 기반 응답 대신 실제 문서를 참조한 정교한 답변이 가능해집니다.

흐름은 이렇습니다:

  1. 사용자가 “택배는 언제 도착하나요?”라고 말함
  2. 콜봇이 이를 음성 → 텍스트(STT) 로 변환
  3. 텍스트를 RAG 서버에 전달하여 답변 생성
  4. 답변 텍스트를 텍스트 → 음성(TTS) 으로 변환
  5. 다시 전화를 통해 음성으로 응답

 

3. Java + Spring으로 구성한 시스템 흐름

Spring 기반 백엔드에서는 보통 Twilio 같은 음성 API 서비스를 통해 전화를 수신받고, Webhook으로 음성 URL을 전달받습니다.

그다음의 흐름은 아래와 같습니다:

  • Controller가 Twilio Webhook을 받아 음성 녹음 URL 추출
  • Service가 STT API를 통해 텍스트로 변환
  • RAG 서버에 질문을 전달하고 응답 수신
  • TTS API로 답변을 음성화 후 Twilio에 전달

예시 코드는 아래와 같습니다:

@RestController
@RequestMapping("/api/twilio")
public class TwilioWebhookController {
  @Autowired
  private CallService callService;

  @PostMapping("/voice")
  public ResponseEntity<String> handleVoice(@RequestParam Map<String,String> params) {
    String responseXml = callService.processCall(params);
    return ResponseEntity.ok()
      .contentType(MediaType.APPLICATION_XML)
      .body(responseXml);
  }
}
@Service
public class CallService {
  public String processCall(Map<String,String> params) {
    String recordingUrl = params.get("RecordingUrl");
    String transcript = speechToText(recordingUrl);
    String answer = ragQuery(transcript);
    return textToSpeechTwiML(answer);
  }
}

 

 

4. RAG 서버는 어떻게 구성할까?

RAG 서버는 보통 Python의 FlaskFastAPI로 구현하고, 내부적으로는 다음과 같은 구성입니다:

  • Embedding: OpenAI API 등을 활용해 질문 벡터화
  • Vector DB: FAISS, Chroma, Weaviate 등을 활용한 유사 문서 검색
  • LLM: GPT API 등을 통해 검색 문서 + 질문을 바탕으로 응답 생성

이걸 Docker로 묶고 Kubernetes로 배포하면 확장성 있는 구조를 만들 수 있습니다.

  • 모니터링: Prometheus + Grafana
  • 로깅/추적: ELK Stack, Zipkin 등

 

5. 마무리하며

이번 작업을 통해 RAG가 LLM의 한계를 어떻게 보완하는지, 그리고 Java 기반 시스템에 어떻게 적용할 수 있는지에 대해 감을 잡을 수 있었습니다.

다만 아직 벡터 DB 성능 최적화, 문서 chunking 전략, 그리고 프롬프트 설계 같은 세부적인 부분은 더 학습이 필요하다고 느꼈습니다. 실제 운영에 적용하려면 이런 부분들을 더 깊이 이해하고 개선해나가야 할 것 같습니다.

요즘 백엔드 개발자에게 요구되는 역량은 예전과 다르다.
그저 CRUD 잘하고 API 몇 개 붙일 줄 아는 걸로는 경쟁력이 되지 않는다.
AI 기술을 활용하는 능력, 복잡한 시스템 구조를 이해하고 설계할 수 있는 감각,
그리고 그것을 실제로 코드에 녹여낼 수 있는 실전 감각이 점점 더 중요해지고 있다.

얼마 전, 테디님의 특강을 듣고 나서
"AI 시대에 백엔드 개발자가 살아남으려면 어떻게 해야 할까?"
이 질문에 대해 나름의 로드맵을 그려볼 수 있었다.
이 글은 그 강의 내용을 바탕으로,
주니어 개발자가 어떤 순서로 기술을 익히고, 어떤 관점으로 성장해야 하는지를 정리한 기술 블로그다.

✔️ 기술 로드맵이 막연한 사람
✔️ AI 백엔드 개발자로 도약하고 싶은 사람
✔️ 기술 스택은 쌓고 있지만 방향성이 헷갈리는 사람

이런 사람이라면 이 글에서 실질적인 기준과 힌트를 얻을 수 있을 거라고 생각한다.


1. AI 시대 백엔드 개발자의 생존 전략

AI 백엔드 개발자는 단순히 GPT API를 호출하고 결과를 처리하는 수준을 넘어,
기초 역량 → AI 친화적 설계 → 운영 자동화까지 포괄하는 인재로 성장해야 한다.
테디님 특강에서는 이를 3단계 성장 구조로 나눠 설명했다.

✅ 3-Layer 구조

  • Layer 1: Fundamentals (0~6개월)
    자료구조, 네트워크, 보안, 인증, SQL 같은 흔들리지 않는 백엔드 기본기
  • Layer 2: AI-Aware (6~18개월)
    RAG 시스템, Vector Store, Agent 설계, 비동기 최적화 등
    → AI 기술을 구조에 녹여내는 실무 개발자 수준
  • Layer 3: DevOps / AI 운영 (18~36개월)
    데이터 파이프라인, LLMOps, 클라우드 인프라 스케일링 등
    → 대규모 AI 시스템의 운영과 최적화까지 다루는 단계

핵심 통찰: “각 Layer에서 하나의 도구만이라도 진지하게 마스터하면, 다음 확장은 빠르게 이어진다.”


2. AI-Aware 개발자로서의 핵심 프레임워크 & 기술

항목 설명 
RAG 시스템 벡터 검색 기반 문서 질의응답 시스템 설계
Agent 개발 복잡한 작업을 수행하는 AI 에이전트 설계 및 구현, 도구 활용과 의사결정 로직 구축
Vector Store 활용 ChromaDB, Pinecone 등으로 고성능 벡터 검색 구현
LangChain / LangGraph 기업에서 가장 많이 사용하는 AI 개발 프레임워크 숙련, 체인과 그래프 구성 능력
비동기 처리 FastAPI + async/await 구조로 대량 요청 대응
스트리밍 파이프라인 실시간 AI 응답과 데이터 스트리밍 구현, 이벤트 기반 아키텍처 설계
  • AI-Aware는 단순 프레임워크 사용을 넘어서 ML 모델을 어떻게 서빙하고, 어떤 구조로 운영할 것인가까지 고려하는 역할
  • 실제 주니어 로드맵도 위 순서 기반으로 설계돼 있고, 면접 시 비동기 처리와 RAG 구조 설명 능력이 큰 차이를 만든다

3. 실전 성장 포인트

  • 오픈소스 기여: GPT 도움 받아서라도 기여 경험을 만들어야 한다
  • TDD + 클린 코드 습관: 장기 유지보수 관점에서 중요
  • 깃헙 레포 관리: 리드미에 아키텍처 흐름과 기술 의도를 반드시 정리
  • 기술 블로그 운영: 글은 기술 설명보단, 설계 관점과 문제 해결 방식 중심
  • 아키텍처 고민 습관화:
    모놀리식 vs 마이크로서비스, 배포 전략, A2A 구조, MCP 보안 등
    → 그냥 설계하는 게 아니라 "왜 이렇게 설계했는가?"에 답할 수 있어야 한다

4. 각 Layer별 우선순위와 기술 체크리스트

🧱 Layer 1 - Fundamentals

  • 자료구조 / 알고리즘 → 벡터 DB 구조의 기반
  • HTTP / REST → 모델 서버 통신의 핵심
  • SQL / RDBMS → 피처 스토어 설계 필수
  • API 인증 / 보안 → OAuth2, JWT, HTTPS로 안전한 API 설계

⚙️ Layer 2 - AI-Aware

  • 비동기 I/O → async 구조로 LLM 트래픽 대응
  • 벡터 DB → Pinecone, ChromaDB
  • ML 모델 서빙 → FastAPI 기반 경량 모델 서빙도 고려
  • 오케스트레이션 → LangChain으로 구조화된 작업 흐름 구현

🧪 Layer 3 - DevOps

  • CI/CD → GitHub Actions, Canary 배포 전략
  • LLMOps → 프롬프트 관리, Token 추적, 비용 최적화
  • 인프라 운영 → 쿠버네티스, GPU 서빙, 멀티클라우드까지

5. 백엔드 취업을 위한 기술 우선순위 스택

🥇 핵심 역량 (1~4순위)

  1. Java & Spring
    Spring Boot, MVC, JPA, DI 구조 필수 습득
  2. DB 설계 & 최적화
    MySQL/PostgreSQL, 인덱싱, ORM, Redis 캐시
  3. AI 통합 설계
    OpenAPI + 벡터DB + RAG 구성 + REST API 확장
  4. DevOps / 클라우드 운영
    Docker, K8s, CI/CD, 자동화 배포 전략

🧠 차별화 역량 (5~8순위)

  1. 마이크로서비스 아키텍처 설계
  2. 보안 / 성능 모니터링 및 최적화
  3. 테스트 / 품질 관리 (TDD, Mockito, SonarQube 등)
  4. AI 특화 기술 (모델 서빙, MLFlow, TensorFlow Serving)

6. 내가 드린 질문과 피드백

- 인턴십 경험 기반 질문

비효율적인 수작업 프로세스를 관찰하고, 이를 기술로 자동화해 실무 적용까지 시도했던 경험이 있음. 이후 AI 기반 확장 아이디어도 구상 중. 이런 경험이 백엔드 개발자로서 메리트가 될 수 있을지 질문.

✅ 피드백

 

  • 충분히 강력한 차별화 포인트.
  • 포인트는 “개발한 것”이 아니라, 기존 문제를 정의하고 해결책을 주도적으로 설계했다는 점.
  • 다만, AI 도입 시 무분별한 오픈AI 의존보다는 로컬 모델부터 실험할 것
  • 컨셉 잡을 때 “회사가 인지하지 못한 위기”를 먼저 던지고, 그걸 기술로 해결한 스토리라인으로 정리하자.

 

 

- 포트폴리오 관련

❓ 포트폴리오 관련 조언을 구함

✅ 피드백
보안 관련 프로젝트 하나는 있으면 좋음
→ 특히 AI 시스템이나 LLM 기반 아키텍처를 다룰 때는, 프라이버시와 보안 요소까지 고려할 수 있는 사람이라는 인상을 줄 수 있음
→ 직접적인 구현까지 가지 않아도, 설계 관점에서 리스크 진단 및 대응 아이디어를 구조화하는 방식으로 풀어내도 충분함

 

- 추가 피드백

  • 깃헙 리드미는 꼭 채우고 잔디도 주기적으로 관리
  • 기술 블로그는 깃헙 연동하고, 제목과 흐름 명확히
  • 이력서/포트폴리오는 충분히 잘 돼 있음 → 깊이 확장하는 단계로 가자

7. 마무리

AI 백엔드 개발자는 단순히 툴을 "쓸 줄 아는 사람"이 아니라,
“어디에 왜 쓰는지를 설명할 수 있는 사람”이 되어야 한다.

하나의 도구만이라도 끝까지 파고,
그걸 기반으로 문제를 정의하고 해결한 경험이 실력도, 경쟁력도 만든다는 것을 깨닫게 된 하루였다.

AI와 추천 시스템에 대한 관심으로 파이썬 기초부터 AI 이론까지 학습하면서, 단순한 문법 암기보다는 개념의 본질을 이해하려 노력했다. 이 글은 파이썬의 덜 알려진 개념과 머신러닝 이론을 연결해 정리한 내용이다.

1. 파이썬 고급 개념 정리

1-1. is vs ==: 같지만 다를 수 있다

  • ==는 값(value)을 비교
  • is는 객체의 정체성(identity), 즉 메모리 주소를 비교
a = [1, 2, 3]
b = [1, 2, 3]

a == b      # True
a is b      # False

1-2. mutable vs immutable: 왜 함수 내부에서 리스트는 바뀌는데 정수는 안 바뀔까?

  • 리스트, 딕셔너리: mutable
  • 정수, 문자열, 튜플: immutable
def modify(x):
    x.append(4)

a = [1, 2, 3]
modify(a)
print(a)  # [1, 2, 3, 4]

1-3. 얕은 복사 vs 깊은 복사: 진짜 복사인가?

import copy

a = [[1, 2], [3, 4]]
shallow = copy.copy(a)
deep = copy.deepcopy(a)

a[0][0] = 99
print(shallow)  # [[99, 2], [3, 4]]
print(deep)     # [[1, 2], [3, 4]]

1-4. 리스트 내포 vs 제너레이터 표현식

  • 리스트 내포: 즉시 모든 값을 메모리에 저장
  • 제너레이터 표현식: 값을 하나씩 계산
[x**2 for x in range(1000)]       # 메모리 사용 큼
(x**2 for x in range(1000))       # 지연 계산

1-5. 함수는 일급 객체: 함수도 변수다

def square(x): return x**2
f = square
print(f(3))  # 9

1-6. 클로저와 함수형 설계: 파라미터 고정 함수 만들기

def make_multiplier(n):
    def multiplier(x):
        return n * x
    return multiplier

double = make_multiplier(2)
print(double(10))  # 20

1-7. 네임스페이스와 스코프: 파라미터 충돌을 피하자

파이썬은 LEGB 스코프 규칙을 따른다:

  • Local
  • Enclosing
  • Global
  • Built-in
def outer():
    x = "enclosing"
    def inner():
        print(x)
    inner()

2. CTR(Click Through Rate) 예측이란?

강의 추천 시스템을 만든다고 가정했을 때, 사용자가 특정 콘텐츠를 클릭할 확률을 예측하는 문제를 CTR 예측이라 한다.

  • 입력값 x: 콘텐츠 정보 (사용자 정보, 메타데이터, 콘텐츠 자체)
  • 출력값 y: 클릭 여부 (0 또는 1)

모델은 입력값을 바탕으로 함수 f(x)를 학습하고, 이 함수는 클릭할지 말지를 예측한다.


3. 머신러닝이란?

머신러닝은 데이터를 이용해 예측 함수를 학습하는 기술이다. 모델은 데이터를 기반으로 함수 f(x)를 만들고, 이 함수의 출력값 y를 예측한다.

핵심 개념

  • 입력값과 출력값을 연결하는 함수 f(x)를 학습
  • 학습 목표: 예측값과 실제값의 차이인 오차(loss) 를 최소화
  • 조정 가능한 값: 파라미터(parameter) 또는 가중치(weight)
y = wx + b

4. 머신러닝과 딥러닝의 차이

구분 머신러닝 딥러닝
특징 추출 사람이 수작업 모델이 자동 수행
데이터 형태 정형 데이터 (표) 비정형 데이터 (이미지, 텍스트)
대표 알고리즘 XGBoost, SVM 등 CNN, RNN, Transformer 등

5. 모델 학습의 과정

데이터 분할

모델을 일반화시키기 위해 데이터를 나눈다:

  • Train: 모델 학습용
  • Validation: 모델 조정용
  • Test: 최종 성능 평가

Validation만 사용하면 해당 데이터에 과적합될 수 있으므로, 별도의 Test 세트를 통해 일반화 성능을 검증하는 것이 좋다.


6. CNN과 BERT

CNN (Convolutional Neural Network)

  • 이미지의 국소 영역을 스캔하여 색상, 모양 등의 특징을 추출
  • 주로 이미지 분류, 객체 탐지에 활용됨

BERT (Bidirectional Encoder Representations from Transformers)

  • 구글이 만든 NLP용 Transformer 기반 모델
  • 사전학습(pretraining) → 미세조정(fine-tuning) 구조
  • 다양한 자연어처리 태스크에서 1위를 기록

마무리

이번 학습을 통해 파이썬 문법의 원리와, 머신러닝 모델이 데이터를 통해 어떻게 구조화되고 학습되는지를 함께 살펴볼 수 있었다. 단순히 문법을 외우기보다는, 개념 간 연결을 통해 더 깊은 이해를 얻을 수 있었다는 점이 좋았다.

🚀 팀 프로젝트 전체 회고

잘 지킨 부분

  • 기능별 패키지 구조 개선: 디렉토리로 체계적 분리
  • 개별 브랜치 관리: 브랜치로 독립적 작업 진행
  • 코드 리팩토링 시도: 중복 코드 제거 및 구조 개선 노력

개선이 필요한 부분

  • 동시 작업으로 인한 충돌: 같은 기능을 여러 명이 작업하여 코드 충돌 발생
  • 작업 현황 공유 부족: 리팩토링 진행 상황이 팀원들에게 충분히 공유되지 않음
  • 그라운드 룰 준수 미흡: 코드 변경 전 사전 논의 과정 부족

프로젝트 목표 달성도

1. "개인의 성장이 아닌 팀의 성장에 집중하기"

  • 현실: 개별 작업이 많아 팀 성장보다는 개인 작업에 치중
  • 개선필요: 서로의 코드 리뷰하고 지식 공유하기

2. "컴한 화려한 프로젝트보다 기본이 단단한 프로젝트 만들기"

  • 잘한 점: 기본적인 CRUD, 페이징, 검색 기능 구현
  • 점검필요: 코드 구조가 정말 단단한가? (현재 리팩토링 중)

3. "싸우지 않기, 예쁘게 함 말 하기"

  • 잘한 점: 큰 갈등 없이 진행
  • 더 나아가기: 건설적인 피드백 문화 만들기

그라운드 룰 준수도

1. "회의 시 1인 1건 제시하기"

  • 점검: 회의에서 모든 팀원이 의견을 냈는가?

2. "이슈 사항 or 수정 사항은 바로 공유하고"

  • 아쉬운 점: 카트/주문 기능 리팩토링 시 사전 공유 부족

3. "출결 이슈 사전 공유하기"

  • 잘한 점: 대체로 잘 지켜진 것 같음

4. "공지 메시지는 확인 후 꼭 이모지 표시하"

  • 점검: 단톡방에서 잘 지켜지고 있는가?

5. "일간 미팅하기"

  • 아쉬운 점: 매일 미팅이 제대로 이루어졌는가?

앞으로의 액션 플랜

즉시 실행

  1. 코드 변경 전 팀원들에게 미리 알리기
  2. 매일 오전 10분 스탠드업 미팅 재개
  3. 이슈 사항 즉시 공유 문화 정착

다음 스프린트부터

  1. 코드 리뷰 의무화
  2. 페어 프로그래밍 도입
  3. 지식 공유 세션 진행

 

📦 내가 맡은 도메인: 주문 처리 (Checkout & Reservation)

1. 구현 기능

1-1. 장바구니 → 주문 생성 전환

  • 선택된 cartId 목록을 세션에 저장해 /reservation 으로 이동
  • 총 금액 계산 후 Order · OrderItem · Reserve · Payment 4개 테이블에 일괄 INSERT

1-2. 주문 완료 처리

  • 주문 완료 시 장바구니 초기화
  • 결제 성공 직후 removeCartItems(selectedIds) 호출해 DB·결제UI 동시 정리

1-3. 주문 상세내역 페이지 (orderDetail.jsp)

  • 객실명 · 체크인/체크아웃 · 결제금액 · 리뷰 가능 여부 표시

1-4. 결제 완료 페이지 (payment_success.jsp)

  • 예약번호, 숙소·객실 썸네일, 결제요약, ‘예약 목록으로 이동’ CTA 제공

1-5. 주문 목록 (orderList.jsp) / 주문 상세 조회

  • “주문 상태 – 숙소명 – 체크아웃 D+N” 카드형 리스트, Ajax 페이징
  • 결제 상태 · 주문일자 · 리뷰 가능 여부 DB 반영

1-6. 회원/비회원 예약 내역 조회

  • 비회원: 첫 이용 시 임시 고객 생성 → 쿠키(nonMemberId)에 id 저장
  • 조회 로직
    • 로그인 회원 있음: 회원 주문만 표시
    • 로그인 없음: 쿠키 없음 → “예약 내역 없음” 출력
    • : 쿠키 존재 → 비회원 주문 표시

1-7. 트랜잭션 처리

  • @Transactional 어노테이션으로 한 번에 commit → 일관성 보장

2. 설계

2-1. 핵심 클래스

  • ReservationController: 화면 전환 · 세션 관리
  • OrderService & OrderServiceImpl: 비즈니스 로직 단일 진입점, @Transactional
  • ShoppingCartService: 장바구니 CRUD, 주문 완료 시 정리 담당

2-2. 주문 생성 플로우

  1. cartItems 로부터 totalPrice 계산
  2. orders → order_items 순차 INSERT 후 key 취득
  3. 각 item당 Reserve · Payment 레코드 생성 (Mapper 분리)
  4. 성공 시 commit, 예외 시 rollback

2-3. 리뷰 작성 가능 로직

  • OrderServiceImpl.calculateReviewEligibility()
    • 체크아웃 + 7일 이내 여부 계산해 canWriteReview 플래그 세팅

2-4. DB 설계

  • orders (PK: order_id)
  • order_items (PK: item_id, FK: order_id)
  • reserves (PK: reserve_id, FK: room_id)
  • payments (PK: payment_id, FK: reserve_id)

2-5. 예외·Validation

  • 중복 결제에 대한 IllegalArgumentException 으로 명확히 전달

2-6. 화면(View)

  • JSP + JSTL ▶ header/footer 공용 fragment 재사용
  • 예약/결제 화면 하단 ‘결제하기’ 버튼 disabled → 필수 입력 검증 후 enable

 

 👩‍💻 협업 시 느낀 점

  • Cart·Room 도메인 담당자와 JSON 필드 정의 & URI 규칙 조율이 중요했다.
  • Git Flow(feature → develop → main) 적용 덕분에 merge conflict 최소화, PR 단위도 작게 유지해 리뷰 효율 ↑

 

 💡 개인 회고

  • Spring @Transactional 의 편리함과 동시에, “하나의 트랜잭션 = 하나의 도메인 시나리오” 원칙을 지키려 노력했다.
  • 처음에는 단순 카트 삭제만 했는데, “결제 실패 시 카트 복구” 요구사항을 놓쳐 RollbackFor 설정을 추가로 고민하게 됐다. 실패 시점별 예외 클래스를 세분화한 경험이 기억에 남는다.
  • 직접 JSP를 열어보고 mock 데이터를 띄워보니 로직-화면 간 미스매치를 빠르게 찾을 수 있었다. 백엔드라도 화면을 보는 습관의 중요성을 깨달았다.
  • 다음 스프린트에서는 테스트 코드(JUnit + MockMvc)를 더 촘촘히 작성해 회귀 버그를 줄이고 싶다. 현재 OrderServiceTest가 부족해 리팩터링 때 위험 부담이 있었다.
  • 전반적으로 ‘주문-결제-재고’ 프로세스를 처음부터 끝까지 설계-구현해 보며 트랜잭션 설계, DB 정규화, RESTful URL 설계의 중요성을 체감했다. 이 경험이 이후 대용량 트래픽 서비스 설계 시 큰 도움이 될 것 같다.
  • 다음에 시도할 것들을 정리해보면 아래와 같다.
    • 데일리 스탠드업 미팅 실시
    • PR 템플릿 적극 활용
    • 실시간 소통 채널 활성화
    • 작업 전 이슈 등록
    • 코드 리뷰 의무화
    • API 문서 자동화 (Swagger)
    • 단위 테스트 습관화

'Project > YaNubJa - ep 1' 카테고리의 다른 글

YaNubJa : ep.2  (0) 2025.06.23
YaNubJa : ep.1  (0) 2025.06.15

1. “포인트 이벤트”와 “포인트 상세”를 합칠 수 없을까?

고민 배경

처음 ERD를 그릴 때, 포인트 트랜잭션을 POINT_EVENT와 POINT_DETAIL 두 테이블로 분리했어요.

  • POINT_EVENT: 고객 단위 적립·사용·만료 등 핵심 이벤트 한 줄
  • POINT_DETAIL: 적립 건별로 얼마나 차감·만료됐는지 기록

그런데 “정말 이 둘을 굳이 나눠야 하나?” 하는 생각이 들더군요.
단순히 테이블 수를 줄이면 관리하기 편할 것 같았거든요.

핵심 고민

  • 데이터 무결성: 한 테이블에 합치면, 적립한 원본 금액과 차감된 세부 내역을 어떻게 분리·추적할까?
  • 유효기간 처리: event 한 줄로는 “어느 적립분에서 얼마가 남았는지” 계산하기가 불가능
  • 감사 추적(audit): 사용·만료 한번에 기록하면, 나중에 “누가 언제 어디서 얼마나” 세부 내역을 리뷰하기 어려움

해결 과정

  1. 모델링 테스트
    • event 한 줄(예: POINT_AMOUNT = -50)만으로 다양한 시나리오(여러 적립 건 차감, 만료 등)를 시뮬레이션
    • 남은 적립분을 정확히 찾기 위해 매번 적립 건별로 쿼리·계산해야 해서 쿼리 복잡도 폭발
  2. 실무 사례 탐구
    • 기존 레퍼런스 코드를 뒤져보니, 대부분 두 계층 구조(메인 이벤트 + 상세 로그)를 유지
    • “포인트 회계장부”처럼, 원장(master)과 분개(journal)를 분리하는 방식이 안정적
  3. 결론
    • 테이블 합치지 않기로
    • 메인 이벤트와 세부 트랜잭션을 분리하면,
      • 비즈니스 로직(유효기간 만료, FIFO 차감)을 SQL/애플리케이션 레벨에서 깔끔히 처리
      • 감사 로그로도 활용 가능
    • 테이블 수는 늘어나지만, 유연성과 가독성이 훨씬 좋아집니다.

2. 결제 취소 시 쿠폰 사용 취소 로직

고민 배경

결제가 취소되면, 그 결제에 쓰인 포인트 뿐 아니라 쿠폰도 되돌려줘야 합니다.
쿠폰을 단순히 IS_USED = false로 업데이트하면 될 것 같지만…

핵심 고민

  • 부분 사용된 쿠폰: 쿠폰 할인액이 결제 금액보다 클 경우 일부만 사용 후 남은 할인액은 어떻게 처리할까?
  • 재사용 가능 쿠폰: IS_REUSABLE = true인 쿠폰인지, 한 번 쓰면 끝나는 건지 구분해야 함
  • 유효기간: 이미 만료 기한이 지난 쿠폰을 취소 시 재발급해줘야 하나?

해결 과정

  1. 쿠폰 상태 다각도로 정의
    • COUPON 테이블에 VALID_FROM, VALID_TO, IS_REUSABLE, DISCOUNT_REMAINING 칼럼 추가
    • USER_COUPON에도 USED_AT, REMAINING_DISCOUNT 필드로 “남은 할인액” 기록
  2. 취소 플로우 설계
    1. 결제 취소 트리거
      • PAYMENT의 IS_REFUNDABLE = true 확인
      • 취소 시 REFUND_RATE_100에 따라 환급 액수 계산
    2. 포인트 복원
      • POINT_EVENT에 “–사용” 이벤트를 반대 방향(+)로 기록
      • POINT_DETAIL는 해당 차감 건을 찾아 반대 트랜잭션 로그 삽입
    3. 쿠폰 복원
      • USER_COUPON의 USED_AT를 NULL로 초기화
      • REMAINING_DISCOUNT를 원래 값으로 복원
      • 만료 기한(VALID_TO)가 지났더라도, “취소 시점” 기준으로 최소 1일 유예를 주도록 정책 반영
  3. 테스트 시나리오
    • 전액 쿠폰 결제: 할인액만큼 REMAINING_DISCOUNT 복원 → USED_AT = NULL
    • 일부 쿠폰+포인트 결제: 포인트, 쿠폰 둘 다 원복
    • 다회 사용 가능한 쿠폰: 남은 할인액이 남아 있을 때도 정상 복원 확인
  4. 결과
    • 재사용 쿠폰은 조건에 맞게 재발급(유예 기한 포함)
    • 1회용 쿠폰은 다시 사용할 수 없도록 IS_USED = false만 리셋
    • 코드 한 줄로 ORDER_CANCELLED 이벤트 하나만 발생시키면, 포인트와 쿠폰 모두 원상 복구되는 구조 완성!

마무리 : 배운 점

  • 테이블 분리의 가치: “합치면 간단해 보인다”가 항상 옳진 않다. 무결성과 추적 가능성을 위해 계층화가 필수
  • 도메인별 예외 처리: 쿠폰·포인트·환불 로직은 서로 얽혀 있어, 단일 트랜잭션 내에서 일관성 있게 다뤄야 안전
  • 정책 반영의 중요성: “만료 기한 유예” 같은 비즈니스 룰은 ERD 설계 단계에서 미리 반영해야 구현이 매끄럽다

이번 주 고민과 해결 과정을 정리하며, 복잡해 보였던 포인트·쿠폰 로직이
“명확한 도메인 테이블” + “일관된 이벤트 흐름”으로 단순해질 수 있음을 다시 한번 체감했습니다.
다음 단계에선 이 로직을 실제 서비스와 연결해 API·트랜잭션 테스트까지 진행해볼 예정입니다!

'Project > YaNubJa - ep 1' 카테고리의 다른 글

YaNubJa : ep.3  (0) 2025.06.28
YaNubJa : ep.1  (0) 2025.06.15

프로젝트 개요

처음 해보는 팀 단위 토이 프로젝트라 설레기도, 걱정도 많았던 4주였습니다.
우리 팀은 숙소·객실 예약 시스템을 모델링하며, 실제 서비스에 가까운 구조를 고민했어요.
저는 포인트/쿠폰 도메인을 맡아 ERD 설계, 더미 데이터 생성, 화면 정의서 작성까지 진행했습니다.


좋았던 점 ⭐️

  1. 팀워크가 정말 찡했다
    • 서로 모르는 부분을 바로 도와주고, 의견을 부담 없이 주고받으면서 소통이 원활했습니다.
    • “이 부분은 내가 할게” “이거 궁금한 점 있으면 언제든 말해” 같은 적극적인 분위기가 프로젝트 내내 유지됐어요.
  2. 역할 분담이 명확했다
    • 도메인별로 영역을 나누니 각자 집중할 수 있었고, 중복 작업도 줄었습니다.
    • 저는 포인트/쿠폰 쪽만 파고들다 보니 몰입도가 높았고, 팀원 덕분에 전체 흐름도 자연스럽게 이해할 수 있었습니다.
  3. 실무 감각 체험
    • 숙소·객실 다대다 관계 설계, 가격 정책 통합 같은 실제 사례를 고민하면서
      “이거 진짜 서비스에 있겠네” 싶을 만큼 현실적인 모델링을 해볼 수 있었습니다.
  4. 밝은 분위기 덕분에 즐거웠다
    • 매일 오전 “오늘은 이걸 마무리해보자” 하고 가볍게 목표를 공유하며 시작했어요.
    • 긍정적인 피드백이 많아서 힘들 때도 금방 기운이 났습니다.

아쉬웠던 점 & 느낀 점 📝

  1. 통일된 설계 규칙이 없었다
    • ERD 설계에 들어가기 전에 공통 룰(네이밍, 데이터 타입 등)을 먼저 정하지 않아
      통일성이 떨어지는 부분이 많았어요.
    • 다음번엔 팀 킥오프 단계에서 꼭 룰북부터 만들 예정입니다.
  2. 더미 데이터 준비 부족
    • 엑셀로 더미 데이터를 정리한 뒤 ERD에 반영했어야 했는데, 시간에 쫓겨 생략했습니다.
    • 실제 서비스 테스트를 위해서라도 데이터 준비 과정을 놓치지 않으려고요.

개인 역할 회고 🔍

  • 맡은 역할: 포인트/쿠폰 도메인
  • 배운 점 :
    1. 쿠폰 발급·사용 내역을 디자인하며 복합 키 설정, FK 제약조건을 직접 고민해 보았습니다.
    2. 화면 정의서 작성 과정에서 UX 흐름을 함께 그려보니, 백엔드 스펙 문서화의 중요성을 실감했어요.
  • 다음엔 :
    • 설계 전 더미 데이터 표부터 준비하고,
    • 화면 정의서 템플릿을 미리 공유해 논의 시간을 줄이려 합니다.

포인트 사용 & 차감 고민과 설계💡

1. 단순한 마이너스 처리로는 답이 아니었다

처음에는 “포인트 1,000원을 쓰면 -1,000 한 줄만 추가하면 되겠지?” 싶었어요.
하지만 유효기간 만료 이슈가 발목을 잡았습니다.

  • 예를 들어 50원이 만기되기 전까지 남아 있어야 할 때,
  • 사용한 만큼만 차감하고 남은 포인트만 정확히 만료 처리해야 하거든요.

 

2. 상세 트랜잭션 테이블로 풀어낸 해법

그래서 POINT_EVENT(메인 이벤트)와 POINT_DETAIL(상세 트랜잭션) 도메인을 분리했습니다.

  1. POINT_EVENT
    • 고객별 포인트 핵심 이벤트 보관
    • POINT_STATUS(적립·사용·만료), POINT_AMOUNT, EXPIRE_AT 등 주요 정보를 담습니다.
  2. POINT_DETAIL
    • 모든 트랜잭션 기록(적립ID 기준)
    • ACCRUAL_ORIGINAL_DETAIL_ID와 CANCEL_ORIGINAL_DETAIL_ID로 연결해
    • “어떤 적립 건에서 얼마나 사용(또는 만료)됐는지”를 한눈에 계산하도록 했습니다.

 

3. 처리 룰: FIFO + 그룹 연산

  • 적립 순서(FIFO) 대로 차감
  • POINT_DETAIL에서 적립 ID별로 GROUP BY
  • 남은 금액이 0이 된 적립 건은 후속 계산에서 제외

예시 흐름

  1. +10원 적립 (d1)
  2. +30원 적립 (d2)
  3. +20원 적립 (d3)
  4. –50원 사용 요청

→ POINT_DETAIL에 쌓이는 레코드

–10원 사용 (d1 차감)
–30원 사용 (d2 차감)
–10원 사용 (d3 차감)
  • d1, d2는 0원이 돼서 더 이상 계산에 참여하지 않고,
  • d3에는 10원이 남아 다음 이벤트나 만기 처리 대상으로 남습니다.

 

4. 유효기간 만료 처리

만료 시에도 같은 로직을 사용합니다.

  • 남은 포인트(10원)가 있는 d3만 골라서
  • “–10원 만료” 레코드를 하나 추가하기만 하면 끝!

 

장점

  • 누락 이벤트 대응: 거래 로그가 빠졌다면, POINT_DETAIL에 한 줄만 더 추가하면 전체 계산이 알아서 정정됩니다.
  • 투명한 감사: 어느 적립 건이 어떻게 사용·만료됐는지, 모든 내역이 그대로 남아 감사를 쉽게 해 줍니다.
  • 유연한 확장: 환불·취소·환급 같은 추가 이벤트도 같은 모델로 대응 가능해요.

“Insert & 상세 이벤트 모델 하나로 포인트의 모든 예외 케이스를 커버한다” 는 게 제 설계의 핵심이었습니다.

 

이렇게 설계하고 나니, 복잡한 포인트 로직도 깔끔하게 돌아갔습니다.

'Project > YaNubJa - ep 1' 카테고리의 다른 글

YaNubJa : ep.3  (0) 2025.06.28
YaNubJa : ep.2  (0) 2025.06.23

이전 글에서는 JSP 기반 로그인 시스템을 구축하면서 쿠키와 세션, DB 연동까지 구현했습니다. 이번에는 이 시스템을 한 단계 발전시켜 다음과 같은 기능을 추가했습니다.

  • DB에 회원 정보 등록 (회원가입 기능)
  • 비밀번호 암호화 (SHA-256)
  • 로그인 실패 시도 횟수 제한 처리

1. 회원가입 기능 (DB에 사용자 등록)

1-1. 사용자 테이블 설계

MySQL Workbench에서 다음과 같은 테이블을 생성합니다.

CREATE TABLE user (
  id VARCHAR(50) PRIMARY KEY,
  pwd VARCHAR(255) NOT NULL,
  name VARCHAR(50),
  fail_count INT DEFAULT 0
);

1-2. 회원가입 폼 (register.jsp)

<form action="register" method="post">
  ID: <input type="text" name="id"><br>
  PWD: <input type="password" name="pwd"><br>
  이름: <input type="text" name="name"><br>
  <input type="submit" value="회원가입">
</form>

1-3. RegisterController.java

@Controller
public class RegisterController {

    @Autowired
    JdbcTemplate jdbcTemplate;

    @RequestMapping(value = "/register", method = RequestMethod.POST)
    public String register(String id, String pwd, String name) throws Exception {
        String encPwd = encrypt(pwd);
        String sql = "INSERT INTO user (id, pwd, name) VALUES (?, ?, ?)";
        jdbcTemplate.update(sql, id, encPwd, name);
        return "redirect:/login.jsp";
    }

    private String encrypt(String pwd) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] hash = md.digest(pwd.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(hash);
    }
}

 

2. 비밀번호 암호화 (SHA-256)

2-1. 암호화 로직 재사용

로그인 시에도 같은 방식으로 비밀번호를 암호화하여 비교해야 합니다.

private String encrypt(String pwd) throws Exception {
    MessageDigest md = MessageDigest.getInstance("SHA-256");
    byte[] hash = md.digest(pwd.getBytes(StandardCharsets.UTF_8));
    return Base64.getEncoder().encodeToString(hash);
}

 

3. 로그인 실패 처리

3-1. 사용자 인증 로직 수정 (LoginController.java)

@Controller
public class LoginController {

    @Autowired
    JdbcTemplate jdbcTemplate;

    @RequestMapping(value = "/login", method = RequestMethod.POST)
    public String login(String id, String pwd, Model model, HttpSession session) throws Exception {
        String sql = "SELECT * FROM user WHERE id = ?";
        List<Map<String, Object>> users = jdbcTemplate.queryForList(sql, id);

        if (users.isEmpty()) {
            model.addAttribute("error", "존재하지 않는 사용자입니다.");
            return "login";
        }

        Map<String, Object> user = users.get(0);
        String encPwd = encrypt(pwd);
        String dbPwd = (String) user.get("pwd");
        int failCount = (Integer) user.get("fail_count");

        if (failCount >= 5) {
            model.addAttribute("error", "로그인 5회 이상 실패. 계정 잠김.");
            return "login";
        }

        if (dbPwd.equals(encPwd)) {
            jdbcTemplate.update("UPDATE user SET fail_count = 0 WHERE id = ?", id);
            session.setAttribute("id", id);
            return "redirect:/index.jsp";
        } else {
            jdbcTemplate.update("UPDATE user SET fail_count = fail_count + 1 WHERE id = ?", id);
            model.addAttribute("error", "비밀번호가 일치하지 않습니다.");
            return "login";
        }
    }

    private String encrypt(String pwd) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] hash = md.digest(pwd.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(hash);
    }
}

4. 로그인 화면에서 오류 메시지 출력

login.jsp 수정

<c:if test="${not empty error}">
  <p style="color:red">${error}</p>
</c:if>

 

5. 테스트 시나리오 요약

  1. 회원가입 페이지에서 회원 등록 (비밀번호는 암호화되어 저장됨)
  2. 올바른 비밀번호로 로그인 → 성공
  3. 틀린 비밀번호 5회 입력 → 계정 잠김
  4. 로그인 성공 시 실패 횟수 초기화
  5. 암호화된 비밀번호를 직접 DB에서 확인 가능
이번 글에서는 Spring MVC 기반 JSP 프로젝트를 통해 로그인 시스템을 처음부터 끝까지 구현한 과정을 정리합니다.
사용한 개발환경은 IntelliJ IDEA Ultimate, Tomcat, MySQL 8.x, Spring Framework (XML 기반)입니다.
이 글은 다음과 같은 순서로 진행됩니다.

Step 1. 기본 로그인 화면 만들기

1-1. HTML 파일 작성

login.html 파일을 만들고 아래와 같이 간단한 로그인 폼을 작성했습니다.

<form action="login" method="post">
  ID: <input type="text" name="id"><br>
  PWD: <input type="password" name="pwd"><br>
  <input type="submit" value="로그인">
</form>

1-2. login.jsp로 변환

위의 HTML 코드를 login.jsp로 변경하여 src/main/webapp/WEB-INF/views 디렉토리에 배치했습니다.

1-3. 브라우저에서 login.jsp 확인

ViewResolver 설정을 통해 login.jsp가 정상적으로 출력되도록 구성했습니다.

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
  <property name="prefix" value="/WEB-INF/views/"/>
  <property name="suffix" value=".jsp"/>
</bean>

1-4. userInfo.jsp 생성

로그인 후 정보를 출력하기 위한 userInfo.jsp 파일을 작성했습니다.

ID: ${id} <br>
PWD: ${pwd}

1-5. login.jsp에서 입력한 값이 userInfo.jsp에 출력되는지 확인

입력한 ID와 PWD가 컨트롤러를 거쳐 userInfo.jsp에 정상적으로 출력되는 것을 확인했습니다.


Step 2. LoginController.java 구성

2-1. 컨트롤러 클래스 생성

@Controller
public class LoginController {

    @RequestMapping(value = "/login", method = RequestMethod.POST)
    public String login(String id, String pwd, Model model) {
        System.out.println("Hello");

        if ("asdf".equals(id) && "1234".equals(pwd)) {
            model.addAttribute("id", id);
            model.addAttribute("pwd", pwd);
            return "userInfo";
        } else {
            return "redirect:/login.jsp";
        }
    }
}

2-2. 조건문으로 로그인 분기 처리

입력된 ID와 PWD가 일치할 경우 userInfo.jsp로 이동하며, 일치하지 않으면 login.jsp로 리디렉션됩니다.


Step 3. 쿠키 기반 자동 로그인 구현

3-1. login.jsp에 체크박스 추가

<input type="checkbox" name="rememberId" ${cookie.id.value != null ? "checked" : ""}> ID 기억

3-2. 컨트롤러에서 쿠키 생성 및 삭제

if ("asdf".equals(id) && "1234".equals(pwd)) {
    if ("on".equals(rememberId)) {
        Cookie cookie = new Cookie("id", id);
        cookie.setMaxAge(60*60*24*7);
        response.addCookie(cookie);
    } else {
        Cookie cookie = new Cookie("id", null);
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }
}

3-3. login.jsp에서 쿠키값으로 자동 입력

ID: <input type="text" name="id" value="${cookie.id.value}">

 

결과

  • 체크박스를 선택하면 다음 방문 시 ID가 자동으로 입력됨
  • 체크박스를 해제하고 로그인 시 쿠키가 삭제되어 ID가 표시되지 않음

Step 4. 메뉴 구성 및 세션 기반 로그인 상태 유지

4-1. index.jsp 작성 (Home / Board / Login 메뉴)

<a href="index.jsp">Home</a>
<a href="board">Board</a>
<c:choose>
  <c:when test="${sessionScope.id == null}">
    <a href="login.jsp">Login</a>
  </c:when>
  <c:otherwise>
    <a href="logout">Logout</a>
  </c:otherwise>
</c:choose>

 

4-2. Board 접근 시 로그인 여부 확인

@Controller
public class BoardController {

    @RequestMapping("/board")
    public String board(HttpSession session) {
        if (session.getAttribute("id") == null) {
            return "redirect:/login.jsp";
        }
        return "board";
    }
}

4-3. 로그인 성공 시 세션에 ID 저장

session.setAttribute("id", id);

Step 5. MySQL 연동

5-1. MySQL 8.x 설치 및 schema 생성

  • 스키마 이름: springbasic
  • 문자셋: utf8
  • collation: utf8_general_ci

5-2. DB 연결 테스트 코드 복사

깃헙에서 DBConnectionTest2.java 파일을 복사해서 실행

5-3. pom.xml에 MySQL 드라이버 의존성 추가

<dependency>
  <groupId>com.mysql</groupId>
  <artifactId>mysql-connector-j</artifactId>
  <version>8.3.0</version>
</dependency>

5-4. root-context.xml 설정

<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
    <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/>
    <property name="url" value="jdbc:mysql://localhost:3306/springbasic?useUnicode=true&characterEncoding=utf8"/>
    <property name="username" value="asdf"/>
    <property name="password" value="1234"/>
</bean>

결과

DBConnectionTest2 실행 시 현재 시간이 출력되고, conn = null이 아니라면 DB 연결 성공입니다.

+ Recent posts