최원종의 개발 블로그

V16-2 포인트 시스템의 개념과 유료 게시글 본문

Spring boot 입문/Spring boot 입문 -1

V16-2 포인트 시스템의 개념과 유료 게시글

chl6698 2026. 6. 25. 15:46

Purchase 도메인 추가

 

Purchase 엔티티 설계⬇️

더보기
package com.tenco.blog.purchase;

import com.tenco.blog.board.Board;
import com.tenco.blog.user.User;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.sql.Timestamp;

/**
 * 구매 내역 엔티티
 *
 * User 와 Board 의 구매 이력 관계를 표현 함
 *
 * 한 사람에 사용자는 여러 게시글을 구매할 수 있다. (o)
 * 한 게시글을 여러 사용자에게 구매 될 수 있다. (o)
 * User : Board - 다대다 관계로 표현이 되기 때문게 중간 테이블 (Purchase) 생성이 되어야 한다
 * Purchase : User --> @ManyToOne --> join column 이름 지정
 * Purchase : Board --> @ManyToOne --> join column 이름 지정
 * 복합키 설정 방법도 확인 (테이블 기준으로 어노테이션 설정)
 */
@Data
@NoArgsConstructor
@Entity
@Table(name = "purchase_tb",
    uniqueConstraints = {
        @UniqueConstraint(name = "uk_user_board", columnNames = {"user_id", "board_id"})
    })
public class Purchase {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    // 단방향 관계 " Purchase -> User "
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    // 단방향 관계 " Purchase -> Board "
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private Board board;

    // 구매시 지불한 포인트
    private Integer price;
    
    @CreationTimestamp // pc -> db 자동 주입
    private Timestamp createdAt;

    @Builder
    public Purchase(User user, Board board, Integer price) {
        this.user = user;
        this.board = board;
        this.price = price;
    }
}

PurchaseRepository 생성⬇️

더보기
package com.tenco.blog.purchase;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository // 가독성 때문에 명시하기도 함
public interface PurchaseRepository extends JpaRepository<Purchase, Integer> {
    // 기본적인 CRUDE 생성 및 추가 기능 자동 생성 됨...
    // 사용자(상세보기)와 게시글의 구매 내역 조회
    @Query("""
        SELECT p FROM Purchase p WHERE p.user.id = :userId AND p.board.id = :boardId
""")
    Optional<Purchase> findByUserIdAndBoardId(@Param("userId") Integer userId,
                                    @Param("boardId") Integer boardId);


    // 사용자(상세보기)와 게시글의 구매 여부 확인
    @Query("""
        SELECT COUNT(p) > 0 FROM Purchase p WHERE p.user.id = :userId AND p.board.id = :boardId
""")
    boolean existsByUserIdAndBoardId(@Param("userId") Integer userId,
                                     @Param("boardId") Integer boardId);

}

PurchaseService⬇️

더보기
package com.tenco.blog.purchase;

import com.tenco.blog._core.errors.Exception400;
import com.tenco.blog._core.errors.Exception404;
import com.tenco.blog.board.Board;
import com.tenco.blog.board.BoardRepository;
import com.tenco.blog.user.User;
import com.tenco.blog.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class PurchaseService {

    private final PurchaseRepository purchaseRepository;
    private final BoardRepository boardRepository;
    private final UserRepository userRepository;

    // 유료 게시글 기본 가격 설정
    private static final Integer PREMIUM_BOARD_PRICE = 500;

    /**
     * 유료 게시글 구매 로직
     * <p>
     * 트랜 잭션 처리 : 포인트 차감과 구매 내역(저장)을 하나의 트랜잭션으로 처리 해야 한다.
     *
     */
    @Transactional
    public User 구매하기(Integer userId, Integer boardId) {

        // 1. 게시글 조회
        Board boardEntity = boardRepository.findById(boardId)
                .orElseThrow(() -> new Exception404("게시글을 찾을 수 없습니다"));

        // 2. 유료 게시글 여부 확인
        // board <-- 컬럼 추가 예정 (premium) , 유료, 무료 확인

        // 3. 작성자가 자신의 게시글을 구매 하려는 경우는 방지
        if (boardEntity.getUser() != null && boardEntity.getUser().getId().equals(userId)) {
            throw new Exception400("자신이 작성한 게시글을 구매할 수 없습니다");
        }

        // 4. 이미 구매한 게시글인지 확인
        if (purchaseRepository.existsByUserIdAndBoardId(userId, boardId)) {
            throw new Exception400("이미 구매한 게시글입니다");
        }

        // 5. 사용자 조회 (포인트 차감 처리)
        User userEntity = userRepository.findById(userId)
                .orElseThrow(() -> new Exception404("사용자를 찾을 수 없습니다"));

        // 6. 포인트 차감
        userEntity.deductPoint(PREMIUM_BOARD_PRICE);

        // 구매 내역 저장
        Purchase purchase = Purchase
                .builder()
                .user(userEntity)
                .board(boardEntity)
                .build();
        // 구매 이력 insert
        purchaseRepository.save(purchase);

        // 사용자 포인트 차감된 상태를 update 처리
        // Controller 단에서 차감된 포인트 상태를 위해 세션 동기화 (반환)
        return userRepository.save(userEntity);
    }

    // 상세보기 진입시 (유료 게시글 이라면)
    // 구매 여부 확인
    public boolean 구매여부확인(Integer userId, Integer boardId) {
        if (userId == null) {
            return false;
        }
        return purchaseRepository.existsByUserIdAndBoardId(userId, boardId);
    }
}

유료 게시글 기능 추가 (글 저장 시)


save-form.mustache⬇️

더보기
{{> layout/header}}
<div class="container p-5 flex-grow-1">
    <div class="card">
        <div class="card-header"><b>글쓰기 화면입니다</b></div>
        <div class="card-body">
            <form action="/board/save" method="post">
                <div class="mb-3">
                    <input type="text" name="title" class="form-control" placeholder="enter title">
                </div>
                <div class="mb-3">
                    <textarea type="text" name="content" rows="5" class="form-control" placeholder="enter content"></textarea>
                </div>

                <div class="mb-3">
                    <input type="checkbox" id="premium" name="premium" value="true" class="form-check-input">
                    <label for="premium" class="form-check-label">유료 게시글 등록(열람시 500포인트 차감 됨)</label>
                </div>

                <button class="btn btn-primary">글쓰기 완료</button>
            </form>
        </div>
    </div>
</div>

{{> layout/footer}}

update-from.mustache⬇️

더보기
{{> layout/header}}
<div class="container p-5 flex-grow-1">
    <div class="card">
        <div class="card-header"><b>글수정 화면입니다</b></div>
        <div class="card-body">
            <!--  익명 게시글 작성    -->
            <form action="/board/{{id}}/update" method="post">
                <div class="mb-3">
                    <input type="text" name="username"  class="form-control"
                           placeholder="enter username" value="{{board.username}}" readonly>
                </div>
                <div class="mb-3">
                    <input type="text" name="title" class="form-control"
                           placeholder="enter title" value="{{board.title}}">
                </div>
                <div class="mb-3">
                    <textarea type="text" name="content" rows="5" class="form-control" placeholder="enter title">
                        {{board.content}}
                    </textarea>
                </div>

                <div class="mb-3">
                    <!-- board.premium 컬럼 추가시 주석 해제     -->
                    <input type="checkbox" id="premium" name="premium" class="form-check-input"
                       {{!board.premium}} checked {{!board.premium}}>
                    <label for="premium" class="form-check-label">유료 게시글 등록(열람시 500포인트 차감 됨)</label>
                </div>

                <button class="btn btn-primary">글수정 완료</button>
            </form>
        </div>
    </div>
</div>

{{> layout/footer}}

BoardRequest⬇️

  • 유료 게시글 여부(체크박스 추가)
더보기
package com.tenco.blog.board;

import com.tenco.blog.user.User;
import lombok.Builder;
import lombok.Data;

// 요청 데이터를 담는 DTO 클래스
// 컨트롤러.. 비즈니스 .. 데이터 계층 사이에서 데이터 전송 역할 객체
public class BoardRequest {

    @Data
    @Builder
    public static class SaveDTO {

        private String title;
        private String content;
        private Boolean premium; // 유료 게시글 여부 (체크 박스)

        // 편의 기능 설계 가능
        // DTO 에서 Entity로 변환해주는 편의 메서드
        public Board toEntity(User user) {
            return Board.builder()
                    .title(title)
                    .user(user)
                    .content(content)
                    .premium(premium != null ? premium : false)
                    .build();
        }

        public void validate() {
            if(title == null || title.trim().isEmpty()) {
                throw new IllegalArgumentException("제목은 필수입니다");
            }
            if(content == null || content.length() < 3) {
                throw new IllegalArgumentException("내용은 3글자 이상 작성해야 합니다.");
            }
        }
    }

    // 내부 정적 클래스 게시글 수정 DTO 설계
    @Data
    public static class UpdateDTO {
        private String title;
        private String content;
        private Boolean premium; // 수정시 유료 게시글 여부 (체크 박스) 

        // 게시글 수정시 유효성 검사 편의 메서드
        public void validate() {
            if(title == null || title.trim().isEmpty()) {
                throw new IllegalArgumentException("제목은 필수입니다");
            }
            if(content== null || content.length() < 3) {
                throw new IllegalArgumentException("내용은 3글자 이상 작성해야 합니다.");
            }
        }

    }

}

Board 코드 추가⬇️

@ColumnDefault("false")
    @Builder.Default // 이게 없으면 다른 파일에서 Build 객체 생성시 null 값이 셋팅 됨
    private Boolean premium = false;
    
    . . .
    
    // 유료 여부도 함께 수정
        this.premium = (updateDTO.getPremium() != null ? updateDTO.getPremium() : false);
더보기
package com.tenco.blog.board;

import com.tenco.blog._core.errors.Exception403;
import com.tenco.blog._core.util.MyDateUtil;
import com.tenco.blog.user.User;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.CreationTimestamp;

import java.sql.Timestamp;

@Data 
@Entity
@Table(name = "board_tb")
@NoArgsConstructor
@AllArgsConstructor // 전체 멤벼 번수를 넣을 수 있는 생성자.
@Builder
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String title;
    private String content;

    @ColumnDefault("false")
    @Builder.Default // 이게 없으면 다른 파일에서 Build 객체 생성시 null 값이 셋팅 됨
    private Boolean premium = false;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id") // 외래키 컬럼명 표시 됨
    private User user;

    @CreationTimestamp
    private Timestamp createdAt;

    public String getTime() {
        return MyDateUtil.timestampFormat(createdAt);
    }

    public void update(BoardRequest.UpdateDTO updateDTO) {
        this.title = updateDTO.getTitle();
        this.content = updateDTO.getContent();
        // 유료 여부도 함께 수정
        this.premium = (updateDTO.getPremium() != null ? updateDTO.getPremium() : false);
    }

    public boolean isOwner(Integer sessionUserId) {
       if(!this.user.getId().equals(sessionUserId)) {
           throw new Exception403("본인이 작성한 게시글이 아닙니다");
       }
       return true;
    }
}

BoardController( 수정사항 x)⬇️

더보기
    /**
     * 게시글 작성 기능 요청
     * @return 페이지 반환
     * 주소설계 : http://localhost:8080/board/save-form
     */
    @PostMapping("/board/save")
    public String saveProc(BoardRequest.SaveDTO saveDTO, HttpSession session) {
        User sessionUser = (User) session.getAttribute(Define.SESSION_USER);
        saveDTO.validate();
        boardService.게시글작성(saveDTO, sessionUser);
        return "redirect:/";
    }

BoardService (수정 사항 x)⬇️

더보기
    /**
     * 게시글 작성
     * @param saveDTO
     * @param sessionUser (세션에서 가져온 사용자 정보)
     */
    @Transactional
    public void 게시글작성(BoardRequest.SaveDTO saveDTO, User sessionUser) {
        Board board = saveDTO.toEntity(sessionUser);
        boardRepository.save(board);
    }