하이어코딩 RSS 태그 관리 글쓰기 방명록 mahiru
2025-07-10 20:23:28
728x90
반응형

🌱 들어가며

"Spring Boot로 백엔드 개발을 시작하고 싶은데 어디서부터 시작해야 할지 모르겠어요."

많은 주니어 개발자들이 하는 고민입니다. 이 글에서는 Spring Boot를 사용해 CRUD(Create, Read, Update, Delete) REST API를 만드는 방법을 처음부터 끝까지 실습해보겠습니다. 실제 프로젝트에서 사용할 수 있는 학회 관리 시스템을 예제로 진행합니다.

📑 목차

  1. 프로젝트 개요
  2. 개발 환경 설정
  3. 프로젝트 생성
  4. 엔티티 설계
  5. Repository 구현
  6. Service 계층 구현
  7. Controller 구현
  8. 예외 처리
  9. 테스트
  10. Docker 배포

프로젝트 개요

🎯 목표

대학 학회 관리를 위한 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 접속

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

마무리

🎯 학습한 내용

  1. Spring Boot 프로젝트 구조 설계
  2. JPA를 활용한 엔티티 매핑
  3. RESTful API 설계 원칙
  4. 계층형 아키텍처 (Controller-Service-Repository)
  5. 예외 처리와 유효성 검증
  6. Docker를 통한 컨테이너화

🚀 다음 단계

  • Spring Security를 활용한 인증/인가
  • JWT 토큰 기반 인증
  • Swagger/OpenAPI 문서화
  • Redis 캐싱
  • PostgreSQL 마이그레이션
  • GitHub Actions CI/CD 파이프라인
  • Kubernetes 배포

📚 추가 학습 자료

728x90
반응형
이 페이지는 리디주식회사에서 제공한 리디바탕 글꼴이 사용되어 있습니다.