JSP & Spring Web 학습 가이드 📚¶
목차¶
JSP 기초¶
JSP란?¶
JSP (Java Server Pages)는 HTML 안에 Java 코드를 삽입할 수 있게 해주는 서버 사이드 스크립트 기술입니다.
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!-- JSP 페이지 지시자: 이 페이지가 HTML 형태로 출력되고, Java 언어를 사용함을 선언 -->
<!DOCTYPE html>
<html>
<head>
<title>JSP 기초 예제</title>
</head>
<body>
<!-- Java 변수 선언과 초기화 -->
<%
String userName = "홍길동"; // 서버에서 실행되는 Java 코드
int visitCount = 5;
%>
<!-- 변수 값을 HTML에 출력 -->
<h1>안녕하세요, <%= userName %>님!</h1> <!-- expression 태그: 변수 값 출력 -->
<p>총 <%= visitCount %>번 방문하셨습니다.</p>
</body>
</html>
💡 현업에서는?¶
- JSP 단독 사용: 거의 없음 (유지보수가 어려움)
- Spring MVC + Thymeleaf: 가장 많이 사용
- React/Vue + REST API: 최신 트렌드
Spring Web 개념¶
Spring MVC 패턴¶
Spring Web은 Model-View-Controller 패턴을 기반으로 합니다.
graph LR
A[클라이언트 요청] --> B[DispatcherServlet]
B --> C[Controller]
C --> D[Service]
D --> E[Repository]
E --> F[(Database)]
F --> E
E --> D
D --> C
C --> G[Model + View]
G --> H[ViewResolver]
H --> I[JSP/Thymeleaf]
I --> J[클라이언트 응답]
기본 Controller 예제¶
package com.example.demo.controller;
import org.springframework.stereotype.Controller; // 스프링 컨트롤러임을 명시
import org.springframework.ui.Model; // 뷰에 데이터 전달용 객체
import org.springframework.web.bind.annotation.GetMapping; // GET 요청 매핑
import org.springframework.web.bind.annotation.RequestParam; // 요청 파라미터 받기
@Controller // 이 클래스가 웹 요청을 처리하는 컨트롤러임을 스프링에게 알림
public class HelloController {
@GetMapping("/hello") // GET 방식으로 "/hello" 경로 요청이 들어오면 이 메서드 실행
public String hello(
@RequestParam(name = "name", defaultValue = "World") String name,
// URL의 ?name=값 파라미터를 받음. 없으면 기본값 "World" 사용
Model model // 뷰(HTML)에 데이터를 전달하기 위한 객체
) {
model.addAttribute("greeting", "안녕하세요, " + name + "님!");
// "greeting"이라는 이름으로 데이터를 뷰에 전달
return "hello"; // "hello.html" 템플릿을 찾아서 렌더링하라고 지시
}
}
🔍 QueryDSL을 활용한 동적 쿼리¶
// QueryDSL 설정 (build.gradle)
dependencies {
implementation 'com.querydsl:querydsl-jpa:5.0.0'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0'
}
// 커스텀 Repository 구현
@Repository
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory; // QueryDSL 쿼리 팩토리
/**
* 동적 검색 쿼리 - 조건에 따라 WHERE 절이 달라짐
*/
@Override
public Page<Member> searchMembers(MemberSearchCondition condition, Pageable pageable) {
QMember member = QMember.member; // QueryDSL Q클래스
List<Member> content = queryFactory
.selectFrom(member)
.where(
nameContains(condition.getName()), // 이름 포함 검색
emailContains(condition.getEmail()), // 이메일 포함 검색
createdBetween(condition.getStartDate(), condition.getEndDate()) // 기간 검색
)
.orderBy(member.createdAt.desc())
.offset(pageable.getOffset()) // 페이징 시작점
.limit(pageable.getPageSize()) // 페이징 크기
.fetch(); // 실행
// 전체 개수 조회 (페이징 정보를 위해)
Long total = queryFactory
.select(member.count())
.from(member)
.where(
nameContains(condition.getName()),
emailContains(condition.getEmail()),
createdBetween(condition.getStartDate(), condition.getEndDate())
)
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
// 동적 조건 메서드들 - null이면 조건에서 제외
private BooleanExpression nameContains(String name) {
return hasText(name) ? QMember.member.name.containsIgnoreCase(name) : null;
}
private BooleanExpression emailContains(String email) {
return hasText(email) ? QMember.member.email.containsIgnoreCase(email) : null;
}
private BooleanExpression createdBetween(LocalDate startDate, LocalDate endDate) {
if (startDate != null && endDate != null) {
return QMember.member.createdAt.between(
startDate.atStartOfDay(),
endDate.atTime(23, 59, 59)
);
}
return null;
}
}
📈 모니터링과 로깅¶
// Application 이벤트를 통한 로깅
@Component
@RequiredArgsConstructor
@Slf4j // Lombok의 로거 자동 생성
public class MemberEventListener {
@EventListener // Spring 이벤트 리스너
@Async // 비동기 처리로 성능 영향 최소화
public void handleMemberCreated(MemberCreatedEvent event) {
log.info("새 회원 가입: ID={}, 이메일={}, 가입시간={}",
event.getMemberId(),
event.getEmail(),
event.getCreatedAt());
// 추가 작업 (이메일 발송, 통계 업데이트 등)
sendWelcomeEmail(event.getEmail());
updateMemberStatistics();
}
private void sendWelcomeEmail(String email) {
// 이메일 발송 로직
log.debug("환영 이메일 발송: {}", email);
}
private void updateMemberStatistics() {
// 통계 정보 업데이트
log.debug("회원 가입 통계 업데이트");
}
}
// Custom 메트릭스 (Spring Boot Actuator)
@Component
@RequiredArgsConstructor
public class MemberMetrics {
private final MemberRepository memberRepository;
private final MeterRegistry meterRegistry; // 메트릭 등록용
@PostConstruct
public void init() {
// 사용자 정의 메트릭 등록
Gauge.builder("members.total.count")
.description("전체 회원 수")
.register(meterRegistry, this, MemberMetrics::getTotalMemberCount);
}
private double getTotalMemberCount(MemberMetrics metrics) {
return memberRepository.count();
}
}
🏗️ 마이크로서비스 아키텍처 준비¶
graph TB
subgraph "Frontend"
A[React/Vue SPA]
end
subgraph "API Gateway"
B[Spring Cloud Gateway<br/>라우팅, 인증, 로드밸런싱]
end
subgraph "Microservices"
C[Member Service<br/>회원 관리]
D[Order Service<br/>주문 관리]
E[Product Service<br/>상품 관리]
F[Notification Service<br/>알림 서비스]
end
subgraph "Data Layer"
G[(Member DB<br/>MySQL)]
H[(Order DB<br/>PostgreSQL)]
I[(Product DB<br/>MongoDB)]
J[Redis Cache<br/>세션, 캐시]
end
subgraph "Message Queue"
K[RabbitMQ/Kafka<br/>비동기 통신]
end
A --> B
B --> C
B --> D
B --> E
B --> F
C --> G
C --> J
D --> H
D --> J
E --> I
E --> J
F --> K
C -.-> K
D -.-> K
E -.-> K
style A fill:#e3f2fd
style B fill:#f3e5f5
style C fill:#e8f5e8
style D fill:#e8f5e8
style E fill:#e8f5e8
style F fill:#e8f5e8
style G fill:#fff3e0
style H fill:#fff3e0
style I fill:#fff3e0
style J fill:#ffebee
style K fill:#f1f8e9
🎯 학습 로드맵과 실무 전환 가이드¶
단계별 학습 계획¶
flowchart TD
A[1단계: Java 기초<br/>2-3주] --> B[2단계: Spring 기초<br/>3-4주]
B --> C[3단계: Spring Boot<br/>4-5주]
C --> D[4단계: JPA/Hibernate<br/>3-4주]
D --> E[5단계: 실무 프로젝트<br/>6-8주]
E --> F[6단계: 고급 주제<br/>지속적 학습]
subgraph "1단계 상세"
A1[Java 문법]
A2[객체지향 프로그래밍]
A3[컬렉션 프레임워크]
A4[예외 처리]
end
subgraph "2단계 상세"
B1[Spring Core]
B2[의존성 주입]
B3[Spring MVC]
B4[AOP 기초]
end
subgraph "3단계 상세"
C1[Auto Configuration]
C2[Starter Dependencies]
C3[Actuator]
C4[Profile 관리]
end
subgraph "4단계 상세"
D1[Entity 설계]
D2[Repository 패턴]
D3[JPQL/QueryDSL]
D4[트랜잭션 관리]
end
subgraph "5단계 상세"
E1[REST API 개발]
E2[인증/인가]
E3[테스트 코드 작성]
E4[배포/운영]
end
subgraph "6단계 상세"
F1[마이크로서비스]
F2[클라우드 네이티브]
F3[성능 최적화]
F4[DevOps]
end
A --> A1
A --> A2
A --> A3
A --> A4
B --> B1
B --> B2
B --> B3
B --> B4
C --> C1
C --> C2
C --> C3
C --> C4
D --> D1
D --> D2
D --> D3
D --> D4
E --> E1
E --> E2
E --> E3
E --> E4
F --> F1
F --> F2
F --> F3
F --> F4
💡 실무 면접 대비 핵심 질문¶
기술 면접 단골 질문들¶
-
Spring과 Spring Boot의 차이점
답변 예시: - Spring: 설정이 복잡, XML 또는 Java Config 필요 - Spring Boot: Auto Configuration으로 간단한 설정 - Starter Dependencies로 의존성 관리 간소화 - 내장 서버(Tomcat) 포함으로 별도 WAS 불필요
-
JPA N+1 문제와 해결방안
답변 예시: - 문제: 연관된 엔티티를 조회할 때 추가 쿼리가 N번 실행 - 해결책: Fetch Join, @EntityGraph, Batch Size 설정 - 지연로딩(LAZY)과 즉시로딩(EAGER) 적절한 선택
-
@Transactional의 동작원리
답변 예시: - AOP Proxy를 통한 트랜잭션 관리 - 메서드 시작 시 트랜잭션 시작, 종료 시 커밋/롤백 - 예외 발생 시 자동 롤백 (RuntimeException 계열) - readOnly 속성으로 성능 최적화 가능
📚 추천 학습 자료¶
필수 도서¶
- Spring Boot 완전 정복 - 실무에 바로 적용 가능한 예제 중심
- 자바 ORM 표준 JPA 프로그래밍 - JPA의 바이블
- 토비의 스프링 - Spring의 원리와 철학 이해
온라인 강의¶
- 인프런: 실무 중심의 한국어 강의 다수
- Spring 공식 가이드: 최신 정보와 베스트 프랙티스
- Baeldung: 영어로 된 고품질 Spring 튜토리얼
실습 프로젝트 아이디어¶
- 게시판 시스템 - CRUD의 기본
- 쇼핑몰 - 실제 비즈니스 로직 경험
- 채팅 애플리케이션 - WebSocket, 실시간 통신
- 마이크로서비스 - 현대적인 아키텍처 경험
🏁 마무리: 현업 개발자가 되기 위한 체크리스트¶
✅ 기본기 체크리스트¶
- [ ] Java 기초: 객체지향 프로그래밍 완전 이해
- [ ] Spring Core: DI, AOP 개념과 활용
- [ ] Spring Boot: 프로젝트 생성부터 배포까지
- [ ] JPA: Entity 설계, Repository 패턴, 쿼리 최적화
- [ ] 데이터베이스: SQL 작성, 인덱스 이해
- [ ] REST API: 설계 원칙과 구현
- [ ] 테스트: 단위 테스트, 통합 테스트 작성
- [ ] Git: 버전 관리, 협업 워크플로우
🎯 실무 준비 체크리스트¶
- [ ] 포트폴리오: GitHub에 3개 이상의 완성된 프로젝트
- [ ] 코드 품질: Clean Code 원칙 적용
- [ ] 문서화: README, API 문서 작성 습관
- [ ] 성능: 쿼리 최적화, 캐싱 전략 이해
- [ ] 보안: 인증/인가, HTTPS, SQL Injection 방어
- [ ] 모니터링: 로깅, 메트릭 수집 경험
- [ ] 배포: CI/CD 파이프라인 구축 경험
🚀 지속적 성장을 위한 가이드¶
graph LR
A[주니어 개발자<br/>0-2년] --> B[시니어 개발자<br/>3-5년]
B --> C[리드 개발자<br/>5-8년]
C --> D[아키텍트<br/>8년+]
A --> A1[기본기 완성<br/>CRUD 구현]
A --> A2[코드 리뷰 참여<br/>Best Practice 학습]
B --> B1[설계 능력 향상<br/>아키텍처 이해]
B --> B2[성능 최적화<br/>장애 대응]
C --> C1[팀 리딩<br/>기술 의사결정]
C --> C2[신기술 도입<br/>표준화 추진]
D --> D1[전체 시스템 설계<br/>기술 전략 수립]
D --> D2[기술 트렌드 분석<br/>조직 성장 기여]
💻 최종 실습: 완전한 게시판 시스템¶
이 가이드의 모든 내용을 종합한 실제 동작하는 게시판 시스템을 만들어보겠습니다.
// 최종 실습: Post Entity
@Entity
@Table(name = "posts")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@Column(nullable = false, length = 50)
private String author;
private int viewCount = 0;
@CreationTimestamp // Hibernate의 자동 생성 시간
@Column(name = "created_at")
private LocalDateTime createdAt;
@UpdateTimestamp // Hibernate의 자동 수정 시간
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// 조회수 증가 메서드
public void increaseViewCount() {
this.viewCount++;
}
}
이제 여러분도 JSP부터 시작해서 최신 Spring Boot까지, 그리고 현업에서 실제로 사용하는 모든 기술들을 이해하고 활용할 수 있게 되었습니다! 🎉
기억하세요:
- 📖 꾸준한 학습이 가장 중요합니다
- 💪 실습을 통해서만 실력이 늡니다
- 🤝 커뮤니티에 참여해서 지식을 나누세요
- 🎯 현업 트렌드를 지속적으로 팔로업하세요
Happy Coding! 🚀
JPA와 Spring Data JPA¶
JPA 개념 이해¶
graph TB
A[JPA<br/>Java Persistence API] --> B[Hibernate<br/>구현체]
A --> C[EclipseLink<br/>구현체]
A --> D[기타 구현체]
B --> E[Spring Data JPA<br/>편의 기능 추가]
E --> F[JpaRepository<br/>인터페이스]
Entity 클래스 예제¶
package com.example.demo.entity;
import lombok.Data; // Getter, Setter, toString 등 자동 생성
import lombok.NoArgsConstructor; // 기본 생성자 자동 생성
import lombok.AllArgsConstructor; // 모든 필드 생성자 자동 생성
import javax.persistence.*; // JPA 어노테이션들
import java.time.LocalDateTime; // 날짜/시간 처리용
@Entity // 이 클래스가 데이터베이스 테이블과 매핑되는 JPA 엔티티임을 선언
@Table(name = "members") // 실제 데이터베이스에서 사용할 테이블 이름 지정
@Data // Lombok: Getter, Setter, toString, equals, hashCode 메서드 자동 생성
@NoArgsConstructor // Lombok: 매개변수 없는 기본 생성자 자동 생성 (JPA 필수)
@AllArgsConstructor // Lombok: 모든 필드를 매개변수로 받는 생성자 자동 생성
public class Member {
@Id // 이 필드가 테이블의 기본 키(Primary Key)임을 선언
@GeneratedValue(strategy = GenerationType.IDENTITY)
// 기본 키 값을 데이터베이스가 자동으로 생성하도록 설정 (AUTO_INCREMENT)
private Long id;
@Column(nullable = false, length = 50)
// 데이터베이스 컬럼 제약조건: NOT NULL, 최대 길이 50자
private String name;
@Column(unique = true, nullable = false)
// 이메일은 중복 불가능하고 필수 입력
private String email;
@Column(name = "created_at")
// Java 필드명과 다른 데이터베이스 컬럼명 지정
private LocalDateTime createdAt;
@PrePersist // 엔티티가 처음 저장되기 전에 자동으로 실행되는 메서드
protected void onCreate() {
createdAt = LocalDateTime.now(); // 현재 시각으로 생성일시 자동 설정
}
}
Repository 인터페이스¶
package com.example.demo.repository;
import com.example.demo.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository; // Spring Data JPA
import org.springframework.data.jpa.repository.Query; // 커스텀 쿼리용
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository // 스프링이 이 인터페이스를 빈으로 관리하도록 지시
public interface MemberRepository extends JpaRepository<Member, Long> {
// JpaRepository<엔티티타입, 기본키타입> 상속으로 기본 CRUD 메서드들이 자동 제공됨
// - save(entity): 저장
// - findById(id): ID로 조회
// - findAll(): 전체 조회
// - deleteById(id): ID로 삭제
// - count(): 전체 개수
// 메서드 이름으로 쿼리 자동 생성 (Query Method)
Optional<Member> findByEmail(String email);
// SELECT * FROM members WHERE email = ? 쿼리 자동 생성
List<Member> findByNameContaining(String name);
// SELECT * FROM members WHERE name LIKE %?% 쿼리 자동 생성
// 커스텀 JPQL 쿼리 작성
@Query("SELECT m FROM Member m WHERE m.createdAt >= :startDate ORDER BY m.createdAt DESC")
// JPQL: 테이블이 아닌 엔티티 클래스를 대상으로 하는 객체지향 쿼리 언어
List<Member> findRecentMembers(@Param("startDate") LocalDateTime startDate);
}
레이어드 아키텍처¶
아키텍처 다이어그램¶
graph TD
subgraph "Presentation Layer"
A[Controller<br/>@Controller]
A1[REST Controller<br/>@RestController]
end
subgraph "Business Layer"
B[Service<br/>@Service]
B1[Business Logic<br/>트랜잭션 관리]
end
subgraph "Persistence Layer"
C[Repository<br/>@Repository]
C1[JpaRepository<br/>데이터 접근]
end
subgraph "Database"
D[(H2/MySQL<br/>실제 데이터 저장)]
end
A --> B
A1 --> B
B --> C
C --> D
style A fill:#e1f5fe
style B fill:#f3e5f5
style C fill:#e8f5e8
style D fill:#fff3e0
Service 계층 예제¶
package com.example.demo.service;
import com.example.demo.entity.Member;
import com.example.demo.repository.MemberRepository;
import lombok.RequiredArgsConstructor; // final 필드에 대한 생성자 자동 생성
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; // 트랜잭션 관리
import java.util.List;
import java.util.Optional;
@Service // 이 클래스가 비즈니스 로직을 담당하는 서비스 계층임을 스프링에게 알림
@RequiredArgsConstructor // final 필드들을 매개변수로 받는 생성자를 자동 생성
@Transactional(readOnly = true) // 기본적으로 읽기 전용 트랜잭션 (성능 최적화)
public class MemberService {
private final MemberRepository memberRepository;
// final로 선언하여 불변성 보장, 생성자 주입으로 의존성 주입받음
/**
* 전체 회원 목록 조회
* @return 모든 회원 리스트
*/
public List<Member> getAllMembers() {
return memberRepository.findAll(); // JpaRepository의 기본 메서드 활용
}
/**
* 이메일로 회원 찾기
* @param email 찾을 회원의 이메일
* @return 해당 회원 정보 (Optional로 null 안전성 보장)
*/
public Optional<Member> findByEmail(String email) {
return memberRepository.findByEmail(email); // 커스텀 메서드 활용
}
/**
* 회원 정보 저장 (생성 및 수정)
* @param member 저장할 회원 객체
* @return 저장된 회원 객체
*/
@Transactional // 쓰기 작업이므로 읽기 전용이 아닌 일반 트랜잭션
public Member saveMember(Member member) {
// 비즈니스 로직: 이메일 중복 체크
if (memberRepository.findByEmail(member.getEmail()).isPresent()) {
throw new IllegalArgumentException("이미 존재하는 이메일입니다: " + member.getEmail());
}
return memberRepository.save(member); // 실제 저장 수행
}
/**
* 회원 삭제
* @param id 삭제할 회원의 ID
*/
@Transactional // 쓰기 작업이므로 트랜잭션 필요
public void deleteMember(Long id) {
// 존재 여부 확인 후 삭제
if (!memberRepository.existsById(id)) {
throw new IllegalArgumentException("존재하지 않는 회원 ID: " + id);
}
memberRepository.deleteById(id);
}
}
Controller 계층 예제¶
package com.example.demo.controller;
import com.example.demo.entity.Member;
import com.example.demo.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller // 웹 요청을 처리하는 컨트롤러임을 선언
@RequestMapping("/members") // 이 컨트롤러의 모든 메서드는 "/members"로 시작하는 URL 처리
@RequiredArgsConstructor // final 필드 생성자 주입
public class MemberController {
private final MemberService memberService; // 서비스 계층 의존성 주입
/**
* 회원 목록 페이지 표시
* GET /members 요청 처리
*/
@GetMapping // GET /members 요청을 이 메서드가 처리
public String listMembers(Model model) { // Model: 뷰에 데이터 전달용 객체
List<Member> members = memberService.getAllMembers(); // 서비스에서 회원 목록 조회
model.addAttribute("members", members); // "members"라는 이름으로 뷰에 데이터 전달
return "member/list"; // templates/member/list.html 파일을 렌더링
}
/**
* 회원 등록 폼 페이지 표시
* GET /members/new 요청 처리
*/
@GetMapping("/new")
public String showAddForm(Model model) {
model.addAttribute("member", new Member()); // 빈 Member 객체를 폼에 바인딩
return "member/addForm"; // templates/member/addForm.html 렌더링
}
/**
* 회원 등록 처리
* POST /members 요청 처리
*/
@PostMapping // POST /members 요청을 이 메서드가 처리
public String addMember(@ModelAttribute Member member) {
// @ModelAttribute: HTML 폼 데이터를 Member 객체로 자동 바인딩
try {
memberService.saveMember(member); // 서비스 계층에 저장 로직 위임
return "redirect:/members"; // 성공시 회원 목록 페이지로 리다이렉트
} catch (IllegalArgumentException e) {
// 에러 발생시 다시 입력 폼으로 (실제로는 에러 메시지도 함께 전달해야 함)
return "member/addForm";
}
}
/**
* 회원 삭제 처리
* DELETE /members/{id} 요청 처리
*/
@DeleteMapping("/{id}")
public String deleteMember(@PathVariable Long id) {
// @PathVariable: URL 경로의 {id} 부분을 매개변수로 받음
memberService.deleteMember(id);
return "redirect:/members"; // 삭제 후 목록 페이지로 리다이렉트
}
}
JPA와 Spring Data JPA¶
핵심 개념¶
flowchart TD
A[JPA<br/>Java Persistence API] --> B[ORM 표준 명세]
B --> C[객체 ↔ 관계형 DB 매핑]
D[Hibernate] --> E[JPA 구현체]
E --> F[실제 DB 작업 수행]
G[Spring Data JPA] --> H[편의 기능 제공]
H --> I[JpaRepository 인터페이스]
I --> J[기본 CRUD 자동 구현]
A --> D
D --> G
실무에서 자주 쓰는 Repository 패턴¶
package com.example.demo.repository;
import com.example.demo.entity.Post;
import org.springframework.data.domain.Page; // 페이징 처리용
import org.springframework.data.domain.Pageable; // 페이징 조건용
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface PostRepository extends JpaRepository<Post, UUID> {
// 1. Query Method: 메서드 이름으로 쿼리 자동 생성
List<Post> findByTitleContaining(String keyword);
// 제목에 특정 키워드가 포함된 게시글 찾기
Page<Post> findByAuthorOrderByCreatedAtDesc(String author, Pageable pageable);
// 특정 작성자의 게시글을 최신순으로 페이징해서 가져오기
long countByStatus(String status);
// 특정 상태의 게시글 개수 세기
// 2. JPQL: 객체 지향 쿼리 언어
@Query("SELECT p FROM Post p WHERE p.createdAt >= :startDate ORDER BY p.createdAt DESC")
List<Post> findRecentPosts(@Param("startDate") LocalDateTime startDate);
// 특정 날짜 이후에 작성된 게시글을 최신순으로 조회
// 3. Native Query: 직접 SQL 작성 (복잡한 쿼리에 사용)
@Query(value = "SELECT * FROM posts WHERE MATCH(title, content) AGAINST(?1)",
nativeQuery = true)
List<Post> searchByFullText(String searchTerm);
// MySQL의 전문 검색 기능 활용
}
의존성 주입 (DI)¶
DI가 필요한 이유¶
// ❌ 나쁜 예: 직접 의존성 생성
public class MemberController {
private MemberService memberService = new MemberService(); // 강한 결합
// 문제점: 테스트 어려움, 설정 변경 어려움, 재사용성 떨어짐
}
// ✅ 좋은 예: 스프링이 의존성 주입
@Controller
@RequiredArgsConstructor // 생성자 주입 자동 생성
public class MemberController {
private final MemberService memberService; // final로 불변성 보장
// @RequiredArgsConstructor가 아래 생성자를 자동 생성해줌
// public MemberController(MemberService memberService) {
// this.memberService = memberService;
// }
}
주입 방식 비교¶
@Service
public class MemberService {
// 1. 필드 주입 (❌ 권장하지 않음)
@Autowired
private MemberRepository memberRepository;
// 문제점: 테스트 어려움, 순환참조 런타임에 발견, 불변성 보장 안됨
// 2. Setter 주입 (△ 선택적 의존성에만 사용)
private MemberRepository memberRepository;
@Autowired
public void setMemberRepository(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// 3. 생성자 주입 (✅ 가장 권장)
private final MemberRepository memberRepository; // final로 불변성 보장
// Spring Boot 2.6+ 에서는 생성자가 하나면 @Autowired 생략 가능
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}
Lombok 활용¶
주요 어노테이션 정리¶
// 1. @Data: 종합 패키지 (DTO에 주로 사용)
@Data
public class MemberDto {
private String name;
private String email;
// 자동 생성: getter, setter, toString, equals, hashCode, RequiredArgsConstructor
}
// 2. Entity에서는 선택적 사용 (안전성 고려)
@Entity
@Getter // getter만 생성
@Setter // setter만 생성 (필요한 경우에만)
@ToString(exclude = {"password"}) // toString에서 password 필드 제외
@EqualsAndHashCode(onlyExplicitlyIncluded = true) // 명시적으로 지정한 필드만 포함
@NoArgsConstructor // 기본 생성자 (JPA 필수)
@AllArgsConstructor // 모든 필드 생성자
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include // equals/hashCode에 id만 포함
private Long id;
private String name;
private String email;
private String password;
}
// 3. Builder 패턴 활용
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class CreateMemberRequest {
private String name;
private String email;
private String password;
}
// 사용 예시
CreateMemberRequest request = CreateMemberRequest.builder()
.name("홍길동") // 가독성이 좋고
.email("hong@example.com") // 순서를 바꿔도 되며
.password("password123") // 필요한 필드만 설정 가능
.build();
실습 예제¶
완전한 회원 관리 시스템¶
// 1. Entity 정의
@Entity
@Table(name = "members")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
@Column(unique = true, nullable = false)
private String email;
@Column(name = "created_at")
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
// 2. Repository 인터페이스
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
@Query("SELECT m FROM Member m ORDER BY m.createdAt DESC")
List<Member> findAllByCreatedAtDesc();
}
// 3. Service 비즈니스 로직
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
public List<Member> getAllMembers() {
return memberRepository.findAllByCreatedAtDesc(); // 최신 가입순으로
}
@Transactional // 쓰기 작업
public Member saveMember(Member member) {
// 중복 이메일 체크
if (memberRepository.findByEmail(member.getEmail()).isPresent()) {
throw new IllegalArgumentException("이미 존재하는 이메일입니다.");
}
return memberRepository.save(member);
}
public Optional<Member> findById(Long id) {
return memberRepository.findById(id);
}
@Transactional
public void deleteMember(Long id) {
memberRepository.deleteById(id);
}
}
// 4. Controller 웹 요청 처리
@Controller
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
// 회원 목록 페이지
@GetMapping
public String listMembers(Model model) {
model.addAttribute("members", memberService.getAllMembers());
return "member/list"; // templates/member/list.html
}
// 회원 등록 폼
@GetMapping("/new")
public String showAddForm(Model model) {
model.addAttribute("member", new Member());
return "member/addForm"; // templates/member/addForm.html
}
// 회원 등록 처리
@PostMapping
public String addMember(@ModelAttribute Member member, Model model) {
try {
memberService.saveMember(member);
return "redirect:/members"; // 성공시 목록으로 리다이렉트
} catch (IllegalArgumentException e) {
model.addAttribute("errorMessage", e.getMessage());
model.addAttribute("member", member);
return "member/addForm"; // 실패시 다시 폼으로
}
}
// 회원 삭제
@PostMapping("/{id}/delete")
public String deleteMember(@PathVariable Long id) {
memberService.deleteMember(id);
return "redirect:/members";
}
}
Thymeleaf 템플릿 예제¶
<!-- templates/member/list.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- Thymeleaf 네임스페이스 선언: th: 속성들을 사용할 수 있게 됨 -->
<head>
<meta charset="UTF-8">
<title>회원 관리 시스템</title>
<!-- Bootstrap CSS로 깔끔한 UI -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>회원 목록</h1>
<!-- 회원 등록 버튼 -->
<div class="mb-3">
<a th:href="@{/members/new}" class="btn btn-primary">새 회원 등록</a>
<!-- th:href="@{/members/new}": Thymeleaf URL 표현식, /members/new로 링크 생성 -->
</div>
<!-- 회원 목록 테이블 -->
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>이름</th>
<th>이메일</th>
<th>가입일</th>
<th>작업</th>
</tr>
</thead>
<tbody>
<!-- th:each: 리스트 반복문, members 컬렉션의 각 항목을 member 변수로 반복 -->
<tr th:each="member : ${members}">
<!-- ${member.id}: 각 회원 객체의 id 필드 출력 -->
<td th:text="${member.id}"></td>
<!-- th:text: 태그 안의 텍스트를 동적으로 설정 -->
<td th:text="${member.name}"></td>
<td th:text="${member.email}"></td>
<!-- #temporals.format: Thymeleaf의 날짜 포맷팅 유틸리티 -->
<td th:text="${#temporals.format(member.createdAt, 'yyyy-MM-dd HH:mm')}"></td>
<td>
<!-- 삭제 폼 (POST 방식으로 DELETE 흉내) -->
<form th:action="@{/members/{id}/delete(id=${member.id})}"
method="post" style="display: inline;">
<!-- th:action에서 {id} 경로 변수에 실제 member.id 값 바인딩 -->
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('정말 삭제하시겠습니까?')">삭제</button>
</form>
</td>
</tr>
<!-- 회원이 없을 때 표시할 메시지 -->
<tr th:if="${#lists.isEmpty(members)}">
<!-- #lists.isEmpty(): Thymeleaf의 리스트 유틸리티, 비어있는지 체크 -->
<td colspan="5" class="text-center">등록된 회원이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
<!-- templates/member/addForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>회원 등록</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>새 회원 등록</h1>
<!-- 에러 메시지 표시 -->
<div th:if="${errorMessage}" class="alert alert-danger">
<!-- th:if: 조건이 true일 때만 해당 요소 렌더링 -->
<span th:text="${errorMessage}"></span>
</div>
<!-- 회원 등록 폼 -->
<form th:action="@{/members}" th:object="${member}" method="post">
<!-- th:object: 폼과 객체 바인딩, 하위 필드들이 member 객체의 속성과 연결됨 -->
<div class="mb-3">
<label for="name" class="form-label">이름</label>
<!-- th:field: 양방향 바인딩, member.name과 input 필드 연결 -->
<input type="text" class="form-control" th:field="*{name}" required>
<!-- *{name}: th:object로 지정된 객체의 name 속성 참조 -->
</div>
<div class="mb-3">
<label for="email" class="form-label">이메일</label>
<input type="email" class="form-control" th:field="*{email}" required>
<!-- type="email": HTML5 이메일 검증 -->
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">등록</button>
<a th:href="@{/members}" class="btn btn-secondary">취소</a>
</div>
</form>
</div>
</body>
</html>
현업 적용 가이드¶
🏢 현업에서 주로 사용하는 기술 스택¶
graph TB
subgraph "Frontend (클라이언트)"
A1[React/Vue.js<br/>최신 트렌드]
A2[Thymeleaf<br/>전통적 방식]
A3[JSP<br/>레거시 시스템]
end
subgraph "Backend (서버)"
B1[Spring Boot<br/>가장 많이 사용]
B2[Spring MVC<br/>전통적 Spring]
end
subgraph "Data Access"
C1[Spring Data JPA<br/>90% 사용]
C2[MyBatis<br/>복잡한 쿼리]
C3[QueryDSL<br/>타입 안전 쿼리]
end
subgraph "Database"
D1[MySQL/PostgreSQL<br/>운영 DB]
D2[H2<br/>개발/테스트용]
D3[Redis<br/>캐시/세션]
end
A1 --> B1
A2 --> B1
A3 --> B2
B1 --> C1
C1 --> D1
C2 --> D1
C3 --> D1
실무 우선순위¶
기술 | 현업 사용도 | 학습 우선순위 | 비고 |
---|---|---|---|
Spring Boot | ⭐⭐⭐⭐⭐ | 1순위 | 필수, 모든 회사에서 사용 |
Spring Data JPA | ⭐⭐⭐⭐⭐ | 1순위 | CRUD 작업의 90% |
Thymeleaf | ⭐⭐⭐⭐ | 2순위 | 서버사이드 렌더링 |
React/Vue | ⭐⭐⭐⭐⭐ | 1순위 | 프론트엔드 분리 트렌드 |
JSP | ⭐⭐ | 3순위 | 레거시 유지보수용 |
MyBatis | ⭐⭐⭐ | 2순위 | 복잡한 쿼리, 금융권 선호 |
QueryDSL | ⭐⭐⭐⭐ | 2순위 | 타입 안전한 동적 쿼리 |
💼 현업 개발 팁¶
// 1. 예외 처리 전략
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
@Transactional
public Member createMember(CreateMemberRequest request) {
// 1. 비즈니스 규칙 검증
validateMemberRequest(request);
// 2. 중복 체크
if (memberRepository.findByEmail(request.getEmail()).isPresent()) {
throw new DuplicateEmailException("이미 존재하는 이메일입니다: " + request.getEmail());
// 커스텀 예외로 명확한 에러 의미 전달
}
// 3. Entity 변환 및 저장
Member member = convertToEntity(request);
return memberRepository.save(member);
}
private void validateMemberRequest(CreateMemberRequest request) {
if (request.getName() == null || request.getName().trim().isEmpty()) {
throw new InvalidInputException("이름은 필수 입력 항목입니다.");
}
if (request.getEmail() == null || !isValidEmail(request.getEmail())) {
throw new InvalidInputException("올바른 이메일 형식이 아닙니다.");
}
}
private boolean isValidEmail(String email) {
// 간단한 이메일 검증 로직 (실무에서는 정규표현식 또는 라이브러리 사용)
return email.contains("@") && email.contains(".");
}
}
// 2. 글로벌 예외 처리
@ControllerAdvice // 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateEmailException.class)
public String handleDuplicateEmail(DuplicateEmailException e, Model model) {
model.addAttribute("errorMessage", e.getMessage());
model.addAttribute("member", new Member());
return "member/addForm"; // 에러와 함께 다시 폼으로
}
@ExceptionHandler(Exception.class) // 예상치 못한 모든 예외 처리
public String handleGeneral(Exception e, Model model) {
model.addAttribute("errorMessage", "시스템 오류가 발생했습니다.");
return "error/500"; // 에러 페이지로
}
}
📊 성능 최적화 팁¶
// 1. 페이징 처리 (대량 데이터 대응)
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
public Page<Member> getMembers(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
// page: 0부터 시작, size: 한 페이지당 개수, Sort: 정렬 조건
return memberRepository.findAll(pageable);
}
}
// 2. N+1 쿼리 문제 해결
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩으로 성능 최적화
@JoinColumn(name = "author_id")
private Member author; // 작성자 정보
}
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
// Fetch Join으로 N+1 문제 해결
@Query("SELECT p FROM Post p JOIN FETCH p.author ORDER BY p.createdAt DESC")
List<Post> findAllWithAuthor();
// 한 번의 쿼리로 Post와 Author 정보를 함께 조회
}
// 3. 캐싱 적용
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
@Cacheable(value = "members", key = "#id") // 결과를 캐시에 저장
public Member findById(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new MemberNotFoundException("회원을 찾을 수 없습니다."));
}
@CacheEvict(value = "members", key = "#member.id") // 캐시에서 제거
@Transactional
public Member updateMember(Member member) {
return memberRepository.save(member);
}
}
배포 설정¶
application.yml 프로필 관리¶
# src/main/resources/application.yml (기본 설정)
spring:
profiles:
active: dev # 기본적으로 개발 환경 활성화
# 모든 프로필에 공통 적용되는 JPA 설정
jpa:
show-sql: true # 실행되는 SQL 쿼리를 콘솔에 출력
properties:
hibernate:
format_sql: true # SQL을 보기 좋게 포맷팅
use_sql_comments: true # JPQL이 어떤 SQL로 변환되는지 주석으로 표시
# Thymeleaf 설정
thymeleaf:
cache: false # 개발 중에는 캐시 비활성화 (수정사항 즉시 반영)
# src/main/resources/application-dev.yml (개발 환경)
# 이 파일은 .gitignore에 추가해서 Git에 올리지 않음
spring:
datasource:
url: jdbc:h2:mem:testdb # 메모리 기반 H2 데이터베이스
driver-class-name: org.h2.Driver
username: sa # H2 기본 사용자명
password: # H2는 기본적으로 비밀번호 없음
h2:
console:
enabled: true # H2 웹 콘솔 활성화
path: /h2-console # 브라우저에서 /h2-console로 DB 접속 가능
jpa:
hibernate:
ddl-auto: create-drop # 애플리케이션 시작시 테이블 생성, 종료시 삭제
defer-datasource-initialization: true # 데이터 초기화 지연
logging:
level:
org.hibernate.SQL: DEBUG # SQL 쿼리 로그 상세히 출력
org.hibernate.type: TRACE # 쿼리 파라미터 값까지 출력
# src/main/resources/application-prod.yml (운영 환경)
spring:
datasource:
url: ${DB_URL:jdbc:mysql://localhost:3306/myapp}
# 환경변수 DB_URL이 있으면 사용, 없으면 기본값 사용
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
# 커넥션 풀 설정 (성능 최적화)
hikari:
maximum-pool-size: 20 # 최대 연결 개수
minimum-idle: 5 # 최소 유지 연결 개수
connection-timeout: 30000 # 연결 대기 시간 (30초)
jpa:
hibernate:
ddl-auto: validate # 운영에서는 스키마 변경 금지, 검증만
show-sql: false # 운영에서는 SQL 로그 비활성화
logging:
level:
com.example.demo: INFO # 애플리케이션 로그 레벨
org.hibernate: WARN # Hibernate 로그는 경고 이상만
file:
name: logs/application.log # 로그 파일 경로
build.gradle 의존성 설정¶
// build.gradle
plugins {
id 'org.springframework.boot' version '2.7.0' // Spring Boot 플러그인
id 'io.spring.dependency-management' version '1.0.11.RELEASE' // 의존성 관리
id 'java' // Java 프로젝트임을 선언
}
group = 'com.example' // 프로젝트 그룹 ID
version = '0.0.1-SNAPSHOT' // 프로젝트 버전
sourceCompatibility = '11' // Java 11 사용
// Lombok 설정
configurations {
compileOnly {
extendsFrom annotationProcessor // Lombok이 컴파일 시점에 코드 생성할 수 있도록
}
}
repositories {
mavenCentral() // Maven 중앙 저장소에서 라이브러리 다운로드
}
dependencies {
// Spring Boot 기본 스타터들
implementation 'org.springframework.boot:spring-boot-starter-web'
// 웹 애플리케이션 개발에 필요한 모든 것 (Tomcat, Spring MVC, Jackson 등)
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
// Thymeleaf 템플릿 엔진과 Spring 통합
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// JPA, Hibernate, Spring Data JPA 포함
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Bean Validation (입력값 검증)
// 데이터베이스 드라이버들
runtimeOnly 'com.h2database:h2' // 개발용 인메모리 DB
runtimeOnly 'com.mysql:mysql-connector-j' // 운영용 MySQL
// Lombok (코드 생성)
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// 테스트용
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// JUnit 5, Mockito, AssertJ 등 테스트 도구들 포함
}
tasks.named('test') {
useJUnitPlatform() // JUnit 5 사용 설정
}
🚀 배포 스크립트 예제¶
#!/bin/bash
# deploy.sh - 간단한 배포 스크립트
echo "🚀 배포 시작..."
# 1. Git에서 최신 코드 가져오기
git pull origin main
# 2. 프로젝트 빌드 (운영 프로필로)
./gradlew clean build -Pspring.profiles.active=prod
# 3. 기존 애플리케이션 중지
echo "기존 애플리케이션 중지 중..."
pkill -f "java -jar.*myapp" # myapp이라는 이름이 포함된 Java 프로세스 종료
# 4. 새 버전 실행
echo "새 애플리케이션 시작 중..."
nohup java -jar -Dspring.profiles.active=prod \
-DDB_URL=${DATABASE_URL} \
-DDB_USERNAME=${DATABASE_USERNAME} \
-DDB_PASSWORD=${DATABASE_PASSWORD} \
build/libs/myapp-0.0.1-SNAPSHOT.jar > app.log 2>&1 &
# nohup: 터미널 종료되어도 계속 실행
# &: 백그라운드 실행
# > app.log 2>&1: 모든 출력을 app.log 파일로 리다이렉션
echo "✅ 배포 완료!"
echo "📋 로그 확인: tail -f app.log"
고급 주제와 실무 가이드¶
🔐 보안 설정 (Spring Security)¶
// 실무에서 필수인 Spring Security 설정
@Configuration
@EnableWebSecurity // Spring Security 활성화
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 비밀번호 암호화
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/register").permitAll() // 인증 불필요
.requestMatchers("/admin/**").hasRole("ADMIN") // 관리자만
.anyRequest().authenticated() // 나머지는 인증 필요
)
.formLogin(form -> form
.loginPage("/login") // 커스텀 로그인 페이지
.defaultSuccessUrl("/members") // 로그인 성공 후 이동 페이지
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/") // 로그아웃 후 이동 페이지
.permitAll()
);
return http.build();
}
}
📊 테스트 코드 작성¶
// 통합 테스트 예제
@SpringBootTest // 스프링 애플리케이션 컨텍스트 로드해서 테스트
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// 실제 DB 대신 테스트용 DB 사용
class MemberServiceTest {
@Autowired
private MemberService memberService; // 실제 빈 주입받아서 테스트
@Autowired
private TestEntityManager testEntityManager; // 테스트용 EntityManager
@Test
@Transactional // 테스트 후 롤백되어 DB 상태 원복
@DisplayName("중복 이메일로 회원 가입시 예외 발생") // 테스트 설명
void duplicateEmailThrowsException() {
// Given (준비)
Member existingMember = new Member();
existingMember.setName("기존회원");
existingMember.setEmail("test@example.com");
testEntityManager.persistAndFlush(existingMember); // DB에 저장
CreateMemberRequest newRequest = CreateMemberRequest.builder()
.name("새회원")
.email("test@example.com") // 같은 이메일
.build();
// When & Then (실행 및 검증)
assertThrows(DuplicateEmailException.class, () -> {
memberService.createMember(newRequest); // 예외가 발생해야 함
});
}
}
// 단위 테스트 예제 (Mock 사용)
@ExtendWith(MockitoExtension.class) // Mockito 확장 활성화
class MemberServiceUnitTest {
@Mock
private MemberRepository memberRepository; // 가짜 Repository 객체 생성
@InjectMocks
private MemberService memberService; // Mock 객체들이 주입된 실제 Service 객체
@Test
@DisplayName("존재하지 않는 이메일로 회원 조회시 빈 결과 반환")
void findByNonExistentEmail() {
// Given
String email = "nonexistent@example.com";
when(memberRepository.findByEmail(email)).thenReturn(Optional.empty());
// Mock 객체의 동작 정의: 특정 이메일 조회시 빈 Optional 반환
// When
Optional<Member> result = memberService.findByEmail(email);
// Then
assertThat(result).isEmpty(); // 결과가 비어있어야 함
verify(memberRepository).findByEmail(email); // Repository 메서드가 호출되었는지 검증
}
}
🌐 REST API 컨트롤러¶
// 현대적인 REST API 방식
@RestController // @Controller + @ResponseBody, JSON으로 응답
@RequestMapping("/api/v1/members") // API 버전 관리
@RequiredArgsConstructor
@Validated // 입력값 검증 활성화
public class MemberApiController {
private final MemberService memberService;
/**
* 회원 목록 조회 (페이징)
* GET /api/v1/members?page=0&size=10&sort=createdAt,desc
*/
@GetMapping
public ResponseEntity<Page<MemberResponse>> getMembers(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
// @PageableDefault: 기본 페이징 설정
Page<Member> members = memberService.getMembers(pageable);
Page<MemberResponse> response = members.map(this::convertToResponse);
// Entity를 직접 노출하지 않고 DTO로 변환
return ResponseEntity.ok(response); // HTTP 200 OK와 함께 데이터 반환
}
/**
* 회원 등록
* POST /api/v1/members
*/
@PostMapping
public ResponseEntity<MemberResponse> createMember(
@Valid @RequestBody CreateMemberRequest request) {
// @Valid: Bean Validation 검증 수행
// @RequestBody: HTTP 요청 본문의 JSON을 객체로 변환
Member member = memberService.createMember(request);
MemberResponse response = convertToResponse(member);
return ResponseEntity.status(HttpStatus.CREATED) // HTTP 201 Created
.header("Location", "/api/v1/members/" + member.getId()) // 생성된 리소스 위치
.body(response);
}
/**
* 회원 상세 조회
* GET /api/v1/members/{id}
*/
@GetMapping("/{id}")
public ResponseEntity<MemberResponse> getMember(@PathVariable Long id) {
Member member = memberService.findById(id)
.orElseThrow(() -> new MemberNotFoundException("회원을 찾을 수 없습니다: " + id));
return ResponseEntity.ok(convertToResponse(member));
}
/**
* 회원 정보 수정
* PUT /api/v1/members/{id}
*/
@PutMapping("/{id}")
public ResponseEntity<MemberResponse> updateMember(
@PathVariable Long id,
@Valid @RequestBody UpdateMemberRequest request) {
Member updatedMember = memberService.updateMember(id, request);
return ResponseEntity.ok(convertToResponse(updatedMember));
}
/**
* 회원 삭제
* DELETE /api/v1/members/{id}
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteMember(@PathVariable Long id) {
memberService.deleteMember(id);
return ResponseEntity.noContent().build(); // HTTP 204 No Content
}
// Entity -> DTO 변환 메서드
private MemberResponse convertToResponse(Member member) {
return MemberResponse.builder()
.id(member.getId())
.name(member.getName())
.email(member.getEmail())
.createdAt(member.getCreatedAt())
.build();
}
}
📝 DTO 설계 패턴¶
// 요청용 DTO
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreateMemberRequest {
@NotBlank(message = "이름은 필수 입력 항목입니다.")
@Size(max = 50, message = "이름은 50자를 초과할 수 없습니다.")
private String name;
@NotBlank(message = "이메일은 필수 입력 항목입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
private String email;
@NotBlank(message = "비밀번호는 필수 입력 항목입니다.")
@Size(min = 8, max = 20, message = "비밀번호는 8~20자 사이여야 합니다.")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "비밀번호는 대소문자와 숫자를 포함해야 합니다.")
private String password;
// Entity 변환 메서드
public Member toEntity(PasswordEncoder passwordEncoder) {
return Member.builder()
.name(this.name)
.email(this.email)
.password(passwordEncoder.encode(this.password)) // 암호화된 비밀번호
.build();
}
}
// 응답용 DTO: 클라이언트에게 반환되는 데이터
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberResponse {
private Long id;
private String name;
private String email;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") // 날짜 포맷 지정
private LocalDateTime createdAt;
}
💡 DTO 설계 Best Practice¶
-
요청(Request) DTO: 사용자의 입력을 담음 → Entity 변환 책임 포함
-
응답(Response) DTO: 외부로 나가는 데이터만 노출 (비밀번호, 내부 ID 제외)
-
Entity와 분리: API 스펙이 바뀌어도 DB 구조에 영향 없음
-
Validation 어노테이션: 입력 검증을 DTO 레벨에서 처리