본문 바로가기

SpringBoot

SpringBoot(37) - Spring 종합 예제(가계부 내역 생성 / 목록 보기 / 내역 삭제 / 수입 및 지출 내역 통계 계산)

728x90
반응형

1) SpringBoot

    1-1) Spring 종합 예제

      1-1-1) 가계부 내역 생성 / 목록 보기 / 내역 삭제 / 수입 및 지출 내역 통계 계산

 

 

 

 

 

1) SpringBoot

1-1) Spring 종합 예제

※ 중요 체크사항

  • 영속성이 있다는 것은 "DB에서 값을 읽어온 경우"를 의미한다.
  • "@Transactional"의 자동 commit은 영속성이 있는 경우에 한해서만 "~repository.save();" 코드를 생략해도 DB(Entity)에 새로운 값이 저장된다.>> 그렇기 때문에 "Create(생성 혹은 작성)" 관련 Service method에는 반드시  "~repository.save();" 코드를 사용해야 하는 반면, "Update(수정)" 관련 Service method에는 "~repository.save();" 코드를 생략해도 자동 commit되어 알아서 DB에 수정사항이 저장된다.(물론 "~repository.save();" 코드를 생략해도 DB에 수정사항이 저장됨)

 

1-1-1) 가계부 내역 생성 / 목록 보기 / 내역 삭제 / 수입 및 지출 내역 통계 계산

* build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.3.2'
	id 'io.spring.dependency-management' version '1.1.6'
}

group = 'net.datasa'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

 

* application.properties

spring.application.name=ex_cashbook

server.port=8888
server.servlet.context-path=/

logging.level.root=info
logging.level.net.datasa.test=debug

# MySQL
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=root

# JPA
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.type.descriptor.sql=trace

 

 

 

(1) [src/main/java] - [net.datasa.test.security] 안에 WebSecurityConfig.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

/**
 * 시큐리티 환경설정
 */
// @Configuration : 제일 먼저 load되면서 설정할 수 있도록 해주는 역할을 한다
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    //로그인 없이 접근 가능 경로
    private static final String[] PUBLIC_URLS = {
            "/"                     //root
            , "/images/**"          //이미지 경로
            , "/css/**"             //CSS파일들
            , "/js/**"              //JavaSCript 파일들
            , "/member/join"        //회원가입
    };

    @Bean
    protected SecurityFilterChain config(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(author -> author
                .requestMatchers(PUBLIC_URLS).permitAll()
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults())
            .formLogin(formLogin -> formLogin
                    .loginPage("/member/loginForm")  // 로그인 없이 접근하려고 하면 로그인 페이지로 보내는 역할을 함
                    .usernameParameter("id")
                    .passwordParameter("password")
                    .loginProcessingUrl("/member/login")
                    .defaultSuccessUrl("/", true)
                    .permitAll()
            )
            .logout(logout -> logout
                    .logoutUrl("/member/logout")
                    .logoutSuccessUrl("/")
            );

        http
            .cors(AbstractHttpConfigurer::disable)
            .csrf(AbstractHttpConfigurer::disable);

        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

 

 

(2) [src/main/java] - [net.datasa.test.security] 안에 AuthenticatedUserDetailsService.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.security;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.test.domain.entity.MemberEntity;
import net.datasa.test.repository.MemberRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * 사용자 인증 처리
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthenticatedUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    // "WebSecurityConfig"는 먼저 "UserDetailsService" 객체를 확인하는데 이 중에서
    // "loadUserByUsername(String id)" method를 만들어둬야 "WebSecurityConfig"에서 아이디를 점검할 수 있음
    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
        log.info("로그인 시도 : {}", id);

        MemberEntity memberEntity = memberRepository.findById(id)
                .orElseThrow(() -> {
                    return new UsernameNotFoundException(id + " : 없는 ID입니다.");
                });

        log.debug("조회정보 : {}", memberEntity);

        // 인증정보 생성
        AuthenticatedUser user = AuthenticatedUser.builder()
                .id(memberEntity.getMemberId())
                .password(memberEntity.getMemberPw())
                .build();

        return user;
    }
}

 

 

(3) [src/main/java] - [net.datasa.test.security] 안에 AuthenticatedUser.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.security;

import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Collections;

/**
 * 회원 인증 정보 객체
 */

@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class AuthenticatedUser implements UserDetails {
    private String id;
    private String password;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
    }

    @Override
    public String getUsername() {
        return id;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

 

(4) [src/main/resources] - [templates] 안에 home.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html lang="ko"
	  xmlns:th="http://www.thymeleaf.org"
	  xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>cashbook</title>
</head>
<body>
	<h1>[ 가계부 ]</h1>

	<th:block sec:authorize="not isAuthenticated()">
		<p><a th:href="@{/member/join}">회원가입</a></p>
		<p><a th:href="@{/member/login}">로그인</a></p>
	</th:block>
	<th:block sec:authorize="isAuthenticated()">
		<p><a th:href="@{/member/logout}">로그아웃</a></p>
		<p><a th:href="@{/cashbook/view}">가계부 보기</a></p>
		<p><a th:href="@{/cashbook/stats}">통계</a></p>
	</th:block>

	<pre>
		SpringBoot & JPA & Ajax
		개인 가계부 서비스
		날짜별 수입, 지출 내역 등록 및 조회, 삭제
		연도별 수입, 지출 내역 통계
	</pre>

</body>
</html>

 

 

(5) [src/main/resources] - [templates] 안에 joinForm.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html lang="ko"
	  xmlns:th="http://www.thymeleaf.org"
	  xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
	<meta charset="UTF-8">
	<title>회원가입</title>
	<!-- jQuery 파일 불러오기 -->
	<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
	<script>
		$(document).ready(function() {

			//가입폼의 submit 버튼을 클릭하면 실행됨
			$('#joinForm').submit(function () {
				//아이디와 비밀번호는 반드시 입력해서 가입신청해야 함
				if ($('#memberId').val() == '' || $('#memberPw').val() == '') {
					alert('ID와 비밀번호를 입력하세요.');
					return false;
				}
				//서버로 폼을 전송
				return true;
			});
		});
	</script>
</head>
<body>
	<h1>[ 회원가입 ]</h1>

	<form id="joinForm" th:action="@{/member/join}" method="post">
		<p>
			<label>ID</label>
			<input type="text" name="memberId" id="memberId">
		</p>
		<p>
			<label>Password</label>
			<input type="password" name="memberPw" id="memberPw">
		</p>
		<p>
			<input type="submit" value="가입">
		</p>
	</form>
	
</body>
</html>

 

 

(6) [src/main/resources] - [templates] 안에 loginForm.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html lang="ko"
	  xmlns:th="http://www.thymeleaf.org"
	  xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
	<meta charset="UTF-8">
	<title>로그인</title>
	<!-- jQuery 파일 불러오기 -->
	<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
	<script>
		$(document).ready(function() {
			//로그인시 아이디와 비밀번호는 반드시 입력해야 함
			$('#loginForm').submit(function() {
				if ($('#id').val() == '' || $('#password').val() == '') {
					alert('ID와 비밀번호를 입력하세요.');
					return false;
				}
				return true;
			});
		});
	</script>
</head>
<body>
	<h1>[ 로그인 ]</h1>

	<!-- 로그인폼의 아이디 입력란의 name은 WebSecurityConfig에서 지정한 .usernameParameter("id") 부분과 일치해야 함
		비밀번호 입력란의 name은 .passwordParameter("password") 부분과 일치해야 함 -->
	<form id="loginForm" th:action="@{/member/login}" method="post">
		<p>
			<label>ID</label>
			<input type="text" name="id" id="id">
		</p>
		<p>
			<label>Password</label>
			<input type="password" name="password" id="password">
		</p>
		<p>
			<button type="submit">Login</button>
		</p>
	</form>

</body>
</html>

 

 

(7) [src/main/java] - [net.datasa.test.domain.entity] 안에 MemberEntity.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.domain.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 회원 정보 Entity
 */
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "cashbook_member")
public class MemberEntity {
    //회원 아이디
    @Id
    @Column(name = "member_id", length = 30)
    String memberId;

    //비밀번호
    @Column(name = "member_pw", nullable = false, length = 100)
    String memberPw;
}

 

 

(8) [src/main/java] - [net.datasa.test.domain.dto] 안에 MemberDTO.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.domain.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 회원 정보 DTO
 */
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberDTO {
    String memberId;
    String memberPw;
}

 

 

(9) [src/main/java] - [net.datasa.test.repository] 안에 MemberRepository.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.repository;

import net.datasa.test.domain.entity.MemberEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
 * 회원 정보 Repository
 */

@Repository
public interface MemberRepository extends JpaRepository<MemberEntity, String> {

}

 

 

(10) [src/main/java] - [net.datasa.test.controller] 안에 MemberController.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.test.domain.dto.MemberDTO;
import net.datasa.test.service.MemberService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

/**
 * 회원 관련 콘트롤러
 */

@Slf4j
@RequiredArgsConstructor
@Controller
@RequestMapping("member")
public class MemberController {

    //회원정보 처리 서비스
    private final MemberService memberService;

    /**
     * 로그인폼으로 이동
     * @return 로그인폼 HTML 파일
     */
    @GetMapping("loginForm")
    public String loginForm() {
        return "loginForm";
    }

    /**
     * 가입폼으로 이동
     * @return 가입폼 HTML 파일
     */
    @GetMapping("join")
    public String join() {
        return "joinForm";
    }

    /**
     * 가입 정보를 입력받아 회원으로 등록하고 메인 페이지로 리다이렉트한다.
     * @param member    사용자가 입력한 가입 정보
     * @return          메인 페이지 리다이렉트 URL
     */
    @PostMapping("join")
    public String join(@ModelAttribute MemberDTO member) {
        log.debug("전달된 가입정보 : ", member);

        memberService.join(member);
        return "redirect:/";
    }
}

 

 

(11) [src/main/java] - [net.datasa.test.service] 안에 MemberService.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.service;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import net.datasa.test.domain.dto.MemberDTO;
import net.datasa.test.domain.entity.MemberEntity;
import net.datasa.test.repository.MemberRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

/**
 * 회원정보 서비스
 */
@RequiredArgsConstructor
@Service
@Transactional
public class MemberService {

    //WebSecurityConfig에서 생성한 암호화 인코더
    private final BCryptPasswordEncoder passwordEncoder;

    //회원 정보 관련 리포지토리
    private final MemberRepository memberRepository;

    /**
     * 회원 가입 처리
     * @param dto 회원 정보
     */
    public void join(MemberDTO dto) {
        //전달된 회원정보를 테이블에 저장한다.
        MemberEntity entity = MemberEntity.builder()
                .memberId(dto.getMemberId())
                .memberPw(passwordEncoder.encode(dto.getMemberPw()))
                .build();

        memberRepository.save(entity);
    }

}

 

 

(12) [src/main/resources] - [templates] 안에 cashbook.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html lang="ko"
	  xmlns:th="http://www.thymeleaf.org"
	  xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
	<meta charset="UTF-8">
	<title>cashbook</title>
	<style>
		#memo {
			width: 200px;
		}
		th {
			width : 100px;
			text-align : left;
		}
	</style>
	<!-- jQuery 파일 불러오기 -->
	<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
	<script>
		$(document).ready(function() {
			//페이지가 로딩된후 가계부 내역 불러오기
			list();
			//입력 버튼의 클릭 이벤트 처리
			$('#inputButton').click(cashbookInput);
		});

		//가계부 입력
		function cashbookInput() {
			//입력한 날짜와 구분, 금액, 내역을 읽어온다
			let cashbookData = {
				inputDate : $('#inputDate').val(),
				type: $('#type').val(),
				amount: $('#amount').val(),
				memo : $('#memo').val()
			};

			$.ajax({
				url: 'input',
				type: 'post',
				data : cashbookData,
				success: function() {
					//정상 처리되면 안내 메시지를 출력한다.
					alert('저장되었습니다.');
					//입력한 금액과 내역 문자열을 지운다.
					$('#amount').val('');
					$('#memo').val('');
					//변경된 가계부 내역을 다시 불러온다.
					list();
				},
				error : function(e) {
					alert('저장 실패했습니다.')
				}
			});
		}

		//가계부 목록 출력
		function list() {
			$.ajax({
				url: 'list',
				type: 'get',
				success: function(list) {
					//가계부 출력 영역을 초기화한다.
					$('#output').empty();

					//서버에서 가져온 목록을 반복문으로 출력한다.
					//각 행에 "삭제" 버튼을 생성하고 클래스명을 "deleteButton"으로 지정한다.
					$(list).each(function(i, obj) {
						let html = `
							<tr>
								<td>${obj.inputDate}</td>
								<td>${obj.type}</td>
								<td>${obj.amount}</td>
								<td>${obj.memo}</td>
								<td><button class="deleteButton" data-num="${obj.infoNum}">삭제</button></td>
							</tr>
						`;
						//생성된 문자열을 출력영역에 표시한다.
						$('#output').append(html)
					});

					//새로 생성된 삭제 버튼들에 클릭 이벤트처리를 한다.
					$('.deleteButton').click(cashbookDelete);
				},
				error : function(e) {
					alert('조회 실패');
				}
			});
		}

		//가계부 내역 삭제
		function cashbookDelete() {
			//삭제여부를 묻고 취소를 선택하면 함수를 종료한다.
			if (!confirm('내역을 삭제하시겠습니까?')) {
				return;
			}

			//클릭한 버튼의 "data-num"속성의 값을 읽어온다.
			// let infoNum = $(this).attr('data-num');
			let infoNum = $(this).data('num');

			$.ajax({
				url: 'delete',
				type: 'post',
				data: {infoNum: infoNum},
				success: function() {
					alert('삭제되었습니다.');
					//내역을 삭제한 후에는 가계부 목록을 다시 불러온다.
					list();
				},
				error : function() {
					alert('삭제 실패');
				}
			});
		}
	</script>
</head>
<body>
	<h1>[ <span th:text="${#authentication.name}"></span>님의 가계부 ]</h1>

	<!-- 가계부 정보 입력 -->
	<div>
		<input type="date" id="inputDate">
		<select id="type">
			<option value="수입">수입</option>
			<option value="지출">지출</option>
		</select>
		<input type="number" id="amount" placeholder="0">
		<input type="text" id="memo" placeholder="내역을 입력하세요.">
		<button id="inputButton">입력</button>
	</div>
	<br>

	<!-- 가계부 출력 영역 -->
	<table>
		<thead>
			<tr>
				<th>날짜</th>
				<th>구분</th>
				<th>금액</th>
				<th>내역</th>
				<th></th>
			</tr>
		</thead>
		<tbody id="output">
			<!-- 가계부 내역 목록 출력영역 -->
		</tbody>
	</table>
</body>
</html>

 

 

(13) [src/main/resources] - [templates] 안에 stats.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html lang="ko"
	  xmlns:th="http://www.thymeleaf.org"
	  xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
	<meta charset="UTF-8">
	<title>cashbook</title>
	<!-- jQuery 파일 불러오기 -->
	<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
	<script>
		$(document).ready(function() {
			//계산하기 버튼을 클릭하면 실행된다.
			//서버로 연도와 구분을 전달하여 요청을 보낸다.
			$('#bt').click(function() {
				$.ajax({
					url : 'getTotal',
					type : 'post',
					data : {year: $('#year').val(), type: $('#type').val()},
					success : function(total) {
						//서버에서 리턴된 합계금액을 지정한 위치에 출력한다.
						$('#output').html(total + '원');
					}
				});
			});
		});
	</script>
</head>
<body>
	<h1>[ <span th:text="${#authentication.name}"></span>님의 수입/지출 내역 통계 ]</h1>

	<!-- 조회 조건 -->
	<div>
		연도
			<select id="year">
				<option value="2024" selected>2024</option>
				<option value="2023">2023</option>
				<option value="2022">2022</option>
			</select>
		구분
			<select id="type">
				<option value="수입" selected>수입</option>
				<option value="지출">지출</option>
			</select>
			<button type="button" id="bt">계산하기</button>
	</div>

	<!-- 금액 출력 부분 -->
	<div>
		<h1 id="output"></h1>
	</div>
</body>
</html>

 

 

(14) [src/main/java] - [net.datasa.test.domain.entity] 안에 CashbookEntity.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.domain.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;

/**
 * 가계부 정보 엔티티
 */
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "cashbook_info")
public class CashbookEntity {
    //일련번호
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "info_num")
    private Integer infoNum;

    //작성자 정보 (외래키)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", referencedColumnName = "member_id")
    private MemberEntity member;

    //구분
    @Column(name = "type", nullable = false, length = 20)
    private String type;

    //내역
    @Column(name = "memo", length = 1000)
    private String memo;

    //금액
    @Column(name = "amount", columnDefinition = "int default 0")
    private Integer amount = 0;

    //날짜
    @Column(name = "input_date", nullable = false, columnDefinition = "date")
    private LocalDate inputDate;
}

 

 

(15) [src/main/java] - [net.datasa.test.domain.dto] 안에 CashbookDTO.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.domain.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDate;

/**
 * 가계부 정보 DTO
 */
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CashbookDTO {
    private Integer infoNum;    //일련번호
    private String memberId;    //사용자 아이디
    private String type;        //구분
    private String memo;        //내역
    private Integer amount;     //금액
    LocalDate inputDate;        //날짜
}

 

 

(16) [src/main/java] - [net.datasa.test.repository] 안에 CashbookRepository.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.repository;

import net.datasa.test.domain.entity.CashbookEntity;
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.List;
import java.util.Optional;

/**
 * 게시판 관련 repository
 */

@Repository
public interface CashbookRepository extends JpaRepository<CashbookEntity, Integer> {

    //한 사용자의 모든 가계부 내역을 날짜순으로 조회
    List<CashbookEntity> findByMember_MemberIdOrderByInputDate(String memberId);
    
    // select * from cashbook_info where member_id = 'xxx' order by input_date;

    //한 사용자의 특정 연도의 수입 또는 지출의 합계를 계산
    //전달받은 값을 Query의 지정한 곳에 넣어서 쿼리를 실행한다.
    //쿼리는 Entity 클래스의 이름을 기준으로 작성한다.
    //전달받은 값은 @Param에서 지정한 이름과 같은 (예) :year ) 부분에 대입된다.
    //매개변수의 이름은 상관없다. (예) y)
    // """는 문자열을 여러 줄에 걸쳐서 입력할 수 있게 해준다.
    @Query("""
        SELECT SUM(c.amount) FROM CashbookEntity c 
        WHERE 
            c.type = :type 
            AND YEAR(c.inputDate) = :year
            AND c.member.memberId = :id
     """)
    Optional<Integer> sumAmountByTypeAndYear(@Param("year") int y, @Param("type") String t, @Param("id") String id);
    // "Optional"은 'null'일 수도 있는 데이터를 처리해주는 역할을 한다.('null'일 경우의 처리를 위해 필요함)

    /*
     // DB에서 위의 SQL문(@Query) 점검용 쿼리문
     SELECT SUM(c.amount) FROM cashbook_info c 
     WHERE 
         c.type = '수입' 
         AND YEAR(c.inputDate) = 2024
         AND c.member_id = 'aaa'
     */
    
}

 

 

(17) [src/main/java] - [net.datasa.test.controller] 안에 CashbookController.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.test.domain.dto.CashbookDTO;
import net.datasa.test.security.AuthenticatedUser;
import net.datasa.test.service.CashbookService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 가계부 관련 콘트롤러
 */

@Slf4j
@RequiredArgsConstructor
@Controller
@RequestMapping("cashbook")
public class CashbookController {

    //가계부 관련 처리 서비스
    private final CashbookService cashbookService;

    /**
     * 가계부 페이지로 이동
     * @return 가계부 출력 페이지
     */
    @GetMapping("view")
    public String listAll() {
        return "cashbook";
    }

    /**
     * 가계부 내역 저장
     * @param cashbookDTO   작성한 가계부 내역
     * @param user          로그인한 사용자 정보
     */
    @ResponseBody
    @PostMapping("input")
    public void write(CashbookDTO cashbookDTO
            , @AuthenticationPrincipal AuthenticatedUser user) {

        //작성한 내용에 사용자 아이디 추가
        cashbookDTO.setMemberId(user.getUsername());
        log.debug("저장할 정보 : {}", cashbookDTO);

        //서비스로 전달하여 저장
        cashbookService.input(cashbookDTO);
    }

    /**
     * 가계부 내역 목록 조회
     * @param user  로그인한 사용자 정보
     * @return  가계부 내역 리스트
     */
    @ResponseBody
    @GetMapping("list")
    public List<CashbookDTO> list(@AuthenticationPrincipal AuthenticatedUser user) {
        //서비스로 사용자 아이디를 전달하여 해당 아이디의 수입,지출 내역을 목록으로 리턴한다.
        List<CashbookDTO> list = cashbookService.getList(user.getUsername());
        return list;
    }

    /**
     * 가계부 내역 삭제
     * @param infoNum   삭제할 번호
     * @param user      사용자 정보
     */
    @ResponseBody
    @PostMapping("delete")
    public void delete(@RequestParam("infoNum") Integer infoNum
            , @AuthenticationPrincipal AuthenticatedUser user) {
        log.debug("삭제할 번호 : {}, 로그인 아이디 : {}", infoNum, user.getUsername());

        //서비스로 가계부내역 번호와 사용자 아이디를 전달하여 삭제한다.
        cashbookService.delete(infoNum, user.getUsername()) ;
    }


    /**
     * 수입 지출 통계 페이지로 이동
     * @return 통계 페이지 HTML 파일
     */
    @GetMapping("stats")
    public String stats() {

        return "stats";
    }

    /**
     * 전달된 연도의 수입 또는 지출 합계 금액을 구하여 리턴
     * @param year          연도
     * @param type          수입 또는 지출
     * @param user          로그인한 사용자 정보
     * @return              합계 금액
     */
    @ResponseBody
    @PostMapping("getTotal")
    public Integer getTotal(
            @RequestParam("year") Integer year
            , @RequestParam("type") String type
            , @AuthenticationPrincipal AuthenticatedUser user) {

        log.debug("연도: {}, 구분: {}, 사용자아이디: {}", year, type, user.getUsername());

        //서비스로 연도, 구분, 아이디를 전달하여 합계 금액을 구한다.
        int total = cashbookService.getTotal(year, type, user.getUsername());
        return total;
    }

}

 

 

(18) [src/main/java] - [net.datasa.test.service] 안에 CashbookService.java 파일 생성 후 아래와 같이 작성

package net.datasa.test.service;

import jakarta.persistence.EntityNotFoundException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.test.domain.dto.CashbookDTO;
import net.datasa.test.domain.entity.CashbookEntity;
import net.datasa.test.domain.entity.MemberEntity;
import net.datasa.test.repository.CashbookRepository;
import net.datasa.test.repository.MemberRepository;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

/**
 * 가계부 관련 서비스
 */
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class CashbookService {

    private final CashbookRepository cashbookRepository;
    private final MemberRepository memberRepository;

    /**
     * 가계부 정보 저장
     *
     * @param cashbookDTO 저장할 정보
     */
    public void input(CashbookDTO cashbookDTO) {

        //로그인한 사용자의 아이디로 회원정보를 조회한다.
        MemberEntity memberEntity = memberRepository.findById(cashbookDTO.getMemberId())
                .orElseThrow(() -> new EntityNotFoundException("회원아이디가 없습니다."));

        //엔티티 객체를 생성하여 전달된 DTO의 값을 저장한다.
        CashbookEntity cashbookEntity = new CashbookEntity();
        cashbookEntity.setMember(memberEntity);
        cashbookEntity.setType(cashbookDTO.getType());
        cashbookEntity.setMemo(cashbookDTO.getMemo());
        cashbookEntity.setAmount(cashbookDTO.getAmount());
        cashbookEntity.setInputDate(cashbookDTO.getInputDate());

        log.debug("저장되는 엔티티 : {}", cashbookEntity);

        //엔티티의 값을 테이블에 저장한다.
        cashbookRepository.save(cashbookEntity);
    }

    /**
     * 로그인한 사용자의 가계부 내역 조회
     * @param id 사용자 아이디
     * @return 가계부 내역 목록
     */
    public List<CashbookDTO> getList(String id) {
        //로그인한 사용자의 가계부 내역을 날짜순으로 정렬하여 조회한다.
        List<CashbookEntity> entityList = cashbookRepository.findByMember_MemberIdOrderByInputDate(id);
        //DTO를 저장할 리스트 생성
        List<CashbookDTO> dtoList = new ArrayList<>();

        //DB에서 조회한 해당 사용자의 가계부 내역을 DTO객체로 변환하여 ArrayList에 저장한다.
        for (CashbookEntity entity : entityList) {
            CashbookDTO dto = CashbookDTO.builder()
                    .infoNum(entity.getInfoNum())
                    .memberId(entity.getMember().getMemberId())
                    .type(entity.getType())
                    .amount(entity.getAmount())
                    .memo(entity.getMemo())
                    .inputDate(entity.getInputDate())
                    .build();
            dtoList.add(dto);
        }
        //DTO객체들이 저장된 리스트를 리턴한다.
        return dtoList;
    }

    /**
     * 가계부 내역 삭제
     * @param infoNum   삭제할 번호
     * @param username  로그인한 사용자 아이디
     */
    public void delete(Integer infoNum, String username) {
        //전달받은 번호의 가계부 내역을 조회한다.
        CashbookEntity entity = cashbookRepository.findById(infoNum)
                .orElseThrow(() -> new EntityNotFoundException("해당 번호의 내역이 없습니다."));

        //로그인한 사용자 본인의 데이터인지 확인하고, 아니면 예외를 발생시킨다.
        if (!entity.getMember().getMemberId().equals(username)) {
            throw new RuntimeException("삭제 권한이 없습니다.");
        }
        //DB 테이블에서 해당 정보를 삭제한다.
        cashbookRepository.deleteById(infoNum);
    }

    /**
     * 전달된 연도의 수입 또는 지출 합계 금액을 구하여 리턴
     * @param year          연도
     * @param type          수입 또는 지출
     * @param id            로그인한 사용자 아이디
     * @return              합계 금액
     */
    public int getTotal(Integer year, String type, String id) {
        //테이블의 정보를 지정한 조건으로 필터링하여 합계금액을 구한다.
        //해당되는 데이터가 없는 경우(null)는 0으로 처리한다.
        Integer total = cashbookRepository.sumAmountByTypeAndYear(year, type, id)
                .orElse(0);
        return total;
    }

}

 

 

(19) 결과 화면

첫 접속 화면

 

 

"가계부 보기" 화면

 

 

"통계" 화면