드디어(?) 그동안 준비해오던 책이 출판되었다. 사실 원고는 작년에 완성하였는데, 디자인과 보정작업이 생각보다 길어져서 이제서야 나오게 되었다. 언어전환이라는 사례를 들고는 있지만 전반적인 프로젝트 관리에 대한 내용을 담고 있어서 독자들이 이부분에 관심을 많이 가져주었으면 하는 바램이다. 언어전환이라는 사례가 누군가에게는 불편할 수도 있는 것이라 조금 우려가 되기도 하지만… 잘팔리는 것보다도 논란거리가 없었으면 하는게 내 바램이다 (잘팔리면 더 좋고!) ^^;;

판매 링크

my_book


주말에 춘천 라이딩을 다녀왔다. 벚꽃이 만개한 주말에 갔더라면 훨씬 더 좋았겠지만 해당 주에는 처가댁에 다녀오느라 벚꽃이 다 진 4월 2째주에나 다녀올 수 있었다. 서울에서 춘천은 약 100KM 정도 되는 거리인데 편도라면 부담없이 갈 수 있고 돌아올 때 지하철을 타면 편리하게 올 수 있기 때문에 중거리 라이딩하기에 딱 좋은 코스이다. 가는길에 10명정도 되는 자전거 팩에 탑승해서 갔는데, 덕분에 체력을 많이 아낄 수 있었다. 역시 혼자타는 것보단 여럿이서 타는게 재밌는데…동아리를 들어가는건 솔직히 아직도 부담스럽다 ^^;;

chun_cheon_riding


회사에서 작년에 이어 올해도 리프레시데이를 주었다. 최근에 수행했던 프로젝트에서 말도 많고 탈도 많았는데 해당 프로젝트가 끝나고 리프레시데이를 줌으로써 사기를 북돋기 위함이리라 생각이 든다. 작년에는 백엔드 챕터끼리 꿉당에 고기를 구어먹으러 갔었는데, 이번에는 마음 맞는 사람끼리 등산을 가기로 했다. 사실 목적은 등산 후 막걸리 한잔하는게 목적이긴 했다 ㅋㅋㅋ

용마산을 오르기로 했는데 너무 높지도 않고 등산로가 잘되어있어서 샤방하게(?) 가기 딱 좋았던 것 같다. 등산 후 내려와서 두부김치에 막걸리도 마시고 보드카페에서 게임도하고 3차에서 양꼬치에 고량주도 마시고 아주 알차게(?) 놀았다.

yongma_mountain


코로나가 점점 종식되면서 다들 그동안 미뤄왔던 결혼식을 올리는 것 같다. 특히 4월은 결혼식이 많은 달이었는데 나도 그렇고 아내도 그렇고 서로 결혼식에 참가하느라 주말에는 거의 쉬지도 못하는 것 같다.

최근에 친한 형이 부산에서 결혼식이 있어 서울에 사는 친구들과 부산에 같이 내려갔다. 삼성에 다니는 친구들이라 해운대에 있는 한화리조트에서 묵었는데 대기업 다니는 친구들 덕분에 광안대교가 보이는 숙소에서 묵는 호사(?)를 누릴 수 있었다.

서울사는 친구들 이야기를 들어보니 회사에 부산으로 발령을 받을 수 있는지 문의해 놓은 상태라고 한다. 이전에 부산에서 대학을 다닐때에는 몰랐는데 서울살이를 하다가 부산에 오니 바다도 너무 좋고 뭔가 모를 여유로움이 너무 좋았다. 그래서 친구들도 부산으로 가려고 하지 않았을까 생각된다. 만약에 우리 회사도 워케이션이 가능했더라면 부산에서 한동안 지내는것도 생각해 봄직했을 텐데 그러지 못해서 아쉬운 마음이다.


요금 구현해본적이 없는 API 설계라던지 구글링을 통해 원하는 바를 찾지 못했을 때 힌트를 얻기위해서 ChatGPT를 많이 활용하고 있다. 아직 ChatGPT에서 제안해주는 코드들을 그대로 활용하긴 어렵지만 키워드 조차 알기 어려워서 구글링을 통해 정보를 찾기 어려웠던 것들을 많이 해결할 수 있어서 좋았다. 유료 버전을 사용하면 좀더 높은 퀄리티의 답변을 받을 수 있다고 하는데 사실 무료버전만으로도 충분히 만족하고 있어서 아직까지는 유료버전을 결제할 생각은 없다.

최근에 경수님 만화를 보다가 ChatGPT 관련 이야기가 있어서 올려본다 ㅋㅋ 라떼(?)는 진짜 Stackoverflow가 만병통치약이었는데 이제는 ChatGPT로 바뀌지 않을가 생각된다.

chat_gpt_meme


동료 개발자분께서 3D 프린터를 이용하여 키보드를 제작하시는데 좋아보여서 하나 장만했다. 솔직히 키 배열이 익숙치 않아서 적응하기까지 다소 시간이 걸릴거 같지만 가볍고 휴대하기 좋을거 같아서 하나 사보았다.

재미있는건 키배열을 코드로 커스터마이징 할 수 있다는 것인데 Github에 키배열 값을 저장해두고 main에 병합하면 키맵 설정 파일을 다운 받을 수 있고 키보드에 적용하면 바로 변경해서 사용할 수 있다.

3주 정도 사용하니 조금 적응할만한거 같은데 여전히 생산성이 반토막난 상태이긴 하다 ^^;;

keyboard


아래는 4월동안 정리한 이슈 내용들이다.

기능테스트에서의 JMS 이슈

테스트에서 JMS를 이용한 메시지 전송 기능 테스트 시 이슈가 있었다. MockServer를 이용하는데 verify가 계속 되지 않는 현상이 지속되었는데 원인을 찾아보니 JMS로 메시지를 전송하면 expectation이 기록되지 않는 현상이 생기는 것을 발견할 수 있었다.

의심가는 부분이 생겼으니 아래와 같이 테스트를 해보았다.

class FooTest(
    private val fooService: FooService,
) : FunctionalTestBase() {
    init {
        test("test") {
            mockery.sendSendbirdUserMessageToGroupChannel()
            fooService.test()
            mockery.verifySendUserMessageToGroupChannel("", "", ORDER_WELCOME_CUSTOM_TYPE, "")
        }
    }
}

@Service
class FooService(
    private val jmsTemplate: JmsTemplate,
) {
    fun test() {
        jmsTemplate.convertAndSend("foo.test", "send test")
    }
}

@Component
class FooListener(
    private val sendbirdApi: SendbirdApi
) {
    @JmsListener(destination = "foo.test")
    fun handle(event: String) {
        val payload = SendbirdSendUserMessagePayload(
            message = """
                안녕하세요. 테스트입니다. 첫 주문을 보내주세요!

                🛒 주문 가이드 : bit.ly/3R3tDyb

                💬 궁금한 내용이 있으면 채팅으로 물어보세요.
            """.trimIndent(),
            customType = ORDER_WELCOME_CUSTOM_TYPE,
            userId = "",
            data = "",
        )

        sendbirdApi.sendUserMessageToGroupChannel(
            groupChannelUrl = event,
            payload = payload,
        )
    }
}

역시 실패하는 것을 볼 수 있다. JMS가 기본적으로 메시지를 동기적으로 전송하도록 설정(참고: https://activemq.apache.org/how-do-i-enable-asynchronous-sending) 한다고는 하지만 MockServer에서 Expectation이 기록되지 않는 이유는 정확하게 모르겠다. 그래서 Await를 걸어 검증하는 방식으로 변경하여 해결하였다.

test("test") {
    mockery.sendSendbirdUserMessageToGroupChannel()
    fooService.test()
    Awaitility.await().atMost(Duration.ofSeconds(2)).untilAsserted {
        mockery.verifySendUserMessageToGroupChannel("", "", ORDER_WELCOME_CUSTOM_TYPE, "")
    }
}

위와 같은 방법 말고도 Kotest에서 제공하는 Eventually를 활용할 수도 있다.

test("test") {
    mockery.sendSendbirdUserMessageToGroupChannel()
    fooService.test()

    eventually(2.seconds) {
        mockery.verifySendUserMessageToGroupChannel("", "", ORDER_WELCOME_CUSTOM_TYPE, "")
    }
}

unleash

관리자에 Unleash를 적용해서 Breaking Change가 발생하는 배포가 있을 때 공사중 페이지를 띄워서 사용자가 접속하지 못하도록 막는 기능을 구현했다.

admin_renewal_page

좋은 서비스 디자인

사내 워크샵에서 좋은 서비스를 위한 디자인이라는 주제로 발표를 하였는데 내용이 좋아서 좋은 서비스 디자인의 15가지 원칙만 적어본다.

좋은 서비스는

  • 찾기 쉽다.
  • 목적을 분명하게 설명한다.
  • 사용자의 기대치를 설정한다.
  • 사용자가 원하는 결과를 얻도록 만든다.
  • 친숙한 방식으로 기능한다.
  • 사전 지식이 없어도 사용할 수 있다.
  • 조직의 구조와 무관하다.
  • 최소한의 단계만 필요로 한다.
  • 전체적으로 일관성 있다.
  • 막힘이 없다.
  • 모든 사람이 동등하게 사용할 수 있다.
  • 사용자와 직원이 올바른 행동을 하도록 장려한다.
  • 변화에 빠르게 대응한다.
  • 결정의 이유를 명확히 설명한다.
  • 도움을 받는 것이 쉽다.

Dynaimc Multi Tenancy in JPA

JPA에서 조직별로 스키마를 다르게 운영할 수 있도록 하는 기능을 개발해보았다. JPA에서는 Entity를 분석해서 쿼리를 실행할 때 StatementInspector를 통해서 커스터마이징 할 수 있는 기능을 제공해주는데 이를 이용하면 손쉽게 조직별로 다른 스키마에 쿼리를 변경해서 실행시킬 수 있다.

class DynamicCatalogNameInterceptor : StatementInspector {
    override fun inspect(sql: String): String {
        val authentication = SecurityContextHolder.getContext().authentication

        return if (authentication != null) {
            val principal = authentication.principal as SecurityUser
            sql.replace("###catalog_name###", principal.catalogName)
        } else {
            sql
        }
    }
}

여기서 신경써야할 부분은 위에 인터페이스를 정의해도 Hibernate가 해당 Interceptor를 적용해주지 않는다는 것이다. 그래서 아래와 같이 설정을 해주어야 한다.

spring:
  jpa:
    properties:
      hibernate.session_factory.statement_inspector: com.example.demo.DynamicCatalogNameInterceptor

POC에 대한 코드는 Github에서 확인해 보길 바란다.

당신이 성장하지 못하는 이유

최근에 당신이 성장하지 못하는 이유라는 제목의 글을 읽게 되었다.

요지는 현실에 안주하지 말고 끊임없이 도전해야 성장할 수 있다는 것이다. 예전처럼 성장에 목메지는 않지만 그렇다고 현실에 안주하고 싶은 마음은 없기에 해당 글에서 말해주는 여러 조언들을 잘 새겨놔야겠다.

정보 통신 용어 사전

최근 특정 용어에 대한 영어명칭을 찾던 중 정보 통신 용어 사전이라는 사이트를 발견하게 되었다. 일반적으로 우리가 사용하던 단어들을 영어와 함께 설명해주므로 유용하게 써볼 수 있을거 같다.

Undertow access logging 설정 방법

Spring Boot에서 Embedded Undertow의 Access Log를 설정하는 방법은 아래와 같다.


undertow access logging 설정 방법

server:
  undertow:
    accesslog:
      enabled: true
      dir: /tmp/
      pattern: '%t[%{i,X-Forwarded-For}(%a)] | [%m %U][%s %Dms][%bbytes] | [%{i,User-Agent}]'
    options:
      server:
        record-request-start-time: true

기본적으로 정의된 패턴이 있으니 해당 패턴은 Wiki를 참고하자.

logback을 xml이 아닌 class file로 설정하는 방법

Spring Boot에서 Logbak을 설정하는 방법을 구글링해보면 대부분 xml을 이용한 설정 방법만 알려준다. 최근에는 Class file을 이용한 설정을 하는 추세인데 Logback만 유달리 xml로 설정하는 경우가 대부분인거 같다. 그래서 Class로 정의하는 방법을 적어본다.

@Configuration
class LogConfig {
    private val context = LoggerFactory.getILoggerFactory() as LoggerContext
    private val defaultLogPattern = "[user] %-5level: [%d{YYYY-MM-DD HH:mm:ss}] %msg%n"

    val filePath = "/tmp/"
    val namePattern = ".%d{yyyy-MM-dd}-%i"
    val fileName = "system_log"
    val ext = ".log"

    init {
        val rootLogger = context.getLogger(ROOT_LOGGER_NAME)
        rootLogger.level = Level.INFO
        rootLogger.addAppender(consoleAppender())
        rootLogger.addAppender(fileAppender())
    }

    private fun consoleAppender(): ConsoleAppender<ILoggingEvent> {
        val appender = ConsoleAppender<ILoggingEvent>()
        appender.context = context
        appender.name = "CONSOLE"
        appender.encoder = encoder(defaultLogPattern)
        appender.start()
        return appender
    }

    private fun fileAppender(): RollingFileAppender<ILoggingEvent> {
        val appender = RollingFileAppender<ILoggingEvent>()
        appender.context = context
        appender.name = "FILE"
        appender.encoder = encoder(defaultLogPattern)
        appender.file = filePath + fileName + ext
        appender.isAppend = false
        appender.rollingPolicy = rollingPolicy(appender)
        appender.start()
        return appender
    }

    private fun rollingPolicy(appender: RollingFileAppender<ILoggingEvent>): SizeAndTimeBasedRollingPolicy<RollingPolicy> {
        val policy = SizeAndTimeBasedRollingPolicy<RollingPolicy>()
        policy.context = context
        policy.setParent(appender)
        policy.fileNamePattern = filePath + fileName + namePattern + ext
        policy.maxHistory = 14
        policy.setMaxFileSize(FileSize.valueOf("10MB"))
        policy.start()
        return policy
    }

    private fun encoder(pattern: String): PatternLayoutEncoder {
        val encoder = PatternLayoutEncoder()
        encoder.context = context
        encoder.pattern = pattern
        encoder.start()
        return encoder
    }
}

Graceful shutdown의 기본 대기 시간

Spring boot의 Graceful Shutdown의 기본 대기시간은 얼마일까? 30초이다. 기억해두자.

출처: https://www.baeldung.com/spring-boot-web-server-shutdown

Embedded Undertow warning

Embedded Undertow를 설정하면 아래와 같이 warning이 뜨는 것을 볼 수 있다.

undertow_warning

큰 문제는 없는데 Warning을 그대로 두는건 불편하니 제거해주면 좋다. 아래와 같이 Gradle에 설정하면 제거할 수 있다.

implementation("org.springframework.boot:spring-boot-starter-undertow") {
    exclude(group = "io.undertow", module = "undertow-websockets-jsr")
}