개발/JAVA

[자바웹개발워크북] 2. 웹과 데이터베이스

독코더 2022. 12. 24. 18:10
반응형

/*

 *

 * 자바웹개발워크북의 내용을 정리하기 위한 포스팅입니다.

 * MariaDB, JDBC, Lombok, ConnectionPool, VO/DTO, CRUD예제, Log4j2

 */

 

1. @Test : 테스트코드 혹은 테스트메소드라고 하는데 @Test를 적용하는 메소드는 반드시 public으로 선언되어야 하고, 파라미터나 리턴타입이 없이 작성합니다.

 

2. Assertions.assertEquals() : 말그대로 '같다고확신한다'는 의미이고, 두변수의 내용이 같아야만 테스트가 성공하게 됩니다.

EX) Assertions.assertEquals(v1, v2);

 

3. 코드설명

@Test
public void testConnection() throws Exception {
    Class.forName("org.mariadb.jdbc.Driver");

    Connection connection = DriverManager.getConnection(
            "jdbc:mariadb://localhost:3306/webdb",
            "webuser",
            "webuser"
    );

    Assertions.assertNotNull(connection);

    connection.close();
}

▶ Class.forName() : JDBC 드라이버 클래스를 메모리상으로 로딩하는 역할을 합니다. 이때 문자열은 패키지명과 클래스명의 대소문자까지 정확히 일치해야합니다. 만일 JDBC 드라이버 파일이 없는경우에는 이 부분에서 예외가 발생합니다.

 

▶ Connection connection : java.sql패키지의 Connection 인터페이스 타입의 변수입니다. Connection은 데이터베이스와 네트워크연결을 의미합니다.

 

▶ DriverManager.getConnetion() : 데이터베이스 내에 있는 여러 정보들을 통해서 특정한 데이터베이스(EX webdb)에 연결을 시도합니다.

 

▶ -jdbc:mariadb://localhost:3306/webdb : jdbc 프로토콜을 이용한다는 의미이고, localhost:3306은 네트워크 연결정보를, webdb는 연결하려는 데이터베이스 정보를 의미합니다.

 

▶ -webuser : 연결을 위해서는 사용자의 계정과 패스워드가 필요합니다.

 

▶ Assertions.assertNotNull() : 데이터베이스와 정상적으로 연결이 된다면 Connection타입의 객체는 null이 아니라는것을 확신한다는 의미입니다.

 

▶ connection.close() : 데이터베이스와 연결을 종료합니다. JDBC프로그램은 데이터베이스와 연결을 잠깐씩 맺고 종료하는 방식으로 처리됩니다. 따라서 반드시 작업이 완료되면 데이터베이스와의 연결을 종료해주어야만 합니다.

 

4. MariaDB에서 사용하는 데이터 타입

[숫자형 데이터 타입]

타입 용도 크기 설명
TINYINT 매우 작은 정수 1byte -128 ~ 127(부호없이 0 ~ 255)
SMALLINT 작은 정수 2byte -32768 ~ 32767
MEDIUMINT 중간 크기의 정수 3byte -(-8388608) ~ -1(8388607)
INT 표준 정수 4byte -2147483648 ~ 2147483647
(0 ~ 4294967295)
BIGINT 큰 정수 8byte -2147483648 ~ 2147483647
(unsigned 0 ~ 4294967295)
FLOAT 단정도 부동 소수 4byte -9223372036854775808 ~ 9223372036854775807
(unsigned 0 ~ 18446744073709551615)
DOUBLE 배정도 부동 소수 8byte -1.7976E+320 ~ 1.7976E+320
(no unsigned)
DECIMAL(m,n) 고정 소수 m과 n에 따라 다르다 숫자데이터지만 내부적으로는 String형태로 저장됨.
최대 65자.
BIT(n) 비트 필드 m에 따라 다르다 1 ~ 64bit 표현

※ MariaDB에서 boolean값은 true/false값 대신에 0과 1로 사용하는 경우가 많으므로 tinyint타입을 이용해서 처리합니다.

 

[날짜형 데이터 타입]

데이터 타입 형태 크기 설명
DATE YYYY-MM-DD 3byte 1000-01-01 ~ 9999-12-31
DATETIME YYYY-MM-DD
hh:mm:ss
8byte 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59
TIMESTAMP YYYY-MM-DD
hh:mm:ss
4byte 1970-01-01 00:00:00 ~ 2037
TIME hh:mm:ss 3byte -839:59:59 ~ 839:59:59
YEAR YYYY 또는 YY 1byte 1901 ~ 2155

 

[문자형 데이터 타입]

데이터 타입 용도 크기 설명
CHAR(n) 고정 길이 비이진(문자) 문자열 n byte  
VARCHAR(n) 가변 길이 비이진 문자열 Length + 1byte  
BINARY(n) 고정 길이 이진 문자열 n byte  
VARBINARY(n) 가변 길이 이진 문자열 Length + 1byte or 2byte  
TINYBLOB 매우 작은 BLOB(Binary Large Object) Length + 1byte  
BLOB 작은 BLOB Length + 2byte 최대크기 64KB
MEDIUMBLOB 중간 크기 BLOB Length + 3byte 최대크기 16MB
LONGBLOB 큰 BLOB Length + 4byte 최대크기 4GB
TINYTEXT 매우 작은 비이진 문자열 Length + 1byte  
TEXT 작은 비이진 문자열 Length + 2byte 최대크기 64KB
MEDIUMTEXT 중간 크기 비이진 문자열 Length + 3byte 최대크기 16MB
LONGTEXT 큰 비이진 문자열 Length + 4byte 최대크기 4GB

 

5. JDBC프로그래밍을 위한 API와 용어들

▶ java.sql.Connection :

Connection인터페이스는 데이터베이스와 네트워크상의 연결을 의미합니다.

개발자들은 Connection이라는 인터페이스를 활용하고 실제 구현 클래스는 JDBC 드라이버 파일 내부의 클래스를 이용합니다.

JDBC 프로그래밍에서 가장 중요한 사실은 'Connection은 반드시 close()해야한다'입니다.

 

▶ java.sql.Statement/PreparedStatement :

JDBC에서 SQL을 데이터베이스로 보내기 위해서는 Statement/PreStatement타입을 이용합니다.

이둘은 SQL문을 미리 전달하고 나중에 데이터를 보내는 방식(PreparedStatement)과, SQL문 내부에 모든 데이터를 같이 전송하는 방식(Statement)이라는 차이가 있습니다.

실제 개발에서는 PreparedStatement만을 이용하는 것이 관례입니다.(SQL인젝션 공격을 막기위함)

Statement/PreparedStatement에서 중요한 기능들은 다음과 같습니다.

■ setXXX() : setInt(), setString(), setDate()와 같이 다양한 타입에 맞게 데이터를 세팅할 수 있습니다.

■ executeUpdate() : DML을 실행하고 결과를 int타입으로 반환합니다. 결과는 '몇개의 행이 영향을 받았는가'입니다.

예를들어 1개의 데이터가 insert되었는지와 같은 결과를 알 수 있습니다.

■ executeQuery() : 말그대로 쿼리를 실행할 때 사용합니다. ResultSet이라는 리턴타입을 이용합니다.

Statement역시 마지막에는 Connection과 마찬가지로 close()를 통해서 종료해 주어야만 데이터베이스 내부에서도 메모리와 같이 사용했던 자원들이 즉각적으로 정리됩니다.

 

▶ java.sql.ResultSet :

쿼리를 실행했을 때, 데이터베이스에서 반환하는 데이터를 읽어 들이기 위해서는 특별하게 ResultSet이라는 인터페이스를 이용합니다.

ResultSet은 자바코드에서 데이터를 읽어 들이기 때문에 getInt(), getString()등의 메소드를 이용합니다.

ResultSet역시 네트워크를 통해서 데이터를 읽어 들이기 때문에 작업이 끝난 후에는 반드시 close()를 해 주어야합니다.

 

▶ Connection Pool과 DataSource :

데이터베이스와의 연결을 맺는 작업은 많은 시간과 자원을 쓰기 때문에 여러번 SQL을 실행할수록 서능 저하는 피할 수 없습니다.

JDBC에서는 보통 커넥션풀이라는 것을 이용해서 이 문제를 해결합니다.

커넥션풀은 쉽게말하면 미리 커넥션들을 생성해서 보관하고, 필요할 때마다 꺼내서 쓰는방식입니다. 보관을 해놓기 때문에 데이터베이스와 연결에 걸리는 시간과 자원을 절약할 수 있으므로 실제 운영되는 웹서비스들은 커넥션풀을 기본적으로 사용합니다.

javax.sql.DataSource 인터페이스는 커넥션풀을 자바에서 API형태로 지원하는 것으로 커넥션풀을 이용하는 라이브러리들은 모두 DataSource 인터페이스를 구현하고 있으므로 이를 활용해서 JDBC코드를 작성하게 됩니다.

 

▶ DAO(Data Acess Object) :

DAO는 데이터를 전문적으로 처리하는 객체를 의미합니다. 일반적으로 데이터베이스의 접근과 처리를 전담하는 객체를 의미하는데 DAO는 주로 VO를 단위로 처리합니다. DAO를 호출하는 객체는 DAO가 내부에서 어떤식으로 데이터를 처리하는지 알 수 없도록 구성합니다.

때문에 JDBC프로그램을 작성한다는 의미는 실제로는 DAO를 작성한다는 의미가 됩니다.

 

▶ VO(Value Object) 혹은 엔티티(Entity)

객체지향 프로그램에서는 데이터를 객체라는 단위로 처리합니다.

예를들어 테이블의 한 행(row)을 자바 프로그램에서는 하나의 객체가 됩니다.

데이터베이스에서는 하나의 테이터를 엔티티라고 하는데 자바 프로그램에서는 이를 처리하기 위해서 테이블과 유사한 구조의 클래스를 만들어서 객체로 처리하는 방식을 이용합니다.

이때 만든 객체는 '값을 보관하는 용도'라는 의미에서 VO(Value Object)라고 합니다.

DTO는 getter/setter를 이용해서 자유롭게 데이터를 가공할 수 있지만 VO는 데이터 자체를 의미하기 때문에 getter만을 이용하는 경우가 대부분입니다.

 

6. Lombok라이브러리

▶ getter/setter관련 : @Getter, @Setter, @Data등을 이용해서 자동생성

▶ toString() : @ToString을 이요한 toString()메소드 자동생성

▶ equals()/hashCode() : @EqualsAndHashCode를 이용한 자동생성

▶ 생성자 자동생성 : @AllArgsConstructor, @NoArgsConstructor등을 이용한 생성자 자동생성

▶ 빌더생성 : @Builder를 이용한 빌더패턴코드생성

 

7. Lombok라이브러리 추가

https://projectlombok.org 접속 > 상단메뉴 install > gradle > dependencies영역 복사

build.gradle의 dependencies 항목을 다음과 같이 수정합니다.

dependencies {
    compileOnly('javax.servlet:javax.servlet-api:4.0.1')

    testImplementation("org.junit.jupiter:junit-jupiter-api:${junitVersion}")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${junitVersion}")

    // https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client
    implementation 'org.mariadb.jdbc:mariadb-java-client:3.0.4'

    compileOnly 'org.projectlombok:lombok:1.18.24'
    annotationProcessor 'org.projectlombok:lombok:1.18.24'

    testCompileOnly 'org.projectlombok:lombok:1.18.24'
    testAnnotationProcessor 'org.projectlombok:lombok:1.18.24'

}

 

8. HikariCP 설정

프로젝트에서 Connection의 생성은 Connection Pool인 HikariCP(https://github.com/brettwooldridge/HikariCP)를 이용합니다.

build.gradle파일의 dependencies에 라이브러리를 다음과 같이 추가하고 그레이들에 변경내용을 반영합니다.

dependencies {
    compileOnly('javax.servlet:javax.servlet-api:4.0.1')

   ... 생략 ...

    implementation group: 'com.zaxxer', name: 'HikariCP', version: '5.0.0'

}

 

9. Connection Pool이용하기

HikariCP를 이용하기위해서는 HikariConfig라는 타입의 객체를 생성해야 합니다.

HikariConfig는 설정에 필요한 정보를 가진 객체인데, 이를 이용해서 HikariDataSource라는 객체를 생성합니다.

HikariDataSource는 getConnection()을 제공하므로 이를 이용해서 Connection객체를 얻어 사용할 수 있게 됩니다.

 

Dao에서는 필요한 작업을 수행할 때 HikariDataSource를 이용하게 되므로 접근이 용이하게 ConnectionUtil클래스를 enum으로 구성해서 사용합니다.

public enum ConnectionUtil {

    INSTANCE;

    private HikariDataSource ds;

    ConnectionUtil() {
        HikariConfig config = new HikariConfig();
        config.setDriverClassName("org.mariadb.jdbc.Driver");
        config.setJdbcUrl("jdbc:mariadb://localhost:3306/webdb");
        config.setUsername("webuser");
        config.setPassword("webuser");
        config.addDataSourceProperty("cachePrepStmts", "true");
        config.addDataSourceProperty("prepStmtCacheSize", "250");
        config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");

        ds = new HikariDataSource(config);

    }

    public Connection getConnection() throws Exception {
        return ds.getConnection();
    }
}

 

다음은 Dao에서 ConnectionUtil을 사용하는 코드입니다.

public class TodoDao {

    // try-with-resource를 이용하는 방법
    public String getTime() {
        String now = null;

        try(Connection connection = ConnectionUtil.INSTANCE.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement("select now()");
            ResultSet resultSet = preparedStatement.executeQuery();
            ) {
            resultSet.next();
            now = resultSet.getString(1);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return now;
    }

    // lombok의 @Cleanup을 이용하는 방법
    public String getTime2() throws Exception {
        @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
        @Cleanup PreparedStatement preparedStatement = connection.prepareStatement("select now()");
        @Cleanup ResultSet resultSet = preparedStatement.executeQuery();

        resultSet.next();

        String now = resultSet.getString(1);

        return now;
    }
}

getTime()은 try-with-resources 기능을 이용해서 try()내에 선언된 변수들이 자동으로 close()될 수 있는 구조로 작성됐습니다.

(try()내에 선언된 변수들은 모두 AutoCloseable이라는 인터페이스를 구현한 타입이어야만 합니다.)

 

getTime2()는 lombok의 @Cleanup을 이용한 코드로서 좀 더 깔끔하게 작성할 수 있습니다.

@Cleanup이 추가된 변수는 해당 메소드가 끝날때 close()가 호출되는 것을 보장합니다.

@Cleanup을 이용하면서 lombok라이브러리에 상당히 종속적인 코드를 작성하게 된다는 부담이 있지만 최소한의 코드로 close()가 보장되는 코드를 작성할 수 있는 장점이 있습니다.

 

10. 간단한 CRUD 예제

// 추가
public void insert(TodoVO vo) throws Exception {
    String sql = "insert into tbl_todo (title, dueDate, finished) values(?, ?, ?)";

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);

    preparedStatement.setString(1, vo.getTitle());
    preparedStatement.setDate(2, Date.valueOf(vo.getDueDate()));
    preparedStatement.setBoolean(3, vo.isFinished());

    preparedStatement.executeUpdate();

}

// 전체조회
public List<TodoVO> selectAll() throws Exception {
    String sql = "select * from tbl_todo";

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);
    @Cleanup ResultSet resultSet = preparedStatement.executeQuery();

    List<TodoVO> list = new ArrayList<>();

    while (resultSet.next()) {
        TodoVO vo = TodoVO.builder()
                .tno(resultSet.getLong("tno"))
                .title(resultSet.getString("title"))
                .dueDate(resultSet.getDate("dueDate").toLocalDate())
                .finished(resultSet.getBoolean("finished"))
                .build();

        list.add(vo);
    }

    return list;
}

// 한개조회
public TodoVO selectOne(long tno) throws Exception {
    String sql = "select * from tbl_todo where tno = ?";

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);

    preparedStatement.setLong(1, tno);

    @Cleanup ResultSet resultSet = preparedStatement.executeQuery();

    resultSet.next();
    TodoVO vo = TodoVO.builder()
            .tno(resultSet.getLong("tno"))
            .dueDate(resultSet.getDate("dueDate").toLocalDate())
            .finished(resultSet.getBoolean("finished"))
            .build();

    return vo;
}

// 한개삭제
public void deleteOne(long tno) throws Exception {
    String sql = "delete from tbl_todo where tno = ?";

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(sql);

    preparedStatement.setLong(1, tno);

    preparedStatement.executeUpdate();
}

// 한개수정
public void updateOne(TodoVO todoVO) throws Exception {
    String sql = "update tbl_todo set title = ?, dueDate = ?, finished = ? where tno = ?";
    Connection connection = ConnectionUtil.INSTANCE.getConnection();
    PreparedStatement preparedStatement = connection.prepareStatement(sql);

    preparedStatement.setString(1, todoVO.getTitle());
    preparedStatement.setDate(2, Date.valueOf(todoVO.getDueDate()));
    preparedStatement.setBoolean(3, todoVO.isFinished());
    preparedStatement.setLong(4, todoVO.getTno());

    preparedStatement.executeUpdate();
}

 

11. DTO와 VO

TodoDTO와 TodoVO는 완전히 같은 구조를 가지고 있습니다만, 적용된 어노테이션에서 차이가 있습니다.

TodoDTO의 경우 @Data를 사용합니다. (@Data는 getter/setter/toString/equals/hashCode등을 모두 컴파일시에 생성)

TodoVO의 경우 getter만을 이용해서 읽기전용으로 구성하는것과 차이가 있습니다.

 

▶ DTO와 VO를 둘 다 만들어야만 하는가에 대한 논쟁은 존재합니다. 별도로 작성하는 경우 불편하거나 번거로운 작업들이 발생하는데요,

그럼에도 DTO와 VO를 구분해서 만드는 방식이 더 나은 방법이라고 생각합니다. 우선 나중에 사용할 JPA에서는 필수적으로 필요하기도 하고, 스프링에서도 DTO는 검증이나 변환에서 다른 어노테이션들이 필요합니다.

 

▶ 가장 번거로운 DTO -> VO, VO-> DTO변환은 ModelMapper라이브러리를 이용해서 처리합니다.

(MVN repository사이트에서 ModelMapper 3.0.0 버전 사용)

 

▶ ModelMapper를 이용할 때는 대상 클래스의 생성자를 이용할 수 있도록 TodoVO에 @AllArgsConstructor, @NoArgsConstructor를 추가합니다. 이는 각각 파라미터가 없는 생성자와 모든 필드값이 필요한 생성자를 만들어냅니다.

@Getter
@Builder
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class TodoVO {

    private Long tno;

    private String title;

    private LocalDate dueDate;

    private boolean finished;
}

 

12. Log4j2와 @Log4j2

프로젝트를 개발하다보면 많은 System.out.println()을 이용하게 됩니다.

문제는 개발이 끝난 후에는 대부분의 System.out.println()은 필요없는 경우가 많아서 코드상에서 해당부분을 모두 삭제하거나 주석처리를 하는 경우가 많습니다.

이러한 문제를 해결하기 위한 기능으로 Log4j2를 이용합니다.

2021년말 Log4j관련 보안의 위험성에 대한 뉴스가 여러번 보도되고, 이에대한 피해의 우려가 컸습니다.

해서 해당문제의 패치버전인 2.17.0이상 버전을 이용합니다.

Log4j2에서 가장 핵심적인 개념은 로그의 레벨(Level)과 어펜더(Appender)입니다.

 

▶ 어펜더 : 로그를 어떤 방식으로 기록할 것인지를 의미하는데 콘솔창에 출력할지, 파일로 출력할지 등을 결정합니다.

System.out.println()대신에는 '콘솔어펜더'라는 것을 지정해서 사용합니다.

 

▶ 레벨 : 로그의 '중요도'개념입니다. System.out.println()은 모든 내용이 출력되지만 로그의 레벨을 지정하면 해당 레벨이상의 로그들만 출력되기 때문에 개발할 때는 로그의 레벨을 많이 낮게 설정해서 개발하고 운영할 때는 중요한 로그들만 기록하게 설정합니다.

Lowest Level -------------------------------------------------> Highest Level

TRACE        /      DEBUG        /     INFO        /    WARN        /    ERROR        /    FATAL

■ 레벨이 ERROR인 경우 : FATAL, ERROR레벨의 로그가 출력

■ 레벨이 INFO인 경우 : FATAL, ERROR, WARN, INFO레벨의 로그가 출력

■ 일반적으로 개발할 땐 INFO이하의 레벨을 이용하고, 운영할 때는 ERROR나 WARN이상을 사용합니다.

■ Log4j2를 이용하기위해 build.gradle을 다음과 같이 수정합니다.

dependencies {
    compileOnly('javax.servlet:javax.servlet-api:4.0.1')

    ... 생략 ...

    // Log4j
    implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.17.2'

    implementation group: 'org.apache.logging.log4j', name: 'log4j-api', version: '2.17.2'

    implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.17.2'

}

■ 앞서 언급했던 어펜더나 로그레벨을 설정할 log4j2.xml파일을 만들어줍니다.

프로젝트의 resource폴더안에 log4j2.xml이라는 파일을 생성해서 다음 내용들을 추가합니다.

(자세한 설정은 https://logging.apache.org/log4j/2.x/manual/configuration.html을 참고하시기 바랍니다.) 

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="info">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

■ 기존 System.out.println() 코드 대신 log.info()코드로 변경 후 테스트코드를 실행한 결과 아래처럼 출력됩니다.

 

19:56:17.164 [Test worker] INFO  org.zerock.jdbcex.service.TodoService - TodoVO(tno=0, title=JDBC Test Title, dueDate=2022-12-26, finished=false)
19:56:17.208 [Test worker] INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
19:56:17.332 [Test worker] INFO  com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection org.mariadb.jdbc.Connection@fc72739
19:56:17.336 [Test worker] INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
BUILD SUCCESSFUL in 3s

 

결과에서 눈여겨 볼 부분은 HikariCP의 로그역시 다르게 출력되고 있다는 점입니다.

이것은 HikariCP가 내부적으로 slf4 라이브러리를 이용하고 있는데, build.gradle의 log4j-slfj-impl 라이브러리가 Log4j2를 이용할 수 있도록 설정되기 때문입니다.

 

■ 테스트환경에서 @Log4j2 사용하기

dependencies {
    compileOnly('javax.servlet:javax.servlet-api:4.0.1')

   ... 생략 ...

    // 테스트코드에서도 Log4j를 쓰도록 추가
    testCompileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.24'
    testAnnotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.24'

}

 

반응형