Java Spring Boot | REST API 서버 만들기

이 글의 핵심

Spring Boot는 설정을 크게 줄여 주지만 계층을 어떻게 나누고 의존성을 어떻게 주입할지는 여전히 직접 정해야 합니다. 필드 주입 대신 생성자 주입을 쓰는 이유, ddl-auto=update를 운영 환경에서 쓰면 위험한 이유를 짚고, 컨트롤러부터 리포지토리까지 이어지는 Todo API를 완성하게 합니다.

들어가며

Spring Boot는 설정을 줄이고 관례에 맞춘 뼈대를 빠르게 올리는 데 유리합니다. 컨트롤러·서비스·리포지토리를 설계도(클래스)에 나누어 두며, 의존성 주입으로 조립하는 방식이 일반적입니다.


Initializr로 프로젝트 만들고 설정하기

프로젝트 생성

Spring Initializr에서:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.x
  • Dependencies: Spring Web, Spring Data JPA, H2 Database

Application.java

package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

@SpringBootApplication은 세 가지 어노테이션을 합친 것입니다. @Configuration(이 클래스가 설정 클래스), @EnableAutoConfiguration(클래스패스에 있는 라이브러리를 보고 필요한 빈을 자동 등록), @ComponentScan(이 클래스가 있는 패키지와 그 하위 패키지에서 @Component 계열 클래스를 찾아 등록)입니다. 예를 들어 Spring Web 의존성이 있으면 내장 Tomcat과 JSON 변환기(Jackson)가, Data JPA와 H2가 있으면 DataSource와 EntityManager가 자동으로 만들어집니다.

처음 프로젝트를 구성할 때 가장 흔히 겪는 문제는 패키지 위치입니다. Application이 com.example.demo에 있는데 컨트롤러를 com.example.controller처럼 형제 패키지에 두면 컴포넌트 스캔 범위 밖이라 빈으로 등록되지 않습니다. 에러 없이 서버가 뜨고, 요청하면 404와 함께 “Whitelabel Error Page”만 나오기 때문에 원인을 찾기 어렵습니다. 이 글의 예제처럼 모든 패키지를 com.example.demo 아래(com.example.demo.controller 등)에 두면 이 문제를 피할 수 있습니다.

application.properties

server.port=8080
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

jdbc:h2:mem:testdb는 프로세스 메모리에만 존재하는 DB라서 재시작하면 데이터가 모두 사라집니다. 개발 중 테이블 내용을 보고 싶다면 spring.h2.console.enabled=true를 추가하고 /h2-console에 접속하면 되는데, 이때 JDBC URL을 설정 파일과 똑같이 jdbc:h2:mem:testdb로 입력해야 합니다. 기본값으로 채워진 다른 URL로 접속하면 빈 DB가 새로 만들어져 “테이블이 없다”고 착각하게 됩니다. ddl-auto와 show-sql을 운영 환경에서 어떻게 다뤄야 하는지는 글 끝의 FAQ에서 다룹니다.


REST Controller와 JPA 엔티티

UserController

package com.example.demo.controller;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @Autowired
    private UserService userService;
    
    @GetMapping
    public List<User> getUsers() {
        return userService.findAll();
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        if (user != null) {
            return ResponseEntity.ok(user);
        } else {
            return ResponseEntity.notFound().build();
        }
    }
    
    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody User user) {
        User saved = userService.save(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<User> updateUser(
            @PathVariable Long id,
            @RequestBody User user) {
        User updated = userService.update(id, user);
        if (updated != null) {
            return ResponseEntity.ok(updated);
        } else {
            return ResponseEntity.notFound().build();
        }
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        boolean deleted = userService.delete(id);
        if (deleted) {
            return ResponseEntity.noContent().build();
        } else {
            return ResponseEntity.notFound().build();
        }
    }
}

@RestController는 @Controller와 @ResponseBody를 합친 것으로, 메서드가 반환한 객체를 뷰 이름이 아니라 응답 본문으로 취급해 Jackson이 JSON으로 변환합니다. List<User>를 반환하면 JSON 배열이, ResponseEntity<User>를 반환하면 상태 코드와 헤더까지 직접 제어한 응답이 나갑니다. 생성에는 201, 삭제에는 204, 없는 리소스에는 404를 돌려주는 식으로 HTTP 의미에 맞춰 상태 코드를 고르는 것이 REST API의 기본입니다.

Spring Boot 3.2(Spring Framework 6.1)로 올리면서 자주 보이는 에러가 있습니다. Maven·Gradle의 Spring Boot 플러그인 설정 없이 직접 컴파일하거나 IDE 빌드 설정이 어긋나 -parameters 컴파일 옵션이 빠지면, @PathVariable Long id처럼 이름을 생략한 매개변수에서 Name for argument of type [java.lang.Long] not specified, and parameter name information not available via reflection 에러가 납니다. 6.1부터 바이트코드에서 매개변수 이름을 추측하던 기능이 제거되었기 때문입니다. spring-boot-starter-parent를 쓰는 Maven 프로젝트는 이 옵션이 기본으로 켜져 있고, 아니라면 @PathVariable("id")처럼 이름을 명시하면 됩니다.

이 컨트롤러는 JPA 엔티티를 그대로 요청 본문으로 받고 응답으로 내보냅니다. 예제로는 간단하지만, 클라이언트가 POST 본문에 "id": 3을 넣으면 save가 새 행을 삽입하는 대신 3번 사용자를 병합(merge)해 덮어쓸 수 있고, 엔티티에 필드를 추가하는 순간 API 응답 형식이 함께 바뀝니다. 규모가 커지면 요청·응답용 DTO를 분리하고 @Valid로 입력을 검증하는 구조로 가는 것이 일반적입니다.

User 엔티티

package com.example.demo.model;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false, unique = true)
    private String email;
    
    private Integer age;
    
    public User() {}
    
    public User(String name, String email, Integer age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }
    
    // Getters
    public Long getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }
    public Integer getAge() { return age; }
    
    // Setters
    public void setId(Long id) { this.id = id; }
    public void setName(String name) { this.name = name; }
    public void setEmail(String email) { this.email = email; }
    public void setAge(Integer age) { this.age = age; }
}

JPA 엔티티에는 인자 없는 생성자가 반드시 있어야 합니다. Hibernate가 DB에서 읽은 행을 객체로 만들 때 먼저 빈 객체를 생성한 뒤 필드를 채우기 때문입니다. 외부에서 빈 객체를 만들지 못하게 하려면 protected User() {}로 두어도 됩니다. 이 생성자를 빠뜨리면 조회 시점에 No default constructor for entity 계열의 에러가 납니다. age가 int가 아니라 Integer인 것도 의도적입니다. DB 컬럼이 NULL일 수 있는데 기본 타입 int는 null을 담을 수 없어서, 조회할 때 예외가 나거나 0으로 오해할 수 있습니다.

테이블 이름을 @Table(name = "users")로 따로 지정한 데는 실용적인 이유가 있습니다. USER는 H2 2.x를 비롯한 여러 DB에서 예약어라, 엔티티 이름 그대로 user 테이블을 만들면 Syntax error in SQL statement "create table user ..." 같은 에러로 애플리케이션이 시작되지 않습니다. order, group 같은 이름도 같은 문제를 일으킵니다.

setId를 public으로 열어 둔 것은 학습 예제의 편의이고, ID는 DB가 생성하므로 실무에서는 setter를 두지 않는 편이 안전합니다. 또 JPA 엔티티의 equals/hashCode를 IDE 자동 생성으로 모든 필드 기준으로 만들면 저장 전후로 해시 값이 바뀌고 지연 로딩 프록시와 비교할 때 문제가 생기므로, 필요하다면 ID 기준으로 신중하게 작성해야 합니다.


Spring Data JPA Repository

UserRepository

package com.example.demo.repository;
import com.example.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    List<User> findByName(String name);
    
    User findByEmail(String email);
    
    List<User> findByAgeGreaterThan(Integer age);
}

인터페이스만 선언했는데 동작하는 이유는 Spring Data JPA가 애플리케이션 시작 시 이 인터페이스의 구현체(프록시)를 만들어 주기 때문입니다. JpaRepository를 상속하는 것만으로 save, findById, findAll, deleteById 같은 기본 CRUD가 생기고, findByName처럼 규칙에 맞는 이름의 메서드는 이름을 파싱해 쿼리를 만듭니다. findByAgeGreaterThan은 where age > ?가 됩니다. 메서드 이름에 없는 속성을 쓰면 시작 시점에 No property 'xxx' found for type 'User'로 실패하므로, 쿼리 오타가 런타임까지 가지 않습니다. JpaRepository를 상속한 인터페이스는 @Repository 없이도 빈으로 등록되므로 이 어노테이션은 생략해도 됩니다.

User findByEmail(String email)처럼 단일 객체를 반환하면 결과가 없을 때 null이 오고, 두 개 이상이면 IncorrectResultSizeDataAccessException이 납니다. email에는 유니크 제약이 있으니 두 개 이상은 나올 수 없지만, 호출하는 쪽이 null 검사를 잊기 쉬우므로 Optional<User> findByEmail(String email)로 선언해 “없을 수 있다”는 사실을 타입에 드러내는 편이 더 안전합니다.


서비스 계층과 트랜잭션

UserService

package com.example.demo.service;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    public List<User> findAll() {
        return userRepository.findAll();
    }
    
    public User findById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    public User save(User user) {
        return userRepository.save(user);
    }
    
    public User update(Long id, User user) {
        User existing = findById(id);
        if (existing != null) {
            existing.setName(user.getName());
            existing.setEmail(user.getEmail());
            existing.setAge(user.getAge());
            return userRepository.save(existing);
        }
        return null;
    }
    
    public boolean delete(Long id) {
        if (userRepository.existsById(id)) {
            userRepository.deleteById(id);
            return true;
        }
        return false;
    }
}

이 서비스에는 @Transactional이 없습니다. Spring Data의 save, deleteById 같은 메서드는 각각 자체 트랜잭션으로 실행되므로 예제가 동작은 하지만, update처럼 “조회 → 수정 → 저장”이 여러 단계로 이루어진 메서드는 전체가 하나의 트랜잭션이 아닙니다. 서비스 메서드에 @Transactional을 붙이면 조회한 existing이 영속성 컨텍스트에 관리되는 상태로 남아 있어서, setter로 값을 바꾸기만 해도 트랜잭션 커밋 시점에 변경 감지(dirty checking)로 UPDATE가 실행됩니다. 즉 userRepository.save(existing) 호출 없이도 저장되는데, 이 동작을 모르고 “save를 안 했는데 왜 DB가 바뀌었지?”라는 질문을 하는 경우가 꽤 많습니다. 조회만 하는 메서드에는 @Transactional(readOnly = true)를 붙여 변경 감지를 생략하게 하면 약간의 성능 이점도 있습니다.

update에서 email을 이미 다른 사용자가 쓰는 값으로 바꾸면 유니크 제약 위반으로 DataIntegrityViolationException이 나고, 별도 처리가 없으면 클라이언트는 500 에러를 받습니다. @RestControllerAdvice에서 이 예외를 409 Conflict로 바꿔 주는 것이 흔한 처리 방식입니다.

의존성 주입: 생성자 주입과 필드 주입

생성자 주입 (권장)

@Service
public class UserService {
    
    private final UserRepository userRepository;
    
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

필드 주입

@Service
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
}

두 코드는 같은 일을 하지만, Spring 팀과 대부분의 스타일 가이드는 생성자 주입을 권장합니다. 이유는 구체적입니다. 첫째, 필드를 final로 선언할 수 있어서 객체가 만들어진 뒤 의존성이 바뀌거나 null로 남는 일이 없습니다. 둘째, 단위 테스트에서 new UserService(mockRepository)처럼 Spring 없이 객체를 만들 수 있습니다. 필드 주입 클래스를 new UserService()로 만들면 userRepository가 null이라, 테스트에서 NullPointerException이 나고 결국 리플렉션이나 @SpringBootTest에 의존하게 됩니다. 셋째, 생성자 매개변수가 일곱 개, 여덟 개로 늘어나면 “이 클래스가 너무 많은 일을 한다”는 신호가 코드에 그대로 드러납니다. 필드 주입에서는 @Autowired 필드가 늘어나도 눈에 잘 띄지 않습니다.

순환 의존성도 차이가 납니다. A가 B를, B가 A를 생성자로 요구하면 어느 쪽도 먼저 만들 수 없으므로 시작 시점에 The dependencies of some of the beans in the application context form a cycle로 실패합니다. 불편해 보이지만 설계 문제를 일찍 알려 주는 것이고, Spring Boot 2.6부터는 필드 주입을 쓰더라도 순환 참조를 기본적으로 금지합니다. Spring 4.3부터는 생성자가 하나뿐이면 @Autowired를 생략해도 되므로, 이 글 앞쪽의 UserController와 UserService도 아래 Todo 예제처럼 생성자 주입으로 바꾸는 것이 좋습니다. Lombok을 쓴다면 @RequiredArgsConstructor가 final 필드용 생성자를 만들어 줍니다.


Todo API 만들어 보기

// TodoController.java
@RestController
@RequestMapping("/api/todos")
public class TodoController {
    
    private final TodoService todoService;
    
    public TodoController(TodoService todoService) {
        this.todoService = todoService;
    }
    
    @GetMapping
    public List<Todo> getTodos() {
        return todoService.findAll();
    }
    
    @PostMapping
    public ResponseEntity<Todo> createTodo(@RequestBody Todo todo) {
        Todo saved = todoService.save(todo);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }
    
    @PutMapping("/{id}/toggle")
    public ResponseEntity<Todo> toggleTodo(@PathVariable Long id) {
        Todo updated = todoService.toggle(id);
        if (updated != null) {
            return ResponseEntity.ok(updated);
        } else {
            return ResponseEntity.notFound().build();
        }
    }
}
// Todo.java
@Entity
public class Todo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String title;
    private boolean completed;
    
    // Constructors, Getters, Setters
}
// TodoService.java
@Service
public class TodoService {
    
    private final TodoRepository todoRepository;
    
    public TodoService(TodoRepository todoRepository) {
        this.todoRepository = todoRepository;
    }
    
    public List<Todo> findAll() {
        return todoRepository.findAll();
    }
    
    public Todo save(Todo todo) {
        return todoRepository.save(todo);
    }
    
    public Todo toggle(Long id) {
        Todo todo = todoRepository.findById(id).orElse(null);
        if (todo != null) {
            todo.setCompleted(!todo.isCompleted());
            return todoRepository.save(todo);
        }
        return null;
    }
}
// TodoRepository.java
@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {
    List<Todo> findByCompleted(boolean completed);
}

Todo 예제는 앞의 User API와 같은 구조를 생성자 주입으로 다시 쓴 것입니다. Todo 클래스의 생성자와 getter/setter는 지면상 주석으로 생략했지만, 실제로는 반드시 작성해야 합니다. getter가 없으면 Jackson이 속성을 찾지 못해 응답이 {}로 나가거나 No serializer found for class Todo 에러가 나고, isCompleted()/setCompleted() 쌍이 있어야 JSON의 completed 필드와 연결됩니다. boolean completed는 기본값이 false이므로 {"title": "공부"}만 POST해도 미완료 상태로 저장됩니다.

toggle처럼 “읽고, 바꾸고, 저장하는” 메서드는 @Transactional을 붙이는 것이 자연스럽고, 두 요청이 동시에 들어와 서로의 변경을 덮어쓰는 문제가 걱정된다면 엔티티에 @Version 필드를 추가해 낙관적 잠금을 거는 방법이 있습니다. 이 경우 늦게 커밋한 쪽이 ObjectOptimisticLockingFailureException을 받으므로 재시도하거나 409로 응답하면 됩니다. 같은 API를 Kotlin으로 작성하면 어떤 부분이 줄어드는지는 Kotlin Spring Boot 글에서 비교해 볼 수 있습니다.


Spring Boot 기본 구성 요약

  1. Spring Boot: 자동 설정, 빠른 개발
  2. @RestController: REST API 엔드포인트
  3. JPA: 객체 관계 매핑 (ORM)
  4. 의존성 주입: 생성자 주입 권장 (필드 @Autowired는 테스트·불변성에서 불리)
  5. Repository: 데이터 접근 계층

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. ddl-auto=update를 운영 환경에서도 써도 되나요?

A. 권장하지 않습니다. update는 엔티티를 보고 테이블과 컬럼을 추가해 주지만, 컬럼 이름 변경이나 삭제는 반영하지 않고 어떤 DDL이 실행됐는지 기록도 남지 않습니다. 예제처럼 H2 인메모리 DB로 개발할 때는 편하지만, 운영에서는 validate나 none으로 두고 Flyway나 Liquibase 같은 마이그레이션 도구로 스키마 변경을 버전 관리하는 것이 일반적입니다.