Spring Boot와 JPA를 활용한 일정관리 API 개발 과정

geoson·2025년 6월 5일

Spring & 백엔드

목록 보기
9/18

프로젝트 개요

이번 프로젝트는 사용자가 회원가입과 로그인을 통해 일정을 생성, 조회, 수정, 삭제할 수 있는 API 서버를 구현하는 것이었습니다. 프로젝트는 총 8단계로 구성되어 있으며, 이 글에서는 필수 기능인 Lv 1-4까지의 개발 과정과 트러블슈팅을 공유하고자 합니다.

기술 스택

  • Java 17
  • Spring Boot 3.x
  • Spring Data JPA
  • MySQL
  • Lombok
  • Spring Validation

ERD (Entity Relationship Diagram)

[User] 1 --- * [Schedule]

User 엔티티

  • id (PK)
  • username
  • email
  • password
  • createdAt
  • modifiedAt

Schedule 엔티티

  • id (PK)
  • title
  • content
  • userId (FK)
  • createdAt
  • modifiedAt

구현 단계별 과정

Lv 1. 일정 CRUD

첫 번째 단계는 일정에 대한 기본적인 CRUD 기능을 구현하는 것이었습니다.

JPA Auditing 설정

엔티티의 생성 시간과 수정 시간을 자동으로 관리하기 위해 JPA Auditing을 설정했습니다.

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Timestamped {

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime modifiedAt;
}

Schedule 엔티티 구현

@Entity
@Getter
@NoArgsConstructor
@Table(name = "schedules")
public class Schedule extends Timestamped {

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

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;

    @Column(nullable = false)
    private String username;

    public Schedule(String title, String content, String username) {
        this.title = title;
        this.content = content;
        this.username = username;
    }

    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

Schedule 컨트롤러 구현

@RestController
@RequestMapping("/api/schedules")
public class ScheduleController {

    private final ScheduleService scheduleService;

    // 임시로 사용할 사용자 이름
    private static final String TEMP_USERNAME = "admin";

    public ScheduleController(ScheduleService scheduleService) {
        this.scheduleService = scheduleService;
    }

    @PostMapping
    public ResponseEntity<ScheduleResponseDto> createSchedule(@RequestBody ScheduleRequestDto requestDto) {
        ScheduleResponseDto responseDto = scheduleService.createSchedule(requestDto, TEMP_USERNAME);
        return ResponseEntity.status(HttpStatus.CREATED).body(responseDto);
    }

    @GetMapping
    public ResponseEntity<List<ScheduleResponseDto>> getSchedules() {
        List<ScheduleResponseDto> responseDtos = scheduleService.getSchedules();
        return ResponseEntity.ok(responseDtos);
    }

    @GetMapping("/{id}")
    public ResponseEntity<ScheduleResponseDto> getSchedule(@PathVariable Long id) {
        ScheduleResponseDto responseDto = scheduleService.getSchedule(id);
        return ResponseEntity.ok(responseDto);
    }

    @PutMapping("/{id}")
    public ResponseEntity<ScheduleResponseDto> updateSchedule(
            @PathVariable Long id,
            @RequestBody ScheduleRequestDto requestDto) {
        ScheduleResponseDto responseDto = scheduleService.updateSchedule(id, requestDto, TEMP_USERNAME);
        return ResponseEntity.ok(responseDto);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Map<String, String>> deleteSchedule(@PathVariable Long id) {
        scheduleService.deleteSchedule(id, TEMP_USERNAME);
        return ResponseEntity.ok(Map.of("msg", "일정이 삭제되었습니다."));
    }
}

Lv 2. 유저 CRUD 및 연관관계 설정

두 번째 단계에서는 사용자 엔티티를 구현하고, 일정과의 연관관계를 설정했습니다.

User 엔티티 구현

@Entity
@Getter
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {

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

    @Column(nullable = false)
    private String username;

    @Column(nullable = false, unique = true)
    private String email;

    public User(String username, String email) {
        this.username = username;
        this.email = email;
    }

    public void update(String username, String email) {
        this.username = username;
        this.email = email;
    }
}

Schedule 엔티티에 연관관계 추가

@Entity
@Getter
@NoArgsConstructor
@Table(name = "schedules")
public class Schedule extends Timestamped {

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

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    public Schedule(String title, String content, User user) {
        this.title = title;
        this.content = content;
        this.user = user;
    }

    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }

    // 사용자 이름 조회 메서드
    public String getUsername() {
        return this.user.getUsername();
    }
}

User 컨트롤러 구현

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    // 유저 생성
    @PostMapping
    public ResponseEntity<UserResponseDto> createUser(@RequestBody UserRequestDto requestDto) {
        UserResponseDto responseDto = userService.createUser(requestDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(responseDto);
    }

    // 전체 유저 조회
    @GetMapping
    public ResponseEntity<List<UserResponseDto>> getUsers() {
        List<UserResponseDto> responseDtos = userService.getUsers();
        return ResponseEntity.ok(responseDtos);
    }

    // 특정 유저 조회
    @GetMapping("/{id}")
    public ResponseEntity<UserResponseDto> getUser(@PathVariable Long id) {
        UserResponseDto responseDto = userService.getUser(id);
        return ResponseEntity.ok(responseDto);
    }

    // 유저 수정
    @PutMapping("/{id}")
    public ResponseEntity<UserResponseDto> updateUser(
            @PathVariable Long id,
            @RequestBody UserRequestDto userRequestDto) {
        UserResponseDto responseDto = userService.updateUser(id, userRequestDto);
        return ResponseEntity.ok(responseDto);
    }

    // 유저 삭제
    @DeleteMapping("/{id}")
    public ResponseEntity<Map<String, String>> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.ok(Map.of("msg", "사용자가 삭제되었습니다."));
    }
}

Lv 3. 회원가입 기능

세 번째 단계에서는 사용자 인증을 위한 회원가입 기능을 구현했습니다.

User 엔티티에 비밀번호 필드 추가

@Entity
@Getter
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {

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

    @Column(nullable = false)
    private String username;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;  // 비밀번호 필드 추가

    public User(String username, String email, String password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }

    public void update(String username, String email) {
        this.username = username;
        this.email = email;
    }
}

Lv 4. 로그인(인증) 기능

네 번째 단계에서는 쿠키와 세션을 활용한 로그인 기능과 인증 필터를 구현했습니다.

로그인 DTO 구현

@Getter
@NoArgsConstructor
public class LoginRequestDto {
    private String email;
    private String password;
}

로그인 기능 구현 (UserService)

// 로그인
public void login(LoginRequestDto requestDto, HttpServletRequest request, HttpServletResponse response) {
    // 사용자 확인
    User user = userRepository.findByEmail(requestDto.getEmail())
            .orElseThrow(() -> new IllegalArgumentException("등록된 사용자가 없습니다."));

    // 비밀번호 확인
    if (!user.getPassword().equals(requestDto.getPassword())) {
        throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
    }

    // 세션 생성 및 사용자 정보 저장
    HttpSession session = request.getSession(true);
    session.setAttribute("userId", user.getId());
    session.setAttribute("username", user.getUsername());
}

로그인 엔드포인트 추가 (UserController)

// 로그인
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(
        @RequestBody LoginRequestDto requestDto,
        HttpServletRequest request,
        HttpServletResponse response
) {
    userService.login(requestDto, request, response);
    return ResponseEntity.ok(Map.of("msg", "로그인 성공"));
}

인증 필터 구현

public class AuthFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
    throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        
        String requestURI = httpRequest.getRequestURI();
        
        // 회원가입, 로그인 요청은 인증 처리에서 제외
        if (requestURI.equals("/api/users") || requestURI.equals("/api/users/login")) {
            chain.doFilter(request, response);
            return;
        }
        
        // 세션에서 인증 정보 확인
        HttpSession session = httpRequest.getSession(false);
        if (session == null || session.getAttribute("userId") == null) {
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpResponse.getWriter().write("인증이 필요합니다.");
            return;
        }
        
        // 인증된 사용자는 요청 계속 진행
        chain.doFilter(request, response);
    }
}

필터 등록 설정

@Configuration
public class WebConfig {

    @Bean
    public FilterRegistrationBean<AuthFilter> authFilter() {
        FilterRegistrationBean<AuthFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new AuthFilter());
        registrationBean.addUrlPatterns("/api/*");
        return registrationBean;
    }
}

트러블슈팅

1. Entity와 DTO 간 변환 문제

문제 상황

Schedule 엔티티에서 ScheduleResponseDto로 변환하는 과정에서 필드가 제대로 매핑되지 않는 문제가 발생했습니다.

원인 분석

DTO 생성자에서 엔티티의 필드를 잘못 참조하고 있었습니다. 특히 사용자 이름을 가져오는 부분에서 문제가 발생했습니다.

해결 방법

DTO 생성자에서 엔티티 필드를 올바르게 매핑하도록 수정했습니다.

// 수정 전
public ScheduleResponseDto(Schedule schedule) {
    this.id = schedule.getId();
    this.title = schedule.getTitle();
    this.content = schedule.getContent();
    this.username = schedule.getUser().getUsername(); // 여기서 NullPointerException 발생
    // ...
}

// 수정 후
public ScheduleResponseDto(Schedule schedule) {
    this.id = schedule.getId();
    this.title = schedule.getTitle();
    this.content = schedule.getContent();
    this.username = schedule.getUsername(); // getUsername() 메서드를 통해 안전하게 조회
    // ...
}

이 문제를 해결하기 위해 Schedule 엔티티에 편의 메서드를 추가했습니다:

// Schedule 엔티티에 추가한 편의 메서드
public String getUsername() {
    return this.user.getUsername();
}

2. 세션 기반 인증 문제

문제 상황

로그인 후 다른 API 호출 시 세션 정보가 유지되지 않는 문제가 발생했습니다.

원인 분석

세션이 제대로 생성되었지만, 클라이언트가 세션 ID를 쿠키로 저장하고 있지 않거나, 요청 시 세션 ID를 전달하지 않았습니다.

해결 방법

로그인 시 세션이 생성되도록 HttpSession의 생성 플래그를 명시적으로 설정하고, 세션이 없는 경우에 대한 처리를 개선했습니다.

// 수정 전
HttpSession session = request.getSession();

// 수정 후
HttpSession session = request.getSession(true); // 세션이 없으면 새로 생성

// 인증 필터에서의 세션 체크 개선
HttpSession session = httpRequest.getSession(false); // 세션이 없어도 새로 생성하지 않음
if (session == null || session.getAttribute("userId") == null) {
    httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    httpResponse.getWriter().write("인증이 필요합니다.");
    return;
}

3. User-Schedule 연관관계 설정 문제

문제 상황

User와 Schedule 엔티티 간의 연관관계를 설정한 후, 기존의 일정 데이터를 조회하는 과정에서 에러가 발생했습니다.

원인 분석

Lv 1에서는 Schedule 엔티티에 단순히 username(String)을 저장했지만, Lv 2에서 User 엔티티와의 연관관계로 변경하면서 기존 데이터와의 불일치가 발생했습니다. 또한 연관관계 설정 시 양방향으로 설정해야 할지, 단방향으로 설정해야 할지 혼란이 있었습니다.

해결 방법

  1. 스키마 마이그레이션을 위해 기존 데이터를 백업하고, 테이블 구조를 새로 설정했습니다.
  2. 연관관계는 단방향(Schedule → User)으로 설정하여 복잡성을 줄였습니다.
// Schedule 엔티티의 연관관계 설정
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;

4. 로그인 인증 시 비밀번호 비교 문제

문제 상황

로그인 시 비밀번호 일치 여부를 확인하는 과정에서 문제가 발생했습니다.

원인 분석

문자열 비교 시 == 연산자를 사용하여 참조 비교를 했기 때문에, 내용이 같아도 다른 객체로 판단되는 문제가 있었습니다.

해결 방법

equals() 메서드를 사용하여 문자열 내용을 비교하도록 수정했습니다.

// 수정 전
if (user.getPassword() == requestDto.getPassword()) { // 잘못된 비교
    // 로그인 성공
}

// 수정 후
if (!user.getPassword().equals(requestDto.getPassword())) {
    throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}

배운 점

1. 3계층 아키텍처의 중요성

Controller, Service, Repository 각 계층의 역할과 책임을 분리함으로써 코드의 가독성과 유지보수성이 크게 향상됩니다. 각 계층이 담당하는 역할을 명확히 이해하고 적용하는 것이 중요합니다.

2. JPA 연관관계 설정

엔티티 간의 연관관계 설정은 데이터베이스 설계의 핵심입니다. @ManyToOne, @JoinColumn 등의 어노테이션을 통해 올바른 관계를 설정하는 방법을 배웠습니다.

3. 인증/인가 메커니즘

쿠키와 세션을 활용한 인증 방식과 Filter를 사용한 인가 처리 방법을 이해하게 되었습니다. 이는 보안적으로 중요한 부분으로, 사용자 식별과 접근 제어의 기본이 됩니다.

4. 올바른 패키지 import의 중요성

개발 중 발생한 많은 오류가 잘못된 패키지 import에서 비롯되었습니다. 특히 같은 이름의 클래스가 여러 패키지에 존재할 경우, 올바른 것을 선택하는 것이 중요하다는 점을 배웠습니다.

결론

이번 프로젝트를 통해 Spring Boot와 JPA를 활용한 웹 애플리케이션 개발의 기본 흐름을 파악할 수 있었습니다. 특히 3계층 아키텍처, 엔티티 설계, 연관관계 매핑, 그리고 인증/인가 구현 방법 등 실무에서 자주 사용되는 개념과 기술을 적용해볼 수 있었습니다.

아직 구현하지 못한 비밀번호 암호화, 예외 처리, 댓글 기능, 페이징 처리 등은 추후 프로젝트를 확장하면서 적용해 볼 예정입니다. 이번 경험이 앞으로 더 복잡한 웹 애플리케이션을 개발할 때 좋은 기반이 될 것으로 기대합니다.

0개의 댓글