Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 인텔리제이 한글 깨짐 해결법
- continue문
- 형 변환
- Java
- 연관관계
- 생성자
- Thread
- function
- 메서드
- 포함관계
- 반복문
- break문
- 시스템 환경 변수 편집
- JAVA객체지향
- 자바 멀티스레딩
- While
- 메서드 오버로딩
- 접근제어지시자
- this예약어
- Java데이터 타입
- OPP개념
- multi-threading
- 상수
- 인텔리제이 기초 설정
- IntelliJ IDEA
- 컴파일
- for문
- JAVA기초
- 집합관계
- java변수
Archives
- Today
- Total
최원종의 개발 블로그
V16-1 포인트 시스템(유료 게시글 기능 추가) 본문
포인트 시스템 개념
- 사용자가 보유한 가상 화폐(포인트)를 관리하는 시스템
- 포인트 충전 → 보유 → 소비(구매) → 열람(권한)으로 이어지는 사이클
- 트랜잭션을 통해 포인트 차감과 구매 내역 저장을 동시에 처리하여 데이터 정합성 보장
트랜잭션(@Transactional) 개념
- 여러 데이터베이스 작업을 하나의 작업 단위로 묶는 것
- 원자성(Atomicity): 모든 작업이 성공하거나 모두 실패 (중간 상태 없음)
- 예: 포인트 차감과 구매 내역 저장 중 하나라도 실패하면 모두 롤백
Many-to-Many 관계 개념
- 한 사용자는 여러 게시글을 구매할 수 있음 (User 1 : Purchase N)
- 한 게시글은 여러 사용자에게 구매될 수 있음 (Board 1 : Purchase N)
- 중간 테이블(Purchase)을 통해 관계를 관리
사용자 포인트 충전 기능 추가

detail.mustache코드 ⬇️
- 보유 포인트 + 충전 추가
- script 추가
더보기
{{> layout/header}}
<div class="container p-5 flex-grow-1">
<div class="card">
<div class="card-header">마이프로필</div>
<div class="card-body">
<!-- 프로필 이미지 start -->
<div class="text-center mb-4">
{{#user.profileImage}}
<!-- 회원 가입시 로컬 가입자 -->
<img class="rounded-circle" src="{{user.profilePath}}" alt="프로필이미지"
style="width:200px; height:200px"/>
{{#user.local}}
<div class="mt-2">
<form action="/user/profile-image/delete" method="post">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('프로필 사진을 삭제할까요?')">프로필 사진 삭제</button>
</form>
</div>
{{/user.local}}
{{/user.profileImage}}
{{^user.profileImage}}
<div class="rounded-circle bg-secondary d-inline-flex
justify-content-center align-items-center border"
style="width:200px; height:200px;">
<span>프로필 사진 없음</span>
</div>
{{/user.profileImage}}
</div>
<!-- 프로필 이미지 end -->
<div class="mb-3">
<label class="form-label fw-bold">사용자명</label>
<p class="form-control-plaintext">{{user.username}}</p>
</div>
<div class="mb-3">
<label class="form-label fw-bold">이메일</label>
{{#user.email}}
<p class="form-control-plaintext">{{user.email}}</p>
{{/user.email}}
</div>
<div class="mb-3">
<label class="form-label fw-bold">가입일</label>
<p class="form-control-plaintext">{{user.createdAt}}</p>
</div>
<!-- 보유 포인트 + 충전(테스트용) -->
<div class="mb-3">
<label for="form-label fw-bold">보유 포인트</label>
<p class="form-control-plaintext">
<span id="pointDisplay">10000(임시){{!user.point}}</span>P
</p>
<div class="input-group" style="max-width: 350px">
<input type="number" id="chargeAmount" class="form-control" value="1000">
<button type="button" class="btn btn-outline-success" onclick="callChargeApi()" >포인트 충전</button>
</div>
</div>
<div class="mb-3">
<a href="/user/update-form" class="btn btn-primary" >회원정보 수정</a>
<a href="/board/list" class="btn btn-secondary" >게시글 목록</a>
</div>
</div>
</div>
</div>
<script>
async function callChargeApi() {
let amount = Number(document.querySelector("#chargeAmount").value);
if(!amount || amount <= 0 ) {
alert('충전할 포인트를 올바르게 입력해주세요');
return;
}
try {
let response = await fetch("/api/point/charge", {
method: 'POST',
headers: {'Content-Type': "application/json; charset=UTF-8;"},
body: JSON.stringify({amount: amount})
});
if(!response.ok) {
alert("충전 실패");
console.log(response);
} else {
let result = await response.json();
// 충전된 금액을 다시 P 태그에 재 갱신 처리
document.querySelector("#pointDisplay").textContent = result.point;
}
} catch (e) {
console.log(e);
alert("포인트 충전 실패");
}
}
</script>
{{> layout/footer}}
User 코드 ⬇️
- 보유 포인트 추가
- point가 null일때 기본값 0설정
- 포인트 관련 편의 메서드 추가
더보기
package com.tenco.blog.user;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.CreationTimestamp;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@Table(name = "user_tb")
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
// 사용자명 중복 방지를 위한 유니크 제약 조건 설정
@Column(unique = true)
private String username;
private String password;
// 물리적으로 중복 값 등록 방지
@Column(unique = true)
private String email;
// 엔티티가 영속화 될 때 자동으로 현재 시간을 주입해라 pc -> db
@CreationTimestamp
private Timestamp createdAt;
// User 테이블에는 이미지 파일명만 저장할 예정 (실제 데이터는 내 서버 컴퓨터 로컬에 저장할 예정)
@Column(nullable = true) // null 허용, 기본값
private String profileImage; // 프로필 이미지는 선택 사항(회원 가입 시)
// User : UserRole 연관 관계를 단방향 1 : N 구조 설계
// JPA가 1 : N 구조일 경우 (User , UserRole) , @JoinColumn(name ="user_id") 의미는
// 여기 테이블에 컬럼 user_id 생성해 라는 의미이다. 그런데 1 : N 구조에서 FK 컬럼이
// 1 쪽 테이블에 생성되는 경우는 없다. 무조건 N 쪽에 FK 컬럼이 만들어져야 하기 때문에
// 자동으로 User 테이블에 @JoinColumn("user_id") 하더라도 알아서 UserRole 컬럼을 지가 생성 한다
/**
* 사용자 권한 목록
* User (1) : UserRole (N) 연관 관계를 정의 함
*
* 1. @OneToMany + @JoinColumn(name ="user_id")
* - User 가 UserRole 리스트를 관리 합니다. (단방향)
* - 실제 DB user_role_tb 테이블에 FK 컬럼은 user_id 명이 user_role_tb 생성 된다.
*
* 2. CascadeType.ALL (운명 공동체)
* Java 기준에서 User 저장하면 Role 도 자동 저장되고, User 삭제하면 가지고 있던
* Role들도 다 삭제가 됩니다. DB 에서 실제 delete 쿼리가 발생 됩니다.
*
* 3. orphanRemoval (리스와 DBㄹ르 동기화)
* DB 에서 실제 delete 쿼리가 발생 됩니다. = true 처리
*
* 4. fetch = FetchType.EAGER (특별취급)
* 데이터 양이 얼마 되지 않습니다. 그래서 한번에 데이터를 채워서 가지고 오는것이
* 편리하다
*/
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
@JoinColumn(name = "user_id")
private List<UserRole> roles = new ArrayList<>();
@Enumerated(EnumType.STRING)
@Column(nullable = false) // null 허용 안함
@ColumnDefault("'LOCAL'") // 어노테이션으로 디폴트값 선언 방법 ( 문자열 일 경우 ' ' 반드시 사용)
private OAuthProvider oAuthProvider;
// 보유 포인트
@ColumnDefault("0") // 테이블 기준 기본값 설정
private Integer point = 0;
@Builder
public User(Integer id, String username, String password,
String email, Timestamp createdAt,
String profileImage, OAuthProvider oAuthProvider,
List<UserRole> roles,
Integer point) {
this.id = id;
this.username = username;
this.password = password;
this.email = email;
this.createdAt = createdAt;
this.profileImage = profileImage;
this.oAuthProvider = (oAuthProvider != null) ? oAuthProvider : OAuthProvider.LOCAL;
// 1. roles(ArrayList 타입 임) 가 null 빈 리스트로 초기회 (NPE 방지 처리)
this.roles = (roles != null) ? roles : new ArrayList<>();
// 2. roles 가 비어있으면 - USER 기본 권한으로 자동 설정
if(this.roles.isEmpty()) {
this.roles.add(UserRole.builder().role(Role.USER).build());
}
// point 가 null 이면 기본값 0으로 설정
this.point = (point != null) ? point : 0;
}
// 편의 기능 추가 - 회원 정보 수정
public void update(UserRequest.UpdateDTO updateDTO) {
if(updateDTO.getPassword() != null) {
// 암호화 변경되어 들어 옴
this.password = updateDTO.getPassword();
}
if(updateDTO.getProfileImageFileName() != null ) {
// 신규 파일 생성해서 들어 옴
this.profileImage = updateDTO.getProfileImageFileName();
}
}
// == User 엔티티에 권한 관련 편의 기능 만들기 보기 ==
// Role 추가 편의 메서드
// Role.ADMIN, Role.USER
public void addRole(Role role) {
//this.roles.get(0) = new UserRole(1, Role.USER);
this.roles.add(UserRole.builder().role(role).build());
}
// 해당 Role 을 가지고 있는 여부 확인
// boolean isAdmin = user.haRole(Role.USER);
public boolean hasRole(Role role) {
// 1. 방어적 코드 작성
if (this.roles == null || this.roles.isEmpty()) {
// Role(해당 유저에 권한이) 자체가 설정 되지 않은 상태
return false;
}
for(UserRole userRole: this.roles) {
if(userRole.getRole() == role) {
return true;
}
}
return false;
}
// 관리지 여부 확인 편의 메서드 - 머스태치에서 is 생략하고 admin으로 접근 가능합니다.
public boolean isAdmin() {
return hasRole(Role.ADMIN);
}
// 머스태치 화면에서 사용할 편의 메서드 1
public String getRoleDisplay() {
return isAdmin() ? "ADMIN" : "USER";
}
// 머스태치 화면에서 사용할 편의 메서드 2
// OAuthProvider 값에 따라서 경로 변수를 다르게 리턴
public String getProfilePath() {
if(this.profileImage == null) {
return null;
}
// 이미지 경로가 http 로 시작 (소셜 가입)
if(this.profileImage.startsWith("http")) {
return this.profileImage;
}
// 로컬 이미지(서버 기준 경로)
return "/images/" + this.profileImage;
}
// 머스태치 화면에서 사용할 편의 메서드 3
public boolean isLocal() {
// true -> 이메일 가입자를 의미 함
return this.oAuthProvider == OAuthProvider.LOCAL;
}
// 포인트 관련 편의 메서드 추가
public void deductPoint(Integer amount) {
if(amount == null || amount <= 0 ) {
throw new Exception400("차감할 포인트는 0보다 커야 합니다");
}
if(this.point < amount) {
throw new Exception400("포인트가 부족합니다. 현재 포인트 : " + this.point);
}
this.point -= amount;
}
public void chargePoint(Integer amount) {
if (amount == null || amount <= 0) {
throw new Exception400("충전할 포인트는 0보다 커야 합니다");
}
this.point += amount;
}
}
UserRequest 코드 추가
@Data
public static class PointChargeDTO {
private Integer amount;
public void validate() {
if(amount == null || amount <= 0) {
throw new Exception400("충전할 포인트는 0보다 커야 합니다");
}
}
}
UserApiController 추가
// 포인트 충전 API 설계 (JSON 형식으로 값이 들어 옴)
@PostMapping("/api/point/charge")
public ResponseEntity<?> chargePoint(@RequestBody UserRequest.PointChargeDTO reqDTO,
HttpSession session) {
// 1. 유효성 검사
reqDTO.validate();
// 2. 세션에서 사용자 정보 추출 (로그인 된 상태만 접근 가능)
User sessionUser = (User) session.getAttribute(Define.SESSION_USER);
if(sessionUser == null) {
return ResponseEntity.status(401).body(Map.of("message", "로그인이 필요합니다"));
}
// 3. 포인트 충전 처리 (비즈니스 로직)
User updatedUser = userService.포인트충전(sessionUser.getId(), reqDTO.getAmount());
// 4. 세션 동기화 처리 (user.point 값 갱신)
session.setAttribute(Define.SESSION_USER, updatedUser);
return ResponseEntity.ok().body(Map.of(
"message", "포인트가충전되었습니다",
"point", updatedUser.getPoint()));
}
UserService
@Transactional
public User 포인트충전(Integer id, Integer amount) {
// id 값으로 db 에 User 정보 조회부터 해야 함
User userEntity = userRepository.findById(id).orElseThrow(
() -> new Exception404("사용자를 찾을 수 없습니다")
);
userEntity.chargePoint(amount);
// Controller 에 User 를 반환하기 위해서 save 호출(신규 저장, 수정 같이 동작 됨)
return userRepository.save(userEntity);
}'Spring boot 입문 > Spring boot 입문 -1' 카테고리의 다른 글
| V16-3 Board 상세보기 유료/무료 구분 (0) | 2026.06.25 |
|---|---|
| V16-2 포인트 시스템의 개념과 유료 게시글 (0) | 2026.06.25 |
| V15-4 이메일 인증 후 회원가입 로직 변경 (0) | 2026.06.23 |
| V15-3 인증 이메일 발송 API 테스트 (0) | 2026.06.18 |
| V15-2 이메일 인증 기능 추가 사전준비 및 프로그램 흐름 (0) | 2026.06.18 |
