Spring Boot 개발기록 #2 [claude 요약]
Lombok, JPA 트랜잭션, 전역 예외 처리, Component Scan 등의 내용을 NestJS와 비교하며 설명하는 글이다.
1. @RequiredArgsConstructor - 생성자 자동 생성
NestJS와 비교
NestJS:
@Controller('todos')
export class TodoController {
constructor(private readonly todoService: TodoService) {}
}
Spring (Lombok 없이):
@RestController
public class TodoController {
private final TodoService todoService;
// 생성자를 직접 작성해야 함!
public TodoController(TodoService todoService) {
this.todoService = todoService;
}
}
Spring (Lombok 사용):
@RestController
@RequiredArgsConstructor // ← final 필드의 생성자를 자동 생성!
public class TodoController {
private final TodoService todoService;
}
→ NestJS의 constructor(private readonly ...)와 동일한 효과!
2. @Transactional(readOnly = true) - 클래스 레벨
개념
클래스 레벨에 선언하면 해당 클래스의 모든 메서드가 기본적으로 읽기 전용 트랜잭션으로 실행됩니다.
NestJS 비유 (Prisma)
// Spring의 @Transactional(readOnly = true)와 유사
await prisma.$transaction(async (tx) => {
// 읽기 전용 모드 - 성능 최적화
const todos = await tx.todo.findMany();
}, { isolationLevel: 'ReadCommitted' });
효과
- 성능 최적화: Hibernate가 변경 감지(Dirty Checking)를 하지 않음
- DB 최적화: 읽기 전용 힌트를 DB에 전달
- 기본값 설정: 클래스 내 모든 메서드가 읽기 전용 트랜잭션
3. 메서드의 @Transactional - 쓰기 가능 트랜잭션
Spring 예시
@Service
@Transactional(readOnly = true) // 클래스 기본값: 읽기 전용
public class TodoService {
public List<Todo> findAll() {
// 읽기 전용 트랜잭션 사용
}
@Transactional // ← 메서드는 쓰기 가능으로 오버라이드!
public TodoResponseDto create(TodoCreateDto dto) {
// 쓰기 가능 트랜잭션
todoRepository.save(todo);
}
}
NestJS 비유
@Injectable()
export class TodoService {
// 읽기만
async findAll() {
return this.prisma.todo.findMany();
}
// 쓰기 필요 - 트랜잭션 시작
async create(dto: CreateTodoDto) {
return this.prisma.$transaction(async (tx) => {
return tx.todo.create({ data: dto });
});
}
}
차이점: Spring은 명시적으로 트랜잭션 경계를 지정, NestJS는 보통 자동 커밋
4. private final - 불변 필드 선언
NestJS와 비교
NestJS:
@Injectable()
export class TodoService {
constructor(
private readonly todoRepository: TodoRepository // ← 이것과 동일!
) {}
}
Spring:
@Service
@RequiredArgsConstructor
public class TodoService {
private final TodoRepository todoRepository; // ← 동일한 의미!
}
공통점
private: 클래스 내부에서만 접근final(Java) =readonly(TypeScript): 한 번 할당되면 변경 불가- 생성자 주입으로 할당됨
5. 의존성 자동 인식 - Component Scan
NestJS: 명시적 등록
// app.module.ts
@Module({
controllers: [TodoController], // ← 명시적 등록
providers: [TodoService], // ← 명시적 등록
})
export class AppModule {}
Spring Boot: 자동 스캔
// 별도 설정 불필요!
@RestController // ← 자동 스캔
public class TodoController {
private final TodoService todoService;
}
@Service // ← 자동 스캔
public class TodoService {
private final TodoRepository todoRepository;
}
interface TodoRepository extends JpaRepository<Todo, Long> {} // ← 자동 생성 + 등록
Spring Boot의 Component Scan 동작 원리
@SpringBootApplication어노테이션이 있는 메인 클래스를 찾음- 해당 패키지 및 하위 패키지를 자동 스캔
@Component,@Service,@RestController,@Repository등을 찾아서 자동 등록- 생성자를 보고 의존성을 자동으로 주입
메인 클래스 예시
@SpringBootApplication // ← 이게 ComponentScan을 포함!
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
NestJS vs Spring Boot 비교
| NestJS | Spring Boot | |
|---|---|---|
| 등록 | @Module에 명시적 등록 |
자동 스캔 (어노테이션만 달면 됨) |
| DI Container | Module 기반 | ApplicationContext (전역) |
| 스캔 범위 | Module별로 제한 | 패키지 기반 전체 스캔 |
→ Spring은 더 "관례 중심(Convention over Configuration)"입니다!
추가 학습한 내용
Java Record
- Java 16에서 정식 도입된 불변 데이터 클래스
- DTO에 적합
- Lombok
@Builder와 함께 사용 가능 - getter가
getTitle()이 아닌title()형태
Hibernate 타임스탬프 자동 생성
@CreationTimestamp: 생성 시간 자동 기록@UpdateTimestamp: 수정 시간 자동 기록- Spring Data JPA의
@CreatedDate/@LastModifiedDate보다 간단 (설정 불필요)
Swagger 어노테이션 간소화
- RequestBody에 대한 긴 Swagger 어노테이션은 대부분 불필요
- Spring의
@RequestBody만으로도 Swagger가 자동 인식 - DTO 레벨에서
@Schema어노테이션으로 문서화하는 것이 재사용성 높음
6. Optional 언박싱 - orElseThrow() 패턴
문제 상황
JPA Repository의 findById()는 Optional<T>를 반환합니다.
Optional<Todo> todo = todoRepository.findById(id);
todo.update(dto); // ❌ 컴파일 에러! Optional에는 update 메서드가 없음
해결 방법
방법 1: orElseThrow() - 추천
@Transactional
public void update(Long id, TodoUpdateDto dto) {
Todo todo = todoRepository.findById(id)
.orElseThrow(() -> new NotFoundException("게시물을 찾을 수 없습니다."));
todo.update(dto);
// save() 불필요 - JPA Dirty Checking으로 자동 저장
}
방법 2: isEmpty() + get() - 비추천
Optional<Todo> optional = todoRepository.findById(id);
if (optional.isEmpty()) {
throw new NotFoundException("게시물을 찾을 수 없습니다.");
}
Todo todo = optional.get();
NestJS와 비교
NestJS:
const todo = await this.todoRepository.findById(id);
if (!todo) {
throw new NotFoundException('게시물을 찾을 수 없습니다.');
}
todo.update(dto);
Spring:
Todo todo = todoRepository.findById(id)
.orElseThrow(() -> new NotFoundException("게시물을 찾을 수 없습니다."));
todo.update(dto);
JPA Dirty Checking
@Transactional 안에서 조회한 엔티티의 필드를 변경하면:
- 트랜잭션 종료 시 Hibernate가 자동으로 변경 감지
- 자동으로 UPDATE 쿼리 실행
repository.save()호출 불필요!
7. 전역 예외 처리 - @RestControllerAdvice
NestJS vs Spring Boot 예외 처리
NestJS:
// exception.filter.ts
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
message: exception.message,
timestamp: new Date().toISOString(),
});
}
}
// main.ts
app.useGlobalFilters(new HttpExceptionFilter());
Spring Boot:
@RestControllerAdvice // ← NestJS의 @Catch()와 유사
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFoundException(
NotFoundException ex,
WebRequest request
) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("status", HttpStatus.NOT_FOUND.value());
body.put("error", ex.getMessage());
body.put("path", request.getDescription(false).replace("uri=", ""));
return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
}
Checked Exception vs Unchecked Exception
왜 Controller에서 throws 선언이 필요 없을까?
Java에는 두 가지 예외 타입이 있습니다:
-
Checked Exception (
Exception을 상속)- 컴파일러가 처리를 강제함
- 메서드 시그니처에
throws선언 필수 - 예:
IOException,SQLException,BadRequestException
-
Unchecked Exception (
RuntimeException을 상속)- 컴파일러가 처리를 강제하지 않음
throws선언 불필요- 예:
NullPointerException,IllegalArgumentException
커스텀 예외 설계:
// Unchecked Exception으로 만들기
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message); // ← RuntimeException을 상속
}
}
Checked Exception 예시:
// Service
public void update(Long id, TodoUpdateDto dto) throws BadRequestException {
// BadRequestException은 Exception을 상속 (Checked)
}
// Controller - throws 선언 필수!
public void update(...) throws BadRequestException {
todoService.update(id, dto);
}
Unchecked Exception 예시:
// Service
public void update(Long id, TodoUpdateDto dto) {
throw new NotFoundException("..."); // RuntimeException 상속
}
// Controller - throws 선언 불필요!
public void update(...) {
todoService.update(id, dto); // ✅ 컴파일 에러 없음
}
GlobalExceptionHandler의 동작 원리
- Controller/Service에서 예외 발생
- Spring이 예외를 캐치
@RestControllerAdvice가 붙은 클래스에서@ExceptionHandler메서드 찾기- 매칭되는 예외 타입의 핸들러 실행
- 핸들러가 반환한 ResponseEntity를 클라이언트에 전송
장점:
- Controller에
throws불필요 (Unchecked Exception 사용 시) - 일관된 에러 응답 형식
- 중복 코드 제거
- 모든 예외를 한 곳에서 관리
실무 패턴
// 커스텀 예외들 (모두 RuntimeException 상속)
public class NotFoundException extends RuntimeException {}
public class UnauthorizedException extends RuntimeException {}
public class ForbiddenException extends RuntimeException {}
// GlobalExceptionHandler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<?> handleNotFound(...) { /* 404 */ }
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<?> handleUnauthorized(...) { /* 401 */ }
@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<?> handleForbidden(...) { /* 403 */ }
}
8. Path Variable vs Request Param
잘못된 사용
@PutMapping(":id") // ❌ 잘못된 문법
public void update(@RequestParam Long id, ...) {
// @RequestParam은 쿼리 파라미터용
// PUT /api/todos?id=1 형태로 요청해야 함
}
올바른 사용
@PutMapping("/{id}") // ✅ 중괄호 사용
public void update(@PathVariable Long id, ...) {
// PUT /api/todos/1 형태로 요청
}
NestJS와 비교
NestJS:
@Put(':id')
update(@Param('id') id: number, @Body() dto: UpdateDto) {
// PUT /api/todos/1
}
@Get()
search(@Query('keyword') keyword: string) {
// GET /api/todos?keyword=spring
}
Spring Boot:
@PutMapping("/{id}")
public void update(@PathVariable Long id, @RequestBody UpdateDto dto) {
// PUT /api/todos/1
}
@GetMapping
public List<Todo> search(@RequestParam String keyword) {
// GET /api/todos?keyword=spring
}
| Path Variable | Query Parameter | |
|---|---|---|
| NestJS | @Param('id') |
@Query('keyword') |
| Spring | @PathVariable |
@RequestParam |
| URL 패턴 | /todos/{id} |
/todos?id=1 |
| 용도 | 리소스 식별 | 필터링/검색 |