mybatis와 JPA(Hibernate)의 계층 차이@Entity와 ddl-auto@ManyToOne, @OneToMany, @JoinColumn, FetchType.LAZYJpaRepository와 쿼리 메서드, @Query(JPQL)filter, interceptor, aop와 요청 흐름JwtFilter — WHITE_LIST, preflight, Bearer 토큰 검증Authorization 헤더 전송insert(), read()와 N + 1insert(), delete(), update()와 Dirty Checking수업 순서는 JWT로 signIn 유효성 검사를 붙인 뒤 blog, comment 순으로 진행했다.
두 방식의 계층 흐름은 이렇게 갈린다.
mybatis
service -> interface -> .xml(SQL) -> DB
JPA(Hibernate) -> xxxEntity(DDL auto create) == table -> DB
mybatis는 SQL을 .xml에 직접 쓰고 mapper interface로 호출한다. JPA는 xxxEntity 클래스가 곧 table에 대응하고, SQL을 직접 쓰지 않는다.
의존성은 spring-boot-starter-data-jpa를 추가한다. mybatis 의존성은 남아 있는 상태다.
// basic dependency lib
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// webmvc -> web
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4'
# jpa
jpa:
hibernate:
# dto X, entity(table) -> auto generator(table)
# create, create-drop, update, none
# flyway :
# - 로컬 개발 초기 update(미적용)
# - 로컬/스테이징 안정화 이후 validate(적용)
# - 운영 validate or none(적용)
ddl-auto: none
# 콘솔출력
show-sql: true
properties:
hibernate:
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
# mariadb
dialect: org.hibernate.dialect.MariaDBDialect
format_sql: true
ddl-auto : Entity를 기준으로 table을 생성/변경할지 정한다. create, create-drop, update, noneshow-sql, format_sql : Hibernate가 만든 SQL을 콘솔에 출력한다dialect : DB 벤더별 SQL 방언. 여기서는 MariaDBphysical-strategy : PhysicalNamingStrategyStandardImpl을 지정하면 필드명이 snake_case로 변환되지 않고 그대로 컬럼명이 된다mybatis 설정은 주석 처리했다.
# mybatis:
# mapper-locations: classpath:/mappers/**/*Mapper.xml
@Entity가 붙은 클래스 하나가 table 하나다. @Table로 table 이름을 지정한다.
@Entity
@Table(name = "SPRING_JPA_USER_TBL")
// @Table(name = "flyway_users_tbl")
@Builder
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity {
@Id
@Column(name = "email", length = 50, nullable = false)
private String email;
private String password;
private String name ;
// option(role : 인가된 권한 : user, admin)
private String role ;
@OneToMany( mappedBy = "author",
orphanRemoval = false )
private List<BlogEntity> blogs = new ArrayList<>();
}
@Id가 PK다. UserEntity는 email이 PK라 별도의 생성 전략이 없지만, blog와 comment는 auto increment를 쓴다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto increment
private Integer blogId ;
@Column의 columnDefinition으로 DDL 제약을 그대로 넣을 수도 있다.
@Column(columnDefinition = "VARCHAR(20) DEFAULT '전체' CHECK( CATEGORY IN ('전체', '개발', '생활', '취미', '일상'))")
private String category ;
관계는 user(1) : blog(n), blog(1) : comment(n)이다.
N쪽에 @ManyToOne과 @JoinColumn을 두면 그 컬럼이 FK가 된다.
// foreign key
// fetch : select * from userEntity where email = ?
// optional = false : null 허용하지 않음.
@ManyToOne(fetch = FetchType.LAZY , optional = false )
@JoinColumn(name = "email")
private UserEntity author ;
// user(1) : blog(n) - blog(1) : comment(n)
// 댓글 Comments
@OneToMany(mappedBy = "blog", orphanRemoval = false )
private List<CommentEntity> comments = new ArrayList<>();
@JoinColumn(name = "email") : FK 컬럼명. 이 컬럼을 들고 있는 쪽이 연관관계의 주인이다mappedBy = "blog" : 1쪽에서 주인 필드의 이름을 적는다. 즉 CommentEntity.blog가 주인이고 1쪽은 읽기만 한다FetchType.LAZY : 필드에 실제로 접근할 때 조회한다optional = false : FK에 null을 허용하지 않는다CommentEntity도 같은 형태다.
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "blogId")
private BlogEntity blog ;
public void updateComment(String comment) {
this.comment = comment;
}
Entity에 updateComment()처럼 값을 바꾸는 메서드를 두는 이유는 아래 Dirty Checking에서 이어진다.
JPA는 Entity를 기준으로 영속성을 관리하므로 계층 사이를 오가는 DTO와 Entity를 서로 변환한다.
// JPA는 UserEntity 기반으로 데이터의 영속성을 관리 DTO -> Entity , Entity -> DTO
public static UserEntity toEntity(UserRequestDTO request) {
return UserEntity.builder()
.email(request.getEmail())
.password(request.getPassword())
.name(request.getName())
.role(request.getRole())
.build() ;
}
blog 쪽은 static이 아니다. FK로 들어갈 UserEntity를 인자로 받아야 하기 때문이다.
// User의 정보를 유지하면서 blog에 접근해야하기 때문에 BlogRequestDTO가 아닌 UserEntity를 받음
// this를 사용하기 위해 static 제거
public BlogEntity toEntity(UserEntity user) {
return BlogEntity.builder()
.title(this.getTitle())
.content(this.getContent())
.category(this.getCategory())
.author(user)
.build();
}
FK 컬럼에 email 문자열을 넣는 것이 아니라 UserEntity 객체 자체를 넣는다. 객체 그래프를 다루는 방식이라 참조를 넘긴다.
interface가 JpaRepository<Entity, PK타입>을 상속하면 구현 없이 CRUD 메서드가 제공된다.
@Repository
public interface UserRepository extends JpaRepository<UserEntity, String>{
// 사용자 정의 메서드 추가
// findByPropertyName, findByPropertyNameAndPropertyName
// findByEmailAndPassword()
public Optional<UserEntity> findByEmailAndPassword(String email, String password) ;
}
메서드 이름 규칙(findByPropertyName)만 지키면 SQL 없이 쿼리가 만들어진다.
이름 규칙으로 표현이 안 되면 @Query로 JPQL을 직접 쓴다. JPQL은 table이 아니라 Entity를 대상으로 한다.
public interface BlogRepository extends JpaRepository<BlogEntity, Integer> {
/* JPQL (직접 쿼리 작성) - Entity 기반으로 Query
@Query("""
SELECT
FROM BlogEntity
JOIN
WHERE
""")
*/
@Query("""
SELECT b
FROM BlogEntity b
LEFT JOIN FETCH b.comments
WHERE b.blogId = :blogId
""")
public Optional<BlogEntity> findByComments(@Param ("blogId") Integer blogId);
}
:blogId가 파라미터 자리고 @Param으로 이름을 맞춘다.
요청이 controller에 닿기 전에 가로챌 수 있는 지점이 세 곳이다. 오늘은 filter를 사용했다.
filter -> dispatcher -> servlet(catalina)
token -> |
|
|
header -> |
filter는 dispatcher보다 앞에 있으므로 토큰이 없는 요청은 controller까지 가지 않는다.
Filter를 구현하고 @Component로 등록한다. 검증에 쓸 key는 .yml의 jwt.secret을 주입받아 @PostConstruct에서 만든다.
@Component
public class JwtFilter implements Filter{
@Value("${jwt.secret}")
private String secret ;
private Key key ;
@PostConstruct
private void init() {
System.out.println("debug >>>> Provider jwt secret : "+secret);
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
토큰 없이 통과시킬 endPoint는 WHITE_LIST로 둔다. 로그인과 회원가입은 토큰을 받기 전이므로 여기 들어가야 한다.
// token 필요없는 endPoint 등록
private static final List<String> WHITE_LIST = List.of(
"/users/**",
"/swagger-ui/**",
"/v3/api-docs/**"
);
private final AntPathMatcher matcher = new AntPathMatcher();
public boolean isPath(String path) {
return WHITE_LIST.stream()
.anyMatch(pattern -> matcher.match(pattern, path));
}
AntPathMatcher로 /** 패턴을 매칭한다.
doFilter()가 실제 검사 지점이다. 순서는 preflight → WHITE_LIST → 토큰 검증이다.
@Override
public void doFilter( ServletRequest request,
ServletResponse response,
FilterChain chain)
throws IOException, ServletException {
System.out.println("debug >>>> JwtFilter doFilter");
HttpServletRequest req = (HttpServletRequest)request;
HttpServletResponse res = (HttpServletResponse)response ;
String endPoint = req.getRequestURI() ;
System.out.println("debug >>>> JwtFilter user endPoint : "+endPoint);
String method = req.getMethod() ;
System.out.println("debug >>>> JwtFilter user method : "+method);
브라우저는 커스텀 헤더가 붙은 요청을 보내기 전에 OPTIONS로 preflight를 먼저 보낸다. 이 요청에는 토큰이 없으므로 헤더만 세팅하고 통과시킨다.
// preflight : get,post -> options 전달이 이루어짐
if("OPTIONS".equalsIgnoreCase(req.getMethod())) {
System.out.println("debug >>>> JwtFilter preflight");
// header set : Origin, Method, Header
res.setStatus(HttpServletResponse.SC_OK);
res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT,PATCH, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Refresh-token");
res.setHeader("Access-Control-Allow-Credentials", "true");
chain.doFilter(request, response);
return ;
}
if(isPath(endPoint)) {
System.out.println("debug >>>> JwtFilter "+endPoint+" 는 토근없이 통과");
chain.doFilter(request, response);
return ;
}
chain.doFilter()가 다음 단계로 넘기는 호출이다. 호출하지 않고 return하면 요청은 거기서 끝난다.
토큰은 Authorization: Bearer xxxxx 형태로 온다. 헤더가 없거나 형식이 다르면 401이다.
/*
토큰 유효성 검증
- white list 등록되지않은 endPoint
- request header 토근 존재 유무 및 유효성을 검증(만료, 서명, 발행자)
- Bearer xxxxxxxxxxxx ;
*/
String header = req.getHeader("Authorization");
System.out.println("debug >>>> JwtFilter header "+header);
if( header == null || !header.startsWith("Bearer ")) {
System.out.println("debug >>>> JwtFilter UNAUTHORIZED ");
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return ;
}
// token 존재한다면?
// token : header, payload(Claims), signature
String token = header.substring(7);
System.out.println("debug >>>> JwtFilter token validation pass");
System.out.println("debug >>>> JwtFilter token "+token);
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
System.out.println("debug >>>> JwtFilter token validation success move to frontcontroller");
chain.doFilter(request, response);
} catch(Exception e) {
e.printStackTrace();
System.out.println("debug >>>> JwtFilter token validation fail");
return ;
}
}
substring(7)은 "Bearer " 7글자를 잘라내는 것이다. parseClaimsJws()가 서명과 만료를 검증하고, 실패하면 예외를 던진다.
토큰 발급은 signIn에서 이루어지고 응답 헤더에 담긴다.
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer "+(String)(map.get("at")));
headers.add("Refresh-Token", (String)(map.get("rt")));
headers.add("Access-Control-Expose-Headers", "Authorization, Refresh-Token");
Access-Control-Expose-Headers가 없으면 브라우저 쪽 JS에서 이 헤더를 읽을 수 없다.
BlogIndexPage.jsx, BlogWritePage.jsx, BlogReadPage.jsx에서 요청마다 토큰을 실어 보낸다.
const at = localStorage.getItem('at');
{
headers : {Authorization : at ? at : ""}
})
토큰이 없으면 빈 문자열을 보내고, filter에서 startsWith("Bearer ")에 걸려 401로 끊긴다.
server 쪽에서 헤더를 직접 받아보는 코드도 확인했다. 원래 controller에서 다룰 값은 아니다.
@GetMapping("/read/{id}")
public ResponseEntity read( @PathVariable ("id") Integer id,
@RequestHeader ("Authorization") String at ) {
System.out.println("debug >>>> blog controller read");
// token은 원래 앞단에서 처리되어 controller에서 다룰 일은 없지만 시범용
System.out.println("debug >>>> blog controller read access token : " + at);
request.getEmail()로 작성자를 먼저 조회하고, 그 UserEntity를 toEntity()에 넘겨 BlogEntity를 만든 뒤 저장한다.
public BlogResponseDTO insert(BlogRequestDTO request) {
System.out.println("debug >>>> blog service insert : " + request);
/*
1. request.getEmail() -> select
2. BlogEntity(toEntity(UserEntity(email)))
3. save()
*/
return userRepository.findById(request.getEmail())
.map(user -> {
BlogEntity blog = blogRepository.save(request.toEntity(user));
return BlogResponseDTO.fromEntity(blog);
})
.orElseThrow(() -> new RuntimeException("Blog Insert Failed!"));
}
findById()가 Optional을 반환하므로 map() 안에서 저장까지 처리하고, 사용자가 없으면 예외를 던진다.
목록은 findAll() 결과를 DTO로 변환한다.
public List<BlogResponseDTO> list() {
System.out.println("debug >>>> blog service list blogRepository : " + blogRepository);
return blogRepository.findAll()
.stream()
.map(BlogResponseDTO::fromEntity)
.toList();
}
blog 한 건과 그 댓글 목록을 같이 가져와야 한다. 따로 조회하면 쿼리가 두 번 나간다.
/*
bad case)
if blog 1 : comments 100
BlogEntity entity = blogRepository.findById(blogId).get()
List<CommentEntity> comments = blog.getComments();
entity.setComments(comments)
SELECT * FROM blogs WHERE id = ?;
SELECT * FROM comments WHERE blog_id = ?;
-> N + 1 problem
good case)
blog + comments 한번에 조회
*/
그래서 repository 쪽 JPQL에 LEFT JOIN FETCH를 썼다. JOIN만 쓰면 조인 조건에만 쓰이고, FETCH를 붙여야 연관 Entity까지 함께 로딩된다.
service는 @Transactional(readOnly = true)를 붙인다.
@Transactional (readOnly = true)
public BlogResponseDTO read(Integer id) {
// blog {} select - comment [] select
System.out.println("debug >>>> blog service read");
System.out.println("debug >>>> blog service read params : " + id);
// 단순 blog
return blogRepository.findByComments(id)
.map(BlogResponseDTO::fromEntity)
.orElseThrow(() -> new RuntimeException("BLOG NOT FOUND"));
// blog + comments (추후 구현)
// return blogRepository.findByComments(id)
// .map(BlogResponseDTO::fromEntityWithComments) // 메서드 가정
// .orElseThrow(() -> new RuntimeException("BLOG NOT FOUND"));
}
응답 DTO는 단순 변환과 댓글 포함 변환을 각각 둔다.
// blogs 반환하는 단순구조
public static BlogResponseDTO fromEntity(BlogEntity entity) {
return BlogResponseDTO.builder()
.id(entity.getBlogId())
.title(entity.getTitle())
.content(entity.getContent())
.category(entity.getCategory())
.email(entity.getAuthor().getEmail())
.build();
}
// blog(1) + comments(N) 반환도 필요함 (N + 1 해결)
public static BlogResponseDTO fromEntityWithComments(BlogEntity entity) {
return BlogResponseDTO.builder()
.id(entity.getBlogId())
.title(entity.getTitle())
.content(entity.getContent())
.category(entity.getCategory())
.email(entity.getAuthor().getEmail())
.comments(
entity.getComments()
.stream()
.map(CommentResponseDTO::fromEntity)
.toList()
)
.build();
}
entity.getAuthor().getEmail()처럼 객체를 타고 들어가 값을 꺼낸다. FK 컬럼값을 직접 읽는 것이 아니라 참조를 따라가는 형태다.
부모인 BlogEntity를 먼저 조회해서 toEntity()에 넘긴다. blog의 insert와 같은 구조다.
public CommentResponseDTO insert(CommentRequestDTO request) {
System.out.println("debug >>>> comment service insert : " + request);
BlogEntity blog = blogRepository
.findById(request.getBlogId())
.orElseThrow(() -> new RuntimeException("Blog Not Found!!"));
CommentEntity entity = request.toEntity(blog);
CommentEntity result = commentRepository.save(entity);
return CommentResponseDTO.fromEntity(result);
}
deleteById() 하나로 끝난다. 조회 후 delete()를 부르는 형태는 주석으로 남겼다.
public int delete(Integer commentId) {
System.out.println("debug >>>> comment service delete");
System.out.println("debug >>>> blog service delete params : " + commentId);
// CommentEntity entity = commentRepository
// .findById(commentId)
// .orElseThrow(() -> new RuntimeException("Comment Not Found!!"));
// commentRepository.delete(entity);
commentRepository.deleteById(commentId);
return 1;
}
JPA의 수정은 save()를 부르지 않는다. findById()로 가져온 Entity는 영속성 컨텍스트가 관리하는 상태이고, 필드를 바꾸면 커밋 시점에 스냅샷과 비교해 달라진 부분만 update 문으로 나간다.
/*
JPA update 주의 사항
- DML : transaction(commit, rollback)
- Dirty Checking(변경 감지)
findById() - 영속성 관리로 진입
setXXXX() - 필드 수정(Dirty Checking) Not Commit
현재상태 vs 스냅샷 상태 비교 후 다름을 확인하면 update 진행 - commit
- @Transactional 필수
*/
@Transactional
public int update(Map<String, Object> map) {
System.out.println("debug >>>> comment service update");
CommentEntity entity = commentRepository
.findById((Integer)map.get("id"))
.orElseThrow(() -> new RuntimeException((Integer)map.get("id") + "Comment Not Found!!"));
entity.updateComment((String)(map.get("comment")));
// commentRepository.save(entity); 필요 없음
return 1;
}
@Transactional이 없으면 커밋 시점이 없어 변경이 반영되지 않는다. CommentEntity에 @Setter 대신 updateComment()를 둔 것도 값을 바꾸는 경로를 제한하기 위한 것이다.
doFilter() 맨 앞의 OPTIONS 분기가 무엇을 막는 코드인지 이해가 되지 않았다.
// preflight : get,post -> options 전달이 이루어짐
if("OPTIONS".equalsIgnoreCase(req.getMethod())) {
브라우저는 cross-origin 요청 중 단순 요청이 아닌 것에 대해, 실제 요청을 보내기 전에 OPTIONS 메서드로 사전 확인 요청을 먼저 보낸다. 이것이 preflight다. Authorization 같은 헤더를 붙이는 순간 단순 요청 조건에서 벗어나므로, 오늘 front에서 헤더를 추가한 요청들이 전부 여기 해당한다.
const at = localStorage.getItem('at');
{
headers : {Authorization : at ? at : ""}
})
문제는 preflight 요청 자체에는 토큰이 실려 있지 않다는 점이다. OPTIONS 요청을 그대로 아래 검증 구문으로 내려보내면 Authorization 헤더가 없어 401로 끊기고, 본 요청은 시작조차 하지 않는다. 그래서 토큰 검사 전에 먼저 걸러내고 200과 허용 정보만 응답한다.
res.setStatus(HttpServletResponse.SC_OK);
res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT,PATCH, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Refresh-token");
res.setHeader("Access-Control-Allow-Credentials", "true");
Access-Control-Allow-Headers에 Authorization이 들어 있어야 브라우저가 본 요청에 그 헤더를 붙이는 것을 허용한다.
CorsConfig가 이미 있는데 filter에서 헤더를 다시 세팅하는 것도 헷갈렸다.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
CorsConfig는 dispatcher 이후 단계의 설정이고 filter는 그보다 앞이다. filter에서 요청이 끊기면 CorsConfig가 적용될 자리까지 가지 못하므로, filter 단계에서 응답을 직접 만들 때는 CORS 헤더도 여기서 붙여야 한다.