Now Loading ...
-
"02와 똑같다", 그리고 감성 맞추기 세 차례
“02와 똑같다”, 그리고 감성 맞추기 세 차례
지난 편 끝에서 구역 탐험형을 골랐다. 이번 편은 그걸 만들어 본 이야기, 그리고 그다음에 그림으로 감성을 맞춰 보려던 이야기다.
프로토타입 03
이번엔 순서를 지켰다. 판정 규칙을 먼저 적고, 조작은 직접 액션으로 골랐다. 자기가 만든 것도 억지로 좋게도 나쁘게도 보지 말고 중립적인 비평가의 눈으로 다시 보는 단계를 늘 두자고 요청해서, 만들기 전과 만든 뒤에 비평가 점검을 적게 했다.
던전 하나에 구역 셋과 보스 방. 구역마다 적의 성질이 다르다. 맞을 때 피해를 깎는 단단한 적, 멀리서 쏘고 물러나는 적. 떨어진 장비는 그 자리에서 주워 바로 바꿔 낀다. 판 사이에는 장비를 고르고, 분해하고, 옵션을 뽑고, 강화한다.
화면 없이 판을 도는 봇으로 먼저 돌려 봤더니, 첫 판 봇이 1구역에서 20초를 못 버텼다. 수치를 고쳤다.
프로토타입 03, 1구역 8초째. 가만히 서 있으면 이렇게 된다(체력 12). 처음엔 봇도 여기서 20초를 못 버텼다.
“02와 똑같다”
처음 몇 번은 예전에 띄워 둔 서버에서 프로토타입 02를 열고 “달라진 게 없다”고 했다. 주소를 바로잡아 03을 열었다. 그리고 해 본 뒤의 말.
“똑같은데 그냥 원래랑 비교했을 때 자동으로 공격 나가던데 내가 수동으로 해야 하는 거 차이 정도잖아”
“나는 이런 감성을 원하는 게 아니라니까? 자꾸 프로토타입을 만들어야 한다는 강박에 이러는 거면 그러지 않아도 돼. 퀄리티가 너무 구려”
“프로토타입은 꼭 실제 플레이가 아니어도 돼. 컨셉 아트라든지 다른 방식으로라도 상관이 없어”
Claude가 만든 뒤에 적은 비평가 점검은 03을 흔한 장르와만 비교했다. 작은 디아블로 같다는 식이었다. 바로 직전에 내가 기각한 02와는 비교하지 않았다. 그래서 돌아간 질문이 “뭐가 작은 디아블로냐”였다.
틀린 이유는 둘이었다. 03은 02와 같은 그림과 코드에 구조만 바꾼 것이라, 해 보는 사람 눈에는 02와 같은 게임이었다. 그리고 둘 다 모눈종이 위의 원과 사각형이라, 내가 원한 감성이 맞는지는 처음부터 물을 수가 없었다. 그 뒤로 비평가 점검에는 “직전과 같은 점”을 반드시 적고, 새 프로토타입은 만들기 전에 “왜 이 질문을 이 형태(플레이, 그림, 글)로 묻는가”를 적게 했다.
감성 맞추기
그래서 플레이 대신 그림으로 가기로 했다. 내가 끌렸던 옛 게임 화면 여섯 장을 건넸다(다른 회사 게임 화면이라 여기에는 싣지 않는다). Claude는 그림을 읽고 감성을 글로 정리했다. 내 답은 “말로만 알려 주는 건 의미가 없다, 싱크가 맞아야 한다”였다.
그래서 맞혀 보기로 했다. 공개된 스팀 상점 스크린샷을 모아, 내가 보기 전에 Claude가 그림마다 끌림인지 별로인지 먼저 걸고 커밋한다. 그다음 내가 클릭으로 답한다. 맞힌 비율이 80%를 넘고, 늘 같은 답만 찍어도 나오는 비율(기준선)보다 15%p 이상 높으면 “싱크가 맞다”.
세 차례 했다.
1차, 2차는 기준선보다도 낮았다. 2차에서 하나 건진 것은, Claude가 싫어할 거라고 본 쪽(무거운 3D, 대전 연출, 적 떼만 있는 빈 바닥)이 열 장 중 아홉 장 맞았다는 것이다. 싫은 건 잘 맞히고, 좋은 건 못 맞혔다. 맛집 추천은 못 해도 피해야 할 집은 아는 친구가 된 셈이다.
3차에는 그림마다 이유를 하나 이상 고르게 했다. 앞의 두 차례는 끌림과 별로만 모아서, 왜 그런지 알 길이 없었기 때문이다.
이유를 모으자 한 번 만에 보이는 게 생겼다. 끌림의 큰 이유는 “할 게 보임”, 화면에 파밍할 것과 장비와 카드가 보이는 것이었다. Claude는 21장 중 어디에도 이 이유를 걸지 않았다. 별로의 첫째 이유는 어둡거나 무거운 게 아니라 “너무 단순, 밋밋함”이었다.
3차의 적중률은 71%로 기준선을 넘었다. 다만 지난 차례의 답을 그대로 따라 했으면 더 잘 맞혔을 것이다. 싱크가 맞았다는 판정은 끝내 한 번도 나오지 않았고, 나는 “3차까지 했으면 할 만큼 한 것 같다”며 거기서 멈췄다.
현재 상황
세 차례에서 남은 기준은 이렇다.
끌리는 것: 할 게 보이는 화면, 공들인 그림체와 캐릭터, 손맛 있는 전투
끌리지 않는 것: 밋밋함, 정신없음, 메뉴가 화면을 차지하는 것
다음은 이 기준과 지금까지의 결정을 대 보고, 컨셉 아트로 “이거다, 아니다”를 가리는 것이다. 이번엔 원과 사각형 말고 그림으로.
여기까지가 10월 말부터 11월 9일까지의 이야기다. 메타포 세 개로 시작해서, 프로토타입 셋, 시뮬레이션 세 번, 감성 맞추기 세 차례를 거쳤지만, 아직 무슨 게임인지 한 줄로 말하지 못하는 상태다. 이 일지는 여기서부터 이야깃거리가 모일 때마다 이어 간다.
-
추천만 골랐더니 뱀파이어 서바이버가 되었다
추천만 골랐더니 뱀파이어 서바이버가 되었다
이번 편은 반성문에 가깝다.
하루에 정한 것들
1편에서 로드맵을 뒤집은 날, 틀과 판 밖 경제를 질문 하나씩 정했다. 방식은 이랬다. Claude가 선택지를 내고 그중 하나에 “추천”을 붙인다. 나는 고른다.
그날 판 밖 경제 단계에서 고른 답은 하나도 빠짐없이 추천안이었다.
하나하나에는 이유가 붙어 있었다. 타깃이 둘(서바이버류를 즐기는 사람, 옛 한국 온라인 게임을 기억하는 사람)이니 판 안은 서바이버류 기준으로 맞추자. 판에서 주운 카드는 몇 장만 가져가고 나머지는 강화 재료로 바꾸자. 강화는 숫자보다 카드의 성격을 바꾸게 하자. 들어 보면 다 그럴듯했다.
같은 날 설계 점검은 “이 게임만의 차별점이 없다”, “맵 감성이 드러날 자리가 없다”를 이미 짚어 두었다. 둘 다 “나중에, 나란히 본다”는 칸으로 들어갔다. 그리고 그 위에 경제를 쌓고, 시뮬레이션을 돌리고, 프로토타입을 만들었다.
프로토타입 02
판 밖 루프를 시험하는 프로토타입이었다. 한쪽 칸에는 보관함, 강화, 옵션 뽑기가 있고, 다른 칸에는 없다. 계획은 사흘 넘게 칸마다 아홉 판 이상 해 보고 판정하는 것이었다.
판정은 첫 플레이에서 끝났다. 해 보고 남긴 말은 이랬다.
“내가 원한 건 이런 게임은 아니다. 그냥 평범한 뱀파이어 서바이버 같은 게임이다. 아류작도 너무 많다”
판 밖 루프는 “굉장히 지루하다”
“별다른 차별점도 못 느끼겠다”
프로토타입 02. 1편의 프로토타입 01 화면과 나란히 놓고 다른 그림 찾기를 해 보면, 난이도가 꽤 높다.
그리고 한 줄을 더 남겼다. “추천대로만 골랐는데 양산형으로 만들어지고 있었다.”
무엇이 잘못됐나
다시 대 보니, 원하는 것과 크게 부딪친 결정이 셋이었다. 판 안을 서바이버류로 둔 것, 판에서 얻는 것이 대부분 재료가 되는 것, 강화의 수치를 작게 둔 것. 셋 다 그날 고른 추천안이다. 위 그림의 빨간 칸이 그것이다.
원인은 하네스가 보지 않던 부분에 있었다. 하네스는 규칙끼리 맞는지는 꼼꼼히 봤지만, 방향이 처음 끌렸던 것과 맞는지, 남과 다른지는 보지 않았다. 추천을 낼 때도 그 안이 처음 끌렸던 지점에서 얼마나 멀어지는지는 적지 않았다. 추천을 낸 쪽도, 고른 쪽도, 그날은 방향을 묻지 않았다.
핵심 경험 기준 네 가지
그래서 원하는 것을 내 말로 적었다. 네 가지다.
강해진 체감: 아이템을 올리면서 강해졌다는 것이, 혼자 하는 플레이에서도 시원시원하게 느껴져야 한다
득템의 재미: 직관적이어야 한다. 늘 재료만 파밍하는 건 재미가 없다
생각하며 하는 플레이: “이 상황에선 이 아이템이면 돌파하겠다”, “이건 아쉽다, 옵션을 다시 뽑을까, 장비를 바꿀까” 하고 머리를 굴리는 게임
보이는 변화: 같은 사각형 맵만 돌아다니면 강해졌는지도 느껴지지 않는다
이제 모든 결정과 추천은 이 네 가지에 대 본다. 세션을 시작하면 훅이 이 네 줄을 먼저 띄우고, 선택지를 낼 때는 안마다 네 기준에 맞는 점과 부딪치는 점을 적는다. 미뤄 둔 방향 문제가 있으면 그 위에 쌓기 전에 먼저 묻는다.
그리고 다시
이 기준으로 판 안의 형태를 다시 골랐다. 후보는 둘이었다. 던전 하나를 구역마다 지나며 장비를 줍는 구역 탐험형, 그리고 스테이지를 골라 준비하고 도전하는 공략 스테이지형. Claude는 구역 탐험형을 추천했다.
나는 그걸 골랐다. 또 추천안이었다. 이번에는 네 기준에 대 본 표가 붙어 있었다는 점이 달랐다.
그 결과는 다음 편에.
-
실수를 문장 말고 장치로 막기
실수를 문장 말고 장치로 막기
지난 편에서 Claude의 실수가 넷 나왔다. 사람끼리라면 “다음엔 조심하자” 한마디로 넘어갔을 것이다. 그런데 새 세션의 Claude는 지난 세션의 다짐을 모른다. 다짐을 적어 둔 문서도, 읽지 않으면 없는 것과 같다.
그래서 다짐 대신 장치를 두기로 했다.
짚지 않아도 알아서 발전하는 하네스
요청은 한 줄이었다. 내가 짚지 않아도 알아서 발전하는 하네스를 만들어 달라.
그렇게 생긴 것들이다.
레포 검사(tools/check.mjs): 문서와 코드가 서로 맞는지, 바꾼 코드에 테스트가 따라왔는지, 모든 테스트가 도는지 본다
훅: 세션을 시작하면 지난번 멈춘 곳과 아직 못 막은 실수를 띄워 주고, 커밋하기 전에 검사를 돌려 실패하면 커밋을 막는다
스킬: 세션 마무리 회고, 시뮬레이션 수정, 결정 기록의 순서표
교훈 기록부: 실수마다 한 줄. 무슨 일이 있었고, 왜 났고, 무엇으로 막았는지
핵심은 장치에 강도가 있다는 것이다. 기계가 판단할 수 있으면 자동 검사, 정해진 순간에 끼어들어야 하면 훅, 사람의 판단이 필요하면 스킬. 문장은 어느 것도 안 될 때만 쓴다. 문장이 가장 약하기 때문이다.
검사를 하나 더할 때마다 일부러 그 실수를 다시 심어 보고, 검사가 정말 잡는지 확인한 뒤 되돌렸다. 여기까지는 또 모범적이었다.
장치를 만들다가 장치를 부쉈다
하네스를 만드는 도중에 사고가 났다. 정규식을 고치려고 sed -i를 썼는데, 바꾼 것이 정규식만이 아니었다. 파일 안의 /가 몽땅 바뀌면서 레포 검사 스크립트와 세션 시작 훅이 통째로 망가졌다. 둘 다 막 만든 새 파일이라 git으로 되돌릴 것도 없었다.
그래서 장치를 하나 더 만들었다. 검사가 모든 스크립트를 문법부터 읽어 보게 했다. 그리고 문장도 하나 적었다. “파일은 셸 치환이 아니라 편집 도구로 고친다.”
같은 날, 그 문장이 적힌 채로 sed -i가 또 쓰였다. 이번엔 결과가 맞긴 했다. 하지만 문장이 실수를 하루도 막지 못한 건 분명했다. 결국 셸 명령을 실행하기 직전에 끼어드는 훅을 달아서, sed -i와 perl -pi로 파일을 고치려 하면 아예 막게 했다. 문장에서 훅으로, 한 칸 올린 것이다.
훅이 너무 성실했다
커밋하기 전에 검사를 돌리는 훅은 커밋 명령에만 반응해야 했다. 설정에 그런 조건도 적었다. 그런데 실제로는 모든 셸 명령마다 검사가 돌았다. 테스트를 아직 고치는 중이라 검사가 실패하는 상태였으니, ls 하나도 막혔다.
훅을 만든 세션에서는 설정이 아직 읽히지 않아 아무 일도 없었고, 설정이 읽힌 뒤에야 드러났다. 고친 방법은 단순하다. 설정의 조건을 믿지 않고, 검사가 받은 명령이 커밋인지 스스로 보고 아니면 바로 통과시킨다.
미래에서 온 기록 170곳
세션을 마치며 회고하다가 이상한 것을 찾았다. PC 시계도 커밋 시각도 11월 9일인데, 문서에는 그날 한 일이 “11월 10일”로 적혀 있었다. 31개 파일, 170곳. 날짜를 시계에서 읽지 않고, 앞선 기록의 날짜를 이어 적은 탓이었다.
그 뒤로 세션을 시작하면 훅이 이 PC 시계로 오늘 날짜를 띄운다. 그리고 이번 커밋에서 새로 더한 줄에 오늘보다 하루에서 사흘 뒤 날짜가 있으면 검사가 막는다. 이미 적힌 170곳은 얽힌 데가 너무 많아 그대로 두고, 바로잡는 줄을 하나 남겼다.
비슷한 일은 더 있었다. “작업은 여기까지 할거야”라고 세션을 끝냈는데, 세션을 끝내는 말을 알아듣는 훅이 그 말을 놓쳤다. 이건 잡아야 할 말 10개와 잡으면 안 되는 말 7개를 예문으로 두고, 검사가 그 예문으로 훅을 시험하게 했다.
21줄
교훈 기록부는 지금 21줄이다. 생긴 날을 세어 보면 이렇다.
11월 9일 하루에 18줄. 그날 일은 2편부터 5편까지에 고루 나온다. 21줄 중 2줄은 아직 장치를 만들지 못해 “열림”으로 남아 있다.
그리고 이 일지를 시작한 날에도 줄은 늘었다. 처음 레포를 만든 맥으로 돌아와 최신 내용을 받아 보니, 검사가 테스트를 돌리지 못해 커밋이 막혔다. 이 맥의 Node 26에서는 node --test tests/처럼 폴더를 넘기는 방식이 실패했다. 테스트를 인자 없이 돌리게 고쳤다. 하네스를 한 PC에서만 시험해 본 대가였다.
다만 이 하네스가 막는 것은 “규칙끼리 어긋나는 실수”까지였다. 방향이 틀린 것은 막지 못했다. 다음 편이 그 이야기다.
-
114분 돌린 시뮬레이션의 결론: 보류
114분 돌린 시뮬레이션의 결론: 보류
지난 편의 교훈은 “무엇을 판정할지 정하기 전에 만든 프로토타입은 답을 주지 못한다”였다. 그래서 이번에는 판정 규칙부터 적었다. 규칙을 먼저 적고, 커밋하고, 그다음에 돌린다. 여기까지는 아주 모범적이었다.
판정 규칙부터 적었다
지난 편에서 통째로 비어 있던 판 밖 경제를 질문 하나씩 채웠다. 카드는 어디서 얻는지, 강화 재료는 어디서 생기고 어디에 쓰이는지, 판에는 무엇을 들고 들어가는지. 채운 답은 모두 가설로 두고, 수치 시뮬레이션으로 확인하기로 했다.
시뮬레이션이 답할 것은 정해 두었다. 재화가 모자라거나 넘치지 않는가, 어떤 행동 하나가 늘 정답이 되어 다른 행동을 지우지 않는가, 성장이 중간에 멈추지 않는가. 답하지 않을 것도 적었다. 재미. 시뮬레이션을 통과해도 재미있다는 뜻은 아니다.
처음 계산은 이랬다. 바꿔 볼 값 10개를 3개씩만 잡아도 조합이 약 6만 개, 여기에 가상 플레이어 500명, 한 명당 300판, 플레이어 성격 5가지를 곱하면 약 440억 판. 그냥 내가 직접 하는 게 빠르겠다 싶었지만, 그건 그것대로 몇 생이 걸린다. 범위를 체험판으로 줄이고, 성격은 셋, 한 명당 최대 100판으로 깎았다.
판정은 세 가지 중 하나로 낸다. 모든 기준을 함께 만족하는 설정이 있으면 확정, 어떤 설정에서도 안 되면 버림, 그 사이면 보류. 판정 규칙 문서는 지금 446줄이다.
1차: 65분, 그리고 “잠깐”
151개 설정을 65분 동안 돌렸다. 결론은 확정 없음, 가설 둘 버림, 나머지 셋 보류.
버림이 둘이나 나왔으니 바로 받아들여야 할까. 받아들이기 전에 확인해 봤더니 Claude의 실수가 둘 나왔다.
옵션 재료는 가설대로라면 보스와 높은 난이도에서만 나온다. 그런데 모델은 프로토타입에서 임시로 쓰던 규칙(“어느 판이든 끝까지 버티면 1개”)을 그대로 가져와 있었다. 버림 판정 하나는 가설이 아니라 그 임시 규칙을 잰 것이었다
다른 기준 하나는 “판에 들고 갈 카드 묶음을 늘 똑같이 고르게 되는가”를 물었다. 그런데 체험판에는 적의 성질이 하나뿐이다. 성질이 하나면 같은 묶음을 고르는 게 당연하다. 애초에 가를 수 없는 질문이었다
그래서 버림 둘은 무효. 고쳐서 다시 돌렸다.
2차: 114분, 다섯 모두 보류
이번엔 136개 설정에 114분. 결론은 다섯 가설 모두 보류였다. 가장 가까웠던 설정은 기준 하나만 0.806이 나와 떨어졌다. 통과 기준은 0.8 이하였다. 0.006 차이다.
그리고 다시 “잠깐”.
“카드 한 장의 운이 강화보다 판을 더 좌우하는가”를 재는 기준은, 비교용 가상 플레이어 대부분이 100판 안에 다음 난이도를 넘지 못해서 사실상 “100판”이라는 상한을 재고 있었다
“성장이 중간에 끊기는가”를 재는 기준은 규칙끼리 서로를 막고 있었다. 한쪽 규칙은 난이도를 “15판쯤에 처음 깨도록” 맞추라고 하고, 다른 쪽은 “10판 안에” 깨야 통과라고 했다. 15판에 맞추면서 10판 안에 끝내라. 시험지가 먼저 틀려 있었다
다만 이번에는 시뮬레이션이 제 몫을 한 대목도 있었다. 적의 성질 하나가 너무 쉽게 풀려서, 성장을 막는 것이 성질이 아니라 그냥 수치였다. 그리고 체험판에서 옵션 뽑기를 할 기회가 1번뿐이었다. “체험판에 옵션 뽑기를 넣는다”는 결정과 “옵션 재료는 보스와 높은 난이도에서만 나온다”는 결정이 체험판 안에서 맞부딪친 것이다. 문서끼리만 대 봐서는 드러나지 않던 것을 시뮬레이션이 찾아냈다.
3차: 115개 중 70개에서 멈춤
또 고쳤다. 이번에는 오래 걸린다는 말을 듣고 설정들을 CPU 코어 11개로 나눠 돌렸다. 앞부분은 6분 만에 끝났다. 뒷부분 115개 중 70개쯤 돌았을 때, 진행 상황을 물어보다 결국 멈췄다. 너무 느리고, 이 방식으로 계속하는 건 현실적으로 무리였다.
나중에 보니 멈추는 게 맞았다. 3차는 맨 처음 기본값 하나로 돌려 본 단계에서 이미 결론을 뒤집을 조건이 보였다. 전체를 끝까지 돌릴 필요가 없었다. 중간까지의 결과도 그 조건 그대로였다.
남은 것
배운 것은 이렇다. “규칙대로 끝까지 돌린다”를 “이걸 돌리면 결정이 바뀌는가”보다 앞에 두면 시간이 녹는다. 지금은 탐색이 단계마다 남은 시간을 찍고, 30분이 넘을 것 같으면 돌리기 전에 먼저 묻게 해 두었다. 기본값 하나로 먼저 보고, 그걸로 결론이 보이면 거기서 멈춘다.
그리고 하나 더. 규칙을 먼저 적었다고 규칙이 맞는 건 아니었다. 두 번의 탐색에서 나온 실수 넷은 모두 “규칙과 모델과 기준을 서로 맞춰 보지 않은” 데서 나왔다.
이 시뮬레이션은 지금 멈춰 있다. 그 뒤에 프로토타입을 직접 해 본 내가 방향 자체를 다시 보기로 했기 때문이다. 그 이야기는 조금 뒤에 하고, 다음 편은 이런 실수들을 문장이 아니라 장치로 막아 보려던 이야기다. 장치를 만들다가도 실수는 났다.
-
프로토타입부터 만들었다가 빈틈 16개
프로토타입부터 만들었다가 빈틈 16개
게임을 하나 만들어 보기로 했다. 혼자서, 주 5시간 남짓으로. 기획부터 코드까지 AI(Claude)와 같이 하고 있고, 이 글은 그 과정을 적는 개발 일지의 첫 편이다. 첫 편부터 자랑할 거리는 별로 없다. 대신 삽질은 넉넉하다.
메타포 세 개
출발점은 예전에 좋아했던 온라인 게임 세 개였다. 서바이벌 프로젝트(서프), 트릭스터, 요구르팅. 셋 다 서비스가 끝난 뒤에도 프리서버가 생길 만큼 다시 찾는 사람이 있었던 게임이다.
다만 셋을 따라 하려는 건 아니다. 끌렸던 것만 뽑아서 섞기로 했다.
서프: 부위별 장비 카드를 모으고, 강화하고, 부가옵션을 뽑는 재미
트릭스터: 지금 봐도 예쁜 아기자기한 그림과 맵
요구르팅: 프라이팬 같은 일상 소품이 무기가 되는 엉뚱함
그리고 원칙을 몇 개 세웠다. 짐작으로 논리를 펴지 않는다, 한 번에 확정하지 않는다, 사실과 제안을 섞지 않는다. 이 원칙들은 앞으로 나를 여러 번 구해 주고, 그만큼 여러 번 귀찮게 한다.
첫 제안은 Netty 서버였다
기획 첫날 받은 첫 제안은 이랬다. 방을 만들어 여럿이 들어가는 멀티 아레나에 채굴을 얹고, 클라이언트는 TypeScript(Phaser), 서버는 Java(Netty)로.
이 블로그 개발 카테고리에는 Netty 글이 두 편 있다. 그런데도 버렸다. 세 게임을 겉핥기로만 이해한 채 나온 안이었고, 내가 쓰던 스택을 굳이 따를 이유도 없었다. Netty 글 두 편이 게임 서버로 데뷔할 기회는 그렇게 날아갔다.
손이 먼저 나갔다
레포를 만든 날, 바로 프로토타입부터 만들었다. 질문은 하나였다. “장비 카드 한 장을 바꿨을 때, 자동 공격형과 직접 액션형 중 어느 쪽에서 더 크게 느껴지는가.” 두 형태를 번갈아 4분씩 버티고, 판이 끝날 때마다 1~5점을 매기는 물건이었다.
프로토타입 01. 모눈종이 위의 파란 원이 나, 빨간 원이 적이다. 손에 든 것은 프라이팬 +2.
계획대로라면 이제 해 보고 점수만 붙이면 조작 형태가 정해질 차례였다.
냉정하게 봐 달라고 했더니
다음 날, 코드를 더 쓰기 전에 설계에 허점이 없는지부터 냉정하게, 빈틈없이 점검해 달라고 했다.
빈틈이 16개 나왔다.
치명적인 것만 두 개였다.
이 게임의 핵심 재미는 판 밖에서 카드를 강화하고 옵션을 뽑는 데 있는데, 그 판 밖의 경제가 통째로 비어 있었다. 카드가 어디서 오는지, 강화 재료는 어디서 생기고 어디에 쓰이는지, 아무것도 정해지지 않았다
끝이 있는 게임인지 끝없이 하는 게임인지도 정하지 않았다. 강화의 상한도, 콘텐츠 분량도, 값도 이 답에 달려 있는데, 로드맵에는 그 칸조차 없었다
그리고 방금 만든 프로토타입. 자동 공격형과 직접 액션형은 조작만 다른 게 아니었다. 한 번에 나오는 적의 수가 250 대 12, 카드가 떨어질 확률이 2.5% 대 10%, 회피가 있고 없고까지 같이 달랐다. 점수 차이가 나와도 그게 조작 때문인지 적 때문인지 가를 수가 없다. 매기는 사람도 만든 사람 한 명뿐이었다. 결론은 “이 프로토타입의 점수로는 조작 형태를 정할 수 없다”였다.
하나 더 있었다. 그 무렵 세운 첫 가설은 “강화한 카드는 한 장만 들고 판에 들어간다”였고, 장점으로 “다 키운 장비가 게임을 시시하게 만드는 문제가 그 한 칸에만 갇힌다”를 들었다. 그런데 같은 가설의 다른 규칙을 따라가 보니, 판 안에서 줍는 카드도 강화된 채로 나왔다. 끝까지 키우면 모든 칸이 최고 강화였다. 갇혀 있던 건 강화가 아니라 우리 생각이었다.
공정하게 적어 두자면, 점검한 쪽도 Claude였고 점검받은 설계를 같이 쓴 쪽도 상당 부분 Claude였다. 셀프 감사였다. 그래도 덕분에 숨어 있던 것들이 꽤 나왔다.
순서를 뒤집었다
로드맵을 다시 짰다. 맨 앞에 있던 조작 형태가 네 번째로 내려가고, 그 앞에 틀이 들어왔다. 누구를 위한 게임인지, 어디에 먼저 내는지, 끝이 있는지, 무엇으로 판정할지.
그날 틀부터 질문을 하나씩 답했다. 스팀에 먼저 내고, 첫 출시는 무료 체험판으로 하고, 본편은 끝이 있되 끝낸 뒤에도 끝없이 오르는 도전을 둔다.
배운 것은 한 줄이다. 무엇을 판정할지 정하기 전에 만든 프로토타입은 답을 주지 못한다.
물론 이걸 배웠다고 다음 프로토타입이 잘된 건 아니다. 그 이야기는 몇 편 뒤에 한다. 다음 편은 시뮬레이션 이야기다. 미리 말하자면, 결론은 “보류”다.
-
2025년 회고록
2025년 회고록
Prologue. 집 한 채, 운동화 한 켤레, 그리고 끝나지 않은 질문
2025년을 돌아보면 세 개의 장면이 떠오른다.
첫 번째는 이사 짐을 다 풀고 난 뒤, 처음으로 내 이름이 적힌 등기부등본을 들여다보던 순간이다. 별것 아닌 서류 한 장이었지만, 묘하게 어깨가 무거워지면서도 동시에 편안해지는 느낌이었다. 이제 1~2년마다 반복되던 ‘이사 고민’이라는 루프에서 벗어났다는 안도감. 그리고 동시에 부동산 뉴스에 귀를 기울이게 되는, 어른이 된 것 같은 묘한 기분.
두 번째는 주말 아침마다 집 앞 천변을 달리며 보던 풍경이다. 3km라는, 누군가에게는 워밍업도 안 될 거리였지만 나에게는 꽤 의미 있는 숫자였다. 절대적인 수치보다 중요한 건, 안 할 수도 있었는데 했다는 것. 그 작은 선택들이 쌓여 만든 변화였다.
세 번째는 회의실에서, 또 모니터 앞에서 반복적으로 던졌던 질문이다. “이대로 괜찮은가?” 올해는 유독 이 질문을 많이 했던 것 같다. 회사에서 요구하는 일들이 점점 이상한 방향으로 흘러갈 때마다, 내가 개발자로서 가야 할 길과 점점 멀어지는 것 같을 때마다, 이 질문은 머릿속에서 떠나지 않았다.
2025년은 어떤 면에서는 안정을 찾은 해였고, 어떤 면에서는 가장 흔들렸던 해였다. 뿌리를 내리면서도 동시에 다시 항해를 준비해야 했던, 그런 한 해. 이제 그 이야기를 천천히 풀어보려 한다.
1부. 일상 이야기 - 뿌리를 내리다
1-1. 드디어 내 이름이 적힌 집
“이번 겨울은 여기서 보내고, 내년 봄쯤 되면 또 이사 준비를 해야 하나?”
몇 년간 반복했던 이 생각을 더 이상 하지 않아도 된다는 게 실감 나지 않았다. 2024년 말, 드디어 내 집을 마련했다. 등기부등본에 내 이름이 적힌 그 순간부터, 뭔가 달라진 느낌이었다.
사실 집을 사기 전까지는 부동산 시장이 어떻게 돌아가는지, 정부 정책이 어떤지 전혀 관심이 없었다. 어차피 내 집이 아니니까. 월세든 전세든 계약 기간만 채우면 그만이었으니까. 그런데 이제는 뉴스에서 부동산 이야기만 나오면 귀가 솔깃해진다. 금리가 어떻게 움직이는지, 우리 동네 시세는 어떤지, 자연스럽게 관심이 생기더라.
호들갑처럼 보일 수도 있겠지만, 이게 바로 어른이 된다는 느낌이 아닐까 싶었다. 신경 써야 할 것들이 하나둘 늘어나는 것. 책임져야 할 영역이 조금씩 확장되는 것. 무겁지만, 그만큼 안정적인 느낌.
2025년 한 해를 살면서 확실히 느낀 건, 내 집이 주는 안정감은 생각보다 훨씬 크다는 것이었다. 물론 매달 나가는 주거비는 부담이다. 하지만 어차피 부모님 집이 아닌 이상, 독립해서 사는 한 주거비는 언제나 지출 항목에 있을 수밖에 없다. 그렇다면 차라리 내 자산으로 쌓이는 쪽이 낫지 않을까.
예전에는 회사 근처에 집을 얻어서 출퇴근이 편했다. 지금은 조금 멀어졌다. 그래도 만족도는 오히려 더 높다. 아파트 단지 앞으로 긴 천이 흐르고, 그 옆으로 뛰거나 자전거를 탈 수 있는 길이 펼쳐져 있다. 대단지라 사람도 많고, 그만큼 인프라도 괜찮다. 출퇴근 시간이 늘어난 것보다, 삶의 질이 올라간 게 더 크게 느껴졌다.
집이라는 공간이 주는 의미를 새삼 깨달은 한 해였다. 단순히 잠을 자고 밥을 먹는 곳이 아니라, 내가 뿌리를 내리고 살아가는 기반이라는 것. 그 기반이 단단해지니, 다른 것들도 조금씩 안정되는 느낌이었다.
1-2. 주말 아침, 3km의 작은 승리
주말 아침이면 운동화 끈을 묶고 집 앞 천변으로 나갔다. 3km. 누군가에게는 가볍게 뛰는 워밍업 거리일 테지만, 나에게는 꽤 의미 있는 숫자였다.
운동을 열심히 하는 사람들 앞에서 “나 운동해”라고 말하기는 좀 민망하다. “그걸 운동이라고 할 수 있냐”는 말이 돌아올 것 같아서. 하지만 나한테는 굉장히 큰 발전이었다. 작년까지만 해도 주말 아침에 일어나서 뭔가를 ‘하는’ 사람이 아니었으니까.
처음엔 그냥 가볍게 시작했다. 별 기대 없이, 그냥 한번 뛰어볼까 하는 마음으로. 그런데 한 번, 두 번 하다 보니 어느새 루틴이 됐다. 주말마다 아침 3km 러닝. 거창한 목표는 아니었지만, 꾸준히 했다는 것 자체가 나에게는 성취였다.
다만 하나 아쉬운 점이 있다면, 장비가 신발밖에 없다는 것. 요즘처럼 추운 날에는 핸드폰을 손에 들고 뛰기도 힘들고, 평일에 입던 옷을 입고 나가면 금방 땀범벅이 된다. 한겨울에 입고 나가서 운동할 만한 옷이 따로 없어서, 날씨가 영하로 떨어진 이후로는 잠시 멈춘 상태다.
그래도 괜찮다. 절대적인 수치로 얼마나 많이 했느냐를 따지기보다, 올해는 우선 스스로에게 수고했다고 말해주고 싶다. 아무것도 안 할 수도 있었는데, 그래도 뭔가를 더 했다는 것. 스스로 생각해서 행동에 옮겼다는 것. 그게 중요하다고 생각한다.
운동을 시작하면서 깨달은 건, 변화는 거창한 결심에서 오는 게 아니라는 것이다. 작은 선택들이 쌓여서 만들어진다. 주말 아침에 이불을 박차고 일어나는 선택, 운동화 끈을 묶는 선택, 문을 열고 밖으로 나가는 선택. 그 작은 선택들이 모여 3km가 됐고, 그게 모여 한 해가 됐다.
내년에는 겨울용 운동복도 하나 장만해야겠다. 그리고 조금 더 먼 거리에 도전해 봐도 좋을 것 같다. 올해가 시작이었다면, 내년은 한 걸음 더 나아가는 해가 되길 바란다.
2부. 회사 이야기 - 흔들리는 배 위에서
2-1. 무난했던 상반기, 지옥이 된 하반기
2025년 회사 생활을 한마디로 요약하자면, 할 말은 많지만 하지 않겠다는 뜻의 ‘할많하않’이다.
올해 초만 해도 괜찮았다. 아니, 사실 어느 회사든 100% 만족스러운 회사 생활이란 건 정말 어렵지 않은가. 그런 의미에서 나름 만족하고 있었다. 출근할 때 발걸음이 무겁지 않을 정도면 충분하다고 생각했다.
그런데 중반을 넘어서면서부터 분위기가 달라지기 시작했다. 조직 개편이 있었다. 겉으로는 효율화, 시너지, 성장 같은 그럴듯한 단어들이 오갔지만, 현장에서 느껴지는 건 혼란이었다. 마치 잔잔하던 바다에 갑자기 파도가 일기 시작한 것 같았다.
조직장은 뭔가 보여줘야 한다는 압박감 때문이었는지, 바쁘게 움직이기 시작했다. 덩달아 조직원들도 영향을 받았다. 배가 흔들리면 그 안에 탄 사람들도 당연히 흔들릴 수밖에 없으니까.
사람 수는 그대로인데, 요구되는 일의 양과 범위는 점점 늘어났다. 전문 직무와도 맞지 않는, 겉으로 보여주기 위한 결과물들이 많아졌다. “이건 왜 우리가 해야 하죠?”라는 질문에 돌아오는 답은 언제나 “해야 하니까”였다.
원래도 내 영역이 아닌 일까지 커버하는 경우가 있었다. 하지만 이번엔 달랐다. 그 범위가 상상을 초월했다. 마치 축구 선수에게 농구공을 던져주고 “이것도 공 아니냐, 다룰 줄 알아야지”라고 말하는 느낌이랄까.
2-2. 청팀끼리 줄다리기를 하는 조직
회사는 결국 여러 직무들이 서로 톱니바퀴처럼 맞물려 돌아간다. 때로는 협력하고, 때로는 첨예하게 대립하면서 균형을 찾아간다. 마치 줄다리기처럼 말이다.
줄다리기는 청팀과 백팀이 서로 반대편에서 줄을 당긴다. 한쪽으로 쏠리면 다른 쪽이 힘을 더 주고, 그렇게 팽팽한 긴장 속에서 최선의 지점을 찾아간다. 기획팀은 사용자 경험을 중시하고, 개발팀은 기술적 현실성을 고려한다. PM은 일정을 지키려 하고, 개발자는 품질을 지키려 한다. 서로 다른 입장에서 줄을 당기기에, 결과물은 어느 한쪽으로 치우치지 않는다.
그런데 올해 내가 느낀 건 좀 달랐다. 청팀과 백팀이 줄다리기를 하는 게 아니라, 청팀끼리 줄다리기를 하는 느낌이었다. 같은 색 조끼를 입은 사람들이 양쪽에서 줄을 잡고 당기고 있었다. 그러니 어느 쪽으로 결론이 나든 청팀의 승리였다.
개발자가 기획을 한다. 개발자가 PM 역할도 한다. 겉으로 보기엔 효율적일 수 있다. 한 사람이 여러 역할을 하니 커뮤니케이션 비용이 줄어들고, 의사결정도 빨라 보인다. 하지만 본질적으로는 건강하지 않다고 생각한다.
개발자가 기획을 하면 어떻게 될까? 자연스럽게 만들기 쉬운 쪽으로 기획이 흘러가기 쉽다. 사용자 입장은 상대적으로 뒷전이 되고, 내부 사정이 우선순위가 되기 마련이다. 개발자가 PM을 하면? 스스로 개발할 수 있는 범위 내에서 일정을 잡고, 스스로 조정하는 일정 관리가 무슨 의미가 있을까.
전문성이란 단순히 그 일을 잘한다는 의미가 아니다. 그 일을 하는 사람만이 가질 수 있는 관점과 기준이 있다는 뜻이다. 그 관점들이 부딪치고 조율되면서 더 나은 결과물이 나온다. 그런데 한 사람이 여러 역할을 하면, 그 건강한 긴장이 사라진다.
청팀끼리 줄다리기를 하는 조직에서는, 줄다리기가 줄다리기의 의미를 잃는다. 그저 형식만 남을 뿐이다.
2-3. 궁지에 몰린 쥐는 고양이를 문다
팀원들이 하나같이 어려움을 호소했다.
“아무리 그래도 이거까지 우리가 해주는 건 좀 무리가 아닐까요?”
“우리 일정도 빡빡한데, 이건 어렵습니다.”
합리적인 의견이었다. 하지만 돌아온 건 더 강한 요구였다. 쥐가 궁지에 몰리면 고양이를 문다고 하지 않던가. 마치 그런 느낌이었다.
당연하지 않은 일을 당연한 거라며 요구했다. 그걸 완수하지 못하면 팀원의 역량을 지적했다. “이 정도는 할 수 있어야 하는 거 아닌가요?”라는 식의 말들은, 팀원들의 의욕을 점점 떨어뜨렸다.
회의실 분위기는 점점 조심스러워졌다. 예전에는 자유롭게 의견을 나누고, 때로는 웃으면서 문제를 풀어가던 공간이었다. 이제는 누가 먼저 입을 여는지 눈치를 보는 자리가 됐다. 말을 꺼내면 지적받을까 봐, 모두가 조용해졌다.
사람들은 지쳤다. 물리적인 업무량도 문제였지만, 더 큰 문제는 신뢰가 약해진 것이었다. “열심히 하면 인정받을 수 있다”는 믿음, “합리적으로 의견을 내면 받아들여질 것”이라는 기대, 그런 것들이 조금씩 흔들렸다.
올해 하반기는 정말 힘들었다. 매일 아침 출근할 때마다 한숨이 나왔고, 퇴근할 때도 개운하지 않았다. 집에 돌아와서도 회사 일이 머릿속을 떠나지 않았다.
회사 이야기는 이 정도로만 하고 싶다. 더 깊이 들어가 봤자 좋을 게 없다. 지나간 일이고, 어차피 바꿀 수 없는 과거다. 다만 이 경험을 통해 배운 건, 조직의 건강함이 얼마나 중요한지, 그리고 그게 흔들리면 얼마나 빠르게 영향이 퍼지는지였다.
순화해서 글쓰기가 힘들다…
3부. 커리어 이야기 - 나침반을 다시 꺼내 들다
3-1. “이대로 괜찮은가?”라는 질문
난 원래 커리어에 대한 고민이 많은 편이다. 앞으로 더 나은 개발자가 되기 위한 방향 같은 것들에 관심이 많고, 실제로 일부분은 행동으로 옮기고 있다고 생각한다.
소위 말하는 찐 개발자의 수준까지는 아니지만, 그래도 코드 작업을 업무 때만 하지는 않는다고 자신 있게 말할 수 있다. 적어도 이 정도만 해도 개발자 상위 50%는 되지 않을까 하고 혼자 생각하면서 말이다.
그런데 올해 회사에서 요구하는 것들을 하다 보니 점점 위기감이 생기기 시작했다. 내가 여기서 기획을 하고 있는 게 맞나. 내가 여기서 PM을 하고 있는 게 맞나 하는 의문이었다.
물론 개발자가 기획이나 PM 업무를 경험하는 게 나쁜 건 아니다. 오히려 다른 직무를 이해하는 데 도움이 될 수도 있다. 하지만 그게 메인이 되면 이야기가 달라진다. 내가 개발자로서 성장하는 게 아니라, 이것저것 다 하는 사람이 되는 느낌이었다.
특히 올해 하반기에는 그 정도가 심했다. 개발보다는 문서 작업, 기획 회의, 일정 조율에 시간을 더 많이 쓰는 날들이 많았다. 퇴근하고 나서 “오늘 하루 코드를 몇 줄이나 짰지?” 생각해 보면 손에 꼽을 정도였다.
이런 환경 속에서 자꾸만 머릿속을 맴도는 질문이 있었다. “이대로 괜찮은가?” 지금 이 길을 계속 가다 보면, 내가 원하는 개발자의 모습에 가까워질 수 있을까. 아니면 점점 멀어지는 건 아닐까.
그 질문은 쉽게 떠나지 않았다. 출근길에도, 점심시간에도, 집에 돌아와서도 계속 따라다녔다. 그리고 그 질문이 쌓이다 보니, 자연스럽게 다른 선택지를 찾게 됐다.
내가 좀 더 추구하는 방향으로 일하고 있는 다른 회사의 포지션을 찾게 됐고, 자연스럽게 이직을 준비하게 됐다. 올해 중후반부터 시작한 일이었다.
3-2. 그래도 멈추지 않기로
이직 준비 과정은 쉽지 않았다. 평일 퇴근 후와 주말 시간을 쪼개서 이력서를 다듬고, 포트폴리오를 정리하고, 면접을 준비했다. 지친 몸으로 책상 앞에 앉아 있는 게 버거울 때도 많았다.
몇 군데 지원했고, 서류를 통과한 곳도 있었고, 면접까지 간 곳도 있었다. 그 과정에서 들었던 피드백 중 하나가 인상 깊었다. “경력으로 보면 이제 미드레벨에 접어드는 시기네요.”
미드레벨. 주니어는 아니지만 시니어도 아닌, 그 중간 어딘가. 기본은 갖췄고, 이제 어떤 방향으로 더 성장할 것인가를 결정해야 하는 시기. 그 말이 묘하게 와닿았다. 지금이 바로 그 갈림길이구나 싶었다.
결과는 아쉽게도 올해 안에는 좋은 소식을 듣지 못했다. 내가 가고 싶었던 회사들과는 인연이 되지 않았다. 처음에는 좀 속상했다. 준비한 시간과 노력이 허무하게 느껴지기도 했다.
하지만 곰곰이 생각해 보니, 이건 끝이 아니라 과정이었다. 이번에 안 됐다고 해서 내가 부족한 사람이 되는 건 아니다. 단지 타이밍이 맞지 않았거나, 더 나은 기회를 위한 준비 시간이 필요한 것뿐이다.
그래서 나는 멈출 생각이 없다. 오히려 이번 경험을 통해 내가 부족한 부분이 뭔지 조금 더 명확히 알게 됐다. 면접에서 대답이 막혔던 질문들, 좀 더 깊이 있게 설명하지 못했던 기술적인 부분들. 그걸 채워나가면 된다.
스스로 내 가치를 더 빛나게 만들다 보면, 언젠가는 내 가치를 알아주는 조직과 만날 수 있을 거라고 생각한다. 조금 시간이 걸릴 수도 있고, 생각보다 빨리 올 수도 있다. 그건 알 수 없지만, 중요한 건 그 방향으로 계속 나아가는 것이다.
올해는 많은 걸 느끼고 배운 한 해였다. 회사에서 겪은 어려움들은 분명 힘들었지만, 그 덕분에 내가 정말 원하는 게 뭔지 더 명확해졌다. 그리고 그걸 찾아가기 위해 움직이기 시작했다는 것만으로도 의미가 있다고 생각한다.
2026년에는 조금 더 나은 환경에서, 내가 하고 싶은 개발을 하고 있기를 바란다. 그리고 그 바람이 현실이 되도록, 계속 노력할 것이다. 이런 긍정적인 마인드로, 조금은 길게 보면서 말이다.
에필로그
2025년 12월 31일. 한 해의 마지막 날, 이 글을 쓰고 있다.
올해는 전체적으로 봤을 때 쉽지 않았다. 일상에서는 안정을 찾았지만, 회사에서는 혼란을 겪었고, 커리어에서는 방향을 다시 잡아야 했다. 한쪽이 안정되면 다른 쪽이 흔들리고, 그렇게 균형을 잡으려 애쓴 한 해였다.
하지만 그래서 더 의미가 있었던 것 같다. 모든 게 순탄했다면, 내가 정말 원하는 게 뭔지 깊이 생각해 볼 기회가 없었을 테니까. 힘든 순간들이 오히려 나를 돌아보게 만들었고, 앞으로 나아갈 방향을 더 명확하게 만들어줬다.
그래서 마무리만큼은 깔끔하게 짓고 싶었다. 올해의 마지막 날에 회고록을 완성하는 것. 여러 가지로 어지러운 한 해였지만, 어떻게든 마무리는 잘 짓는 느낌이라 다행이다.
2025년을 돌아보며 떠오르는 세 개의 장면을 다시 생각해 본다.
내 이름이 적힌 등기부등본을 보며 느낀 안정감. 주말 아침마다 집 앞 천변을 달리며 쌓아온 작은 선택들. 그리고 끊임없이 던졌던 “이대로 괜찮은가?”라는 질문.
이 세 가지가 2025년을 만들었다. 안정과 불안, 성취와 고민, 만족과 갈증. 그 모든 것들이 섞여 한 해가 됐다.
2026년에는 어떤 장면들이 기억에 남을까. 아직은 알 수 없다. 하지만 분명한 건, 올해 경험한 것들이 내년을 만드는 밑거름이 될 거라는 것이다.
집이라는 안정된 기반 위에서, 꾸준히 나를 돌보는 습관을 유지하면서, 내가 진짜 하고 싶은 일을 향해 한 걸음씩 나아가는 한 해. 그런 2026년을 그려본다.
올해 수고했다, 나 자신에게. 그리고 내년도 잘 부탁한다.
2025년 12월 31일, 회고록을 마치며.
-
2025년 3분기 회고 (뜬금포)
2025년 3분기 회고록
왜 지금, 3분기인가
연초도 아니고 연말도 아닌 지금, 3분기에 회고록을 쓰는 이유는 단순하다.
올해는 나에게 정말 힘든 해였고, 그 과정에서 큰 심경의 변화를 겪었기 때문이다.
그래서 지금 이 시점에 내 삶을 돌아보고 기록으로 남기려 한다.
1. 현재 재직 중인 회사 이야기
올해 회사에서 겪은 일
올해 가장 큰 변화는 팀장님의 부재였다.
겸직으로 다른 분이 팀장 역할까지 해줄 것이라는 막연한 기대가 있었지만, 결국 현실은 달랐다. ‘겸직’이라는 말은 이름뿐이었고, 팀장 역할은 팀 내에서 근속 연차가 오래된 나와 또 다른 동료가 나눠 맡게 되었다.
그 과정에서 나는 본래 내 업무 외에도 새로운 책임들을 떠안게 되었다. 대표님께 직접 보고할 비전과 전략 PT 준비 같은, 원래 내 역할을 넘어서는 일들도 맡아야 했다. 자연스럽게 조직장과 사원급 사이의 중간 관리자 역할을 하게 되었고, 그 과정에서 감정적 소모도 상당했다. 이제는 단순히 내 업무만 잘한다고 되는 연차가 아니라는 걸 뼈저리게 느낀 해였다.
하지만 무엇보다 힘들었던 점은, 책임은 늘어났지만 권한은 그대로였다는 것이다.
조직장은 은근하게 나에게 사원급들의 업무를 지시하라고 압박했지만, 내 입장에서는 여전히 일반 팀원일 뿐이었다. 아무런 공식적인 권한이 없는 상태에서 같은 팀원에게 지시를 내리는 게 맞는 걸까? 하는 의문이 늘 따라다녔다. 듣는 사람 입장에서도 “같은 팀원인데 왜 이래라 저래라 하지?”라고 느낄까 두려웠다. 이 애매한 위치는 나를 불편하게 만들었고, 매일같이 심리적인 부담으로 다가왔다.
조직과 나의 관계 변화
올해를 돌이켜보면, 조직과 나 사이에 극적인 변화가 있었던 것은 아니다. 하지만 그 속에서 분명히 아쉬움은 남았다.
업무를 진행하면서 실무 리더들이 잡는 방향이 지나치게 근시안적이라는 생각을 자주 했다. 당장 이번 프로젝트에서는 문제가 없을지 몰라도, 다른 고객사에서는 충분히 불거질 만한 리스크를 미리 고려하지 않은 채 설계가 진행되곤 했다. 나는 장기적으로 더 안정적이고 범용적인 방안을 고민했지만, 조직은 “고객 요구사항을 빠르게 맞추는 것”에만 초점을 맞췄다. 마치 성과를 빨리 만들어내 보고해야 한다는 압박감에 눌려 있는 듯 보였고, 그 배경이 임원진의 지시 때문인지, 아니면 리더 선에서의 판단 때문인지는 알 수 없었다. 다만 그 과정이 늘 아쉽게 다가왔다.
무엇보다, 올해 나와 조직의 관계는 투명하지 않았다. 위에서 내려오는 지시나 과제는 충실히 수행했지만, 그 반대로 내가 바텀업으로 올린 건의나 개선 요청은 실제로 반영된 적이 거의 없었다. 조직이 나에게 기대하는 바는 명확했지만, 내가 조직에 기대하는 바는 돌아오지 않았다. 그 불균형 속에서, 나는 회사와의 관계가 일방적이라는 사실을 더욱 선명하게 느끼게 되었다.
업무와 성장, 그리고 한계에 대한 솔직한 심정
올해 내가 업무적으로 성장했다고 느낀 부분은, 단순히 주어진 일을 처리하는 데 그치지 않았다는 점이다.
업무 속에서 발견한 문제점이나 개선점을 그냥 흘려보내지 않고, 어떻게든 해결하고 개선하려는 노력을 했다. 그것은 단순한 생각의 차원이 아니라, 실제로 움직이고 실천에 옮기려는 행동이었다. 그 과정에서 문제를 바라보는 시각과 해결책을 만들어가는 힘이 조금씩 자라났음을 느낄 수 있었다.
하지만 동시에 뚜렷한 한계도 있었다. 우리 회사는 아쉽게도 “당위성”을 지나치게 많이 따지는 조직 문화를 가지고 있다. 아무리 심각한 문제라 하더라도, 당위성을 판단하는 사람이 동의하지 않으면 실행되지 않는다. 아무리 좋은 개선안이라 하더라도, 같은 이유로 묵살되는 경우가 많았다.
그 당위성의 기준은 명확하지 않았다. 판단자의 개인적인 사견이 개입되기도 하고, 회사 인프라의 제약이 이유가 되기도 했다. 결국 나는 내가 고민하고 준비한 것들을 Best Practice로 구현해볼 기회를 자주 잃곤 했다. 문제를 개선할 의지와 실행력은 있었지만, 그것을 끝까지 밀어붙일 수 있는 환경은 부족했다는 사실이 올해 나에게 가장 크게 다가온 한계였다.
나를 지탱해 준 것과, 나를 힘들게 했던 것
사실 올해 나를 지탱해 준 게 무엇인지 명확히 말하기는 어렵다.
누군가의 격려나 어떤 환경이 나를 버티게 했다기보다, 스스로를 끊임없이 채찍질하며 여기까지 왔다고 생각한다. 더 좋은 환경, 더 좋은 개발자가 되고 싶다는 마음 하나로 스스로를 몰아붙였다. 힘든 순간이 있을 때마다 그 채찍질이 멈추지 않게 했고, 어쩌면 그게 지금의 나를 버티게 한 유일한 원동력이었을지도 모른다.
생각해보면, 만약 올해가 조금 더 안락했다면 이렇게 회고록을 쓰고 있지도 않았을 것이다.
편안함보다는 불편함이, 안정보다는 혼란이, 나를 돌아보게 만들었다.
나를 힘들게 했던 일들은 많지만, 굳이 다 꺼내고 싶지는 않다.
다만 분명한 것은 — 2020년 1월 개발자로서 커리어를 시작한 이후, 올해가 가장 힘든 해였다는 사실이다.
그럼에도 나는 이 힘듦을 단순한 고통으로만 남기고 싶지 않다.
이 모든 시간을 양분삼아 더 단단하게 성장하기 위해 앞으로 나아가려 한다.
2. 이직 준비 이야기
이직을 결심하게 된 배경
나는 기본적으로 커리어에 대한 욕심이 있는 사람이다.
그렇다고 해서 매일같이 자기계발에 매달리는 타입은 아니지만, 최소한 내가 몸담고 있는 회사에서 믿고 나아갈 만한 무언가는 있어야 한다고 생각한다.
그게 기술 스택이든, 조직 문화든, 함께 일하는 사람들 간의 관계든 상관없다.
중요한 건, 내가 시간을 들여 머물러도 괜찮다고 느낄 만한 ‘지향점’이 있어야 한다는 것이다.
하지만 지금의 나는 회사 안에서 그 어떤 지향점도 찾을 수 없었다.
너무 구체적으로 말하면 불만처럼 들리겠지만, 사실 단 하나라도 버팀목이 될 만한 요소가 없었다.
기술 스택은 오래됐지만 개선하려는 의지가 보이지 않았고, 조직의 문화나 구성원 간의 피드백 흐름도 단절되어 있었다. 그렇다고 사람 간의 관계가 편한 것도 아니었다.
결국 모든 것이 애매한 상태로 남아 있었다.
이런 환경 속에서 더 나은 개발자가 되기 위한 동력도, 회사와 함께 성장할 수 있다는 믿음도 점점 희미해졌다. 그래서 나는 결심했다.
이직은 단순히 회피가 아니라, 다시 한 번 내가 성장할 수 있는 환경을 찾기 위한 선택이었다.
면접, 지원 과정에서 느낀 것들
지원 과정과 면접을 거치면서, 내가 내 커리어에 얼마나 무심했는지를 뼈저리게 느꼈다.
그동안은 주어진 일을 묵묵히 해내는 걸로 충분하다고 생각했는데, 막상 새로운 기회를 찾아 나서보니 그게 전부가 아니었다.
내가 쌓아온 경험이 어떤 의미를 가지는지, 내 이력서를 본 사람들이 어떤 인상을 받을지,
그런 것들을 평소에 진지하게 고민해본 적이 없었다는 걸 깨달았다.
그 부족함이 면접 자리에서 고스란히 드러났다.
아쉬웠지만, 동시에 나 자신을 돌아보게 만드는 계기이기도 했다.
늦었다면 늦은 거고, 발등에 불이 떨어져서야 움직이기 시작한 걸 수도 있지만,
그래도 지금이라도 다시 방향을 잡아보려 한다.
조금씩이라도 꾸준히, 나를 더 나은 개발자로 만들기 위한 발걸음을 내딛고 있다.
앞으로 내가 바라는 커리어 방향
앞으로의 커리어 방향에 대해 생각해보면, 나는 우선 ‘좋은 회사’를 찾기보다 ‘좋은 사람이 되는 것’에 집중하려고 한다.
내가 먼저 경쟁력 있고 단단한 사람이 되어야, 그에 걸맞은 환경과 동료를 만날 수 있다고 믿는다.
그동안 나는 회사의 한 구성원으로서 책임감 있게 일해왔지만, 이제는 그 책임감의 방향을 나 자신에게도 향하게 하려 한다.
내가 맡은 일의 완성도뿐 아니라, 개발자로서의 시야와 깊이를 넓히고 싶다.
누가 시켜서가 아니라, 스스로 성장의 필요성을 느껴서 움직이는 사람이 되고 싶다.
결국 커리어라는 건 회사가 만들어주는 게 아니라, 내가 만들어가는 거니까.
지금까지는 주어진 일을 잘 해내는 사람이 되고자 했다면,
이제부터는 내가 나아가고 싶은 방향을 스스로 설계할 줄 아는 개발자가 되고 싶다.
3. 오랜 취미와 거리 두기
오랜 취미이자 친구 같았던 게임과의 관계
중고등학교 시절부터 게임은 내 인생의 한 부분이었다.
그 시절의 나에게 게임은 단순한 취미를 넘어, 하루의 피로를 잊게 해주는 친구 같았다.
새로운 게임이 출시되면 설레었고, 밤을 새워서라도 클리어해야 직성이 풀렸다.
그렇게 오랜 시간 동안 내 삶에 자연스럽게 스며 있던 게임이었지만,
이제는 조금씩 거리를 두고 있다.
물리적으로 시간을 내기도 어렵고, 하루를 마치고 나면 체력적으로도 여유가 없다.
거기에 돈까지 들어가다 보니, 문득 ‘내가 이걸 해서 얻는 게 뭘까’ 하는 생각이 들었다.
예전엔 그저 즐겁기만 했던 일이,
지금은 어딘가 허무하게 느껴질 때가 있다.
그래서 요즘은 게임 대신 다른 것들에 눈을 돌리려 한다.
언젠가 다시 편한 마음으로 게임을 즐길 수 있는 날이 오겠지만,
적어도 지금의 나는, 그 시절처럼 몰입하긴 어려울 것 같다.
게임을 대신해 채워야 할 시간과 마음
게임을 멀리하게 되면서, 하루 중에 조금의 여유가 생겼다.
예전 같았으면 그 시간에 모니터 앞에 앉아 있었겠지만,
이제는 그 시간을 어떻게 채워야 할지 스스로 고민하게 된다.
돌이켜보면 그동안의 여가시간은 늘 ‘게임이 정해주는 흐름’ 속에 있었다.
이제는 그 흐름이 사라졌고, 대신 내가 내 일상을 주도적으로 설계해야 하는 숙제가 생겼다.
무언가를 하지 않아도 괜찮다고 스스로를 다독이기도 하고,
하루의 균형을 되찾으려는 작은 시도를 반복하고 있다.
아직은 완전히 익숙하진 않다.
하지만 분명한 건, 이 시간을 통해 내 일상에 안정감을 되찾고 싶다는 마음이다.
일을 위한 하루가 아니라, 나를 위한 하루를 만들어 가는 과정이라고 믿고 싶다.
‘나’를 위한 새로운 여가와 성장의 가능성
이 회고록을 쓰는 동안, 회사의 한 동료가 퇴사한다는 소식을 들었다.
이직을 준비하며 만든 포트폴리오를 보여줬는데, 그걸 보고 나 스스로가 꽤 나이브하게 생각하고 있었다는 걸 깨달았다.
막연히 “열심히 하면 언젠가 좋아질 거야”라고 믿고 있었던 부분들이 실은 근거 없는 위로에 가까웠다는 걸 느꼈다.
그때부터 생각이 조금 달라졌다.
앞으로는 내가 가고 싶은 회사, 맡고 싶은 직무와 더 밀접한 개발 활동을 해야겠다고 마음먹었다.
단순히 공부하거나 기록하는 수준이 아니라, 결과물이 눈에 보이게 남는 방식으로.
그래야만 나 자신이 성장하고 있다는 걸 실감할 수 있고,
그 흔적들이 결국 내 커리어의 다음 장으로 이어질 수 있을 테니까.
이제는 여가의 의미도 달라졌다.
단순한 ‘휴식’이 아니라, 나를 더 나은 개발자로 만들어주는 ‘시간의 투자’로 생각하려 한다.
그 안에서 작은 프로젝트를 만들 수도 있고, 기술적인 호기심을 실험할 수도 있겠지.
그런 시도들이 쌓여 언젠가는 또 다른 ‘나’를 만들어 줄 거라 믿는다.
맺음말
힘들었던 2025년을 어떻게 마주하고 있는가
2025년은 나에게 유난히 버거운 한 해였다.
한창 힘들었던 시기에는 스스로가 조금은 부정적으로 변해가는 걸 느꼈다.
무기력했고, 자꾸만 이유를 찾으려 했고, 그 이유가 결국 나 자신에게로 향하기도 했다.
하지만 지금은 마음을 조금 다잡았다.
이런 시간도 결국은 내가 지나가야 할 과정이라고 생각하게 됐다.
힘들었던 경험 안에서도 내가 배울 수 있는 것, 얻어갈 수 있는 것이 분명히 있다고 믿는다.
이제는 단순히 ‘힘든 시기’로만 남기고 싶지 않다.
좋을 때도 있고, 나쁠 때도 있는 게 삶이라면
지금은 그 ‘나쁠 때’를 견디며 더 단단해지는 시기라고 받아들이려 한다.
결국 이 시기를 잘 버티고 나면, 그 어떤 순간에도 흔들리지 않을 내면의 근육이 생길 것 같다는
그런 믿음으로 하루하루를 마주하고 있다.
앞으로의 삶에서 지켜가고 싶은 태도
앞으로의 10년, 20년 뒤를 미리 그려보는 건 아직은 어렵다.
다만, 그 시간 속에서도 변하지 않게 지키고 싶은 태도 하나는 분명하다.
바로 향상심이다.
지금의 나이에 멈춰버리거나, 익숙함 속에서 안주하는 건 너무나도 나태한 일이라고 생각한다.
그래서 나는 언제나 과거의 나보다 조금이라도 나아지고자 노력한다.
하루하루 살아가면서도 “지금 내가 하는 일이 과연 최선인가?”를 스스로에게 묻는다.
그 질문이 때로는 나를 괴롭히기도 하지만, 동시에 앞으로 나아가게 만드는 원동력이 되기도 한다.
나는 그렇게, 늘 조금씩 성장하는 사람으로 남고 싶다.
멈추지 않고, 스스로를 돌아보며, 더 나은 방향으로 나아가려는 태도.
그게 내가 앞으로의 삶에서 꼭 지켜가고 싶은 마음가짐이다.
-
-
-
-
패킷 단편화와 Multi Send 상황을 고려한 Netty Decoder 적용 (1)
패킷 단편화와 Multi Send 상황을 고려한 Netty Decoder 적용 (1)
Netty 는 비동기 이벤트 기반 프레임워크다. 이런 Netty 를 활용한 TCP 서버 개발 당시, 클라이언트의 다중 send() 요청을 하나의 Transaction 으로 간주하여 처리해야 했다.
또한, 다중 send()가 아니더라도 TCP 통신 특성 상, 여러 개로 단편화된 패킷이 수신될 경우를 대비하여 Netty Decoder 를 적용한 사례를 정리한 글이다.
클라이언트는 서버에서 메시지의 총 길이와 헤더 정보가 담긴 구조체를 send 하고, 바로 이어서 메시지의 Body 내용이 담긴 구조체를 send 한다.
클라이언트가 보내는 두 번의 send 과정 중, 데이터의 크기나 프로토콜 특성으로 인해 패킷이 분할되어 수신될 수 있다.
위의 2가지 상황을 고려하여 아래와 같이 보완 처리했다.
메시지의 총 길이와 헤더 정보 구조체에서 메시지의 총 길이를 추출하여 그 길이만큼 패킷을 계속 누적하여 수집
패킷이 유실될 가능성을 염두하여 특정 Timeout 만큼 대기하고 이후에는 패킷을 Drop 처리
1. 메시지의 총 길이만큼 패킷 누적
public class MultiSendDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// 최소 4바이트 수신 여부 확인
if (4 > in.readableBytes()) {
return;
}
// 프로토콜 코드 peek
int code = in.getIntLE(in.readerIndex());
switch (code) {
case MultiSendConstants.OLD_PROTOCOL -> { // 구 프로토콜은 패킷 사이즈가 고정 (100 바이트 고정)
// 전체 데이터 수신시까지 누적
if ( 100 == in.readableBytes() ) {
out.add(in.readBytes(100));
} else {
log.debug("[RECEIVED SIZE]{}", in.readableBytes());
}
}
case MultiSendConstants.NEW_PROTOCOL_CODE -> out.add(in.readBytes(4)); // Multi Send 첫 번째 (프로토콜 코드값만 read)
case MultiSendConstants.NEW_PROTOCOL_DATA -> { // Multi Send 두 번째
// 패킷 구조 (message code:4 + message length:4 + message:N)
// message length 까지 수신된 경우
if ( 8 <= in.readableBytes() ) {
// message length peek (message code 4바이트만큼 건너뛴 offset 부터 읽기)
int messageSize = in.getIntLE(4 + in.readerIndex());
// message length + message 까지 전부 수신된 경우
if ((4 + messageSize) == in.readableBytes()) {
out.add(in.readBytes(4 + messageSize)); // message length:4 + message:N 만큼 데이터를 Netty Handler 에 전달
} else {
log.debug("[RECEIVED SIZE]{}, [MESSAGE SIZE]{}", in.readableBytes(), messageSize);
}
}
}
default -> {
log.warn("Unsupported protocol version. [CODE]{}", code);
// 미지원 프로토콜 요청 패킷 SKIP 처리
in.skipBytes(in.readableBytes());
ctx.close().addListener(ChannelFutureListener.CLOSE);
}
}
}
}
코드 설명
MultiSendConstants.OLD_PROTOCOL : 옛날 프로토콜로 객체의 데이터 길이가 제한되어 있고, 패킷에 데이터를 4바이트 단위로 남는 바이트는 0으로 채운(padding) 형태로 marshalling 하여 패킷 전송
MultiSendConstants.NEW_PROTOCOL_CODE : 신규 프로토콜로 통신해야 할 프로토콜 코드를 먼저 전달
MultiSendConstants.NEW_PROTOCOL_DATA : (2)의 프로토콜 코드 전달 후, 바로 이어서 DATA 패킷 send() 하며, DATA 패킷은 메시지 코드와 이어질 데이터 부분의 전체 길이를 포함한 형태로 패킷 전송
-
이력서
김진용
📧 이메일: yong9976@naver.com
🎨 GitHub: github.com/readra
프로필
Java & Spring 기반의 5년차 백엔드 개발자로 국내 5,000개 이상의 고객사 보유한 솔루션 회사에서 백엔드 플랫폼 개발 및 클라우드 기반 ABAC 모델 개발 등을 해왔습니다.
여러 동료들의 다양한 코드 리뷰에 참석하거나, 본인의 코드 리뷰에 참여를 권하여 코드 리뷰를 통해 지식을 공유하고, 배우는 것을 영양분으로 삼아서 확장성과 사용성을 신경 쓰는 개발자로 거듭나기 위해 노력하고 있습니다.
테스트를 중요하게 생각하여 테스트 코드 작성이나 제한적인 환경에서는 자체 테스트 드라이버&스텁을 만들어서 테스트 환경을 구축 및 수행합니다.
현재는 팀 내 백엔드 파트 리더로 백엔드와 클라우드 기반의 주요 프로젝트 수행, 파트원들과의 업무 분장, 파트원 매니징 등을 주요 업무로 하고 있습니다.
기술 스택
현재 업무에 사용 중 혹은 사용했던 기술들입니다.
Backend
Java
Spring Boot, Spring Batch, Spring Security, Spring MVC, Spring Data JPA
MyBatis, JPA, Hibernate
Junit5, Mockito
Maven, Gradle
IntelliJ
DevOps
MySQL, MariaDB, Oracle, Sybase
AWS EC2, CodeDeploy, RDS, DynamoDB
Tomcat
Linux Ubuntu, Linux CentOS
경력
피앤피시큐어 – 연매출 580억, 순이익 270억의 중소 보안 솔루션 기업
백엔드 개발자 | 2021년 5월 ~ 현재
ABAC(Attribute-based access control) 모델 개발
DBSAFER 백엔드 개발
Cloud(AWS/GCP/Azure/NCP) 자원 자동 탐지 및 자사 솔루션과 연계 기능 개발
휴머스온 – 통합메시징 솔루션 기업
웹 어플리케이션 개발자 | 2020년 1월 ~ 2021년 5월
Mail/SMS/Push/알림톡/Fax 발송 엔진 개발
B2B 프로젝트 참여 및 개발 수행
증권사 체결 전용 발송 엔진 개발 (대규모 트래픽, 데이터 환경 경험)
프로젝트
속성 기반 접근 제어(ABAC) 모델 개발
피앤피시큐어 (2024년 10월 ~ )
On-Premise 와 Cloud 시스템의 다양한 요소를 반영할 수 있는 표현력과 유연성이 높은 제어 모델 개발
리소스 속성(On-premise/Cloud) 수집 모듈 개발
온프레미스 시스템의 인사/서버/계정 등의 속성 정보를 수집하는 표준 모듈 개발
클라우드 시스템을 구성하는 속성 정보(VPC, AMI, SG, Tag 등)를 수집하는 표준 모듈 개발
기술 스택: Java, Spring Boot, WebClient, gRPC, AWS/GCP/Azure SDK
속성 자원 관리 서버 개발
수집한 속성 정보를 중앙에서 관리하는 API 서버 고도화 작업
복잡한 속성 조건 계산을 위한 속성 정보 구조화 및 캐싱 처리
관리자 페이지에서 ABAC 수립에 필요한 API 개발
레거시 접근 제어(RuBAC, RBAC) 모델 호환 지원 작업
기술 스택: Java, Spring Boot, Ehcache, gRPC, MySQL, MariaDB, MyBatis, HikariCP, JWT
접근제어 백엔드 고도화 개발
피앤피시큐어 (2024년 7월 ~ 2024년 12월)
기존에 제품 버전마다 형상을 분리해서 관리하고 있었기에, 개발 생산성과 일부 중복 개발로 인한 리소스 낭비를 줄이기 위한 여러 개의 접근제어 제품 버전을 통합으로 지원할 수 있는 아키텍쳐로 전환
기존의 특정 고객사 전용 요구사항 개발 기능을 별도의 형상으로 관리하고 있었기에, 히스토리 관리의 어려움과 개발 생산성이 낮았기에, 이러한 문제를 해결하고자 master 형상에서 특정 고객사 전용 요구사항 개발이 가능한 구조로 고도화했으며, 빌드 옵션 처리를 통해 master 형상 또는 특정 고객사 전용 형상으로 테스트 → 빌드 → Docs 생성까지 자동화 처리
대외 시스템 대상으로 API 오픈을 위해 강력한 인증 보안이 요구되었고, 이를 충족하기 위해 Refresh Token Rotation 과 Refresh Token 탈취 및 재사용 감지 기능 개발
기술 스택: Java, Spring Boot, Spring Security, MySQL, MariaDB, MyBatis, HikariCP, JWT
고객사 프로젝트 수행 (카카오페이/한국산업은행/카카오 등)
피앤피시큐어 (2021년 5월 ~ )
주요 성과
서버 자동 접속 관리 백엔드 개발 (기술 스택: Netty, Spring Boot)
각종 고객사 시스템(인사/결재/클라우드) 연동 (기술 스택: Spring Boot, AWS SDK)
고객사 프로젝트 수행 (라인뱅크/삼성증권 등)
휴머스온 (2020년 1월 ~ 2021년 5월)
주요 성과
입/출금, 체결 전용 메시지 우선 발송 엔진 개발 (기술 스택: Spring Batch)
솔루션 RDBMS 컨버팅 (Sybase → Oracle)
기간계 시스템 연동
자격증
정보처리기사
2019년 11월
기타 활동
블로그: 개발 관련 블로그 운영 (소스 보관함)
브런치스토리: 일상 이야기와 생각 정리 (토마)
교육
한세대학교 – 경기
정보통신공학과 학사 | 2014년 3월 – 2020년 2월 (졸업)
한영고등학교 – 서울
인문계 | 2010년 3월 – 2013년 2월 (졸업)
-
Spring Security 다중 인증 방식 주의사항 (2)
Spring Security 다중 인증 방식 주의사항 (2)
유효하지 않은 인증 정보로 JWT 발급이 가능한 현상
원인#1. UsernamePasswordAuthenticationToken 을 사용한 2가지의 인증 방식이 존재 (API KEY, JWT 방식)
해결 방안#1. 각 인증 방식에서 사용하는 Token 클래스를 분리 (JWT = UsernamePasswordAuthenticationToken, API KEY = AbstractAuthenticationToken 상속 사용자 정의 클래스)
원인#2. Provider 루프에 의해 JWT, API KEY Provider 의 authenticate 함수가 호출되는데, JWT Provider 처리 시, Password 불일치하더라도 다음 API KEY Provider 처리 시, ID는 일치하여 인증이 성공하는 현상
해결 방안#2. 각 인증 방식 Provider 에서 취급하는 Token 클래스가 아닌 경우, authenticate 함수 return 처리하여 인증 로직을 수행하지 않고 Pass 처리
잘못된 예외 처리로 인해 다중 인증 방식이 정상적으로 동작하지 않은 현상
원인#1. ProviderManager 에서 throw 처리하는 Exception 을 발생시키고 있어, 다음 Provider 로 넘어가지 않고 즉시 인증이 실패하는 현상
원인#2. 커스텀 인증 방식 중에 실제로 존재하지 않는 ID를 기반으로 한 익명 계정으로 인증할 수 있도록 기능 제공한 사례가 있다. 이 때, 존재하지 않는 익명의 계정 정보로 인증 요청이 온 경우, InternalAuthenticationServiceException 이 발생하여 익명 계정을 기반으로 인증을 수행하는 Provider 까지 넘어가지 않고 인증이 실패하는 현상
해결 방안#1. NPE 처럼 크리티컬한 오류 상태가 아닌 경우에는 InternalAuthenticationServiceException 혹은 AccountStatusException 사용을 지양
-
Spring Security 다중 인증 방식 주의사항 (1)
Spring Security 다중 인증 방식 주의사항 (1)
Spring Security 기반 API 인증 방식을 2가지 이상 지원 시, 발생했던 이슈를 정리한 글이다.
JWT 기반 인증 방식 (내/외부 사용자)
API KEY 기반 인증 방식 (내부 사용자 전용)
유효하지 않은 인증 정보로 JWT 발급이 가능한 이슈가 발생했다.
이슈 해결하는 과정에서 잘못된 예외 처리로 인해 다중 인증 방식이 정상적으로 동작하지 않는 현상도 발생했다.
ProviderManager Class 해체 분석
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
/* 중략 */
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
Iterator var9 = this.getProviders().iterator();
while(var9.hasNext()) { // 1번
AuthenticationProvider provider = (AuthenticationProvider)var9.next();
if (provider.supports(toTest)) {
if (logger.isTraceEnabled()) {
Log var10000 = logger;
String var10002 = provider.getClass().getSimpleName();
++currentPosition;
var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
}
try { // 2번
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
} catch (InternalAuthenticationServiceException | AccountStatusException var14) { // 3번
this.prepareException(var14, authentication);
throw var14;
} catch (AuthenticationException var15) { // 4번
lastException = var15;
}
}
}
if (result == null && this.parent != null) {
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
} catch (ProviderNotFoundException var12) {
} catch (AuthenticationException var13) {
parentException = var13;
lastException = var13;
}
}
if (result != null) { // 5번
if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
((CredentialsContainer)result).eraseCredentials();
}
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
} else {
if (lastException == null) { // 6번
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
}
if (parentException == null) {
this.prepareException((AuthenticationException)lastException, authentication);
}
throw lastException;
}
}
/* 중략 */
}
등록된 Providers 하나씩 루핑하면서 while 문 내부의 동작을 수행한다.
등록된 Provider 의 authenticate 함수의 결과를 result 에 저장한다.
InternalAuthenticationServiceException 혹은 AccountStatusException 발생 시, 예외가 throw 되며 인증에 실패한다.
AuthenticationException 발생 시, 마지막 예외 상태(lastException)에 예외를 저장하고 다음 Provider 로 넘어간다.
인증 결과가 존재할 경우, if 조건문 처리 후, 결과를 return 한다.
인증 결과가 없을 경우, 마지막 Exception 을 throw 한다.
대략 이런 구조로 인증 절차가 수행된다. 다음 포스팅에서는 아래의 원인에 대해 작성하겠다.
유효하지 않은 인증 정보로 JWT 발급이 가능했던 원인
잘못된 예외 처리로 인해 다중 인증 방식이 정상적으로 동작하지 않은 원인
-
-
Spring Boot + Maven 환경에서 커스텀 빌드하기 (3)
Spring Boot + Maven 환경에서 커스텀 빌드하기 (3)
CUSTOM REST API 예제 작성
고객사 전용 커스텀 예제를 작성한다.
마찬가지로 최대한 간단하게 작성한다. (이게 중요한 게 아니니까2)
Test Code
package com.toy.springbootmavencustombuild.site.customer;
import com.toy.springbootmavencustombuild.site.customer.rest.CustomerController;
import com.toy.springbootmavencustombuild.site.customer.service.CustomerService;
import org.junit.Before;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.restdocs.JUnitRestDocumentation;
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* CustomerController 테스트 코드
*
* @author readra
*/
@AutoConfigureRestDocs
@WebMvcTest(CustomerController.class)
public class CustomerControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private WebApplicationContext webApplicationContext;
@MockBean
private CustomerService customerService;
@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext)
.apply(documentationConfiguration(new JUnitRestDocumentation()))
.build();
}
@Test
@DisplayName("Customer Hello World")
void helloWorldTest() throws Exception {
final int code = 100;
final String result = String.format(CustomerService.HELLO_WORLD, code);
// given
given(customerService.helloWorld(anyInt())).willReturn(result);
// when
ResultActions resultActions = mockMvc.perform(
RestDocumentationRequestBuilders.get("/v1/customer/hello-world/{code}", code)
.contentType(MediaType.APPLICATION_JSON)
);
// then
resultActions.andExpect(status().isOk())
.andDo(
document(
"Customer Hello World",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
pathParameters(
parameterWithName("code").description("코드")
),
responseFields(
fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"),
fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드")
)
)
)
.andDo(print());
}
}
Controller
package com.toy.springbootmavencustombuild.site.customer.rest;
import com.toy.springbootmavencustombuild.site.customer.service.CustomerService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 고객사 전용 Controller Layer
*
* @author readra
*/
@RestController
@RequestMapping("/v1/customer/")
public class CustomerController {
private final CustomerService customerService;
@Autowired
public CustomerController(final CustomerService customerService) {
this.customerService = customerService;
}
/**
* 안녕하세요!
*
* @param code
* 코드
* @return
* 안녕하세요!
*/
@GetMapping("/hello-world/{code}")
public String helloWorld(@PathVariable int code) {
return customerService.helloWorld(code);
}
}
Service
package com.toy.springbootmavencustombuild.site.customer.service;
import org.springframework.stereotype.Service;
/**
* 고객사 전용 Service Layer
*
* @author readra
*/
@Service
public class CustomerService {
public static final String HELLO_WORLD = "{ \"message\" : \"Hello World My Customer\", \"code\" : %d }";
/**
* 안녕하세요!
*
* @param code
* 코드
* @return
* 안녕하세요!
*/
public String helloWorld(int code) {
return String.format(HELLO_WORLD, code);
}
}
REST Docs
= Rest Docs Customer Sample API Document
readra.github.io
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
[[introduction]]
== 소개
readra Spring Rest Docs API
---
=== 고객사 전용 안녕하세요 API
고객사 전용 안녕하세요 API를 소개합니다.
operation::Customer Hello World[snippets='http-request,path-parameters,http-response,response-fields,curl-request']
---
-
Spring Boot + Maven 환경에서 커스텀 빌드하기 (2)
Spring Boot + Maven 환경에서 커스텀 빌드하기 (2)
REST API 예제 작성
예제는 최대한 간단하게 작성한다. (이게 중요한 게 아니니까)
Test Code
package com.toy.springbootmavencustombuild.rest;
import com.toy.springbootmavencustombuild.service.HelloWorldService;
import org.junit.Before;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.restdocs.JUnitRestDocumentation;
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* HelloWorldController 테스트 코드
*
* @author readra
*/
@AutoConfigureRestDocs
@WebMvcTest(HelloWorldController.class)
public class HelloWorldControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private WebApplicationContext webApplicationContext;
@MockBean
private HelloWorldService helloWorldService;
@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext)
.apply(documentationConfiguration(new JUnitRestDocumentation()))
.build();
}
@Test
@DisplayName("Hello World")
void helloWorldTest() throws Exception {
final int code = 100;
final String result = String.format(HelloWorldService.HELLO_WORLD, code);
// given
given(helloWorldService.helloWorld(anyInt())).willReturn(result);
// when
ResultActions resultActions = mockMvc.perform(
RestDocumentationRequestBuilders.get("/v1/hello-world/{code}", code)
.contentType(MediaType.APPLICATION_JSON)
);
// then
resultActions.andExpect(status().isOk())
.andDo(
document(
"Hello World",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
pathParameters(
parameterWithName("code").description("코드")
),
responseFields(
fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"),
fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드")
)
)
)
.andDo(print());
}
}
Controller
package com.toy.springbootmavencustombuild.rest;
import com.toy.springbootmavencustombuild.service.HelloWorldService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 예제 Controller Layer
*
* @author readra
*/
@RestController
@RequestMapping("/v1")
public class HelloWorldController {
private final HelloWorldService helloWorldService;
@Autowired
public HelloWorldController(final HelloWorldService helloWorldService) {
this.helloWorldService = helloWorldService;
}
/**
* 안녕하세요!
*
* @param code
* 코드
* @return
* 안녕하세요!
*/
@GetMapping("/hello-world/{code}")
public String helloWorld(@PathVariable int code) {
return helloWorldService.helloWorld(code);
}
}
Service
package com.toy.springbootmavencustombuild.service;
import org.springframework.stereotype.Service;
/**
* 예제 Service Layer
*
* @author readra
*/
@Service
public class HelloWorldService {
public static final String HELLO_WORLD = "{ \"message\" : \"Hello World\", \"code\" : %d }";
/**
* 안녕하세요!
*
* @param code
* 코드
* @return
* 안녕하세요!
*/
public String helloWorld(int code) {
return String.format(HELLO_WORLD, code);
}
}
REST Docs
= Rest Docs Sample API Document
readra.github.io
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
[[introduction]]
== 소개
readra Spring Rest Docs API
---
=== 안녕하세요 API
안녕하세요 API를 소개합니다.
operation::Hello World[snippets='http-request,path-parameters,http-response,response-fields,curl-request']
---
-
-
Spring Boot + Maven 환경에서 커스텀 빌드하기 (1)
Spring Boot + Maven 환경에서 커스텀 빌드하기 (1)
현재 나는 솔루션 회사에 근무하고 있다. 여러 업무 중 하나로 API 서버를 개발/유지보수하고 있다.
솔루션에는 대부분 기능의 표준이 존재하지만, 고객의 강력한 요청에 의해 표준의 경계가 흐려지거나 망가지는 케이스가 많다. (경험상)
API 서버라고 예외는 없다. 고객의 강력한 커스터마이징 요청이 있었고, 이를 대응하기 위해 하나의 프로젝트에서 고객사별로 커스텀 빌드가 가능하도록 처리한 내용을 정리하려고 한다.
개인적으로 하는 개발과 달리 다소 제약적인 회사 내, 개발 환경이라는 점 참고 바랍니다.
[AS-IS]
* 공통 코드는 하나의 단독 프로젝트로 관리한다.
* 커스터마이징 코드는 공통 코드와는 분리된 별도의 프로젝트로 관리하며, 저장소 위치도 다르다.
* 커스터마이징 개발의 경우, 커스터마이징 코드에 공통 코드를 Import Module 하여 개발한다.
나는 개인적으로 이런 낯선 개발 환경/방식을 스스로 풀어나가는 걸 좋아한다.
하지만, 문제는 새로 입사하신 분들 중에는 빠르게 정답만 알고 싶어하시는 분이 있다는 점이다.
그럴 때마다 듣는 사람도 심드렁하고, 알려주는 보람도 없는 일을 더 이상 하기 싫어졌다.
커스터마이징 코드 관리 전략을 스스로 생각하기에 좋은 패턴으로 개선한 내용을 소개하려고 한다.
[TO-BE]
* 공통 코드는 하나의 단독 프로젝트로 관리한다.
* 커스터마이징 코드는 공통 코드와 동일한 프로젝트에 관리하며, 저장소 위치도 동일하다.
* 커스터마이징 개발의 경우, 추가 작업 없이 바로 개발이 가능하다.
* 단, 커스터마이징 코드는 정해진 hierarchy 규칙에 따라 관리/개발한다.
AS-IS 구조의 개발이 안좋다고 말하려는 것이 아니다.
정답은 없다고 생각하며, 또 다른 하나의 방법을 제시하는 것이다.
글로만 전달하려고 하니, 괜한 오해가 있을까봐 말이 길어진다...
커스터마이징 너무 싫다... 조금만 줄이고 싶다...
프로젝트 준비
Common API 테스트 코드 작성 (+REST Docs)
Common API 테스트 코드 기반으로 실제 코드 작성
Customizing API 테스트 코드 작성 (+REST Docs)
Customizing API 테스트 코드 기반으로 실제 코드 작성
Common + Customizing API 패키징 (실제 코드 + 테스트 코드 + REST Docs)
위의 총 5-Step 에 걸쳐 정리할 예정이다.
다음 글에서는 간단한 Common API 테스트 코드 작성하여 REST Docs API 문서를 만들고, 이를 기반으로 실제 코드를 작성하도록 하겠다.
-
Touch background to close