최원종의 개발 블로그

V16-1 포인트 시스템(유료 게시글 기능 추가) 본문

Spring boot 입문/Spring boot 입문 -1

V16-1 포인트 시스템(유료 게시글 기능 추가)

chl6698 2026. 6. 23. 11:46

포인트 시스템 개념

- 사용자가 보유한 가상 화폐(포인트)를 관리하는 시스템
- 포인트 충전 → 보유 → 소비(구매) → 열람(권한)으로 이어지는 사이클
- 트랜잭션을 통해 포인트 차감과 구매 내역 저장을 동시에 처리하여 데이터 정합성 보장

트랜잭션(@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);
    }