728x90

개발을 공부하면서 여러 언어를 접해봤지만, 결국 자바(Java)를 선택했다. 배우다 보니 확실히 장점이 많고, 실제로도 많이 쓰이는 언어라는 걸 알게 되었다. 내가 공부하면서 알게 된 자바의 특징을 정리해보려고 한다.


자바(Java)의 주요 특징

1. 플랫폼 독립성 – 한 번 작성하면 어디서든 실행 가능

자바는 JVM(Java Virtual Machine) 위에서 실행되기 때문에, 운영체제에 상관없이 같은 코드가 실행된다. 쉽게 말해, 윈도우에서 만든 프로그램을 리눅스나 맥에서도 실행할 수 있다는 뜻이다. 코드를 .class 파일로 변환한 후, JVM이 알아서 해석해주기 때문에 이런 게 가능하다. 이게 바로 자바의 "Write Once, Run Anywhere" 원칙이다.

2. 객체 지향 프로그래밍(OOP)

자바는 객체 지향 언어라서 캡슐화, 상속, 다형성, 추상화 같은 개념을 활용할 수 있다. 처음에는 어렵게 느껴졌지만, 규모가 커지는 프로젝트에서는 유지보수하기 좋고, 재사용성이 높다는 걸 알게 됐다. 특히 인터페이스(interface)나 추상 클래스(abstract class)를 활용하면 유연하게 설계할 수 있어서 좋다.

3. 자동 메모리 관리 – 가비지 컬렉션(GC)

C나 C++은 개발자가 직접 메모리를 해제해야 하지만, 자바는 JVM의 가비지 컬렉터(GC)가 필요 없는 객체를 자동으로 정리해준다. 덕분에 메모리 누수를 걱정할 필요가 없고, 메모리 관리에 신경을 덜 써도 된다. 물론, GC가 언제 동작하는지 잘 이해하면 더 최적화할 수 있다.

4. 멀티스레드 – 동시에 여러 작업 처리 가능

자바는 멀티스레드를 지원하기 때문에, 여러 작업을 동시에 처리할 수 있다. 예를 들어, 웹 서버가 여러 요청을 동시에 처리해야 할 때, 멀티스레드가 필수다. Thread 클래스나 Runnable 인터페이스를 활용해서 구현할 수도 있고, CompletableFuture 같은 걸 활용하면 더 편리하게 비동기 처리를 할 수 있다.

5. 표준 라이브러리 – 기본 제공 기능이 많음

자바는 기본적으로 엄청나게 많은 라이브러리를 제공한다. 파일 입출력(IO), 데이터 구조(컬렉션 프레임워크), 네트워크, 날짜 및 시간 처리 등 다양한 기능이 있어서 굳이 새로 구현할 필요 없이 가져다 쓰면 된다. 덕분에 생산성이 높아진다.

6. 생태계와 프레임워크

자바는 단순한 언어가 아니라, Spring, Hibernate, MyBatis, JPA 같은 프레임워크가 엄청 많다. 특히 Spring은 백엔드 개발에서 거의 필수적으로 쓰이기 때문에, 자바를 배운다면 자연스럽게 Spring도 같이 배우게 된다. 또한, Gradle, Maven 같은 빌드 도구도 있어서 프로젝트 관리가 편하다.

7. 보안성 – 안전한 실행 환경 제공

자바는 바이트코드 검증, JVM 보안 매니저 같은 기능을 통해 보안을 강화하고 있다. 그리고 기본적으로 암호화, 인증, 접근 제어 같은 보안 관련 기능도 제공하기 때문에, 보안이 중요한 기업에서도 많이 사용된다.

8. 커뮤니티와 지속적인 발전

자바는 Oracle, OpenJDK, GraalVM 같은 다양한 그룹에서 지속적으로 발전하고 있다. 또, LTS(Long-Term Support) 버전이 있어서 기업에서도 안정적으로 사용할 수 있다. 커뮤니티도 크고 자료도 많아서, 막힐 때 검색하면 해결책을 금방 찾을 수 있다.


자바를 선택한 이유

개발을 하다 보면 어떤 언어를 선택할지 고민이 많아진다. 내가 자바를 선택한 이유를 정리하면 다음과 같다.

  • 운영체제에 상관없이 실행 가능해서 유지보수가 편하다.
  • 객체 지향 언어(OOP)라서 유연성과 확장성이 높다.
  • 가비지 컬렉션(GC) 메모리 관리가 자동으로 이루어진다.
  • 멀티스레드 지원 덕분에 서버 개발에 유리하다.
  • Spring 같은 프레임워크가 있어서 백엔드 개발이 쉽다.
  • 보안과 커뮤니티 지원이 탄탄해서 기업에서도 많이 사용된다.
728x90
728x90

Spring Boot에서 WebSocket 테스트하기

Spring Boot 애플리케이션에서 WebSocket 기능을 구현한 후, 테스트를 진행했습니다. @SpringBootTestWebSocketStompClient를 활용하여 WebSocket 통신을 테스트하고, AssertJ를 사용하여 검증했습니다.

Spring Boot 테스트 설정 및 포트 구성

WebSocket 테스트를 위해 @SpringBootTest 어노테이션을 사용하여 통합 테스트 환경을 구성했습니다. 이때, webEnvironment 속성을 SpringBootTest.WebEnvironment.RANDOM_PORT로 설정했습니다. DEFINED_PORT를 사용하여 지정된 포트를 사용할 수도 있습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebSocketTest {
    @LocalServerPort
    private int port;

    // 테스트에 필요한 필드 및 메서드 정의
}

@LocalServerPort 어노테이션을 통해 할당된 포트를 주입받고 클라이언트 설정 시 사용했습니다

WebSocket 클라이언트 설정

테스트에서 WebSocket 클라이언트 역할을 수행하기 위해 WebSocketStompClient를 설정합니다. 이 클라이언트는 STOMP 프로토콜을 사용하여 서버와 통신합니다.

private WebSocketStompClient stompClient;

@BeforeEach
void setUp() {
    // SockJS 클라이언트 생성
    List<Transport> transports = List.of(new WebSocketTransport(new StandardWebSocketClient()));
    SockJsClient sockJsClient = new SockJsClient(transports);

    // WebSocketStompClient 생성 및 설정
    stompClient = new WebSocketStompClient(sockJsClient);
    stompClient.setMessageConverter(new MappingJackson2MessageConverter());
}

SockJsClientWebSocketTransport를 사용하여 다양한 전송 방식을 지원하며, MappingJackson2MessageConverter를 통해 JSON 형식의 메시지를 처리했습니다.

BlockingQueue와 CompletableFuture 비교

테스트에서 비동기적으로 수신되는 메시지를 처리하기 위해 BlockingQueue를 사용습니다. 이는 메시지를 대기열에 넣고, 테스트 코드에서 이를 가져와 검증하는 방식입니다.

private BlockingQueue<MessageDto> blockingQueue;

@BeforeEach
void setUp() {
    blockingQueue = new LinkedBlockingQueue<>();
    // stompClient 설정 코드
}

또한, BlockingQueue외에 CompletableFuture를 사용하여 비동기 작업의 완료를 기다리고 결과를 처리할 수 있습니다.

CompletableFuture<MessageDto> completableFuture = new CompletableFuture<>();

// 메시지 수신 시
completableFuture.complete(receivedMessage);

// 테스트 코드에서
MessageDto message = completableFuture.get(3, TimeUnit.SECONDS);

BlockingQueue는 여러 스레드에서 안전하게 접근할 수 있는 반면, CompletableFuture는 단일 결과를 비동기적으로 처리하는 데 유용합니다. 테스트의 복잡성과 요구사항에 따라 적절한 방식을 선택하면 됩니다.

WebSocket 클라이언트 구독 시 FrameHandler 설정

서버로부터 수신한 메시지를 처리하기 위해 StompFrameHandler를 구현한 핸들러를 설정합니다.

class DefaultStompFrameHandler implements StompFrameHandler {
    @Override
    public Type getPayloadType(StompHeaders headers) {
        return MessageDto.class;
    }

    @Override
    public void handleFrame(StompHeaders headers, Object payload) {
        blockingQueue.offer((MessageDto) payload);
    }
}

getPayloadType 메서드에서는 수신할 메시지의 타입을 지정했고, handleFrame 메서드에서는 수신한 메시지를 blockingQueue에 추가합니다.

Byte[]와 DTO 클래스 비교

초기에 테스트를 구현할 때, 컨버터를 적용하지 않아 Byte[]로 데이터를 처리했습니다. 현재는 컨버터로 변경하였지만 사용했던 경험이 있어 비교하여 적어봅니다.
StompFrameHandlergetPayloadType 메서드에서 반환하는 타입을 byte[].class로 설정하면, 수신한 메시지를 바이트 배열로 처리하게 됩니다. 이 경우, 수동으로 바이트 배열을 원하는 객체로 변환해야 합니다.

@Override
public Type getPayloadType(StompHeaders headers) {
    return byte[].class;
}

@Override
public void handleFrame(StompHeaders headers, Object payload) {
    byte[] bytes = (byte[]) payload;
    // 바이트 배열을 문자열로 변환
    String message = new String(bytes, StandardCharsets.UTF_8);
    // JSON 문자열을 객체로 변환
    MessageDto messageDto = new ObjectMapper().readValue(message, MessageDto.class);
    blockingQueue.offer(messageDto);
}

반면, MessageDto.class로 설정하면 메시지 컨버터가 자동으로 JSON을 MessageDto 객체로 변환해주므로 코드가 더 간결해집니다. DTO 클래스를 직접 사용하는 것이 편리했습니다. 바이트 배열을 사용하는 것은 특별한 이유가 있을 때 사용하면 될 것 같습니다.

전체 코드 및 AssertJ를 사용한 검증

아래는 WebSocket 기능을 테스트하기 위한 전체 코드입니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebSocketTest {

    @LocalServerPort
    private int port;

    private WebSocketStompClient stompClient;
    private BlockingQueue<MessageDto> blockingQueue;

    private String WEBSOCKET_URI;
    private final String WEBSOCKET_TOPIC = "/topic";

    @BeforeEach
    void setUp() {
        // 메시지 컨버터 설정
        MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter();
        ObjectMapper objectMapper = converter.getObjectMapper().registerModule(new JavaTimeModule());

        // BlockingQueue 초기화
        blockingQueue = new LinkedBlockingQueue<>();

        // WebSocketStompClient 설정
        stompClient = new WebSocketStompClient(new SockJsClient(
                List.of(new WebSocketTransport(new StandardWebSocketClient()))));
        stompClient.setMessageConverter(converter);

        // WebSocket URI 설정
        WEBSOCKET_URI = "ws://localhost:" + port + "/ws";
    }

    @Test
    @DisplayName("WebSocket 메시지 전송 및 수신 테스트")
    void testWebSocketMessaging() throws Exception {
        Long roomId = 1L;
        String username = "user1";

        // WebSocket 연결 시 쿠키 설정
        HttpHeaders headers = new HttpHeaders();
        headers.add("Cookie", "login=" + username);

        // WebSocket 세션 연결
        StompSession session = stompClient
                .connectAsync(WEBSOCKET_URI + "?roomId=" + roomId, new WebSocketHttpHeaders(headers),
                        new StompSessionHandlerAdapter() {})
                        // 연결시도를 위한 최대 1초 대기
                .get(1, TimeUnit.SECONDS);

        // 특정 주제 구독
        session.subscribe(WEBSOCKET_TOPIC + "/room" + roomId, new DefaultStompFrameHandler());

        // 전송할 메시지 생성
        MessageDto messageToSend = new MessageDto("TEXT", "hi", "user1", "");

        // 메시지 전송
        session.send("/app/room" + roomId, messageToSend);

        // 메시지 수신 및 검증
        MessageDto receivedMessage = blockingQueue.poll(3, TimeUnit.SECONDS);

        assertThat(receivedMessage).isNotNull();
        assertThat(receivedMessage.getType()).as("메시지 타입 확인").isEqualTo(MessageType.TEXT);
        assertThat(receivedMessage.getContent()).as("메시지 내용 확인").isEqualTo(messageToSend.getContent());
        assertThat(receivedMessage.getSender()).as("메시지 발신자 확인").isEqualTo(messageToSend.getSender());
    }

    // StompFrameHandler 구현
    class DefaultStompFrameHandler implements StompFrameHandler {
        @Override
        public Type getPayloadType(StompHeaders headers) {
            return MessageDto.class;
        }

        @Override
        public void handleFrame(StompHeaders headers, Object payload) {
            blockingQueue.offer((MessageDto) payload);
        }
    }
}

결론

위의 테스트 코드를 통해 Spring Boot 애플리케이션에서 WebSocket 기능을 효과적으로 검증할 수 있었습니다. @SpringBootTestWebSocketStompClient, @ActiveProfile를 활용하여 통합 테스트를 구성하고, AssertJ를 사용하여 수신된 메시지의 내용을 검증함으로써 WebSocket 통신의 신뢰성을 확인할 수 있었습니다.

728x90
728x90

웹소켓

WebSocket은 ws 프로토콜을 기반으로 클라이언트와 서버 사이에 지속적인 완전 양방향 연결 스트림을 만들어 주는 기술입니다. 출처 : https://developer.mozilla.org/ko/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications

연결은 클라이언트에서 연결요청을 보내면

GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

서버에서 응답을 통해 웹소켓 프로토콜로 업그레이드한다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

그러면 실시간 양방향 통신이 되는 웹소켓을 사용할 수 있게 된다.

웹소켓은 데이터가 크면 조각으로 나누어 보낼 수 있고 그것에 대한 설명은 웹소켓 프로토콜에 대한 웹소켓에서 확인할 수 있다. 또한, 웹소켓은 일반적으로 보낼 수 있는 데이터에 제한이 있을 수 있다. 해당 내용은 스프링 Doc도 확인이 가능하며, 실제로도 웹소켓 이용중 연결이 끊기는 경우를 경험할 수 있을 것이다. 데이터가 너무 크면 서버의 부하가 있을 수 있어 제한이 있다. 하지만 이러한 제한도 설정을 통해 해결할 수 있는 방법이 있다. 아니면 큰데이터를 조각화하여 보내고 서버에서 합쳐서 대응하는 방법 등이 있다. 또한, 한번 연결된 웹소켓이 연결이 끊어지는 경우도 있다. 재연결에 대한 방안도 있으니 문제가 된다면 찾아보자.
웹소켓 재연결 핸들링한 글도 있으니 참고해보면 좋을 것 같다. 이 글은 다른 환경 및 라이브러리를 사용했으므로 어떻게 대처하는지에 대한 참고만 해보자.

웹소켓에 대한 대략적인 내용을 봤다. 이후에는 웹소켓을 java에서 사용하는 간략한 예제와 함께 봐보자.

Java에서의 웹소켓 사용하기

@Configuration
@EnableWebSocketMessageBroker
public class CustomWebsocketConfig implements WebSocketMessageBrokerConfigurer{}

자바에서 웹소켓을 사용하기 위해서 위의 클래스와 어노테이션만 있어도 Stomp를 사용하는 Websocket을 사용할 수 있다. Spring이 인메모리를 사용해 메세지브로커 역할을 하는 것이다. 만약, 더 많은 기능이 필요하다면 외부 브로커(RabbitMQ 등)를 사용해서 설정을 하면된다.
자바에서는 Stomp를 사용하기를 여러 이유에서 권하고 있다. Stomp사용 이점

나는 websocket을 사용하기 위한 설정 뿐만 아니라,
HandshakeHandler와 HandshakeIntercepter, EventListener를 같이 사용했다.
웹소켓에 Principle 인터페이스를 주입할 수 있는데 스프링 시큐리티를 같이 사용하면 스프링 시큐리티에서 Security Context에서 Authentication객체를 가져와서 자동으로 주입해준다고 한다. 하지만 내가 만든 프로젝트에서는 security를 적용하지 않아서 직접 주입을 해야했고, 따라서 HandshakeHandler와 HandshakeIntercepter를 사용하게 되었다. EventListener는 웹소켓 연결이 끊겼을 때 동작을 구현해 두었다.


예시 코드

@Configuration  
@EnableWebSocketMessageBroker  
@RequiredArgsConstructor  
public class SimpleWebsocketConfig implements WebSocketMessageBrokerConfigurer {  
    private final CustomHandshakeHandler customHandshakeHandler;  
    private final WebsocketHandshakeInterceptor msgHandshakeInterceptor;

    @Override  
    public void registerStompEndpoints(StompEndpointRegistry registry) {  
        registry.addEndpoint("/ws")  
                .addInterceptors(msgHandshakeInterceptor)  
                .setHandshakeHandler(customHandshakeHandler)  
                .withSockJS();
    }  

    @Override  
    public void configureMessageBroker(MessageBrokerRegistry registry) {  
        registry.enableSimpleBroker("/topic");  
        registry.setApplicationDestinationPrefixes("/app");  
    }
@Component  
@RequiredArgsConstructor  
public class WebsocketHandshakeInterceptor implements HandshakeInterceptor {  
    @Override  
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        // 유저정보를 처리하여 attributes에 주입
        // 웹소켓 연결시 쿼리스트링을 사용할 수 있어서 원하는 정보를 받을 수도 있다.
        return true;  // intercept를 사용하려면 true를 반환해야한다.
    }  

    @Override  
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {  

    }  
}
@Component  
public class CustomHandshakeHandler extends DefaultHandshakeHandler {  
    @Override  
    public Principal determineUser(  
            ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {  
        // attributes.get(유저정보);  
        // return Principle 구현체;
    }  
}

웹소켓을 사용하기 위해 위 3개의 클래스를 사용했다. 위에서 언급했듯 설정을 위한 cofigurer를 사용하고 유저정보 주입을 위한 handler와 intercepter를 사용했다.

유저정보가 주입되는 과정은

  1. 클라이언트에서 핸드쉐이크 요청
  2. 인터셉터에서 유저정보를 attribute에 추가
  3. 핸들러에서 attribute의 유저정보를 가져와서 websocket에 넣어준다.

유저를 넣는 과정이 이럴뿐이지 다른 정보도 넣을 수 있다.

코드 설명

가장 중요한 설정을 살펴보자.
엔드포인트 설정과 메세지브로커 설정이 있다.
먼저, 엔드포인트 설정

  1. 엔드포인트 : 웹소켓 클라이언트를 연결하기 위한 엔드포인트이다. 클라이언트에서 웹소켓에 연결하기 위해서 해당 엔드포인트가 필요하다. 엔드포인트는 1개만 되는 것이 아니라 다양한 엔드포인트를 설정할 수 있다.
  2. 핸들러, 인터셉터(선택): 각 엔드포인트에 추가할 인터셉터와 핸들러를 추가해주면 된다.
  3. sockJs : sockJs를 사용할 것이라면 withSockJS()를 붙여줘야 한다.

SockJs는 웹소켓뿐만 아니라, polling이나 HTTP Streaming을 선택적으로 연결해준다. 물론 주로 웹소켓을 연결해준다. Spring Doc에도 내용이 있다.

다음은 브로커 설정이다.

  1. 심플브로커("/topic") : 브로커를 찾기위한 접두어이다. 클라이언트에서 구독을 할 때 사용한다. 그래야 브로커가 받은 메세지를 전달해줄테니까. 1개말고 여러개를 추가할 수 있다. 스프링 예제에서는 "/queue"도 추가되어 있다.
  2. 어플리케이션프리픽스("/app") : 클라이언트에서 서버로 데이터를 보낼 때 사용한다. "/app" 이후의 Path를 찾아서 데이터를 처리한다. 각 목적지에 맞게 컨트롤러에서 찾아준다.

그러면 메세지를 받아야하는 서버 설정만 하면되는건가? 아니다. 아까 "/app"을 설정해주었다. 이 접두어에 보내진 메세지를 처리하는 로직이 필요하다.
컨트롤러에서 @MessageMapping을 사용하여 지정된 패스에 따라 메세지를 전달한다. Payload만 받아서 처리할 수도 있고, Path에서 변수를 받을 수도, principle을 사용할 수도 있다. 이외에도 더 있으니 필요한 파라미터를 받아서 사용하자.

@MessageMapping("패스/{id}")  
public void roomSendMsg(@DestinationVariable(value = "id")Long id,  
                        DTO클래스 Payload,  
                        Principal user){  
    SimpMessagingTemplate template = new SimpMessagingTemplate();
    String dest = "/topic" + destination;  
    template.convertAndSend(dest, message);
}

메세지를 보내는 방법은 SimpMessagingTemplate을 사용했다. 목적지로 생각하는 브로커 접두어에 목적지를 추가하고 데이터를 보내면 된다. 외부 브로커는 다를 수 있다. 참고하자.


클라이언트(브라우저)에서 연결

클라이언트에서는 어떻게 연결할까?
나는 Thymeleaf를 사용하여 html에 javascript로 직접 script를 작성했다. 따라서 javascript에서 사용할 수 있는 StompJS를 사용했다.

const sockjs = new SockJS(엔드포인트, {}, {});  

// stompJs 버전 변경 후  
const stompClient = new StompJs.Client({  
    webSocketFactory: () => sockjs
});  

stompClient.onConnect = function (frame) {
    stompClient.subscribe(`/topic`, function (data) {  
        // data를 처리하는 로직
    }  
}
stompClient.activate();
stompClient.publish({  
    destination: `/app/패스`,  
    body: JSON.stringify({ msg: msg })  
})

구독과 메시지를 보내는 방법이다. 연결을 할 때 구독을 해주었다. 구독은 원하는

  1. 브로커 주소를 넣고
  2. 구독을 통해 데이터를 처리할 함수를 추가하면 된다.

그리고 activate는 해주도록 하자. 그래야 동작하니까.
예시에는 브로커에 "/topic"만 넣었으나, 서버측 컨트롤러에서 보면 destination이 추가되는 것을 볼 수 있다. 원하는 브로커를 잘 선택해야한다. 예를 들어 서버에서 메세지를 /topic/site로 보냈는데 구독을 /topic만 하면 데이터를 전달받을 수 없다. 구독 할 때 /topic/site를 추가해야한다.

반대로 메세지를 보낼때는 @MessageMapping 어노테이션에 맞게 입력해주어야 메세지가 제대로 전달된다. Payload(body)로 Json형태로 보냈으나, binary데이터도 보낼 수 있다. Stomp는 텍스트와 binary데이터를 지원한다.


이렇게 Websocket과 Stomp를 사용해서 아주 간단한 실시간채팅을 구현할 수 있다. 단순히 연결만 하는 것은 그다지 어려운 내용은 아니였다. 하지만 Handler와 intercepter가 어떻게 동작하는지, 웹소켓이 연결되는 과정, 데이터 크기 제한 등 다양한 문제를 다루었었고 완전히 해결한 문제, 부분적으로 해결한 문제 등 다사다난했다. 하지만 Stomp를 사용한 웹소켓은 매우 익숙하게 사용할 수 있게 되었다. 가장 힘들었던 것은 데이터 크기제한과 extension(Implementation-Specific Limits)과 관련한 것이었다. 부분적으로 밖에 해결하지 못한 것중 하나이다. 서버제한이나, 조각화 등 다양한 방법으로 접근해봐야 하는 부분이여서 당장 급하지않아서 차차 해결하면 될 것이다.

Java에서 웹소켓을 테스트하는 것도 다루어 보겠다.

728x90
728x90

Spring Boot WebMvcTest에서 발생한 Jackson Mapping 문제 해결 과정

Spring Boot에서 @WebMvcTest를 사용하여 컨트롤러 테스트를 진행하던 중, 아래와 같은 에러가 발생했다. 

[Test worker] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: (was java.lang.UnsupportedOperationException)]

UnsupportedOperationException의 설명을 보면 "Thrown to indicate that the requested operation is not supported.
This class is a member of the Java Collections Framework."라고 설명하고 있다. 처음에는 테스트 코스에 사용하고 있는 List에서 문제가 있는 건 아닌지 확인했으나, List부분은 모두 잘 동작했다. 그럼 JSON을 쓸 수 없다고 했으니, LocalDateTime에서 문제를 만드는 건가 확인했는데 그것도 아니었다. 그러면 어디서 발생하는 문제일까? 찾아보기 위해 디버거를 사용하여 계속 추적했다.

com.fasterxml.jackson.databind.JsonMappingException: (was java.lang.UnsupportedOperationException) (through reference chain: org.springframework.data.domain.Unpaged["offset"])

MockMvc에서 MvcResult를 반환하는 과정에서 이러한 에러가 발생하여 찾기가 어려웠다. 에러로그를 계속 추적했고 결국 결정적인 로그를 찾을 수 있었다. 에러 로그를 분석한 결과, 보다 구체적인 원인을 다음과 같이 확인할 수 있었다. 

문제 원인

Spring Data의 Page 인터페이스를 JSON으로 변환하는 과정에서 Unpaged 인스턴스를 직렬화하려 할 때 UnsupportedOperationException이 발생한 것이다. 아래와 같이 코드를 작성했었으며, Page Interface와 Jackson이 상호작용하면서 발생한 문제였다. Pageable을 사용한 페이징 응답을 직렬화하는 과정에서, Unpaged 객체가 Jackson에 의해 처리되지 못하면서 예외가 발생했다. PageImple과 list데이터를 사용해서 Page를 반환했다. 이때, 생성자에 list만 넣고 생성하면서 Unpaged객체로 생성되었고, Jackson이 직렬화하는 과정에서 getOffset메소드를 실행한 것이다. Unpaged에서 offset을 얻으려고 하면 예외를 발생시키는데 이로 인해 직렬화가 마무리되지 못하고 중단되어 에러가 발생했던 것이다.

when(drawingService.getRoomList(pageable)).thenReturn(new PageImpl<>(list));

MvcResult mvcResult = mockMvc.perform(get("/api/game/list"))
        .andExpect(status().isOk())
        .andReturn();

 

해결 방법

물론, 간단하게 해결하는 방법도 있었다. PageImpl 생성자에 pageable객체와 size만 추가하면 Paged가 되면서 offset이 예외를 발생시키지 않는다. 하지만 이 문제를 해결하기 위해, Page를 직접 반환하는 대신, 필요한 데이터만 포함하는 CustomPageDto 클래스를 만들어 사용했다.

@Getter  
public class CustomPageDto<T> {  
    private List<T> content;  
    private int number;  
    private long totalElements;  
    private int totalPages;  
    private int pageSize;  
    private boolean empty;  

    public CustomPageDto(){  
        this.content = new ArrayList<>();
        this.empty = true;  
    }

    public CustomPageDto(Page<T> page){  
        this.content = new ArrayList<>(page.getContent());  
        this.number = page.getNumber();  
        this.totalPages = page.getTotalPages();  
        this.totalElements = page.getTotalElements();  
        this.pageSize = page.getSize();  
    }  
}

 

DTO를 커스텀하여 사용하는 장점

  1. 불필요한 데이터 제거
    • Page 인터페이스에는 Pageable, Sort, Unpaged 등 직렬화할 필요가 없는 데이터가 포함되어 있다.
    • CustomPageDto<T>를 사용하면 필요한 정보만 포함하여 직렬화할 수 있다.
  2. Jackson Mapping 예외 방지
    • Page<T>를 JSON으로 변환할 때 문제가 되었던 Unpaged["offset"] 관련 예외를 해결할 수 있다.
    • 또한, 필요한 데이터만 골라서 변환하므로 예측가능한 수준으로 문제발생범위를 좁힐 수 있다.
  3. 통신 비용 절감
    • 클라우드 환경에서 REST API를 사용할 경우, 불필요한 데이터를 줄임으로써 네트워크 트래픽을 절감할 수 있다.
    • 데이터가 많아질수록 API 응답 크기가 작아지고 성능 최적화에 기여할 수 있다.

이러한 방식을 적용함으로써, WebMvcTest에서 발생한 Jackson Mapping 문제를 해결하고, 보다 효율적인 페이징 응답을 구현할 수 있었다. 3번의 경우에는 이번에 해당 문제를 해결하기 위한 다양하게 검색을 시도했는데 그때 알 수 있었던 새로운 정보이다. 최근 클라우드 사용이 많아지고 있다. 네트워크 트래픽을 감소하여 클라우드 네트워크 비용을 줄이는데 기여할 수 있다는 것을 알게 되었으며, 데이터의 크기가 줄어들면서 FE-BE통신 간 성능최적화에도 기여할 수 있다는 것이다.

결론

MockMvc로 테스트를 진행할 때, Content-Type: application/json으로 인해 응답을 Json으로 응답하게 된다. 이때, Page Interface를 그대로 응답하면 불필요한 데이터와 Page Interface 전체를 Json직렬화를 하면서 불필요한 리소스를 사용하게 된다. 이런 문제를 해결하기 위해 필요한 데이터만 골라서 전달할 수 있는 DTO를 만들고 전달하는 방법을 고려해보자.

이번 Page문제뿐만 아니라 다른 라이브러리들에도 적용될 수 있으므로 항상 응답하는 답변에 대해서 불필요한 정보가 포함되지는 않는지, 불필요한 과정이 더 많이 들어가지는 않는지 확인해 보자. 비용을 절감하는 것은 사업가나 기획, 운영을 하는 사람뿐만 아니라 개발자에게도 비즈니스에 기여해야 하는 책임이 있다. 최근 비즈니스의 경향이 오롯이 돈을 목적으로 하지는 않지만, 비즈니스와 돈은 떼려야 뗄 수 없는 관계이므로 개발자로서 1가지 방법을 알게 된데 굉장히 좋은 경험이라 생각한다.

728x90
728x90

AZURE에서 spring 어플리케이션을 배포하는 방법은 VM(가상머신) 사용, Container app 사용, Azure App Service 사용 등 방법이 있다.
VM의 경우 기존에 내가 어플리케이션을 배포하기 위해 사용했던 방식이었다. 직접 Cloud 서비시에서 VM을 만들고 Linux VM에서 어플리케이션을 배포하는 것이다. 이러한 방법은 대부분의 설정을 직접해야한다. 덕분에 많은 공부를 할 수 있었다.(리버스 프록시, 도커, 도커 컴포즈 등)
이번에는 AZURE에서 서비스하는 Azure Spring Apps를 사용하여 배포를 진행하려 했으나, 배포 방법을 알아보던 중 2024년 12월에 올라온 Azure Spring Apps의 중단을 알리는 글을 확인하고 Container App을 사용하는 방법으로 방향을 변경했다.


AZURE의 Java 학습

위의 안내를 통해 Sample Project를 사용하여 어플리케이션을 배포하는 방법을 연습했다.
연습을 위해 사전준비를 먼저 진행했다.

준비물

  1. Azure 계정
  2. GitHub 계정
  3. git
  4. Azure CLI
  5. Container Apps CLI 확장
  6. Java
  7. Apache Maven

준비물 중 1,2,3,6번은 기존에 사용을 하고 있어서 별도로 준비하지 않았다. 4,5,7번은 이번 연습을 위해 준비를 했다.

4번 Azure CLI

brew update && brew install azure-cli

5번 Container Apps CLI 확장

az extension add --name containerapp --upgrade --allow-preview

7번 Apache Maven

brew install mvn

준비과정 이후에는 학습 내용을 그대로 따라하는 과정이다. 과정은 단순하다.

  1. Spring PetClinic 샘플어플리케이션을 복제한다.
  2. 프로젝트를 Maven을 사용하여 빌드한다.
  3. CLI를 사용하여 application을 배포한다.

 

애플리케이션 빌드 후

빌드 이후 CLI 명령어를 살펴보겠다.
az containerapp up 명령어를 사용하여 빌드한 파일을 배포할 수 있다. 학습에서 사용하는 명령어의 옵션 중 environment는 별도로 입력해 줄것이 없어서 입력하지 않았다.

az containerapp up \

--name <name> \ # 컨테이너 이름
--resource-group <resource-group> \ # 리소스 그룹 이름
--location <location> \ # 지역
--ingress external \
--target-port 8080 \
--query properties.configuration.ingress.fqdn \ # 배포가 완료된 뒤 URL을 반환하도록 하는 인수
--artifact ./target/spring-petclinic-3.4.0-SNAPSHOT.jar # 빌드파일 위치
  • name : 이름은 원하는 이름을 작성해 주자. 필수 항목이다.
  • resource group : Azure Portal에서 리소스 그룹을 찾아서 들어가면 현재 있는 리소스 그룹의 목록을 나타내주고 그 중 하나의 이름을 입력하면 된다. 대부분의 서비스에서 요구하는 항목으로 몇번 사용하면 익숙해지는 항목이다.
  • location : 지역명은 지역에 들어가서 최상단의 HTTP GET요청을 수행하면 지역리스트를 확인할 수 있다. 필요한 지역을 확인하고 입력하자. 참고로, 난 koreacentral을 입력했었다. terminal에서 az account list-locations를 입력하여 리스트를 얻는 방법도 있고 azure 설정을 통해 기본값을 지정할 수 있다.
  • ingress, query : 별도의 수정없이 사용하였다. ingress는 수신에 대한 내용으로 {external, internal} 옵션이 있고, query는 JMESPath 쿼리문자열이라고 한다. 별도의 페이지를 참조하기 바란다. https://jmespath.org/
  • artifact : 컨테이너 이미지를 빌드하기 위한 애플리케이션 아티팩트 로컬 경로이다. 나는 학습에서 지시하는 대로 spring-petclinic폴더에서 컨테이너를 만들어서 상대경로로 ./target/spring-petclinic-3.4.0-SNAPSHOT.jar를 입력했다.

Browse to your container app at: http://springtestapp.livelypebble-8fb4693e.koreacentral.azurecontainerapps.io

배포가 완료되면 위와 같은 메세지와 함께 배포가 완료된 애플리케이션에 접속할 수 있다. 본인의 URL을 통해 접속해보자.


연습을 통한 Container Apps를 사용해 배포를 진행했다. 확실히 직접 VM을 사용하여 배포하는 것보다 간편하게 배포를 진행할 수 있었다. 또한, Container Apps에서는 자동배포나, 리비전 관리 등을 지원한다고 하니 차후 이런 기능에 대해서도 알아보면 좋겠다.

또한, 다음에는 샘플 프로젝트가 아닌 직접 구현한 프로젝트를 배포할 예정인데 샘플과는 다르게 버전이 JDK21이므로 해당 옵션도 설정하고, 운영 프로파일을 개발과 다르게 설정했으므로 이러한 시도해볼 예정이다.

728x90
728x90
목차
이런 기술스택 배지를 만들어 봅시다!
배지 만들기
  ↳ 배지 만드는 방법
커스텀 로고 만들기

Static Badge

이런 기술스택 배지를 만들어 봅시다!

포트폴리오나 Readme를 작성하기 위해 아이콘을 찾고 배치하고 기술명 입력하고 색 입히고 했던 경험이 있으시거나, 현재 찾고 계신다면 이 글을 참조해주시면 좋겠습니다. (사실 제가 찾아다니기 귀찮아서 글을 작성하는 겁니다만...)

저도 여러번 같은 경험을 하면서 항상 찾아다녔습니다. 이 아이콘인가 저 아이콘인가... 저 블로그였나 이러면서 찾아다녔죠. 이제 찾아다니지 마세요. 필요한건 만들어서 사용합시다.(정확히는 만들어 주시는거) 굳이 만들지 않고 그냥 복사하고 싶으시다면 아래 참고 블로그 넣어두었습니다. 해당 페이지로 가시면 몇몇 샘플이 있으니 그거 사용하셔도 될것 같습니다.

배지를 만들어주는 사이트가 있습니다. 참고사이트로 하단에 명시해 두었습니다. 단순한 GET요청만으로도 여러분들이 깃헙에서 많이 보셨던 그 배지를 만들어 주십니다.

Static Badge

Javascript 아이콘과 그냥 대충 만든 '아이콘이름', yellow 색상으로 배지를 만들어 보았습니다. 자세한 설명은 해당페이지를 보면서 설명해 보겠습니다.

배지 만들기

배지를 만들어주는 사이트

우측에 보면 GET요청을 사용해서 배지를 만들 수 있습니다. URL, HTML 등 다양한 형식으로 제공해주니 적절히 사용하면 되겠습니다.

그럼 어떻게 만드느냐. URL에서 ':badgeContent'를 원하는 내용으로 입력해주시면 됩니다. 
기본 구조는 '라벨-색상' 입니다. 라벨색상은 필수입니다. 라벨은 원하는 글 뭐든 상관없습니다. 색상의 경우 이미지에서 볼 수 있듯 Hex, rgb, rgba, ... css named colors까지 가능합니다.  '라벨-메세지(선택)-색상'으로 작성할 수도 있습니다. 예시는 회색과 파란색으로 구성된 'any text you like'입니다. 회색부분이 라벨이고 파란색 부분이 메세지입니다.


https://img.shields.io/badge/%EC%95%84%EB%AC%B4%EC%95%84%EC%9D%B4%EC%BD%98-yellow?logo=javascript

제가 위에서 만든 노란색 아이콘의 URL입니다.(한글이 url 인코딩이 되어있네요;;) 저는 badgeContent에 '아무아이콘-yellow'로 입력을 했습니다. 하지만 아이콘이 추가되어 있죠? URL을 보시면 logo 쿼리가 추가되어 있습니다. Shields.io에서 간편하게 로고를 입력할 수 있도록 쿼리를 추가해주었습니다. 하지만 저렇게 간단하게 입력해서 모든 로고를 만들어주지는 않습니다.

아래 Simple Icons라는 페이지 링크를 확인해보시면 많은 아이콘이 있습니다. 그중에서도 Simple Icons의 깃헙에 slugs.md라는 마크다운파일이 있는데 여기에 명시되어 있는 아이콘만 logo쿼리로 제공한다고 합니다. Brand slug에서 찾으면 됩니다. 저는 javascript를 찾았고 마침 Brand name과 Brand slug가 동일했습니다. 자 이제 배지를 만들기 위해 필요한건 다 봤습니다.

 

 

배지 만드는 방법

  1. Simple Icons의 깃헙 slugs.md 파일에서 원하는 아이콘을 찾는다. (브라우저 찾기 기능 이용)
  2. shields.io 페이지에서 원하는 라벨을 작성하시거나,
    <img alt="Static Badge" src="https://img.shields.io/badge/라벨-색상?logo=brandslug">
    위의 태그를 사용하시면 됩니다.

 

커스텀 로고 만들기

여기까지 읽고 의문을 가지시는 분이 있을 수 있습니다.

Java는 Brand slug에 없는데?? 맨 위에 있는 저 아이콘은 뭐지?

Java가 Brand slug에 없어서 직접 만들었습니다. 만드는 방법 어렵지 않습니다. logo쿼리에 해당 아이콘만 입력해주시면 됩니다. 단! base64 encoding이 이미지로 입력해야합니다.

Custom Logo 예시

그래서, base64 encoding을 할 수 있는 곳도 준비했습니다. PNG를 base64로 인코딩 해주며 PNG외 다른 이미지나 파일 등도 인코딩을 해주므로 해당 사이트를 사용하시면 되겠습니다.

이제, 웬만한 배지는 다 만들수 있게 되었습니다. 그 외 신경쓸만한 부분은 그만큼 관심을 가지고 계신 것이니 사이트에 직접 접속하셔서 손수 커스텀해보시기 바랍니다. 마지막으로 Shields.io에서 제공하는 스타일만 체크해보시고 글은 여기서 마치도록 하겠습니다. 마음에 드시는 스타일로 적용해보시기 바랍니다.

  • flat : Static Badge
  • flat-square : Static Badge
  • plastic : Static Badge
  • for-the-badge : Static Badge
  • social : Static Badge

 

참고 사이트

https://simpleicons.org/

 

Simple Icons

3296 Free SVG icons for popular brands

simpleicons.org

https://github.com/simple-icons/simple-icons/blob/master/slugs.md

 

simple-icons/slugs.md at master · simple-icons/simple-icons

SVG icons for popular brands. Contribute to simple-icons/simple-icons development by creating an account on GitHub.

github.com

https://shields.io/badges/static-badge

 

Static Badge | Shields.io

The color of the logo (hex, rgb, rgba, hsl, hsla and css named colors supported). Supported for simple-icons logos but not for custom logos.

shields.io

https://base64.guru/converter/encode/image/png

 

PNG to Base64 | Image | Base64 Encode | Base64 Converter | Base64

PNG to Base64 Convert PNG to Base64 online and use it as a generator, which provides ready-made examples for data URI, img src, CSS background-url, and others. The PNG to Base64 converter is identical to Image to Base64, with the only difference that it fo

base64.guru

 

참고 블로그

https://cocoon1787.tistory.com/689

 

[GitHub] 기술스택 배지로 깃허브 프로필, README.md 예쁘게 꾸미기

안녕하세요. 📌 이번 포스팅은 깃허브에서 많이 볼 수 있는 스킬 배지에 관한 포스팅입니다. 🚀 사용법 기본 구조 예를 들어 html5라고 글씨가 써져있고 html마크가 새겨졌으면 좋겠다! 하신다면

cocoon1787.tistory.com

 

728x90
728x90

자료구조 중 Heap을 공부하고 직접 구현한 코드와 Heap관련된 코딩테스트 문제를 후기로 남긴다.


Heap

우선, Heap은 완전 이중 트리 중 하나로 우선순위에 따라 정렬되는 자료구조이다. 깃허브 Heap 구현 코드를 작성해 두었다. 코드가 그다지 깔끔하다고는 할 수 없지만, 최소힙으로 필요한 부분은 대부분 구현해 두었다.

깃허브에 작성해 둔 것처럼 Heap과 우선순위 큐를 혼동할 수 있다. Heap은 구체적인 자료구조로 완전 이중 트리로 구현될 수 있다. 반면, 우선순위 큐는 추상적인 자료구조로 Heap, Array 등 다양한 방법으로 구현할 수 있다. Heap은 완전 이중 트리로 구현된 만큼 시간 복잡도가 O(log n)로 빠르다.

자바에서는 PriorityQueue 클래스가 Heap을 이용해 구현된 우선순위 큐이다. 우선순위에 따라서 큐가 정렬되며 기본적인 우선순위(최소값) 외에도 Comparator를 커스텀하여 PriorityQueue 클래스를 사용할 수 있다.

import java.util.*;

PriorityQueue<CustomObject> queue = new PriorityQueue<>(new Comparator<CustomObject>(){
	@Override
    public int compare(CustomObject o1, CustomObject o2){
    // 커스텀
    // o1가 앞에 온다면 양수, o2가 앞에 온다면 음수, 같으면 0 반환
    }
});

Programmers 코딩테스트 - 이중우선순위큐

- 문제 -
이중 우선순위 큐는 다음 연산을 할 수 있는 자료구조를 말합니다.
1. 명령어 : I 숫자, 실행 : 큐에 주어진 숫자를 삽입합니다.
2. 명령어 : D 1, 실행 : 큐에서 최댓값을 삭제합니다.
3. 명령어 : D -1, 실행 : 큐에서 최솟값을 삭제합니다.

이중 우선순위 큐가 할 연산 operations가 매개변수로 주어질 때, 모든 연산을 처리한 후 큐가 비어있으면 [0,0] 비어있지 않으면 [최댓값, 최솟값]을 return 하도록 solution 함수를 구현해주세요.

제한사항
operations는 길이가 1 이상 1,000,000 이하인 문자열 배열입니다.operations의 원소는 큐가 수행할 연산을 나타냅니다.원소는 “명령어 데이터” 형식으로 주어집니다.- 최댓값/최솟값을 삭제하는 연산에서 최댓값/최솟값이 둘 이상인 경우, 하나만 삭제합니다.빈 큐에 데이터를 삭제하라는 연산이 주어질 경우, 해당 연산은 무시합니다.

Programmers에서 위와 같은 문제를 Heap을 이용해 해결하였다.
최소힙과 최대힙을 각각 구현하기 보다는 하나의 Array로 최소힙 정렬과 최대힙 정렬을 필요에 따라 적용하는 방법을 사용했다. 이렇게 구현하게 되면 최소 및 최대값을 번갈아가며 출력할 때, 시행횟수가 다소 많지만, 두 Arrays를 동시에 이용하는 것보다는 번거로움이 적고 힙의 시간복잡도가 빠른 것을 활용하여 구현하였다.

Dqueue라는 클래스를 직접 구현하였으며, 일반적인 Heap은 요소 추가시 항상 우선순위에 따른 정렬을 유지하지만 직접 구현한 클래스 에서는 시행횟수를 줄이기 위해 단순 추가만 하였다. 그리고 최소값과 최대값은 최소값 우선 정렬 및 최대값 우선 정렬을 이용해 구현해두었다.

문제풀이

import java.util.*;
class Dqueue{
    private int[] heap;
    private int size;
    private int capacity;
    
    public Dqueue(int initCapa){
        this.heap = new int[initCapa + 1];
        this.size = 0;
        this.capacity = initCapa;
    }
    
    private int parent(int idx){
        return idx / 2;
    }
    private int leftChild(int idx){
        return idx * 2;
    }
    private int rightChild(int idx){
        return idx * 2 + 1;
    }
    
    public void ensureCapa(){
        if(size >= capacity){
            capacity *= 2;
            heap = Arrays.copyOf(heap, capacity + 1);
            
        }
    }
    
    public void add(int element){
        ensureCapa();
        heap[++size] = element;
    }
    
    private void swap(int a, int b){
        int temp = heap[a];
        heap[a] = heap[b];
        heap[b] = temp;
    }
    
    private void maxSort(){
        int originSize = size;
        for(int i = size; i > 1; i--){
            maxUp(i);
        }
    }
    
    private void maxUp(int idx){
        int current = idx;
        while(current > 1){
            if(heap[current] > heap[parent(current)]){
                swap(current, parent(current));
                current = parent(current);
            } else{
                return;
            }
        }
    }
    
    private void minSort(){
        int originSize = size;
        for(int i = size; i > 1; i--){
            minUp(i);
        }
    }
    
    private void minUp(int idx){
        int current = idx;
        while(current > 1){
            if(heap[current] < heap[parent(current)]){
                swap(current, parent(current));
                current = parent(current);
            } else{
                return;
            }
        }
    }
    
    public int getMin(){
        if(size == 0){
            return 0;
        }
        minSort();
        int min = heap[1];
        heap[1] = heap[size--];
        return min;
    }
    
    public int getMax(){
        if(size == 0){
            return 0;
        }
        maxSort();
        int max = heap[1];
        heap[1] = heap[size--];
        return max;
    }
    
    public int[] getHeap(){
        if(size == 0){
            return new int[0];
        }
        return Arrays.copyOfRange(heap,1,size);
    }
}

class Solution {
    public int[] solution(String[] operations) {
        int[] answer = new int[2];
        Dqueue q = new Dqueue(1);
        for(String op : operations){
            switch (op.substring(0,1)){
                case "I" : q.add((int) Integer.valueOf(op.substring(2))); break;
                case "D" : if(op.substring(2).equals("1")){
                    int a = q.getMax();
                    // System.out.println(a);
                }else{
                    int b = q.getMin();
                    // System.out.println(b);
                }
            }
        }
        answer[0] = q.getMax();
        answer[1] = q.getMin();
        return answer;
    }
}

테스트 결과도 꽤 준수한 것을 볼 수 있다. 비록 구현해 두었으나 사용하지 않은 코드들도 있으니 그러한 부분은 참고하기를 바란다.

테스트 결과


공부 후기

자료구조를 공부하면서 공부한 자료구조를 직접 구현해보니 훨씬 더 이해가 잘된다. 그리고 막히는 부분은 이미 잘 구현된 코드를 보면서 부족한 부분을 공부하니 코드를 작성할 때, 발생하는 오류, 제한사항 등 아직은 신경쓰지 못하는 부분들도 세세하게 공부할 수 있어서 많은 도움이 되었다.

728x90
728x90

Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure.
The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.

도커로 Mysql을 실행하고 Java Spring으로 구현한 서비스를 도커에서 실행된 Mysql과 연동했던 프로젝트가 있었다. 서버의 사용기간(NCP 크레딧 만료)으로 인해 서버를 Standard에서 Micro로 변경할 수 밖에 없었다. 서버를 내리고 Micro 서버를 개설했다.(terraform을 사용하여 일련번호만 변경했고 매우 편한게 처리했다.) 새로 개설한 서버에 도커를 이용하여 NGINX, Mysql, Java Spring app을 실행하려고 했으나, 서버 성능이슈로 인하여 실패.(하루를 날려 먹음. 너무 느려서)

NGINX를 포기하고 Mysql과 Spring App만 구동하려고 했으나, 기존에는 잘 연결되었는데 이번에는 Spring에서 MySql이 연결이 되지 않는 다는 에러로 인하여 많은 시간을 허비했다. 결국 문제를 해결하여 해결방안을 기록한다.


기존 서버에서 교체서버로 다시 배포

기존 프로젝트에서 아래와 같이 yml을 작성했다. 도커를 이용하여 배포를 진행하고 데이터베이스는 도커로 실행한 Mysql을 이용하는 프로젝트였다.

Mysql 포트 포워딩을 3306:3306으로 해두었으므로 도커 호스트(도커 호스트의 IP는 172.17.0.1이다.)의 3306포트로 접근하면 Mysql과 연결을 할 수 있기 때문에 '//172.17.0.1:3306/my_database'로 URL을 설정했다.

datasource:
  url: jdbc:mysql://172.17.0.1:3306/my_database
  username: ${database.username}
  password: ${database.password}
  driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
  database-platform: org.hibernate.dialect.MySQL8Dialect

하지만 서버를 교체하는 과정에서 글 상단에 기술한 것처럼 "Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure."을 마주하게 되었다.


해결과정 - 서버가 너무 느려서 결국 로컬에서 실시

첫번째로, Mysql의 bind-address을 확인하고 변경하는 해보았다. /etc/my.conf 에서 아래처럼 내용을 추가(수정)했다.

[mysqld]
bind-address = 0.0.0.0

Mysql에서 쿼리를 이용해 변경된 것을 확인하고 다시 서비스 도커를 실행. 하지만 이번에도 연결에 실패했다.

SHOW VARIABLES LIKE 'bind_address';

+---------------+-----------+
| Variable_name | Value     |
+---------------+-----------+
| bind_address  | 0.0.0.0   |
+---------------+-----------+

 

두번째, 혹시 mysql가 실행이 안되고 있는 것은 아닌지, 연결이 안되고 있는 것은 아닌지 확인했다.

  • 도커에서 컨테이너 현황을 확인하여 Mysql이 제대로 실행 되고 있는지 확인
  • Mysql 컨테이너에 접속하여 데이터베이스 직접 확인
  • 다른 컨테이너를 실행하여 'ping 172.17.0.1:3306'을 테스트

결론은 모두 정상이였으며, mysql 컨테이너에서는 문제가 없었다.

 

세번째, 버전확인 서비스를 빌드할 당시 connector-j의 버전이 8.3.0임을 확인하고 현재 최신 Mysql의 버전이 8.4로 항상 최신으로 사용하던 습관 때문에 미처 버전이 다른 것을 인지하지 못했다. mysql 버전을 8.3.0으로 다시 pull 받고 컨테이너를 실행했다.

결론은 역시나 실패. 연결이 되지 않았다.

 

네번째, 도커 네트워크를 사용하여 mysql과 서비스를 한 네트워크로 묶는 방법을 사용하였다.

docker network create mynet

docker run -d mysql --network mynet -p 3306:3306
docker run -d myservice --network mynet -p 80:8080

이렇게 도커 네트워크로 두 컨테이너를 묶고 연결을 시도했다. 하지만 실패했다. 그러나 유의미한 변화가 생겼다. 에러 메세지가 변경되었다.

java.sql.SQLSyntaxErrorException: Access denied for user 'user'@'%.%.%.%' to database 'my_database'

아무런 패킷을 받지 못했다는 에러메세지였으나, 이번에는 접근이 거절되었다는 메세지로 바뀌었다. 이번 시행에서 mysql에서 User의 권한을 미처 설정 하지 못해서 생겼던 에러였다. 권한문제를 해결하고 다시 실행한 결과, 드디어 서비스와 Mysql의 연결을 성공했다.


초기 yml 설정을 변경하지 않고도 문제를 해결할 수 있어서 매우 다행이기는 하나, 글에 적힌 것에 비해서 생각보다 많은 시간을 소비해야했다. 기존에는 잘 실행됐는데 다시 하는과정에서 에러가 발생하여 오는 당혹감과 서버 성능으로 인한 실행 지연, 피드백 지연으로 화가 머리끝까지 나기도 했다. 또한 도커 내부에서 통신을 해야하는 환경이다보니 익숙하지 않고, 피드백이라고는 고작 Java 에러메세지가 전부라서 해결에 어려움이 많았다.

그래도 기존에 통신에 대해 공부했던 내용과 도커에 대해 다시 한번 공부 할 수 있었다. 비록, 큰 서비스는 RDBS를 별도의 서버로 두지만 이런 작은 서비스에서는 별도의 서버를 두기에는 어려움이 있기 때문에 한 서버에 서비스와 데이터베이스를 같이 두었으나, 실제 현업에서 실용성이 있을지는 의문이다.

그래도 이번에 얻는 것은 하드웨어 선택, 통신, 버전 관리, Spring 설정 등 다양한 방면을 고루 공부할 수 있었던 것은 매우 좋은 경험이라고 생각한다.

728x90

+ Recent posts