728x90
반응형
🌱 들어가며
"Spring Boot로 백엔드 개발을 시작하고 싶은데 어디서부터 시작해야 할지 모르겠어요."
많은 주니어 개발자들이 하는 고민입니다. 이 글에서는 Spring Boot를 사용해 CRUD(Create, Read, Update, Delete) REST API를 만드는 방법을 처음부터 끝까지 실습해보겠습니다. 실제 프로젝트에서 사용할 수 있는 학회 관리 시스템을 예제로 진행합니다.
📑 목차
프로젝트 개요
🎯 목표
대학 학회 관리를 위한 REST API 백엔드 구축
📋 주요 기능
- 회원 관리 (학회원 등록, 조회, 수정, 삭제)
- 프로젝트 관리 (작품/프로젝트 CRUD)
- 이벤트 관리 (행사 일정 CRUD)
🛠 기술 스택
- Java 17
- Spring Boot 3.2.x
- Spring Data JPA
- H2 Database (개발용)
- Maven
- Docker (배포용)
개발 환경 설정
필수 설치 프로그램
bash
# Java 17 설치 확인
java -version
# Maven 설치 확인
mvn -version
# Docker 설치 확인 (선택사항)
docker --version
IDE 추천
- IntelliJ IDEA (Community Edition 무료)
- Visual Studio Code + Spring Boot Extension Pack
- Eclipse + Spring Tools Suite
프로젝트 생성
1. Spring Initializr 사용
start.spring.io 접속 후 다음과 같이 설정:
yaml
Project: Maven
Language: Java
Spring Boot: 3.2.5
Project Metadata:
Group: com.university
Artifact: club-management
Name: club-management
Package name: com.university.clubmanagement
Packaging: Jar
Java: 17
Dependencies:
- Spring Web
- Spring Data JPA
- H2 Database
- Lombok
- Spring Boot DevTools
2. 프로젝트 구조
club-management/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/university/clubmanagement/
│ │ │ ├── controller/
│ │ │ ├── dto/
│ │ │ ├── entity/
│ │ │ ├── exception/
│ │ │ ├── repository/
│ │ │ ├── service/
│ │ │ └── ClubManagementApplication.java
│ │ └── resources/
│ │ ├── application.properties
│ │ └── data.sql (선택사항)
│ └── test/
├── pom.xml
└── README.md
3. pom.xml 구성
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.university</groupId>
<artifactId>club-management</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>club-management</name>
<description>Club Management System</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
4. application.properties 설정
properties
# Server Configuration
server.port=8080
spring.application.name=club-management
# Database Configuration (H2)
spring.datasource.url=jdbc:h2:mem:clubdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA Configuration
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# H2 Console Configuration
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.h2.console.settings.web-allow-others=true
# Logging
logging.level.com.university.clubmanagement=DEBUG
logging.level.org.springframework.web=DEBUG
엔티티 설계
1. BaseEntity (공통 필드)
java
package com.university.clubmanagement.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
2. Member 엔티티
java
package com.university.clubmanagement.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDate;
@Entity
@Table(name = "members")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 20)
private String studentId;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(length = 50)
private String department;
@Column(length = 20)
private String phoneNumber;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private MemberRole role = MemberRole.MEMBER;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private MemberStatus status = MemberStatus.ACTIVE;
@Column(nullable = false)
private LocalDate joinDate = LocalDate.now();
@Column(columnDefinition = "TEXT")
private String introduction;
}
3. Enum 클래스들
java
package com.university.clubmanagement.entity;
public enum MemberRole {
PRESIDENT("회장"),
VICE_PRESIDENT("부회장"),
SECRETARY("총무"),
MEMBER("일반회원");
private final String description;
MemberRole(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
java
package com.university.clubmanagement.entity;
public enum MemberStatus {
ACTIVE("활동중"),
INACTIVE("비활동"),
GRADUATED("졸업");
private final String description;
MemberStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
4. Project 엔티티
java
package com.university.clubmanagement.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "projects")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Project extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(length = 50)
private String projectType;
@Column(length = 500)
private String thumbnailUrl;
@Column(length = 500)
private String projectUrl;
private LocalDate startDate;
private LocalDate endDate;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private ProjectStatus status = ProjectStatus.PLANNING;
@ManyToMany
@JoinTable(
name = "project_members",
joinColumns = @JoinColumn(name = "project_id"),
inverseJoinColumns = @JoinColumn(name = "member_id")
)
private List<Member> participants = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "leader_id")
private Member leader;
}
java
package com.university.clubmanagement.entity;
public enum ProjectStatus {
PLANNING("기획중"),
IN_PROGRESS("진행중"),
COMPLETED("완료"),
CANCELLED("취소");
private final String description;
ProjectStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
5. Event 엔티티
java
package com.university.clubmanagement.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "events")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Event extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(length = 100)
private String location;
@Column(nullable = false)
private LocalDateTime eventDateTime;
@Enumerated(EnumType.STRING)
@Column(length = 30)
private EventType eventType;
private Integer maxParticipants;
@Builder.Default
private Integer currentParticipants = 0;
@Column(length = 500)
private String imageUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organizer_id")
private Member organizer;
}
java
package com.university.clubmanagement.entity;
public enum EventType {
WORKSHOP("워크샵"),
SEMINAR("세미나"),
EXHIBITION("전시회"),
COMPETITION("대회"),
SOCIAL("친목행사"),
MEETING("정기모임");
private final String description;
EventType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
Repository 구현
1. MemberRepository
java
package com.university.clubmanagement.repository;
import com.university.clubmanagement.entity.Member;
import com.university.clubmanagement.entity.MemberRole;
import com.university.clubmanagement.entity.MemberStatus;
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
public interface MemberRepository extends JpaRepository<Member, Long> {
// 학번으로 회원 찾기
Optional<Member> findByStudentId(String studentId);
// 이메일로 회원 찾기
Optional<Member> findByEmail(String email);
// 학번 중복 확인
boolean existsByStudentId(String studentId);
// 이메일 중복 확인
boolean existsByEmail(String email);
// 활성 회원만 조회
List<Member> findByStatus(MemberStatus status);
// 역할별 회원 조회
List<Member> findByRole(MemberRole role);
// 부서별 회원 조회
List<Member> findByDepartment(String department);
// 이름으로 검색 (부분 일치)
List<Member> findByNameContaining(String name);
// 복잡한 검색 쿼리
@Query("SELECT m FROM Member m WHERE " +
"(:name IS NULL OR m.name LIKE %:name%) AND " +
"(:department IS NULL OR m.department = :department) AND " +
"(:role IS NULL OR m.role = :role) AND " +
"(:status IS NULL OR m.status = :status)")
List<Member> searchMembers(@Param("name") String name,
@Param("department") String department,
@Param("role") MemberRole role,
@Param("status") MemberStatus status);
}
2. ProjectRepository
java
package com.university.clubmanagement.repository;
import com.university.clubmanagement.entity.Project;
import com.university.clubmanagement.entity.ProjectStatus;
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.time.LocalDate;
import java.util.List;
@Repository
public interface ProjectRepository extends JpaRepository<Project, Long> {
// 프로젝트 타입별 조회
List<Project> findByProjectType(String projectType);
// 상태별 프로젝트 조회
List<Project> findByStatus(ProjectStatus status);
// 특정 회원이 참여한 프로젝트
@Query("SELECT p FROM Project p JOIN p.participants m WHERE m.id = :memberId")
List<Project> findByParticipantId(@Param("memberId") Long memberId);
// 특정 회원이 리더인 프로젝트
List<Project> findByLeaderId(Long leaderId);
// 기간별 프로젝트 조회
@Query("SELECT p FROM Project p WHERE p.startDate >= :startDate AND p.endDate <= :endDate")
List<Project> findProjectsBetweenDates(@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
// 진행중인 프로젝트
@Query("SELECT p FROM Project p WHERE p.status = 'IN_PROGRESS' AND :currentDate BETWEEN p.startDate AND p.endDate")
List<Project> findActiveProjects(@Param("currentDate") LocalDate currentDate);
}
3. EventRepository
java
package com.university.clubmanagement.repository;
import com.university.clubmanagement.entity.Event;
import com.university.clubmanagement.entity.EventType;
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.time.LocalDateTime;
import java.util.List;
@Repository
public interface EventRepository extends JpaRepository<Event, Long> {
// 이벤트 타입별 조회
List<Event> findByEventType(EventType eventType);
// 특정 기간의 이벤트 조회
@Query("SELECT e FROM Event e WHERE e.eventDateTime BETWEEN :start AND :end ORDER BY e.eventDateTime")
List<Event> findEventsBetween(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
// 예정된 이벤트 조회
@Query("SELECT e FROM Event e WHERE e.eventDateTime > :now ORDER BY e.eventDateTime")
List<Event> findUpcomingEvents(@Param("now") LocalDateTime now);
// 주최자별 이벤트 조회
List<Event> findByOrganizerId(Long organizerId);
// 참가 가능한 이벤트 (정원 미달)
@Query("SELECT e FROM Event e WHERE e.maxParticipants > e.currentParticipants AND e.eventDateTime > :now")
List<Event> findAvailableEvents(@Param("now") LocalDateTime now);
}
Service 계층 구현
1. MemberService
java
package com.university.clubmanagement.service;
import com.university.clubmanagement.dto.MemberCreateDto;
import com.university.clubmanagement.dto.MemberResponseDto;
import com.university.clubmanagement.dto.MemberUpdateDto;
import com.university.clubmanagement.entity.Member;
import com.university.clubmanagement.entity.MemberStatus;
import com.university.clubmanagement.exception.DuplicateResourceException;
import com.university.clubmanagement.exception.ResourceNotFoundException;
import com.university.clubmanagement.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
@Transactional
public MemberResponseDto createMember(MemberCreateDto createDto) {
log.debug("Creating new member with studentId: {}", createDto.getStudentId());
// 중복 검사
if (memberRepository.existsByStudentId(createDto.getStudentId())) {
throw new DuplicateResourceException("이미 존재하는 학번입니다: " + createDto.getStudentId());
}
if (memberRepository.existsByEmail(createDto.getEmail())) {
throw new DuplicateResourceException("이미 존재하는 이메일입니다: " + createDto.getEmail());
}
Member member = Member.builder()
.studentId(createDto.getStudentId())
.name(createDto.getName())
.email(createDto.getEmail())
.department(createDto.getDepartment())
.phoneNumber(createDto.getPhoneNumber())
.introduction(createDto.getIntroduction())
.build();
Member savedMember = memberRepository.save(member);
log.info("Member created successfully with id: {}", savedMember.getId());
return MemberResponseDto.from(savedMember);
}
public List<MemberResponseDto> getAllMembers() {
return memberRepository.findAll().stream()
.map(MemberResponseDto::from)
.collect(Collectors.toList());
}
public MemberResponseDto getMemberById(Long id) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("회원을 찾을 수 없습니다. ID: " + id));
return MemberResponseDto.from(member);
}
public MemberResponseDto getMemberByStudentId(String studentId) {
Member member = memberRepository.findByStudentId(studentId)
.orElseThrow(() -> new ResourceNotFoundException("회원을 찾을 수 없습니다. 학번: " + studentId));
return MemberResponseDto.from(member);
}
@Transactional
public MemberResponseDto updateMember(Long id, MemberUpdateDto updateDto) {
log.debug("Updating member with id: {}", id);
Member member = memberRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("회원을 찾을 수 없습니다. ID: " + id));
// 이메일 변경 시 중복 검사
if (updateDto.getEmail() != null && !updateDto.getEmail().equals(member.getEmail())) {
if (memberRepository.existsByEmail(updateDto.getEmail())) {
throw new DuplicateResourceException("이미 존재하는 이메일입니다: " + updateDto.getEmail());
}
member.setEmail(updateDto.getEmail());
}
// 나머지 필드 업데이트
if (updateDto.getName() != null) {
member.setName(updateDto.getName());
}
if (updateDto.getDepartment() != null) {
member.setDepartment(updateDto.getDepartment());
}
if (updateDto.getPhoneNumber() != null) {
member.setPhoneNumber(updateDto.getPhoneNumber());
}
if (updateDto.getIntroduction() != null) {
member.setIntroduction(updateDto.getIntroduction());
}
if (updateDto.getRole() != null) {
member.setRole(updateDto.getRole());
}
if (updateDto.getStatus() != null) {
member.setStatus(updateDto.getStatus());
}
Member updatedMember = memberRepository.save(member);
log.info("Member updated successfully with id: {}", updatedMember.getId());
return MemberResponseDto.from(updatedMember);
}
@Transactional
public void deleteMember(Long id) {
log.debug("Deleting member with id: {}", id);
Member member = memberRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("회원을 찾을 수 없습니다. ID: " + id));
// Soft delete: 상태만 변경
member.setStatus(MemberStatus.INACTIVE);
memberRepository.save(member);
log.info("Member soft deleted successfully with id: {}", id);
}
public List<MemberResponseDto> getActiveMembers() {
return memberRepository.findByStatus(MemberStatus.ACTIVE).stream()
.map(MemberResponseDto::from)
.collect(Collectors.toList());
}
public List<MemberResponseDto> searchMembers(String name, String department, String role, String status) {
// 검색 파라미터 변환
var memberRole = role != null ? MemberRole.valueOf(role) : null;
var memberStatus = status != null ? MemberStatus.valueOf(status) : null;
return memberRepository.searchMembers(name, department, memberRole, memberStatus).stream()
.map(MemberResponseDto::from)
.collect(Collectors.toList());
}
}
2. DTO 클래스들
java
package com.university.clubmanagement.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberCreateDto {
@NotBlank(message = "학번은 필수입니다")
@Pattern(regexp = "^[0-9]{8}$", message = "학번은 8자리 숫자여야 합니다")
private String studentId;
@NotBlank(message = "이름은 필수입니다")
@Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
private String name;
@NotBlank(message = "이메일은 필수입니다")
@Email(message = "올바른 이메일 형식이 아닙니다")
private String email;
@Size(max = 50, message = "학과명은 50자를 초과할 수 없습니다")
private String department;
@Pattern(regexp = "^01[0-9]-[0-9]{3,4}-[0-9]{4}$", message = "올바른 전화번호 형식이 아닙니다")
private String phoneNumber;
@Size(max = 500, message = "자기소개는 500자를 초과할 수 없습니다")
private String introduction;
}
java
package com.university.clubmanagement.dto;
import com.university.clubmanagement.entity.Member;
import com.university.clubmanagement.entity.MemberRole;
import com.university.clubmanagement.entity.MemberStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberResponseDto {
private Long id;
private String studentId;
private String name;
private String email;
private String department;
private String phoneNumber;
private MemberRole role;
private MemberStatus status;
private LocalDate joinDate;
private String introduction;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static MemberResponseDto from(Member member) {
return MemberResponseDto.builder()
.id(member.getId())
.studentId(member.getStudentId())
.name(member.getName())
.email(member.getEmail())
.department(member.getDepartment())
.phoneNumber(member.getPhoneNumber())
.role(member.getRole())
.status(member.getStatus())
.joinDate(member.getJoinDate())
.introduction(member.getIntroduction())
.createdAt(member.getCreatedAt())
.updatedAt(member.getUpdatedAt())
.build();
}
}
java
package com.university.clubmanagement.dto;
import com.university.clubmanagement.entity.MemberRole;
import com.university.clubmanagement.entity.MemberStatus;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberUpdateDto {
@Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
private String name;
@Email(message = "올바른 이메일 형식이 아닙니다")
private String email;
@Size(max = 50, message = "학과명은 50자를 초과할 수 없습니다")
private String department;
@Pattern(regexp = "^01[0-9]-[0-9]{3,4}-[0-9]{4}$", message = "올바른 전화번호 형식이 아닙니다")
private String phoneNumber;
@Size(max = 500, message = "자기소개는 500자를 초과할 수 없습니다")
private String introduction;
private MemberRole role;
private MemberStatus status;
}
Controller 구현
1. MemberController
java
package com.university.clubmanagement.controller;
import com.university.clubmanagement.dto.MemberCreateDto;
import com.university.clubmanagement.dto.MemberResponseDto;
import com.university.clubmanagement.dto.MemberUpdateDto;
import com.university.clubmanagement.service.MemberService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class MemberController {
private final MemberService memberService;
@PostMapping
public ResponseEntity<MemberResponseDto> createMember(@Valid @RequestBody MemberCreateDto createDto) {
log.info("POST /api/members - Creating new member");
MemberResponseDto response = memberService.createMember(createDto);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping
public ResponseEntity<List<MemberResponseDto>> getAllMembers(
@RequestParam(required = false) String name,
@RequestParam(required = false) String department,
@RequestParam(required = false) String role,
@RequestParam(required = false) String status) {
log.info("GET /api/members - Fetching all members");
List<MemberResponseDto> members;
if (name != null || department != null || role != null || status != null) {
members = memberService.searchMembers(name, department, role, status);
} else {
members = memberService.getAllMembers();
}
return ResponseEntity.ok(members);
}
@GetMapping("/{id}")
public ResponseEntity<MemberResponseDto> getMemberById(@PathVariable Long id) {
log.info("GET /api/members/{} - Fetching member by id", id);
MemberResponseDto response = memberService.getMemberById(id);
return ResponseEntity.ok(response);
}
@GetMapping("/student/{studentId}")
public ResponseEntity<MemberResponseDto> getMemberByStudentId(@PathVariable String studentId) {
log.info("GET /api/members/student/{} - Fetching member by student ID", studentId);
MemberResponseDto response = memberService.getMemberByStudentId(studentId);
return ResponseEntity.ok(response);
}
@PutMapping("/{id}")
public ResponseEntity<MemberResponseDto> updateMember(
@PathVariable Long id,
@Valid @RequestBody MemberUpdateDto updateDto) {
log.info("PUT /api/members/{} - Updating member", id);
MemberResponseDto response = memberService.updateMember(id, updateDto);
return ResponseEntity.ok(response);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteMember(@PathVariable Long id) {
log.info("DELETE /api/members/{} - Deleting member", id);
memberService.deleteMember(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/active")
public ResponseEntity<List<MemberResponseDto>> getActiveMembers() {
log.info("GET /api/members/active - Fetching active members");
List<MemberResponseDto> members = memberService.getActiveMembers();
return ResponseEntity.ok(members);
}
}
2. ProjectController와 EventController도 유사하게 구현
예외 처리
1. Custom Exceptions
java
package com.university.clubmanagement.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
java
package com.university.clubmanagement.exception;
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String message) {
super(message);
}
}
2. Global Exception Handler
java
package com.university.clubmanagement.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFoundException(ResourceNotFoundException ex) {
log.error("Resource not found: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.NOT_FOUND.value())
.error("Resource Not Found")
.message(ex.getMessage())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleDuplicateResourceException(DuplicateResourceException ex) {
log.error("Duplicate resource: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.CONFLICT.value())
.error("Duplicate Resource")
.message(ex.getMessage())
.build();
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Validation Failed")
.message("입력값 검증에 실패했습니다")
.details(errors)
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGlobalException(Exception ex) {
log.error("Unexpected error occurred", ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Internal Server Error")
.message("서버 오류가 발생했습니다")
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}
java
package com.university.clubmanagement.exception;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private Map<String, String> details;
}
테스트
1. API 테스트 파일 (test-api.http)
http
### 회원 생성
POST http://localhost:8080/api/members
Content-Type: application/json
{
"studentId": "20240001",
"name": "김한동",
"email": "kim@handong.edu",
"department": "전산전자공학부",
"phoneNumber": "010-1234-5678",
"introduction": "안녕하세요! 미디어 제작에 관심이 많습니다."
}
### 모든 회원 조회
GET http://localhost:8080/api/members
### 특정 회원 조회
GET http://localhost:8080/api/members/1
### 회원 정보 수정
PUT http://localhost:8080/api/members/1
Content-Type: application/json
{
"email": "kim.updated@handong.edu",
"department": "AI융합교육원",
"role": "VICE_PRESIDENT"
}
### 회원 삭제
DELETE http://localhost:8080/api/members/1
### 회원 검색
GET http://localhost:8080/api/members?name=김&department=전산전자공학부&status=ACTIVE
### 활성 회원만 조회
GET http://localhost:8080/api/members/active
2. 애플리케이션 실행 및 테스트
bash
# 프로젝트 빌드
mvn clean package
# 애플리케이션 실행
mvn spring-boot:run
# 또는 JAR 파일 직접 실행
java -jar target/club-management-0.0.1-SNAPSHOT.jar
3. H2 Console 접속
- URL: http://localhost:8080/h2-console
- JDBC URL: jdbc:h2:mem:clubdb
- Username: sa
- Password: (비워둠)
Docker 배포
1. Dockerfile
dockerfile
# 빌드 스테이지
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
# 실행 스테이지
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# 필요한 패키지 설치
RUN apk add --no-cache tzdata && \
cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
echo "Asia/Seoul" > /etc/timezone
# JAR 파일 복사
COPY --from=build /app/target/*.jar app.jar
# 애플리케이션 실행 사용자 생성
RUN addgroup -g 1000 spring && \
adduser -D -u 1000 -G spring spring
USER spring:spring
# 포트 노출
EXPOSE 8080
# JVM 옵션 설정
ENV JAVA_OPTS="-Xms256m -Xmx512m"
# 애플리케이션 실행
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
2. docker-compose.yml
yaml
version: '3.8'
services:
app:
build: .
container_name: club-management-api
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=docker
- TZ=Asia/Seoul
networks:
- club-network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
club-network:
driver: bridge
3. 배포 명령어
bash
# Docker 이미지 빌드
docker build -t club-management:latest .
# Docker 컨테이너 실행
docker run -d -p 8080:8080 --name club-api club-management:latest
# Docker Compose로 실행
docker-compose up -d
# 로그 확인
docker logs -f club-api
# 컨테이너 중지
docker stop club-api
# 컨테이너 삭제
docker rm club-api
마무리
🎯 학습한 내용
- Spring Boot 프로젝트 구조 설계
- JPA를 활용한 엔티티 매핑
- RESTful API 설계 원칙
- 계층형 아키텍처 (Controller-Service-Repository)
- 예외 처리와 유효성 검증
- Docker를 통한 컨테이너화
🚀 다음 단계
- Spring Security를 활용한 인증/인가
- JWT 토큰 기반 인증
- Swagger/OpenAPI 문서화
- Redis 캐싱
- PostgreSQL 마이그레이션
- GitHub Actions CI/CD 파이프라인
- Kubernetes 배포
📚 추가 학습 자료
728x90
반응형
'유용한 컴공 테크닉' 카테고리의 다른 글
Spring Boot + Thymeleaf로 웹 페이지 만들기: Bootstrap을 곁들인 CRUD 완성하기 (1) | 2025.07.20 |
---|---|
Spring Boot H2 Console 사용 완벽 가이드 (0) | 2025.07.11 |
Postman 완벽 가이드: REST API 테스트부터 협업까지 (1) | 2025.07.10 |
Java와 SQLite JDBC 연결 완벽 가이드 (0) | 2025.07.05 |
로그인 필요없이 몇 초만에 무료 이미지 얻는 사이트 2탄 - unsplash (0) | 2021.02.05 |