[Spring Boot] 12. bankv1 (기능설계)

김건우's avatar
Apr 01, 2025
[Spring Boot] 12. bankv1 (기능설계)
회원가입
로그인
계좌목록
계좌생성
계좌이체
계좌상세

1. 회원가입 (/join)

: 회원가입 폼을 열고, 사용자가 가입 요청을 보냄
notion image
🔹 실행 흐름:
  1. /join-form 요청 → 회원가입 화면을 반환
  1. 사용자가 정보 입력 후 회원가입 요청 (Post/join)
  1. 중복된 username 체크 후, DB에 사용자 저장
🔹 [파일] UserController.java
@GetMapping("/join-form") public String joinForm() { return "user/join-form"; // 회원가입 페이지 } @PostMapping("/join") public String join(UserRequest.JoinDTO joinDTO) { userService.회원가입(joinDTO); return "redirect:/login-form"; // 회원가입 후 로그인 페이지로 이동 }
 
🔹 [파일] UserService.java
@Transactional public void 회원가입(UserRequest.JoinDTO joinDTO) { User user = userRepository.findByUsername(joinDTO.getUsername()); if (user != null) throw new RuntimeException("동일한 username이 존재합니다"); userRepository.save(joinDTO.getUsername(), joinDTO.getPassword(), joinDTO.getFullname()); }
🔹 [파일] UserRepository.java
public void save(String username, String password, String fullname) { Query query = em.createNativeQuery( "INSERT INTO user_tb(username, password, fullname, created_at) VALUES (?, ?, ?, now())" ); query.setParameter(1, username); query.setParameter(2, password); query.setParameter(3, fullname); query.executeUpdate(); }

: 사용자가 로그인하면 세션에 저장됨
notion image
🔹 실행 흐름:
  1. /login-form 요청 → 로그인 페이지 반환
  1. 사용자가 로그인 요청 (POST /login) → sessionUser 저장
🔹 [파일] UserController.java
@GetMapping("/login-form") public String loginForm() { return "user/login-form"; } @PostMapping("/login") public String login(UserRequest.LoginDTO loginDTO) { User sessionUser = userService.로그인(loginDTO); session.setAttribute("sessionUser", sessionUser); // 로그인 성공 시 세션 저장 return "redirect:/"; }
🔹 [파일] UserService.java
public User 로그인(UserRequest.LoginDTO loginDTO) { User user = userRepository.findByUsername(loginDTO.getUsername()); if (user == null) throw new RuntimeException("해당 username이 없습니다"); if (!(user.getPassword().equals(loginDTO.getPassword()))) throw new RuntimeException("비밀번호가 틀렸습니다"); return user; // 로그인 성공 시 유저 반환 }
🔹 [파일] UserRepository.java
public User findByUsername(String username) { Query query = em.createNativeQuery("SELECT * FROM user_tb WHERE username = ?", User.class); query.setParameter(1, username); try { return (User) query.getSingleResult(); } catch (Exception e) { return null; } }

3. 계좌 생성 페이지 이동 (/account/save-form)

: 로그인된 사용자가 계좌를 만들기 위해 /account/save-form으로 이동
notion image
🔹 실행 흐름:
  1. /account/save-form 요청 → 세션 확인 후 계좌 생성 폼 반환
🔹 [파일] AccountController.java
@GetMapping("/account/save-form") public String saveForm() { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("로그인 후 사용해주세요"); return "account/save-form"; }

4. 계좌 생성 (/account/save)

: 사용자가 계좌 정보를 입력하고, [계좌 생성] 버튼 클릭
🔹 실행 흐름:
  1. 계좌 생성 요청 (POST /account/save)
  1. 로그인 확인 → DB에 계좌 저장 → 계좌 목록 페이지로 이동
🔹 [파일] AccountController.java
@PostMapping("/account/save") public String save(AccountRequest.SaveDTO saveDTO) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("로그인 후 사용해주세요"); accountService.계좌생성(saveDTO, sessionUser.getId()); return "redirect:/account"; // 계좌 생성 후 계좌 목록으로 이동 }
🔹 [파일] AccountService.java
@Transactional public void 계좌생성(AccountRequest.SaveDTO saveDTO, int userId) { accountRepository.save(saveDTO.getNumber(), saveDTO.getPassword(), saveDTO.getBalance(), userId); }
🔹 [파일] AccountRepository.java
public void save(Integer number, String password, Integer balance, int userId) { Query query = em.createNativeQuery( "INSERT INTO account_tb(number, password, balance, user_id, created_at) VALUES (?, ?, ?, ?, now())" ); query.setParameter(1, number); query.setParameter(2, password); query.setParameter(3, balance); query.setParameter(4, userId); query.executeUpdate(); }

: 사용자가 본인의 계좌 목록을 확인
notion image
🔹 실행 흐름:
  1. /account 요청 → DB에서 로그인한 사용자의 계좌 목록 조회
  1. 조회된 데이터를 뷰에 전달하여 화면에 출력
🔹 [파일] AccountController.java
@GetMapping("/account") public String list(HttpServletRequest request) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("로그인 후 사용해주세요"); List<Account> accountList = accountService.나의계좌목록(sessionUser.getId()); request.setAttribute("models", accountList); return "account/list"; // 계좌 목록 페이지 }
🔹 [파일] AccountService.java
public List<Account> 나의계좌목록(Integer userId) { return accountRepository.findAllByUserId(userId); }
🔹 [파일] AccountRepository.java
public List<Account> findAllByUserId(Integer userId) { Query query = em.createNativeQuery( "SELECT * FROM account_tb WHERE user_id = ? ORDER BY created_at DESC", Account.class ); query.setParameter(1, userId); return query.getResultList(); }

6. 계좌 이체

notion image
📍 실행 흐름:
1. 이체 페이지로 이동
: 로그인한 사용자만 접근 가능
📂AccountController.java
@GetMapping("/account/transfer-form") public String transferForm() { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("로그인 후 사용해주세요"); return "account/transfer-form"; // Mustache 뷰 반환 }
  • sessionUser가 없으면 "로그인 후 사용해주세요" 예외 발생
  • 로그인된 사용자만 이체 페이지 (transfer-form.mustache)에 접근 가능
 
2. 사용자가 이체 요청
: 사용자가 출금 계좌, 입금 계좌, 금액, 비밀번호 입력 후 요청 📂 AccountController.java
@PostMapping("/account/transfer") public String transfer(AccountRequest.TransferDTO transferDTO) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("로그인 후 사용해주세요"); accountService.계좌이체(transferDTO, sessionUser.getId()); return "redirect:/"; // 계좌이체 후 홈으로 이동 }
  • transferDTO에서 출금 계좌번호, 입금 계좌번호, 금액, 비밀번호 가져오기
  • 로그인한 사용자만 요청 가능
 
3. 출금 & 입금 계좌 검증
: 출금 계좌 & 입금 계좌가 존재 하는지 확인 📂 AccountService.java
@Transactional public void 계좌이체(AccountRequest.TransferDTO transferDTO, int userId) { // 1️⃣ 출금 계좌 조회 Account withdrawAccount = accountRepository.findByNumber(transferDTO.getWithdrawNumber()); if (withdrawAccount == null) throw new RuntimeException("출금 계좌가 존재하지 않습니다"); // 2️⃣ 입금 계좌 조회 Account depositAccount = accountRepository.findByNumber(transferDTO.getDepositNumber()); if (depositAccount == null) throw new RuntimeException("입금 계좌가 존재하지 않습니다"); }
  • withdrawAccount 또는 depositAccount가 DB에 없으면 예외 발생
📂 AccountRepository.java
public Account findByNumber(Integer number) { Query query = em.createNativeQuery("SELECT * FROM account_tb WHERE number = ?", Account.class); query.setParameter(1, number); try { return (Account) query.getSingleResult(); } catch (Exception e) { return null; } }
  • 계좌번호로 계좌 정보를 조회하는 쿼리
 
4. 출금 계좌 검증
: 비밀번호 & 소유자 확인 📂 AccountService.java
// 3️⃣ 출금 계좌 잔액 검사 if (withdrawAccount.getBalance() < transferDTO.getAmount()) { throw new RuntimeException("출금 계좌의 잔액이 부족합니다"); } // 4️⃣ 출금 계좌 비밀번호 확인 if (!withdrawAccount.getPassword().equals(transferDTO.getWithdrawPassword())) { throw new RuntimeException("출금 계좌 비밀번호가 틀렸습니다"); } // 5️⃣ 출금 계좌 소유자 확인 if (!withdrawAccount.getUserId().equals(userId)) { throw new RuntimeException("출금 계좌의 권한이 없습니다"); }
  • 잔액 부족하면 예외 발생
  • 출금 계좌 비밀번호가 일치하지 않으면 예외 발생
  • 출금 계좌 주인이 현재 로그인한 사용자와 다르면 예외 발생
 
5. 잔액 업데이트
: 출금 계좌 잔액 차감, 입금 계좌 잔액 추가 📂 AccountService.java
// 6️⃣ 출금 계좌 잔액 업데이트 int withdrawBalance = withdrawAccount.getBalance() - transferDTO.getAmount(); accountRepository.updateByNumber(withdrawBalance, withdrawAccount.getPassword(), withdrawAccount.getNumber()); // 7️⃣ 입금 계좌 잔액 업데이트 int depositBalance = depositAccount.getBalance() + transferDTO.getAmount(); accountRepository.updateByNumber(depositBalance, depositAccount.getPassword(), depositAccount.getNumber());
📂 AccountRepository.java
public void updateByNumber(int balance, String password, int number) { Query query = em.createNativeQuery("UPDATE account_tb SET balance = ?, password = ? WHERE number = ?"); query.setParameter(1, balance); query.setParameter(2, password); query.setParameter(3, number); query.executeUpdate(); }
  • 출금 계좌 잔액 감소 (balance - amount)
  • 입금 계좌 잔액 증가 (balance + amount)
  • 계좌 번호 기준으로 UPDATE 실행
 
6. 거래 내역 저장
: 거래 내역을 history_tb 테이블에 저장 📂 AccountService.java
// 8️⃣ 거래 내역 저장 historyRepository.save(transferDTO.getWithdrawNumber(), transferDTO.getDepositNumber(), transferDTO.getAmount(), withdrawBalance);
📂 HistoryRepository.java
public void save(int withdrawNumber, int depositNumber, int amount, int withdrawBalance) { Query query = em.createNativeQuery( "INSERT INTO history_tb(withdraw_number, deposit_number, amount, withdraw_balance, created_at) VALUES (?, ?, ?, ?, now())" ); query.setParameter(1, withdrawNumber); query.setParameter(2, depositNumber); query.setParameter(3, amount); query.setParameter(4, withdrawBalance); query.executeUpdate(); }
  • 거래 내역을 history_tb 테이블에 저장

7. 계좌 상세 보기

: 사용자가 본인의 특정 계좌의 입출금 내역을 확인

[1] 복잡한 쿼리 작성 (한방 쿼리)

  • 거래 내역과 계좌, 사용자 테이블 조인
  • 특정 계좌 번호(1111)의 입출금 내역을 한 번의 쿼리로 조회
select dt.*, substr(created_at, 1, 16) created_at, withdraw_number w_number, deposit_number d_number, amount amount, case when withdraw_number = 1111 then withdraw_balance else deposit_balance end balance, case when withdraw_number = 1111 then '출금' else '입금' end type from history_tb ht inner join ( select at.number account_number, at.balance account_balance, ut.fullname account_owner from account_tb at inner join user_tb ut on at.user_id = ut.id where at.number = 1111 ) dt on 1=1 where deposit_number = 1111 or withdraw_number = 1111;
notion image
🔹 주요 동작
  • history_tb(거래 내역)에서 입출금 계좌가 해당 계좌 번호인 모든 거래 조회
  • account_tb, user_tb 조인 → 계좌 정보와 소유자 정보도 함께 가져옴
  • withdraw_number(출금 계좌)가 해당 계좌면 "출금", 아니라면 "입금"으로 구분

[2] 계좌 상세 DTO 객체 생성

AccountResponse.java
: 쿼리 결과를 객체로 변환하기 위한 DTO
package com.metacoding.bankv1.account; import lombok.AllArgsConstructor; import lombok.Data; public class AccountResponse { @AllArgsConstructor @Data public static class DetailDTO { private int accountNumber; private int accountBalance; private String accountOwner; private String createdAt; private int wNumber; private int dNumber; private int amount; private int balance; private String type; } }
@AllArgsConstructor → 모든 필드를 포함한 생성자 자동 생성

[3] Repository (DB 조회)

AccountRepository.java
public List<AccountResponse.DetailDTO> findAllByNumber(int number) { String sql = """ select dt.account_number, dt.account_balance, dt.account_owner, substr(created_at, 1, 16) created_at, withdraw_number w_number, deposit_number d_number, amount amount, case when withdraw_number = ? then withdraw_balance else deposit_balance end balance, case when withdraw_number = ? then '출금' else '입금' end type from history_tb ht inner join (select at.number account_number, at.balance account_balance, ut.fullname account_owner from account_tb at inner join user_tb ut on at.user_id = ut.id where at.number = ?) dt on 1=1 where deposit_number = ? or withdraw_number = ?; """; Query query = em.createNativeQuery(sql); query.setParameter(1, number); query.setParameter(2, number); query.setParameter(3, number); query.setParameter(4, number); query.setParameter(5, number); List<Object[]> obsList = query.getResultList(); List<AccountResponse.DetailDTO> detailList = new ArrayList<>(); for (Object[] obs : obsList) { AccountResponse.DetailDTO detail = new AccountResponse.DetailDTO( (int) obs[0], (int) obs[1], (String) obs[2], (String) obs[3], (int) obs[4], (int) obs[5], (int) obs[6], (int) obs[7], (String) obs[8] ); detailList.add(detail); } return detailList; }
설명
  • 한방 쿼리를 실행하여 거래 내역 조회
  • Object[] 배열을 DTO 객체(DetailDTO)로 변환하여 리스트에 저장
  • findAllByNumber(int number) : 특정 계좌의 모든 거래 내역 반환

[4] 복잡한 쿼리 테스트

AccountRepositoryTest.java
package com.metacoding.bankv1.account; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import java.util.List; @Import(AccountRepository.class) @DataJpaTest public class AccountRepositoryTest { @Autowired private AccountRepository accountRepository; @Test public void findAllByNumber_test() { int number = 1111; List<AccountResponse.DetailDTO> detailList = accountRepository.findAllByNumber(number); for (AccountResponse.DetailDTO detail : detailList) { System.out.println(detail); } } }
설명
  • findAllByNumber_test() : 특정 계좌(1111)의 거래 내역이 정상 조회되는지 테스트
  • @DataJpaTest : JPA 관련 테스트 환경 구성
  • @Import(AccountRepository.class) : 리포지토리 수동 등록

[5] 서비스 구현

AccountService.java
public List<AccountResponse.DetailDTO> 계좌상세보기(int number, String type, Integer sessionUserId) { // 1. 계좌 존재 확인 Account account = accountRepository.findByNumber(number); if (account == null) throw new RuntimeException("계좌가 존재하지 않습니다"); // 2. 계좌 주인 확인 account.계좌주인검사(sessionUserId); // 3. 조회해서 반환 List<AccountResponse.DetailDTO> detailDTOList = accountRepository.findAllByNumber(number); return detailDTOList; }
설명
  • 계좌상세보기()
      1. 계좌 존재 여부 확인
      1. 계좌 주인인지 검증
      1. 거래 내역 조회 후 반환

[6] 컨트롤러 구현

AccountController.java
@GetMapping("/account/{number}") public String detail(@PathVariable("number") int number, @RequestParam(value = "type", required = false, defaultValue = "전체") String type, HttpServletRequest request) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("로그인 후 사용해주세요"); List<AccountResponse.DetailDTO> detailList = accountService.계좌상세보기(number, type, sessionUser.getId()); request.setAttribute("models", detailList); return "account/detail"; // 계좌 상세 페이지 }
설명
  • @PathVariable("number") : URL에서 {number} 값을 추출
  • @RequestParam("type") : 입금/출금 필터링 (기본값 "전체")
  • 계좌 상세 정보를 조회하여 JSP로 전달

[7] 화면 출력

account/detail.mustache
{{>layout/header}} <div class="container mt-2"> <div class="mt-4 p-5 bg-light text-dark rounded-4"> <p>{{models.0.accountOwner}}님 계좌</p> <p>계좌번호 : {{models.0.accountNumber}}</p> <p>계좌잔액 : {{models.0.accountBalance}}원</p> </div> <table class="table table-hover"> <thead> <tr> <th>날짜</th> <th>출금계좌</th> <th>입금계좌</th> <th>금액</th> <th>계좌잔액</th> <th>출금/입금</th> </tr> </thead> <tbody> {{#models}} <tr> <td>{{createdAt}}</td> <td>{{wNumber}}</td> <td>{{dNumber}}</td> <td>{{amount}}원</td> <td>{{balance}}원</td> <td>{{type}}</td> </tr> {{/models}} </tbody> </table> </div> {{>layout/footer}}
설명
  • 거래 내역을 반복문({{#models}})을 이용해 출력
  • 출금/입금 내역을 테이블로 정리

8. 계좌 상세보기 (동적쿼리)

: 사용자가 특정 계좌의 거래 내역을 유형(전체, 입금, 출금)별로 필터링하여 조회

1. 화면에서 거래 내역 필터링 버튼 추가

detail.mustache
<div class="mt-3 mb-3"> <a href="/account/{{models.0.accountNumber}}?type=전체" class="btn btn-outline-primary">전체</a> <a href="/account/{{models.0.accountNumber}}?type=입금" class="btn btn-outline-primary">입금</a> <a href="/account/{{models.0.accountNumber}}?type=출금" class="btn btn-outline-primary">출금</a> </div>
설명
  • {{models.0.accountNumber}} : 계좌 번호를 URL 파라미터로 전달
  • 사용자가 버튼을 클릭하면 해당 유형(전체, 입금, 출금)에 따라 계좌 거래 내역을 필터링
  • ?type=입금, ?type=출금 등의 URL을 생성하여 필터링 기능 활성화

2. 서비스에서 필터링 타입(type) 전달

AccountService.java
List<AccountResponse.DetailDTO> detailDTOList = accountRepository.findAllByNumber(number, type);
설명
  • findAllByNumber(number, type) 호출 시 조회할 거래 유형을 인자로 전달
  • type 값에 따라 전체, 입금, 출금 쿼리를 선택적으로 실행
 

3. 동적 쿼리 작성

AccountRepository.java
public List<AccountResponse.DetailDTO> findAllByNumber(int number, String type) { String allSql = """ select dt.account_number, dt.account_balance, dt.account_owner, substr(created_at, 1, 16) created_at, withdraw_number w_number, deposit_number d_number, amount amount, case when withdraw_number = ? then withdraw_balance else deposit_balance end balance, case when withdraw_number = ? then '출금' else '입금' end type from history_tb ht inner join (select at.number account_number, at.balance account_balance, ut.fullname account_owner from account_tb at inner join user_tb ut on at.user_id = ut.id where at.number = ?) dt on 1=1 where deposit_number = ? or withdraw_number = ?; """; String withdrawSql = """ select dt.account_number, dt.account_balance, dt.account_owner, substr(created_at, 1, 16) created_at, withdraw_number w_number, deposit_number d_number, amount amount, withdraw_balance balance, '출금' type from history_tb ht inner join (select at.number account_number, at.balance account_balance, ut.fullname account_owner from account_tb at inner join user_tb ut on at.user_id = ut.id where at.number = ?) dt on 1=1 where withdraw_number = ?; """; String depositSql = """ select dt.account_number, dt.account_balance, dt.account_owner, substr(created_at, 1, 16) created_at, withdraw_number w_number, deposit_number d_number, amount amount, deposit_balance balance, '입금' type from history_tb ht inner join (select at.number account_number, at.balance account_balance, ut.fullname account_owner from account_tb at inner join user_tb ut on at.user_id = ut.id where at.number = ?) dt on 1=1 where deposit_number = ?; """; Query query = null; if (type.equals("입금")) { query = em.createNativeQuery(depositSql); query.setParameter(1, number); query.setParameter(2, number); } else if (type.equals("출금")) { query = em.createNativeQuery(withdrawSql); query.setParameter(1, number); query.setParameter(2, number); } else { query = em.createNativeQuery(allSql); query.setParameter(1, number); query.setParameter(2, number); query.setParameter(3, number); query.setParameter(4, number); query.setParameter(5, number); } List<Object[]> obsList = query.getResultList(); List<AccountResponse.DetailDTO> detailList = new ArrayList<>(); for (Object[] obs : obsList) { AccountResponse.DetailDTO detail = new AccountResponse.DetailDTO( (int) obs[0], (int) obs[1], (String) obs[2], (String) obs[3], (int) obs[4], (int) obs[5], (int) obs[6], (int) obs[7], (String) obs[8] ); detailList.add(detail); } return detailList; }
설명
  • 거래 유형(type)에 따라 서로 다른 SQL 실행
    • allSql : 전체 거래 내역 조회
    • withdrawSql : 출금 내역만 조회
    • depositSql : 입금 내역만 조회
  • Query query = em.createNativeQuery(...) 를 통해 SQL 동적으로 실행
  • query.setParameter(n, value)를 활용해 바인딩

4. 서비스에서 필터링 적용

AccountService.java
public List<AccountResponse.DetailDTO> 계좌상세보기(int number, String type, Integer sessionUserId) { // 1. 계좌 존재 확인 Account account = accountRepository.findByNumber(number); if (account == null) throw new RuntimeException("계좌가 존재하지 않습니다"); // 2. 계좌 주인 확인 account.계좌주인검사(sessionUserId); // 3. 필터링된 거래 내역 조회 List<AccountResponse.DetailDTO> detailDTOList = accountRepository.findAllByNumber(number, type); return detailDTOList; }
설명
  • findAllByNumber(number, type)를 호출하여 필터링된 내역 반환

5. 컨트롤러에서 필터링 값 처리

AccountController.java
@GetMapping("/account/{number}") public String detail(@PathVariable("number") int number, @RequestParam(value = "type", required = false, defaultValue = "전체") String type, HttpServletRequest request) { User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("로그인 후 사용해주세요"); List<AccountResponse.DetailDTO> detailList = accountService.계좌상세보기(number, type, sessionUser.getId()); request.setAttribute("models", detailList); return "account/detail"; // 계좌 상세 페이지 }
설명
  • @RequestParam(value = "type", required = false, defaultValue = "전체")
    • type 값을 URL에서 받아옴 (기본값 "전체")
    • ?type=입금 → 입금 내역만 조회
    • ?type=출금 → 출금 내역만 조회
  • 조회된 거래 내역을 models 속성으로 Mustache 템플릿에 전달
Share article

gunwoo