1. 문자열과 char

  • String은 수정할 수 없음 → StringBuilder 사용
char ch = 'A';
String str = "Apple";

str.length(); // 5
str.charAt(2); // p
str.substring(1, 3); // pp: 1번부터 2번까지
str.contains("pp"); // true
str.toLoserCase() // apple

// 모든 문자 하나씩 확인
for (int i = 0; i < text.length(); i++) {
    char ch = text.charAt(i);
    System.out.println(ch);
}

// 문자열 내용 비교 vs 객체 비교
String a = new String("java");
String b = new String("java");

System.out.println(a.equals(b)); // true: 내용이 같음
System.out.println(a == b);      // false: 서로 다른 객체

int number = Integer.parseInt("123");
String text = String.valueOf(123); // toString대신 null값 처리 가능

2. BufferedReader

  • 입력을 한 줄씩 읽는 도구
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

String line = br.readLine();

// 숫자로 변환
int age = Integer.parseInt(br.readLine());

// 전체 기본 구조
import java.io.*;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader br =
                new BufferedReader(new InputStreamReader(System.in));

        int number = Integer.parseInt(br.readLine());
        System.out.println(number + 1);
    }
}

3. StringTokenizer

  • 문자열을 공백 같은 구분자를 기준으로 나누는 도구
  • br이 한 줄을 읽고, st가 그 줄을 나누는 형식
StringTokenizer st = new StringTokenizer("10 20 30");

int a = Integer.parseInt(st.nextToken()); // 10
int b = Integer.parseInt(st.nextToken()); // 20
int c = Integer.parseInt(st.nextToken()); // 30

// 입력: 10 20
StringTokenizer st = new StringTokenizer(br.readLine());

int a = Integer.parseInt(st.nextToken());
int b = Integer.parseInt(st.nextToken());

// 조각 개수 모를 때
while (st.hasMoreTokens()) {
    System.out.println(st.nextToken());
}

4. StringBuilder

  • 문자열을 수정, 연결할 때
StringBuilder sb = new StringBuilder();

sb.append("hello");
sb.append(" ");
sb.append("java");

String result = sb.toString(); // "hello java"

// 자주 쓰는 기능
StringBuilder sb = new StringBuilder("abcd");

sb.append("e");       // "abcde"
sb.setCharAt(0, 'A');  // "Abcde"
sb.deleteCharAt(1);    // "Acde"
sb.reverse();         // "edcA"
sb.length();          // 4

// 반복문으로 한 글자씩 출력
for (int i = 1; i <= 3; i++) {
    sb.append(i).append('\n');
}

5. ArrayList

  • 크기가 자동으로 늘어나는 배열 같은 자료구조
  • 일반 배열은 만들 때 크기를 정함
  • ArrayList는 원소를 추가하면서 크기가 늘어남
// 일반 배열
int[] arr = new int[3];

// ArrayList
ArrayList<Integer> list = new ArrayList<>();

list.add(10);
list.add(20);
list.add(30);

// 사용 가능한 메소드
list.get(0);      // 10: 조회
list.set(1, 99);  // [10, 99, 30]: 변경
list.remove(0);   // [99, 30]: 0번 원소 삭제
list.size();      // 2: 원소 개수
list.isEmpty();   // false
list.contains(99); // true

// 정수리스트 원소 삭제
list.remove(1);                  // 1번 인덱스 삭제
list.remove(Integer.valueOf(99)); // 값이 99인 첫 원소 삭제

6. HashMap

  • 키와 값을 짝지어 저장하는 자료구조
  • 키는 중복되지 않아서 같은 키로 다시 저장하면 값이 바뀜
HashMap<String, Integer> scores = new Hashmap<>();

scores.put("민수", 90);
scores.ut("영희", 85);

scores.get("민수"); // 90

scores.put("민수", 100);
scores.get("민수"); // 100

// 자주 쓰는 기능
scores.containsKey("민수");     // true
scores.getOrDefault("영희", 0); // 없으면 0
scores.remove("지수");          // 해당 키와 값 삭제
scores.size();                 // 저장된 쌍의 개수

// 코테에서는 등장 횟수 세기에 자주 사용
String[] fruits = {"apple", "banana", "apple"};

HashMap<String, Integer> counts = new HashMap<>();

for (String fruit : fruits) {
    counts.put(fruit, counts.getOrDefault(fruit, 0) + 1);
}

// apple → 2
// banana → 1

// 전체 순회
for (String key : counts.keySet()) {
    System.out.println(key + ": " + counts.get(key));
}

7. HashSet

  • 중복 없이 값만 저장
HashSet<Integer> set = new HashSet<>();

set.add(10);
set.add(20);
set.add(10);

System.out.println(set.size()); // 2

// 10을 2번 넣어도 1번만 저장됨
set.contains(10); // true
set.remove(20);   // 20 삭제
set.isEmpty();    // false

 

이름별 점수, 단어별 개수 HashMap
중복 제거, 이미 등장했는지 확인 HashSet

8. ArrayDeque

  • 앞 뒤 양쪽에서 원소를 넣고 뺄 수 있는 자료구조
  • 큐: BFS 같은 탐색에서 사용
  • 스택: 괄호 검사, 최근 작업 처리 등에 사용
  • peek: 확인만 함
  • poll/pop: 꺼내면서 삭제
  • 빈 상태에서 pollFirst()는 널 반환, pop은 예외 발생
// 큐로 사용하면 먼저 들어간 것이 먼저 나옴
ArrayDeque<Integer> deque = new ArrayDeque<>();

deque.offerFirst(10); // [10]
deque.offerLast(20);  // [10, 20]
deque.offerFirst(5);  // [5, 10, 20]

deque.peekFirst();   // 5: 앞 조회
deque.peekLast();    // 20: 뒤 조회
deque.pollFirst();   // 5를 꺼내고 삭제
deque.pollLast();    // 20을 꺼내고 삭제

// 스택으로 사용하면 나중에 들어간 것이 먼저 나옴
ArrayDeque<Integer> stack = new ArrayDeque<>();

stack.push(10);
stack.push(20);

stack.pop(); // 20
stack.pop(); // 10

9. PriorityQueue

  • 우선순위가 높은 원소부터 꺼냄
  • 정수는 기본적으로 작은 수부터
  • 큰 수부터로 바꾸고 싶으면 Comparator.reverseOrder()
PriorityQueue<Integer> pq = new PriorityQueue<>();

pq.offer(30);
pq.offer(10);
pq.offer(20);

pq.peek(); // 10: 확인만 함

pq.poll(); // 10
pq.poll(); // 20
pq.poll(); // 30

PriorityQueue<Integer> pq =
        new PriorityQueue<>(Comparator.reverseOrder());

10. sort

배열 Arrays.sort()
ArrayList 같은 리스트 Collections.sort()
// 배열 정렬
int[] arr = {30, 10, 20};

Arrays.sort(arr);
// arr: [10, 20, 30]

// 리스트 정렬
ArrayList<Integer> list = new ArrayList<>();

list.add(30);
list.add(10);
list.add(20);

Collections.sort(list);
// list: [10, 20, 30]

11. Comparator

  • 정렬 기준을 직접 정하는 도구
    • 오름차순/내림차순
    • 문자열 길이 순..
Collections.sort(list, Comparator.reverseOrder()); // 내림차순

// 문자열 길이 순
String[] words = {"banana", "cat", "apple"};

Arrays.sort(
    words,
    (a, b) -> Integer.compare(a.length(), b.length())
);

// ["cat", "apple", "banana"]

 

'전공 > Java' 카테고리의 다른 글

[JAVA] Maven 프로젝트 톰캣 실행 오류 정리  (0) 2026.07.20

개발하면서 정리해야지 정리해야지 하다가 이제야 정리하게 되었다..

안드로이드와 API를 연결하며 직접 오류를 확인하던 프로젝트에 자동 테스트를 추가하고, 실행 결과와 실제 코드를 참고해 API 명세를 작성하는 CLI 에이전트를 만들었다. 기존 Doody 프로젝트의 테스트와 문서화를 개선한 후속 개발 기록이다.

1. 시작은 API를 연결하며 반복하던 확인 작업이었다

Doody의 백엔드는 Spring Boot로 개발했다. 당시에는 안드로이드 화면에 기능을 하나씩 연결하면서 API가 정상적으로 동작하는지, 오류 상황에서는 어떤 응답이 오는지 직접 확인했다.

API 문서도 개발하면서 작성했다. Notion에 기능별 HTTP 메서드, 경로, 개발 상태를 정리하고 프론트엔드와 공유하는 방식이었다.

실제로 사용했던 Notion API 명세서에는 다음과 같은 API가 등록돼 있다.

기능 메서드 경로
오늘의 미션 GET /api/users/{userId}/missions/today
주변 두디 핀 조회 GET /api/collection/pins
두디 잡기 POST /api/collection/capture
자립ON 미션 완료 POST /api/users/{userId}/autonomy/missions/{missionId}/complete

 

문서에는 백엔드 API와 AI 엔진의 엔드포인트 목록도 따로 정리돼 있다. API가 늘어날수록 구현, 앱 연결 확인, 오류 확인, 명세 작성을 함께 챙겨야 했다.

코드를 다시 열어보니 AI 클라이언트와 텍스트 처리에 관한 테스트는 일부 있었지만, 포인트와 수집 보상을 담당하는 서비스에는 테스트가 없었다. 예를 들어 화면에서 포인트가 지급되는 것을 확인해도 다음 조건까지 함께 확인했다고 보기는 어려웠다.

  • 0이나 음수의 포인트는 저장되지 않는가?
  • 이미 가진 두디를 다시 저장하지 않는가?
  • 보상 금액뿐 아니라 출처와 출처 ID도 맞게 저장되는가?
  • 이미 해금한 연락처의 정보를 덮어쓰지 않는가?

이 조건을 코드로 남겨두면 같은 기능을 수정했을 때 다시 확인할 수 있다. 여기에 API 문서 작성까지 연결해보기로 했다.

2. AI에게 맡길 작업의 경계를 정했다

목표는 다음 과정을 하나의 흐름으로 연결하는 것이었다.

관련 코드와 기존 명세 읽기
           ↓
검증할 조건에 맞는 테스트 작성
           ↓
Gradle 실행 및 JUnit 결과 수집
           ↓
결과를 모델에 전달하고 다음 행동 결정
           ↓
Controller·DTO·예외 처리 확인
           ↓
검증 범위를 표시한 API 문서 작성

 

구현에는 Codex의 도움을 받았다. 모델이 선택한 도구를 로컬 실행기가 수행하고, 실행 결과를 다시 모델에 전달하도록 구성했다.

이번 실제 실행은 현재 대화 모델과 JSON 파일로 연결하는 세션 모드로 진행했다. 모델 응답을 CLI에 전달하는 과정이 있다. 별도의 모델 API를 무인으로 호출한 실행은 아직 하지 않았으며, OpenAI Responses API 어댑터는 모의 응답으로 검증했다.

초기 범위는 RewardPersistenceService의 테스트와 POST /api/collection/capture 문서 한 개로 잡았다. 전체 프로젝트의 모든 API를 한 번에 처리하는 범용 도구까지 구현한 것은 아니다.

3. 먼저 실행 가능한 테스트 환경을 확보했다

프로젝트는 Java 21을 사용하지만 기본 PATH의 Java는 17이었다. 설치돼 있던 JDK 21을 지정해 빌드를 실행했다.

$env:JAVA_HOME = 'C:\Program Files\Java\jdk-21.0.10'
.\gradlew.bat clean build --no-daemon --console=plain

 

기존 테스트 11개 중 10개가 통과했고, 전체 애플리케이션을 초기화하는 테스트 1개가 실패했다. 원인은 DB 설정값이 없는 상태에서 PostgreSQL에 접속하려 했기 때문이었다.

테스트용 H2를 추가하고, 컨텍스트 테스트에서 별도의 프로필을 사용하도록 했다.

@SpringBootTest(properties = "AI_ENGINE_BASE_URL=")
@ActiveProfiles("test")
class ApplicationTests {
    @Test
    void contextLoads() {
    }
}
spring:
  datasource:
    url: 
    driver-class-name: org.h2.Driver
    username: sa
    password: ""
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        hbm2ddl:
          halt_on_error: true

 

위 YAML은 DB 설정 부분을 발췌한 것이다. 실제 테스트 설정에는 AWS 리전과 빈 버킷 설정도 포함했다. AI 주소는 테스트 속성으로 비워 외부 서버 warmup 요청을 막았다.

이 설정으로 기존 테스트 11개를 모두 통과시켰다. H2에서 초기화됐다는 사실과 실제 PostgreSQL의 동작 검증은 구분했다.

4. 모델에 코드를 읽고 실행할 도구를 제공했다

실행기는 Node.js 표준 라이브러리로 작성했다. 모델이 선택할 수 있는 행동을 정해두고, 하나의 행동을 실행한 결과를 다음 입력에 포함했다.

도구 역할
list_files 관련 Java 파일 찾기
read_file 서비스·엔티티·DTO·명세 읽기
write_test 지정된 테스트 파일 작성
run_tests Gradle 실행 후 결과 수집
write_api_doc 테스트 실행과 소스 확인 후 API 문서 작성
finish 결과와 한계를 정리하고 종료

모델이 반환하는 행동의 형식은 다음과 같다.

{
  "tool": "read_file",
  "reason": "보상 서비스의 실제 분기와 저장 경로를 확인한다",
  "path": "src/main/java/doody/spring/common/service/RewardPersistenceService.java",
  "content": "",
  "query": "",
  "summary": ""
}

 

API 자동 모드의 루프를 발췌하면 다음과 같다. 세션 모드도 같은 실행 함수를 사용하며, 모델 응답을 파일로 전달한다.

const next = await requestAction(state, {
  key: process.env.OPENAI_API_KEY
});

state.input.push(...next.output);

const result = await step(state, next.action, next.call);

 

step()에서는 선택한 도구를 실행하고 결과를 모델 입력에 추가한다.

result = await executeAction(root, runDir, state, action);

state.input.push({
  type: 'function_call_output',
  call_id: call.call_id,
  output: JSON.stringify(result)
});

 

함수 호출 형식은 OpenAI 공식 문서를 참고했다.

쓰기 대상은 지정된 테스트 파일과 API 문서로 제한했다. 서비스 구현을 자동으로 수정하는 도구는 제공하지 않았다. 생성·수정은 최대 3회, 테스트 실행은 최대 3회, 전체 도구 행동은 최대 20회로 제한했다.

다만 이 경로 제한이 Java 실행 샌드박스를 제공하는 것은 아니다. 생성된 테스트와 Gradle은 로컬 코드 권한으로 실행되므로, 본인 저장소에서 검토하며 사용하는 구조다.

5. 저장된 값까지 확인하는 테스트를 만들었다

먼저 서비스에서 확인한 규칙을 R01~R08로 정리했다. 제품 정책이 불명확한 항목은 별도로 남겼다. 현재 코드가 그렇게 동작한다는 사실만으로 요구사항까지 확정하지 않기 위해서다.

모델은 서비스와 실제 엔티티·Repository를 읽은 뒤 테스트를 생성했다. Repository는 Mockito 대역으로 바꿔 실제 DB 없이 서비스의 분기와 저장 요청을 확인했다.

예를 들어 포인트 금액 검증은 다음과 같다. 아래 코드는 생성한 테스트에서 발췌했다.

@ParameterizedTest
@NullSource
@ValueSource(ints = {0, -1, -100})
void R01_rejectsNullZeroAndNegativeAmounts(Integer amount) {
    service.earnPoint(user, amount, "mission", "MISSION", 17L);
    verifyNoInteractions(points);
}

 

양수일 때는 저장 호출뿐 아니라 저장 객체의 값도 검증했다.

service.earnPoint(user, 30, "completed mission", "MISSION", 17L);

var captor = ArgumentCaptor.forClass(PointTransaction.class);
verify(points).save(captor.capture());

var saved = captor.getValue();
assertThat(saved.getUser()).isSameAs(user);
assertThat(saved.getAmount()).isEqualTo(30);
assertThat(saved.getTransactionType()).isEqualTo("EARN");
assertThat(saved.getReason()).isEqualTo("completed mission");
assertThat(saved.getSourceType()).isEqualTo("MISSION");
assertThat(saved.getSourceId()).isEqualTo(17L);

 

포인트가 지급됐더라도 출처 ID를 빠뜨리면 이후 적립 내역 추적에 문제가 생길 수 있다. 그래서 주요 필드를 함께 확인했다.

생성한 보상 서비스 테스트는 매개변수별 실행을 포함해 20개였다. 첫 생성본도 모두 통과했다. 이번 실행에서 실패한 테스트를 모델이 자동으로 고쳤다는 성과는 없다.

6. 실행 결과를 구조화해 모델에 돌려줬다

테스트 실행은 고정된 Gradle 명령과 클래스 필터로 수행했다. 아래는 실행 코드의 일부다.

const args = [
  '-classpath',
  path.join(root, 'gradle/wrapper/gradle-wrapper.jar'),
  'org.gradle.wrapper.GradleWrapperMain',
  'test',
  '--no-daemon',
  '--console=plain',
  '--rerun-tasks'
];

if (!all) args.push('--tests', TEST_CLASS);
if (!all && includeApi) args.push('--tests', API_TEST_CLASS);

 

표준 출력과 종료 코드, JUnit XML을 함께 읽어 결과를 분류했다.

export function classifyRun(exitCode, output, suites, timedOut = false) {
  if (timedOut) return 'timeout';
  if (/Execution failed for task ':compile(?:Test)?Java'|Compilation failed|error: cannot find symbol/.test(output)) return 'compilation';
  if (suites.some(s => s.failures || s.errors)) return 'test_failure';
  if (exitCode === 0 && suites.reduce((n, s) => n + s.tests - (s.skipped || 0), 0) > 0) return 'passed';
  if (exitCode === 0) return 'no_tests';
  return 'environment_or_build';
}

 

컴파일 실패와 테스트 단언 실패는 다음에 확인해야 할 내용이 다르다. 실행된 테스트가 없거나 모두 건너뛴 경우도 정상 검증으로 집계하지 않았다. 이전 실행 XML이 다음 실행의 결과로 섞이지 않도록 실행 전에 기존 XML을 제거했다.

7. 오류를 직접 넣어 테스트의 탐지 능력을 확인했다

정상 코드에서 통과하는 것만으로 테스트의 품질을 판단하기 어려웠다. 그래서 별도 코드 복사본에 오류를 하나씩 넣고 같은 테스트를 실행했다.

주입한 오류 결과
0·음수 포인트 거절 조건 제거 테스트 3개 실패
중복 수집 방지 조건 제거 테스트 1개 실패
기본 등급을 잘못된 상수로 변경 테스트 1개 실패
포인트 출처 ID 누락 테스트 1개 실패
연락처 축을 잘못된 값으로 변경 테스트 1개 실패

 

예를 들어 실제 코드의 다음 조건을 복사본에서 amount == null로 바꿨다.

if (amount == null || amount <= 0) {
    return;
}

 

그 결과 0, -1, -100을 입력하는 테스트가 실패했다. 정상 코드 복사본에서는 20개가 모두 통과했다.

평가를 준비하며 중복 수집 테스트도 개선했다. 저장 대역이 기본값인 null을 반환하면, 조건을 제거했을 때 우연한 NullPointerException으로 실패할 수 있었다. 그래서 잘못된 저장이 발생하더라도 대역은 엔티티를 반환하도록 설정했다.

lenient().when(collections.save(any(DoodyCollection.class)))
    .thenAnswer(call -> call.getArgument(0));

 

이제 중복을 허용했을 때 빈 결과를 기대한 단언이 실패한다. 테스트 대역의 우연한 예외보다, 확인하려는 규칙이 깨졌다는 근거가 명확해졌다.

결과 집계에서도 문제가 있었다. XML의 self-closing 성공 testcase와 다음 실패 testcase를 묶어 실패 이름을 잘못 표시하는 경우가 있어 파서를 수정했다. Mockito의 NoInteractionsWanted를 검증 실패로 집계하도록 보완한 뒤, 이미 실행한 XML을 다시 읽어 결과를 확정했다.

선택한 오류 5종은 모두 탐지됐다. 이 결과는 실제 운영 버그 5개를 발견했다는 의미도, 일반적인 오류 탐지율이 100%라는 의미도 아니다.

8. API 문서에 사용할 HTTP 계약 테스트를 추가했다

보상 서비스 테스트만으로 API 요청·응답 형식을 확인했다고 쓰기는 어렵다. 그래서 두디 잡기 API에 MockMvc 테스트 4개를 추가했다.

이 테스트에서는 실제 Controller와 공통 예외 처리기를 사용하고, CollectionCaptureService는 대역으로 바꿨다.

mvc = MockMvcBuilders
    .standaloneSetup(new CollectionCaptureController(service))
    .setControllerAdvice(new GlobalExceptionHandler())
    .build();

 

성공 응답 테스트의 일부는 다음과 같다.

mvc.perform(post("/api/collection/capture")
        .contentType(MediaType.APPLICATION_JSON)
        .content(BODY))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$.status").value(200))
    .andExpect(jsonPath("$.isSuccess").value(true))
    .andExpect(jsonPath("$.result.reward.hanaMoney").value(30))
    .andExpect(jsonPath("$.result.dudy.id").value("d01"));

 

요청 필드의 역직렬화, 성공 응답의 주요 필드, 서비스가 던진 예외의 400·404 매핑, 내부 예외의 500 응답을 확인했다.

위 테스트의 30포인트는 대역이 반환한 예시 값이다. 실제로 모든 수집 요청에서 30포인트를 지급한다는 뜻은 아니다. 거리 제한과 실제 저장 흐름 역시 이 HTTP 테스트에서 통합 검증하지 않았다.

9. 테스트 결과와 실제 소스를 참고해 API 문서를 생성했다

문서 생성에는 테스트 결과와 함께 다음 소스를 읽도록 했다.

  • Controller: HTTP 메서드, 경로, 요청 전달 방식.
  • 요청·응답 DTO: 필드 이름과 타입.
  • 서비스: 입력 확인과 오류 발생 조건.
  • 공통 응답·예외 처리: 응답 구조와 메시지.

실행기는 테스트가 통과했는지, 관련 소스를 읽었는지 확인한 뒤 문서를 쓰도록 했다. 실제 구현을 발췌하면 다음과 같다.

if (action.tool === 'write_api_doc') {
  if (state.lastRun?.classification !== 'passed') {
    throw new Error('API documentation requires a passing test execution');
  }
  if (API_SOURCES.some(file => !state.readFiles.includes(file))) {
    throw new Error('Read controller, service, DTOs, response envelope and exception handler first');
  }
  // 이후 필수 섹션과 출력 경로를 확인하고 문서를 저장한다.
}

 

필수 섹션은 요청, 응답, 오류, 검증 범위, 미검증 범위로 정했다. 문서 끝에는 실행 테스트 수, 실패 수, 테스트 소스 해시, 실행 종료 시각을 첨부했다.

이 조건은 문서를 작성할 근거와 형식을 확인하는 장치다. 모든 설명의 정확성을 자동으로 보장하는 것은 아니므로, 생성 결과를 검토하는 단계는 남는다.

생성한 문서의 요청 예시는 다음과 같다.

POST /api/collection/capture
Content-Type: application/json
{
  "userId": "user-1",
  "pinId": "pin-1",
  "lat": 37.5,
  "lng": 127.0
}

 

오류 설명도 근거를 함께 표시했다.

HTTP 메시지 예시 확인한 범위
400 pin is too far to capture. 서비스의 400 예외를 HTTP 400으로 매핑
404 user not found. 서비스의 404 예외를 HTTP 404로 매핑
500 internal server error. 내부 예외 세부 정보를 노출하지 않는 응답

 

100m를 초과하면 거절하는 규칙은 서비스 소스에서 확인했다. 이번에 추가한 HTTP 테스트가 실제 거리 계산까지 확인한 것은 아니므로 문서에서도 이를 구분했다.

현재 자동 생성한 명세는 Markdown 파일 한 개다. 기존 Notion 전체 명세를 자동으로 갱신하거나 동기화하는 기능까지 구현하지는 않았다.

10. 결과: 반복 가능한 검증과 문서 작성 흐름을 확보했다

항목 결과
보상 서비스 테스트 20개 통과
새 HTTP 계약 테스트 4개 통과
기존 테스트 포함 전체 Java 테스트 35개 통과
실행기·API 어댑터 테스트 7개 통과, API는 모의 응답
선택한 결함 주입 평가 오류 5종 모두 탐지
생성한 API 문서 두디 잡기 API 1개

 

문서 생성 직전 실행한 보상·HTTP 테스트 24개는 실행기 기록 기준 약 15.4초에 완료됐다. 이 값은 해당 테스트 실행 단계의 소요 시간이며, 모델의 코드 분석·테스트 작성·검토·문서 작성 시간을 모두 합한 값은 아니다.

기존에는 앱에 API를 연결하며 오류를 확인하고, 명세도 개발자가 작성했다. 이번에는 보상 규칙을 반복 실행할 수 있는 테스트로 남겼고, 코드 탐색부터 테스트 실행·결과 취합·문서 초안 작성까지 연결했다. 이후 수정 작업에서도 같은 테스트를 다시 실행할 기반을 확보했다.

시간 단축률을 숫자로 쓰지 않은 이유

당시 수동 테스트와 문서 작성에 걸린 시간을 기록하지 않아, 이번 결과와 같은 조건으로 비교할 수 없었다. 따라서 “테스트·문서화 시간을 60% 단축했다”거나 “전체 개발 시간이 절반으로 줄었다”는 수치는 쓰지 않았다.

다음에는 같은 변경 과제를 대상으로 다음 시간을 각각 기록하려 한다.

비교 항목 수동 작업 AI 활용 작업
테스트 코드 조사 + 작성 + 실행 + 수정 모델 처리 + 실행 + 사람 검토 + 수정
문서 명세 조사 + 작성 + 검토 초안 생성 + 사람 검토 + 수정

계산식은 다음과 같다.

단축률(%) = (수동 작업 시간 - AI 활용 후 시간) / 수동 작업 시간 × 100

 

테스트 개수와 문서 항목, 정확성 기준도 같게 맞춰야 한다. 모델 응답 시간만 수동 작업 전체 시간과 비교하면 검토·수정 시간을 누락하게 된다. 같은 과제를 반복하며 생기는 학습 효과도 구분할 필요가 있다.

현재 확인한 성과는 자동화 범위와 테스트 실행 결과다. 전체 개발 시간 절감률은 후속 측정 과제로 남겼다.

11. 돌아보며

처음에는 AI가 테스트 코드를 작성해주는 기능을 생각했다. 구현하면서 테스트를 실행하는 것, 실패 원인을 읽는 것, 결과를 정확하게 집계하는 것, 문서에 검증 범위를 표시하는 것까지 함께 다뤄야 한다는 점을 알게 됐다.

이번에는 테스트 대역의 null 반환과 XML 파서 문제처럼, 자동화 도구 자체에도 검증이 필요하다는 것을 확인했다. 테스트와 문서의 초안을 빠르게 만들 수 있도록 역할을 나누면서도, 무엇을 근거로 확인했다고 말할 수 있는지 기록하는 데 집중했다.

앞으로는 실제 모델 API를 연결한 자동 실행을 검증하고, PostgreSQL 연동 테스트와 동일 과제의 작업 시간 비교를 추가하려 한다. 이를 바탕으로 정확성과 작업 비용을 함께 평가할 계획이다.

Spring Boot 백엔드 프로젝트를 만들면서 PostgreSQL, Redis, AWS RDS, EC2, S3까지 연결하는 과정을 정리했다.

1. Spring Initializr 설정

`https://start.spring.io/`에서 프로젝트를 생성했다.

추천 설정은 다음과 같다.

- Project: Gradle - Groovy
- Language: Java
- Spring Boot: Stable 버전
- Packaging: Jar
- Java: 21

 

추가한 Dependencies는 다음과 같다.

- Spring Web
- Spring Data JPA
- PostgreSQL Driver
- Spring Data Redis
- Lombok
- Validation
- DevTools

 

일반적인 Spring Boot API 서버는 `War`보다 `Jar`가 편하다.

2. IntelliJ에 프로젝트 적용

다운로드한 zip 파일의 압축을 풀고 IntelliJ에서 열었다.

File → Open → build.gradle이 있는 프로젝트 폴더 선택

프로젝트를 열면 Gradle 의존성이 자동으로 다운로드된다.

3. application.yml 구성

기본 설정 파일은 다음 위치에 만든다.

src/main/resources/application.yml

 

yaml파일에 프로필을 나누기 위해 다음처럼 설정했다.

spring:
profiles:
active: local

 

그리고 환경별로 파일을 분리했다.

application-local.yml
application-prod.yml

`application-prod.yml`은 AWS 배포를 고려해서 환경변수 기반으로 작성했다.

spring:
datasource:
url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}?sslmode=require
username: ${DB_USERNAME}
password: ${DB_PASSWORD}

jpa:
hibernate:
ddl-auto: update
show-sql: false

server:
port: 8080

cloud:
aws:
region:
static: ${AWS_REGION}
s3:
bucket: ${S3_BUCKET}

 

비밀번호나 AWS 정보는 코드에 직접 넣지 않고 환경변수로 관리하는 것이 좋다.

4. AWS 구성 순서

AWS는 다음 순서로 설정했다.

  1. VPC 생성
  2. Security Group 생성
  3. RDS PostgreSQL 생성
  4. S3 Bucket 생성
  5. EC2 생성
  6. EC2에서 RDS 연결 테스트

기본 구조는 다음과 같다.

사용자 → EC2 → RDS PostgreSQL
사용자 → EC2 → S3

RDS는 외부에 직접 공개하지 않고 EC2에서만 접근하도록 구성했다.

5. VPC와 Security Group

EC2와 RDS는 같은 VPC를 사용해야 한다.

EC2 Security Group: doody-app-ec2-sg
RDS Security Group: doody-app-rds-sg

EC2 Security Group inbound 예시:

SSH 22 내 IP
HTTP 80 0.0.0.0/0
HTTPS 443 0.0.0.0/0
8080 테스트 중에만 허용

RDS Security Group inbound:

PostgreSQL 5432
Source: doody-app-ec2-sg

RDS에 `0.0.0.0/0`를 열지 않는 것이 중요하다.

6. RDS PostgreSQL 생성

RDS에서는 PostgreSQL을 선택했다.

주의할 점은 Aurora가 아니라 일반 PostgreSQL을 선택하는 것이다.

추천 설정:

Engine: PostgreSQL
Template: Free tier
DB instance class: db.t4g.micro
Storage: 20GiB
Public access: No
VPC: 직접 만든 VPC
Security Group: doody-app-rds-sg

처음에 스토리지를 200GiB로 잡으면 비용이 커질 수 있으므로 개발용은 20GiB면 충분하다.

RDS의 `DB 식별자`와 실제 `DB 이름`은 다르다.

DB 식별자: doody-app-db
실제 DB 이름: doodyappdb

접속 테스트는 EC2에서 진행했다.

psql "host=$DB_HOST port=5432 dbname=postgres user=doodyuser sslmode=require"

 

접속 후 앱용 DB를 생성했다.

7. S3 Bucket 생성

S3는 일반적인 파일 업로드 용도로 사용할 것이므로 General purpose bucket을 선택했다.

추천 설정:

Bucket type: General purpose
Region: ap-northeast-2
Block all public access: On
Object Ownership: ACLs disabled
Encryption: SSE-S3

Spring Boot에서 S3를 사용할 때는 Access Key를 코드에 넣기보다 EC2 IAM Role을 사용하는 것이 좋다.

8. EC2 생성과 SSH 접속

EC2는 public subnet에 생성하고 public IP를 받아야 SSH 접속이 가능하다.

접속 명령어는 WSL 기준으로 다음과 같다.

ssh -i ~/.ssh/{키} ubuntu@EC2_PUBLIC_IP



Windows Downloads에 있는 pem 파일은 권한 문제가 날 수 있어서 WSL 홈으로 복사했다.

그 후 접속에 성공했다.

9. EC2 환경변수 설정

EC2에는 환경변수를 설정했다.

 

임시 설정은 SSH 세션이 끝나면 사라지므로 `~/.bashrc`에 추가하면 유지된다.

nano ~/.bashrc
source ~/.bashrc

10. 로컬 JDK 21 설치

Spring Boot 프로젝트는 Java 21 기준으로 생성했기 때문에 로컬에도 JDK 21이 필요했다.

Windows에서는 winget으로 설치했다.

 

설치 후 IntelliJ에서 다음 설정을 맞춘다.

File → Project Structure → Project SDK → JDK 21
Settings → Build Tools → Gradle → Gradle JVM → JDK 21

11. ERD 기반 Spring JPA 코드 작성

DB 설계 이미지를 기준으로 JPA Entity와 Repository를 생성했다.

 

Repository도 각 엔티티별로 만들었다.

 

복합키가 필요한 테이블은 `@EmbeddedId`를 사용했다.

JSON 컬럼은 PostgreSQL 기준으로 `jsonb`로 매핑했다.

 

빌드는 JDK 21 설정 후 정상적으로 통과했다.

마무리

이번 설정에서 가장 중요했던 점은 다음 세 가지였다.

  1. RDS는 Public access를 No로 설정한다.
  2. RDS Security Group은 EC2 Security Group만 허용한다.
  3. DB 비밀번호와 AWS 정보는 코드에 직접 넣지 않고 환경변수로 관리한다.

 

이클립스(Eclipse) 환경에서 내장 Apache Tomcat 9.0을 연동하여 자바 표준 웹소켓(JSR-356) 및 org.json 라이브러리 기반의 전자봉투 암호화 서비스를 구축하는 과정에서 발생한 5가지 핵심 빌드/런타임 에러와 그 해결법에 대한 정리입니다.

1. Tomcat 7 v.s. Java 최신 바이트코드 충돌 에러

[현상]

Maven Plugin(tomcat7-maven-plugin:run)으로 가동 시 콘솔에 아래와 같은 치명적 예외가 출력됨.

SEVERE: Unable to process Jar entry [META-INF/versions/9/module-info.class] from Jar [...]
org.apache.tomcat.util.bcel.classfile.ClassFormatException: Invalid byte tag in constant pool: 19

[원인]

Tomcat 7 버전에 내장된 오래된 바이트코드 컴파일러 스캔 모듈(BCEL)이, 최신 Java 규격(Java 9 이상 Multi-Release JAR) 및 최신 라이브러리(json-20240303.jar) 내부에 포함된 module-info.class 구조를 해석하지 못해 컴파일 형식을 오인한 것입니다.

[해결법]

외부 플러그인 구동 방식을 배제하고, Java 최신 스펙을 정상 지원하는 Tomcat 8.5 또는 Tomcat 9.0 메인 엔진을 이클립스 내장 서버 제어(WTP) 환경에 직접 연동하는 표준 방식으로 전환해야 합니다.

2. Project Facets 메뉴 누락 및 Java EE 버전 불일치

[현상]

이클립스 프로젝트 속성(Properties) 창에서 웹 프로젝트 설정을 위한 Project Facets 메뉴가 보이지 않거나, 설정 시 Tomcat version 9.0 only supports J2EE 1.2, 1.3, 1.4, and Java EE 5, 6, 7, and 8 Web modules 제약 메시지가 발생함.

[원인]

  • 메이븐 기본 빌드 포맷이 일반 자바 애플리케이션(jar)으로 지정되어 있어 이클립스가 구조를 오인함.
  • Dynamic Web Module 규격을 Tomcat 9.0의 최대 수용 사양인 Java EE 8 (Servlet 4.0)을 초과하여 Jakarta EE 규격(5.0 이상)으로 선택했을 때 발생하는 정적 버전 충돌입니다.

[해결법]

  1. pom.xml 최상단에 웹 아카이브 빌드 지시어를 명시합니다.
  2.  
    <packaging>war</packaging>
    
  3. 프로젝트 우클릭 -> Properties -> Project Facets 메뉴에서 Dynamic Web Module 버전을 4.0으로 하향 조정하고, Java 컴파일러 버전을 로컬 JDK 사양(예: 11 또는 17)과 일치시킵니다.

3. Bootstrap 클래스 로드 실패 예외 (ClassNotFoundException)

[현상]

톰캣 서버 실행 시 기동 시점에 메인 스레드가 다운됨.

Exception in thread "main" java.lang.ClassNotFoundException: org.apache.catalina.startup.Bootstrap

[원인]

이클립스 서버 런타임 설정이 꼬이면서 톰캣 가동의 최우선 진입점 파일인 bin/bootstrap.jar 경로 인덱스를 Java 가상 머신(JVM)의 Classpath 인자가 잃어버렸기 때문입니다.

[해결법]

  1. Run -> Run Configurations... ->  Apache Tomcat 트리 내부의 인스턴스를 선택합니다.
  2. Classpath 탭으로 이동하여 User Entries를 마우스로 선택합니다.
  3. Add External JARs... 버튼을 눌러 실제 Tomcat 설치 폴더의 bin/ 디렉토리 내부에서 아래 3가지 핵심 Core 파일을 수동으로 찾아 등록합니다.
    • bootstrap.jar
    • tomcat-juli.jar
    • commons-daemon.jar

4. Catalina 클래스 로드 예외 및 이클립스 내부 경로 꼬임

[현상]

Bootstrap은 찾았으나 연이어 엔진 초기화 실패 예외가 발생함.

경고: Problem with directory [.../eclipse/lib], exists: [false]
심각: Init exception
java.lang.ClassNotFoundException: org.apache.catalina.startup.Catalina

[원인]

톰캣 컨테이너가 구동될 때 자사 엔진 파일들을 참조할 마스터 홈 경로 파라미터인 catalina.home과 catalina.base가 실제 톰캣 설치 폴더가 아닌 이클립스 프로그램 실행 폴더로 잘못 오인되어 라이브러리 스캔 범위가 이탈한 것입니다.

[해결법]

  1. Run Configurations... 설정 창의 Arguments 탭으로 이동합니다.
  2. VM arguments 입력란 맨 뒤에 한 칸을 띄우고, 실제 Tomcat 물리 경로 파라미터를 다음과 같이 강제 주입합니다.
  3. Plaintext
     
    -Dcatalina.home="D:/apache-tomcat-9.0.100" -Dcatalina.base="D:/apache-tomcat-9.0.100"
    
  4. 하단의 Working directory를 Default에서 Other로 체크 변경한 뒤, 실제 Tomcat 최상위 설치 폴더 경로를 명시해 줍니다.

5. 웹소켓 처리 시 JSONObject 클래스 미발견 예외 (NoClassDefFoundError)

[현상]

화면(HTTP) 진입은 성공하고 웹소켓 커넥션(ws://...) 핸드셰이크 요청까지 도달했으나, 패킷이 인입되는 순간 서버 채널이 강제로 끊어지며 자바 콘솔에 아래 에러가 출력됨.

[웹소켓 에러 발생] 사유: java.lang.NoClassDefFoundError: org/json/JSONObject
[시스템 정보] 클라이언트 세션 커넥션 닫힘

[원인]

pom.xml 파일에 org.json 의존성이 올바르게 정의되어 컴파일은 성공했으나, 이클립스 WTP 내부의 배포 어셈블리(Deployment Assembly) 명세서에 메이븐 의존성 항목이 누락된 상태입니다. 이로 인해 톰캣 가동용 런타임 가상 디렉토리인 WEB-INF/lib/ 폴더 내부로 json-20240303.jar 파일이 물리적으로 복사되지 않아 발생한 자바 전형적인 런타임 예외입니다.

[해결법]

  1. 프로젝트 우클릭 ->  Properties로 진입합니다.
  2. 왼쪽 메뉴에서 Deployment Assembly 탭을 선택합니다.
  3. 우측 메뉴 바에서 Add... 버튼을 클릭합니다.
  4. Java Build Path Entries를 선택하고 Next를 누른 뒤, 목록에서 Maven Dependencies를 선택하고 Finish를 누릅니다.
  5. 소스 목록에 Maven Dependencies가 추가되고 이의 배포 경로(Deploy Path)가 WEB-INF/lib로 안전하게 바인딩된 것을 확인한 뒤 서버를 리스타트합니다.

'전공 > Java' 카테고리의 다른 글

[JAVA] 기본 문법 정리  (0) 2026.10.22

구글 콘솔에서 정보 설정

https://console.cloud.google.com/welcome?hl=ko&inv=1&invt=Abo6BQ&project=deep-contact-450106-u3

먼저 구글 콘솔에서 앱에 관한 설정을 진행합니다

 

새 프로젝트를 설정한 후 프로젝트 이름을 적습니다

 

그 후 API 및 서비스로 들어가서 OAuth 동의 화면을 만듭니다

 

그 다음 사용자 인증 정보에 들어갑니다

사용자 인증 정보를 만듭니다

 

사용자 인증 정보에서 OAuth 클라이언트 ID를 선택합니다

 

총 3개를 만들겁니다

debug용 android, release용 android, 웹 어플리케이션입니다

먼저 debug용 android 입니다

어플리케이션 유형에서 android를 선택한 후 필요한 정보를 입력합니다

이름은 알아보기 쉽게 android debug로 하겠습니다

 

다음은 release용 android입니다

위와 마찬가지로 필요한 정보를 입력하는데 여기서는 기존에 안드로이드 gradle에서 signingreport를 통해 얻은 SHA-1키를 사용할 수 없습니다

방법은 링크를 통해 확인하시면 되겠습니다

https://blog.naver.com/jungeun85623/220607717078

여기서 apk 파일까지 만든 후

build.gradle 파일에 다음과 같이 적고 synk를 해줍니다

android {
	signingConfigs {
        create("release") {
            storeFile = file("C:/DATA/LoginTest/keystore.jks") // 예시
            storePassword = "aaaa1111" // 예시
            keyAlias = "key1" // 예시
            keyPassword = "aaaa1111" // 예시
        }
    }
    
    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            signingConfig = signingConfigs.getByName("release") // 추가
        }
    }
    ...
}

 

그 후 다시 signingreport를 하시면 release용 SHA-1키도 얻을 수 있습니다

이렇게 release용 SHA-1키를 가지고 debug용 android를 만들었던 것처럼 동일하게 진행합니다

이름은 알아보기 쉽게 android release로 하겠습니다

 

마지막으로 웹어플리케이션입니다

어플리케이션 유형을 웹어플리케이션으로 선택한 후 추가합니다

그럼 다음과 같습니다

 

파이어베이스에서 정보 설정

다음은 파이어베이스에서 로그인 정보를 설정합니다

https://console.firebase.google.com/u/0/?hl=ko

 

로그인 - Google 계정

이메일 또는 휴대전화

accounts.google.com

 

새 프로젝트를 생성합니다

어플 이름을 입력 후 프로젝트 설정에 들어갑니다

 

그 후 앱을 추가해줍니다

 

그 후 필요한 작업을 해줍니다

다음으로 debug용 SHA-1키와 release용 SHA-1키를 추가합니다

 

그 후 google-service.json을 안드로이드 프로젝트 app/에 넣어줍니다

build.gradle 파일 설정

// module
dependencies {
		...
    // google login
    implementation(libs.play.services.auth)
    implementation(platform(libs.firebase.bom))
    implementation(libs.firebase.auth.ktx)
    implementation(libs.firebase.analytics)
}
// project
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    id("com.google.gms.google-services") version "4.4.2" apply false
}

 

코드 구현

<resources>
    <string name="app_name">LoginTest</string>
    <string name="google_client_id">...</string>
</resources>

여기서 중요한 점은 google_client_id를 아까 구글 콘솔에서 설정한 웹어플리케이션의 id를 가져와야한다는 점입니다

절대 android키로 가져오면 안됩니다

 

 

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding

    private lateinit var mGoogleSigninClient: GoogleSignInClient // 선언 추가

    private val googleLoginResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
        val resultCode = result.resultCode
        val data = result.data

        try {
            val completedTask = GoogleSignIn.getSignedInAccountFromIntent(data)
            val account = completedTask.getResult(ApiException::class.java)
            onLoginCompleted("${account?.id}", "${account?.idToken}")
        } catch (e: ApiException) {
            onError(Error(e))
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // google login
        binding.signInButton.setOnClickListener {
            signInWithGoogle()
        }
    }

    private fun signInWithGoogle() {
        val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestIdToken(getString(R.string.google_client_id)) // 요청할 때마다 받아오기
            .requestEmail()
            .build()

        mGoogleSigninClient = GoogleSignIn.getClient(this, gso)
        googleLoginResult.launch(mGoogleSigninClient.signInIntent)
    }

    private fun onLoginCompleted(userId: String?, accessToken: String?){
        Toast.makeText(this, "구글 로그인 성공", Toast.LENGTH_SHORT).show()
        Log.e("YMC", "userId: $userId / accessToken: $accessToken")
    }
    private fun onError(error : Error?){
        Toast.makeText(this, "구글 로그인 실패", Toast.LENGTH_SHORT).show()
        Log.e("YMC", "구글 로그인 실패 onError / error: ${error} / error.msg: ${error?.message}")
    }
}

결과

# https://developers.naver.com/docs/login/android/android.md 접속

 

# 오픈 API 이용 신청

어플리케이션과 로그인 시 사용할 정보를 선택한다.

 

어플을 다운로드 할 URL과 안드로이드 앱 패키지 이름을 작성한다.

URL은 마땅히 없으면 네이버 URL을 적어도 된다.

 

등록하기를 누르면 Client ID와 Client Secret이 나온다.

이 정보를 프로젝트의 res/string.xml에 적어두도록 하자.

<resources>
    <string name="app_name">LoginTest</string>
    <string name="naver_client_id">client id 작성</string>
    <string name="naver_client_secret">client secret 작성</string>
    <string name="naver_client_name">client name 작성</string>
</resources>

 

# 개발 환경 설정

네이버 로그인 API를 사용하기 위해 다음과 같이 gradle파일을 설정한다.

dependencies {
	...
    
    // naver login
    implementation(libs.oauth.jdk8) // jdk 8
    
    // naver login dependency
    implementation (libs.kotlin.stdlib)
    implementation (libs.kotlinx.coroutines.android)
    implementation (libs.androidx.appcompat.v131)
    implementation (libs.androidx.legacy.support.core.utils)
    implementation (libs.androidx.browser)
    implementation (libs.androidx.legacy.support.v4)
    implementation (libs.androidx.constraintlayout.v113)
    implementation (libs.androidx.security.crypto)
    implementation (libs.androidx.core.ktx.v130)
    implementation (libs.retrofit)
    implementation (libs.converter.gson)
    implementation (libs.moshi.kotlin)
    implementation (libs.logging.interceptor)
    implementation (libs.lottie)
}

 

 

activity_main.xml에 네이버 로그인 버튼을 만든다.

이때 네이버 로그인 버튼은 네이버에서 정한 디자인을 따라야 한다.

com.navercorp.nid.oauth.view.NidOAuthLoginButton

이 버튼을 사용하거나, 혹은 다른 디자인을 원한다면 아래의 디자인 가이드를 참고하면 되겠다.

https://developers.naver.com/docs/login/bi/bi.md

 

로그인 버튼 사용 가이드 - LOGIN

네이버 로그인은 애플리케이션에 사용할 수 있는 네이버 로그인 버튼 기본 이미지를 제공합니다. 애플리케이션의 상황에 맞게 버튼 이미지의 디자인을 변경할 수 있지만 네이버 고유의 아이덴

developers.naver.com

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.navercorp.nid.oauth.view.NidOAuthLoginButton
        android:id="@+id/buttonOAuthLoginImg"
        android:layout_width="wrap_content"
        android:layout_height="50dp"
        android:layout_marginTop="16dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="TouchTargetSizeCheck,SpeakableTextPresentCheck" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

이후 다음과 같은 코드를 작성하면 네이버 로그인 구현을 완성할 수 있다.

package com.example.logintest

import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.example.logintest.databinding.ActivityMainBinding
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.navercorp.nid.NaverIdLoginSDK
import com.navercorp.nid.oauth.OAuthLoginCallback

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // naver login
        NaverIdLoginSDK.initialize(this, getString(R.string.naver_client_id), getString(R.string.naver_client_secret), getString(R.string.naver_client_name))

        binding.buttonOAuthLoginImg.setOAuthLogin(object : OAuthLoginCallback {
            override fun onSuccess() {
                Log.d("test", "AccessToken : " + NaverIdLoginSDK.getAccessToken())
                Log.d("test", "client id : " + getString(R.string.naver_client_id))
                Log.d("test", "ReFreshToken : " + NaverIdLoginSDK.getRefreshToken())
                Log.d("test", "Expires : " + NaverIdLoginSDK.getExpiresAt().toString())
                Log.d("test", "TokenType : " + NaverIdLoginSDK.getTokenType())
                Log.d("test", "State : " + NaverIdLoginSDK.getState().toString())
            }

            override fun onFailure(httpStatus: Int, message: String) {
                val errorCode = NaverIdLoginSDK.getLastErrorCode().code
                val errorDescription = NaverIdLoginSDK.getLastErrorDescription()
                Log.e("test", "$errorCode $errorDescription")
            }

            override fun onError(errorCode: Int, message: String) {
                onFailure(errorCode, message)
            }
        })

    }
}

# https://developers.kakao.com/ 접속

# 로그인 후 상단 ‘내 어플리케이션’에서 어플리케이션 추가

 

# 클릭해서 진입 후 플랫폼을 설정한다

 

안드로이드 플랫폼 등록 클릭

 

키 해시 등록하기

build.gradlekts(Module) 파일에 모듈을 추가

maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") } 작성 후 싱크해준다

 

필요한 모듈을 등록해준다 

implementation("com.kakao.sdk:v2-all:2.20.0") // 전체 모듈 설치, 2.11.0 버전부터 지원

 

GlobalApplication 파일을 만들어준다

class GlobalApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // 다른 초기화 코드들

        // Kakao SDK 초기화
        KakaoSdk.init(this, "{NATIVE_APP_KEY}")
    }
}

 

앱 키는 해당 부분에서 확인할 수 있다

 

manifest 파일에 다음을 추가한다

android:name=".GlobalApplication"

 

메인화면에 다음을 추가하고 실행하면 해시키가 나온다

var keyHash = Utility.getKeyHash(this)
Log.i("kjwTest", "keyHash: $keyHash")

 

# 카카오 로그인 활성화

 

코드 작성

binding.btnKakaoLogin.setOnClickListener {
            kakaoLogin_Step01()
        }
fun kakaoLogin_Step01() {
        if (UserApiClient.instance.isKakaoTalkLoginAvailable(this)) {
            // 카카오톡 로그인
            UserApiClient.instance.loginWithKakaoTalk(this) { token, error ->
                // 로그인 실패 부분
                if (error != null) {
                    Log.e("kj308", "로그인 실패", error)
                    // 사용자가 취소
                    if (error is ClientError && error.reason == ClientErrorCause.Cancelled ) {
                        return@loginWithKakaoTalk
                    }
                    // 다른 오류
                    else {
                        UserApiClient.instance.loginWithKakaoAccount(this, callback = mCallback) // 카카오 이메일 로그인
                    }
                }
                // 로그인 성공 부분
                else if (token != null) {
                    Log.e("kj308", "로그인 성공 ${token.accessToken}")
                    UserApiClient.instance.me {
                        user, error ->
                        if (error != null) {
                            Log.e("kj308", "사용자 정보 요청 실패 $error")
                        } else if (user != null) {
                            Log.e("kj308", "사용자 정보 요청 성공 : $user")
                        }
                    }
                }
            }
        } else {
            UserApiClient.instance.loginWithKakaoAccount(this, callback = mCallback) // 카카오 이메일 로그인
        }
    }

    private val mCallback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
        if (error != null) {
            Log.e("kj308", "로그인 실패 $error")
        } else if (token != null) {
            Log.e("kj308", "로그인 성공 ${token.accessToken}")
        }
    }

 

인터넷 설정

<uses-permission android:name="android.permission.INTERNET" />

 

→ 결과

# 사진 넣기

1. 어댑터 클래스에서 뷰 홀더를 bind 해줄 때 어댑터 position을 이용한다

override fun onBindViewHolder(holder: MovieViewHolder, position: Int) {
        when (position) {
            0 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie01)
            }
            1 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie02)
            }
            2 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie03)
            }
            3 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie04)
            }
            4 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie05)
            }
            5 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie06)
            }
        }
        if (position > 5) {
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        ...

 

- 더 효율적으로 작성하려면?

val imageResources = arrayOf(
        R.drawable.movie01,
        R.drawable.movie02,
        R.drawable.movie03,
        R.drawable.movie04,
        R.drawable.movie05,
        R.drawable.movie06
    )
if (position < imageResources.size) {
            holder.itemBinding.ivPhoto.setImageResource(imageResources[position])
        } else {
            // Set a default image for positions outside the range of the array
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }

 

2. update activity에 사진 넣기

- 위와 같이 이미지 배열 사용

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(updateBinding.root)

        val dto = intent.getSerializableExtra("dto") as MovieDto
		...

        if (dto.id in 1..imageResources.size) {
            updateBinding.ivUpdatePhoto.setImageResource(imageResources[dto.id - 1])
        } else {
            updateBinding.ivUpdatePhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        ...

 

# 검색 기능 만들기

1. 검색 레이아웃과 메뉴칸을 추가한다

- 레이아웃

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/textView5"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="영화 제목 검색하기"
        android:textSize="25sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/etSearch"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:ems="10"
        android:hint="검색어 입력"
        android:inputType="text"
        android:textSize="20sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.497"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView5" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvSearch"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="20dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/btnSearchCancel"
        app:layout_constraintVertical_bias="0.0" />

    <Button
        android:id="@+id/btnSearchCancel"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="뒤로가기"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/etSearch" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

- 메뉴칸 추가

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menuItem01"
        android:title="영화 추가" />
    <item
        android:id="@+id/menuItem02"
        android:title="개발자 정보" />
    <item
        android:id="@+id/menuItem03"
        android:title="영화 정보 검색" />
    <item
        android:id="@+id/menuItem04"
        android:title="앱 종료"/>
</menu>

 

 

2. 검색 기능을 위한 어댑터를 새로 생성한다

- 기존 어댑터와 유사하지만 다른 점은 onBindHolder에서 item을 바인딩할 때 전체 리스트가 아닌 검색한 리스트를 바인딩한다.

override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
        if (position < imageResources.size) {
            holder.itemBinding.ivPhoto.setImageResource(imageResources[position])
        } else {
            // Set a default image for positions outside the range of the array
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        holder.itemBinding.tvMovie.text = filteredList[position].movie
        holder.itemBinding.tvRate.text = filteredList[position].rate
        holder.itemBinding.tvDirector.text = filteredList[position].director
    }

 

- getFilter 함수를 구현한다

override fun getFilter(): Filter {
        return object : Filter() {
            override fun performFiltering(input: CharSequence?): FilterResults {
                val userInput = input.toString().lowercase() // 사용자가 입력한 값

                filteredList = if (userInput.isEmpty()) {
                    unFilteredList
                } else {
                    val filteringList = ArrayList<MovieDto>()
                    for (movie in unFilteredList) {
                        if (movie.movie.lowercase().contains(userInput) || movie.director.lowercase().contains(userInput)) {
                            filteringList.add(movie)
                        }
                    }
                    filteringList
                }

                val filterResults = FilterResults()
                filterResults.values = filteredList

                return filterResults
            }

            @SuppressLint("NotifyDataSetChanged")
            override fun publishResults(p0: CharSequence?, results: FilterResults?) {
                filteredList = results?.values as List<MovieDto>
                notifyDataSetChanged() // 필터링 된 결과가 갱신 되었음을 알려준다.
            }
        }
    }

 

- 전체 코드는 다음과 같다

더보기
package ddwu.com.mobile.finalreport

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Filter
import android.widget.Filterable
import androidx.recyclerview.widget.RecyclerView
import ddwu.com.mobile.finalreport.data.MovieDto
import ddwu.com.mobile.finalreport.databinding.ListItemBinding

class SearchAdapter(val movies: ArrayList<MovieDto>)
    : RecyclerView.Adapter<SearchAdapter.SearchViewHolder>(), Filterable {
    val imageResources = arrayOf(
        R.drawable.movie01,
        R.drawable.movie02,
        R.drawable.movie03,
        R.drawable.movie04,
        R.drawable.movie05,
        R.drawable.movie06
    )
    private var filteredList: List<MovieDto> = movies
    private val unFilteredList: List<MovieDto> = movies

    override fun getItemCount(): Int = filteredList.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder {
        val itemBinding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return SearchViewHolder(itemBinding)
    }

     override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
        if (position < imageResources.size) {
            holder.itemBinding.ivPhoto.setImageResource(imageResources[position])
        } else {
            // Set a default image for positions outside the range of the array
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }
        holder.itemBinding.tvMovie.text = filteredList[position].movie
        holder.itemBinding.tvRate.text = filteredList[position].rate
        holder.itemBinding.tvDirector.text = filteredList[position].director
    }

    inner class SearchViewHolder(val itemBinding: ListItemBinding)
        : RecyclerView.ViewHolder(itemBinding.root) {

    }

    override fun getFilter(): Filter {
        return object : Filter() {
            override fun performFiltering(input: CharSequence?): FilterResults {
                val userInput = input.toString().lowercase() // 사용자가 입력한 값

                filteredList = if (userInput.isEmpty()) {
                    unFilteredList
                } else {
                    val filteringList = ArrayList<MovieDto>()
                    for (movie in unFilteredList) {
                        if (movie.movie.lowercase().contains(userInput) || movie.director.lowercase().contains(userInput)) {
                            filteringList.add(movie)
                        }
                    }
                    filteringList
                }

                val filterResults = FilterResults()
                filterResults.values = filteredList

                return filterResults
            }

            @SuppressLint("NotifyDataSetChanged")
            override fun publishResults(p0: CharSequence?, results: FilterResults?) {
                filteredList = results?.values as List<MovieDto>
                notifyDataSetChanged() // 필터링 된 결과가 갱신 되었음을 알려준다.
            }
        }
    }
}

 

3. SearchActivity 구현하기

- SearchAdapter를 활용하여 액티비티를 완성한다.

package ddwu.com.mobile.finalreport

import android.annotation.SuppressLint
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import ddwu.com.mobile.finalreport.data.MovieDao
import ddwu.com.mobile.finalreport.data.MovieDto
import ddwu.com.mobile.finalreport.databinding.ActivitySearchBinding

class SearchActivity : AppCompatActivity() {
    val searchBinding by lazy {
        ActivitySearchBinding.inflate(layoutInflater)
    }
    lateinit var movies: ArrayList<MovieDto> // 데이터
    val movieDao by lazy {
        MovieDao(this)
    }

    @SuppressLint("Range")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(searchBinding.root)

        // Fetch all movies from the database
        movies = movieDao.getAllMovies()

        // Setup RecyclerView
        val adapter = SearchAdapter(movies)
        searchBinding.rvSearch.layoutManager = LinearLayoutManager(this)
        searchBinding.rvSearch.adapter = adapter

        // Add TextWatcher to filter movies based on user input
        searchBinding.etSearch.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                adapter.filter.filter(s)
            }

            override fun afterTextChanged(s: Editable?) {}
        })

        // Cancel button click listener
        searchBinding.btnSearchCancel.setOnClickListener {
            finish()
        }
    }
}

 

4. 메인에서 메뉴 클릭 시 액티비티 전환 구현

R.id.menuItem03 -> {
                val intent = Intent(this@MainActivity, SearchActivity::class.java)
                startActivity(intent)
            }

 

5. 오류

- 검색 시 이미지가 filteredList의 position에 걸리는 것이 아닌 dto의 id에 영향을 받아야 제대로 이미지가 나온다

when (filteredList[position].id) {
            1 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie01)
            }
            2 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie02)
            }
            3 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie03)
            }
            4 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie04)
            }
            5 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie05)
            }
            6 -> {
                holder.itemBinding.ivPhoto.setImageResource(R.drawable.movie06)
            }
        }
        if (filteredList[position].id > 6) {
            holder.itemBinding.ivPhoto.setImageResource(R.drawable.ic_launcher_foreground)
        }

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# 구현하고자 하는 것

- recycle view 클릭 시 수정 창이 나온다

- 수정한 결과가 반영된다

 

# 어댑터 클릭 인터페이스 작성

// 클릭 인터페이스
    interface OnItemClickListener {
        fun onItemClick(view: View, position: Int)
    }

    var listener: OnItemClickListener? = null

    fun setOnItemClickListener(listener: OnItemClickListener) {
        this.listener = listener
    }

 

 

# Update Activity 작성

- id를 사용

class UpdateActivity : AppCompatActivity() {
    val updateBinding by lazy {
        ActivityUpdateBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(updateBinding.root)

        val dto = intent.getSerializableExtra("dto") as MovieDto

        updateBinding.etUpdateMovie.setText(dto.movie)
        updateBinding.etUpdateRate.setText(dto.rate)
        updateBinding.etUpdateDirector.setText(dto.director)

        updateBinding.btnUpdate.setOnClickListener {
            dto.movie = updateBinding.etUpdateMovie.text.toString()
            dto.rate = updateBinding.etUpdateRate.text.toString()
            dto.director = updateBinding.etUpdateDirector.text.toString()

            if (updateMovie(dto) > 0) {
                setResult(RESULT_OK)
            }
            else {
                setResult(RESULT_CANCELED)
            }

            finish()
        }

        updateBinding.btnUpdateCancel.setOnClickListener {
            setResult(RESULT_CANCELED)
            finish()
        }
    }

    fun updateMovie(dto: MovieDto): Int {
        val helper = MovieDBHelper(this)
        val db = helper.writableDatabase

        val updateValue = ContentValues()
        updateValue.put(MovieDBHelper.COL_MOVIE, dto.movie)
        updateValue.put(MovieDBHelper.COL_RATE, dto.rate)
        updateValue.put(MovieDBHelper.COL_DIRECTOR, dto.director)

        val whereClause = "${BaseColumns._ID}=?"
        val whereArgs = arrayOf(dto.id.toString())

        Log.d("UpdateActivity", "Updating movie with ID: ${dto.id}, values: $updateValue")

        val resultCount = db.update(MovieDBHelper.TABLE_NAME,
            updateValue, whereClause, whereArgs)

        Log.d("UpdateActivity", "Rows updated: $resultCount")

        helper.close()
        return resultCount
    }
}

 

# Main

- 어댑터 연결

adapter.setOnItemClickListener(object: MovieAdapter.OnItemClickListener {
            override fun onItemClick(view: View, position: Int) {
                val intent = Intent(this@MainActivity, UpdateActivity::class.java)
                intent.putExtra("dto", movies.get(position))   // 클릭한 RecyclerView 항목 위치의 dto 전달
                startActivityForResult(intent, REQ_UPDATE)      // 수정결과를 받아오도록 Activity 호출
            }
        })

 

- intent 결과 반환

REQ_UPDATE -> {
                if (resultCode == RESULT_OK) {
                    movies.clear()                       // 기존 항목 제거
                    movies.addAll(movieDao.getAllMovies())         // 항목 추가
                    binding.recyclerView.adapter?.notifyDataSetChanged()      // RecyclerView 갱신
                } else {
                    // 취소하면 아무것도 안함
                }
            }

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

 

 

# 구현하고자 하는 것

- 메뉴 클릭 시 나오는 정보 추가를 클릭하면 데이터를 추가하는 창 띄우기

- 데이터 추가 창에서 정보 입력 후 확인 누르면 데이터 반영

 

# 정보 추가 레이아웃 만들기

- textView와 plainText 사용

- 버튼은 linearLayout 사용하여 병렬 정리

<LinearLayout
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:orientation="horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/etAddDirector">

        <Button
            android:id="@+id/btnAdd"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="추가" />

        <Button
            android:id="@+id/btnCancel"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="취소" />
    </LinearLayout>

 

다른 정보 추가도 넣음

 

# 메뉴 아이템 클릭했을 때 화면이 바뀌게 구성

- 아이템 id 선택지 구현

override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            R.id.menuItem01 -> {
               
            }
            R.id.menuItem02 -> {

            }
            R.id.menuItem03 -> {

            }
        }
        return true
    }

 

- 정보 추가창 id=menuItem01, intent를 사용하여 activity 바꾼다

R.id.menuItem01 -> {
       val intent = Intent(this@MainActivity, AddActivity::class.java)
       startActivityForResult(intent, REQ_ADD)
}

 

- AddActivity를 만든다

class AddActivity : AppCompatActivity()  {
    val addBinding by lazy {
        ActivityAddBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(addBinding.root)
    }
}

 

- AndroidManifest.xml 에 AddActivity를 추가한다

<activity android:name=".AddActivity" />

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

# SQLDatabase 설정

class MovieDBHelper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, 1) {
    val TAG = "MovieDBHelper"
    
    companion object {
        const val DB_NAME = "movie_db"
        const val TABLE_NAME = "movie_table"
        const val COL_MOVIE = "movie"
        const val COL_RATE = "rate"
        const val COL_DIRECTOR = "director"
        const val COL_DATE = "date"
    }

    override fun onCreate(db: SQLiteDatabase?) {
        val CREATE_TABLE =
            "CREATE TABLE $TABLE_NAME ( ${BaseColumns._ID} INTEGER PRIMARY KEY AUTOINCREMENT, " +
                    "$COL_MOVIE TEXT, $COL_RATE TEXT, $COL_DIRECTOR TEXT, $COL_DATE TEXT )"
        Log.d(TAG, CREATE_TABLE)    // SQL 문장이 이상 없는지 Log에서 확인 필요
        db?.execSQL(CREATE_TABLE)
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
        val DROP_TABLE ="DROP TABLE IF EXISTS $TABLE_NAME"
        db?.execSQL(DROP_TABLE)
        onCreate(db)
    }
}

 

- 샘플 데이터 추가

// 샘플 데이터 넣음
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '은혼 더 파이널', '4.5', '미야와키 치즈루', '2021-07-22')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '어벤저스 엔드게임', '4.3', '앤서니 루소', '2019-04-24')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '엘리멘탈', '4.1', '피터 손', '2023-06-14')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '파묘', '4.8', '장재현', '2024-02-22')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '범죄도시3', '3.8', '이상용', '2023-05-31')")
        db?.execSQL("INSERT INTO $TABLE_NAME VALUES (0, '인사이드 아웃2', '4.1', '켈시 만', '2024-06-12')")

 

 

# AddActivity 설정

- 확인 버튼 / 취소 버튼 구현

addBinding.btnAdd.setOnClickListener {
            val addETMovie = addBinding.etAddMovie.text.toString()
            val addETRate = addBinding.etAddRate.text.toString()
            val addETDirector = addBinding.etAddDirector.text.toString()
            val addETDate = addBinding.etAddRate.text.toString()

            if (addMovie(MovieDto( 0, addETMovie, addETRate, addETDirector, addETDate)) > 0) {
                setResult(RESULT_OK)
            } else {
                setResult(RESULT_CANCELED)
            }
            finish()
        }

        addBinding.btnCancel.setOnClickListener {
            setResult(RESULT_CANCELED)
            finish()
        }

 

- addMovie 함수 구현

fun addMovie(newDto: MovieDto) : Long {
        val helper = MovieDBHelper(this)
        val db = helper.writableDatabase

        val newValue = ContentValues()
        newValue.put(MovieDBHelper.COL_MOVIE, newDto.movie)
        newValue.put(MovieDBHelper.COL_RATE, newDto.rate)
        newValue.put(MovieDBHelper.COL_DIRECTOR, newDto.director)
        newValue.put(MovieDBHelper.COL_DATE, newDto.date)

        val newCount = db.insert(MovieDBHelper.TABLE_NAME, null, newValue)

        helper.close()

        return newCount
    }

 

# add 데이터를 main에서 받기

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        when (requestCode) {
            REQ_ADD -> {
                if (resultCode == RESULT_OK) {
                    movies.clear()                       // 기존 항목 제거
                    movies.addAll(movieDao.getAllMovies())         // 항목 추가
                    binding.recyclerView.adapter?.notifyDataSetChanged()      // RecyclerView 갱신
                } else {
                    // 취소하면 아무것도 안함
                }
            }
        }
    }

 

 

# 몰랐던 부분

- 데이터베이스에 샘플 데이터를 나중에 넣었을 시 기존 앱 삭제 후 처음부터 run해줘야 반영이 된다

 

 

동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.

 

+ Recent posts