728x90

지금까지 데이터베이스를 조작하기 위해 계속 무언가를 만들었는데, 사실 스프링에서는 이러한 것들을 편리하게 템플릿으로 이미 제공해준다.

 

일단 JdbcTemplate은 생성자로 dataSource를 받기 때문에 멤버로 JdbcTemplate을 만들어주고, 생성자에서 dataSource를 넣어서 만들어준다.

    public UserDao(
            DataSource dataSource) {
        this.dataSource = dataSource;
        this.jdbcContext = new JdbcContext(dataSource);
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

 

그리고는 다음과 같이 3가지 방법 중 하나로 쿼리문을 작성하면 되는데

public void deleteAll(){
        this.jdbcTemplate.update("delete from users");
        this.jdbcTemplate.update(con -> con.prepareStatement("delete from users"));
        this.jdbcTemplate.update(new PreparedStatementCreator() {
            @Override
            public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
                return con.prepareStatement("delete from users");
            }
        });
    }

 

아무리봐도 가장 위가 단순해보인다.

 

add()에 관한 메서드도 기존

this.jdbcContext.workWithStatementStrategy(c -> {
            PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?, ?, ?)");

            ps.setString(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getPassword());

            return ps;
        });

방법에서

 

    this.jdbcTemplate.update(
                "insert into users(id, name, password) values(?, ?, ?)",
                user.getId(), user.getName(), user.getPassword()
        );

이렇게 더 간단하게 사용이 가능하다.

 

이렇게 Create, Update, Delete와 같이 데이터베이스의 정보가 변경되는 쿼리는 update를 통해 작성해주면 된다.

 

이제는 정보를 가져오는 쿼리를 작성해보자.

getCount()는 유저의 수를 가져오는 메서드였다.

수를 가져오는 쿼리는 간단하게 queryForObject()를 사용한다.

쿼리를 작성하고, 원하는 타입을 지정해주면 된다.

    public int getCount() {
        Integer result = jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
        return result == null ? 0 : result;
    }

 

이 방법으로 user의 정보 또한 읽어올 수 있다.

    public User get(String id){
        return jdbcTemplate.queryForObject(
                "select * from users where id = ?", (rs, rowNum) -> new User(
                        rs.getString("id"),
                        rs.getString("name"),
                        rs.getString("password")
                ), id);
    }

엄청 간단해진 get() 메서드이다.

RowMapper를 상속받아서 mapRow() 메서드를 구현해야 하지만, 이 부분도 lambda로 간단하게 작성했다.

 

대신 queryForObject는 하나의 row만 가져오기 때문에, 해당하는 row가 없는 경우에도 에러가 발생한다.

그 때 발생하는 에러가 EmptyResultDataAccessException이기 때문에, 해당 에러를 핸들링하며 개발하면 된다.

 

다음 row를 가져오고 싶다면 rs.next()를 호출해가며 다음 값을 읽어오면 된다.

 

그러면 리스트로 user들을 조회하는 메서드를 만들어보자.

일단 TDD 느낌으로 테스트를 먼저 만들어보면

    @Test
    public void getAll() throws SQLException {
        User user1 = new User("user1", "user1", "pw1");
        User user2 = new User("user2", "user2", "pw2");

        userDao.deleteAll();

        userDao.add(user1);
        List<User> users1 = userDao.getAll();
        Assertions.assertEquals(1, users1.size());
        Assertions.assertEquals(user1, users1.get(0));

        userDao.add(user2);
        List<User> users2 = userDao.getAll();
        Assertions.assertEquals(2, users2.size());
        Assertions.assertEquals(user1, users2.get(0));
        Assertions.assertEquals(user2, users2.get(1));
    }

 

그리고는 getAll() 메서드를 다음과 같이 작성해준다.

    public List<User> getAll() {
        return this.jdbcTemplate.query(
                "SELECT * FROM users ORDER BY id",
                (rs, rowNum) ->
                        new User(rs.getString("id"), rs.getString("name"), rs.getString("password"))
        );
    }

query() 메서드를 사용해 값을 가져온다.

query()는 여러 개의 로우가 결과로 나오는 일반적인 경우에 사용한다.

그렇기 때문에 리턴 타입은 List<T>이다.

query()를 사용하면 next를 사용하지 않아도, 로우의 개수만큼 RowMapper 콜백으로 데이터를 mapping 해준다.

 

이렇게 작성하고 테스트를 실행하면 깔끔하게 성공하는 것을 볼 수 있다.

 

이렇게하면 끝나지만 이 책에서는 항상 예외적인 상황까지 생각해보라고 말한다.

 

데이터베이스가 빈 경우에는 과연 null이 리턴될까? 빈 list가 리턴될까?의 질문이다.

사실 당연히 빈 list가 나오겠지만, 그래도 테스트 코드를 작성해보자.

    @Test
    public void getAllWithEmpty() {
        userDao.deleteAll();

        List<User> users = userDao.getAll();
        Assertions.assertEquals(0, users.size());
    }

 

이렇게 작성하고 테스트를 해보니

테스트를 통과한다.

 

이런 당연한 경우도, 혹시 모를 변경에 대비하여 미리 테스트 코드를 작성해두라고 한다.

 

package seungkyu;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.lang.NonNullApi;

import javax.sql.DataSource;
import java.sql.*;
import java.util.List;

public class UserDao {

    private final JdbcTemplate jdbcTemplate;

    public UserDao(
            DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public void add(User user) throws SQLException {
        this.jdbcTemplate.update(
                "insert into users(id, name, password) values(?, ?, ?)",
                user.getId(), user.getName(), user.getPassword()
        );
    }

    public void deleteAll(){
        this.jdbcTemplate.update("delete from users");
    }

    public User get(String id){
        return jdbcTemplate.queryForObject(
                "select * from users where id = ?", (rs, rowNum) -> new User(
                        rs.getString("id"),
                        rs.getString("name"),
                        rs.getString("password")
                ), id);
    }

    public int getCount() {
        Integer result = jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
        return result == null ? 0 : result;
    }

    public List<User> getAll() {
        return this.jdbcTemplate.query(
                "SELECT * FROM users ORDER BY id",
                (rs, rowNum) ->
                        new User(rs.getString("id"), rs.getString("name"), rs.getString("password"))
        );
    }
}

코드가 기존에 dataSource를 가져오던 때에 비해 많이 깔끔해졌다.

 

하지만 그럼에도 중복되는 부분이 보인다.

(rs, rowNum) -> new User(...) 부분이다.

 

이 정도면 깨끗하긴 하지만, 더 깨끗한 코드를 위해서가 아니라 확장을 위해서다.

앞으로 Dao에 이렇게 Mapping 하는 코드를 더 작성할텐데, 그 때마다 이 코드를 작성해 줄 수가 없기 때문이다.

 

private final RowMapper<User> userRowMapper = (rs, rowNum) -> new User(
            rs.getString("id"),
            rs.getString("name"),
            rs.getString("password"));

 

이렇게 Mapper 인스턴스를 만들어서 사용하자.

 

뭔가 함수를 변수로 가지고 있는 것 같지만

private final RowMapper<User> userRowMapperRaw = new RowMapper<>() {
        @Override
        public User mapRow(ResultSet rs, int rowNum) throws SQLException {
            return new User(
                    rs.getString("id"),
                    rs.getString("name"),
                    rs.getString("password"));
        }
    };

그냥 이렇게 RowMapper 인터페이스를 구현해서 가지고 있는 것이다.

@FunctionalInterface
public interface RowMapper<T> {
    @Nullable
    T mapRow(ResultSet rs, int rowNum) throws SQLException;
}

이렇게 @FunctionalInterface를 가지고 있기 때문에 가능한 일이다.

 

일단 이렇게해서 템플릿에 대해 공부했다.

뭔가 JDBC가 많이 나왔지만, 그래도 최대한 템플릿과 callback으로 이해해보려고 한다.

사실 JDBC는 원래도 사용할 줄 알았고, 보통은 JPA를 더 많이 사용하기에

728x90

저번 workWithStatementStrategy로 StatementStrategy 구현체를 넘기는 과정에서 ide가 자동으로 콜백을 추천했었다.

callback은 실행되는 것을 목적으로 다른 오브젝트의 메서드에 넘겨주는 오브젝트를 말한다.

안에 무언가 정보는 신경쓰지 않고, 그 오브젝트의 메서드를 실행하기 위해서이다.

 

인터페이스가 여러 개의 메서드를 가지는 전략 패턴과 달리, 콜백은 단일 메서드만을 가지는 인터페이스에서 시작한다.

어차피 그 메서드 하나만 실행되기 때문이다.

 

저번에 만들었던 workWithStatementStrategy()에 콜백으로 넘겨보자.

workWithStatementStrategy()에 StatementStrategy를 넘겨주어야 하기 때문에

public interface StatementStrategy {
    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

 

해당 인터페이스의 구현체를 만들어 넘겨줬었고, 그럼 또 makePreparedStatement에 Connection을 넘겨줘야 했었다.

 

하지만 callback을 이용하면 해당 구현체를 만들지 않고 사용 할 수 있다.

    public void add(User user) throws SQLException {
        this.jdbcContext.workWithStatementStrategy(c -> {
            PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?, ?, ?)");

            ps.setString(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getPassword());

            return ps;
        });
    }

 

이렇게 람다식으로 connection을 이용해 preparedStatement를 만드는 과정만 넘겨주면 된다.

 

콜백을 작업 흐름은 다음과 같다고 한다.

나는 그냥 파라미터로 함수를 넘긴다고 생각하려고 한다.

그리고 호출된 메서드에서 해당 함수로 작업을 수행한다고 이해했다.

 

이런 callback 패턴은 try-catch-finally에도 굉장히 많이 사용한다고 한다.

예제를 위해 테스트 코드를 작성해보자.

package seungkyu;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.Objects;

public class CalcSumTest {

    static class Calculator{

        public Integer calcSum(String filePath) throws IOException {
            BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath));
            int sum = 0;
            String line;
            while ((line = bufferedReader.readLine()) != null) {
                sum += Integer.parseInt(line);
            }

            bufferedReader.close();
            return sum;
        }
    }

    @Test
    public void sumOfNumbers() throws IOException {
        ClassLoader classLoader = getClass().getClassLoader();
        Calculator calculator = new Calculator();
        int sum = calculator.calcSum(Objects.requireNonNull(classLoader.getResource("numbers.txt")).getFile());
        Assertions.assertTrue(sum > 0);
    }
}

 

그냥 단순하게 파일 읽어서 값 테스트하는 코드이다.

이제 throws한 IOException을 잡아보자.

 

public class CalcSumTest {

    static class Calculator{

        public Integer calcSum(String filePath) {
            try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
                int sum = 0;
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    sum += Integer.parseInt(line);
                }

                bufferedReader.close();
                return sum;
            } catch (IOException e) {
                System.out.println(e.getMessage());
            }
            return 1;
        }
    }

    @Test
    public void sumOfNumbers() {
        ClassLoader classLoader = getClass().getClassLoader();
        Calculator calculator = new Calculator();
        int sum = calculator.calcSum(Objects.requireNonNull(classLoader.getResource("numbers.txt")).getFile());
        Assertions.assertTrue(sum > 0);
    }
}

 

일단 try-catch-finally로 핸들링 하도록 했다.

 

하지만 메서드들이 추가될 때마다 예외처리 코드를 복사할 수는 없을 것이다.

이제 이 중복이 될 코드들을 템플릿/콜백으로 제거해보도록 하자.

 

가장 간단하게 생각이 드는 것은 템플릿이 파일을 열고 각 라인을 읽어올 수 있는 BufferedReader를 만들어서 콜백에게 전달해주고, 콜백이 각 라인을 읽어서 알아서 처리한 후 결과를 돌려주는 것이다.

 

일단 파일을 읽어서 결과를 반환해주는 메서드를 만들어준다.

    public int fileReadTemplate(String filePath, BufferedReaderCallback bufferedReaderCallback){
            try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
                return bufferedReaderCallback.doSomethingWithBufferedReader(bufferedReader);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

parameter로는 파일의 위치와 어떤 작업을 수행 할건지에 대한 callback을 작성해준다.

 

넘겨주는 callback의 interface도 만들어준다.

public interface BufferedReaderCallback {

    int doSomethingWithBufferedReader(BufferedReader bufferedReader) throws IOException;
}

 

이제 더하기에 대한 연산을 만들어보자.

    public Integer calcSum(String filePath) {
            BufferedReaderCallback sumCallback = bufferedReader -> {
                int sum = 0;
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    sum += Integer.parseInt(line);
                }
                return sum;
            };

            return fileReadTemplate(filePath, sumCallback);
        }

BufferedReaderCallback의 구현체를 lambda로 만들어서 바로 fileReadTemplate의 결과를 반환해준다.

 

이런 식으로 작성하면 일단 try-catch-finally에 대한 중복은 제거할 수 있다.

 

더 간결하게 만들 수 있을까?

만약 여기 곱하기가 추가된다고 해보자.

그러면 while문으로 버퍼를 읽는 부분도 중복이 될거고, 그 부분도 추출하고 싶어진다.

 

그러기 위해서는 또 callback을 위한 인터페이스를 만들고

public interface LineCallback {
    int doSomethingWithLine(String line, int value);
}

 

해당 인터페이스를 이용하는 메서드도 만든다. (fileReadTemplate메서드 처럼)

 

    public int lineReadTemplate(String filePath, LineCallback callback, int initVal){
            BufferedReader bufferedReader;
            try{
                bufferedReader = new BufferedReader(new FileReader(filePath));
                int res = initVal;
                String line;
                while ((line = bufferedReader.readLine()) != null) {
                    res = callback.doSomethingWithLine(line, res);
                }
                return res;
            }
            catch (IOException e){
                System.out.println(e.getMessage());
            }
            return initVal;
        }

 

메서드를 읽어보자면, 파일을 읽어오기 위한 경로와 작업을 수행할 메서드 그리고 초기값을 받아온다.

 

res 값을 callback 메서드로 계속 변경하기 때문에 더하기, 곱하기에 대한 메서드만 넣어주면 된다.

    public Integer calcSum(String filePath) {
            return lineReadTemplate(filePath, (line, value) -> {
                return Integer.parseInt(line) + value;
            }, 0);
        }

덕분에 calcSum 메서드도 더하기에 대한 연산만 넣어주면 끝나게 된다.

 

그리고 만약 곱하기에 대한 메서드도 필요하다면 

    public Integer calcMulti(String filePath) {
            return lineReadTemplate(filePath, (line, value) -> {
                return Integer.parseInt(line) * value;
            }, 1);
        }

이렇게 간단하게 곱하기에 대한 얀산만 넣어준다.

 

calcSum, calcMulti를 보면 중복되는 부분도 없이 리펙토링 된 것을 볼 수 있다.

 

굉장히 좋은 방법이지만, 솔직히 실전에서는 callback을 쓸 생각이 잘 안난다.

프론트 개발자들은 자주 사용하던데, 나도 좀 적극적으로 사용하려고 노력해야겠다.

728x90

AsynchronousChannel을 지원한다고 한다.

callback 함수를 지정할 수 있고, future로 반환을 지원한다고 한다.

callback과 future를 사용하면 비동기적인 로직이 가능할 것 같다.

 

  • File - callback

우선 callback 함수를 이용해서 파일을 읽어보자.

@Slf4j
public class AsyncFileChannelReadCallbackExample {

    @SneakyThrows
    public static void main(String[] args) {
        log.info("start");
        var file = new File("path");
        var channel = AsynchronousFileChannel.open(file.toPath());
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);

        channel.read(byteBuffer, 0, null, new CompletionHandler<Integer, Object>() {
            @SneakyThrows
            @Override
            public void completed(Integer result, Object attachment) {
                byteBuffer.flip();
                var resultString = StandardCharsets.UTF_8.decode(byteBuffer);
                log.info("result: {}", resultString);
                channel.close();
            }

            @Override
            public void failed(Throwable exc, Object attachment) {

            }
        });

        while(channel.isOpen()){
            log.info("Reading...");
        }
        log.info("end");
    }
}

 

completed 함수로 callback을 지정해주어, 파일이 모두 읽히면 result: {}로 내용을 출력할 수 있도록 하였다.

파일이 열린동안 메인 쓰레드가 닫히지 않도록 channel.isOpen으로 대기를 걸어주었다.

 

이렇게 callback으로 파일의 내용이 출력된 것을 볼 수 있다.

 

  • File - future
@Slf4j
public class AsyncFileChannelReadFutureExample {
    
    @SneakyThrows
    public static void main(String[] args) {
        log.info("start");
        
        var file = new File("path");
        
        try(var channel = AsynchronousFileChannel.open(file.toPath())){
            var buffer = ByteBuffer.allocateDirect(1024);
            Future<Integer> channelRead = channel.read(buffer, 0);
            while(!channelRead.isDone()){
                log.info("Reading...");
            }
            
            buffer.flip();
            var result = StandardCharsets.UTF_8.decode(buffer);
            log.info("result: {}", result);
        }
        
        log.info("end");
    }
}

 

이제 Future로 받고, 해당 Future가 끝나기를 기다린 후 파일의 내용을 출력하게 되었다.

코드는 깔끔해졌지만, while에서 동기적으로 동작하게 된다.

 

  • socket - callback

이번엔 소켓이다.

accept하는 부분을 비동기적으로 변경하였다.

@Slf4j
public class AsyncServerSocketCallbackExample {

    @SneakyThrows
    public static void main(String[] args) {
        log.info("start");

        var serverSocketChannel = AsynchronousServerSocketChannel.open();
        var address = new InetSocketAddress("localhost", 8080);
        serverSocketChannel.bind(address);

        serverSocketChannel.accept(null, new CompletionHandler<>() {

            @Override
            public void completed(AsynchronousSocketChannel clientSocket, Object attachment) {
                log.info("accepted");
                var requestBuffer = ByteBuffer.allocateDirect(1024);

                clientSocket.read(requestBuffer, null, new CompletionHandler<>() {
                    @SneakyThrows
                    @Override
                    public void completed(Integer result, Object attachment) {
                        requestBuffer.flip();
                        var request = StandardCharsets.UTF_8.decode(requestBuffer);
                        log.info("request: {}", request);

                        var response = "I am server";
                        var responseBuffer = ByteBuffer.wrap(response.getBytes());
                        clientSocket.write(responseBuffer);
                        clientSocket.close();
                        log.info("end");
                    }
                    @Override
                    public void failed(Throwable exc, Object attachment) {

                    }
                });
            }
            @Override
            public void failed(Throwable exc, Object attachment) {

            }
        });
        Thread.sleep(1000000);
        log.info("end");

    }
}

 

Callback이 2개이다.

바깥은 accept하는 부분이고, 안 쪽은 read하는 부분의 callback이다.

 

 

  • socket - Future
@Slf4j
public class AsyncServerSocketFutureExample {

    @SneakyThrows
    public static void main(String[] args) {
        log.info("start");
        var serverSocketChannel = AsynchronousServerSocketChannel.open();
        var address = new InetSocketAddress("localhost", 8080);
        serverSocketChannel.bind(address);

        Future<AsynchronousSocketChannel> clientSocketFuture = serverSocketChannel.accept();
        while(!clientSocketFuture.isDone()){
            Thread.sleep(100);
            log.info("Wainting");
        }
        var clientSocket = clientSocketFuture.get();

        var requestBuffer = ByteBuffer.allocateDirect(1024);
        Future<Integer> channelRead = clientSocket.read(requestBuffer);
        while(!channelRead.isDone()){
            log.info("Reading...");
        }

        requestBuffer.flip();
        var request = StandardCharsets.UTF_8.decode(requestBuffer);
        log.info("request: {}", request);

        var response = "This is server.";
        var responseBuffer = ByteBuffer.wrap(response.getBytes());
        clientSocket.write(responseBuffer);
        clientSocket.close();
        log.info("end client");

    }
}

 

Future로 변경을 해보았다.

File 때와 마찬가지로 while에서 동기적으로 작동하게 되며, 보완이 필요하다고 생각된다.

'Spring > 리액티브 프로그래밍' 카테고리의 다른 글

Java Selector  (1) 2024.03.20
Java IO Server 서버를 NIO로 변경  (0) 2024.03.19
Java NIO  (0) 2024.03.18
JAVA IO  (0) 2024.03.18
Mutiny  (0) 2024.03.14

+ Recent posts