내용 준비중...
- Spring Web-JSP 2025.12.10
- Spring Web-Thymeleaf 2025.12.09 1
- Spring Web-Restful API 2025.12.08
- Lombok 2025.12.08 1
- Spring Boot DevTools 2025.12.08
- JPA vs MyBatis 2025.11.21
- Spring Boot에서 자주 사용되는 도구 2025.11.20
- Spring Boot란 2025.11.20
- @Async-비동기 처리 2025.11.19 1
- 'JPA N+1' 문제, @Async를 활용한 비동기 처리 2025.11.19
Spring Web-JSP
Spring Web-Thymeleaf
0. 최종적으로 만들 구조
우리가 목표로 하는 구조는 대략 이렇게 될 거예요.
src/main/java
└─ com.example.demo
├─ DemoApplication.java
├─ controller
│ └─ HomeController.java
├─ service
│ └─ HomeService.java
├─ repository
│ └─ HomeRepository.java
├─ domain (또는 entity)
│ └─ Sample.java
└─ dto (선택)
└─ SampleDto.java
src/main/resources
├─ application.properties
├─ templates
│ └─ home.html
└─ static
└─ css
└─ app.css
1단계. MVC 계층용 패키지 생성
STS에서:
- src/main/java 아래의 com.example.demo 패키지에 우클릭
- New → Package
- 아래 패키지들을 차례로 만듭니다.
- com.example.demo.controller
- com.example.demo.service
- com.example.demo.repository
- com.example.demo.domain (또는 entity 이름도 많이 씀)
- com.example.demo.dto (선택, 나중에 응답/요청용 객체)
지금은 빈 패키지여도 괜찮아요. 일단 뼈대를 만든다고 생각하면 됩니다.
2단계. 의존성(Thymeleaf) 추가 – 화면 있는 MVC 용
지금은 spring-boot-starter-web만 있어서 JSON API만 바로 쓸 수 있고,
HTML 템플릿을 쓰려면 Thymeleaf 의존성을 한 줄 추가해 주는 게 좋습니다.
- 프로젝트 루트의 build.gradle 더블클릭해서 열기
- dependencies { ... } 블록 안에 아래 한 줄을 추가:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
#thymeleaf 추가
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
- 저장(Ctrl+S) 후, 프로젝트 우클릭 → Gradle → Refresh Gradle Project
- (또는 오른쪽 Gradle 뷰에서 새로고침 아이콘)
3단계. Controller 만들기
3-1. HomeController 클래스 생성
- com.example.demo.controller 패키지 우클릭 → New → Class
- Name: HomeController
- Finish 클릭 후, 아래처럼 작성:
package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
// http://localhost:8080/ 으로 접속했을 때 실행
@GetMapping("/")
public String home(Model model) {
// 뷰에 넘길 데이터
model.addAttribute("message", "Hello Spring MVC!");
// templates/home.html 을 의미
return "home";
}
}
return "home" 은 src/main/resources/templates/home.html 파일을 찾겠다는 뜻입니다.
4단계. Service / Repository / Domain 샘플 만들기
지금은 DB까지 안 붙였으니, 간단한 구조만 잡겠습니다.
4-1. Domain (모델) 예시
- com.example.demo.domain 패키지 → New → Class → Sample
package com.example.demo.domain;
public class Sample {
private Long id;
private String name;
public Sample() {}
public Sample(Long id, String name) {
this.id = id;
this.name = name;
}
// getter / setter
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
4-2. Repository (임시 메모리 저장소)
- com.example.demo.repository → New → Class → HomeRepository
package com.example.demo.repository;
import com.example.demo.domain.Sample;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
@Repository
public class HomeRepository {
// DB 대신 임시 메모리 리스트
private final List<Sample> samples = new ArrayList<>();
public HomeRepository() {
// 테스트용 더미 데이터
samples.add(new Sample(1L, "Spring"));
samples.add(new Sample(2L, "MVC"));
}
public List<Sample> findAll() {
return samples;
}
}
4-3. Service 계층
- com.example.demo.service → New → Class → HomeService
package com.example.demo.service;
import com.example.demo.domain.Sample;
import com.example.demo.repository.HomeRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class HomeService {
private final HomeRepository homeRepository;
// 생성자 주입
public HomeService(HomeRepository homeRepository) {
this.homeRepository = homeRepository;
}
public List<Sample> getSamples() {
return homeRepository.findAll();
}
}
4-4. Controller에서 Service 사용하게 수정
HomeController를 아래처럼 수정해 봅니다.
package com.example.demo.controller;
import com.example.demo.domain.Sample;
import com.example.demo.service.HomeService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@Controller
public class HomeController {
private final HomeService homeService;
public HomeController(HomeService homeService) {
this.homeService = homeService;
}
@GetMapping("/")
public String home(Model model) {
List<Sample> samples = homeService.getSamples();
model.addAttribute("message", "Hello Spring MVC!");
model.addAttribute("samples", samples);
return "home";
}
}
5단계. templates / static 폴더의 파일 만들기
5-1. home.html (뷰)
- src/main/resources/templates 폴더 → 우클릭 → New → HTML File (없으면 File 선택 후 이름에 .html 입력)
- 이름: home.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>Home</title>
<link rel="stylesheet" th:href="@{/css/app.css}">
</head>
<body>
<h1 th:text="${message}">기본 메시지</h1>
<ul>
<li th:each="item : ${samples}">
<span th:text="${item.id}">1</span> -
<span th:text="${item.name}">Name</span>
</li>
</ul>
</body>
</html>
5-2. CSS 파일
- src/main/resources/static 폴더 아래에 css 폴더를 만듭니다.
(static 우클릭 → New → Folder → 이름 css) - 그 안에 app.css 파일 생성:
body {
font-family: Arial, sans-serif;
}
h1 {
text-align: center;
}
ul {
list-style: none;
padding: 0;
}
6단계. 애플리케이션 실행해서 확인
- DemoApplication.java 우클릭 → Run As → Spring Boot App
- 콘솔에 Started DemoApplication 비슷한 로그가 나오면 성공.
- 브라우저에 http://localhost:8080/ 접속
- “Hello Spring MVC!”와 더미 데이터 리스트가 보이면 구조가 잘 잡힌 것입니다.
Spring Web-Restful API
0. 최종 목표 구조
src/main/java
└─ com.example.demo
├─ DemoApplication.java
├─ api
│ └─ PostApiController.java
├─ service
│ └─ PostService.java
├─ repository
│ └─ PostRepository.java
├─ domain
│ └─ Post.java
└─ dto
├─ PostRequestDto.java
└─ PostResponseDto.java
src/main/resources
└─ application.properties (필요 시)
- URL 예시
- GET /api/posts : 전체 조회
- GET /api/posts/{id} : 단건 조회
- POST /api/posts : 생성
- PUT /api/posts/{id} : 수정
- DELETE /api/posts/{id} : 삭제
1단계. 패키지 준비
이미 controller, service, repository, domain, dto를 만들어두셨다면 그대로 써도 됩니다.
REST API만 따로 보고 싶으면 아래처럼 api 패키지를 하나 더 두는 방법도 깔끔합니다.
- src/main/java 아래 com.example.demo 우클릭 → New → Package
- 이름: com.example.demo.api 생성
기존 MVC용 controller와 구분하려고 REST 전용은 api 패키지에 두는 방식입니다(선호 스타일).
2단계. Domain(Post) 엔티티 만들기
- com.example.demo.domain 패키지 → 우클릭 → New → Class
- 이름: Post
package com.example.demo.domain;
/**
* 간단한 게시글 도메인 객체
*/
public class Post {
private Long id; // 식별자
private String title; // 제목
private String content; // 내용
public Post() {
}
public Post(Long id, String title, String content) {
this.id = id;
this.title = title;
this.content = content;
}
// getter / setter
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
3단계. DTO (요청/응답용) 만들기
실무처럼 요청 DTO / 응답 DTO를 분리해 봅니다.
3-1. PostRequestDto (클라이언트 → 서버)
- com.example.demo.dto → New → Class → PostRequestDto
package com.example.demo.dto;
/**
* 게시글 생성/수정 요청용 DTO
*/
public class PostRequestDto {
private String title;
private String content;
public PostRequestDto() {
}
public PostRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
// getter / setter
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
3-2. PostResponseDto (서버 → 클라이언트)
- com.example.demo.dto → PostResponseDto
package com.example.demo.dto;
/**
* 게시글 응답용 DTO
*/
public class PostResponseDto {
private Long id;
private String title;
private String content;
public PostResponseDto() {
}
public PostResponseDto(Long id, String title, String content) {
this.id = id;
this.title = title;
this.content = content;
}
// getter / setter
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
4단계. Repository (메모리 기반)
실제 DB(JPA)는 나중 단계에서 붙이고, 지금은 Map으로 CRUD를 흉내만 냅니다.
- com.example.demo.repository → New → Class → PostRepository
package com.example.demo.repository;
import com.example.demo.domain.Post;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* 메모리 기반 게시글 저장소 (데모용)
*/
@Repository
public class PostRepository {
// id 자동 증가용 시퀀스
private final AtomicLong sequence = new AtomicLong(0L);
// 메모리 Map (id → Post)
private final Map<Long, Post> store = new HashMap<>();
public List<Post> findAll() {
return new ArrayList<>(store.values());
}
public Optional<Post> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public Post save(Post post) {
Long id = sequence.incrementAndGet();
post.setId(id);
store.put(id, post);
return post;
}
public Optional<Post> update(Long id, String title, String content) {
Post post = store.get(id);
if (post == null) {
return Optional.empty();
}
post.setTitle(title);
post.setContent(content);
return Optional.of(post);
}
public boolean deleteById(Long id) {
return store.remove(id) != null;
}
}
5단계. Service 계층
- com.example.demo.service → New → Class → PostService
package com.example.demo.service;
import com.example.demo.domain.Post;
import com.example.demo.dto.PostRequestDto;
import com.example.demo.repository.PostRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* 비즈니스 로직 담당 서비스
*/
@Service
public class PostService {
private final PostRepository postRepository;
// 생성자 주입
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
public List<Post> getPosts() {
return postRepository.findAll();
}
public Optional<Post> getPost(Long id) {
return postRepository.findById(id);
}
public Post createPost(PostRequestDto requestDto) {
Post post = new Post();
post.setTitle(requestDto.getTitle());
post.setContent(requestDto.getContent());
return postRepository.save(post);
}
public Optional<Post> updatePost(Long id, PostRequestDto requestDto) {
return postRepository.update(id, requestDto.getTitle(), requestDto.getContent());
}
public boolean deletePost(Long id) {
return postRepository.deleteById(id);
}
}
6단계. REST Controller 만들기
이제 핵심인 REST API 컨트롤러입니다.
여기서는 @RestController + @RequestMapping("/api/posts") 형태로 작성합니다.
- com.example.demo.api → New → Class → PostApiController
package com.example.demo.api;
import com.example.demo.domain.Post;
import com.example.demo.dto.PostRequestDto;
import com.example.demo.dto.PostResponseDto;
import com.example.demo.service.PostService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* 게시글 REST API 컨트롤러
*/
@RestController
@RequestMapping("/api/posts")
public class PostApiController {
private final PostService postService;
public PostApiController(PostService postService) {
this.postService = postService;
}
/**
* GET /api/posts
* 전체 게시글 목록 조회
*/
@GetMapping
public List<PostResponseDto> getPosts() {
List<Post> posts = postService.getPosts();
return posts.stream()
.map(p -> new PostResponseDto(p.getId(), p.getTitle(), p.getContent()))
.collect(Collectors.toList());
}
/**
* GET /api/posts/{id}
* 개별 게시글 조회
*/
@GetMapping("/{id}")
public ResponseEntity<PostResponseDto> getPost(@PathVariable Long id) {
return postService.getPost(id)
.map(p -> ResponseEntity.ok(
new PostResponseDto(p.getId(), p.getTitle(), p.getContent())
))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* POST /api/posts
* 게시글 생성
*/
@PostMapping
public ResponseEntity<PostResponseDto> createPost(@RequestBody PostRequestDto requestDto) {
Post created = postService.createPost(requestDto);
PostResponseDto response = new PostResponseDto(
created.getId(), created.getTitle(), created.getContent()
);
// 생성 시 보통 201 Created 사용
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
/**
* PUT /api/posts/{id}
* 게시글 수정 (전체 수정)
*/
@PutMapping("/{id}")
public ResponseEntity<PostResponseDto> updatePost(@PathVariable Long id,
@RequestBody PostRequestDto requestDto) {
return postService.updatePost(id, requestDto)
.map(p -> ResponseEntity.ok(
new PostResponseDto(p.getId(), p.getTitle(), p.getContent())
))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/**
* DELETE /api/posts/{id}
* 게시글 삭제
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
boolean deleted = postService.deletePost(id);
if (deleted) {
return ResponseEntity.noContent().build(); // 204
} else {
return ResponseEntity.notFound().build();
}
}
}
여기서 핵심은
- @RestController → 메서드 리턴값이 곧 JSON 응답
- ResponseEntity<T> → HTTP status + body를 함께 제어
7단계. 실행 & 테스트
- DemoApplication 우클릭 → Run As → Spring Boot App
- 실행된 상태에서 HTTP 클라이언트로 테스트합니다.
7-1. 브라우저에서 간단 테스트 (GET)
- GET http://localhost:8080/api/posts
→ 처음에는 빈 배열 [] 이 나올 겁니다.
7-2. POSTMAN / curl 예시
새 게시글 생성
POST http://localhost:8080/api/posts
Content-Type: application/json
{
"title": "첫 번째 글",
"content": "REST API 테스트입니다."
}
전체 조회
GET http://localhost:8080/api/posts
단건 조회
GET http://localhost:8080/api/posts/1
수정
PUT http://localhost:8080/api/posts/1
Content-Type: application/json
{
"title": "제목 수정",
"content": "내용도 수정했습니다."
}
삭제
DELETE http://localhost:8080/api/posts/1
Lombok
1. Lombok이란 무엇인가?
Lombok은 **"지루하고 반복적인 코드(Boilerplate Code)를 어노테이션(@) 하나로 자동으로 생성해 주는 라이브러리"**입니다.
Java는 **"예의 바르지만 말이 너무 많은 언어"**입니다. 단순히 데이터를 담는 User 클래스 하나를 만들려고 해도 다음과 같은 코드를 일일이 작성해야 했습니다.
Getter(값 가져오기)Setter(값 넣기)Constructor(생성자)toString()(객체 정보 출력)equals(),hashCode()(객체 비교)
Lombok은 컴파일 과정(javac)에서 개입하여 이 코드들을 개발자 대신 작성해서 .class 파일에 넣어줍니다.
2. 백문이 불여일견: 코드 비교
이것을 보시면 왜 Lombok을 "필수"라고 하는지 바로 이해되실 겁니다.
❌ Lombok이 없을 때 (Java의 현실)
필드(변수)는 고작 2개인데, 코드는 30줄이 넘어갑니다.
public class User {
private Long id;
private String name;
// 1. 기본 생성자
public User() {}
// 2. 모든 필드 생성자
public User(Long id, String name) {
this.id = id;
this.name = name;
}
// 3. Getter / Setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
// 4. toString
@Override
public String toString() {
return "User{id=" + id + ", name='" + name + "'}";
}
}
✅ Lombok 사용 시 (마법)
똑같은 기능을 하는 코드가 단 5줄로 줄어듭니다.
import lombok.Data; // 롬복 임포트
@Data // <- 이 어노테이션 하나가 위 기능을 다 만들어줍니다!
public class User {
private Long id;
private String name;
}
3. 자주 쓰는 핵심 어노테이션 (Must Know)
Lombok에는 수많은 어노테이션이 있지만, 실무에서 90% 이상 사용하는 것들은 정해져 있습니다.
| 어노테이션 | 역할 | 비고 |
|---|---|---|
@Getter |
모든 필드의 getXXX() 메서드 생성 |
가장 기본 |
@Setter |
모든 필드의 setXXX() 메서드 생성 |
데이터 변경이 필요할 때만 사용 |
@ToString |
객체를 출력할 때 예쁘게 보여주는 메서드 생성 | 디버깅용으로 필수 |
@NoArgsConstructor |
파라미터가 없는 기본 생성자 생성 | JPA(DB) 사용할 때 필수 |
@AllArgsConstructor |
모든 필드를 파라미터로 받는 생성자 생성 | 테스트할 때 유용 |
@RequiredArgsConstructor |
final이 붙은 필드만 받는 생성자 생성 |
의존성 주입(DI) 할 때 최고! |
@Builder |
빌더 패턴(User.builder().id(1).build()) 생성 |
객체 생성을 직관적으로 만듦 |
@Data |
위의 기능들을 종합 선물 세트로 제공 | Getter, Setter, ToString, Equlas 등 포함 |
4. 작동 원리 (Compile-Time Magic)
많은 분들이 Lombok이 실행 중(Runtime)에 동작한다고 착각하지만, 사실은 컴파일(Compile) 시점에 동작합니다.
- 개발자가 소스 코드(
.java)를 작성합니다 (어노테이션만 붙임). - 컴파일러(
javac)가 코드를 컴파일하기 시작합니다. - Lombok Annotation Processor가 중간에 끼어듭니다. "어?
@Getter가 있네? 내가getName()메서드 만들어서 끼워 넣어야지." - 결과물인 바이트코드(
.class)에는 실제getName()메서드가 존재하게 됩니다.
💡 핵심: 그래서 Lombok 라이브러리는 프로그램이 실제로 돌아가는 서버에는 배포될 필요가 없습니다. (오직 컴파일할 때만 필요함)
5. 설치 및 설정 방법 (Gradle)
build.gradle에 다음과 같이 설정합니다.
dependencies {
// 1. 컴파일할 때만 롬복을 써라 (배포 파일인 JAR에는 포함 안 됨 -> 용량 절약)
compileOnly 'org.projectlombok:lombok'
// 2. 어노테이션을 해석해서 코드를 생성해라
annotationProcessor 'org.projectlombok:lombok'
}
⚠️ 주의사항 (IntelliJ 사용자 필독):
IntelliJ에서 Lombok을 쓰려면 **'Lombok Plugin'**이 설치되어 있어야 하고, 설정에서 **'Enable annotation processing'**을 체크해야 합니다. (최신 버전은 대부분 자동으로 되어 있습니다.)
6. 전문가의 조언 (Pros & Cons)
장점 (Pros)
- 생산성 폭발: 코드를 읽고 쓰는 시간이 획기적으로 줄어듭니다.
- 가독성: 핵심 로직(필드 데이터)에만 집중할 수 있습니다.
- 유지보수: 필드 이름을
username에서nickname으로 바꿔도, Getter/Setter를 일일이 고칠 필요가 없습니다.
단점 및 주의점 (Cons)
@Data남용 금지:@Data는 너무 강력해서Setter까지 다 만들어버립니다. 데이터가 함부로 바뀌면 안 되는 중요한 객체에는@Getter와@Builder정도만 쓰는 것이 좋습니다.- Kotlin과의 관계: 앞서 질문하셨던 Kotlin 언어는 Lombok의 기능(Data Class)이 언어 자체에 내장되어 있어서 Lombok을 전혀 쓰지 않습니다. (Java의 단점을 Lombok이 메워주고 있었던 것이죠.)
네, 각 어노테이션이 실제로 어떤 코드로 변환되는지, 그리고 실제 코드에서 어떻게 사용하는지 핵심만 딱 짚어서 보여드리겠습니다.
하나의 User 클래스를 예시로 들어 설명합니다.
1. @Getter / @Setter
가장 기본입니다. 필드에 접근하고 값을 수정하는 메서드를 만듭니다.
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class User {
private String name;
private int age;
}
// --- 실제 사용 예시 ---
public class Main {
public void example() {
User user = new User();
// @Setter 덕분에 사용 가능
user.setName("Gemini");
user.setAge(25);
// @Getter 덕분에 사용 가능
System.out.println(user.getName()); // 출력: Gemini
}
}
2. @NoArgsConstructor (기본 생성자)
파라미터가 없는 텅 빈 생성자를 만듭니다. JPA(DB)나 Jackson(JSON 파싱) 라이브러리가 객체를 만들 때 필수적으로 필요합니다.
import lombok.NoArgsConstructor;
import lombok.Getter;
@Getter
@NoArgsConstructor // -> public User() {} 생성
public class User {
private String name;
}
// --- 실제 사용 예시 ---
public class Main {
public void example() {
// 인자 없는 기본 생성자로 객체 생성 가능
User user = new User();
}
}
3. @RequiredArgsConstructor (필수 인자 생성자)
**final**이 붙거나 **@NonNull**이 붙은 필드만 챙기는 생성자를 만듭니다.
Spring Boot에서 **의존성 주입(DI)**을 받을 때 가장 많이 쓰입니다.
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor // -> public UserService(UserRepository userRepository) { ... } 생성
public class UserService {
// final이 붙었으므로 "필수"로 취급됨 -> 생성자 파라미터에 포함됨
private final UserRepository userRepository;
// final이 없으면 생성자에서 제외됨
private int tempValue;
public void logic() {
userRepository.save(); // 안전하게 주입받아 사용 가능
}
}
4. @Builder (빌더 패턴)
객체를 생성할 때 생성자 대신 체이닝 방식으로 직관적으로 값을 넣을 수 있게 해 줍니다. 파라미터 순서를 헷갈릴 일이 없어집니다.
import lombok.Builder;
import lombok.ToString;
@Builder
@ToString
public class User {
private String name;
private int age;
private String email;
}
// --- 실제 사용 예시 ---
public class Main {
public void example() {
// 생성자 순서(name, age, email)를 외울 필요 없이 명시적으로 값을 넣음
User user = User.builder()
.name("Gemini")
.email("gemini@google.com")
.age(25)
.build();
System.out.println(user);
}
}
5. [종합] 실무에서 가장 많이 쓰는 조합 (DTO)
실무에서는 보통 **DTO(Data Transfer Object)**를 만들 때 아래 조합을 세트 메뉴처럼 사용합니다.
import lombok.*;
@Getter // 데이터 조회용
@NoArgsConstructor // JPA/Jackson용 기본 생성자
@AllArgsConstructor // Builder 패턴이 제대로 동작하기 위해 전체 생성자 필요
@Builder // 객체 생성을 편하게
public class UserDto {
private String username;
private String email;
private Integer age;
}
// 사용:
// UserDto dto = UserDto.builder().username("Test").build();
Tip: * @RequiredArgsConstructor는 주로 Controller, Service 같은 빈(Bean) 객체에 씁니다.
@Builder,@NoArgsConstructor는 주로 Entity, DTO 같은 데이터 객체에 씁니다.
Spring Boot DevTools
1. Spring Boot DevTools란?
개발(Development) 단계에서 필요한 여러 가지 편의 기능을 모아놓은 도구 모음입니다.
가장 큰 특징은 **"코드를 고치면, 알아서 서버를 재시작해 준다"**는 점입니다.
⚠️ 주의: 이 도구는 오직 **개발 환경(Development)**을 위한 것입니다. 배포(Production) 시에는 자동으로 비활성화되므로 안심하고 사용하셔도 됩니다.
2. 핵심 기능 3가지 (Why use it?)
① Automatic Restart (자동 재시작)
- 상황: 자바 코드(
Controller,Service등)를 수정했습니다. - 과거: 서버 중지 -> 다시 실행 버튼 클릭 -> 스프링 로고 보며 10초 대기 -> 테스트.
- DevTools: 저장(Ctrl+S) 또는 빌드(Ctrl+F9) 하자마자 서버가 번개처럼 빠르게 재시작됩니다.
- 원리: 변경되지 않는 외부 라이브러리(Spring, Jackson 등)는 그대로 두고, 우리가 짠 코드만 갈아 끼우기 때문에 껐다 켜는 것보다 훨씬 빠릅니다.
② LiveReload (라이브 리로드)
- 상황: HTML, CSS, JS 같은 정적 자원을 수정했습니다.
- DevTools: 브라우저를 새로고침(F5)하지 않아도 화면이 자동으로 바뀝니다.
- (참고: 브라우저에 무료 LiveReload 확장 프로그램을 설치해야 완벽하게 동작합니다.)
③ Property Defaults (설정 기본값 최적화)
- 상황: Thymeleaf 같은 템플릿 엔진은 성능을 위해 한 번 읽은 파일을 캐싱(Caching) 합니다. 그래서 개발 중에 HTML을 고쳐도 바로 반영이 안 될 때가 있습니다.
- DevTools: 개발 중에는 이런 캐시 옵션들을 자동으로
false로 꺼줍니다. (spring.thymeleaf.cache=false등을 일일이 적을 필요가 없습니다.)
3. 프로젝트에 적용하기 (Installation)
build.gradle (또는 pom.xml)에 의존성을 딱 한 줄만 추가하면 됩니다.
Gradle (build.gradle)
dependencies {
// ... 다른 의존성들 ...
// developmentOnly: 운영 배포 파일(JAR)에는 포함되지 않게 함
developmentOnly 'org.springframework.boot:spring-boot-devtools'
}
Maven (pom.xml)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional> </dependency>
4. IntelliJ 사용 시 주의사항 (필독!) 🚨
많은 초보자분들이 "DevTools를 깔았는데 자동 재시작이 안 돼요!" 라고 질문합니다.
IntelliJ는 기본적으로 자동 저장 시 빌드를 하지 않기 때문입니다.
제대로 작동하게 하려면 두 가지 방법 중 하나를 쓰셔야 합니다.
- 수동 트리거 (추천): 코드를 고치고
Ctrl + F9(Build Project) 를 누릅니다. 그러면 DevTools가 감지하고 재시작합니다. - 설정 변경 (자동):
Settings->Build, Execution, Deployment->Compiler- "Build project automatically" 체크
- (Advanced Settings) "Allow auto-make to start even if developed application is currently running" 체크
5. 심화: 왜 빠를까? (작동 원리)
DevTools는 클래스 로더(Class Loader)를 두 개로 나누어 관리합니다.
- Base ClassLoader: 변경될 일이 거의 없는 **외부 라이브러리(JARs)**를 읽습니다. (무거움)
- Restart ClassLoader: 개발자가 작성 중인 **애플리케이션 코드(.class)**를 읽습니다. (가벼움)
코드를 수정하면 'Restart ClassLoader'만 버리고 새로 만듭니다. 무거운 라이브러리는 다시 로딩하지 않기 때문에, 콜드 부팅(Cold Boot)보다 훨씬 빠른 속도로 재시작이 가능한 것입니다.
부록-LiveReload (라이브 리로드) 정상적으로 동작하지 않는다면
http://localhost:35729/livereload.js?snipver=1 호출 결과 '페이지를 찾을 수 없습니다.', 404인경우 포트('35729')가 활성화 되지않아서 입니다.
application.properties 파일에 해당 옵션을 추가해 줍니다. 이후 서버를 정지 후 시작을 하면 포트('35729')가 활성화됩니다.
spring.devtools.livereload.enabled=true
JPA vs MyBatis
| JPA (Java Persistence API) | MyBatis (SQL Mapper) | |
| 주요 역할 | ORM (객체-관계 매핑) 기술의 표준 명세 | SQL 매퍼 프레임워크 |
| 개발 방식 | 객체 중심 개발 (SQL을 직접 작성하지 않음) | SQL 중심 개발 (SQL을 직접 작성하고 매핑) |
| SQL 처리 | JPA 구현체(예: Hibernate, EclipseLink)가 자동 생성 | 개발자가 XML 파일 등에 직접 작성 |
| 데이터 매핑 | 엔티티 객체와 DB 테이블을 매핑 | DTO/VO 객체와 SQL 결과를 매핑 |
| 데이터베이스 독립성 | 높음 (DB 변경 시 코드 수정 최소화) | 낮음 (SQL이 DB에 종속될 수 있음) |
1. JPA 예제 코드
// User.java (JPA Entity)
@Entity
@Table(name = "USERS")
public class User {
@Id
private Long id;
private String name;
// Getter, Setter, Constructors 생략
}
// UserRepository.java (Spring Data JPA 사용 예시)
public interface UserRepository extends JpaRepository<User, Long> {
// findById(Long id) 메서드는 JPA가 자동으로 구현해 줍니다.
// 개발자는 SQL을 작성할 필요가 없습니다.
}
// UserService.java
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User getUserById(Long userId) {
// 객체를 찾습니다. SQL은 JPA 구현체(Hibernate)가 자동으로 생성합니다.
// SELECT * FROM USERS WHERE id = ?
return userRepository.findById(userId).orElse(null);
}
}
2. MyBatis (SQL Mapper)
// User.java (VO/DTO)
public class User {
private Long id;
private String name;
// Getter, Setter, Constructors 생략
}
// UserMapper.java
@Mapper // MyBatis Mapper Interface
public interface UserMapper {
// MyBatis가 아래 메서드 이름과 매핑된 XML의 SQL을 찾아 실행합니다.
User selectUserById(Long id);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">
<select id="selectUserById" resultType="com.example.User">
SELECT
id,
name
FROM
USERS
WHERE
id = #{id}
</select>
</mapper>
// UserService.java
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public User getUserById(Long userId) {
// 매퍼 인터페이스를 통해 XML에 정의된 SQL을 호출합니다.
return userMapper.selectUserById(userId);
}
}Spring Boot에서 자주 사용되는 도구
1. Developer Tools
1.1. Spring Boot DevTools
Provides fast application restarts, LiveReload, and configurations for enhanced development experience.
Spring Boot DevTools
1. Spring Boot DevTools란?개발(Development) 단계에서 필요한 여러 가지 편의 기능을 모아놓은 도구 모음입니다.가장 큰 특징은 **"코드를 고치면, 알아서 서버를 재시작해 준다"**는 점입니다.⚠️ 주의:
jangjeonghun.tistory.com
1.2. Lombok
Java annotation library which helps to reduce boilerplate code.
Lombok
1. Lombok이란 무엇인가?Lombok은 **"지루하고 반복적인 코드(Boilerplate Code)를 어노테이션(@) 하나로 자동으로 생성해 주는 라이브러리"**입니다.Java는 **"예의 바르지만 말이 너무 많은 언어"**입니다.
jangjeonghun.tistory.com
2. Web
2.1. Spring Web
Build web, including RESTful, applications using Spring MVC. Uses Apache Tomcat as the default embedded container.
Spring Web
1. Spring Web이란? (spring-boot-starter-web)한마디로 정의하면 **"웹 애플리케이션을 만들기 위해 필요한 모든 것을 한 방에 모아놓은 종합 선물 세트"**입니다.build.gradle에 이 한 줄을 추가하는 순간, 당
jangjeonghun.tistory.com
3. Security
Spring Security
Highly customizable authentication and access-control framework for Spring applications.
4. SQL
Spring Data JPA
Persist data in SQL stores with Java Persistence API using Spring Data and Hibernate.
MySQL Driver
MySQL JDBC driver.
[옵션]1.Template Engines
Thymeleaf
A modern server-side Java template engine for both web and standalone environments. Allows HTML to be correctly displayed in browsers and as static prototypes.
[옵션]2. Security
OAuth2Client
Spring Boot integration for Spring Security's OAuth2/OpenID Connect client features.
Spring Boot란
1. 스프링 부트(Spring Boot)란?
자바 기반의 웹 프레임워크.
2. 프레임워크(Framework)란?
도구 모음.
스프링 부트(Spring Boot)는 개발자가 애플리케이션 개발에 더 집중할 수 있도록 돕는 자바 기반의 웹 프레임워크입니다.
프레임워크(Framework)는 소프트웨어를 개발할 때 공통적인 기능들을 미리 정해진 구조(틀)와 규칙 안에 제공하여 개발자가 반복적인 작업을 줄이고 핵심 로직 개발에 집중할 수 있게 돕는 도구 모음입니다.
https://spring.io/projects/spring-boot
Spring Boot
spring.io
@Async-비동기 처리
@Async를 활용한 비동기 처리
사용자 요청에 대한 응답 시간을 단축하기 위해, 메인 트랜잭션과 독립적으로 처리할 수 있는 부수적인 작업을 별도의 스레드에서 비동기적으로 실행하는 기술입니다.
1.1. 비동기 활성화 설정
애플리케이션의 메인 클래스나 별도의 설정 클래스에 **@EnableAsync**를 추가합니다.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@EnableAsync // ✅ 비동기 기능 활성화
@SpringBootApplication
public class AsyncApplication {
public static void main(String[] args) {
SpringApplication.run(AsyncApplication.class, args);
}
}
// 별도의 스레드 풀 설정을 원하는 경우, AsyncConfigurer 인터페이스를 구현합니다.
// @Configuration
// public class AsyncConfig implements AsyncConfigurer {
// @Override
// public Executor getAsyncExecutor() {
// ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// executor.setCorePoolSize(10); // 기본 스레드 수
// executor.setMaxPoolSize(50); // 최대 스레드 수
// executor.setQueueCapacity(100); // 대기열 사이즈
// executor.setThreadNamePrefix("My-Async-"); // 스레드 이름 접두사
// executor.initialize();
// return executor;
// }
// }
1.2.1. 비동기 서비스 메서드
메인 로직과 독립적으로 실행되어야 하는 메서드에 **@Async**를 붙입니다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
// ✅ @Async가 붙으면 이 메서드는 호출 스레드(메인 요청 처리 스레드)와 별개의 스레드에서 실행됩니다.
// 메인 스레드는 이 메서드의 완료를 기다리지 않고 즉시 다음 코드를 실행합니다.
@Async
public void sendEmailNotification(String userEmail, String message) {
// 비동기 작업 시작
log.info("Sending email started by thread: {}", Thread.currentThread().getName());
try {
// 이메일 전송은 I/O 바운드 작업으로 시간이 오래 걸릴 수 있습니다. (예시로 3초 지연)
Thread.sleep(3000);
} catch (InterruptedException e) {
// 비동기 메서드의 예외는 호출 스레드로 전파되지 않으므로,
// 반드시 내부에서 예외 처리를 해야 합니다.
log.error("Email sending interrupted: {}", e.getMessage());
Thread.currentThread().interrupt(); // 인터럽트 상태 복원
}
log.info("Email sent successfully to {} by thread: {}", userEmail, Thread.currentThread().getName());
// 이 작업이 완료되든 말든, 사용자에게는 이미 응답이 전달되었을 수 있습니다.
}
}
1.2.2. 비동기 메서드 호출 (Controller 예시)
Controller에서는 비동기 메서드를 호출하고 즉시 응답을 반환하여 사용자의 체감 속도를 높입니다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PostController {
private static final Logger log = LoggerFactory.getLogger(PostController.class);
private final NotificationService notificationService;
public PostController(NotificationService notificationService) {
this.notificationService = notificationService;
}
@PostMapping("/posts")
public ResponseEntity<String> createPost() {
log.info("Request received by thread: {}", Thread.currentThread().getName());
// 1. 주요 작업 (게시물 DB 저장) 실행
// ... (게시물 저장 로직) ...
log.info("Post saved to DB.");
// 2. 부수적인 작업 (이메일 알림)을 비동기로 호출
// notificationService.sendEmailNotification() 메서드가 호출되자마자 즉시 반환됩니다.
// 메인 스레드는 3초를 기다리지 않습니다.
notificationService.sendEmailNotification("user@example.com", "새 게시물이 등록되었습니다.");
log.info("Async email task launched. Returning response immediately.");
// 사용자는 이메일 전송 완료 여부와 상관없이 1초 이내에 응답을 받습니다.
return ResponseEntity.ok("게시물이 성공적으로 등록되었습니다. (알림은 백그라운드에서 처리됩니다.)");
}
}
1.3. 비동기 작업을 논블로킹(Non-Blocking) 방식으로 처리(CompletableFuture)
'JPA N+1' 문제, @Async를 활용한 비동기 처리
1. 'JPA N+1' 문제 해결
N+1 문제는 JPA 환경에서 가장 흔하고 치명적인 성능 저하의 원인입니다. 1개의 메인 쿼리(N=1) 후, 연관된 엔티티를 조회하기 위해 N개의 추가 쿼리가 발생하는 현상을 의미합니다.
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
//
@Entity
@Table(name = "team")
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// N+1 문제의 근원. 'Team'을 조회할 때 'members'를 함께 가져올지 결정합니다.
// 기본적으로 OneToMany는 LAZY 로딩입니다.
// Team을 조회한 후, members 리스트에 접근할 때(지연 로딩 시점)마다 별도의 쿼리가 나갑니다.
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
// Getter, Setter, Constructors... (생략)
}
@Entity
@Table(name = "member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
// Getter, Setter, Constructors... (생략)
}
1.1. FETCH JOIN을 사용한 해결 (권장)
가장 확실한 방법으로, JPQL(@Query)을 사용하여 처음부터 필요한 모든 데이터를 한 번의 쿼리로 가져옵니다.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
@Repository
public interface TeamRepository extends JpaRepository<Team, Long> {
// ⛔ N+1 문제 발생 예시:
// 1. SELECT * FROM team (모든 팀 조회)
// 2. 이후, 각 팀의 members에 접근할 때마다 N번의 SELECT * FROM member WHERE team_id = ? 쿼리 발생
// List<Team> findAll();
// ✅ FETCH JOIN을 사용한 N+1 문제 해결
// Team과 연관된 Member를 JOIN 구문으로 한 번의 쿼리에 모두 담아 가져옵니다.
// 데이터베이스 레벨에서 JOIN이 일어나기 때문에 가장 효율적입니다.
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembersFetchJoin();
// 참고: `distinct` 키워드를 추가하면, Team 결과가 중복되는 것을 방지할 수 있습니다.
// @Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
// List<Team> findAllWithMembersDistinctFetchJoin();
}
1.2. @BatchSize를 사용한 해결
FETCH JOIN이 너무 많은 데이터를 가져오거나 복잡한 상황일 때 사용합니다. N+1 문제를 1+1 문제 (또는 1+K 문제, K는 Batch 쿼리 수)로 줄여줍니다.
import org.hibernate.annotations.BatchSize;
import jakarta.persistence.*;
import java.util.List;
@Entity
@Table(name = "team")
// BatchSize를 엔티티 레벨에 적용하여 모든 OneToMany 연관 관계에 영향을 줄 수 있습니다.
// 하지만 특정 연관 관계에만 적용하는 것이 더 좋습니다.
public class Team {
// ... 필드 정의 생략 ...
// ✅ @BatchSize를 사용한 N+1 문제 해결
// members에 접근할 때, 쿼리가 N번 나가는 대신, Team의 ID를 100개씩 묶어
// IN 절을 사용하는 추가 쿼리 (SELECT * FROM member WHERE team_id IN (ID1, ID2, ...))가 나갑니다.
@BatchSize(size = 100)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members;
// Getter, Setter, Constructors... (생략)
}
2. @Async를 활용한 비동기 처리
사용자 요청에 대한 응답 시간을 단축하기 위해, 메인 트랜잭션과 독립적으로 처리할 수 있는 부수적인 작업을 별도의 스레드에서 비동기적으로 실행하는 기술입니다.
2.1. 비동기 활성화 설정
애플리케이션의 메인 클래스나 별도의 설정 클래스에 **@EnableAsync**를 추가합니다.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@EnableAsync // ✅ 비동기 기능 활성화
@SpringBootApplication
public class AsyncApplication {
public static void main(String[] args) {
SpringApplication.run(AsyncApplication.class, args);
}
}
// 별도의 스레드 풀 설정을 원하는 경우, AsyncConfigurer 인터페이스를 구현합니다.
// @Configuration
// public class AsyncConfig implements AsyncConfigurer {
// @Override
// public Executor getAsyncExecutor() {
// ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// executor.setCorePoolSize(10); // 기본 스레드 수
// executor.setMaxPoolSize(50); // 최대 스레드 수
// executor.setQueueCapacity(100); // 대기열 사이즈
// executor.setThreadNamePrefix("My-Async-"); // 스레드 이름 접두사
// executor.initialize();
// return executor;
// }
// }
2.2.1. 비동기 서비스 메서드
메인 로직과 독립적으로 실행되어야 하는 메서드에 **@Async**를 붙입니다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
// ✅ @Async가 붙으면 이 메서드는 호출 스레드(메인 요청 처리 스레드)와 별개의 스레드에서 실행됩니다.
// 메인 스레드는 이 메서드의 완료를 기다리지 않고 즉시 다음 코드를 실행합니다.
@Async
public void sendEmailNotification(String userEmail, String message) {
// 비동기 작업 시작
log.info("Sending email started by thread: {}", Thread.currentThread().getName());
try {
// 이메일 전송은 I/O 바운드 작업으로 시간이 오래 걸릴 수 있습니다. (예시로 3초 지연)
Thread.sleep(3000);
} catch (InterruptedException e) {
// 비동기 메서드의 예외는 호출 스레드로 전파되지 않으므로,
// 반드시 내부에서 예외 처리를 해야 합니다.
log.error("Email sending interrupted: {}", e.getMessage());
Thread.currentThread().interrupt(); // 인터럽트 상태 복원
}
log.info("Email sent successfully to {} by thread: {}", userEmail, Thread.currentThread().getName());
// 이 작업이 완료되든 말든, 사용자에게는 이미 응답이 전달되었을 수 있습니다.
}
}
2.2.2. 비동기 메서드 호출 (Controller 예시)
Controller에서는 비동기 메서드를 호출하고 즉시 응답을 반환하여 사용자의 체감 속도를 높입니다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PostController {
private static final Logger log = LoggerFactory.getLogger(PostController.class);
private final NotificationService notificationService;
public PostController(NotificationService notificationService) {
this.notificationService = notificationService;
}
@PostMapping("/posts")
public ResponseEntity<String> createPost() {
log.info("Request received by thread: {}", Thread.currentThread().getName());
// 1. 주요 작업 (게시물 DB 저장) 실행
// ... (게시물 저장 로직) ...
log.info("Post saved to DB.");
// 2. 부수적인 작업 (이메일 알림)을 비동기로 호출
// notificationService.sendEmailNotification() 메서드가 호출되자마자 즉시 반환됩니다.
// 메인 스레드는 3초를 기다리지 않습니다.
notificationService.sendEmailNotification("user@example.com", "새 게시물이 등록되었습니다.");
log.info("Async email task launched. Returning response immediately.");
// 사용자는 이메일 전송 완료 여부와 상관없이 1초 이내에 응답을 받습니다.
return ResponseEntity.ok("게시물이 성공적으로 등록되었습니다. (알림은 백그라운드에서 처리됩니다.)");
}
}
2.3. 저장에 성공하고 특정 조건(예: 새로운 게시물인지)을 만족할 때만 알림을 호출합니다.(Transactional 활용)
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PostService {
private final PostRepository postRepository;
private final NotificationService notificationService; // 비동기 서비스 주입
public PostService(PostRepository postRepository, NotificationService notificationService) {
this.postRepository = postRepository;
this.notificationService = notificationService;
}
// @Transactional: DB 작업의 무결성을 보장하는 동기 트랜잭션
@Transactional
public Post createPost(Post post, String notificationTargetEmail) {
// 1. 핵심 동기 작업: DB 트랜잭션 실행
Post savedPost = postRepository.save(post);
// 2. 동기식 결과에 따른 조건 판단
boolean isPostSuccessfullySaved = (savedPost.getId() != null);
if (isPostSuccessfullySaved) {
// 3. 조건이 충족되면 비동기 메서드 호출
// 이메일 전송은 여기서 바로 시작되지만, Controller 스레드는 기다리지 않습니다.
notificationService.sendEmailNotification(
notificationTargetEmail,
"새 게시물 [" + savedPost.getTitle() + "]이 등록되었습니다."
);
}
// Controller 스레드는 알림 전송 완료를 기다리지 않고 즉시 응답 반환 준비
return savedPost;
}
}
@Transactional과 @Async의 주의사항
@Transactional 트랜잭션 범위 문제:
PostService.createPost() 메서드는 @Transactional이 적용되어 동기적으로 실행됩니다.
NotificationService.sendEmailNotification() 메서드는 @Async가 적용되어 새로운 스레드에서 실행됩니다.
Spring의 트랜잭션은 **스레드 로컬(Thread-Local)**로 관리됩니다. 따라서, @Async 메서드는 PostService의 트랜잭션과 분리된 완전히 새로운 스레드에서 실행됩니다.
만약 이메일 전송 시점에 PostService의 트랜잭션이 아직 커밋되지 않은 상태이더라도, @Async 메서드는 실행됩니다. 따라서, @Async 메서드 내에서 동기 트랜잭션이 완료된 결과를 필요로 하는 데이터베이스 조회 작업을 수행하려면, 트랜잭션 커밋이 확실히 완료된 후에 비동기 작업이 시작되도록 로직을 설계해야 합니다. (위 예시에서는 DB 저장 후 바로 호출하므로, 이미 savedPost 객체는 확정된 상태입니다.)