Websocket?

http는 비연결성, 무상태성을 가진 단방향 통신이라는 것을 알고 있을 것이다.

그런데 서버에서 클라이언트로 요청을 보내야 하는 상황, 이전에 주고 받은 요청들을 알고 있어야 하는 상황이 있으면 어떻게 해야 할까?

 

Polling

옛날에는 해결 방법으로 polling을 사용하였다.

클라이언트가 주기적으로 서버에 요청을 보내면, 서버에 응답 데이터가 있을 경우 응답이 반환 된다.

 

그러나 단점이 많이 존재하는데

1. 설정한 일정 시간마다 요청을 보내는 것일 뿐 실시간이 아니다.

2. 주기적으로 요청을 보내야 하기 때문에 필요없는 통신이 발생하며 프로그램에 부하를 주게 된다.

3. http 통신을 하기 때문에 오버헤드가 발생한다.

 

WebSocket

이런 불편함을 해결하기 위해 웹소켓이 등장하였다.

웹소켓은 양방향, 실시간 통신을 지원해주는 통신 프로토콜이다.

즉 서버에서 클라이언트로 요청을 보낼 수 있다는 뜻이다. 연결을 끊지 않았다면 언제든!

 

동작 방법

출처: 위키피디아

웹소켓의 동작은

- 클라이언트와 서버의 연결(핸드쉐이크)

- 양방향 메세지 전달

- 한 곳에서 연결을 끊음

이 세 가지 단계로 이루어져 있다.

 

HandShake

핸드쉐이크란, 서버와 클라이언트 간 연결이 잘 되었는지 서로 확인하는 과정이다.

이 과정은 http로 이루어 지며 웹소켓으로 프로토콜을 전환(Upgrade)할 것이라고 알린다.

 

Bidirectional messages

이제 실질적인 필요한 데이터를 주고 받는 부분이다.

여기서는 메세지 단위 통신이 이루어 지며, 양 방향으로 요청을 보낼 수 있다.

따라서 매번 헤더를 전송할 필요가 없기 때문에 오버헤드가 발생하지 않는다.

 

OneSides closes channel

클라이언트, 서버 중 한 쪽이 연결을 종료하면 웹소켓 채널이 닫힌다.

 

 

실제 사용에 대해서는 따로 포스팅을 해보겠다.

 

'Spring' 카테고리의 다른 글

QueryDSL의 null처리  (0) 2024.09.23
[Spring] JPA 심화 강의 정리  (0) 2024.06.26
[Spring] 🙉Fixture Monkey🙉  (1) 2024.06.15
[Spring] DB 테스트 환경 분리  (0) 2024.06.13
[Spring] AOP란 무엇일까? (+Spring AOP)  (0) 2024.06.12

 

 

 

Query DSL 활용

Query DSL의 큰 장점 중 하나는 where 절에서의 null 처리이다.

 

여러 조건에 부합하는 결과를 조회해야 한다고 하자.

칸반 보드 프로젝트를 진행할 당시 상태와 담당자 닉네임을 필터링할 필요가 있었다.

그러나 해당 조건을 전부 받을 수도 있으나 담당자 닉네임 혹은 상태만 들어올 가능성도 있었다.

그러면 총 세 개의 Query 메서드를 작성해야 하나? 생각할 수도 있지만

Query DSL은 where 절에 null이 들어왔을 경우 알아서 where 문을 생략해 준다!

queryFactory.selectFrom(card)
    .where(card.board.id.eq(boardId)
            ,eqStatus(searchCond.getStatus())
            ,eqNickname(searchCond.getNickname()))
    .fetch();

 

 

(값을 받아올 때는 search cond를 사용하였다.)

참고로 where 절 안에서 ,를 사용해 조건을 추가할 때만 이 기능이 동작한다.

and를 사용해 조건을 추가할 때는 동작하지 않기 때문에 상황에 맞게 사용하자.

 

eqStatus와 eqNickname은 직접 만든 메서드인데 BooleanExpression를 반환한다.

조건이 참인지 거짓인지 판별하는 것이다.

private BooleanExpression eqStatus(String status){
    return StringUtils.hasText(status) ? card.status.title.eq(status) : null;
}

 

해당 메서드는 이렇게 생겼다.

String이 빈 값인지 아닌지 판단해서 BooleanExpression 혹은 null을 반환한다.

Query DSL이 null 조건을 무시할 수 있도록 말이다.

 

 

전체 코드

@Repository
@RequiredArgsConstructor
public class CardRepositoryQueryImpl implements CardRepositoryQuery{

    private final JPAQueryFactory queryFactory;

    @Override
    public List<Card> findBySearchCond(Long boardId, CardSearchCond searchCond){

        // 1. Board ID가 일치 해야 합니다.
        // 2. status 이름이 일치 해야 합니다.
        // 3. 담당자 nickname이 일치해야 합니다.

        /**
         * select *
         * from Card
         * where card.board.Id = boardId
         * and card.status.title = statusTitle
         * and card.User.nickname = nickname
         */

        return queryFactory.selectFrom(card)
                .where(card.board.id.eq(boardId)
                        ,eqStatus(searchCond.getStatus())
                        ,eqNickname(searchCond.getNickname()))
                .fetch();
    }

    private BooleanExpression eqStatus(String status){
        return StringUtils.hasText(status) ? card.status.title.eq(status) : null;
    }

    private BooleanExpression eqNickname(String nickname){
        return StringUtils.hasText(nickname) ? card.user.nickname.eq(nickname) : null;
    }

}

 

2주차 데이터베이스 다루기

데이터베이스 생성 (H2)

  • Server Mode
    • 외부에서 DB 엔진 구동
    • 애플리케이션을 종료시 데이터가 사라지지 않음.
    • 배포용도
  • In-memory Mode
    • 엔진을 설치하지 않고 애플리케이션 내부의 엔진을 사용
    • 데이터를 애플리케이션의 메모리에 저장.
    • 애플리케이션 종료시 데이터가 사라짐.
    • 테스트 용도
  • Embedded Mode
    • 엔진을 설치하지 않고 애플리케이션 내부의 엔진을 사용.
    • 데이터를 애플리케이션 외부에 저장.
    • 애플리케이션 종료시 데이터가 사라지지 않음.
    • 개발 용도

인메모리 모드 설정 방법

# application.yml
spring:  
    datasource:    
        driver-class-name: org.h2.Driver
        url: jdbc:h2:mem:{DB 이름}
        username: sa
        password:

임베디드 모드 설정 방법

# application.yml
spring:
  datasource:
      driver-class-name: org.h2.Driver
      url: jdbc:h2:{DB가 저장될 경로}    
      username: sa    
      password:

alt text

h2 콘솔은 spring boot를 통해 여는 것이긴 하지만 application.yml 설정과 상관 없이 콘솔 입력 값에 따라 동작한다.

데이터베이스에서 데이터 다루기(SQL)

  • DDL: 데이터 정의 언어
    • 테이블 생성 (CREATE TABLE)
    • 테이블 수정 (ALTER TABLE)
    • 테이블 삭제 (DROP TABLE)
  • DML: 데이터 조작 언어
    • SELECT문 (SELECT, WHERE, ORDER BY, GROUP BY, JOIN ...)
    • 데이터 추가 (INSERT INTO)
    • 데이터 업데이트 (UPDATE)
    • 데이터 삭제 (DELETE FROM)
  • DCL: 데이터 제어 언어

트랜잭션

  • 데이터베이스의 상태를 변화시키기 위해서 수행하는 작업의 단위
  • 원자성 (All of Noting)
    • 트랜잭션이 데이터베이스에 모두 반영되던가, 아니면 전혀 반영되지 않아야 한다.
    • 트랜잭션은 논리적인 작업 단위.
  • 일관성 (Keeps Data Correct)
    • 작업 처리 결과가 항상 일관성이 있어야 한다.
    • 트랜잭션 중에 데이터베이스가 변경되어도 이전 데이터베이스를 바탕으로 작업한다.
  • 독립성 (Independent)
    • 둘 이상의 트랜잭션이 동시에 실행되고 있을 경우 다른 트랜잭션의 연산에 끼어들 수 없다.

데이터베이스 연결 (Driver)

  • 드라이버의 역할: 애플리케이션의 요청을 데이터베이스가 이해할 수 있는 언어로 변환.
  • 드라이버의 종류: 데이터베이스 시스템마다 호환되는 드라이버가 있음.

드라이버의 동작 방식

  • 연결 초기화
    • 요청 수신: 애플리케이션은 데이터베이스 작업을 시작하기 위해 드라이버에 연결을 요청.
    • 연결 설정: 드라이버는 데이터베이스 서버에 로그인하고 필요한 설정을 수행하여 연결을 완료.(네트워크 정보, 인증 자격 증명 등 사용)
  • SQL 전송 및 실행
    • SQL 명령 변환: 애플리케이션에서 발송된 SQL 명령을 데이터베이스가 이해할 수 있는 형태로 변환.
    • 명령 처리: 변환된 명령은 데이터베이스 서버로 전송되어 실행.
  • 결과 처리
    • 결과 수신: 데이터베이스에서 작업의 결과가 도착하면 애플리케이션에서 해석할 수 있는 형태로 변환.
    • 결과 전달: 결과를 애플리케이션에 전달.
  • 연결 종료
    • 드라이버는 데이터베이스 서버와의 연결을 종료.

alt text

JDBC Driver Manager는 런타임 시점에 Connection(데이터베이스 서버와 연결 함), Statement(쿼리를 요청하게 함), ResultSet(쿼리 결과를 받아올 수 있게 함) 객체를 생성한다.

데이터베이스 데이터를 외부에서 다루기 (JDBC)

spring-boot-starter-jdbc

  • JDBC API 지원: JDBC API를 통해 SQL 데이터베이스에 접근하고 작업을 수행할 수 있음.
  • DataSource 구성: 데이터 소스 연결을 위한 기본적인 설정을 자동으로 구성.
  • JdbcTemplate: Spring의 핵심 클래스 중 하나. SQL 쿼리 실행, 결과 세트 처리, 예외 처리 등을 단순화.
  • 장점
    • 간소화된 데이터베이스 연결: DataSource, JdbcTemplate 사용을 통해 JDBC 코드 간소화.
    • 자동 구성: 대부분의 구성을 자동으로 처리.
    • 효율적인 예외 처리: DataAccessException을 통해 일관될 예외 체계로 변환.

JDBC 실습

// JdbcApplication.java

package com.thesun4sky.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
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

        // connection 얻어오기
        try (Connection connection = DriverManager.getConnection(url, username, null)) {
            try {
                // 테이블 생성 (statement 생성)
                String creatSql = "CREATE TABLE USERS (id SERIAL, username varchar(255))";
                try (PreparedStatement statement = connection.prepareStatement(creatSql)) {
                    statement.execute();
                }

                // 데이터 추가 (statement 생성)
                String insertSql = "INSERT INTO USERS (username) VALUES ('teasun kim')";
                try (PreparedStatement statement = connection.prepareStatement(insertSql)) {
                    statement.execute();
                }

                // 데이터 조회 (statement 생성 후 rs = resultSet 수신 & next() 조회)
                String selectSql = "SELECT * FROM USERS";
                try (PreparedStatement statement = connection.prepareStatement(selectSql)) {
                    var rs = statement.executeQuery();
                    while (rs.next()) {
                        System.out.printf("%d, %s", 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();
                }
            }
        }
    }
}

PreparedStatement란?

  • Statement를 상속하고 있는 Interface로 내부적으로 Statement의 4단계 중 첫 번째(parse) 과정의 결과를 캐싱하고 나머지 3 단계만 거쳐 SQL 문이 실행될 수 있게 한다. (성능 향상)

alt textalt text

String을 만들 때 + 연산자를 사용하는 것과 %s 등으로 포맷팅 하는 것의 차이와 비슷.

JDBC Template

JDBC로 직접 SQL을 작성했을 때는 자원 관리를 따로 해줘야 하고, 중복 코드도 자주 발생한다는 문제가 있음. 이를 해결하기 위해 Persistence Framework가 등장.

  • Persistence Framework
    • SQL Mapper: JDBC Template, MyBatis
    • ORM: JPA, Hibernate
  • SQL Mapper(QueryMapper)
    • SQL <-> Object
    • SQL 문과 객체의 필드를 매핑하여 데이터를 객체화
  • RowMapper
    • 쿼리 수행 결과와 객체 필드 매핑
    • 응답필드 매핑코드 재사용.
// UserRowMapper.java
import java.sql.ResultSet;
import java.sql.SQLException;

import org.springframework.jdbc.core.RowMapper;


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

// 레지스토리
// 사용자 ID로 User 조회 (Read)
public User findUserById(Long id) {
    return jdbcTemplate.queryForObject(
        "SELECT * FROM users WHERE id = ?",
        new UserRowMapper(),  // 이자리에 매퍼를 생성자로 넣어주면 됨
        id
    );
}

3주차 RawJPA 기본

쿼리 파일 만들기 (QueryMapper)

MyBatis

  • 코드의 설정(Connection) 부분을 줄여 반복적인 JDBC 프로그래밍 단순화.
  • SQL 쿼리를 XML 파일에 작성하여 코드와 SQL 분리.
  • 다른 방식에 비해 객체 보다는 쿼리에 집중 가능.

단점

  • 결국 SQL을 직접 작성해야 한다는 한계가 있음.
  • DB타입 및 테이블에 종속적임.

쿼리 파일

<!-- UserMapper.xml -->
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.thesun4sky.querymapper.mapper.UserMapper">
    <select id="selectUserById" resultType="User">
        select id, name from users where id = #{id}
    </select>
</mapper>

Dao 클래스 정의

// UserDao.java
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Component;

import com.thesun4sky.querymapper.domain.User;

@Component
public class UserDao {

  // SqlSession 멤버 변수로 사용하며 쿼리파일 수행 요청
  private final SqlSession sqlSession;

  public UserDao(SqlSession sqlSession) {
    this.sqlSession = sqlSession;
  }

  public User selectUserById(long id) {
    return this.sqlSession.selectOne("selectUserById", id);
  }

}

select id(selectUserById)를 sqlSession.selectOne의 첫 파라미터로 넣는다. 다음 파라미터부터는 쿼리의 변수들을 받는다.

Mapper Interface 정의

// UserMapper.java
@Mapper
public interface UserMapper {

  User selectUserById(@Param("id") Long id);

}

select id(selectUserById)와 메서드 이름을 맞춘다. 파라미터로는 쿼리의 변수들을 받는다.

쿼리 코드 만들기 (JpaRepository)

ORM은 테이블을 하나의 객체와 대응시킨다.

테이블과 객체를 매핑할 때 문제점

  • 상속의 문제
    • 문제점: 객체에는 상속관계를 맺을 수 있으나 RDB는 모두 독립적으로 존재함.
    • 해결법: 매핑정보에 상속정보를 넣어줌. (@OneToMany, @ManyToOne)
  • 관계 문제
    • 문제점: 참조를 통해 관계를 가지며 방향을 가지나 RDB는 FK를 설정해 Join해야 참조 가능.
    • 해결법: 매핑정보에 방향정보를 넣어줌. (@JoinColumn, @MappedBy)
  • 탐색 문제
    • 문제점: 객체는 참조를 통해 다른 객체로 순차적 탐색 가능하나 RDB는 참조하는 만큼 추가 쿼리나 Join이 발샘함.
    • 해결법: 매핑/조회 정보로 참조탐색 시점을 관리함. (@FetchType, fetchJoin())
  • 밀도 문제
    • 문제점: 객체는 멤버 객체크기가 각양각색이나 RDB는 기본 데이터 타입만 존재함.
    • 해결법: 크기가 큰 멤버 객체는 테이블을 분리하여 상속으로 처리. (@embedded)
  • 식별성 문제
    • 문제점: 객체는 hashCode 또는 equals() 메소드를 통해 식하나 RDB는 PK로만 식별함.
    • 해결법: PK를 객체 Id로 설정하고 EntityManager는 해당 값으로 객체를 식별하여 관리 함. (@Id, @GeneratedValue)

영속성 컨텍스트(1차 캐시)를 활용한 쓰기지연

(영속성에 대한 정리는 많이 했었으므로 자세한 내용 생략!)

영속성이란 데이터를 생성한 프로그램이 종료되어도 사라지지 않는 데이터의 특성을 말한다.

alt text

영속성 4가지 상태 (비영속 -> 영속 -> 준영속 / 삭제)

alt text

단 키 생성전략이 generationType.IDENTITY 로 설정 되어있는 경우 중복키 생성을 방지하기 위해 단일 쿼리로 수행하며 쓰기 지연이 발생하지 않는다.

JpaRepository

JpaRepository는 @NotRepositoryBean 된 인터페이스들을 상속 받고있음. Bean으로 등록하지 않을 것이라는 뜻으로 상속받으면 생성돼서 사용.

테이블 객체 이해하기

Raw JPA 타입 매핑 기능

  • @Entity
    • 객체 관점에서의 테이블
  • @Table
    • RDB의 테이블 이름. 기본값은 Entity 이름
  • @Id
    • 엔티티의 주키를 매핑할 때 사용
    • 자바의 모든 primitive 타입과 그 래퍼 클래스 사용 가능.
    • Date, BigDecimal, BigInteger도 사용 가능.
  • @GeneratedValue
    • TABLE, SEQUENCE, IDENTITY 중 하나
  • @Column
    • uniqu, nullable, length, columnDefinition... DB의 설정 넣기 위해 사용
  • @Temparal
    • 시간, 날짜 관련
  • @Transient
    • 칼럼으로 매핑하고 싶지 않은 변수에 사용

Raw JPA 필드 매핑 기능

  • 기본 타입
    • @Column: DB의 설정 넣기 위해 사용. Class에 @Entity가 붙어있으면 자동으로 필드에 @Column이 붙음.
    • @Enumerated: Enum 매핑용도로 쓰임. @Enumerated(EnumType.STRING) 으로 사용 권장. Default의 경우 순서에 영향을 받는 문제 있음.
  • Composite Value 타입
    • @Embeddable: 복합 값 객체로 사용할 클래스 지정
    • @Embedded: 복합 값 객체 적용할 필드 지정
    • @AttributeOverrides: 복합 값 객체 여러개 지정
    • @AttributeOverride: 복합 값 객체 필드명 선언
    • @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @Setter @Getter public class Member{ @Id @GeneratedValue @Column(name = "MEMBER_ID") private Long id;
    • @Embeddable public class Address { private String city; private String street; }
    private String name;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name = "city", column = @Column(name = "home_city")),
            @AttributeOverride(name = "street", column = @Column(name = "home_street")),
    })
    private Address homeAddress;


    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name = "city", column = @Column(name = "company_city")),
            @AttributeOverride(name = "street", column = @Column(name = "company_street")),
    })
    private Address companyAddress;

}
```
  • Collection Value 타입
    • 현업에선 잘 사용하지 않음. 일대다 연관관계로 풀어 씀.
    • @ElementCollection

테이블 객체 만들기

템플릿

/**
 * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
 */


/**
 * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
 */


/**
 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
 */


/**
 * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
 */


/**
 * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
 */

샘플

// User.java
// lombok
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString

// jpa
@Entity
@Table(name = "users")
public class User {

  /**
   * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
   */
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id")
  private Long id;

  private String username;

  private String password;

  /**
   * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
   */
  @Builder
  public User(String username, String password) {
    this.username = username;
    this.password = password;
  }

  /**
   * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
   */
  @OneToMany
  @Exclude
  private Set<UserChannel> userChannel;

  /**
   * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
   */

  /**
   * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
   */
  public void updateUserName(String username) {
    this.username = username;
  }

  public void updatePassword(String password) {
    this.password = password;
  }
}

테이블 객체끼리 관계만들기

  • @OneToOne
  • @OneToMany
    • 단방향으로 쓰이면 문제가 발생할 수 있음.
    • 속성: mappedBy, fetch(기본 LAZY), cascade, targetEntity...
  • @ManyToOne
    • optional(default true): false로 설정하면 연관된 엔티티가 반드시 있어야 함.
    • fetch: 기본 EGEAR이나 LAZY 추천.
    • cascade: 영속성 전이
    • targetEntity: 연관된 엔티티의 타입 정보 설정
  • @ManyToMany
    • 실무에서는 사용하지 않음. 중간 테이블 생성.

4주차 SpringData JPA 기본

테이블 객체 다루는법

Cascade (영속성 전이)

  • 부모 엔티티(OneToMany, OneToOne이 있는 곳)에서 사용
  • 양쪽 엔티티의 라이프사이클이 비슷할 경우
  • 대상 엔티티로의 영속성 전이는 현재 엔티티에서만 전이 되어야 함
  • 옵션 종류
    • ALL: 전체 상태 전이
    • PERSIST: 저장 상태 전이
    • REMOVE: 삭제 상태 전이
    • MERGE: 업데이트 상태 전이
    • REFRESH: 갱신 상태 전이
    • DETACH: 비영속성 상태 전이

orhanRemoval (고아 객체 제거)

  • 부모 엔티티에서 사용
  • 부모 객체에서 리스트 요소를 삭제했을 때 자식 객체는 매핑정보가 없어져 고아 객체가 되므로 삭제.

보통 둘 다 같이 사용.

Fetch (조회시점)

  • Entity에 FetchType으로 설정
  • 기본적으로 LAZY를 설정하고 필요할 때만 fetch Join을 수행하는 것이 좋다.
  • 항상 같이 쓰일 경우에는 EAGER를 사용하자.

테이블 객체로 자동 쿼리 생성하기

구조와 작동 원리

alt text

@SpringBootApplication을 통해 자동으로 붙여지는 @EnableJpaRepositories의 JpaRepositoriesRegistrar를 통해 등록된다. JrpRepositoriesRegistrar는 ImportBeanDefinitionRegistrar의 구현체로 프로그래밍을 통해 빈을 주입해준다.

JpaRepository 쿼리 사용 방법

리턴타입 {접두어}{도입부}By{프로퍼티 표현식}(조건식)(And|Or){프로퍼티 표현식}(조건식) (매개변수...)

   
접두어 Find, Get, Query, Count, ...
도입부 Distinct, First(N), Top(N)
프로퍼티 표현식 Person.Address.ZipCode => find(Person)ByAddress_ZipCode(...)
조건식 IgnoreCase, Between, LessThan, GreaterThan, Like, Contains, ...
정렬 조건 OrderBy{프로퍼티}Asc
리턴 타입 E, Optional, List, Page, Slice, Stream
매개변수 Pageable, Sort

예시 코드

// 기본
List<User> findByNameAndPassword(String name, String password);

// distinct (중복제거)
List<User> findDistinctUserByNameOrPassword(String name, String password);
List<User> findUserDistinctByNameOrPassword(String name, String password);

// ignoring case (대소문자 무시)
List<User> findByNameIgnoreCase(String name);
List<User> findByNameAndPasswordAllIgnoreCase(String name, String password);

// 정렬
List<Person> findByNameOrderByNameAsc(String name);
List<Person> findByNameOrderByNameDesc(String name);

// 페이징
Page<User> findByName(String name, Pageable pageable);  // Page 는 카운트쿼리 수행됨
Slice<User> findByName(String name, Pageable pageable); // Slice 는 카운트쿼리 수행안됨
List<User> findByName(String name, Sort sort);
List<User> findByName(String name, Pageable pageable);

// 스트림 (stream 다쓴후 자원 해제 해줘야하므로 try with resource 사용추천)
Stream<User> readAllByNameNotNull();

JpaRepository 효율적으로 사용하는 방법

default 메서드를 사용하여 변형한 jpa 쿼리 메서드를 사용할 수 있다.

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

메서드명 간소화

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

비즈니스 로직 통합(예시를 위한 코드로 현업에서는 서비스 레이어에서 처리)

public interface UserRepository extends JpaRepository<User, Long> {

// 사용자 ID로 사용자를 찾고, 존재할 경우 연락처 정보를 업데이트하는 메소드
default void updateUserContact(Long userId, String newContact) {
        findById(userId).ifPresent(user -> {
            user.setContact(newContact);
            save(user);
        });
    }
}

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

페이징 요청: Pageable

Pageable 만드는 법

PageRequest.of(int page, int size) : 0부터 시작하는 페이지 번호와 개수. 정렬이 지정되지 않음
PageRequest.of(int page, int size, Sort sort) : 페이지 번호와 개수, 정렬 관련 정보
PageRequest.of(int page int size, Sort sort, Direction direction, String ... props) : 0부터 시작하는 페이지 번호와 개수, 정렬의 방향과 정렬 기준 필드들

Pageable 메서드

pageable.getTotalPages() : 총 페이지 수
pageable.getTotalElements() : 전체 개수
pageable.getNumber() : 현재 페이지 번호
pageable.getSize() : 페이지 당 데이터 개수
pageable.hasnext() : 다음 페이지 존재 여부
pageable.isFirst() : 시작페이지 여부
pageable.getContent(), PageRequest.get() : 실제 컨텐츠를 가지고 오는 메서드. getContext는 List<Entity> 반환, get()은 Stream<Entity> 반환

페이징 응답

  • Page: 게시판 형태의 페이징에서도 사용됨. 전체 요소 갯수도 함께 조회.
  • `{ "content": [ {"id": 1, "username": "User 0", "address": "Korea", "age": 0}, ... {"id": 5, "username": "User 4", "address": "Korea", "age": 4} ], "pageable": { "sort": { "sorted": false, // 정렬 상태 "unsorted": true, "empty": true }, "pageSize": 5, // 페이지 크기 "pageNumber": 0, // 페이지 번호 (0번 부터 시작) "offset": 0, // 해당 페이지의 첫번째 요소의 전체 순번 (다음 페이지에서는 5) "paged": true, "unpaged": false }, "totalPages": 20, // 페이지로 제공되는 총 페이지 수 "totalElements": 100, // 모든 페이지에 존재하는 총 원소 수 "last": false, // 마지막 페이지 여부 "number": 0, "sort": { "sorted": false, // 정렬 사용 여부 "unsorted": true, "empty": true }, "size": 5, // Contents 사이즈 "numberOfElements": 5, // Contents 의 원소 수 "first": true, // 첫페이지 여부 "empty": false // 공백 여부 }`
  • Slice: 더보기 형태의 페이징에서 사용됨. 전체 요소 갯수 대신 offset 필드로 조회 가능하나 성능이 안 좋아서 현업에서 안 씀.
`{ "content": [ { "id": 13, "username": "User 12", "address": "Korea", "age": 12 }, ... { "id": 16, "username": "User 15", "address": "Korea", "age": 15 } ], "pageable": { "sort": { "sorted": false, "unsorted": true, "empty": true }, "pageNumber": 3, "pageSize": 4, "offset": 12, "paged": true, "unpaged": false }, "number": 3, "numberOfElements": 4, "first": false, "last": false, "size": 4, "sort": { "sorted": false, "unsorted": true, "empty": true }, "empty": false }`
  • List: 전체 목록보기 형태의 페이징에서 사용.

정렬

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 생성시 추가

컬럼이 아닌 값도 Alias를 기준으로 정렬 가능.

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

SQL 함수도 사용 가능: JpaSort 사용

// 아래와 같이 일반적인 쿼리에서
@Query("SELECT u FROM user u WHERE u.username = ?1") // 이건 없어도됨
List<User> findByUsername(String username, Sort sort);
// 이렇게 쿼리함수 LENGTH() 조건을 걸어서 password 문자길이 기준으로 정렬할 수 있다.
List<User> users = findByUsername("user", JpaSort.unsafe("LENGTH(password)"));

테이블 객체로 수동 쿼리 생성하기

Query

@Query의 인자값으로 쿼리 작성 가능.

  • 테이블명이 아니라 Entity 명으로 작성한다.
  • ?변수순번, :변수명 두 가지 사용 방법이 있다.
public interface UserRepository extends JpaRepository<User, Long> { @Query("SELECT u, u.password AS customField FROM User u WHERE u.username = ?1") List<User> findByUsernameWithCustomField(String username, Sort sort);`

@Query("SELECT u FROM User u WHERE u.username = :username")  
List findByUsername(String username, Sort sort);  
}

5주차 SpringDataJPA 심화

QueryDSL

Entity 의 매핑정보를 활용하여 쿼리에 적합하도록 쿼리 전용 클래스(Q클래스)로 재구성해주는 기술.
JPAQueryFactory는 Q클래스를 통해 객체 또는 함수로 쿼리를 작성하고 실행하게 해주는 기술로 Q클래스를 활용할 수 있는 기능들을 제공함.


@PersistenceContext  
EntityManager em;

public List selectUserByUsernameAndPassword(String username, String password){  
JPAQueryFactory jqf = new JPAQueryFactory(em);  
QUser user = QUser.user;

List<Person> userList = jpf
                            .selectFrom(user)
                            .where(person.username.eq(username)
                                .and(person.password.eq(password))
                            .fetch();

return userList;

}

config


// configuration 패키지안에 추가

@Configuration  
public class JPAConfiguration {

@PersistenceContext  
private EntityManager entityManager;

@Bean  
public JPAQueryFactory jpaQueryFactory() {  
return new JPAQueryFactory(entityManager);  
}  
}

Auditing

엔티티를 언제 생성/마지막 수정 했는지 자동으로 기록

  • 메인 애플리케이션 위에 @EnableJpaAuditing 추가
  • 엔티티 클래스 위에 @EntityListeners(AuditingEntityListener.class) 추가

@CreatedDate  
private Date created;

@LastModifiedDate  
private Date updated;

@CreatedBy  
@ManyToOne  
private Account createdBy;

@LastModifiedBy  
@ManyToOne  
private Account updatedBy;

방명록 기능에서 작성/수정한 사람을 찾기 위해 AuditorAware 구현체를 만들 수 있음.

  • SecurityContextHolder에서 UserDetailsImpl을 사용해 user 객체 가져오기.

@Service  
public class UserAuditorAware implements AuditorAware {  
@Override  
public Optional getCurrentAuditor() {  
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
    return Optional.empty();
}

return Optional.of(((UserDetailsImpl) authentication.getPrincipal()).getUser());

}

}

```

Dynamic Insert/Update

@DynamicInsert

  • Insert 문에 null인 칼럼 제외.

@DynamicUpdate

  • update 문에 null인 칼럼 제외.

불필요한 칼럼이 들어가는 것을 막아 최적화.

 

Fixture Monkey란?

 

Fixture Monkey

 

naver.github.io

 

나는 새로운 프레임워크, 라이브러리 등을 공부할 때 꼭 도큐먼트부터 찾아간다.

블로그 글 여럿 보는 것보다 Docs 한 번이 더 도움 된다.

게다가 Fixture Monkey는 네이버에서 개발된 라이브러리인 만큼 한국어도 지원한다!

 

그래서 픽쳐 몽키란 무엇인가?

Fixture Monkey는 테스트 객체를 쉽게 생성하고 조작할 수 있도록 고안된 Java 및 Kotlin 라이브러리입니다.

 

내 식으로 말하자면 "테스트 코드 객체 생성 노가다를 줄여주는 라이브러리"이다.

 

 

종속성

  • JDK 1.8 이상 (또는 Kotlin 1.8 이상)
  • JUnit 5 platform

build.gradle

testImplementation 'com.navercorp.fixturemonkey:fixture-monkey-starter:1.0.14'

 

 

테스트 객체 생성 전략

객체 생성 전략은 FixtureMonkey 인스턴스를 만들 때 정의할 수 있다.
기본 객체 생성 전략은 BeanArbitraryIntrospector이다.
FixtureMonkey fixtureMonkey = FixtureMonkey.create();
FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
    + options...
    .build();

FixtureMonkey.create()를 사용하면 기본 전략인 BeanArbitrayIntrospector로 설정된다.

builder를 통해 직접 객체 생성 전략을 정의할 수 있다.

 

FixtureMonkey 인스턴스를 생성할 때 말고 테스트 객체를 생성할 때도 .giveMeBuilder와  .instantiate를 통해 생성 방법을 지정할 수도 있는데 자세한 내용은 아래 페이지에서 확인할 수 있다.

 

객체 생성 방법 지정하기

각 테스트마다 객체 생성을 다르게 하고 싶을 수 있습니다. 예를 들어, 같은 클래스에서도 첫 테스트에서는 생성자로 객체를 생성하고, 다른 테스트에서는 팩터리 메서드로 객체를 생성하고 싶

naver.github.io

 

1. setter + 기본 생성자를 사용할 경우

@Getter
@Setter
@ToString
public class PostCreateRequestDto {
    @NotBlank
    private String title;

    @NotBlank
    private String contents;
}

기본 생성자와 setter를 사용할 경우에는 BeanArbitraryIntrospector 생성 전략을 사용한다.

 

FixtureMonkey fixtureMonkey = FixtureMonkey.create();

FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
    .objectIntrospector(BeanArbitraryIntrospector.INSTANCE)
    .build();

FixtureMonkey.create()로 인스턴스를 만들어도 되고,

아래에 BeanArbitraryIntrospector.INSTANCE로 정의해 줄 수도 있다. (결과는 같다.)

 

2. builder를 사용할 경우

builder를 사용할 경우에는 BuilderArbitraryIntrospector 생성 전략을 사용한다.

FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
    .objectIntrospector(BuilderArbitraryIntrospector.INSTANCE)
    .build();

 

3. 주어진 생성자 사용 + Lombok 사용

주어진 생성자를 사용하며 Lombok을 사용한다면 ConstructorPropertiesArbitraryIntrospector 생성 전략을 사용할 수 있다.

대신 lombok.config 파일에 lombok.anyConstructor.addConstructorProperties=true를 추가해야 한다.

lombok.config는 최상단에 파일을 생성하면 된다.

이 기능은 생성자에 @ConstructorProperties를 추가해주는 역할을 한다.

FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
    .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE)
    .build();

 

4. 리플렉션을 사용

FieldReflectionArbitraryIntrospector

FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
    .objectIntrospector(FieldReflectionArbitraryIntrospector.INSTANCE)
    .build();

 

5. 두 개 이상의 생성 전략 사용

FailoverArbitraryIntrospector

FixtureMonkey sut = FixtureMonkey.builder()
    .objectIntrospector(new FailoverIntrospector(
        Arrays.asList(
            FieldReflectionArbitraryIntrospector.INSTANCE,
            ConstructorPropertiesArbitraryIntrospector.INSTANCE
        )
    ))
    .build();

 

 

테스트 객체 생성

giveMeOne() - 객체 하나 생성

특정 타입 인스턴스를 하나 생성할 때는 giveMeOne()을 사용한다.

Product product = fixtureMonkey.giveMeOne(Product.class);

List<String> strList = fixtureMonkey.giveMeOne(new TypeReference<List<String>>() {});

인자로 클래스 혹은 타입이 들어간다.

 

giveMe() - 객체 여러 개 생성

특정 타입 인스턴스를 여러 개 생성할 때는 giveMe()를 사용한다.

스트림 또는 리스트를 생성한다.

Stream<Product> productStream = fixtureMonkey.giveMe(Product.class);

Stream<List<String>> strListStream = fixtureMonkey.giveMe(new TypeReference<List<String>>() {});

List<Product> productList = fixtureMonkey.giveMe(Product.class, 3);

List<List<String>> strListList = fixtureMonkey.giveMe(new TypeReference<List<String>>() {}, 3);

인자로 클래스 혹은 타입은 필수이며, 크기를 지정할 수도 있다.

 

giveMeBuilder() - 인스턴스 커스텀

인스턴스를 커스텀 할 때는 giveMeBuilder()를 사용한다.

PostCreateRequestDto requestDto = fixtureMonkey.giveMeBuilder(PostCreateRequestDto.class)
        .set("title", "제목")
        .set("contents", "내용")
        .sample();

인자로 클래스 혹은 타입이 들어간다.

giveMeBuilder 메서드는 ArbitraryBuilder를 반환하는데 set 메서드를 통해 필드 내용을 직접 정의할 수 있다.

sample 메서드로 해당 클래스 객체를 반환받는다.

 

 

커스터마이징

기본적으로 아~주 랜덤한 값이 주어진다.

심지어 간헐적으로 null 값이 들어가기도 해서 골치가 아팠었다.

내가 뭘 잘못한 줄 ㅠㅠ

 

null, not null을 적용하거나 size를 제한하는 등 Validation에 필요한 내용을 적용하고 싶다면

커스터마이징 기능을 사용하면 된다.

 

대표적으로 Null, NotNull 기능을 살펴보자.

fixtureMonkey.giveMeBuilder(Product.class)
    .setNull("id");

fixtureMonkey.giveMeBuilder(Product.class)
    .setNotNull("id");

giveMeBuilder에서 설정해 줄 수 있다.

 

커스터마이징 부분은 기능이 많기 때문에 자세한 내용은 docs를 참고하자.

 

커스터마이징 API

Fixture Monkey는 ArbitraryBuilder를 통해 생성된 객체를 커스텀할 수 있는 다양한 API를 제공합니다. 픽스쳐 커스터마이징하기 # set() # set() 메서드는 표현식에 참조된 하나 이상의 프로퍼티에 값을 설정

naver.github.io

 

 

사용 후기

직접 값을 세팅해 줄 때보다 여러 케이스를 테스트할 수 있을 뿐만 아니라 코드가 굉장히 깔끔해졌다.

사용 법도 간단하고 무엇보다도 Docs사 한국어기 때문에 앞으로 테스트 코드를 작성할 때 항상 사용하게 될 것 같다.

'Spring' 카테고리의 다른 글

QueryDSL의 null처리  (0) 2024.09.23
[Spring] JPA 심화 강의 정리  (0) 2024.06.26
[Spring] DB 테스트 환경 분리  (0) 2024.06.13
[Spring] AOP란 무엇일까? (+Spring AOP)  (0) 2024.06.12
[Spring] JUnit 5 단위 테스트  (0) 2024.06.11

 

 

 

 

 

테스트 환경 분리 - application.yml

테스트 환경 중에서도 DB를 분리해서 사용하였다.

그러다보니 application.yml 파일을 test용으로 분리해야 했다.

 

분리하는 방법

기존 파일을 복사해 테스트 용으로 설정을 바꿨다.

test 폴더 아래에 resources 폴더를 만든다.

그 아래에 application.yml 파일을 옮기면 test 클래스는 이 파일로 인식하게 된다.

 

변경할 것

spring:
  config.activate.on-profile: default
  application:
    name: GameBlog
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    password: ${MYSQL_PASSWORD}
    url: jdbc:mysql://127.0.0.1:3307/blog_test
    username: ${MYSQL_USER}

test용 DB를 사용할 것이기 때문에 url을 변경하였다.

우선 DB 이름을 바꿨고, 원래 사용하던 3306 포트 대신 3307 포트를 사용하였다.

properties 파일을 쓰더라도 똑같이 수정해주면 된다.

 

테스트 환경 분리 - docker

version: "3.8"
services:
  db:
    image: mysql:8.0.28
    platform: linux/x86_64
    restart: always
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: 루트 패스워드
      MYSQL_DATABASE: blog
      MYSQL_USER: user
      MYSQL_PASSWORD: 패스워드
      TZ: Asia/Seoul

  db-test:
    image: mysql:8.0.28
    platform: linux/x86_64
    restart: always
    ports:
      - "3307:3306"
    environment:
      MYSQL_ROOT_PASSWORD: 루트 패스워드
      MYSQL_DATABASE: blog_test
      MYSQL_USER: user
      MYSQL_PASSWORD: 패스워드
      TZ: Asia/Seoul

docker-compose.yml 파일도 수정했다.

여기서도 test DB의 포트 번호를 바꾸고 DB명도 바꿨다.

docker-compose up -d

그리고 다시 위 명령어로 컨테이너를 올린다.

 

그러면 이렇게 두 개의 db가 올라가게 된다.

'Spring' 카테고리의 다른 글

[Spring] JPA 심화 강의 정리  (0) 2024.06.26
[Spring] 🙉Fixture Monkey🙉  (1) 2024.06.15
[Spring] AOP란 무엇일까? (+Spring AOP)  (0) 2024.06.12
[Spring] JUnit 5 단위 테스트  (0) 2024.06.11
[Spring] Naver, Google 로그인 (OAuth2)  (0) 2024.06.10

 

 

AOP

 

AOP( Aspect-Oriented Programming)
관점 지향 프로그래밍

 

다수의 모듈에 공통적으로 나타나는 부분이 존재하는데 이를 횡단 관심사(cross-cutting concern)라고 한다.
개발자들은 중복 코드를 싫어하기 때문에 이런 횡단 관심사는 단 한 번만 작성하고 싶다.
그때 AOP를 사용한다.
@Aspect
public class MyAspect {
    @Before("execution(public void aop002.Boy.runSomething ())")
    public void before(JoinPoint joinPoint) {
        System.out.println("얼굴 인식 확인: 문을 개방하라");
    }
}

@Aspect 어노테이션은 이 클래스를 AOP에서 사용하겠다는 뜻

@Before 어노테이션은 대상 메서드 실행 전에 이 메서드를 실행하겠다는 의미다.

 

중요한 것은 아래 세 개다.

  1. 스프링 AOP는 인터페이스 기반이다.
  2. 스프링 AOP는 프록시 기반이다.
  3. 스프링 AOP는 런타임 기반이다.

예시에서는 @Before을 사용했지만 프록시가 개입할 수 있는 시점은 5가지이다.

  • Arround
  • Before
  • After
  • AfterReturning
  • AfterThrowing

 

 

용어

Pointcut
  • Aspect 적용 위치 지정자 : public void aop002.Person.runSomething()
    • 여기서는 정규식과 AspectJ 표현식 등을 사용할 수 있다.
    • [접근제한자패턴] 리턴타입패턴 [패키지&클래스패턴] 매서드이름패턴(파라미터패턴) [throws 예외패턴]
    • [] 대괄호는 생략이 가능하다.
@Aspect
public class MyAspect {
    @Before("execution(* runSometing())")
    public void before(JoinPoint joinPoint) {
        System.out.println("얼굴 인식 확인: 문을 개방하라");
    }
}

*표시는 와일드 카드로 어떤 클래스의 runSometime 메서드이든 주입할 수 있다.

 

@Aspect
public class MyAspect {
    @Before("execution(* aop002.*")
    public void before(JoinPoint joinPoint) {
        System.out.println("얼굴 인식 확인: 문을 개방하라");
    }
}

@Aspect
public class MyAspect {
    @Before("execution(* aop002..")
    public void before(JoinPoint joinPoint) {
        System.out.println("얼굴 인식 확인: 문을 개방하라");
    }
}

*은 패키지 하위를 나타낼 때도 찍을 수 있으며 * aop002.*는 패키지의 모든 클래스에 적용함을 나타낸다.

비슷하게 ..는 * aop002..와 같이 사용하며 모든 하위 패키지의 모든 클래스에 적용한다.

 

@Aspect
public class MyAspect {
    @Before("execution(* run*")
    public void before(JoinPoint joinPoint) {
        System.out.println("얼굴 인식 확인: 문을 개방하라");
    }
}

메서드 이름에도 적용 가능하다.

run으로 시작하는 메서드에 적용하겠다는 뜻이다.

 

@Pointcut("execution(* aop002.Boy.*(..))")
private void boy() {}

@Before("boy()")
public void before(JoinPoint joinPoint) {
    System.out.println("얼굴 인식 확인: 문을 개방하라");
}

이렇게 포인트컷을 미리 만들어두고 사용할 수도 있다.

 

마지막으로 파라미터 패턴도 존재한다.

  • () - 인수 없음
  • (*) - 인수 1개 (타입 상관없음)
  • (..) - 인수 0~N개 (타입 상관없음)

따라서 위의 예시는 인수가 0개거나 N개인 메서드에 적용하겠다는 것을 나타낸다.

 

JoinPoint
  • 연결점 : JoinPoint joinPoint
    • Aspect 적용이 가능한 모든 지점. 이 중 Pointcut에만 적용하는 것이니 Pointcut은 JoinPoint의 부분집합인 셈이다.
    • 스프링 AOP에서는 스프링 프레임워크가 관리하는 빈의 모든 메서드에 해당한다.
    • Pointcut의 메서드가 호출되면 JointPoint에 그 메서드가 들어올 것이다.

 

Advice
  • 언제 무엇을 : @Before() public void before(JoinPoint joinPoint) { System.out.println("...");}
    • pointcut에 적용할 로직과 적용 시점

 

 

동작 원리

Spring이 프록시객체를 중간에 삽입해준다.

DispatcherServlet 과 ProductController 입장에서는 변화가 전혀 없다.

  • 호출되는 함수의 input, output 이 완전 동일.
  • "joinPoint.proceed()" 에 의해서 원래 호출하려고 했던 함수, 인수(argument) 가 전달됨.
  • → createProduct(requestDto);

'Spring' 카테고리의 다른 글

[Spring] 🙉Fixture Monkey🙉  (1) 2024.06.15
[Spring] DB 테스트 환경 분리  (0) 2024.06.13
[Spring] JUnit 5 단위 테스트  (0) 2024.06.11
[Spring] Naver, Google 로그인 (OAuth2)  (0) 2024.06.10
[Spring] Naver 로그인 (Oauth2 X)  (1) 2024.06.08

 

JUnit 5

자바 프로그래밍 언어 용 단위 테스트 프레임워크
단위 테스트는 각 단위가 정확하게 동작하는지를 검사하는 테스트 기법으로 빠르게 작성할 수 있고 문제 발생 시 잘못된 부분을 손쉽게 장점이 있다.

 

 

JUnit 5 User Guide

Although the JUnit Jupiter programming model and extension model do not support JUnit 4 features such as Rules and Runners natively, it is not expected that source code maintainers will need to update all of their existing tests, test extensions, and custo

junit.org

 

 

Befor After

JUnit 테스트에 대하여 테스트의 실행/종료 시 호출
@BeforeEach
void setUp() {
    System.out.println("각각의 테스트 코드가 실행되기 전에 수행");
}

@AfterEach
void tearDown() {
    System.out.println("각각의 테스트 코드가 실행된 후에 수행\n");
}

@BeforeAll
static void beforeAll() {
    System.out.println("모든 테스트 코드가 실행되기 전에 최초로 수행\n");
}

@AfterAll
static void afterAll() {
    System.out.println("모든 테스트 코드가 수행된 후 마지막으로 수행");
}

테스트1 테스트 2라는 이름의 테스트가 있을 경우,

  1. beforeAll
  2. setUp
  3. 테스트1
  4. tearDown
  5. setUp
  6. 테스트2
  7. tearDown
  8. afterAll

순서로 실행되게 된다.

 

 

테스트 꾸미기 - DisplayName, Nested, TestMethodOrder

@DisplayName
테스트 네이밍
@Test
@DisplayName("테스트의 내용을 한눈에 알아볼 수 있게 네이밍 해줄 때")
void test1() {
    System.out.println("테스트 내용 빠르게 파악");
}

 

@Nested
테스트 그룹화 (클래스 화)
@Nested
@DisplayName("Test2 다른 주제")
class Test2 {
    @Test
    @DisplayName("Test2 - test1()")
    void test1() {
        System.out.println("Test2.test1");
    }

    @Test
    @DisplayName("Test2 - test2()")
    void test2() {
        System.out.println("Test2.test2");
    }
}

 

@TestMethodOrder, @Order
Order로 테스트 실행 순서 지정
@Nested
@DisplayName("주제 별로 테스트를 그룹지어서 파악하기 좋습니다.")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class Test1 {

    @Order(1)
    @Test
    @DisplayName("Test1 클래스")
    void test() {
        System.out.println("\nTest1 클래스");
    }

    @Order(3)
    @Test
    @DisplayName("Test1 - test1()")
    void test1() {
        System.out.println("Test1.test1");
    }

    @Order(2)
    @Test
    @DisplayName("Test1 - test2()")
    void test2() {
        System.out.println("Test1.test2");
    }
}

 

 

테스트 반복 - RepeatedTest, ParameterizedTest

@RepeatedTest
value만큼 테스트 반복
@RepeatedTest(value = 5, name = "반복 테스트 {currentRepetition} / {totalRepetitions}")
void repeatTest(RepetitionInfo info) {
    System.out.println("테스트 반복 : " + info.getCurrentRepetition() + " / " + info.getTotalRepetitions());
}

 

@ParameterizedTest, @ValueSource
ValueSource에서 각 반복 회차의 파라미터 값을 넣어줄 수 있다.
@DisplayName("파라미터 값 활용하여 테스트 하기")
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})
void parameterTest(int num) {
    System.out.println("5 * num = " + 5 * num);
}

반복 기능으로 분류하였으나 정확히는 파라미터 값을 넣을 수 있다는 것에 의미가 있다.

 

 

Assertion

실행 결과를 예측하고 검사한다.
assertEquals, assertNotEquals
값1, 2의 값이 동일한가? 동일하지 않은가?
@Test
@DisplayName("assertEquals")
void test1() {
    assertEquals(2.5, 5.0 / 2.0);
}
@Test
@DisplayName("assertNotEquals")
void test1_2() {
    assertNotEquals(2.5, 2);
}

 

assertTrue, assertFalse
값이 true인가? false인가?
@Test
@DisplayName("assertTrue 와 assertFalse")
void test2() {
    assertTrue(9 == 9);
    assertFalse(0 == 9);
}

 

assertNotNull, assertNull
값이 null이 아닌가? null인가?
@Test
@DisplayName("assertNotNull 과 assertNull")
void test3() {
    TestObject obj1 = new TestObject();
    TestObject obj2;
    assertNotNull(obj1);
    assertNull(obj2);
}

 

assertThrows
값이 Throwable을 던지는가?
@Test
@DisplayName("assertThrows")
void test4() {
    IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> 예외를 던질 메서드...);
    assertEquals("잘못된 연산자입니다.", exception.getMessage());
}

assertThrows의 경우 결과를 검사할 뿐만 아니라 throwable 객체를 반환 받을 수 있다.

 

 

Given, When, Then 패턴

테스트 코드를 보기 좋게 작성하기 위한 표현 방식

Given

  • 테스트 하고자하는 대상을 실제로 실행하기 전에 테스트에 필요한 값(상태)을 미리 선언한다.

When

  • 테스트 하고자하는 대상을 실제로 실행 시킨다.

Then

  • 테스트 실행 시 발생할거라고 예상되는 결과에 대해 예측하고 검사한다.
  • assertion이 사용된다.
 

[내배캠][TIL] 22일 차 - 목요일, Mockito? Jacoco?

📢 오늘의 목표 📢 ✔️ 알고리즘, SQL 문제 풀이✔️ 알고리즘 코드카타✔️ SQL 코드카타✔️ 프로그래머스 Level 2✔️ 스프링 입문 개인 과제✔️ 9단계✔️ use-case-diagram✔️ 스프링 공부 - 스

mountain-noroo.tistory.com

위 링크에서 이전에 Mockito를 사용해보며 작성했던 테스트 코드가 있다.

Given - When - Then 패턴을 적용하였기 때문에 참고하면 좋을 듯!

 

OAuth2를 사용하지 않을 경우

https://mountain-noroo.tistory.com/176

 

[Spring] Naver 로그인 (Oauth2 X)

네이버 애플리케이션 등록네이버 소셜 로그인 기능을 사용하기 위해서는 네이버 디벨로퍼스에서 네이버 애플리케이션 등록이 필요하다.https://developers.naver.com/ NAVER Developers네이버 오픈 API들을

mountain-noroo.tistory.com

 

OAuth2 라이브러리를 사용하지 않을 경우는 위 글을 참고해보자.

여기서는 기본적으로 구글, 네이버의 애플리케이션 등록이 끝났음을 전제로 포스팅을 작성하겠다.

개인적으로는 OAuth2를 사용하였을 때 훨씬 간편했으나 유연성은 직접 만들었을 때를 따라잡을 수 없다고 생각한다.

 

 

OAuth2란

Open Authorization 2.0 혹은 OAuth2.0은 웹 및 애플리케이션 인증 및 권한 부여를 위한 개방형 표준 프로토콜입니다. 이 프로토콜에서는 third-party 애플리케이션이 사용자의 리소스에 접근하기 위한 절차를 정의하고 서비스 제공자의 API를 사용할 수 있는 권한을 부여합니다. 대표적으로 네이버 로그인, 구글 로그인과 같은 소셜 미디어 간편 로그인이 있습니다. OAuth2.0을 사용해 third-party 애플리케이션이 사용자의 소셜미디어 프로필 정보에 접근할 수 있도록 합니다. B2B PRISM Live Studio 역시 OAuth2.0을 사용하여 권한을 관리하기 때문에 OAuth2.0의 기본 개념을 안내하고, 권한 부여 방법을 설명합니다.

네이버 클라우드 문서에서 퍼온 글이다.

https://guide.ncloud-docs.com/docs/b2bpls-oauth2

 

표준 프로토콜을 사용해 여러 소셜 로그인을 하나의 로직으로 처리할 수 있다는 장점이 있다.

 

 

Spring Security OAuth2 라이브러리 사용하기

build.gradle 라이브러리 추가

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

 

 

application.yml

    # OAuth 로그인
    security:
        oauth2:
            client:
                registration:
                    google:
                        client-id: ${SOCIAL_GOOGLE_CLIENT_ID}
                        client-secret: ${SOCIAL_GOOGLE_CLIENT_SECRET}
                        scope:
                            - email
                            - profile
                    naver:
                        client-id: ${SOCIAL_NAVER_CLIENT_ID}
                        client-secret: ${SOCIAL_NAVER_CLIENT_SECRET}
                        scope:
                            - name
                            - email
                        client-name: Naver
                        authorization-grant-type: authorization_code
                        redirect-uri: http://localhost:8080/login/oauth2/code/naver
                provider:
                    naver:
                        authorization-uri: https://nid.naver.com/oauth2.0/authorize
                        token-uri: https://nid.naver.com/oauth2.0/token
                        user-info-uri: https://openapi.naver.com/v1/nid/me
                        user-name-attribute: response

google, naver를 사용하기 위한 설정 값들을 세팅해준다.

 

 

OAuth2User

우선 기존의 UserDetails를 구현하던 클래스에서 OAuth2User를 구현한다.

@Getter
@RequiredArgsConstructor
public class UserPrincipal implements UserDetails, OAuth2User {
    private final User user;
    private Map<String, Object> attributes;

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    // ...
}

getAttributes 구현이 필수인데 모양만 흉내내고 직접 사용하진 않았다.

이렇게 소셜 로그인 유저도 UserPrincipal이 처리할 수 있게 된다.

 

 

DefaultOAuth2UserService

DefaultOAuth2UserService를 상속하여 새로운 서비스 클래스를 만든다.

@Service
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {
    private final List<OAuth2UserInfo> oAuth2UserInfoList;
    private final UserService userService;

    public OAuth2UserServiceImpl(UserService userService) {
        this.userService = userService;
        this.oAuth2UserInfoList = List.of(new NaverOAuth2UserInfo(), new GoogleOAuth2UserInfo());
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        Assert.notNull(userRequest, "userRequest cannot be null");

        OAuth2User oAuth2User = super.loadUser(userRequest);

        String providerId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2UserInfo oAuth2UserInfo = oAuth2UserInfoList.stream()
                .filter(userInfo -> userInfo.supports(providerId))
                .findFirst()
                .orElseThrow(() -> new OAuth2AuthenticationException("지원하지 않는 provider"));

        User user;
        try {
            user = this.userService.getUserByEmailWithSnsInfo(
                    oAuth2UserInfo.getEmailFromAttributes(oAuth2User.getAttributes()),
                    () -> new OAuth2AuthenticationException("존재하지 않는 유저")
            );

            if (user.getSnsInfo() == null) {
                this.userService.addSnsInfoToUser(user, providerId);
            }
        } catch (Exception e) {
            UserSignupRequestDto userSignupRequestDto = new UserSignupRequestDto();
            userSignupRequestDto.setEmail(oAuth2UserInfo.getEmailFromAttributes(oAuth2User.getAttributes()));
            userSignupRequestDto.setName(oAuth2UserInfo.getNameFromAttributes(oAuth2User.getAttributes()));
            userSignupRequestDto.setPassword(UUID.randomUUID().toString().substring(0, 8));
            userSignupRequestDto.setRole(User.Role.NORMAL);
            userSignupRequestDto.setStatusCode(User.StatusCode.ACTIVE);
            user = this.userService.signup(userSignupRequestDto, providerId);
        }

        return new UserPrincipal(user);
    }
}

loadUser의 구현이 필수인데 이는 OAuth2User를 반환한다.

 

request에서 RegistrationId를 가져와(yml의 spring.security.oauth2.client.registration.naver.client-name) 무슨 플랫폼인지 확인한다. 플랫폼 별 반환 값의 형식, 명칭 등이 조금씩 차이가 있기 때문에 해당하는 어트리뷰트를 가져와야 하기 때문.

 

그 이후는 이전 편에서 봤던 방식과 동일하다.

 

 

SecurityConfig

        http.oauth2Login(httpSecurityOAuth2LoginConfigurer -> httpSecurityOAuth2LoginConfigurer
                .loginPage("/login.html")
                .defaultSuccessUrl("/hellowrodl")
                .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(OAuth2UserServiceImpl))
                .successHandler(oauth2SuccessHandler())
        );

oauth2Login 설정도 SecurityConfig에 추가한다.

참고로 우리 프로젝트에선 successHandler에서 토큰 발급을 처리했다.

+ Recent posts