728x90

한 달 만에 195명, 호빵 유저를 모으며 느낀 마케팅의 힘

 

호빵 서비스를 본격적으로 알리기 시작한 지 어느덧 한 달이 넘었습니다. 그 사이 약 180명의 유저가 가입했고, 그 과정을 통해 가장 크게 느낀 점은 “마케팅은 사업의 절반 이상”, 어쩌면 80% 이상일 수도 있다는 사실이다.

 

단순히 좋은 서비스를 만드는 것만으로는 충분하지 않았습니다. 어떻게 알릴지, 어디에 노출할지, 어떤 메시지로 전달할지가 오히려 더 중요하다는 것을 매일 실감하고 있다.

 

 

데이터 기반 마케팅의 위력

 

마케팅 전략을 세우는 데 있어 통계 데이터의 힘은 정말 강력했다.

총 가입자 수

등록된 견적 수

앱 유입 비율

일별 유저 유입/탈퇴/견적 등록 추이

지역별 견적 등록 수

유입 경로별 클릭 수

 

이 모든 데이터를 시각화하여 매일 확인하고 분석했다.

 

예를 들어, 지역별 견적 등록 수를 분석해보니 서울/경기권의 수요가 압도적으로 높다는 것을 확인할 수 있었고, 이를 기반으로 인스타그램 광고의 타겟 지역을 정했다.

 

또한 일별 유저 유입 그래프를 통해 신규 유저가 주말에 더 많이 유입된다는 사실을 파악했고, 그에 맞춰 광고 예산을 주말에 집중적으로 투입하는 전략을 실행했습니다. 작은 실험이었지만, 분명한 효과를 체감할 수 있었다.

 

 

고객의 ‘진짜 니즈’에 다가가기

 

마케팅을 하며 또 한 가지 중요한 것을 배웠다.

바로 잠재고객들이 무엇을 원하는지를 명확히 파악해야 한다는 것다.

 

서비스에 대한 메시지를 조금씩 바꿔가며 실험해봤습니다. 블로그, 카페, SNS 등에 홍보 글을 올릴 때마다 서비스 URL에 쿼리 파라미터를 다르게 설정해 경로별 클릭 수를 추적했다.

 

그 결과, 유저들이 가장 관심을 가지는 포인트는 단연 “가격”이었다.

 

예를 들어 인스타그램 광고에서 단순한 이미지보다, 정확한 수치가 명시된 이미지를 올렸을 때 클릭율이 훨씬 높았다.

 

단순 텍스트 설명 이미지 (클릭률 2.2%)

 

 

정확한 가격 수치 포함 이미지 (클릭률 7.8%)

 

무려 5.6%포인트 차이였다.

이 작은 차이가 실제 유저 유입에는 큰 차이를 만들어냈다.

 

 

결과

 

지금도 호빵의 마케팅은 실험 중이다.

어떤 문구가 사람들의 마음을 움직이는지, 어떤 플랫폼에서 전환율이 높은지,

그리고 결국 어떤 정보가 고객에게 ‘신뢰’로 다가가는지.

 

이 모든 것을 고민하고 실험하며, “가장 정확하고, 가장 합리적인 창호 견적 플랫폼” 으로 자리잡기 위한 여정을 계속하고 있다.

 

앞으로 구글, 네이버 등 광고를 진행 하여 고객님들의 반응을 살펴 볼 예정이다.

 

추가적으로 할인 이벤트를 할 예정인데 문구를 어떻게 해야 할지 광고를 해서 반응을 살펴 봐야할 것 같다.

 

'난중(개발)일기 > [프로젝트] 호빵' 카테고리의 다른 글

출시 후 2주차 회고  (1) 2025.03.03
728x90

현재 Java 21에서는 실행 중인 JVM에 에이전트를 동적으로 로드할 때 경고를 발생시키도록 변경되었다. 이는 향후 릴리스에서 기본적으로 동적 에이전트 로딩을 금지하기 위한 준비 단계로, 애플리케이션의 무결성과 보안을 강화하기 위한 조치를 한 것이라고 한다.

 

여기서 에이전트 (Agent) 란?

Java 에이전트 (Agent)는 JVM (Java Virtual Machine)에서 프로그램의 실행 중간에 개입해 동작을 변경하거나 분석할 수 있는 특별한 모듈임. 주로 성능 모니터링, 디버깅, 트래픽 분석, 보안 검사 등을 위해 사용된다.


 

그러면 어플리케이션이 로딩 되면서 에이전트가 무엇을 하길래 금지를 시킬까?

 


에이전트의 주요 역할

1. 바이트코드 조작 (Bytecode Manipulation)
   - 실행 중인 클래스의 바이트코드를 변경하거나 주입할 수 있다.
   - 예를 들어, 메서드 호출 전에 로그를 남기거나, 특정 메서드의 실행 시간을 측정할 수 있다.
2. 프로파일링 (Profiling)
   - CPU 사용량, 메모리 사용량, 메서드 호출 빈도 등을 측정해 성능 분석을 한다.
3. 보안 검사 (Security Monitoring)
   - 악의적인 코드 실행을 방지하고 보안 정책을 강제할 수 있다.
4. 트랜잭션 관리
   - 분산 환경에서 트랜잭션 추적로그 수집에 사용됩니다.
   - 예: APM (Application Performance Monitoring) 툴


에이전트를 동적 로딩을 금지하면서 취할 수 있는 이득은?

동적 에이전트 로딩실행 중인 JVM에 외부 에이전트를 주입해 코드의 동작을 변경하거나 모니터링할 수 있다. 이 과정에서 심각한 보안 이슈가 발생할 수 있습니다. 아래는 그 이유이다.

1. 임의의 코드 실행
동적 에이전트를 통해 JVM 내부에서 임의의 바이트코드를 주입할 수 있다. 예를 들어, 악성 에이전트가 System.exit() 호출을 우회하거나, 데이터베이스 접근 코드를 삽입해 기밀 정보를 유출할 수 있다.

더나아가 루트 권한으로 실행되는 JVM의 경우, 시스템 전체를 장악하는 심각한 보안 위험을 가져올 수 있다.

2. 무결성 침해
동적 에이전트는 JVM 실행 중에 클래스의 바이트코드를 변경할 수 있다. 예를 들어, 권한 검사를 우회하거나, 보안 검사 코드를 비활성화시킬 수 있다.

이로 인해 암호화 모듈이 변조되어 평문 데이터를 유출할 수 있어 API 요청 검증 로직을 무력화 될 것이다.

3. 메모리 및 자원 침해
에이전트를 통해 JVM의 메모리와 리소스를 모니터링하고 제어할 수 있다. 예를 들어, 자바 힙 메모리의 데이터를 읽어 세션 토큰이나 비밀번호를 유출할 수 있을 것이다.

Heap Dump 를 통해서 서버 민감 정보를 획득할 수 있을 것이고, GC 설정을 조작 해서 서비스가 마비될 수도 있을 것 같다.


결론

그래서 Java 21에서 동적 로딩 금지의 목적은 

- 무결성 강화: JVM 실행 중 클래스 변조 방지.
- 보안성 향상: 악성 에이전트 주입을 통한 취약점 방어.
- 안정성 확보: 실행 중 JVM의 예측 불가능한 동작 방지.

으로 정리 될 수 있겠네요.


 

그럼에도 불구하고 동적 로딩을 허용하고 싶다면 다음 설정으로 동적 로딩 허용은 가능하다

-XX:+EnableDynamicAgentLoading: 동적 에이전트 로딩을 명시적으로 허용
-javaagent 또는 -agentlib: JVM 시작 시 에이전트를 로드

728x90

728x90

개인프로젝트를 앱스토어, 플레이스토어에 둘 다 출시 했다.

 

앱 명은 인테리어를 하면서 사람들이 소위 호구를 많이 당하는데, 구를 명으로 만들고자 호빵으로 지었다.

 


기획부터 프론트엔드(웹, 앱), 백엔드, 인프라까지 혼자 맡아서 하다 보니 퇴근 후 작업이 매우 힘들었지만, 값진 경험이었다. 게다가 실제 유저들까지 유입되어 보람을 느꼈다. 현재 유저는 총 81명으로 (가족과 지인을 제외한 수치) 특별히 돈을 안 들이고 블로그 글 4개로 얻은 성과인 점을 감안하면 개인적으로 매우 의미 있는 숫자라고 생각한다.

혼자서 많은걸 하다 보니 UI/UX에 신경을 충분히 쓰지 못한 것 같다. 현업에서는 백엔드 개발을 하고 있고, 프론트엔드는 학교 과제나 개인 프로젝트를 통해 일부 경험해 본 것이 전부라 어려움이 있었다. 또한, 처음 해본 앱 개발이 가장 큰 난관이었다.

실제 유저가 유입되었으며, 전체 서비스 이용률은 약 30~40%였고, 회원가입 과정에서의 이탈률은 약 20%였다. 이를 기반으로 데이터를 분석해 보았다.

1. 유저의 UI에 대한 불신
앱이 렌더링되는 디바이스 화면에 맞게 CSS가 제대로 적용되지 않았다. (출시 후 조금씩 조정하자는 생각으로 미뤄두었다. 사실상 선택과 집중을 한 것이었다고 생각한다. 한정된 시간 속에서 익숙한 백엔드 쪽에 더 공을 들인 것이 가장 큰 이유였다.)

2. 불편하고 친절하지 않은 UX로 인한 불신
예를 들어, 최초 회원가입 후 유저의 휴대폰 인증 과정에서 인증번호 실패 시의 대처 전략이 미흡했다. 렌더링되는 디바이스에 맞게 오류 메시지가 보여야 하는데, 너비가 좁은 디바이스에서는 해당 메시지가 보이지 않아 유저가 당황하고 이탈한 것으로 판단했다.

 

 

결론

위 문제점들을 개선한 결과, 유저 이탈률이 감소하고 전체 서비스 이용률이 증가하는 긍정적인 변화를 경험했다.

'난중(개발)일기 > [프로젝트] 호빵' 카테고리의 다른 글

서비스 시작 후 1달차 회고  (1) 2025.03.23
728x90

RN 으로 배포 후 카카오로그인을 했는데 자꾸 안 됐다.

 

로그를 보니 해시키값이 문제인 것 같았다.

 

그래서 배포할 때 사용한 배포용 keystore 해시키를 추출해서 사용 했는데 안 됐다. (다음과 같이)

keytool -exportcert -alias androiddebugkey -keystore ~/.android/release.keystore -storepass 123456 -keypass 123456 | openssl sha1 -binary | openssl base64

 

이런식으로 해도 안됐다.

 

그래서 GooglePlayConsole > 앱 무결성 > Play 앱 서명 >  앱 서명 키인증서 > SHA-1 인증서 지문 값을 복사 해서 Base64로 컨버팅 한 후 등록 하니까 됐다. 

 

사용 한 컨버터는 다음과 같다. https://tomeko.net/online_tools/hex_to_base64.php?lang=en

728x90

https://ydontustudy.tistory.com/207

 

CGI (Common Gateway Interface)

CGI 란서버와 애플리케이션 간에 데이터를 주고받는 이 프로세스 또는 규칙을 공통 게이트웨이 인터페이스(CGI)라고 한다. 동적인 컨텐츠를 생성할 수 있는 프로그램을 갈망하는 사람들의 요구로

ydontustudy.tistory.com

이 글에 이어서 CGI를 Servlet 과 비교 해본다.

해당 글을 요약하자면, CGI는 웹 서버와 외부 프로그램 간의 통신을 위한 표준 인터페이스이다. 각각의 요청마다 새로운 프로세스가 fork 되는데 이는 서버 리소스가 많이 들고, 동시처리가 어려운 치명적인 단점이 있다. 그래서 이를 개선한 Fast CGI 가 나와서 프로세스 재사용이 가능해졌다. 그러나 여전히 부족한 부분이 많아서 각 플랫폼마다 각자의 웹 서버 기술을 가지게 된다.

 

CGI는 동적인 웹 서비스를 할 수 있게 하는 새로운 페러다임을 소개한다. 그러나 단점이 존재하고 이를 보완하기 위해 Fast CGI라는게 나왔지만 일부만 해결된 정도이다. 그래서 각 플랫폼에서 CGI 의 아이디어를 딴 웹서버가 탄생한다. 이 중에서 CGI와 JAVA의 Servlet을 비교 해보겠다.

 

 

프로세스 vs 쓰레드

CGI의 가장 큰 단점은 매 요청마다 새로운 프로세스를 생성하는데 반면 Servlet은 멀티스레딩을 사용한다. 하나의 프로세스 내에서 여러 스레드를 생성하여 요청을 처리한다. 이는 CGI에 비해 훨씬 적은 시스템 리소스를 사용하며, 더 빠른 응답 시간을 제공한다.

 

 

생명주기 관리 측면

CGI 프로그램은 요청이 들어올 때마다 실행되고 종료된다. Servlet은 한 번 로드되면 메모리에 상주하며, 여러 요청을 처리할 수 있다. 이는 Servlet의 초기화 시간을 줄이고 성능을 향상시킨다.

 

 

이식성

Servlet은 자바의 "Write Once, Run Anywhere" 철학을 따른다. 따라서 다양한 플랫폼에서 실행될 수 있다. 반면 CGI는 플랫폼에 종속적일 수 있다. 자바의 Servlet은 JVM 위에서 돌아가기 때문에 플랫폼에 독립적이라 클라우드 환경에서도 쉽게 적용이 가능하다. 반면 CGI는 스크립트 작성방식, 프로세스 생성방식 등 플랫폼에 종속적이다.

728x90

CGI 란

서버와 애플리케이션 간에 데이터를 주고받는 이 프로세스 또는 규칙을 공통 게이트웨이 인터페이스(CGI)라고 한다. 동적인 컨텐츠를 생성할 수 있는 프로그램을 갈망하는 사람들의 요구로 인해 만들어진 규약이다.

 

 

CGI 작동 방식

프로세스기반으로 동작한다. 즉 매 요청마다 프로세스를 새로 시작하는 방식이다.

이로 인해 요청 수가 많아 지면 서버에 많은 프로세스가 생성되고,요청을 처리 후 종료 된다. 그래서 요청 수가 많아지면 서버에 부담을 준다.

 

FastCGI 의 탄생

이를 보완하기 위해 FastCGI와 같은 대안이 나왔다.

개선점은 매 요청 프로세스를 생성하지 않고 프로세스를 재사용할 수 있게 했다.

그래서 FastCGI 부터는 프로세스 풀이 생김.

IPC 최적화로 인해 멀티스레드와 멀티프로세스 지원이 가능해짐

 

 

결과적으로, FastCGI의 프로세스 풀 개념은 CGI가 겪던 성능 문제를 크게 개선해주었다. 이후의 다른 웹 애플리케이션 인터페이스들이 이러한 개념을 적용하면서 더 발전하게 됨.

 

 

참고

- https://kldp.org/node/73386

 

흠....PHP도 CGI 인가요? | KLDP

제가 CGI에 대한 정의들을 찾아보니 CGI란 클라이언트의 요청을 서버에서 받아 그 요청을 다른 응용 프로그램으로 넘겨서 필요에 따라서 그 결과를 다시 서버로 보내 서버에서 클라이언트로 그결

kldp.org

- https://jongminlee0.github.io/2020/10/10/cgivsservlet/

 

[Server] CGI와 Servlet에 대해서 - Jongmin's Blog

기존 Java를 이용하여 개발하고 현재는 PHP를 사용하여 업무를 하고 있습니다. 그렇다보니 두 언어의 차이점이 존재하고 그 차이점에 대해 궁금하였습니다. 그 중 PHP의 Fast-CGI를 맞닥드렸습니다.

JongMinLee0.github.io

- https://www.geeksforgeeks.org/difference-between-java-servlet-and-cgi/

 

Difference between Java Servlet and CGI - GeeksforGeeks

A Computer Science portal for geeks. It contains well written, well thought and well explained computer science and programming articles, quizzes and practice/competitive programming/company interview Questions.

www.geeksforgeeks.org

- https://www.javatpoint.com/difference-between-java-servlets-and-cgi

728x90

서버 로그를 그라파나에 보여지게 하려는데, 에러가 나를 몇시간 동안 괴롭혔다.

 

해당 에러를 해석 해보자면 연결은 됐지만 라벨을 받지 못했으니 Loki, Promtail 설정을 잘해봐라. 라는 뜻으로 이해했다.

 

처음에는 Docker compose 파일에 다음과 같이 Promtail 컨테이너를 세팅했다.

 

 

문제는 Promtail 설정이 담긴 promtail-config.yml 파일명이었다. 하이픈을 제거해서 promtail.yml로 변경하니 됐다.

 

 

728x90

다음과 같은 에러가 나와 고생좀 했다.

github.com/prometheus/prometheus/promql.NewActiveQueryTracker({0x3053abc, 0x5}, 0x14, {0x39f12e0, 0xc000099400})
	/app/promql/query_logger.go:121 +0x3cd
main.main()
	/app/cmd/prometheus/main.go:597 +0x6713
ts=2024-10-13T13:46:01.358Z caller=main.go:491 level=info msg="No time or size retention was set so using the default time retention" duration=15d
ts=2024-10-13T13:46:01.358Z caller=main.go:535 level=info msg="Starting Prometheus Server" mode=server version="(version=2.37.6, branch=HEAD, revision=8ade24a23af6be0f35414d6e8ce09598446c29a2)"
ts=2024-10-13T13:46:01.358Z caller=main.go:540 level=info build_context="(go=go1.19.6, user=root@5f96027a7c3e, date=20230220-09:36:40)"
ts=2024-10-13T13:46:01.358Z caller=main.go:541 level=info host_details="(Linux 6.5.0-28-generic #29~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Apr  4 14:39:20 UTC 2 x86_64 40ea8565bbf0 (none))"
ts=2024-10-13T13:46:01.358Z caller=main.go:542 level=info fd_limits="(soft=1048576, hard=1048576)"
ts=2024-10-13T13:46:01.359Z caller=main.go:543 level=info vm_limits="(soft=unlimited, hard=unlimited)"
ts=2024-10-13T13:46:01.359Z caller=query_logger.go:113 level=error component=activeQueryTracker msg="Failed to create directory for logging active queries"
ts=2024-10-13T13:46:01.359Z caller=query_logger.go:91 level=error component=activeQueryTracker msg="Error opening query log file" file=/prometheus/data/queries.active err="open data/queries.active: no such file or directory"
panic: Unable to create mmap-ed active query log

goroutine 1 [running]:
github.com/prometheus/prometheus/promql.NewActiveQueryTracker({0x3053abc, 0x5}, 0x14, {0x39f12e0, 0xc0004b9540})
	/app/promql/query_logger.go:121 +0x3cd
main.main()
	/app/cmd/prometheus/main.go:597 +0x6713

 

Prometheus가 /prometheus/data/queries.active 파일을 생성하려고 할 때 권한이 부족하다는 문제인 것 같았다. 그래서 찾다가 다음링크를 참고해서 해결했다.

 

https://github.com/prometheus/prometheus/issues/5976#issuecomment-535455952

 

err="open /prometheus/queries.active: permission denied" · Issue #5976 · prometheus/prometheus

Proposal Use case. Why is this important? centos7 + docker-ce + prometheus Bug Report no change docker default: Docker Root Dir i can docker run prometheus right when i change Docker Root Dir eg:Do...

github.com

 

 

이 설정은 호스트의 파일 시스템과 컨테이너 간 권한 문제를 해결하는 방법이다. 특히 공유된 볼륨에서 컨테이너 내부에서 파일을 작성하고 수정할 때 발생할 수 있는 충돌을 방지한다. UID 1000:1000로 실행하면 호스트의 동일한 사용자 권한을 가진 것처럼 컨테이너가 동작하게 되어, 파일 시스템에 접근할 때 권한 문제가 없어진다.

728x90

 

 다음 세 가지로 나눠서 JVM에게 메모리를 어떻게 할당되는지 살펴 볼 것이다.

 

  • 클래스 변수
  • 인스턴스 변수
  • 지역 변수

 

 

다음 코드를 예시로 그림으로 가시화 하여 실제로 메모리가 어떻게 올라가는지 살펴보겠습니다.

 

class My_Obj {
	Integer value;
	static Integer classVar;
}

public class Main {
	public static void main(String[] args) {
		My_Obj obj = new My_Obj();
		obj.value = 2;
		obj.classVar = 1;

		int a = 10;

		My_Obj obj2 = new My_Obj();
		obj2.value = 10;
		obj2.classVar = 9;
	}
}

 

 

들어가기 앞서 코드 설명을 하자면,

 

  • obj 객체를 생성한다.
  • 이 객체의 맴버 변수인 value, classVar 에 각각 2, 1를 할당한다.
  • obj 객체의 맴버 변수를 출력한다. 그 다음에, obj2 객체를 생성한다.
  • 그리고 이 객체의 맴버 변수인 value, classVar 에 각각 10, 11을 할당한다. 그 다음 obj2 객체의 맴버 변수를 출력한다.

 

그림으로 어떻게 변수들이 할당되는지 살펴보자.

 

 

크게 3가지 영역으로 나누어본다. Method, Heap, Stack 영역 으로 나뉜다. 이 중에서 클래스 변수는 클래스 파일을 읽어들이고 로더가 JVM 메모리에 올릴 때 메모리에 올라간다. 그리고 프로그램이 종료될 때 사라진다. 힙에 있는 인스턴스들은, GC의 대상이 될 때 처리가 된다. 마지막으로 지역 변수는 자신이 선언된 블록이 끝났을 때 사라진다.

 

 

처음 obj 객체의 출력 까지만 그림으로 표현해보았다. Heap 영역에는 GC가 있기 때문에 대상이 되는 인스턴스들과 Constant Pool 안에 있는 것들은 사용되지 않으면 정리가 된다. 변수에 할당된 모든 리터럴 값들은 Constant Pool 에 있고 Method 영역이나 Stack 영역에서 각 객체들은 Constant Pool 안의 해당 값의 참조 주소를 갖고 있다.

그 다음 obj2 객체가 생성이 되면 이렇게 된다.

 

classVar 클래스 변수가 갱신이 되면서 Constant Pool 내에 리터럴 값 9의 참조 주소를 참조하는 것으로 갱신된 것을 볼 수 있다.

 

그러면 My_Obj 객체의 value 필드의 타입을 참조타입에서 원시타입으로 변경 해서 살펴보자.

class My_Obj {
	int value; << 원시 타입으로 변경
}

public class Main {
	public static void main(String[] args) {
		My_Obj obj = new My_Obj();
		obj.value = 2;

		int a = 11;  << 새로 추가

		My_Obj obj2 = new My_Obj();
		obj2.value = 10;
	}
}

 

내부 메모리상으로는 다음과 같을 것이다.

 

 

여기서 알아가야 할게 Integer는 내부적으로 캐시가 된다는 점이다.

 

Integer 객체는 범위 -128에서 127 사이의 값일 경우 Constant Pool에서 캐싱된 값을 사용하겠지만, 그렇지 않다면 새로운 객체가 힙에 생성됨. 증거 자료는 다음과 같다. (Integer 래퍼 클래스 내부에 정적 프라이빗 클래스인 IntegerCachevalueOf 메소드이다.)

 

 

 

그러므로 예를 들어,

Integer i1 = 100;
Integer i2 = 100;
Integer i3 = 200;
Integer i4 = 200;

System.out.println(i1 == i2);  // true (캐싱된 객체를 재사용)
System.out.println(i3 == i4);  // false (새로운 객체가 각각 생성됨)

 

 

  • i1 == i2는 true를 반환함. 왜냐하면 100은 -128 ~ 127 사이에 있으므로 캐싱된 같은 객체를 사용하기 때문.
  • 반면, i3 == i4는 false를 반환함. 왜냐하면 200은 캐싱 범위 밖이므로 새로운 객체가 각각 생성되었기 때문.

 

 

그러면, 이점을 응용을 해보자. 다음 코드를 보자.

 

class My_Obj {
	int value;
}

public class Main {
	public static void main(String[] args) {
		My_Obj obj = new My_Obj();
		obj.value = 10;
        
        int a = 10;
        
        System.out.println(obj.value == a) // true 가 나온다.
	}
}

 

 

이런 그림이 될 것 이다.

 

결론

메모리 구조를 한번 더 정리하는 계기가 되었다.

 

 

 

👉🏻 참조

https://codingdog.tistory.com/entry/java-클래스-변수-어떻게-메모리에-올라갈까

+ Recent posts