2026 AX 아이디어 경진대회 서류 심사를 통과하여 본선 발표 대상자로 선정되었다. 이번 대회에서는 아이디어 기획 부문 9팀, 제품·서비스 부문 8팀, 자유분석 부문 9팀, 지정분석 부문 8팀 등 총 34개 팀이 본선에 진출하였다. 우리 팀은 지정분석 부문에 선정되어 8개 팀과 함께 발표를 진행하고 평가 받게 되었다. 발표 준비와 함께 제4회 문화체육관광 인공지능·데이터 활용 공모전도 병행하고 있었고, 해당 프로젝트를 Unity로 개발하다 보니 블로그 작성이 조금 늦어지게 되었다.
여기에 더해 코딩 테스트 공부와 다른 프로젝트들도 함께 진행하고 있어 생각보다 여유가 없었다. 그래도 그동안 경험한 내용들을 기록으로 남기는 것은 중요하다고 생각하기 때문에, 늦었지만 다시 블로그 작성을 시작해보려고 한다. 잠시 멈춰 있었던 만큼 앞으로는 학습 과정과 프로젝트 진행 내용, 공모전 준비 과정 등을 꾸준히 정리해 나갈 예정이다. (공모전 수상 및 다른 이슈가 있다면 다 정리해서 한번에 올려야지..) 다시 하나씩 기록해 보자.

Spring은 Java 엔터프라이즈 개발의 표준 프레임워크다. 그런데 초기 Spring은 XML 설정 파일, 의존성 버전 충돌, 외부 Tomcat 설치까지 시작 전에 할 일이 너무 많았다. Spring Boot는 그 불편함을 해소하기 위해 만들어진 확장판이다.
| 구분 | Spring | Spring Boot |
|---|---|---|
| 설정 방식 | XML / Java 직접 설정 | Auto Configuration (자동) |
| 서버 | 외부 Tomcat 설치 필요 | 내장 Tomcat 포함 |
| 의존성 | 버전 직접 관리 | Starter로 묶음 관리 |
| 진입 장벽 | 높음 | 낮음 |
Spring Boot = Spring + 자동 설정 + 내장 서버 + Starter 의존성. Spring을 "사람이 쓸 수 있게" 만든 것이다.
Spring의 핵심 개념이다. IoC(Inversion of Control, 제어의 역전)은 객체 생성과 관리를 개발자가 아닌 Spring Container가 대신 담당하는 것이다.
// IoC 없이 — 개발자가 직접 생성·연결
BookRepository repo = new BookRepository();
BookService service = new BookService(repo);
// Spring IoC — Container가 생성하고 주입해준다
@Service
public class BookService {
private final BookRepository bookRepository; // Spring이 알아서 넣어준다
}
Spring Container가 관리하는 객체를 Bean이라고 부른다. @ComponentScan이 패키지를 스캔해서 아래 어노테이션이 붙은 클래스를 자동으로 Bean으로 등록한다.
| 어노테이션 | 용도 |
|---|---|
@Component | 범용 Bean 등록 |
@Service | 비즈니스 로직 레이어 Bean |
@Repository | DB 접근 레이어 Bean |
@Controller / @RestController | 웹 요청 처리 Bean |
@Bean | 메서드 반환값을 Bean으로 등록 (설정 클래스에서 사용) |
@SpringBootApplication안에@ComponentScan이 포함되어 있다. 그래서 같은 패키지 하위의 모든@Service,@Repository등이 자동으로 등록된다.
Spring Initializr (start.spring.io)로 생성하면 기본 구조가 잡힌다.

bookapp/
├── src/
│ ├── main/
│ │ ├── java/com/aivle/bookapp/
│ │ │ ├── BookappApplication.java ← 진입점
│ │ │ ├── controller/ ← HTTP 요청 처리
│ │ │ ├── domain/ ← 엔티티 (테이블 대응)
│ │ │ ├── repository/ ← DB 접근
│ │ │ ├── service/ ← 비즈니스 로직
│ │ │ └── exception/ ← 예외 처리
│ │ └── resources/
│ │ └── application.yaml ← 설정 파일
│ └── test/
└── build.gradle ← 의존성 관리
이 구조가 Layered Architecture(계층형 아키텍처)다. 요청이 Controller → Service → Repository 순서로 흘러내려간다. 각 레이어가 자기 역할만 하도록 분리되어 있다.
server:
port: 8080
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:bookdb
username: sa
password: 1234
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: create # 앱 시작 시 테이블 자동 생성
show-sql: true # 실행되는 SQL을 콘솔에 출력
H2는 인메모리 데이터베이스다. 앱을 끄면 데이터가 사라지지만, MySQL 설치 없이 바로 쓸 수 있어서 개발·학습 단계에서 유용하게 사용된다. /h2-console에서 브라우저로 DB를 직접 확인할 수 있다.
ddl-auto 옵션은 환경에 따라 달리 설정해야 한다. 잘못 설정하면 운영 DB 테이블이 날아갈 수 있다.
| 옵션 | 동작 | 사용 시점 |
|---|---|---|
create | 시작 시 DROP → CREATE | 개발 초기 |
create-drop | 시작 시 CREATE, 종료 시 DROP | 테스트 |
update | 변경분만 ALTER | 개발 중 |
validate | 스키마 검증만 (변경 안 함) | 스테이징 |
none | 아무것도 안 함 | 운영(Production) |
운영 환경에서는 반드시
none으로 설정한다.create를 운영에 쓰면 서버 재시작 때마다 모든 데이터가 삭제된다.
@SpringBootApplication
public class BookappApplication {
public static void main(String[] args) {
SpringApplication.run(BookappApplication.class, args);
}
}
@SpringBootApplication은 세 어노테이션의 합성이다.
| 어노테이션 | 역할 |
|---|---|
@SpringBootConfiguration | Spring 설정 클래스로 등록 |
@EnableAutoConfiguration | 의존성 기반 자동 설정 활성화 |
@ComponentScan | 패키지 하위 Bean 자동 등록 |
강의에서는 @Bean CommandLineRunner로 앱 시작 시 초기 데이터를 DB에 넣는 방법을 학습했다.
@Bean
CommandLineRunner init(BookRepository bookRepository) {
return args -> {
Book b1 = new Book();
b1.setTitle("자바의 정석");
b1.setAuthor("남궁성");
bookRepository.save(b1);
Book b2 = new Book();
b2.setTitle("Spring 입문");
b2.setAuthor("임한울");
bookRepository.save(b2);
};
}
CommandLineRunner는 앱 구동이 완료된 직후 한 번 실행된다. 테스트 데이터 세팅이나 초기화 작업에 쓴다.

@RestController
public class HelloController {
@GetMapping("/hello/{name}")
public String hello(@PathVariable String name) {
return "Hello, " + name + "!";
}
@GetMapping("/greet")
public String greet(@RequestParam(defaultValue = "en") String lang) {
if (lang.equals("ko")) {
return "안녕하세요";
}
return "Hello";
}
}
| 어노테이션 | 역할 |
|---|---|
@RestController | @Controller + @ResponseBody. 반환값을 JSON으로 직렬화 |
@GetMapping | HTTP GET 요청 처리 |
@PathVariable | URL 경로에서 값 추출 (/hello/Alice → "Alice") |
@RequestParam | 쿼리 파라미터에서 값 추출 (/greet?lang=ko → "ko") |
@RequestBody | HTTP 요청 body(JSON)를 Java 객체로 역직렬화 |
// @RequestBody 사용 예
@PostMapping("/books")
public ResponseEntity<Book> createBook(@RequestBody Book book) {
// {"title": "Spring 입문", "author": "임한울"} → Book 객체로 변환
Book saved = bookService.create(book);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@PathVariable은 리소스 식별에,@RequestParam은 필터·옵션 설정에,@RequestBody는 POST/PATCH 요청의 본문 수신에 쓴다.
강의 초반에는 Book을 직접 생성해 반환하게 진행했다.
// 처음 — 하드코딩
@GetMapping("/books/1")
public Book getBook() {
return new Book(1L, "Spring Boot 입문", "임한울");
}
실제 서비스에서 데이터는 DB에서 꺼내야 한다.
JPA(Java Persistence API)는 Java 객체를 관계형 DB 테이블과 매핑해주는 표준 인터페이스다.
.png)
구현체는 Hibernate이고, Spring Boot는 Hibernate를 기본으로 사용한다.
Java 객체 (Book) ←→ JPA / Hibernate ←→ DB 테이블 (book)
SQL을 직접 쓰는 대신 Java 코드로 DB를 다룰 수 있게 해준다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
@NotBlank
private String title;
@Column(nullable = false)
@NotBlank
private String author;
}
| 어노테이션 | 역할 |
|---|---|
@Entity | 이 클래스가 DB 테이블과 매핑됨을 선언 |
@Id | 기본키(PK) 지정 |
@GeneratedValue(strategy = IDENTITY) | DB AUTO_INCREMENT로 ID 자동 생성 |
@Column | 컬럼 속성 지정 (nullable, length 등) |
@Table(name = "BOOK2") | 테이블 이름을 클래스명과 다르게 지정할 때 |
클래스 이름이 테이블 이름, 필드가 컬럼이 된다.
Book클래스 →book테이블 자동 생성.
public interface BookRepository extends JpaRepository<Book, Long> {
// 인터페이스 선언만 해도 구현체를 Spring이 자동으로 만들어준다
}
코드가 없어도 아래 메서드들이 즉시 사용 가능하다.
| 메서드 | 설명 |
|---|---|
save(entity) | 저장 / 수정 |
findById(id) | ID로 조회 → Optional<T> 반환 |
findAll() | 전체 조회 |
deleteById(id) | ID로 삭제 |
existsById(id) | 존재 여부 확인 |
count() | 전체 개수 조회 |
JPA의 Spring에서 좋은 기능 중 하나다. @Transactional 안에서 엔티티를 조회하고 필드 값만 바꾸면, 트랜잭션이 끝날 때 JPA가 변경을 감지해서 자동으로 UPDATE 쿼리를 날린다.
@Transactional
public Book update(Long id, Book book) {
Book existing = findById(id); // DB에서 조회 → 영속성 컨텍스트에 등록
if (book.getTitle() != null) {
existing.setTitle(book.getTitle()); // 값만 변경
}
if (book.getAuthor() != null) {
existing.setAuthor(book.getAuthor());
}
return bookRepository.save(existing); // save 없이도 트랜잭션 종료 시 자동 UPDATE
}
save()를 명시적으로 호출하지 않아도 트랜잭션 커밋 시점에 변경된 필드만 UPDATE된다. 이게 Dirty Checking이다.
Spring Data JPA의 핵심 기능이다. 메서드 이름 규칙만 따르면 SQL을 한 줄도 쓰지 않아도 쿼리가 자동 생성된다.
public interface BookRepository extends JpaRepository<Book, Long> {
List<Book> findByTitle(String title); // WHERE title = ?
List<Book> findByAuthor(String author); // WHERE author = ?
List<Book> findByTitleContaining(String keyword); // WHERE title LIKE '%?%'
List<Book> findByTitleAndAuthor(String title, String author); // WHERE title = ? AND author = ?
}
주요 키워드 목록:
| 키워드 | 생성되는 SQL 조건 | 예시 |
|---|---|---|
findBy | SELECT * WHERE | findByTitle |
Containing | LIKE '%keyword%' | findByTitleContaining |
StartingWith | LIKE 'keyword%' | findByTitleStartingWith |
EndingWith | LIKE '%keyword' | findByTitleEndingWith |
And | AND 조건 | findByTitleAndAuthor |
Or | OR 조건 | findByTitleOrAuthor |
OrderBy | ORDER BY | findByAuthorOrderByTitleAsc |
GreaterThan | > | findByIdGreaterThan |
LessThan | < | findByIdLessThan |
IsNull | IS NULL | findByAuthorIsNull |
countBy | COUNT | countByAuthor |
findByTitleContaining("Spring")만 선언하면WHERE title LIKE '%Spring%'이 자동 생성된다. 메서드 이름이 곧 SQL이다.
Java 강의에서 배운 Stream이 여기서 사용된다.
// 특정 저자의 모든 책 제목만 추출
public List<String> authorGetTitle(String author) {
List<Book> books = bookRepository.findByAuthor(author);
return books.stream()
.map(book -> book.getTitle())
.toList();
}

강의 중반까지는 Controller가 Repository를 직접 사용했다.
// Controller가 Repository를 직접 사용하는 방식
@RestController
@RequiredArgsConstructor
public class BookController {
private final BookRepository bookRepository;
@GetMapping("/books/{id}")
public Book getBook(@PathVariable Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Book not found: " + id));
}
}
로직이 단순할 때는 괜찮지만, 검증·예외 처리·복잡한 비즈니스 로직이 늘어나면 Controller가 비대해진다. Service 레이어를 분리하면 각 레이어의 책임이 명확해진다.
Controller — HTTP 요청/응답 형식 담당 (어떤 요청인지)
Service — 비즈니스 로직 담당 (어떻게 처리할지)
Repository — DB 접근 담당 (어디서 꺼낼지)
Spring에서 Bean을 주입하는 방법은 세 가지다.
// 1. 필드 주입 — 코드가 짧지만 테스트하기 어렵고 Spring 권장 안 함
@Autowired
private BookRepository bookRepository;
// 2. Setter 주입 — 선택적 의존성에 사용 (잘 안 씀)
@Autowired
public void setBookRepository(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
// 3. 생성자 주입 (권장) — 불변성 보장, 테스트 용이, 순환 의존성 감지
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
실제로는 @RequiredArgsConstructor로 생성자 주입을 자동화한다.
@Service
@RequiredArgsConstructor // final 필드를 받는 생성자를 Lombok이 자동 생성
public class BookService {
private final BookRepository bookRepository; // 생성자 주입이 자동으로 이루어짐
}
Spring 공식 권장 방식은 생성자 주입이다.
final로 선언하면 주입 후 변경 불가(불변성), 테스트 시 Mock 객체를 직접 넣을 수 있다(테스트 용이성).
@Service
@RequiredArgsConstructor
public class BookService {
private final BookRepository bookRepository;
@Transactional(readOnly = true)
public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
@Transactional
public Book create(Book book) {
return bookRepository.save(book);
}
@Transactional
public Book update(Long id, Book book) {
Book existing = findById(id);
if (book.getTitle() != null) existing.setTitle(book.getTitle());
if (book.getAuthor() != null) existing.setAuthor(book.getAuthor());
return bookRepository.save(existing);
}
@Transactional
public void deleteBook(Long id) {
if (!bookRepository.existsById(id)) {
throw new BookNotFoundException(id);
}
bookRepository.deleteById(id);
}
}
@Transactional은 하나의 작업이 전부 성공하거나, 실패하면 전부 롤백됨을 보장한다. DB의 ACID 원칙 중 Atomicity(원자성)을 코드 레벨에서 보장하는 것이다.
| 옵션 | 설명 |
|---|---|
@Transactional | 읽기/쓰기 트랜잭션 |
@Transactional(readOnly = true) | 읽기 전용 최적화 (쓰기 잠금 생략, Dirty Checking 비활성화) |
Java 강의에서 배운 DI 패턴이 여기서 그대로 나온다.
BookService는BookRepository인터페이스에 의존하고, Spring이 구현체를 주입해준다.
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("Book not found: id=" + id);
}
}
Java 강의의 PaymentFailedException과 구조가 동일하다. RuntimeException("Book not found")보다 BookNotFoundException이 훨씬 명확하다.
RuntimeException을 상속하면 throws 선언 없이 사용할 수 있다. Spring Boot는 체크 예외보다 언체크 예외(RuntimeException)를 권장한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(BookNotFoundException e) {
Map<String, String> body = Map.of(
"error", "Book not found",
"message", e.getMessage()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldError().getDefaultMessage();
Map<String, String> body = Map.of("error", "Validation failed", "message", msg);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
}
@RestControllerAdvice는 모든 Controller의 예외를 한 곳에서 잡아서 처리한다. 각 Controller마다 try-catch를 쓰는 대신, 이 클래스 하나가 전역 예외 처리기 역할을 한다.
| 예외 | HTTP 상태 코드 | 응답 |
|---|---|---|
BookNotFoundException | 404 Not Found | {"error": "Book not found", "message": "..."} |
MethodArgumentNotValidException | 400 Bad Request | {"error": "Validation failed", "message": "..."} |
Controller마다 try-catch를 뿌리는 대신 한 곳에서 일괄 처리한다. AOP(관점 지향 프로그래밍)의 개념이 여기서 적용된다.
Lombok은 컴파일 시점에 어노테이션을 읽어서 코드를 자동 생성해주는 라이브러리다.
// Lombok 없이 — 매번 직접 작성
public class Book {
private String title;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Book() {}
public Book(Long id, String title, String author) { ... }
}
// Lombok 있으면 — 어노테이션으로 끝
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Book { ... }
| 어노테이션 | 자동 생성되는 코드 |
|---|---|
@Getter | 모든 필드의 getter |
@Setter | 모든 필드의 setter |
@NoArgsConstructor | 기본 생성자 |
@AllArgsConstructor | 전체 필드 생성자 |
@RequiredArgsConstructor | final 필드만 받는 생성자 |
@Builder | 빌더 패턴 생성 |
@ToString | toString() 메서드 |
@RequiredArgsConstructor는 Service, Controller에서 생성자 주입 자동화에 쓴다.
필드가 많은 객체를 생성할 때 생성자 인자 순서를 외우는 것보다 훨씬 가독성이 좋다.
@Builder
@Getter
public class Book {
private Long id;
private String title;
private String author;
}
// 생성자 방식 — 인자 순서를 외워야 함
Book book = new Book(null, "Spring 입문", "임한울");
// Builder 방식 — 어떤 값인지 명확
Book book = Book.builder()
.title("Spring 입문")
.author("임한울")
.build();
// Book 엔티티에 검증 조건 선언
@NotBlank
private String title;
@NotBlank
private String author;
// Controller에서 @Valid로 검증 적용
@PostMapping("/books")
public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
Book saved = bookService.create(book);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@Valid가 붙으면 요청 본문을 역직렬화할 때 제약 조건을 검사한다. 검증 실패 시 MethodArgumentNotValidException이 발생하고, GlobalExceptionHandler가 400 Bad Request로 처리한다.
자주 쓰는 Bean Validation 어노테이션:
| 어노테이션 | 설명 |
|---|---|
@NotNull | null 불가 |
@NotBlank | null, 빈 문자열, 공백만 있는 문자열 불가 |
@NotEmpty | null, 빈 문자열 불가 |
@Size(min, max) | 문자열/컬렉션 크기 제한 |
@Min(value) | 최솟값 |
@Max(value) | 최댓값 |
@Email | 이메일 형식 검증 |
@Pattern(regexp) | 정규식 패턴 검증 |
검증 로직을 Controller 안에 if문으로 직접 쓰는 대신, 어노테이션으로 선언하고 전역 핸들러로 처리하는 패턴이다. 코드가 훨씬 깔끔해진다.
대용량 데이터를 한 번에 내려주면 성능이 나쁘다. 페이징으로 나눠서 전달한다.
@Transactional(readOnly = true)
public Page<Book> getPage(int page, int size, String sortBy) {
Sort sort = Sort.by(sortBy).ascending(); // 오름차순
// Sort sort = Sort.by(sortBy).descending(); // 내림차순
Pageable pageable = PageRequest.of(page, size, sort);
return bookRepository.findAll(pageable);
}
@GetMapping("/books/page")
public Page<Book> getPage(
@RequestParam int page,
@RequestParam int size,
@RequestParam String sortBy
) {
return bookService.getPage(page, size, sortBy);
}
GET /books/page?page=0&size=10&sortBy=title을 호출하면 제목순 정렬로 첫 10개를 반환한다.
Page<Book> 응답에는 데이터뿐 아니라 메타데이터도 함께 담긴다.
{
"content": [ { "id": 1, "title": "자바의 정석", "author": "남궁성" }, ... ],
"totalElements": 5, // 전체 데이터 수
"totalPages": 1, // 전체 페이지 수
"number": 0, // 현재 페이지 번호 (0-based)
"size": 10, // 페이지 크기
"first": true, // 첫 페이지 여부
"last": true // 마지막 페이지 여부
}
프론트엔드에서 "다음 페이지" 버튼 표시 여부를 last 필드로 판단할 수 있다.
@GetMapping("/books/{id}") // 단건 조회
@GetMapping("/books") // 전체 조회
@PostMapping("/books") // 생성
@PatchMapping("/books/{id}") // 부분 수정
@DeleteMapping("/books/{id}") // 삭제
| HTTP 메서드 | 용도 | 응답 상태 코드 | 멱등성 |
|---|---|---|---|
| GET | 조회 | 200 OK | O |
| POST | 생성 | 201 Created | X |
| PUT | 전체 수정 | 200 OK | O |
| PATCH | 부분 수정 | 200 OK | △ |
| DELETE | 삭제 | 204 No Content | O |
PUT vs PATCH 차이: PUT은 전체 교체(보내지 않은 필드는 null로 덮어씀), PATCH는 보낸 필드만 수정한다. 강의 실습에서 @PatchMapping을 쓴 이유가 이것이다.
ResponseEntity를 쓰면 HTTP 상태 코드, 헤더, 바디를 직접 제어할 수 있다.
@PostMapping("/books")
public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
Book saved = bookService.create(book);
return ResponseEntity.status(HttpStatus.CREATED).body(saved); // 201 + body
}
@DeleteMapping("/books/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
bookService.deleteBook(id);
return ResponseEntity.noContent().build(); // 204 + 빈 body
}
@GetMapping("/books/{id}")
public ResponseEntity<Book> getBook(@PathVariable Long id) {
Book book = bookService.findById(id);
return ResponseEntity.ok(book); // 200 + body (축약형)
}
강의를 통해 하나의 HTTP 요청이 어떻게 처리되는지 전체 흐름을 정리하자.
[클라이언트] POST /books {"title": "Spring 입문", "author": "임한울"}
↓
[DispatcherServlet] 요청을 적절한 Controller로 라우팅
↓
[BookController] @Valid로 입력값 검증 → BookService.create() 호출
↓
[BookService] @Transactional 시작 → bookRepository.save() 호출
↓
[BookRepository] JpaRepository → Hibernate → SQL 실행
↓
[DB] INSERT INTO book (title, author) VALUES (?, ?)
↓
[응답] 201 Created + 저장된 Book JSON
예외가 발생하면:
[BookService] BookNotFoundException 발생
↓
[GlobalExceptionHandler] @ExceptionHandler가 잡아서 처리
↓
[응답] 404 Not Found + {"error": "Book not found", "message": "..."}
Spring을 앞으로 자주 사용할지는 아직 모르겠다. 최근에는 목적에 따라 다양한 서버 기술들이 사용되고 있기 때문이다. Python 기반 또는 간단한 프로젝트에서는 FastAPI나 Flask를 사용하는 경우가 많고, JavaScript 생태계에서는 Node.js와 Express, NestJS를 활용하는 사례도 많다. 또한 AI 서비스를 개발할 때는 모델 추론 서버를 FastAPI로 구축하는 경우가 흔하며, 게임이나 소규모 프로젝트에서는 Firebase나 Supabase 같은 BaaS 서비스를 활용하여 별도의 서버 개발 없이 기능을 구현하기도 한다. AWS Lambda와 같은 서버리스 환경을 사용하면 서버를 직접 운영하지 않고도 백엔드 기능을 제공할 수 있다.
그럼에도 이번 Spring 학습은 단순히 특정 프레임워크를 배우는 것 이상의 의미가 있었다. 어떤 기술 스택을 선택하더라도 서버 애플리케이션은 의존성 관리, 계층 분리, 예외 처리, 데이터 접근과 같은 공통적인 문제를 해결해야 한다. Spring은 이러한 문제들을 체계적으로 다루는 방법을 제공했고, Java 수업에서 배웠던 객체지향 설계 원칙들이 실제 프로젝트에서 어떻게 활용되는지를 이해할 수 있게 해주었다.
특히 Java 강의에서 학습했던 내용들이 Spring에서 자연스럽게 연결된다는 점과 실제 다음 미니프로젝트에서 연결해 사용할 수 있다는점이 좋았다. (예비군 참여로 인해 프로젝트에 기여한점은 없었다.) 인터페이스를 활용한 다형성은 의존성 주입(DI) 구조로 이어졌고, 사용자 정의 예외는 서비스 계층의 예외 처리 방식으로 확장되었다.
그중에서도 가장 생각해보고 고민한 부분은 생성자 주입 방식이었다. Java 수업에서는 객체를 직접 생성하거나 의존성을 전달하는 코드를 작성했지만, Spring에서는 @RequiredArgsConstructor와 final 키워드만으로 의존성 주입이 자동으로 이루어졌다. 덕분에 객체 간 결합도를 낮추면서도 코드를 더욱 간결하게 유지할 수 있었다.
학습 과정에서 가장 어려웠던 점은 레이어를 분리하는 기준을 이해하는 것이었다. Controller, Service, Repository 구조 자체는 이해할 수 있었지만 어떤 로직을 어느 계층에 배치해야 하는지 판단하는 것이 쉽지 않았다. 하지만 "HTTP 요청과 응답 처리는 Controller, 비즈니스 로직은 Service, 데이터 접근은 Repository"라는 기준을 세워야 각 계층의 역할이 명확하게 보인다고 생각한다. 이후에는 코드를 작성할 때도 책임을 분리하여 설계할 수 있었다.
JPA의 Dirty Checking 역시 처음에는 매우 신기한 기능으로 느껴졌다. 데이터를 수정한 뒤 save()를 호출하지 않았는데도 데이터베이스의 값이 변경되는 이유를 이해하지 못했지만, 영속성 컨텍스트가 엔티티의 상태를 추적하고 트랜잭션 종료 시점에 변경 사항을 자동으로 반영한다는 원리를 학습하면서 동작 과정을 이해할 수 있었다. 내부적으로 어떤 방식으로 넘어가는 후에 글을 찾아보며 공부해보자. 10-2글을 마무리한다.
출처/참고