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) 결과 화면
첫 접속 화면
"가계부 보기" 화면
"통계" 화면