문제 : intellij 프로젝트 내부에 작업물이 보이지 않음

-> Spring boot application.properties 인식 오류도 동일

 

코드를 짜다가 렉이 좀 걸리는 것 같아서 인텔리제이를 종료하고 다시 실행했더니 프로젝트 내부에 내용물들이 하나도 보이지 않았다!!!!

구글링 결과 .idea를 지우고 재시작하면 된다고 했다!!

 

프로젝트 경로에서 아래 명령어를 수행하고 인텔리제이를 재시작하면 된다!!

ls -a
rm -rf .idea

 

Cascade (영속성 전이)

  • 다대일에서 일쪽에 즉, 부모에 걸어야 함(게시물과 댓글 중 게시물에)
  • 양쪽 엔티티의 라이프사이클이 동일하거나 비슷할 때 거는 것
  • 영속성 전이는 현재 엔티티에서만 전이되야 함 → 댓글을 게시물이 아닌 다른 곳에서 하면 안 됨
  • 옵션 종류
    • 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 와 비슷한 용도로 삭제를 전파하는데 쓰임
  • 부모객체가 삭제됐을 때 자식이 삭제되고, 부모 객체의 리스트에서 해당 자식객체의 요소를 삭제한 경우에도 자식이 삭제됨
  • 리스트 요소로써의 영속성 전이도 해준다는 뜻
  • 옵션
    • true
    • false

parent1.getChildList().remove(0); // delete 쿼리가 나감

 

영속성 전이 최강 조합 : orphanRemoval=true + Cascade.ALL

자식 엔티티의 라이프 사이클이 부모 엔티티와 동일해지며, 직접 자식 엔티티의 생명주기를 관리할 수 있음

 

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);
    }
}

 

 

테이블 객체로 페이지 조회하기

페이징 처리 프로세스

  1. PageRequest 를 사용하여 Pageable에 페이징 정보를 담아 객체화
  2. Pageable을 JpaRepository가 상속된 인터페이스의 메서드에 T(Entity)와 함꼐 파라미터로 전달
  3. 2번의 메서드의 return 으로 Page<T>가 응답
  4. 응답된 Page<T>에 담겨진 Page 정보를 바탕으로 로직을 처리

페이징 정렬

Sort 클래스

Sort sort1 = Sort.by("name").descending();     // 내림차순
Sort sort2 = Sort.by("password").ascending();  // 오름차순
Sort sortAll = sort1.and(sort2);      // 2개이상 다중정렬도 가능하다
Pageable pageable = PageRequest.of(0, 10, sortAll);  // pageable 생성시 추가

 

@Query 사용시 Alias(쿼리에서 as 로 지정한 문구) 를 기준으로 정렬

// 아래와 같이 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"));

 

JPA 속도 향상하는 방법 : 필요한 부분만 갱신하기

@DynamicInsert

Insert 쿼리를 날릴 때 null 인 값은 제외하고 쿼리문이 만들어짐

Hibernate: 
    insert 
    into
        users
        (username, id) 
    values
        (?, ?)  // 133ms 소요

 

@DynamicUpdate

Update 쿼리를 날릴 때 null인 값은 제외하고 쿼리문이 만들어짐

Hibernate: 
    update
        users 
    set
        password=? 
    where
        id=?  // 134ms

 

-> 필드가 많아질수록 둘 다 속도가 향상된다!

객체지향과 RDB의 차이를 해결하기 위해 ORM 이 해결해야하는 문제점

상속

  • 매핑정보에 상속정보를 넣어준다. (@OneToMany, @ManyToOne)

관계

  • 객체는 참조를 통해 관계를 가지며, 방향을 가짐, rdb는 외래키를 설정하여 join시에만 참조가 가능함
  • → 매핑정보에 방향정보를 넣어준다.(@JoinColumn, @MappedBy)

탐색

  • 객체는 컬랙션도 잘 탐색하고 참조를 통해서 다른 객체도 순회가 가능함, rdb는 탐색 시 조인이나 추가 쿼리가 발생함
  • → 매핑/조회 정보로 참조탐색 시점을 관리한다.(@FetchType, fetchJoin())

 

영속성

  • 데이터를 생성한 프로그램이 종료되어도 데이터가 종료되지 않는 특성
  • 데이터를 파일이나 db에 저장함으로써 데이터에 영속성을 부여

 

영속성 컨텍스트 (Persistence Context)

  • JPA가 엔티티를 관리하는 "논리적인 메모리 공간”
  • 엔티티 객체를 저장하고 관리하는 1차 캐시
  • 엔티티를 persist() 하면 DB에 즉시 저장하는 것이 아니라 영속성 컨텍스트에 먼저 저장
  • 트랜잭션이 commit될 때 한 번에 DB로 반영됨(쓰기 지연)

 

영속성 컨텍스트의 장점

  1. 캐시 지원 : 한 번 조회된 데이터는 다시 쿼리 하더라도 DB를 사용하지 않고 캐시 데이터를 사용
  2. 더티 체킹 : 트랜잭션 종료 시점에 자동으로 변경사항을 DB에 반영

 

영속성 상태

비영속 : 엔티티 객체만 만들어지고, 영속성 컨텍스트와 관계가 전혀 없음 (사귀기 전)

영속 : 엔티티가 영속성 컨텍스트에 저장, 영속성 컨텍스트가 관리할 수 있음 (사귐)

준영속 : 엔티티가 영속성 컨텍스트에 저장됐다가 분리됨, 영속성 컨텍스트가 더 이상 관리 안함 (헤어짐)

삭제 : 엔티티를 영속성 컨텍스트와 디비에서 삭제하겠다고 표시한 상태 (사귀는 상태에서 세상에서 사라져버림)

 

영속성 상태 관리 메서드

  • 객체의 영속성 상태는 Entity Manager 의 메소드를 통해 전환
  • new > (비영속상태) > persist(),merge() > (영속성 컨텍스트에 저장된 상태) > flush()  > (DB에 쿼리가 전송된 상태) >  commit() > (DB에 쿼리가 반영된 상태)

 

  • 예시
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    // 쓰기 지연이 발생한 부분

 

 

DB 저장 방식

 

Server Mode

  • 애플리케이션 외부에서 디비 엔진이 실행되기 때문에 애플리케이션을 종료해도 데이터가 사라지지 않음

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 동작 방식

  1. 연결 초기화
    • 애플리케이션이 드라이버에 연결 요청
    • 드라이버는 디비 서버에 로그인하고 연결 완료
  2. sql 전송 및 실행
    • 애플리케이션에서 받은 명령을 디비가 이해할 수 있는 형태로 변환
    • 변환된 명령을 디비 서버로 전송해서 실행
  3. 결과 처리
    • 디비에서 작업의 결과를 드라이버로 보내면 이 결과를 애플리케이션에서 이해할 수 있는 형태로 변환
    • 해당 결과를 드라이버는 애플리캐이션으로 전송
  4. 연결 종료
    • 작업이 완료되면 드라이버는 디비서버와의 연결을 종료

 

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);
	}
}

대략적인 erd

 

장바구니 API & 주문 API

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 토큰 (일반사용자만 접근 가능)

POST 주문 등록 /api/v1/orders Authorization : JWT 토큰
GET 주문단건조회 /api/v1/orders/{orderId} Authorization : JWT 토큰
GET 주문전체조회(자신이 한 주문) /api/v1/orders Authorization : JWT 토큰
PATCH 주문수락 /api/v1/orders/{orderId}/accept Authorization : JWT 토큰
PATCH 주문거절 /api/v1/orders/{orderId}/reject Authorization : JWT 토큰
PATCH 주문취소 /api/v1/orders/{orderId}/cancel Authorization : JWT 토큰
PATCH 배달중 /api/v1/orders/{orderId}/delivering Authorization : JWT 토큰
PATCH 배달완료 /api/v1/orders/{orderId}/complete Authorization : JWT 토큰

 

장바구니 및 주문 프로세스

1. 사용자가 장바구니에 메뉴를 담는다

  • 장바구니 테이블에 insert
  • 이때 다른 가게의 메뉴인지 확인하고, 장바구니에 담은 가게의 메뉴와 다르면 예외 처리
  • 장바구니에 담은 메뉴의 개수가 재고보다 작다면 예외 처리

2. 사용자가 주문 요청을 한다

  • 주문 테이블을 먼저 생성 : 사용자 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 클래스 내부에 메서드를 만들어서 그 안에서 검증해도 충분할 것 같다! 

였다!

추후에 상태변경 검증 메서드를 enum안에 넣도록 수정해야겠다

 

 

튜터님의 추가적인 피드백

  • 회사마다 다르지만 사실 실무에서는 장바구니를 서버의 데이터베이스에 저장하지는 않는다 (그래도 이번은 과제니까 저장하는게 맞음)
    • 예를 들어 수량을 올릴 때마다 서버에 요청을 한다면 수량을 100개 올린다면 그만큼 응답 속도가 오래 걸리게 된다
    • 하지만 쿠팡은 어떤 메뉴가 장바구니에 많이 담겼는지 조사하기 위해서 저장한다고 한다
    • 주로 프론트에서 정보를 저장하고 주문시에만 서버로 요청이 간다
  • 장바구니에는 사용자가 주문당시에 담은 가격도 들어가야 한다
    • 주문당시에는 15000원이었으나 주문버튼을 눌렀을 때 그 사이에 사장이 가격을 17000원으로 올린다면 낭패

 

정리하자면

1. 도메인의 주제에서 벗어난 키값은 패스에 함부로 넣으면 안됨

2. 조인테이블을 여러개 하나 추가적인 조회를 한 번 하나 성능은 거기서 거기

3. 상태변경은 하나의 api로 할 것

4. 상태변경 검증 로직은 이넘클래스 내부에서 메서드 만들어서 할 것

5. 장바구니에는 당시의 가격도 넣어야 함

6. 서비스 로직에서 검증에 관한 부분을 보고 그 부분이 다른 서비스에 적합하다고 생각하면 책임을 분리할 것

7. 실무에서는 장바구니는 디비에 저장하지 않는다

 

 

느낀점

  • 장바구니 엔티티 짜는 것부터 걱정이 많이 됐는데 그래도 어찌저찌 성공하게 되서 다행이었다
  • 설계가 정말 어려운 것 같다는 생각이 들었고 이넘 클래스를 사용해서 개발 해 볼 수 있어서 좋았다
  • 예외처리를 생각하는 것이 정말 쉽지 않다는 것을 느꼈다

대략 설계된 1차 ERD

  • (테이블의 속성만 설계됐다고 보면 됨, 타입이나 널값여부같은 것은 아직 제대로 결정 안 함)
  • 일반회원과 사장테이블은 시간관계상 나누면 복잡해질 것 같아서 통합
  • 사장은 여러개의 가게를 만들 수 있기 때문에 회원 테이블과 가게 테이블은 1 : N
  • 이때 구현려고 한 요구사항은 회원은 1개의 메뉴만 선택하여 주문할 수 있는 것임
  • 리뷰는 하나의 주문에만 달 수 있기 때문에 주문 테이블과 1대 1로 지정해 놓음
  • 리뷰 테이블의 가게ID는 외래키(기본키로 잘못 설정해놨다, 수정함)
  • 리뷰를 조회할 때 작성자명이 보여야 하기 때문에 회원 외래키를 어디에 적용해야 할지 고민함
    • 주문테이블에 있는 회원을 타고 가도 충분할 것 같다고 생각해서, 리뷰 테이블에 회원 외래키를 놓지 않음

피드백

  • 메뉴가 수정되면 주문한 메뉴가 수정될 수 있기 때문에 주문테이블과 메뉴테이블은 분리해 주어야 한다고 하심
  • 주문메뉴 테이블 같은걸 추가해라!

 

 2차 ERD : 장바구니 기능 추가

  • 이번 프로젝트에서 내가 담당해야 할 기능은 주문인데, 메뉴 1개만 주문하게 구현한다면 너무 단순해질 것 같아서 장바구니 테이블을 추가해서 고쳐 보았다

 

장바구니 테이블

  • 회원은 여러개의 메뉴를 장바구니에 담을 수 있고, 메뉴 또한 여러개의 장바구니에 담기기 때문에 서로 N : M
  • 수량 카운트도 추가

 

주문 테이블

  • 가게 : 주문 = 1 : N
  • 회원 : 주문 = 1 : N
  • 튜터님께 여쭤본 결과 주문pk 이외에 주문 번호도 고유하게 필요하다고 하셔서 필드 추가!
    • 주문 번호를 아예 pk로 해도 된다고 하셨다
    • 채번형식으로 할지 uuid로 할지 고민할 예정

 

주문상세 테이블

  • 주문 1개에는 여러개의 메뉴가 들어간다!
  • 그렇기에 주문 1개에 들어가는 여러가지 메뉴를 기록할 상세 테이블을 생성

 

추가 +) 회원 테이블

  • 사장님인 경우와 일반 회원인 경우를 나누지 않고 하나의 테이블로 했기 때문에 사장님이 일반 회원으로 가입하고 싶은 경우도 염두해야 한다
  • 이메일과 권한을 유니크 제약조건을 추가할 것!

 

 

대략 장바구니 부분의 erd는 설계완료 했기 때문에 구현을 먼저 하고 추후에 더 수정해봐야겠다! 끝 😂😂😂

 


최종 ERD

N + 1 문제

  • 연관관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수만큼 연관관계의 조회 쿼리가 추가로 발생하는 것
  • 연관된 데이터를 조회할 때 비효율적인 쿼리가 발생하는 문제

만약 주인은 3명이 있고, 고양이는 9마리가 있다고 하자

주인 1명당 고양이를 3마리씩 소유하고 있음

이때 주인 엔티티를 조회하게 되면 쿼리는 총 4번 날라가게 된다!

SELECT * FROM Owner; → 회원 3명을 조회하는 1번의 쿼리
SELECT * FROM Cat WHERE owner_id = ?; → 각 회원마다 고양이를 조회하는 3번의 쿼리

 

해결방법

이렇게 여러번의 select 쿼리가 날라가지 않게 하기 위해서는 join쿼리를 날리고 싶기 마련이다

select * from owner left join cat on cat.owner_id = owner.id

 

JPQL을 이용한 일반 JOIN 쿼리

지난 프로젝트때 실제로 이렇게 쿼리를 작성해서 데이터를 조회했었다 🫣

하지만 이렇게 하면 실제로는 위와 똑같이  N+1 문제가 동일하게 발생한다

@Query("SELECT o FROM Owner o JOIN o.cats c")
List<Owner> findAllWithJoin();

 

JOIN과 FETCH JOIN 차이

  • JOIN은 관계된 엔티티를 결합하여 데이터를 조회하지만, 연관된 엔티티는 지연 로딩(Lazy Loading) 방식으로 처리되며, 추가적인 쿼리가 실행
  • FETCH JOIN은 관계된 엔티티를 즉시 로딩(Eager Loading)하며, 연관된 엔티티도 한 번의 쿼리로 조회하여 N+1 문제 방지

 

FETCH JOIN

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • FetchType을 Lazy로 해놓는것이 무의미
  • 하나의 쿼리문으로 가져오다 보니 페이징 단위로 데이터를 가져오는것이 불가능하므로 페이징 쿼리를 사용할 수 없음
  • inner join으로 호출
@Query("SELECT c FROM Cat c JOIN FETCH c.owner o")
List<Cat> findAllCatsWithOwners();

 

수행되는 쿼리 : 1번만 수행

SELECT c.id, c.name, o.id, o.name
FROM Cat c
JOIN Owner o ON c.owner_id = o.id;

 

EntityGraph

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • outer join으로 호출
@EntityGraph(attributePaths = "cats")
@Query("select o from Owner o")
List<Owner> findAllEntityGraph();

 

수행되는 쿼리 : 1번만 수행

SELECT o.*, c.*
FROM owner o
LEFT JOIN cat c ON o.id = c.owner_id;

 

 

Fetch Join과 EntityGraph 주의할 점

Cartesian Product가 발생하여 Owner의 수만큼 Cat이 중복 데이터가 존재 -> 중복된 데이터가 컬렉션에 존재하지 않도록 주의

이런식으로 데이터가 들어오기 때문에 set을 사용하여 데이터를 담아줘야 한다!

public List<OwnerDTO> convertToDTO(List<Owner> owners) {
    Set<OwnerDTO> ownerDTOs = new LinkedHashSet<>();  // Set 사용하여 중복 제거

    for (Owner owner : owners) {
        // 새로 OwnerDTO 생성
        List<CatDTO> catDTOs = new ArrayList<>();
        for (Cat cat : owner.getCats()) {
            catDTOs.add(new CatDTO(cat.getId(), cat.getName()));
        }

        // OwnerDTO를 Set에 추가
        OwnerDTO ownerDTO = new OwnerDTO(owner.getId(), owner.getName(), catDTOs);
        ownerDTOs.add(ownerDTO);  // 중복 제거
    }

    // Set을 List로 변환해서 반환
    return new ArrayList<>(ownerDTOs);
}

 

 

FETCH JOIN을 사용했는데 Page객체로 받았다면?

  • JPA는 페이징을 DB에서 하지 않고, 모든 데이터를 가져와서 메모리에서 페이징을 시도
  • 결과적으로 OutOfMemoryError 등의 성능 문제가 발생가능성이 생김
@Query("SELECT t FROM Todo t JOIN FETCH t.user ORDER BY t.modifiedAt DESC")
Page<Todo> findAllTodos(Pageable pageable);

AOP

  • 관점 지향 프로그래밍
  • 반복되고 공통적으로 사용되는 부분을 분리
  • 부가적인 기능을 핵심 로직으로부터 분리하여 핵심로직은 오로지 자신에게만 집중할 수 있음

 

발생한 문제 : NPE

LogAspect를 모두 작성하고 실행하려고 하는데 NullPointerException이 발생했다

Caused by: java.lang.NullPointerException: null

 

첫 번째 시도 : HttpServletRequest 수정

HttpServletRequest를 불러오는 부분에서 문제가 있었나 싶어서 해당 부분을 인텔리제이가 추천하는 코드로 변경했다

 

변경 전

HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

 

변경 후

HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();

 

하지만 동일한 에러는 계속 발생했다

 

해결 : Pointcut 수정

기존에 작성했던 포인트컷은 @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()));
    }
}

 

필터에 적용

  @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);

 

 

발생한 문제 : HttpServletResponse의 Body조회 불가

응답 객체의 body도 조회해야 했기 때문에 Request의 Body를 조회할 때처럼 유사하게 하면 된다고 생각했다

 

첫 번째 시도 : RequestWrapper 사용 

public class RequestWrapper extends HttpServletRequestWrapper {
    private final String body;  // 요청 본문을 저장할 변수

    public RequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        StringBuilder stringBuilder = new StringBuilder();
        BufferedReader bufferedReader = request.getReader();
        String line;
        while ((line = bufferedReader.readLine()) != null) {
            stringBuilder.append(line).append("\n");
        }
        body = stringBuilder.toString(); // 본문 저장
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body.getBytes())));
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return true;
            }

            @Override
            public void setReadListener(ReadListener readListener) {}

            @Override
            public int read() {
                return byteArrayInputStream.read();
            }
        };
    }

    public String getBody() {
        return body;
    }
}

 

필터 적용

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletRequest httpRequest = (HttpServletRequest) request;
    HttpServletResponse httpResponse = (HttpServletResponse) response;

    RequestWrapper requestWrapper = new RequestWrapper(httpRequest);
    chain.doFilter(requestWrapper, httpResponse);
}

 

하지만 로그를 찍어보니 body는 공백으로 나왔다

한참동안 이 방법으로 해결하기 위해서 수많은 삽질을 했다 😂

 

두 번째 시도 : returning = "returnObj" 사용해보기 (안될 것 같긴 했음..)

다른 예시들을 찾아보니 returning = "returnObj"를 사용하여 응답객체를 받아와서 출력하도록 했다

그래서 이 방법으로 응답객체를 출력하기 위해서 시도해봤다

(하지만 그 예시는 @AfterReturning이긴 했다..ㅎㅎ)

(그래도 @Around도 실행 전후에 발생시키는 거니까 응답객체를 사용할 수 있지 않을까하는 일말의 희망으로 사용해봤다.,.)

@Around("userAdminController() || commentAdminController()", returning = "returnObj")
public Object logging(ProceedingJoinPoint joinPoint, Object returnObj) throws Throwable {
@Around("userAdminController() || commentAdminController()")
public Object logging(ProceedingJoinPoint joinPoint, @Return("returnObj") Object returnObj) throws Throwable

 

하지만 인텔리제이에서 빨간줄이 뜨면서 실행할 수 없었다..ㅎㅎ

자세하게 이해하지 못하고 이것저것 끌어서 사용하려니 이런 불상사가 발생한 것이다!

하지만 이것 저것 해봐도 계속 실패했기에 이런거라도 시도해보고 싶었다..

 

해결  :  joinPoint.proceed()의 return 객체로 조회하기

여러번의 시도를 해봤지만 응답객체의 바디값은 나오지 않았고.. 시도했던 이상한 중구난방 클래스들과 메서드들을 모두 지우고 처음부터 다시 찾아보기로 마음을 잡았다

사실 저 첫 번째 시도때 사용했던 코드들은 이해가 잘 되지 않았다 (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를 사용한 문제해결을 최대한 지양해야겠다는 생각이 더 들게 됐다

결론 : 문제 해결을 구글링이나 자료보고 생각해보고 하기!

+ Recent posts