Spring Boot, JPA를 활용한 미니 프로젝트

Ckd gus·2026년 2월 3일

Project

목록 보기
1/6

Spring MVC + JPA 기반 직원 관리 프로그램 만들기

스프링 MVC 패턴과 JPA를 활용하여 직원 관리 프로그램을 간단하게 구현해보았다. CRUD 전반, JPA 연관관계 설정, 전역 예외 처리까지 한 번에 경험하는 것을 목표로 한 미니 프로젝트이다.

프로젝트 개요

  • Backend: Spring Boot, Spring MVC, Spring Data JPA
  • DB 관계: 직원 ↔ 부서 (ManyToOne / OneToMany)
  • Frontend: AI 활용
  • 기타: 사용자 정의 예외, 전역 예외 처리

테이블 설계

Employee 테이블

  • id : Long (PK, Auto Increment)
  • name : String
  • birthDay : LocalDate
  • createDate : LocalDate
  • department_id : Long (FK)

Department 테이블

  • id : Long (PK)
  • departmentName : String
    부서 테이블이 삭제되면 해당 부서에 소속된 사원도 함께 삭제되도록 설계하였다.

구현 기능

  • 사원 전체 리스트 출력
  • 사원 추가 / 삭제 / 수정 / 상세 조회
  • 사원 ID 검색
  • 부서 리스트 출력
  • 부서 추가 / 삭제 (삭제 시 사원 자동 삭제)
  • 전역 예외 처리 페이지
  • 검증 로직 전반
  • 프론트엔드는 AI 활용

프로젝트 구조

  • templates : HTML
  • static : CSS
  • domain : Entity
  • repository : JPA Repository
  • service : 비즈니스 로직
  • dto : DTO
  • controller : MVC Controller
  • customException : 사용자 정의 예외
  • application.yml

Domain (Entity)

각각 사원(Employee)과 부서(Department) 도메인이다. Lombok을 활용하여 Getter/Setter/기본 생성자를 구성했고, @Entity로 DB와 매핑했다. 또한 @Table로 테이블 명을 지정했다. 연관관계는 직원이 부서를 참조하도록 ManyToOne을, 부서가 직원 목록을 갖도록 OneToMany를 구성했다. 특히 부서 삭제 시 해당 부서의 사원도 함께 삭제되도록 cascade = CascadeType.REMOVE를 적용했다.

Employee

package org.example.demo7.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDate;

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long emid;

    private String name;
    private LocalDate birthDay;
    private LocalDate createDate;

    // 연관관계 설정
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id", nullable = false)  // 외래키 지정
    private Department department;

    // 사원정보 추가 생성자
    public Employee(String name, LocalDate birthDay, LocalDate createDate) {
        this.name = name;
        this.birthDay = birthDay;
        this.createDate = createDate;
    }
}
Department
package org.example.demo7.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

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

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "department")
public class Department {

    @Id
    private Long id;

    private String departmentName;

    // 연관관계 설정
    @OneToMany(mappedBy = "department", cascade = CascadeType.REMOVE)
    private List<Employee> employees = new ArrayList<>();
}
RepositoryRepository는 기본적인 CRUD만 필요하다고 판단하여 JpaRepository만 상속받아 사용했다. DepartmentRepository에는 부서명 중복 여부를 체크하기 위해 existsByDepartmentName만 추가했다.

EmployeeRepository
package org.example.demo7.repository;

import org.example.demo7.domain.Employee;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
}
DepartmentRepository
package org.example.demo7.repository;

import org.example.demo7.domain.Department;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface DepartmentRepository extends JpaRepository<Department, Long> {
    boolean existsByDepartmentName(String departmentName);
}
DTO
원래는 DTO를 최대한 활용해보려 했으나, 구현 과정에서 사용처가 많지 않아 전체 조회용 DTO 하나만 구현했다. 전체 리스트 반환 시 사원 테이블 정보와 부서 테이블 정보를 동시에 전달하기 위한 목적이다.

package org.example.demo7.Dto;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDate;

@Getter
@Setter
@NoArgsConstructor
public class FindAllDto {
    private Long id;
    private String name;
    private LocalDate birthDate;
    private LocalDate hireDate;
    private Long departmentId;
    private String departmentName;
}

Service

서비스 레이어에는 요구사항에 맞는 메서드들을 정의했다. 데이터 변경이 발생하는 로직에는 @Transactional을 적용해 데이터 일관성을 유지했고, 값 누락/중복 같은 케이스에서는 예외를 던지도록 구성했다.

EmployeeService

package org.example.demo7.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.demo7.Dto.FindAllDto;
import org.example.demo7.domain.Department;
import org.example.demo7.domain.Employee;
import org.example.demo7.repository.DepartmentRepository;
import org.example.demo7.repository.EmployeeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class EmployeeService {

    private final EmployeeRepository employeeRepository;
    private final DepartmentRepository departmentRepository;

    // 전체 리스트
    public List<FindAllDto> findAllEmployees() {
        List<Employee> employees = employeeRepository.findAll();

        // 스트림으로 DTO 변환
        return employees.stream()
                .map(this::convertTo)
                .collect(Collectors.toList());
    }

    // DTO 변환
    private FindAllDto convertTo(Employee employee) {
        FindAllDto dto = new FindAllDto();
        dto.setId(employee.getEmid());
        dto.setName(employee.getName());
        dto.setBirthDate(employee.getBirthDay());
        dto.setHireDate(employee.getCreateDate());

        if (employee.getDepartment() != null) {
            dto.setDepartmentId(employee.getDepartment().getId());
            dto.setDepartmentName(employee.getDepartment().getDepartmentName());
        } else {
            dto.setDepartmentId(null);
            dto.setDepartmentName("부서 미배정");
        }

        return dto;
    }

    // 생성
    @Transactional
    public void createEmployee(Employee employee, Long departmentId) {
        // 부서 조회
        if (departmentId == null) {
            throw new NullPointerException("departmentId is null");
        }
        Department department = departmentRepository.findById(departmentId)
                .orElseThrow(() -> new RuntimeException("Department Not Found"));

        // 사원 정보(필요 필드만 재구성)
        Employee employeedata = new Employee(
                employee.getName(),
                employee.getBirthDay(),
                employee.getCreateDate()
        );

        if (employeedata.getBirthDay() == null || employeedata.getCreateDate() == null) {
            throw new IllegalArgumentException("생일 또는 입사일을 입력해주세요");
        }

        // 외래키 참조
        employeedata.setDepartment(department);

        // 저장
        employeeRepository.save(employeedata);
        log.info("success for createEmployee");
    }

    // 상세보기
    public Employee findById(Long id) {
        return employeeRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Employee Not Found"));
    }

    // 사원정보 수정
    @Transactional
    public Employee updateEmployee(Long id, Employee employee, Long departmentId) {
        if (employee.getBirthDay() == null) {
            throw new NullPointerException("employee is null");
        }

        Employee updatedEmployee = employeeRepository.findById(employee.getEmid())
                .orElseThrow(() -> new RuntimeException("Employee Not Found"));

        updatedEmployee.setName(employee.getName());
        updatedEmployee.setBirthDay(employee.getBirthDay());
        updatedEmployee.setCreateDate(employee.getCreateDate());

        if (departmentId != null) {
            Department department = departmentRepository.findById(departmentId)
                    .orElseThrow(() -> new RuntimeException("Department Not Found"));
            updatedEmployee.setDepartment(department);
        }

        return updatedEmployee;
    }

    // 삭제
    @Transactional
    public void deleteEmployee(Long id) {
        if (employeeRepository.existsById(id)) {
            employeeRepository.deleteById(id);
        } else {
            throw new RuntimeException("Employee Not Found");
        }
    }
}
DepartmentService
package org.example.demo7.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.demo7.customException.DepartmentIdException;
import org.example.demo7.domain.Department;
import org.example.demo7.repository.DepartmentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class DepartmentService {

    private final DepartmentRepository departmentRepository;

    // 전체 부서 리스트
    public List<Department> findAll() {
        return departmentRepository.findAll();
    }

    // 부서 추가
    @Transactional
    public Department cratedept(Long departmentId, Department department) {
        if (departmentId == null || department == null) {
            throw new IllegalArgumentException("부서 id 또는 부서명이 입력되지 않았습니다.");
        }

        if (departmentRepository.existsById(departmentId)) {
            throw new DepartmentIdException("이미 존재하는 부서 ID입니다");
        }

        department.setId(departmentId);
        return departmentRepository.save(department);
    }

    // 부서 삭제
    @Transactional
    public void deletedept(Long id) {
        Department department = departmentRepository.findById(id).get();
        departmentRepository.delete(department);
    }
}
Custom Exception
배운 김에 한 번 사용해보고 싶어서 간단한 사용자 정의 예외를 추가했다. 부서 ID가 중복으로 입력되면 DepartmentService에서 예외를 던지고, View에서는 해당 메시지를 출력하도록 구성했다.

package org.example.demo7.customException;

public class DepartmentIdException extends RuntimeException {
    public DepartmentIdException(String message) {
        super(message);
    }
}
GlobalExceptionHandler
전역 예외 처리 핸들러를 추가해 예외가 발생했을 때 예외 메시지를 모델에 담아 공통 에러 페이지(errorPage)로 이동하도록 만들었다.

package org.example.demo7.controller;

import org.example.demo7.customException.DepartmentIdException;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public String RuntimeEx(RuntimeException ex, Model model) {
        model.addAttribute("message", ex.getMessage());
        return "errorPage";
    }

    @ExceptionHandler(Exception.class)
    public String Ex(Exception ex, Model model) {
        model.addAttribute("message", ex.getMessage());
        return "errorPage";
    }

    // 사용자 정의 예외 처리
    @ExceptionHandler(DepartmentIdException.class)
    public String DepartmentIdException(DepartmentIdException ex, Model model) {
        model.addAttribute("message", ex.getMessage());
        return "errorPage";
    }
}

Controller

컨트롤러에서는 @GetMapping, @PostMapping으로 URL을 매핑하고, View 이름을 반환하여 화면을 구성했다. 사원 관련 기능은 /employee, 부서 관련 기능은 /dept로 분리했다.

EmployeeController

package org.example.demo7.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.demo7.Dto.FindAllDto;
import org.example.demo7.domain.Department;
import org.example.demo7.domain.Employee;
import org.example.demo7.service.DepartmentService;
import org.example.demo7.service.EmployeeService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/employee")
@RequiredArgsConstructor
public class EmployeeController {

    private final EmployeeService employeeService;
    private final DepartmentService departmentService;

    // 리스트 출력
    @GetMapping
    public String findAllEmployees(Model model) {
        List<FindAllDto> employees = employeeService.findAllEmployees();
        model.addAttribute("employees", employees);
        return "emList";
    }

    // 추가 폼 이동
    @GetMapping("/add")
    public String employeeAddPage(Employee employee, Model model) {
        model.addAttribute("employee", employee);
        List<Department> departments = departmentService.findAll();
        model.addAttribute("departments", departments);
        return "emAddForm";
    }

    // 추가
    @PostMapping("/add")
    public String employeeAdd(Employee employee, Long departmentId, Model model) {
        employeeService.createEmployee(employee, departmentId);
        model.addAttribute("employee", employee);
        return "redirect:/employee";
    }

    // 상세보기
    @GetMapping("/{id}")
    public String view(@PathVariable Long id, Model model) {
        Employee emID = employeeService.findById(id);
        model.addAttribute("employee", emID);
        return "view";
    }

    // 삭제
    @PostMapping("/delete/{id}")
    public String deleteEmployee(@PathVariable Long id) {
        employeeService.deleteEmployee(id);
        return "redirect:/employee";
    }

    // 수정폼
    @GetMapping("/edit/{id}")
    public String editEmployee(@PathVariable Long id, Model model) {
        Employee emid = employeeService.findById(id);
        List<Department> departments = departmentService.findAll();
        model.addAttribute("departments", departments);
        model.addAttribute("employee", emid);
        return "editform";
    }

    // 사원 수정
    @PostMapping("/edit/{id}")
    public String updateEmployee(@PathVariable Long id, Employee employee, Long departmentId) {
        employee.setEmid(id); // ID 명시적 설정
        employeeService.updateEmployee(id, employee, departmentId);
        return "redirect:/employee";
    }
}

DepartmentController

package org.example.demo7.controller;

import lombok.RequiredArgsConstructor;
import org.example.demo7.domain.Department;
import org.example.demo7.service.DepartmentService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Controller
@RequiredArgsConstructor
@RequestMapping("/dept")
public class DepartmentController {

    private final DepartmentService departmentService;

    // 부서 리스트
    @GetMapping
    public String findalldept(Model model) {
        List<Department> departments = departmentService.findAll();
        model.addAttribute("departments", departments);
        return "deptlist";
    }

    // 부서 추가 폼
    @GetMapping("/add")
    public String adddept(Department dept, Model model) {
        model.addAttribute("dept", dept);
        List<Department> departments = departmentService.findAll();
        model.addAttribute("departments", departments);
        return "deptadd";
    }

    // 추가
    @PostMapping("/add")
    public String adddept(Department dept, Long departmentId) {
        departmentService.cratedept(departmentId, dept);
        return "redirect:/dept";
    }

    // 삭제
    @GetMapping("/delete")
    public String deletedept(Long departmentId) {
        departmentService.deletedept(departmentId);
        return "redirect:/dept";
    }
}

결과 확인

사원/부서 CRUD 기능이 모두 정상적으로 동작했으며, 부서를 삭제하면 해당 부서 소속 사원도 함께 삭제되는 것을 확인했다. 예외가 발생했을 때는 전역 예외 핸들러가 이를 잡아서 errorPage로 이동하며 메시지를 출력한다.

후기

페이징 기능을 구현하지 못한 점이 아쉽고, 처음 해보는 프로젝트라 데이터베이스 설계가 조금 아쉬웠던 것 같다. 다음에는 여러 기술 스택을 더 접목해서 확장해보고 싶다.

profile
백엔드 공부중입니다.

0개의 댓글