영속성 전이는 현재 엔티티에서만 전이되야 함 → 댓글을 게시물이 아닌 다른 곳에서 하면 안 됨
옵션 종류
ALL : 전체 상태 전이
PERSIST : 저장 상태 전이
REMOVE : 삭제 상태 전이
MERGE : 업데이트 상태 전이
REFERESH : 갱신 상태 전이
DETACH : 비영속성 상태 전이
예를 들어 Cascade.REMOVE를 하면 게시물이 삭제됐을 때 댓글도 삭제됨
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
private List<Child> children = new ArrayList<>();
public void addChild(Child child) {
children.add(child);
child.setParent(this);
}
}
@Entity
public class Child {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
}
// 사용 예
Parent parent = new Parent();
parent.setName("부모 엔티티");
Child child1 = new Child();
child1.setName("자식1");
Child child2 = new Child();
child2.setName("자식2");
parent.addChild(child1);
parent.addChild(child2);
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final CommentRepository commentRepository;
@Transactional
public void addComment(Long postId, String content) {
// 게시글 조회
Post post = postRepository.findById(postId)
.orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다."));
// 댓글 생성
Comment comment = new Comment(content);
post.addComment(comment); // Post 엔티티의 addComment 메서드 호출
// 저장 (CascadeType.ALL 덕분에 post 저장 시 comment도 저장됨)
postRepository.save(post);
}
}
-> Parent를 DB에 저장하면, List<Child>에 있는 Child들도 DB에 저장
orphanRemoval (고아 객체 제거)
다대일에서 일쪽에 즉, 부모에 걸어야 함(게시물과 댓글 중 게시물에)
Cascade.REMOVE 와 비슷한 용도로 삭제를 전파하는데 쓰임
부모객체가 삭제됐을 때 자식이 삭제되고, 부모 객체의 리스트에서 해당 자식객체의 요소를 삭제한 경우에도 자식이 삭제됨
→ 자식 엔티티의 라이프 사이클이 부모 엔티티와 동일해지며, 직접 자식 엔티티의 생명주기를 관리할 수 있음
Fetch (조회시점)
@ManyToMany, @OneToMany, @ManyToOne, @OneToOne에 사용
기본 LAZY(부모 조회 시 자식은 필요할 때 조회) 를 설정한 뒤에 필요할때만 fetch Join 을 수행
같이 쓰이는 연관관계 일 경우만 EAGER(부모 조회 시 자식도 같이 조회) 를 설정
JpaRepository 효율적으로 사용하는 방법
Optional 제거하기 : 비지니스 로직에서 Optional 처리를 위한 추가적인 작업을 방지
public interface UserRepository extends JpaRepository<User, Long> {
// Default 메소드를 사용하여 findById의 Optional을 내부적으로 처리
default User findUserById(Long id) {
return findById(id).orElseThrow(() -> new DataNotFoundException("User not found with id: " + id));
}
}
메서드명 간소화하기 : default 메서드를 활용하면 긴 메서드명을 간결하고 명확하게 표현
public interface ProductRepository extends JpaRepository<Product, Long> {
// 기존의 긴 쿼리 메소드
List<Product> findAllByCategoryAndPriceGreaterThanEqualAndPriceLessThanEqualOrderByPriceAsc(String category, BigDecimal minPrice, BigDecimal maxPrice);
// Default 메소드를 사용하여 간결한 메소드명 제공
default List<Product> findProductsByCategoryAndPriceRange(String category, BigDecimal minPrice, BigDecimal maxPrice) {
return findAllByCategoryAndPriceGreaterThanEqualAndPriceLessThanEqualOrderByPriceAsc(category, minPrice, maxPrice);
}
}
테이블 객체로 페이지 조회하기
페이징 처리 프로세스
PageRequest 를 사용하여 Pageable에 페이징 정보를 담아 객체화
Pageable을 JpaRepository가 상속된 인터페이스의 메서드에 T(Entity)와 함꼐 파라미터로 전달
// 아래와 같이 AS user_password 로 Alias(AS) 를 걸어주면
@Query("SELECT u.user_name, u.password AS user_password FROM user u WHERE u.username = ?1")
List<User> findByUsername(String username, Sort sort);
// 이렇게 해당 user_password 를 기준으로 정렬할 수 있다.
List<User> users = findByUsername("user", Sort.by("user_password"));
Item item = new Item(); // 1
item.setItemNm("테스트 상품");
EntityManager em = entityManagerFactory.createEntityManager(); // 2
EntityTransaction transaction = em.getTransaction(); // 3
transaction.begin();
em.persist(item); // 4-1
em.flush(item). // 4-2 (DB에 SQL 보내기/commit시 자동수행되어 생략 가능함)
transaction.commit(); // 5
em.close(); // 6
1️⃣ 영속성 컨텍스트에 담을 상품 엔티티 생성
2️⃣ 엔티티 매니저 팩토리로부터 엔티티 매니저를 생성
3️⃣ 데이터 변경 시 무결성을 위해 트랜잭션 시작
4️⃣ 영속성 컨텍스트에 저장된 상태, 아직 DB에 INSERT SQL 보내기 전
5️⃣ 트랜잭션을 DB에 반영, 이 때 실제로 INSERT SQL 커밋 수행
6️⃣ 엔티티 매니저와 엔티티 매니저 팩토리 자원을 close() 호출로 반환
쓰기 지연
여러가지 동작이 많이 발생하더라도 쿼리는 트랜잭션당 최적화 되어 최소쿼리만 날라감
엔티티를 DB에 즉시 반영하지 않고, 트랜잭션이 commit될 때 한꺼번에 SQL을 실행하는 최적화 기법
persist() 호출 시 즉시 INSERT 하지 않고, 영속성 컨텍스트 내부의쓰기 지연 저장소에 SQL을 모아둠
트랜잭션이commit될 때 flush()가 자동 호출되면서모아둔 SQL을 한 번에 실행
만약 쓰기지연이 없었다면?
Item item = new Item(); // 여기서 insert
item.setItemNm("테스트 상품"); // 여기서 update 쿼리 두번이나 날라감
-> 쓰기 지연이 있다면 insert 쿼리만 한번 날라감
쓰기 지연 예시
Team teamA = new Team();
teamA.setName("TeamA");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);
Member member_A = new Member();
member_A.setName("memberA");
member_A.setTeam(teamA);
em.persist(member_A);
em.flush();
Member findMember = em.find(Member.class, member_A.getId());
Team findTeam= findMember.getTeam();
System.out.println(findTeam.getName());
flush가 있는 경우 : 쓰기 지연된 SQL을 강제로 실행
쓰기 지연 저장소에 쌓인 SQL이 즉시 실행됨.
→ INSERT INTO TEAM과 INSERT INTO MEMBER가 flush() 실행 시 즉시 DB에 반영
flush가 있는 경우
create member
create team
insert team // flush로 인해 쓰기지연이 발생하지 않음
insert member // flush로 인해 쓰기지연이 발생하지 않음
print "TeamA" (memberA.getTeam())
flush가 없는 경우 : 쓰기 지연된 SQL이 트랜젝션이 종료될 때 자동으로 실행
쓰기 지연 저장소에 SQL이 쌓이고 트랜잭션이 끝나야 실행됨
영속성 컨텍스트에서 조회하므로 DB 조회 쿼리 없이도 객체를 찾을 수 있음
→ DB에서 조회되지 않고, 프록시 객체가 유지됨
flush가 없는 경우
create member
create team
print "TeamA" (memberA.getTeam()) // 쓰기 지연이 발생하더라도 영속성 컨텍스트에서 조회해옴
insert team // 쓰기 지연이 발생한 부분
insert member // 쓰기 지연이 발생한 부분
애플리케이션 외부에서 디비 엔진이 실행되기 때문에 애플리케이션을 종료해도 데이터가 사라지지 않음
In-memory Mode
애플리케이션 내부에서 디비 엔진이 실행(실행 주체가 스프링)되기 때문에 애플리케이션을 종료하면 디비 엔진도 함께 종료
애플리케이션의 메모리에 데이터가 저장됨
mem을 기재하여 애플리케이션실행 메모리(스프링 실행 메모리) 자체에서 디비를 사용하겠다는 것을 선언
설정 코드 : spring.datasource.url=jdbc:h2:mem:{DB 이름}
Embedded Mode
애플리케이션 내부에서 디비 엔진이 실행(실행 주체가 스프링)
애플리케이션 외부에 데이터가 저장되므로 애플리케이션을 종료해도 데이터는 사라지지 않음
설정 코드 : spring.datasource.url=jdbc:h2:{DB가 저장될 경로}
JDBC
Java 앱과 DB 를 연결시켜주기 위해 만들어진 기술 ->JPA도 이 기술을 사용하여 구현
JDBC Driver
DB와 애플리케이션(스프링)간의 통신을 중개하는 역할
JDBC Driver 동작 방식
연결 초기화
애플리케이션이 드라이버에 연결 요청
드라이버는 디비 서버에 로그인하고 연결 완료
sql 전송 및 실행
애플리케이션에서 받은 명령을 디비가 이해할 수 있는 형태로 변환
변환된 명령을 디비 서버로 전송해서 실행
결과 처리
디비에서 작업의 결과를 드라이버로 보내면 이 결과를 애플리케이션에서 이해할 수 있는 형태로 변환
해당 결과를 드라이버는 애플리캐이션으로 전송
연결 종료
작업이 완료되면 드라이버는 디비서버와의 연결을 종료
JDBC Driver Manager
1. Connection(연결) 을 생성하여 쿼리를 요청할 수 있는 상태를 만듦
2. Statement(상태) 를 생성하여 쿼리를 요청하게 함
3. ResultSet(결과셋) 을 생성해 쿼리 결과를 받아올 수 있게 해줌
Statement
동작방식 및 실행 방법
executeQuery() 나 executeUpdate() 를 실행하는 시점에 파라미터로 SQL문을 전달
SQL문을 수행하는 과정에서 구문 분석을 수행하기 때문에 효율성이 떨어짐
PreparedStatement
Statement를 상속하고 있는 Interface
내부적으로 Statement의 4단계(구문분석, 치환, 실행, 인출) 과정 중 첫 번째 parse 과정의 결과를 캐싱하고,
나머지 3가지 단계만 거쳐서 SQL문이 실행
구문 분석(parse)의 결과를 캐싱해서 과정을 생략할 수 있으므로 Statement보다 성능이 향상
SQL Injection 도 방어
순수 JDBC
try가로 안에 자원을 넣으면 해당 try문이 끝나면 close 메서드를 호출해서 자원을 반환
직접 Connection, Statement, ResultSet을 만들어 줘야 한다
public class JdbcApplication {
public static void main(String[] args) throws SQLException {
// 어플리케이션 실행 컨텍스트 생성
SpringApplication.run(JdbcApplication.class, args);
// 데이터베이스 연결정보
String url = "jdbc:h2:mem:test"; // spring.datasource.url
String username = "sa"; // spring.datasource.username
try (Connection connection = DriverManager.getConnection(url, username, null)) {
// 테이블 생성
String createSql = "CREATE TABLE USERS (id SERIAL, username varchar(255))";
try (PreparedStatement statement = connection.prepareStatement(createSql)) {
statement.execute();
}
// 데이터 추가
String insertSql = "INSERT INTO USERS (username) VALUES ('teasun kim')";
try (PreparedStatement statement = connection.prepareStatement(insertSql)) {
statement.execute();
}
// 데이터 조회
String selectSql = "SELECT * FROM USERS";
try (PreparedStatement statement = connection.prepareStatement(selectSql);
ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
System.out.printf("%d, %s%n", rs.getInt("id"), rs.getString("username"));
}
}
} catch (SQLException e) {
if (e.getMessage().equals("ERROR: relation \"account\" already exists")) {
System.out.println("USERS 테이블이 이미 존재합니다.");
} else {
throw new RuntimeException(e);
}
}
}
}
JDBC Template (QueryMapper)
직접 SQL을 실행하여 매핑
SQL 쿼리 요청시 중복 코드 발생 및 Connection, Statement 등.. 자원 관리를 따로 해줘야하고 예외가 모두 Checked Exception (SQL Exception) 처리되는 문제 발생
public List<User> getUsers() {
String sql = "SELECT id, name, age FROM users";
return jdbcTemplate.query(sql, (rs, rowNum) ->
new User(rs.getLong("id"), rs.getString("name"), rs.getInt("age"))
);
}
QueryMapper의 문제를 해결하기 위한 Persistence Framework 등장
SQL Mapper : JDBC Template, MyBatis
ORM : JPA, Hibernate
JDBC Template (RowMapper)
SQL Mapper 첫번째 주자로 JDBCTemplate 에 RowMapper 탄생
쿼리 수행 결과와 객채 필드 매핑
RowMapper 로 응답필드 매핑코드 재사용
Connection, Statement, ResultSet 반복적 처리 대신 해줌
결과값을 객체 인스턴스에 매핑하는데 여전히 많은 코드가 필요함
public class UserRowMapper implements RowMapper<User> {
// JDBCTemplate 에서 row 응답을 mapRow() 메서드에 rs 파라미터로 넘겨주어 객체에 매핑하기 쉽도록 도와준다.
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
var user = new User();
user.setId(rs.getInt("ID"));
user.setName(rs.getString("NAME"));
return user;
}
}
@Repository
public class DataRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
// 테이블 생성
public void createTable() {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS users (id SERIAL, name VARCHAR(255))");
}
// 사용자 추가 (Create)
public void insertUser(String name) {
jdbcTemplate.update("INSERT INTO users (name) VALUES (?)", name);
}
// 사용자 ID로 User 조회 (Read)
public User findUserById(Long id) {
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE id = ?",
new UserRowMapper(), // 이자리에 매퍼를 생성자로 넣어주면 됨
id
);
}
// 사용자 이름 변경 (Update)
public void updateUser(Long id, String newName) {
jdbcTemplate.update("UPDATE users SET name = ? WHERE id = ?", newName, id);
}
// 사용자 삭제 (Delete)
public void deleteUser(Long id) {
jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}
}
POST 장바구니 추가 /api/v1/carts Authorization : JWT 토큰 (일반사용자만 접근 가능) GET 장바구니 목록 조회 /api/v1/carts Authorization : JWT 토큰 (일반사용자만 접근 가능) PATCH 장바구니 수정 /api/v1/carts/{cartId} Authorization : JWT 토큰 (일반사용자만 접근 가능) DELETE 장바구니 삭제 /api/v1/carts/{cartId} Authorization : JWT 토큰 (일반사용자만 접근 가능)
주문 테이블을 먼저 생성 : 사용자 id, 장바구니에 있는 첫번째 메뉴를 가지고 와서 해당 가게 id insert
장바구니 테이블에서 사용자의 id로 목록을 조회하고 해당 값들을 주문 상세 테이블에 넣는다
마지막에 주문 상세 테이블에 있는 메뉴의 가격을 더해서 주문 테이블에 총 주문 금액을 넣어준다
3. 주문 변경은 정해진 순서대로 가능하다
사용자가 주문을 하면 "주문요청" 상태
사용자는 아직 "주문요청" 상태라면 "주문 취소"가 가능
사장님은 사용자가 "주문요청" 상태인 경우에 "주문 수락" 혹은 "주문 거절" 가능
사장님은 "주문수락" 상태라면 "배달중"으로 변경 가능 -> 이때 메뉴의 재고가 감소
사장님은 "배달중" 상태에서 "배달완료"로 변경 가능
주문 상태는 enum으로 관리
converter
public class OrderStatusConverter implements AttributeConverter<OrderStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(OrderStatus attribute) {
return attribute.getCode();
}
@Override
public OrderStatus convertToEntityAttribute(Integer dbData) {
return OrderStatus.of(dbData);
}
}
orderstatus
@Getter
public enum OrderStatus {
ORDER_REQUESTED(0, "주문요청"),
ORDER_ACCEPTED(1, "주문수락"),
ORDER_REJECTED(2, "주문거절"),
ORDER_CANCELED(3, "주문취소"),
DELIVERING(4, "배달중"),
DELIVERED(5, "배달완료");
private final Integer code;
private final String description;
OrderStatus(Integer code, String description) {
this.code = code;
this.description = description;
}
public static OrderStatus of(Integer code) {
return Arrays.stream(OrderStatus.values())
.filter(c -> c.getCode().equals(code))
.findFirst()
.orElseThrow(() -> new RuntimeException("데이터가 없습니다."));
}
// 배달완료 상태 확인
public boolean isReviewable() {
return this == DELIVERED;
}
}
fetch join 사용
장바구니 및 주문 관련된 기능을 개발하다보니 조인을 생각보다 많이 해야 해서 N+1문제 해결을 위해서 fetch join을 열심히 사용했다!
@Query("SELECT o FROM Order o " +
"LEFT JOIN FETCH o.store s " +
"LEFT JOIN FETCH o.user u " +
"WHERE o.user.id = :userId")
List<Order> findByUserId(@Param("userId") Long userId);
@Query("SELECT o FROM Order o " +
"LEFT JOIN FETCH o.store s " +
"LEFT JOIN FETCH o.user u " +
"WHERE o.user.id = :userId")
Page<Order> findByUserIdPaged(Pageable pageable, @Param("userId") Long userId);
API에 대한 고민
주문을 할때 가게의 아이디를 패스로 안 넣고 그냥 서비스 단에서 사용자의 아이디로 장바구니를 찾고 해당 장바구니의 가게를 찾는 식으로 구현을 했다. 그래서 서비스 코드를 보면 이렇게 조인을 3번을 타고 가게의 정보를 가져오게 된다
@Transactional
public OrderResponseDto save(AuthUser authUser) {
// 일반 회원인지 검증
isValidCustomer(authUser);
// 사용자 아이디로 장바구니에 있는 목록을 조회해서 첫번째 메뉴를 통해서 가게 아이디를 뽑아옴
User user = userRepository.findById(authUser.getId()).orElseThrow(() -> new InvalidRequestException(ErrorCode.USER_NOT_FOUND));
List<Cart> carts = cartRepository.findByUserId(authUser.getId());
Long storeId = carts.get(0).getMenu().getStore().getId();
Store store = storeRepository.findById(storeId).orElseThrow(() -> new InvalidRequestException(ErrorCode.STORE_NOT_FOUND));
...
}
문득 든 생각은 과연 이렇게 조인을 3번이나 하는 것은 성능에 부담이 가지 않을까라는 걱정이 들었다.
그래서 결국 애초에 api를 설계했을 때 주문 등록시에는 가게 id를 path로 보냈어야 하는 고민과 아니면 데이터베이스에서 장바구니 필드에 가게 id도 외래키로 넣었어야 했나싶은 생각이 들었다.
하지만 튜터님께 여쭤보고 들은 결과 결론은 (알고보니 배민 개발자셨다 ㅎㅎㅎㅎ)
1. 테이블을 3개 조인 하는 것과 조인을 하지 않고 select를 1번 하는 더 하는 것은 성능적으로 큰 차이가 없다
select를 1번 더 하게 되면 어쨌든 1번의 데이터베이스 IO가 발생하게 되므로 이것 또한 비용이 되고, 오히려 조인을 3번해서 데이터를 가져오는 것이 성능적으로 더 이점이 될 수도 있다
즉, 테이블 조인을 2개 하고 조회를 한번 더 하나, 아니면 테이블 조인을 3개하고 조회를 한번 더 하지 않거나 둘 다 거기서 거기라는 것이다
2. 주문 api에 /api/v1/stores/{storeId}/orders를 하기 위해서는 클라이언트가 storeId값을 알아야 한다
이런식으로 api를 짜게 된다면 클라이언트는 가게의 id를 알고 있어야 하기 때문에 이런 점들은 함부로 백엔드 개발자가 독단적으로 고려할 수 없다는 것이다. 그렇기에 내가 기존에 짠대로 가는 것이 옳은 방향이었다!!
3. 상태변경 api는 하나로 만들고 그 내부에서 검증하면 된다.
실제로 실무에서 변경되는 상태는 굉장히 여러가지이고 api하나로 만들어서 모두 검증해도 된다고 하셨기에 수정할 계획이다!
예외처리 메서드 분리에 대한 고민
장바구니와 주문, 주문 상태변경에 관한 기능을 맡게 되니 예외 처리할 부분들이 생각보다 매우 많았다
장바구니 예외 처리 리스트
주문 예외 처리 리스트
위와 같은 메서드를 모두 구현하려 보니 예외 처리 메서드가 상당히 많이 생기게 되었다..
등등.. 그래서 너무 자잘하게 예외 처리에 대한 부분들을 메서드로 나눈 것은 아닌가 싶은 생각이 들었다
간단한 if문만 사용해서 검증하는 부분인데도 투머치로 간 것이 아닌지에 대한 생각을 지울 수 없었다..ㅎ
하지만 튜터님께 피드백을 받은 결과
1. 예외처리를 메서드로 분리해서 나눈 것은 문제가 되지 않음
2. 하지만 가게가 열었는지 확인하는 검증 메서드는 가게 서비스로 분리시켜도 될 것 같다 (주문시에 가게가 영업중인지 확인하는 부분이 필요했다)
3. 주문 상태 변경의 경우 enum 클래스 내부에 메서드를 만들어서 그 안에서 검증해도 충분할 것 같다!
기존에 작성했던 포인트컷은 @Pointcut("execution(* ..Admin.(..))") 이다
요구사항에서 로깅을 찍어야 하는 컨트롤러는 모두 이름에 Admin이 들어가야 했기 때문에 저렇게 작성을 해줬는데 뭔가 저 포인트컷의 내용이 모호하게 잡혀있어서 제대로 반영되지 못하고 AOP가 모든 요청에 대해서 실행하는 것 같은 기분이 들었다
그래서 좀 더 명시적으로 포인트컷을 수정했다!
@Pointcut("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
public void userAdminController() {
}
@Pointcut("execution(* org.example.expert.domain.comment.controller.CommentAdminController.CommentAdminController(..))")
public void commentAdminController() {
}
패키지 경로부터 시작해서 아예 관리자 컨트롤러 내부에 있는 메서드까지 명시하니까 에러가 사라지고 잘 작동했다!
발생한 문제 : HttpServletRequest의 getInputStream() 호출 시 에러 발생
requestBody내역을 로그에 찍어야 하기 때문에 LogAspect에서 getInputStream()을 호출하는 코드를 작성했다!
public String getRequestBody(HttpServletRequest request) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readTree(request.getInputStream()).toString();
}
하지만 에러가 발생했다
Request processing failed: java.lang.IllegalStateException: getInputStream() has already been called for this request
HTTP 요청에 대해 getInputStream() 메서드를 한 번 이상 호출했을 때 발생하는 에러가 발생한 것이다!
찾아보니 HttpServletRequest에서 Stream형태로 RequestBody에 접근해 데이터를 가져갈경우에 스트림이 소비되면서 딱 한번만 읽을 수 있기 때문에 getInputStream() 또는 getReader()를 호출하면, 스트림이 소비되어 이후 다시 읽을 수 없다는 것이다
AOP를 사용하면서 먼저 RequestBody를 읽어서 스트림을 소비하고, 그 다음에 컨트롤러에서 한번 더 읽으려고 했는데 이미 스트림은 소비된 후라서 발생하게 된 것!!
해결 : HttpServletRequest의 Body를 여러번 읽을 수 있도록 바꾸기
RequestWrapper 추가
@Getter
public class RequestWrapper extends HttpServletRequestWrapper {
// 요청 바디를 저장하는 변수 (다시 읽을 수 있도록 캐싱)
private final String body;
public RequestWrapper(HttpServletRequest request) {
super(request);
StringBuilder stringBuilder = new StringBuilder();
try (BufferedReader bufferedReader = request.getReader()) { // 요청 바디를 읽을 BufferedReader 생성
char[] charBuffer = new char[128]; // 128자씩 읽어올 버퍼 생성
int bytesRead;
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
// 읽어온 데이터를 StringBuilder에 추가
stringBuilder.append(charBuffer, 0, bytesRead);
}
} catch (IOException e) {
// 요청 바디를 읽다가 예외가 발생하면 400 Bad Request 예외 발생
throw new BadRequestException();
}
// 읽은 요청 바디를 문자열로 변환하여 저장
body = stringBuilder.toString();
}
@Override
public ServletInputStream getInputStream() {
// 저장된 body 문자열을 ByteArrayInputStream으로 변환 (다시 읽을 수 있도록 함)
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false; // 스트림이 끝났는지 여부 (false로 설정하여 계속 읽을 수 있도록 함)
}
@Override
public boolean isReady() {
return false; // 스트림이 준비되었는지 여부 (false로 설정)
}
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException(); // 비동기 읽기 미지원
}
@Override
public int read() {
return byteArrayInputStream.read(); // 저장된 요청 바디를 한 글자씩 읽어서 반환
}
};
}
@Override
public BufferedReader getReader() {
// getInputStream()을 기반으로 BufferedReader 생성하여 반환 (요청 바디를 여러 번 읽을 수 있도록 함)
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
}
여러번의 시도를 해봤지만 응답객체의 바디값은 나오지 않았고.. 시도했던 이상한 중구난방 클래스들과 메서드들을 모두 지우고 처음부터 다시 찾아보기로 마음을 잡았다
사실 저 첫 번째 시도때 사용했던 코드들은 이해가 잘 되지 않았다 (gpt에게 냅다 물어봐서 얻은 코드이기 때문..)
이걸 쓰면서도 이게 뭔지도 모르고 자꾸 에러나고 하라는대로 하면서 계속 삽질을 했기 때문이다
다시 구글링을 열심히 하면서 찾아본 결과!!
// 메서드 실행
Object result = joinPoint.proceed();
이 result객체가 메서드를 실행한 이후의 응답 객체라는 것이다!!!!
엥?? 그러면 그냥
// 메서드 실행
Object result = joinPoint.proceed();
// 메서드 실행 후
LocalDateTime responseTime = LocalDateTime.now();
String responseBody = getResponseBody(result);
이러면 되는거 아닌가?
실행결과 responseBody값이 드디어 나와 주었다!!😳❤️❤️🥳🩷
API 로깅 전체 코드
LogAspect
@Aspect
@Slf4j
@Component
@RequiredArgsConstructor
public class LogAspect {
@Pointcut("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
public void userAdminController() {
}
@Pointcut("execution(* org.example.expert.domain.comment.controller.CommentAdminController.CommentAdminController(..))")
public void commentAdminController() {
}
@Around("userAdminController() || commentAdminController()")
public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
// 메서드 실행 전
Class clazz = joinPoint.getTarget().getClass();
Logger logger = LoggerFactory.getLogger(clazz);
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse();
Long userId = (Long) request.getAttribute("userId");
LocalDateTime requestTime = LocalDateTime.now();
String requestBody = getRequestBody(request);
Map<String, Object> params = new HashMap<>();
try{
String decodedURL = URLDecoder.decode(request.getRequestURI(), "UTF-8");
params.put("userId", userId);
params.put("requestTime", requestTime);
params.put("requestUrl", decodedURL);
params.put("requestBody", requestBody);
}catch (Exception e) {
logger.error("LoggerAspect error", e);
}
log.info("Request - User : {} | Time : {} | Url : {} | Body : {}",
params.get("userId"),
params.get("requestTime"),
params.get("requestUrl"),
params.get("requestBody"));
// 메서드 실행
Object result = joinPoint.proceed();
// 메서드 실행 후
LocalDateTime responseTime = LocalDateTime.now();
String responseBody = getResponseBody(result);
params.put("responseTime", responseTime);
params.put("responseBody", responseBody);
log.info("Response - User : {} | Time : {} | Url : {} | Body : {}",
params.get("userId"),
params.get("responseTime"),
params.get("requestUrl"),
params.get("responseBody"));
return result;
}
private static String getResponseBody(Object result) {
ObjectMapper objectMapper = new ObjectMapper();
JsonNode resultNode = objectMapper.valueToTree(result);
JsonNode bodyNode = resultNode.path("body");
return bodyNode.toString();
}
public String getRequestBody(HttpServletRequest request) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readTree(request.getInputStream()).toString();
}
}
Filter
@RequiredArgsConstructor
public class JwtFilter implements Filter {
private final JwtUtil jwtUtil;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
request = new RequestWrapper(httpRequest); // 여기서 요청 객체 저장(캐싱)하기
}
느낀점
응답 body를 출력하기 위해서 구글링을 하던 도중 내가 원하는 자료가 많이 보이지 않아서 그냥 gpt에게 물어보면서 해결하려 했는데(첫 번째 시도 : RequestWrapper 사용 부분), 무슨 코드인지도 모르는 상태에서 계속 질문하고 답은 안되고 뱅글뱅글 반복하면서 시간을 너무 허비한 것 같았다..
사실 gpt를 사용해서 코드를 짜는 걸 별로 좋아하지 않는데 (내가 짠 코드도 아니고 이게 뭔지도 모르면서 쓰는 것도 찝찝하고 감당이 안되서.. ) 역시 직접적인 문제해결은 내가 일단 이해하고 뭔지 안 다음에 되는 것 같다는 생각이 들었다
Object result = joinPoint.proceed();이것만 잠시 생각해 봐도 금방 해결할 수 있는 문제를 오히려 gpt를 사용하면서 더 헛수고를 하게 된 것이다..!!!
지금 단계에서는 역시 내가 감당하고 이해할 수 있는 코드만 만들고 gpt를 사용한 문제해결을 최대한 지양해야겠다는 생각이 더 들게 됐다